From cfcb129bbe9180730daf2522404161e22286527f Mon Sep 17 00:00:00 2001 From: Sambhav Aggarwal <4591834+sambhav-aggarwal@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:48:10 +0530 Subject: [PATCH] Email verification: code alongside the link, app chooses what to show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verification email now carries both proofs — the signed link and a numeric code — so the session that is WAITING can complete verification in place: cross-device signups (register on desktop, read mail on phone), limited-input devices, and environments where security scanners consume single-use links (Outlook SafeLinks et al). Which proofs to surface is the developer's choice, exercised in code they already own — the ejected email-verify template (new $otp variable in the RFC 002 §5.5 contract) and their UI — not a config toggle. The old email_verification_method key does not return; the package always provides both capabilities: - AuthService::sendEmailVerification() issues the code with the link (repurposing the previously unused polymorphic otp table) - POST {prefix}/email/verify-otp (API, throttle 5/min) and a Verify form on the Blade kit's verification page complete it - Codes: hashed at rest, otp_expiry_time lifetime, invalidated after 5 wrong attempts (OTP::MAX_ATTEMPTS invariant) and by the link succeeding — either proof kills the other via markEmailAsVerified() - Reset / email-change mails carry no code (link-only purposes) - API resend endpoint deduplicated through the service (also fixes it ignoring the blade-vs-headless URL choice) - otp table gains an attempts column (UPGRADING note for existing installs); SPA guide gains the waiting-screen section (code entry or poll/re-check) 9 new tests incl. attempt-cap exhaustion, expiry, resend-resets- attempts, mutual invalidation, and no-code-on-reset. Suite 1014 green; Pint and PHPStan clean. --- CHANGELOG.md | 3 + UPGRADING.md | 12 ++ .../2025_01_01_000002_create_otp_table.php | 1 + docs/api-reference.md | 25 +++ docs/rfcs/002-starter-kits.md | 2 +- docs/spa-authentication.md | 32 +++ resources/views/emails/email-verify.blade.php | 13 ++ routes/neev.php | 5 + .../Auth/UserAuthApiController.php | 41 ++-- .../Controllers/Auth/UserAuthController.php | 21 ++ src/Mail/VerifyUserEmail.php | 1 + src/Models/OTP.php | 8 + src/Services/AuthService.php | 63 +++++- src/Traits/NeevAuthenticatable.php | 8 + stubs/blade/views/auth/verify-email.blade.php | 16 ++ .../Feature/Auth/EmailVerificationOtpTest.php | 183 ++++++++++++++++++ 16 files changed, 421 insertions(+), 13 deletions(-) create mode 100644 tests/Feature/Auth/EmailVerificationOtpTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 658ddad..bc339e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Email verification code alongside the link** — the verification email now carries both a signed link and a numeric code (`$otp` added to the app-owned `email-verify` template's variable contract). The code lets the *waiting* session complete verification in place — cross-device signups, TVs, and environments where security scanners consume single-use links. New endpoints: `POST {prefix}/email/verify-otp` (API) and the Verify form on the Blade kit's verification page. Codes are stored hashed, expire after `otp_expiry_time`, die after 5 wrong attempts, and either proof invalidates the other. Which proofs to show is the app's choice — via its owned email template and UI, not a config toggle. The API resend endpoint now delegates to `AuthService::sendEmailVerification()` (deduplicated) + ### Fixed - **`PasswordHistory` rule blocked first-time registration** — with the default `neev.password` rules, `PasswordHistory::notReused()` failed with "Unable to verify password history." whenever no user could be resolved, which is exactly the first-time-signup case (no authenticated user; the submitted email belongs to nobody yet). The rule now passes vacuously when there is no user — there is no history to reuse. Reported by a consuming-app developer diff --git a/UPGRADING.md b/UPGRADING.md index ef4b88c..090eb04 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -11,6 +11,18 @@ changes see [CHANGELOG.md](./CHANGELOG.md). --- +## 0.5.0 → Unreleased + +**Email verification code (additive; one schema note).** +Verification emails now carry a numeric code alongside the signed link, +verifiable via `POST {prefix}/email/verify-otp` or the Blade kit's +verification page. The `otp` table gains an `attempts` column — the +package edits its migration in place, so existing installs add it +themselves: `$table->unsignedTinyInteger('attempts')->default(0);` +Apps that ejected the `email-verify` template before this release +won't show the code until they add the `$otp` block (see the stub +template) — everything else works regardless. + ## 0.4.5 → 0.5.0 **The package is now headless by default (RFC 002, action required for diff --git a/database/migrations/2025_01_01_000002_create_otp_table.php b/database/migrations/2025_01_01_000002_create_otp_table.php index 420f215..7c59683 100644 --- a/database/migrations/2025_01_01_000002_create_otp_table.php +++ b/database/migrations/2025_01_01_000002_create_otp_table.php @@ -15,6 +15,7 @@ public function up(): void $table->unsignedBigInteger('owner_id'); $table->string('owner_type'); $table->text('otp'); + $table->unsignedTinyInteger('attempts')->default(0); $table->timestamp('expires_at'); $table->timestamps(); $table->unique(['owner_id', 'owner_type']); diff --git a/docs/api-reference.md b/docs/api-reference.md index 1b9f9f1..79ce300 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -307,6 +307,31 @@ Authorization: Bearer {token} --- +### Verify Email via Code + +The verification email also carries a numeric code, so the session that is waiting (cross-device signup, TVs, environments where security scanners consume links) can complete verification in place. Codes expire after `otp_expiry_time` minutes and are invalidated after 5 wrong attempts or once the link is used. Throttled to 5 requests/minute. + +```http +POST /neev/email/verify-otp +``` + +**Headers:** +```http +Authorization: Bearer {token} +``` + +**Request Body:** + +```json +{ + "otp": "123456" +} +``` + +**Response:** `200` with `{"message": "Email verification done."}` — or `400` (`Code verification failed.` / `Email already verified.`). + +--- + ## Email Change ### Request Email Change diff --git a/docs/rfcs/002-starter-kits.md b/docs/rfcs/002-starter-kits.md index 694f34a..5078554 100644 --- a/docs/rfcs/002-starter-kits.md +++ b/docs/rfcs/002-starter-kits.md @@ -152,7 +152,7 @@ place them. The contract per template: | Template (`neev::emails.*`) | Mailable | Variables | |---|---|---| -| `email-verify` | `VerifyUserEmail` | `$url` (signed verification link), `$username`, `$purpose`, `$expiry` (minutes) | +| `email-verify` | `VerifyUserEmail` | `$url` (signed verification link), `$username`, `$purpose`, `$expiry` (minutes), `$otp` (verification code; null for reset/email-change purposes — the template chooses whether to show link, code, or both) | | `email-otp` | `EmailOTP` | `$username`, `$otp`, `$expiry` | | `login-link` | `LoginUsingLink` | `$url` (signed magic link), `$expiry` | | `team-invitation` | `TeamInvitation` | `$team`, `$username`, `$url` (accept link), `$expiry`, `$userExist` | diff --git a/docs/spa-authentication.md b/docs/spa-authentication.md index 1e29b20..7719999 100644 --- a/docs/spa-authentication.md +++ b/docs/spa-authentication.md @@ -242,6 +242,38 @@ Content-Type: application/json } ``` +#### The "verify your email" waiting screen + +`email_verified: false` means your app should show a verification +screen. The verification email carries **both a link and a numeric +code**, and there are two patterns for completing it without leaving +the user stranded: + +- **Code entry (recommended for cross-device):** the user reads the + code from their mail app (possibly on another device) and types it + into your waiting screen: + + ```http + POST /neev/email/verify-otp + { "otp": "123456" } + ``` + + The verification completes *in the waiting session* — no polling, no + stale tab. Codes expire (`otp_expiry_time`) and die after 5 wrong + attempts; `POST /neev/email/send` re-issues both proofs. + +- **Link + re-check:** if the user clicks the emailed link instead + (which may open in a different browser), your waiting screen won't + know. Poll `GET /neev/users` every few seconds — or show an + "I've verified" button that re-checks — and proceed when + `email_verified` flips to `true`. Either proof invalidates the + other, so supporting both is safe. + +Your app decides which proofs to surface: the email template is +app-owned (edit `resources/views/vendor/neev/emails/email-verify.blade.php` +to show the link, the code, or both), and your UI decides whether to +render a code input. + ### 4.4 Magic link Request the link: diff --git a/resources/views/emails/email-verify.blade.php b/resources/views/emails/email-verify.blade.php index 46c9271..2ac271b 100644 --- a/resources/views/emails/email-verify.blade.php +++ b/resources/views/emails/email-verify.blade.php @@ -21,6 +21,19 @@
+ @if (!empty($otp)) +Or enter this code on the device you signed up on:
+ +{{ $otp }}
+ @endif +This link is valid for {{ $expiry ?? '15' }} minutes after you receive this email. If you didn’t request this, you can safely ignore this email.
Regards,
{{ config('app.name') }}