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}}
+
+
+