Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions database/migrations/2025_01_01_000002_create_otp_table.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
25 changes: 25 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/rfcs/002-starter-kits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
32 changes: 32 additions & 0 deletions docs/spa-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions resources/views/emails/email-verify.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@
</a>
</p>

@if (!empty($otp))
<p>Or enter this code on the device you signed up on:</p>

<p style="
font-size: 24px;
font-weight: bold;
letter-spacing: 6px;
background-color: #f4f4f4;
display: inline-block;
padding: 10px 20px;
border-radius: 5px;">{{ $otp }}</p>
@endif

<p>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.</p>

<p>Regards,<br>{{ config('app.name') }}</p>
Expand Down
5 changes: 5 additions & 0 deletions routes/neev.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down Expand Up @@ -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']);
Expand Down
41 changes: 30 additions & 11 deletions src/Http/Controllers/Auth/UserAuthApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
21 changes: 21 additions & 0 deletions src/Http/Controllers/Auth/UserAuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/Mail/VerifyUserEmail.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public function __construct(
public $username,
public $purpose = '',
public $expiry = 15,
public $otp = null,
) {
}

Expand Down
8 changes: 8 additions & 0 deletions src/Models/OTP.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

Expand Down
63 changes: 62 additions & 1 deletion src/Services/AuthService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

/**
Expand Down
8 changes: 8 additions & 0 deletions src/Traits/NeevAuthenticatable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)));
Expand Down
16 changes: 16 additions & 0 deletions stubs/blade/views/auth/verify-email.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@
<div class="py-2">
{{$email}}
</div>

<form method="POST" action="{{ route('email.verify.otp') }}" class="mt-4">
@csrf

<div class="flex gap-2 items-end">
<div class="grow">
<x-neev-component::label for="otp" value="{{ __('Or enter the code from the email') }}" />
<x-neev-component::input id="otp" class="block mt-1 w-full" type="text" name="otp" inputmode="numeric" autocomplete="one-time-code" required />
</div>

<x-neev-component::button type="submit">
{{ __('Verify') }}
</x-neev-component::button>
</div>
</form>

<div class="mt-4 flex items-center justify-between">
<form method="GET" action="{{ route('email.verification.send') }}">
@csrf
Expand Down
Loading