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') }}

diff --git a/routes/neev.php b/routes/neev.php index db9191e..9186c13 100644 --- a/routes/neev.php +++ b/routes/neev.php @@ -100,6 +100,10 @@ Route::get('/email/send', [UserAuthController::class, 'emailVerifySend']) ->name('email.verification.send'); + Route::post('/email/verify-otp', [UserAuthController::class, 'emailVerifyOtpStore']) + ->middleware('throttle:5,1') + ->name('email.verify.otp'); + Route::get('/email/change', [UserAuthController::class, 'emailChangeCreate']) ->name('email.change'); Route::put('/email/change', [UserAuthController::class, 'emailChangeStore']) @@ -233,6 +237,7 @@ Route::post('/email/send', [UserAuthApiController::class, 'sendMailVerificationLink']); Route::get('/email/verify', [UserAuthApiController::class, 'emailVerify'])->name('mail.verify'); + Route::post('/email/verify-otp', [UserAuthApiController::class, 'verifyEmailOtp'])->middleware('throttle:5,1'); Route::post('/email/change', [UserAuthApiController::class, 'requestEmailChange']); Route::get('/mfa', [UserApiController::class, 'getMFAMethods']); Route::post('/mfa/add', [UserApiController::class, 'addMultiFactorAuthentication']); diff --git a/src/Http/Controllers/Auth/UserAuthApiController.php b/src/Http/Controllers/Auth/UserAuthApiController.php index fd7508f..a49e169 100644 --- a/src/Http/Controllers/Auth/UserAuthApiController.php +++ b/src/Http/Controllers/Auth/UserAuthApiController.php @@ -199,23 +199,42 @@ public function sendMailVerificationLink(Request $request) ], 400); } - $expiryMinutes = config('neev.url_expiry_time', 60); - $signedUrl = URL::temporarySignedRoute( - 'mail.verify', - now()->addMinutes($expiryMinutes), - ['id' => $user->id, 'hash' => hash('sha256', $user->email)] - ); - - $query = parse_url($signedUrl, PHP_URL_QUERY); - $frontendUrl = config('app.url'); - $url = "{$frontendUrl}/verify-email?{$query}"; - Mail::to($user->email)->send(new VerifyUserEmail($url, $user->name, 'Verify Email', $expiryMinutes)); + app(AuthService::class)->sendEmailVerification($user); return response()->json([ 'message' => 'Verification link has been sent.', ]); } + public function verifyEmailOtp(Request $request) + { + $request->validate([ + 'otp' => ['required'], + ]); + + $user = User::model()->find($request->user()?->id); + if (!$user) { + return response()->json([ + 'message' => 'User not found.', + ], 404); + } + if ($user->hasVerifiedEmail()) { + return response()->json([ + 'message' => 'Email already verified.', + ], 400); + } + + if (!app(AuthService::class)->verifyEmailOtp($user, (string) $request->otp)) { + return response()->json([ + 'message' => 'Code verification failed.', + ], 400); + } + + return response()->json([ + 'message' => 'Email verification done.', + ]); + } + public function logout(Request $request) { $accessToken = AccessToken::find($request->attributes->get('token_id')); diff --git a/src/Http/Controllers/Auth/UserAuthController.php b/src/Http/Controllers/Auth/UserAuthController.php index 4b5b775..ef79398 100644 --- a/src/Http/Controllers/Auth/UserAuthController.php +++ b/src/Http/Controllers/Auth/UserAuthController.php @@ -347,6 +347,27 @@ public function emailVerifyCreate(Request $request) /** * Show the verify email send. */ + public function emailVerifyOtpStore(Request $request) + { + $request->validate([ + 'otp' => ['required'], + ]); + + $user = User::model()->find($request->user()?->id); + if (!$user) { + return back()->withErrors(['message' => 'User not found.']); + } + if ($user->hasVerifiedEmail()) { + return redirect(config('neev.home')); + } + + if (!$this->auth->verifyEmailOtp($user, (string) $request->otp)) { + return back()->withErrors(['otp' => 'Code verification failed.']); + } + + return redirect(config('neev.home'))->with('status', __('Email verified.')); + } + public function emailVerifySend(Request $request) { $user = User::model()->find($request->user()?->id); diff --git a/src/Mail/VerifyUserEmail.php b/src/Mail/VerifyUserEmail.php index a0de4ae..156ec62 100644 --- a/src/Mail/VerifyUserEmail.php +++ b/src/Mail/VerifyUserEmail.php @@ -18,6 +18,7 @@ public function __construct( public $username, public $purpose = '', public $expiry = 15, + public $otp = null, ) { } diff --git a/src/Models/OTP.php b/src/Models/OTP.php index a398c11..ce58da3 100644 --- a/src/Models/OTP.php +++ b/src/Models/OTP.php @@ -10,17 +10,25 @@ * @property int $owner_id * @property string $owner_type * @property string $otp + * @property int $attempts * @property \Carbon\Carbon|null $expires_at * @property \Carbon\Carbon|null $created_at * @property \Carbon\Carbon|null $updated_at */ class OTP extends Model { + /** + * Failed confirmations before the code is invalidated. A hard + * invariant, not config: a 6-digit space demands an attempt cap. + */ + public const MAX_ATTEMPTS = 5; + protected $table = 'otp'; protected $fillable = [ 'owner_id', 'owner_type', 'otp', + 'attempts', 'expires_at', ]; diff --git a/src/Services/AuthService.php b/src/Services/AuthService.php index 95d3fda..f91699d 100644 --- a/src/Services/AuthService.php +++ b/src/Services/AuthService.php @@ -12,8 +12,10 @@ use Illuminate\Validation\ValidationException; use Ssntpl\Neev\Events\LoggedIn; use Ssntpl\Neev\Events\PasswordChanged; +use Illuminate\Support\Facades\Hash; use Ssntpl\Neev\Mail\VerifyUserEmail; use Ssntpl\Neev\Models\LoginAttempt; +use Ssntpl\Neev\Models\OTP; use Ssntpl\Neev\Models\User; class AuthService @@ -124,7 +126,66 @@ public function sendEmailVerification(User $user): void $url = config('app.url') . '/verify-email?' . parse_url($signedUrl, PHP_URL_QUERY); } - Mail::to($user->email)->send(new VerifyUserEmail($url, $user->name, 'Verify Email', $expiryMinutes)); + // Both proofs travel in one email; the app-owned template decides + // which to show. The code lets the user complete verification on + // the device that is waiting (cross-device signup, TVs, SafeLinks- + // mangled links); either proof invalidates the other on success. + $otp = $this->createEmailVerificationOtp($user); + + Mail::to($user->email)->send(new VerifyUserEmail($url, $user->name, 'Verify Email', $expiryMinutes, $otp)); + } + + /** + * Issue (or replace) the user's email-verification code. Stored + * hashed; resending resets the attempt counter. + */ + protected function createEmailVerificationOtp(User $user): string + { + $length = (int) config('neev.otp_length', 6); + $otp = (string) random_int(10 ** ($length - 1), (10 ** $length) - 1); + + OTP::updateOrCreate( + ['owner_id' => $user->id, 'owner_type' => $user->getMorphClass()], + [ + 'otp' => $otp, + 'attempts' => 0, + 'expires_at' => now()->addMinutes(config('neev.otp_expiry_time', 15)), + ], + ); + + return $otp; + } + + /** + * Verify an email-verification code for the waiting session. + * Wrong codes count toward OTP::MAX_ATTEMPTS, after which the code + * is invalidated and a fresh email must be requested. + */ + public function verifyEmailOtp(User $user, string $otp): bool + { + $record = OTP::query() + ->where('owner_id', $user->id) + ->where('owner_type', $user->getMorphClass()) + ->first(); + + if (!$record || $record->expires_at->isPast() || $record->attempts >= OTP::MAX_ATTEMPTS) { + $record?->delete(); + return false; + } + + if (!Hash::check($otp, $record->otp)) { + $record->increment('attempts'); + if ($record->attempts >= OTP::MAX_ATTEMPTS) { + $record->delete(); + } + return false; + } + + // markEmailAsVerified() deletes the OTP row, so the signed link + // and the code invalidate each other through the same path. + $user->markEmailAsVerified(); + + return true; } /** diff --git a/src/Traits/NeevAuthenticatable.php b/src/Traits/NeevAuthenticatable.php index 6567d96..a1bcc5c 100644 --- a/src/Traits/NeevAuthenticatable.php +++ b/src/Traits/NeevAuthenticatable.php @@ -5,6 +5,7 @@ use Illuminate\Support\Facades\DB; use Ssntpl\Neev\Events\EmailVerified; use Ssntpl\Neev\Models\LoginAttempt; +use Ssntpl\Neev\Models\OTP; use Ssntpl\Neev\Models\Passkey; trait NeevAuthenticatable @@ -35,6 +36,13 @@ public function markEmailAsVerified(): bool $saved = $this->save(); if ($saved && !$wasVerified) { + // Whichever proof completed verification (signed link or + // emailed code), the outstanding code is now dead. + OTP::query() + ->where('owner_id', $this->id) + ->where('owner_type', $this->getMorphClass()) + ->delete(); + // Registration flows call this inside a transaction; only // announce the verification once it is durable. DB::afterCommit(fn () => event(new EmailVerified($this))); diff --git a/stubs/blade/views/auth/verify-email.blade.php b/stubs/blade/views/auth/verify-email.blade.php index f6a7732..3a041ce 100644 --- a/stubs/blade/views/auth/verify-email.blade.php +++ b/stubs/blade/views/auth/verify-email.blade.php @@ -20,6 +20,22 @@
{{$email}}
+ +
+ @csrf + +
+
+ + +
+ + + {{ __('Verify') }} + +
+
+
@csrf diff --git a/tests/Feature/Auth/EmailVerificationOtpTest.php b/tests/Feature/Auth/EmailVerificationOtpTest.php new file mode 100644 index 0000000..b72a390 --- /dev/null +++ b/tests/Feature/Auth/EmailVerificationOtpTest.php @@ -0,0 +1,183 @@ +create(['email_verified_at' => null]); + $token = $user->createLoginToken(1440)->plainTextToken; + + return ['user' => $user, 'token' => $token]; + } + + /** + * Send the verification mail and capture the plaintext OTP from it. + */ + private function sendAndCaptureOtp(User $user): string + { + Mail::fake(); + app(AuthService::class)->sendEmailVerification($user); + + $otp = null; + Mail::assertSent(VerifyUserEmail::class, function (VerifyUserEmail $mail) use (&$otp) { + $otp = $mail->otp; + return true; + }); + + $this->assertNotNull($otp); + + return $otp; + } + + // ----------------------------------------------------------------- + // The email carries both proofs + // ----------------------------------------------------------------- + + public function test_verification_email_contains_link_and_code(): void + { + Mail::fake(); + $user = User::factory()->create(['email_verified_at' => null]); + + app(AuthService::class)->sendEmailVerification($user); + + Mail::assertSent(VerifyUserEmail::class, function (VerifyUserEmail $mail) { + return !empty($mail->url) + && is_string($mail->otp) + && strlen($mail->otp) === (int) config('neev.otp_length', 6); + }); + + $this->assertDatabaseHas('otp', [ + 'owner_id' => $user->id, + 'owner_type' => $user->getMorphClass(), + ]); + } + + public function test_resend_replaces_the_code_and_resets_attempts(): void + { + $user = User::factory()->create(['email_verified_at' => null]); + $first = $this->sendAndCaptureOtp($user); + OTP::query()->update(['attempts' => 3]); + + $second = $this->sendAndCaptureOtp($user); + + $this->assertSame(1, OTP::count()); + $this->assertSame(0, OTP::first()->attempts); + // Verification only accepts the latest code. + $this->assertFalse(app(AuthService::class)->verifyEmailOtp($user->fresh(), $first === $second ? '000000' : $first)); + } + + // ----------------------------------------------------------------- + // API endpoint + // ----------------------------------------------------------------- + + public function test_valid_code_verifies_the_email(): void + { + $data = $this->unverifiedUserWithToken(); + $otp = $this->sendAndCaptureOtp($data['user']); + + $response = $this->withHeader('Authorization', 'Bearer ' . $data['token']) + ->postJson('/neev/email/verify-otp', ['otp' => $otp]); + + $response->assertOk(); + $this->assertTrue($data['user']->fresh()->hasVerifiedEmail()); + $this->assertSame(0, OTP::count()); + } + + public function test_wrong_code_fails_and_counts_an_attempt(): void + { + $data = $this->unverifiedUserWithToken(); + $this->sendAndCaptureOtp($data['user']); + + $response = $this->withHeader('Authorization', 'Bearer ' . $data['token']) + ->postJson('/neev/email/verify-otp', ['otp' => '000000']); + + $response->assertStatus(400); + $this->assertFalse($data['user']->fresh()->hasVerifiedEmail()); + $this->assertSame(1, OTP::first()->attempts); + } + + public function test_code_is_invalidated_after_max_attempts(): void + { + $data = $this->unverifiedUserWithToken(); + $otp = $this->sendAndCaptureOtp($data['user']); + OTP::query()->update(['attempts' => OTP::MAX_ATTEMPTS - 1]); + + // One more wrong attempt exhausts the cap and deletes the code. + $this->withHeader('Authorization', 'Bearer ' . $data['token']) + ->postJson('/neev/email/verify-otp', ['otp' => '000000']) + ->assertStatus(400); + $this->assertSame(0, OTP::count()); + + // Even the correct code is dead now. + $this->withHeader('Authorization', 'Bearer ' . $data['token']) + ->postJson('/neev/email/verify-otp', ['otp' => $otp]) + ->assertStatus(400); + $this->assertFalse($data['user']->fresh()->hasVerifiedEmail()); + } + + public function test_expired_code_fails(): void + { + $data = $this->unverifiedUserWithToken(); + $otp = $this->sendAndCaptureOtp($data['user']); + OTP::query()->update(['expires_at' => now()->subMinute()]); + + $this->withHeader('Authorization', 'Bearer ' . $data['token']) + ->postJson('/neev/email/verify-otp', ['otp' => $otp]) + ->assertStatus(400); + + $this->assertSame(0, OTP::count()); + } + + public function test_already_verified_email_returns_400(): void + { + $user = User::factory()->create(['email_verified_at' => now()]); + $token = $user->createLoginToken(1440)->plainTextToken; + + $this->withHeader('Authorization', 'Bearer ' . $token) + ->postJson('/neev/email/verify-otp', ['otp' => '123456']) + ->assertStatus(400); + } + + // ----------------------------------------------------------------- + // Mutual invalidation: the signed link kills the code + // ----------------------------------------------------------------- + + public function test_link_verification_invalidates_the_outstanding_code(): void + { + $data = $this->unverifiedUserWithToken(); + $this->sendAndCaptureOtp($data['user']); + $this->assertSame(1, OTP::count()); + + $data['user']->fresh()->markEmailAsVerified(); + + $this->assertSame(0, OTP::count()); + } + + // ----------------------------------------------------------------- + // Reset / email-change mails carry no code + // ----------------------------------------------------------------- + + public function test_password_reset_mail_carries_no_otp(): void + { + Mail::fake(); + $user = User::factory()->create(['email_verified_at' => now()]); + + $this->postJson('/neev/forgotPassword', ['email' => $user->email])->assertOk(); + + Mail::assertSent(VerifyUserEmail::class, function (VerifyUserEmail $mail) { + return $mail->otp === null; + }); + } +}