diff --git a/CHANGELOG.md b/CHANGELOG.md index 658ddad..b26c550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **BREAKING: Magic-link authentication rebuilt as stateful, single-use tokens** — replaces the stateless signed-URL flow. Opaque, high-entropy tokens are stored **hashed** (SHA-256) in the new `magic_link_tokens` table; only the plain token ever leaves the app (in the emailed URL). + - **Single-use by deletion** — the token row is deleted on successful redemption, so a link can never be replayed. Generating a new link **invalidates the user's previous link** for that channel. + - **Config-driven, extensible channels** — new `magic_link.channels` config (`web`, `mobile`, and any host-defined channel such as `desktop`, added by config with no code change). A channel with a `scheme`/`universal_link` is built as a deep link; otherwise `base_url` + `path`. Send accepts a `channel` parameter. + - **Confirmation required by default** (`magic_link.require_confirmation`, `NEEV_MAGIC_LINK_CONFIRMATION`) — a GET never consumes a link, so scanning mail gateways (Outlook SafeLinks, Mimecast) cannot burn a single-use link by prefetching it. Clients must handle the new `confirmation_required` auth state and POST the token to complete login. Disable only if your users are not behind such a gateway. + - **Optional browser/device binding** (`magic_link.bind_to_browser`, default off) — binds a link to the requesting device. Generation throws `MagicLinkBindingException` when binding is on but the request has no binding source (X-Device-Id / `binding` field / session), rather than minting a link that could never be redeemed. + - **Single route for login + confirmation** — `GET|POST /neev/loginUsingLink` (API) and `GET|POST /login-link/verify` (Blade): GET opens the link (validate-only when confirmation is required), POST is the explicit confirm. New `GET|POST /neev/loginUsingLink/validate` checks a token without consuming it. Redemption routes are now rate-limited (`throttle:10,1`). + - **Unverified-email policy** (`magic_link.allow_unverified_users`, `NEEV_MAGIC_LINK_ALLOW_UNVERIFIED`, default off) — sending to an unverified address is refused (`MagicLinkUnverifiedException`; `403` from the API, an inline error in Blade) instead of mailing a link that redemption always rejected as "invalid or expired". When enabled, unverified users may use magic links and redeeming one marks the email verified and fires `EmailVerified` — following the link proves inbox control. + - **New `magic_link` config block** — single engine (no driver switch): `expires_in` (default 10 min), `bind_to_browser`, `require_confirmation`, `allow_unverified_users`, `channels`. The legacy `url_expiry_time` no longer governs magic links (still used by password-reset / email-verification links). + - **API surface** — `MagicLinkManager` service (inject it; no facade). `forWeb()`/`forMobile()`/`generate()` return an array (`url`, `token`, `expires_in`, `channel`, `model`); `validate()`/`consume()` return a `MagicLinkResult` whose status is a string constant (`MagicLinkResult::VALID`, `EXPIRED`, `INVALID`, `BINDING_MISMATCH`, `PENDING_CONFIRMATION`, `INACTIVE_USER`). Token mechanics (`generateToken`/`hashToken`/`findByToken`) live on the `MagicLinkToken` model. + - **Removed the legacy web route** `GET /login/{id}` (`login.link`); the Blade flow now redeems at `GET|POST /login-link/verify` (`login.link.verify`). + - Note: a valid magic link still completes login **without** enforcing MFA (unchanged, by design). + +### Added +- **Lifecycle events** — `MagicLinkGenerated`, `MagicLinkConsumed`, `MagicLinkRejected` for auditing, notifications, and abuse detection. +- **`neev:clean-magic-links` command** — deletes expired magic-link tokens; schedule alongside `neev:clean-login-attempts`. Purges every expired token regardless of tenancy config: it runs from cron with no resolved tenant, where the tenant scope would otherwise narrow the delete to `tenant_id IS NULL` and silently strand every tenant's expired rows (and their retained IP/user-agent data) while still reporting success. + ### Fixed +- **Magic-link endpoints no longer 500 on malformed input** — `POST /neev/sendLoginLink` validates `email` (it raised a `TypeError` when the field was absent), and a non-scalar `token` (`?token[]=a&token[]=b`) is rejected as an invalid token instead of raising "Array to string conversion". - **`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 ## [0.5.0] - 2026-07-02 diff --git a/README.md b/README.md index b1b4a6c..3990e43 100644 --- a/README.md +++ b/README.md @@ -176,12 +176,12 @@ curl -X POST https://yourapp.com/neev/login \ ### Magic Link (Passwordless) ```bash -# Send login link +# Send login link (single-use token; optional channel, default "web") curl -X POST https://yourapp.com/neev/sendLoginLink \ - -d '{"email": "john@example.com"}' + -d '{"email": "john@example.com", "channel": "web"}' -# Login via link -curl -X GET "https://yourapp.com/neev/loginUsingLink?id=1&signature=..." +# Login via link (opaque token from the email URL) +curl -X GET "https://yourapp.com/neev/loginUsingLink?token=THE_OPAQUE_TOKEN" ``` ### Passkey / WebAuthn @@ -227,7 +227,8 @@ All API routes are prefixed with `/neev` — the prefix is configurable via `rou | POST | `/neev/register` | Register new user | No | | POST | `/neev/login` | Login with credentials | No | | POST | `/neev/sendLoginLink` | Send magic link | No | -| GET | `/neev/loginUsingLink` | Login via magic link | No | +| GET / POST | `/neev/loginUsingLink` | Redeem magic link (GET opens, POST confirms) | No | +| GET / POST | `/neev/loginUsingLink/validate` | Validate a magic-link token without using it | No | | POST | `/neev/logout` | Logout current session | Yes | | POST | `/neev/logoutAll` | Logout all other sessions | Yes | | POST | `/neev/forgotPassword` | Send password reset link | No | @@ -346,7 +347,7 @@ The Blade page routes below (everything except the OAuth/SSO endpoints) register | PUT | `/login` | `login.password` | Show password form | | POST | `/login` | - | Process login | | POST | `/login/link` | `login.link.send` | Send magic link | -| GET | `/login/{id}` | `login.link` | Login via magic link | +| GET / POST | `/login-link/verify` | `login.link.verify` | Redeem magic link (GET opens, POST confirms) | | GET | `/forgot-password` | `password.request` | Forgot password form | | POST | `/forgot-password` | `password.email` | Send reset link | | GET | `/update-password/{id}/{hash}` | `reset.request` | Reset password form | @@ -669,7 +670,8 @@ Email verification is enforced by applying the opt-in `neev:verified-email` midd ```php 'login_token_expiry_minutes' => 1440, // Login access tokens 'mfa_jwt_expiry_minutes' => 30, // Temporary MFA JWTs -'url_expiry_time' => 60, // Magic links, reset links +'url_expiry_time' => 60, // Password-reset & email-verification links +'magic_link' => ['expires_in' => 10], // Magic links (single-use, stateful) 'otp_expiry_time' => 15, // OTP codes ``` diff --git a/UPGRADING.md b/UPGRADING.md index ef4b88c..fd41634 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -11,6 +11,71 @@ changes see [CHANGELOG.md](./CHANGELOG.md). --- +## 0.5.0 → Unreleased + +**Magic links are now stateful and single-use (action required).** +The stateless signed-URL flow is gone. Links are opaque tokens stored +hashed in the new `magic_link_tokens` table, deleted on redemption, and +superseded whenever a newer link is issued for the same channel. + +- **Run `php artisan migrate`** — the new `magic_link_tokens` table is + required. Any signed magic links already in users' inboxes stop + working on deploy; users simply request a new one. +- **Links are single-use and expire faster.** A link can be redeemed + exactly once, and the default expiry drops from 60 to 10 minutes + (`magic_link.expires_in`, `NEEV_MAGIC_LINK_EXPIRY`). The legacy + `url_expiry_time` no longer governs magic links — it still applies to + password-reset and email-verification links. +- **Redemption now takes a `token` parameter**, not signed-URL query + params. Frontends must forward the `token` from the link to + `POST /neev/loginUsingLink`. The emailed URL points at + `{magic_link.channels.web.base_url}{path}?token=...` (default + `{app.url}/login-link`), so your `/login-link` page reads `token` from + the query string and posts it. +- **Clients must handle `confirmation_required`.** Because links are + single-use, a GET must never consume one: mail-scanning gateways + (Outlook SafeLinks, Mimecast) prefetch links and would burn them + before the user clicks. `GET /neev/loginUsingLink` therefore returns + `{"auth_state": "confirmation_required"}` — render a "confirm sign-in" + button and `POST` the same token back to complete login. + `GET|POST /neev/loginUsingLink/validate` checks a token without + consuming it. To restore one-click GET redemption, set + `NEEV_MAGIC_LINK_CONFIRMATION=false` — only do this if you are certain + your users are not behind a scanning mail gateway. +- **Blade users:** the legacy `GET /login/{id}` route (`login.link`) is + removed; redemption is `GET|POST /login-link/verify` + (`login.link.verify`). Re-run `php artisan neev:ui blade` or apply the + new `auth/confirm-login-link.blade.php` view if you ejected the kit. + Apps that published `routes/neev.php` must re-apply the route change. +- **Unverified users can no longer request a link by default.** + `POST /neev/sendLoginLink` returns `403` ("Please verify your email + address before using a login link.") for an unverified address, and + `MagicLinkManager::generate()` throws `MagicLinkUnverifiedException`. + Previously a link was mailed and every redemption of it failed with + "Invalid or expired" — a dead end. Set + `NEEV_MAGIC_LINK_ALLOW_UNVERIFIED=true` to let unverified users use + magic links; redeeming one then **marks the email verified** and fires + `EmailVerified`, since following the link proves inbox control. +- **Malformed input is now a rejection, not a 500.** + `POST /neev/sendLoginLink` without an `email` returns `422` (it raised + a `TypeError` before), and a non-scalar `token` on the redemption and + validate endpoints is treated as an invalid token rather than raising + "Array to string conversion". +- **Browser binding** (`magic_link.bind_to_browser`, default off) binds + a link to the device that requested it. When enabled, generation + throws `MagicLinkBindingException` if the request has no binding + source (`X-Device-Id` header, a `binding` field, or a session) — + rather than minting a link that could never be redeemed. Session-less + API clients must send `X-Device-Id` before enabling it. +- Both refusals above happen **before** the previous link is + invalidated, so a rejected send never costs the user the working link + already in their inbox. +- Redemption routes are now rate-limited (`throttle:10,1`). +- Schedule `neev:clean-magic-links` alongside `neev:clean-login-attempts` + to purge expired tokens. + +--- + ## 0.4.5 → 0.5.0 **The package is now headless by default (RFC 002, action required for diff --git a/config/neev.php b/config/neev.php index 43997b4..1b90ba9 100644 --- a/config/neev.php +++ b/config/neev.php @@ -151,7 +151,7 @@ // Secret key for signing MFA JWTs. Falls back to APP_KEY if not set. 'jwt_secret' => env('NEEV_JWT_SECRET'), - // Minutes before magic links, password reset links expire. + // Minutes before password reset links expire. 'url_expiry_time' => 60, // Minutes before email OTP codes expire. @@ -160,6 +160,70 @@ // Days before password expires. 0 = disabled. 'password_expiry_days' => 90, + /* + |-------------------------------------------------------------------------- + | Magic Link (Passwordless) Authentication + |-------------------------------------------------------------------------- + | + | Magic links are server-side, single-use, revocable opaque tokens, stored + | hashed and invalidated on redemption. + | + | Neev is UI-agnostic: it manages token lifecycle, validation and security + | policy only. Host applications own all routing, deep-link and UI concerns. + | + */ + + 'magic_link' => [ + // Minutes before a magic link expires. Recommended: 5-15 minutes. + 'expires_in' => env('NEEV_MAGIC_LINK_EXPIRY', 10), + + // Bind a link to the browser/device that requested it. The binding + // source is context['binding'], the `binding` request field, the + // X-Device-Id header, or the web session id — in that order. Enabling + // this makes generation FAIL when no source is available. + 'bind_to_browser' => env('NEEV_MAGIC_LINK_BIND_TO_BROWSER', false), + + // Require an explicit POST confirmation before a link is consumed. + // + // Keep this enabled unless you are certain your users are not behind a + // scanning mail gateway. Corporate scanners (Outlook SafeLinks, + // Mimecast) prefetch GET links; because links are single-use, a + // prefetch would consume the link before the user ever clicks it and + // lock them out. With confirmation on, GET only ever validates. + 'require_confirmation' => env('NEEV_MAGIC_LINK_CONFIRMATION', true), + + // Whether a user whose email is unverified may use magic links. + // + // Following a link proves control of the inbox, so when this is on, + // redeeming one also marks the email verified. When off, sending to an + // unverified address is refused outright (MagicLinkUnverifiedException) + // rather than mailing a link that redemption would always reject. + 'allow_unverified_users' => env('NEEV_MAGIC_LINK_ALLOW_UNVERIFIED', false), + + // Channel-aware link generation. Neev builds the URL; it never renders + // UI or handles deep-link routing — the host app does. + // + // Add your own channels here (e.g. 'desktop') — no code changes needed. + // A channel with a 'scheme'/'universal_link' is built as a deep link; + // otherwise it is a web URL built from 'base_url' + 'path'. + 'channels' => [ + 'web' => [ + 'base_url' => env('APP_URL'), + // Path appended to base_url for the redemption link. + 'path' => '/login-link', + ], + 'mobile' => [ + // Custom URL scheme for native deep links (e.g. "myapp://login"). + 'scheme' => env('NEEV_MOBILE_SCHEME'), + // HTTPS universal/app link fallback. + 'universal_link' => env('NEEV_MOBILE_UNIVERSAL_LINK'), + ], + // 'desktop' => [ + // 'scheme' => env('NEEV_DESKTOP_SCHEME'), // e.g. "myapp-desktop://login" + // ], + ], + ], + /* |-------------------------------------------------------------------------- | Rate Limiting (Progressive Delay) diff --git a/database/factories/MagicLinkTokenFactory.php b/database/factories/MagicLinkTokenFactory.php new file mode 100644 index 0000000..646bbe1 --- /dev/null +++ b/database/factories/MagicLinkTokenFactory.php @@ -0,0 +1,36 @@ + UserFactory::new(), + 'token' => hash('sha256', fake()->unique()->sha256()), + 'channel' => 'web', + 'expires_at' => now()->addMinutes(10), + ]; + } + + public function expired(): static + { + return $this->state(['expires_at' => now()->subMinute()]); + } + + public function mobile(): static + { + return $this->state(['channel' => 'mobile']); + } + + public function boundTo(string $fingerprint): static + { + return $this->state(['meta_data' => ['fingerprint' => hash('sha256', $fingerprint)]]); + } +} diff --git a/database/migrations/2025_01_01_000013_create_magic_link_tokens_table.php b/database/migrations/2025_01_01_000013_create_magic_link_tokens_table.php new file mode 100644 index 0000000..a96bacd --- /dev/null +++ b/database/migrations/2025_01_01_000013_create_magic_link_tokens_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete(); + $table->string('token')->unique(); + $table->string('channel')->default('web'); + $table->json('meta_data')->nullable(); + $table->text('user_agent')->nullable(); + $table->string('created_ip')->nullable(); + $table->timestamp('expires_at'); + $table->timestamps(); + + $table->index('user_id'); + $table->index(['user_id', 'channel']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('magic_link_tokens'); + } +}; diff --git a/docs/README.md b/docs/README.md index e93d014..02105ee 100644 --- a/docs/README.md +++ b/docs/README.md @@ -177,6 +177,9 @@ Neev fires Laravel's native auth events where semantics match, and its own event | `LoggedOut` | `$user` | User logs out | | `PasswordChanged` | `$user` | Password changes (including resets) | | `EmailVerified` | `$user` | Email is verified for the first time | +| `MagicLinkGenerated` | `$user, $token` | A magic link is issued (previous link for that channel is invalidated) | +| `MagicLinkConsumed` | `$user, $result` | A magic link is successfully redeemed (token deleted) | +| `MagicLinkRejected` | `$result` | A magic link is refused (expired, invalid, binding mismatch, inactive user) | | `MfaMethodAdded` | `$user, $method` | An MFA method is configured | | `MfaMethodRemoved` | `$user, $method` | An MFA method is removed | | `RecoveryCodesGenerated` | `$user` | Recovery codes are (re)generated | diff --git a/docs/api-reference.md b/docs/api-reference.md index 1b9f9f1..9710acf 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -117,7 +117,9 @@ When `auth_state` is `mfa_required`, the token is a short-lived JWT (type `mfa`) ### Send Login Link -Send a magic link to the user's email for passwordless login. +Send a magic link to the user's email for passwordless login. Issues a +single-use token (stored hashed) and invalidates the user's previous link for +the channel. ```http POST /neev/sendLoginLink @@ -127,10 +129,14 @@ POST /neev/sendLoginLink ```json { - "email": "john@example.com" + "email": "john@example.com", + "channel": "web" } ``` +`channel` is optional (default `web`); it must be a channel defined in +`magic_link.channels`. + **Response:** ```json @@ -139,27 +145,78 @@ POST /neev/sendLoginLink } ``` +Returns `401` for an unknown email. + --- ### Login Using Link -Authenticate using a magic link. +Redeem a magic link. Login and the confirmation step share this route: `GET` +opens the link, `POST` is the explicit confirm. While +`magic_link.require_confirmation` is on (the default), a `GET` only validates +and returns `{"auth_state": "confirmation_required"}` without logging in — this +keeps a scanning mail gateway's prefetch from consuming the single-use link. +Rate-limited (`throttle:10,1`). ```http -GET /neev/loginUsingLink?id={email_id}&signature={signature}&expires={timestamp} +GET|POST /neev/loginUsingLink?token={token} ``` -**Response:** +**Response (confirmation required):** + +```json +{ + "auth_state": "confirmation_required", + "channel": "web", + "message": "Please confirm this login to continue." +} +``` + +Render a confirm control and `POST` the same token back to complete login. + +**Response (success):** ```json { "auth_state": "authenticated", "token": "1|abc123...", "expires_in": 1440, + "mfa_options": null, + "email_verified": true +} +``` + +- `403 { "message": "Invalid or expired verification link." }` — invalid, expired, replayed, or binding-mismatch token. +- `422` with an `email` validation error — the account is deactivated. + +The token is single-use: the row is deleted on success, so replays return `403`. + +--- + +### Validate Login Link + +Check a magic-link token **without** consuming it (e.g. to render a +confirmation screen). Never authenticates. + +```http +GET|POST /neev/loginUsingLink/validate?token={token} +``` + +**Response:** + +```json +{ + "status": "pending_confirmation", + "valid": false, + "requires_confirmation": true, + "channel": "web", "email_verified": true } ``` +`status` is one of `valid`, `invalid`, `expired`, `binding_mismatch`, +`pending_confirmation`, `inactive_user`. + --- ### Logout diff --git a/docs/authentication.md b/docs/authentication.md index fd0b250..8b51779 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -165,13 +165,21 @@ Enforcement is opt-in: apply the `neev:password-not-expired` middleware alias (` Passwordless login via secure email links. Always available — no config toggle. +Magic links are **stateful and single-use**: an opaque, high-entropy token is +stored **hashed** in the `magic_link_tokens` table (only the plain token ever +leaves the app, inside the emailed URL). On successful use the row is **deleted**, +so a link can never be replayed, and issuing a new link **invalidates the user's +previous link** for that channel. + +> A valid magic link completes login **without** enforcing MFA, even if the user +> has MFA enabled (by design — same posture as OAuth login). See [security.md](./security.md). + ### Flow -1. User enters email on login page -2. Clicks "Send Login Link" -3. Receives email with secure link -4. Clicks link to authenticate -5. Automatically logged in +1. User enters email and requests a link (`POST /neev/sendLoginLink`) +2. Receives an email with a secure link containing an opaque `token` +3. Opens the link → the token is validated and consumed +4. Automatically logged in (web: session; API: a login token) ### API Example @@ -180,23 +188,84 @@ Passwordless login via secure email links. Always available — no config toggle ```bash curl -X POST https://yourapp.com/neev/sendLoginLink \ -H "Content-Type: application/json" \ - -d '{"email": "john@example.com"}' + -d '{"email": "john@example.com", "channel": "web"}' ``` **Use Link:** ```bash -curl -X GET "https://yourapp.com/neev/loginUsingLink?id=1&signature=abc123&expires=1234567890" +# Opening the link only validates it — the response asks for confirmation. +curl -X GET "https://yourapp.com/neev/loginUsingLink?token=THE_OPAQUE_TOKEN" +# => {"auth_state": "confirmation_required", "channel": "web", ...} + +# The user's explicit confirm consumes the link and logs them in. +curl -X POST "https://yourapp.com/neev/loginUsingLink" \ + -H "Content-Type: application/json" \ + -d '{"token": "THE_OPAQUE_TOKEN"}' +# => {"auth_state": "authenticated", "token": "...", ...} ``` -### Link Expiry +Login and the confirmation step share one route: `GET` opens the link, `POST` is +the explicit confirm. `GET|POST /neev/loginUsingLink/validate` checks a token +without consuming it. Redemption is rate-limited (`throttle:10,1`). + +A `GET` never consumes a link while `require_confirmation` is on (the default), +and that matters because links are single-use: scanning mail gateways (Outlook +SafeLinks, Mimecast) prefetch `GET` links, so a consuming `GET` would let the +scanner burn the link before the user clicks it. Your frontend should treat +`confirmation_required` as "render a confirm button that POSTs the token back". -Configure in `config/neev.php`: +### Channels + +`sendLoginLink` accepts a `channel` (default `web`). Channels are config-driven — +add your own (e.g. `desktop`) under `magic_link.channels` with no code change. A +channel with a `scheme`/`universal_link` builds a deep link; otherwise a web URL. + +### Configuration & options + +Configured under `magic_link` in `config/neev.php`: ```php -'url_expiry_time' => 60, // Minutes +'magic_link' => [ + 'expires_in' => 10, // minutes a link stays valid + 'bind_to_browser' => false, // restrict redemption to the originating browser/device + 'require_confirmation' => true, // explicit confirm step; a GET never consumes the link + 'allow_unverified_users' => false, // unverified users may use links; redeeming verifies the email + 'channels' => [ /* web, mobile, ... */ ], +], ``` +See [configuration.md](./configuration.md#magic-link) for the full block. + +### Calling it in code + +Inject the `MagicLinkManager` service (there is no facade): + +```php +use Ssntpl\Neev\Services\MagicLink\MagicLinkManager; + +$link = $magicLink->forWeb($user); // ['url', 'token', 'channel', 'expires_at', 'expires_in', 'model'] +$result = $magicLink->consume($request); +if ($result->isValid()) { /* $result->user is authenticated */ } +``` + +`forWeb()`/`forMobile()`/`generate()` refuse rather than mint a link the +recipient could never use. Both checks run **before** the previous link is +invalidated, so a refused send never costs the user the link already in their +inbox: + +| Exception | Thrown when | +|---|---| +| `MagicLinkBindingException` | `bind_to_browser` is on but the request has no binding source (`X-Device-Id`, `binding`, or session). | +| `MagicLinkUnverifiedException` | The user's email is unverified and `allow_unverified_users` is off. The API surfaces this as `403`. | + +With `allow_unverified_users` on, redeeming a link marks the email verified and +fires `EmailVerified`: following the link is itself proof of inbox control. + +Lifecycle events `MagicLinkGenerated` (`Ssntpl\Neev\Events\`), `MagicLinkConsumed`, +and `MagicLinkRejected` are fired for auditing/notifications. Prune expired tokens +with `php artisan neev:clean-magic-links`. + --- ## Passkey / WebAuthn Authentication diff --git a/docs/cli-commands.md b/docs/cli-commands.md index 7e5eabc..05c8208 100644 --- a/docs/cli-commands.md +++ b/docs/cli-commands.md @@ -78,6 +78,27 @@ Retention is controlled by `config('neev.mfa_pending_setup_retention_days')` (de $schedule->command('neev:clean-pending-mfa-setups')->daily(); ``` +### `neev:clean-magic-links` + +Delete expired magic-link tokens. (Consumed and superseded tokens are already +deleted on use, so only expired rows can linger.) + +```bash +php artisan neev:clean-magic-links +``` + +Expired is expired: this purges every expired token regardless of tenancy +config, since it is a maintenance sweep rather than a request. Expired tokens +cannot be redeemed in any case — the point is to stop the table growing without +bound and to honour retention, as each row holds the requesting IP and user +agent. + +Schedule it alongside the other maintenance commands: + +```php +$schedule->command('neev:clean-magic-links')->daily(); +``` + --- ## Tenant / Team Provisioning diff --git a/docs/configuration.md b/docs/configuration.md index 747e59e..e96eaba 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -198,7 +198,63 @@ Secret key for signing MFA JWTs. Falls back to `APP_KEY` if not set. 'url_expiry_time' => 60, ``` -Minutes before magic links and password reset links expire. +Minutes before password-reset and email-verification links expire. (Magic links +use their own `magic_link.expires_in` — see [Magic Link](#magic-link) below.) + +### Magic Link + +Stateful, single-use passwordless login. See +[authentication.md](./authentication.md#magic-link-authentication) for the flow. + +```php +'magic_link' => [ + // Minutes a link stays valid (recommended 5–15). + 'expires_in' => env('NEEV_MAGIC_LINK_EXPIRY', 10), + + // Restrict redemption to the browser/device that requested it. Generation + // fails when there is no binding source, rather than minting a dead link. + 'bind_to_browser' => env('NEEV_MAGIC_LINK_BIND_TO_BROWSER', false), + + // Require an explicit confirm step before consuming, so a GET never uses up + // a single-use link. Keep on unless your users are not behind a mail scanner. + 'require_confirmation' => env('NEEV_MAGIC_LINK_CONFIRMATION', true), + + // May unverified users use magic links? When on, redeeming a link also + // verifies the email (the click proves inbox control). When off, sending + // to an unverified address is refused outright. + 'allow_unverified_users' => env('NEEV_MAGIC_LINK_ALLOW_UNVERIFIED', false), + + // Channel-aware URL building. Add your own channels (e.g. 'desktop') here + // with no code change. A channel with a 'scheme'/'universal_link' is built + // as a deep link; otherwise a web URL from 'base_url' + 'path'. + 'channels' => [ + 'web' => [ + 'base_url' => env('APP_URL'), // point at your frontend for a decoupled SPA + 'path' => '/login-link', + ], + 'mobile' => [ + 'scheme' => env('NEEV_MOBILE_SCHEME'), // e.g. myapp://login + 'universal_link' => env('NEEV_MOBILE_UNIVERSAL_LINK'), + ], + ], +], +``` + +| Key | Default | Purpose | +|---|---|---| +| `expires_in` | `10` | Minutes a link is valid. | +| `bind_to_browser` | `false` | Only redeem from the originating browser/device. | +| `require_confirmation` | `true` | Require an explicit `POST` confirm; a `GET` never consumes. | +| `allow_unverified_users` | `false` | Let unverified users request links; redeeming then verifies the email. | +| `channels` | web + mobile | Per-channel URL building (extensible). | + +Notes: +- Tokens are single-use (deleted on redemption); a new link invalidates the previous one. +- Because links are single-use, **`require_confirmation` should stay on** for any app whose users may sit behind a scanning mail gateway (Outlook SafeLinks, Mimecast). Those gateways prefetch `GET` links, which would consume the link before the user ever clicks it and lock them out. With confirmation on, a `GET` only ever validates. +- With `bind_to_browser` on, generation throws `MagicLinkBindingException` if the request has no binding source (`X-Device-Id` header, `binding` field, or session). Session-less API clients must send `X-Device-Id`. +- With `allow_unverified_users` off (the default), generation throws `MagicLinkUnverifiedException` for an unverified address and the API returns `403`. Otherwise the link would be mailed and every redemption of it would fail as "invalid or expired". Turning it on lets those users in and marks the email verified on redemption. Both refusals happen **before** the previous link is invalidated, so a rejected send never costs the user the link already in their inbox. +- A magic link does **not** enforce MFA (by design). +- Prune expired rows with `php artisan neev:clean-magic-links`. ### OTP Expiry diff --git a/docs/db-schema.dbml b/docs/db-schema.dbml index c3027ad..844eb6b 100644 --- a/docs/db-schema.dbml +++ b/docs/db-schema.dbml @@ -248,5 +248,24 @@ Table tenant_auth_settings { updated_at timestamp } +Table magic_link_tokens { + id bigint [pk, increment] + user_id bigint [ref: > users.id] + tenant_id bigint [null, ref: > tenants.id] + token varchar [unique, note: 'SHA-256 hash of the opaque token'] + channel varchar [default: 'web', note: 'web / mobile / custom'] + meta_data json [null, note: 'Holds the browser/device binding fingerprint'] + user_agent text [null] + created_ip varchar [null] + expires_at timestamp + created_at timestamp + updated_at timestamp + + indexes { + user_id + (user_id, channel) + } +} + // Cross-table refs that can't be inline Ref: users.default_team_id > teams.id diff --git a/docs/spa-authentication.md b/docs/spa-authentication.md index 1e29b20..d666ac6 100644 --- a/docs/spa-authentication.md +++ b/docs/spa-authentication.md @@ -256,10 +256,13 @@ POST /neev/sendLoginLink { "message": "Login link has been sent." } ``` -The emailed link points at your frontend (`{APP_URL}/login-link?id=…&signature=…&expires=…`). Your SPA route at `/login-link` forwards the query string to the API **via XHR** (the XHR carries your stateful `Origin`, which is what triggers the cookie): +The emailed link points at your frontend (`{base_url}/login-link?token=…`, where +`base_url` is `magic_link.channels.web.base_url` — set it to your frontend origin). +Your SPA route at `/login-link` forwards the opaque `token` to the API **via XHR** +(the XHR carries your stateful `Origin`, which is what triggers the cookie): ```http -GET /neev/loginUsingLink?id={id}&expires={timestamp}&signature={signature} +GET /neev/loginUsingLink?token={token} ``` **Response** — cookie set: diff --git a/docs/web-routes.md b/docs/web-routes.md index ab51f4e..ac384e2 100644 --- a/docs/web-routes.md +++ b/docs/web-routes.md @@ -41,8 +41,17 @@ These routes are accessible without authentication. | Method | Route | Name | Description | |--------|-------|------|-------------| -| POST | `/login/link` | `login.link.send` | Send login link to email | -| GET | `/login/{id}` | `login.link` | Login via magic link | +| POST | `/login/link` | `login.link.send` | Send a single-use login link to email | +| GET / POST | `/login-link/verify` | `login.link.verify` | Redeem the link (GET opens; POST confirms) | + +The link is single-use and redeemed via `token`. While +`magic_link.require_confirmation` is on (the default), the `GET` only shows a +confirmation page — never consuming the link, so a mail scanner's prefetch cannot +burn it — and the user's `POST` completes the login (see +[authentication.md](./authentication.md#magic-link-authentication)). + +The legacy `GET /login/{id}` (`login.link`) route was removed along with the +signed-URL flow. --- @@ -310,6 +319,7 @@ php artisan neev:ui blade | `auth/register.blade.php` | Registration form | | `auth/login.blade.php` | Login form | | `auth/login-password.blade.php` | Password entry after email | +| `auth/confirm-login-link.blade.php` | Magic-link confirmation page (shown on GET when `require_confirmation` is on) | | `auth/forgot-password.blade.php` | Password reset request | | `auth/reset-password.blade.php` | New password form | | `auth/verify-email.blade.php` | Verification pending | diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 4c28401..6862932 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -117,7 +117,7 @@ parameters: - message: '#^Parameter \#1 \$view of function view expects view\-string\|null, string given\.$#' identifier: argument.type - count: 9 + count: 10 path: src/Http/Controllers/Auth/UserAuthController.php - diff --git a/routes/neev.php b/routes/neev.php index db9191e..90e2670 100644 --- a/routes/neev.php +++ b/routes/neev.php @@ -44,9 +44,6 @@ Route::get('/login', [UserAuthController::class, 'loginCreate']) ->name('login'); - Route::get('/login/{id}', [UserAuthController::class, 'loginUsingLink']) - ->name('login.link'); - Route::get('/otp/mfa/{method}', [UserAuthController::class, 'verifyMFAOTPCreate']) ->name('otp.mfa.create'); @@ -72,6 +69,9 @@ Route::post('/login/link', [UserAuthController::class, 'sendLoginLink']) ->name('login.link.send'); + Route::match(['get', 'post'], '/login-link/verify', [UserAuthController::class, 'verifyLoginLink']) + ->name('login.link.verify'); + Route::post('/otp/mfa', [UserAuthController::class, 'verifyMFAOTPStore']) ->name('otp.mfa.store'); @@ -219,7 +219,8 @@ Route::get('/oauth/{service}/redirect', [OAuthApiController::class, 'redirectUrl']); Route::post('/oauth/{service}/callback', [OAuthApiController::class, 'callback']); }); - Route::get('/loginUsingLink', [UserAuthApiController::class, 'loginUsingLink'])->name('loginUsingLink'); + Route::match(['get', 'post'], '/loginUsingLink', [UserAuthApiController::class, 'loginUsingLink'])->middleware('throttle:10,1')->name('loginUsingLink'); + Route::match(['get', 'post'], '/loginUsingLink/validate', [UserAuthApiController::class, 'validateLoginLink'])->middleware('throttle:10,1')->name('loginUsingLink.validate'); Route::post('/resetPassword', [UserAuthApiController::class, 'resetPassword'])->middleware('throttle:10,1')->name('neev.resetPassword'); Route::post('/email/change/verify', [UserAuthApiController::class, 'verifyEmailChange'])->middleware('throttle:10,1')->name('neev.email.change.verify'); diff --git a/src/Commands/CleanExpiredMagicLinks.php b/src/Commands/CleanExpiredMagicLinks.php new file mode 100644 index 0000000..f3ecffc --- /dev/null +++ b/src/Commands/CleanExpiredMagicLinks.php @@ -0,0 +1,23 @@ +where('expires_at', '<', now()) + ->delete(); + + $this->info("Deleted {$count} expired magic-link token(s)."); + + return self::SUCCESS; + } +} diff --git a/src/Events/MagicLinkConsumed.php b/src/Events/MagicLinkConsumed.php new file mode 100644 index 0000000..06744bb --- /dev/null +++ b/src/Events/MagicLinkConsumed.php @@ -0,0 +1,23 @@ +validate([ + 'email' => ['required', 'string', 'email'], + 'channel' => ['sometimes', 'string'], + ]); + $user = User::findByEmail($request->email); if (!$user) { return response()->json([ @@ -373,39 +382,96 @@ public function sendLoginLink(Request $request) ], 401); } - $expiryMinutes = config('neev.url_expiry_time', 60); - $signedUrl = URL::temporarySignedRoute( - 'loginUsingLink', - now()->addMinutes($expiryMinutes), - ['id' => $user->id] - ); + $channel = (string) $request->input('channel', 'web'); - $query = parse_url($signedUrl, PHP_URL_QUERY); - $frontendUrl = config('app.url'); - $url = "{$frontendUrl}/login-link?{$query}"; + try { + $link = $magicLink->generate($user, $channel, ['request' => $request]); + } catch (MagicLinkUnverifiedException $e) { + Log::warning($e); + + return response()->json([ + 'message' => 'Please verify your email address before using a login link.', + ], 403); + } catch (MagicLinkBindingException $e) { + Log::warning($e); - Mail::to($user->email)->send(new LoginUsingLink($url, $expiryMinutes)); + return response()->json([ + 'message' => 'Unable to send a login link for this request.', + ], 422); + } + + Mail::to($user->email)->send(new LoginUsingLink($link['url'], $link['expires_in'])); return response()->json([ 'message' => 'Login link has been sent.', ]); } - public function loginUsingLink(Request $request, GeoIP $geoIP) + /** + * Redeem a magic link (login + confirmation share this single route). + * + * When confirmation is required, a GET only validates the link (scanner-safe) + * and returns a "confirmation_required" state; a POST is the user's explicit + * confirmation, which consumes the link. Otherwise the link is consumed and + * a login token is issued. + */ + public function loginUsingLink(Request $request, GeoIP $geoIP, MagicLinkManager $magicLink) + { + if (config('neev.magic_link.require_confirmation', false) && $request->isMethod('get')) { + $result = $magicLink->validate($request); + } else { + $result = $magicLink->consume($request); + } + + return $this->respondToMagicLink($request, $geoIP, $result); + } + + /** + * Validate a magic link WITHOUT consuming it. + * + * Exposes channel metadata and the "pending confirmation" state so host + * applications can build confirmation UX. Never authenticates. + */ + public function validateLoginLink(Request $request, MagicLinkManager $magicLink) + { + $result = $magicLink->validate($request); + + return response()->json([ + 'status' => $result->status, + 'valid' => $result->isValid(), + 'requires_confirmation' => $result->needsConfirmation(), + 'channel' => $result->channel, + 'email_verified' => $result->user?->hasVerifiedEmail(), + ]); + } + + /** + * Build the JSON response for a magic-link redemption result. + */ + protected function respondToMagicLink(Request $request, GeoIP $geoIP, MagicLinkResult $result) { - if (! $request->hasValidSignature()) { + if ($result->needsConfirmation()) { return response()->json([ - 'message' => 'Invalid or expired verification link.', - ], 403); + 'auth_state' => 'confirmation_required', + 'channel' => $result->channel, + 'message' => 'Please confirm this login to continue.', + ]); } - $user = User::model()->find($request->id); - if (!$user || !$user->hasVerifiedEmail()) { + if (!$result->isValid()) { + // Preserve the historical deactivated-account behaviour (422). + if ($result->status === MagicLinkResult::INACTIVE_USER) { + throw ValidationException::withMessages([ + 'email' => 'Your account is deactivated, please contact your admin to activate your account.', + ]); + } + return response()->json([ 'message' => 'Invalid or expired verification link.', ], 403); } + $user = $result->user; $expiryMinutes = config('neev.login_token_expiry_minutes', 1440); $token = app(AuthService::class)->createApiToken($request, $geoIP, $user, LoginAttempt::MagicAuth, $expiryMinutes); diff --git a/src/Http/Controllers/Auth/UserAuthController.php b/src/Http/Controllers/Auth/UserAuthController.php index 4b5b775..7c51404 100644 --- a/src/Http/Controllers/Auth/UserAuthController.php +++ b/src/Http/Controllers/Auth/UserAuthController.php @@ -14,8 +14,12 @@ use Illuminate\Validation\ValidationException; use Ssntpl\Neev\Events\LoggedOut; use Ssntpl\Neev\Exceptions\InvalidInvitationException; +use Ssntpl\Neev\Exceptions\MagicLinkBindingException; +use Ssntpl\Neev\Exceptions\MagicLinkUnverifiedException; use Ssntpl\Neev\Http\Controllers\Controller; use Ssntpl\Neev\Http\Requests\Auth\LoginRequest; +use Ssntpl\Neev\Services\MagicLink\MagicLinkManager; +use Ssntpl\Neev\Support\MagicLink\MagicLinkResult; use Ssntpl\Neev\Mail\EmailOTP; use Ssntpl\Neev\Mail\LoginUsingLink; use Ssntpl\Neev\Mail\VerifyUserEmail; @@ -160,40 +164,85 @@ public function loginPassword(LoginRequest $request) return view('neev::auth.login-password', $viewData); } - public function sendLoginLink(Request $request) + public function sendLoginLink(Request $request, MagicLinkManager $magicLink) { + $request->validate([ + 'email' => ['required', 'string', 'email'], + ]); + $user = User::findByEmail($request->email); if (!$user) { return back()->withErrors(['message' => 'Credentials are wrong.']); } - $expiryMinutes = config('neev.url_expiry_time', 60); - $signedUrl = URL::temporarySignedRoute( - 'login.link', - now()->addMinutes($expiryMinutes), - ['id' => $user->id] - ); + // Single-use token redeemed server-side by the Blade flow. + try { + $link = $magicLink->forWeb($user, ['request' => $request]); + } catch (MagicLinkUnverifiedException $e) { + Log::warning($e); - Mail::to($user->email)->send(new LoginUsingLink($signedUrl, $expiryMinutes)); + return back()->withErrors([ + 'message' => 'Please verify your email address before using a login link.', + ]); + } catch (MagicLinkBindingException $e) { + Log::warning($e); + + return back()->withErrors([ + 'message' => 'Unable to send a login link right now. Please try again later.', + ]); + } + + $url = route('login.link.verify', ['token' => $link['token']]); + + Mail::to($user->email)->send(new LoginUsingLink($url, $link['expires_in'])); return back()->with('status', 'Login link has been sent.'); } - public function loginUsingLink(Request $request, $id, GeoIP $geoIP) + /** + * Magic-link redemption for the Blade flow (login + confirmation share this + * single route). + * + * When confirmation is required, a GET only validates the link (so email + * scanners cannot auto-consume it) and renders a confirmation page; the + * user then POSTs back here to consume it. Otherwise the link is consumed + * and the session is authenticated immediately. + */ + public function verifyLoginLink(Request $request, GeoIP $geoIP, MagicLinkManager $magicLink) { if ($request->user()?->id) { return redirect(config('neev.home')); } - if (! $request->hasValidSignature()) { - return redirect(route('login'))->withErrors(['message' => 'Invalid or expired login link.']); + + if (config('neev.magic_link.require_confirmation', false) && $request->isMethod('get')) { + $result = $magicLink->validate($request); + + if ($result->needsConfirmation()) { + return view('neev::auth.confirm-login-link', [ + 'token' => $request->input('token'), + 'channel' => $result->channel, + ]); + } + + return $this->completeWebMagicLink($request, $geoIP, $result); } - $user = User::model()->find($id); - if (!$user || !$user->hasVerifiedEmail()) { - return redirect(route('login')); + $result = $magicLink->consume($request); + + return $this->completeWebMagicLink($request, $geoIP, $result); + } + + /** + * Complete a web magic-link redemption: log in on success, redirect with an + * error otherwise. + */ + protected function completeWebMagicLink(Request $request, GeoIP $geoIP, MagicLinkResult $result) + { + if (!$result->isValid()) { + return redirect(route('login'))->withErrors(['message' => 'Invalid or expired login link.']); } - $this->auth->login($request, $geoIP, $user, LoginAttempt::MagicAuth); + $this->auth->login($request, $geoIP, $result->user, LoginAttempt::MagicAuth); return redirect(config('neev.home')); } diff --git a/src/Models/MagicLinkToken.php b/src/Models/MagicLinkToken.php new file mode 100644 index 0000000..9eb527a --- /dev/null +++ b/src/Models/MagicLinkToken.php @@ -0,0 +1,120 @@ +|null $meta_data + * @property string|null $user_agent + * @property string|null $created_ip + * @property \Carbon\Carbon $expires_at + * @property \Carbon\Carbon|null $created_at + * @property \Carbon\Carbon|null $updated_at + */ +class MagicLinkToken extends Model +{ + use BelongsToTenant; + use HasFactory; + + protected static function newFactory() + { + return MagicLinkTokenFactory::new(); + } + + protected $fillable = [ + 'user_id', + 'tenant_id', + 'token', + 'channel', + 'meta_data', + 'user_agent', + 'created_ip', + 'expires_at', + ]; + + protected $hidden = [ + 'token', + ]; + + protected $casts = [ + 'meta_data' => 'array', + 'expires_at' => 'datetime', + ]; + + public function user() + { + return $this->belongsTo(User::getClass(), 'user_id'); + } + + // ----------------------------------------------------------------- + // Token helpers + // ----------------------------------------------------------------- + + /** + * Create a new opaque, high-entropy plain-text token. + */ + public static function generateToken(): string + { + return bin2hex(random_bytes(32)); + } + + /** + * Deterministically hash a plain token for storage and lookup. + */ + public static function hashToken(string $plain): string + { + return hash('sha256', $plain); + } + + /** + * Find a token row by its plain-text value (matches on the stored hash). + */ + public static function findByToken(string $plain): ?self + { + return static::query()->where('token', static::hashToken($plain))->first(); + } + + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + public function isUsable(): bool + { + return !$this->isExpired(); + } + + /** + * The stored browser/device binding fingerprint, if any. + */ + public function fingerprint(): ?string + { + return $this->meta_data['fingerprint'] ?? null; + } + + /** + * Scope: tokens that are still active (not yet expired). Consumed/revoked + * tokens no longer exist — they are deleted on use. + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('expires_at', '>', now()); + } +} diff --git a/src/Models/User.php b/src/Models/User.php index 1be1c9f..eb55dc1 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -34,6 +34,7 @@ * @property-read \Illuminate\Database\Eloquent\Collection $recoveryCodes * @property-read \Illuminate\Database\Eloquent\Collection $loginAttempts * @property-read \Illuminate\Database\Eloquent\Collection $accessTokens + * @property-read \Illuminate\Database\Eloquent\Collection $magicLinkTokens * @property-read \Illuminate\Database\Eloquent\Collection $teams * @property-read \Illuminate\Database\Eloquent\Collection $allTeams * @property-read \Illuminate\Database\Eloquent\Collection $ownedTeams @@ -159,4 +160,9 @@ public function deactivate() $this->active = false; return $this->save(); } + + public function magicLinkTokens() + { + return $this->hasMany(MagicLinkToken::class, 'user_id'); + } } diff --git a/src/NeevServiceProvider.php b/src/NeevServiceProvider.php index 93cd662..d550601 100644 --- a/src/NeevServiceProvider.php +++ b/src/NeevServiceProvider.php @@ -9,6 +9,7 @@ use Illuminate\Support\ServiceProvider; use Ssntpl\Neev\Commands\Auth\ConfigureAuthCommand; use Ssntpl\Neev\Commands\Auth\ShowAuthCommand; +use Ssntpl\Neev\Commands\CleanExpiredMagicLinks; use Ssntpl\Neev\Commands\CleanOldLoginAttempts; use Ssntpl\Neev\Commands\CleanPendingMfaSetups; use Ssntpl\Neev\Commands\Domain\AddDomainCommand; @@ -38,6 +39,7 @@ use Ssntpl\Neev\Http\Middleware\ResolveTeamMiddleware; use Ssntpl\Neev\Http\Middleware\TenantMiddleware; use Ssntpl\Neev\Services\ContextManager; +use Ssntpl\Neev\Services\MagicLink\MagicLinkManager; use Ssntpl\Neev\Services\TenantResolver; use Ssntpl\Neev\Services\TenantSSOManager; @@ -122,6 +124,8 @@ public function boot(): void __DIR__.'/../database/migrations/2025_01_01_000011_create_team_auth_settings_table.php' => database_path('migrations/2025_01_01_000011_create_team_auth_settings_table.php'), __DIR__.'/../database/migrations/2025_01_01_000012_create_tenant_auth_settings_table.php' => database_path('migrations/2025_01_01_000012_create_tenant_auth_settings_table.php'), + + __DIR__.'/../database/migrations/2025_01_01_000013_create_magic_link_tokens_table.php' => database_path('migrations/2025_01_01_000013_create_magic_link_tokens_table.php'), ], 'neev-migrations'); // Blade starter kit: ejected into the app (app-owned from then on). @@ -174,12 +178,14 @@ public function register(): void $this->app->scoped(ContextManager::class); $this->app->scoped(TenantResolver::class); $this->app->singleton(TenantSSOManager::class); + $this->app->singleton(MagicLinkManager::class); $this->commands([ InstallNeev::class, InstallUi::class, DownloadGeoLiteDb::class, CleanOldLoginAttempts::class, + CleanExpiredMagicLinks::class, CleanPendingMfaSetups::class, CreateTenantCommand::class, diff --git a/src/Services/MagicLink/MagicLinkManager.php b/src/Services/MagicLink/MagicLinkManager.php new file mode 100644 index 0000000..322b990 --- /dev/null +++ b/src/Services/MagicLink/MagicLinkManager.php @@ -0,0 +1,499 @@ + $context + * @return array ['url', 'token', 'channel', 'expires_at', 'expires_in', 'model'] + */ + public function forWeb(object $user, array $context = []): array + { + return $this->generate($user, 'web', $context); + } + + /** + * Generate a mobile-channel magic link. + * + * @param array $context + * @return array + */ + public function forMobile(object $user, array $context = []): array + { + return $this->generate($user, 'mobile', $context); + } + + /** + * Issue a new magic link. + * + * The channel is any key under config('neev.magic_link.channels') — host + * apps can add their own (e.g. 'desktop') without changing Neev. Unknown + * channels fall back to 'web'. + * + * Always invalidates the user's existing link(s) for this channel, then + * persists a fresh single-use token and fires MagicLinkGenerated. + * + * @param array $context + * @return array ['url', 'token', 'channel', 'expires_at', 'expires_in', 'model'] + * + * @throws MagicLinkBindingException When binding is enabled but the + * request carries no binding source. + * @throws MagicLinkUnverifiedException When the user's email is unverified + * and the unverified policy forbids it. + */ + public function generate(object $user, string $channel = 'web', array $context = []): array + { + $channel = $this->normalizeChannel($channel); + $context = $this->withRequest($context); + $request = $context['request'] ?? null; + + // Both checks run before invalidating: a refused send must not cost the + // user the link they already have. + $this->assertUserMayReceiveLink($user); + $metaData = $this->buildMetaData($request, $context); + + $this->invalidatePrevious($user->id, $channel); + + $plain = MagicLinkToken::generateToken(); + + $token = MagicLinkToken::create([ + 'user_id' => $user->id, + 'token' => MagicLinkToken::hashToken($plain), + 'channel' => $channel, + 'meta_data' => $metaData, + 'user_agent' => $request?->userAgent(), + 'created_ip' => $request?->ip(), + 'expires_at' => now()->addMinutes($this->expiryMinutes()), + ]); + + MagicLinkGenerated::dispatch($user, $token); + + return $this->linkPayload($token, $plain); + } + + /** + * Refuse to issue a link the recipient could never use. + * + * Without this the link is mailed, and every redemption of it fails as + * "invalid or expired" — a dead end the user cannot get out of. + * + * @throws MagicLinkUnverifiedException + */ + protected function assertUserMayReceiveLink(object $user): void + { + if (!$user->hasVerifiedEmail() && !$this->allowsUnverifiedUsers()) { + throw MagicLinkUnverifiedException::forEmail($user->email); + } + } + + /** + * Whether users with an unverified email may use magic links at all. + * + * When enabled, redeeming a link also marks the email verified — following + * the link is itself proof of control over the inbox. + */ + protected function allowsUnverifiedUsers(): bool + { + return (bool) config('neev.magic_link.allow_unverified_users', false); + } + + /** + * Delete the user's existing links for a channel (invalidate previous). + */ + protected function invalidatePrevious(int $userId, string $channel): void + { + MagicLinkToken::query() + ->where('user_id', $userId) + ->where('channel', $channel) + ->delete(); + } + + /** + * Number of minutes a generated link stays valid. + */ + protected function expiryMinutes(): int + { + return (int) config('neev.magic_link.expires_in', 10); + } + + /** + * Build the JSON-able metadata stored on a token (currently the binding + * fingerprint), or null when there's nothing to store. + * + * When binding is enabled this throws rather than returning null: a token + * stored without a fingerprint can never pass the redemption check, so + * failing here surfaces the misconfiguration at send time instead of + * locking the user out of a link that was dead when it was minted. + * + * @param array $context + * @return array|null + * + * @throws MagicLinkBindingException + */ + protected function buildMetaData(?Request $request, array $context): ?array + { + $fingerprint = $request ? $this->fingerprint($request, $context) : null; + + if ($fingerprint === null && $this->bindingEnabled()) { + throw new MagicLinkBindingException(); + } + + return $fingerprint !== null ? ['fingerprint' => $fingerprint] : null; + } + + /** + * Whether links are bound to the browser/device that requested them. + */ + protected function bindingEnabled(): bool + { + return (bool) config('neev.magic_link.bind_to_browser', false); + } + + /** + * Shape the array returned to callers after generating a link. + * + * @return array + */ + protected function linkPayload(MagicLinkToken $token, string $plain): array + { + return [ + 'url' => $this->buildChannelUrl($token->channel, ['token' => $plain, 'channel' => $token->channel]), + 'token' => $plain, + 'channel' => $token->channel, + 'expires_at' => $token->expires_at, + 'expires_in' => max(0, (int) ceil(($token->expires_at->getTimestamp() - now()->getTimestamp()) / 60)), + 'model' => $token, + ]; + } + + /** + * Validate a redemption request WITHOUT consuming the token. + * + * Safe to call from a GET handler / link preview: it never authenticates + * and never deletes the token. + * + * @param array $context + */ + public function validate(Request $request, array $context = []): MagicLinkResult + { + $result = $this->resolve($request, $context); + + if (!$result->isValid() && !$result->needsConfirmation()) { + MagicLinkRejected::dispatch($result); + } + + return $result; + } + + /** + * Validate AND consume the token (single-use), completing the redemption. + * + * On success the token row is deleted and MagicLinkConsumed is fired. + * + * @param array $context + */ + public function consume(Request $request, array $context = []): MagicLinkResult + { + $result = $this->resolve($request, $context); + + // A pending-confirmation token is eligible for consumption: this call + // IS the explicit confirmation step. + if (!$result->isValid() && !$result->needsConfirmation()) { + MagicLinkRejected::dispatch($result); + return $result; + } + + // Single-use: deleting the row prevents any replay. + $result->token?->delete(); + + $user = $result->user; + + // The click proved control of the inbox, so redeeming doubles as email + // verification. Only reachable when the unverified policy allows it — + // resolve() rejects unverified users otherwise. + if (!$user->hasVerifiedEmail()) { + $user->markEmailAsVerified(); + } + + $final = MagicLinkResult::valid($user, $result->channel, $result->token); + MagicLinkConsumed::dispatch($user, $final); + + return $final; + } + + /** + * Resolve the status of a redemption request without side effects or events. + * + * @param array $context + */ + protected function resolve(Request $request, array $context = []): MagicLinkResult + { + $plain = $this->extractToken($request, $context); + if ($plain === null || $plain === '') { + return MagicLinkResult::failure(MagicLinkResult::INVALID); + } + + $record = MagicLinkToken::findByToken($plain); + if (!$record) { + return MagicLinkResult::failure(MagicLinkResult::INVALID); + } + + $channel = $record->channel; + + if ($record->isExpired()) { + return MagicLinkResult::failure(MagicLinkResult::EXPIRED, $channel, $record); + } + + if ($this->bindingEnabled() + && !$this->bindingMatches($record->fingerprint(), $request, $context)) { + return MagicLinkResult::failure(MagicLinkResult::BINDING_MISMATCH, $channel, $record); + } + + $user = $this->resolveUser($record->user_id); + if (!$user) { + return MagicLinkResult::failure(MagicLinkResult::INVALID, $channel, $record); + } + + if (!$user->active) { + return MagicLinkResult::failure(MagicLinkResult::INACTIVE_USER, $channel, $record, $user); + } + + if (config('neev.magic_link.require_confirmation', false)) { + return MagicLinkResult::failure(MagicLinkResult::PENDING_CONFIRMATION, $channel, $record, $user); + } + + return MagicLinkResult::valid($user, $channel, $record); + } + + /** + * Resolve an eligible user, or null. + * + * An unverified user is only eligible when the unverified policy allows it, + * in which case consume() marks the email verified on redemption. + */ + protected function resolveUser(int|string|null $userId): ?object + { + if ($userId === null) { + return null; + } + + $user = User::model()->find($userId); + + if (!$user) { + return null; + } + + if (!$user->hasVerifiedEmail() && !$this->allowsUnverifiedUsers()) { + return null; + } + + return $user; + } + + + /** + * @param array $context + */ + protected function extractToken(Request $request, array $context = []): ?string + { + if (!empty($context['token'])) { + return is_scalar($context['token']) ? (string) $context['token'] : null; + } + + $token = $request->input('token'); + + // Client-controlled input: `?token[]=a&token[]=b` arrives as an array, + // and casting that to string raises "Array to string conversion" — a + // 500 where a plain rejection belongs. + return is_scalar($token) ? (string) $token : null; + } + + // ----------------------------------------------------------------- + // Browser / device binding + // ----------------------------------------------------------------- + + /** + * Capture a binding fingerprint for the current request/context. + * + * Precedence: an explicit binding value (context['binding'], the `binding` + * request field, or the X-Device-Id header) for session-less clients, then + * the web session id. Only the SHA-256 hash is ever stored. + * + * @param array $context + */ + protected function fingerprint(Request $request, array $context = []): ?string + { + $source = $this->bindingSource($request, $context); + + return $source === null ? null : hash('sha256', $source); + } + + /** + * @param array $context + */ + protected function bindingMatches(?string $storedFingerprint, Request $request, array $context = []): bool + { + // Fail closed. Safe because generation refuses to mint an unbound token + // while binding is on: a null fingerprint here means the link predates + // the setting, and those links should not bypass the check. + if ($storedFingerprint === null) { + return false; + } + + $current = $this->fingerprint($request, $context); + + return $current !== null && hash_equals($storedFingerprint, $current); + } + + /** + * @param array $context + */ + protected function bindingSource(Request $request, array $context = []): ?string + { + if (!empty($context['binding'])) { + return (string) $context['binding']; + } + + $fromRequest = $request->input('binding') ?? $request->header('X-Device-Id'); + if (!empty($fromRequest)) { + return (string) $fromRequest; + } + + if ($request->hasSession()) { + $session = $request->session(); + if (!$session->isStarted()) { + $session->start(); + } + $id = $session->getId(); + + return $id !== '' ? $id : null; + } + + return null; + } + + // ----------------------------------------------------------------- + // Channel URL building + // ----------------------------------------------------------------- + + /** + * @param array $params + */ + protected function buildChannelUrl(string $channel, array $params): string + { + $base = $this->channelBaseUrl($channel); + $separator = str_contains($base, '?') ? '&' : '?'; + + return $base . $separator . http_build_query($params); + } + + /** + * Build the base redemption URL for any configured channel. + * + * A channel whose config has a `scheme` or `universal_link` is treated as a + * deep link (mobile/desktop/...). Otherwise it is a web URL built from + * `base_url` + `path`. Works for any channel the host adds to config. + */ + protected function channelBaseUrl(string $channel): string + { + $config = (array) config("neev.magic_link.channels.{$channel}", []); + + // Deep-link channels (mobile, desktop, ...): scheme or universal link. + $deepLink = $config['scheme'] ?? $config['universal_link'] ?? null; + if (!empty($deepLink)) { + return rtrim((string) $deepLink, '/'); + } + + // Web-style channels: base URL + path. + $base = rtrim($this->webBaseUrl($config), '/'); + $path = (string) ($config['path'] ?? '/login-link'); + + return $base . '/' . ltrim($path, '/'); + } + + /** + * Host for a web-style channel link. + * + * In tenant mode a tenant is reached at its own host (subdomain or custom + * domain), and the token is stored tenant-scoped. A link must therefore be + * redeemed on the tenant's host — on any other host the tenant scope hides + * the token and redemption fails as "invalid or expired". The current + * request already arrived on that host, so it is the authoritative base; + * the static `base_url` config cannot represent more than one tenant. + * + * Falls back to the configured `base_url` (or app.url) in shared mode and + * whenever there is no request (CLI / queued generation). + * + * @param array $config + */ + protected function webBaseUrl(array $config): string + { + if (config('neev.tenant', false)) { + $request = $this->container->bound('request') + ? $this->container->make('request') + : null; + + if ($request instanceof Request && $request->getHost() !== '') { + return $request->getSchemeAndHttpHost(); + } + } + + return (string) ($config['base_url'] ?? config('app.url')); + } + + /** + * Resolve a requested channel to a valid configured channel, defaulting to + * 'web' when it is empty or not defined in config. + */ + protected function normalizeChannel(?string $channel): string + { + $channel = $channel ?: 'web'; + $channels = array_keys((array) config('neev.magic_link.channels', [])); + + return in_array($channel, $channels, true) ? $channel : 'web'; + } + + /** + * Default the current request into the context. + * + * @param array $context + * @return array + */ + protected function withRequest(array $context): array + { + if (!isset($context['request']) && $this->container->bound('request')) { + $context['request'] = $this->container->make('request'); + } + + return $context; + } +} diff --git a/src/Support/MagicLink/MagicLinkResult.php b/src/Support/MagicLink/MagicLinkResult.php new file mode 100644 index 0000000..f4e5c7c --- /dev/null +++ b/src/Support/MagicLink/MagicLinkResult.php @@ -0,0 +1,78 @@ + $meta + */ + public function __construct( + public readonly string $status, + public readonly ?object $user = null, + public readonly ?string $channel = null, + public readonly ?MagicLinkToken $token = null, + public readonly array $meta = [], + ) { + } + + public static function valid(object $user, ?string $channel = null, ?MagicLinkToken $token = null, array $meta = []): self + { + return new self(self::VALID, $user, $channel, $token, $meta); + } + + public static function failure(string $status, ?string $channel = null, ?MagicLinkToken $token = null, ?object $user = null, array $meta = []): self + { + return new self($status, $user, $channel, $token, $meta); + } + + public function isValid(): bool + { + return $this->status === self::VALID; + } + + public function needsConfirmation(): bool + { + return $this->status === self::PENDING_CONFIRMATION; + } + + /** + * @return array + */ + public function toArray(): array + { + return array_merge([ + 'status' => $this->status, + 'channel' => $this->channel, + ], $this->meta); + } +} diff --git a/stubs/blade/views/auth/confirm-login-link.blade.php b/stubs/blade/views/auth/confirm-login-link.blade.php new file mode 100644 index 0000000..01e6702 --- /dev/null +++ b/stubs/blade/views/auth/confirm-login-link.blade.php @@ -0,0 +1,29 @@ + + + + + + +
+ {{ __('Please confirm that you want to sign in. This extra step protects your account from automated email link scanners.') }} +
+ + + +
+ @csrf + + + + @if (!empty($channel)) + + @endif + +
+ + {{ __('Confirm and Sign In') }} + +
+
+
+
diff --git a/tests/Feature/Auth/CleanExpiredMagicLinksTest.php b/tests/Feature/Auth/CleanExpiredMagicLinksTest.php new file mode 100644 index 0000000..a26eccd --- /dev/null +++ b/tests/Feature/Auth/CleanExpiredMagicLinksTest.php @@ -0,0 +1,107 @@ +create([ + 'user_id' => $user->id, + 'tenant_id' => $tenantId, + 'token' => MagicLinkToken::hashToken($plain), + 'channel' => 'web', + 'expires_at' => $expiresAt, + ]); + } + + /** All surviving tokens, ignoring tenant scoping. */ + private function remaining() + { + return MagicLinkToken::withoutTenantScope()->get(); + } + + public function test_it_purges_expired_tokens_across_every_tenant(): void + { + config(['neev.tenant' => true]); + + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + $userA = User::factory()->create(['tenant_id' => $tenantA->id]); + $userB = User::factory()->create(['tenant_id' => $tenantB->id]); + $platformUser = User::factory()->create(); + + $this->makeToken($userA, 'tenant-a-expired', $tenantA->id, now()->subDay()); + $this->makeToken($userB, 'tenant-b-expired', $tenantB->id, now()->subMinute()); + $this->makeToken($platformUser, 'platform-expired', null, now()->subDay()); + + // Runs with no resolved tenant, exactly as it does from cron. + $this->artisan('neev:clean-magic-links') + ->expectsOutputToContain('Deleted 3 expired magic-link token(s).') + ->assertSuccessful(); + + $this->assertCount(0, $this->remaining(), 'Expired is expired — every tenant included.'); + } + + public function test_it_keeps_unexpired_tokens(): void + { + config(['neev.tenant' => true]); + + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $this->makeToken($user, 'tenant-expired', $tenant->id, now()->subDay()); + $this->makeToken($user, 'tenant-live', $tenant->id, now()->addHour()); + + $this->artisan('neev:clean-magic-links')->assertSuccessful(); + + $left = $this->remaining(); + + $this->assertCount(1, $left); + $this->assertSame( + MagicLinkToken::hashToken('tenant-live'), + $left->first()->token, + 'Only the unexpired token should survive.' + ); + } + + public function test_it_purges_expired_tokens_with_tenancy_disabled(): void + { + config(['neev.tenant' => false]); + + $user = User::factory()->create(); + + $this->makeToken($user, 'expired', null, now()->subDay()); + $this->makeToken($user, 'live', null, now()->addHour()); + + $this->artisan('neev:clean-magic-links') + ->expectsOutputToContain('Deleted 1 expired magic-link token(s).') + ->assertSuccessful(); + + $this->assertCount(1, $this->remaining()); + } + + public function test_it_reports_nothing_to_delete_on_a_clean_table(): void + { + $this->artisan('neev:clean-magic-links') + ->expectsOutputToContain('Deleted 0 expired magic-link token(s).') + ->assertSuccessful(); + } +} diff --git a/tests/Feature/Auth/MagicLinkTest.php b/tests/Feature/Auth/MagicLinkTest.php index e131435..85b47f1 100644 --- a/tests/Feature/Auth/MagicLinkTest.php +++ b/tests/Feature/Auth/MagicLinkTest.php @@ -3,10 +3,13 @@ namespace Ssntpl\Neev\Tests\Feature\Auth; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Facades\URL; +use Ssntpl\Neev\Exceptions\MagicLinkBindingException; use Ssntpl\Neev\Mail\LoginUsingLink; use Ssntpl\Neev\Models\User; +use Ssntpl\Neev\Services\MagicLink\MagicLinkManager; +use Ssntpl\Neev\Support\MagicLink\MagicLinkResult; use Ssntpl\Neev\Tests\TestCase; use Ssntpl\Neev\Tests\Traits\WithNeevConfig; @@ -20,6 +23,14 @@ private function createUser(array $state = []): User return User::factory()->create($state); } + /** + * Generate a magic link for the user and return its plain token. + */ + private function magicLinkToken(User $user): array + { + return app(MagicLinkManager::class)->forWeb($user); + } + // ----------------------------------------------------------------- // POST /neev/sendLoginLink // ----------------------------------------------------------------- @@ -61,20 +72,55 @@ public function test_send_login_link_returns_401_for_non_existent_email(): void } // ----------------------------------------------------------------- - // GET /neev/loginUsingLink + // GET /neev/loginUsingLink (open — must never consume by default) + // ----------------------------------------------------------------- + + public function test_get_returns_confirmation_required_without_consuming_the_token(): void + { + $user = $this->createUser(); + + $link = $this->magicLinkToken($user); + + $response = $this->getJson('/neev/loginUsingLink?token=' . $link['token']); + + $response->assertOk(); + $response->assertJson([ + 'auth_state' => 'confirmation_required', + 'channel' => 'web', + ]); + $response->assertJsonMissingPath('token'); + + // A mail-gateway prefetch must leave the link redeemable. + $this->assertDatabaseCount('magic_link_tokens', 1); + $this->postJson('/neev/loginUsingLink', ['token' => $link['token']])->assertOk(); + } + + public function test_get_consumes_the_token_when_confirmation_is_disabled(): void + { + config(['neev.magic_link.require_confirmation' => false]); + + $user = $this->createUser(); + + $link = $this->magicLinkToken($user); + + $response = $this->getJson('/neev/loginUsingLink?token=' . $link['token']); + + $response->assertOk(); + $response->assertJson(['auth_state' => 'authenticated']); + $this->assertDatabaseCount('magic_link_tokens', 0); + } + + // ----------------------------------------------------------------- + // POST /neev/loginUsingLink (confirm) // ----------------------------------------------------------------- - public function test_login_using_link_with_valid_signature_returns_token(): void + public function test_login_using_link_with_valid_token_returns_token(): void { $user = $this->createUser(); - $signedUrl = URL::temporarySignedRoute( - 'loginUsingLink', - now()->addMinutes(60), - ['id' => $user->id] - ); + $link = $this->magicLinkToken($user); - $response = $this->getJson($signedUrl); + $response = $this->postJson('/neev/loginUsingLink', ['token' => $link['token']]); $response->assertOk(); $response->assertJson([ @@ -90,13 +136,9 @@ public function test_login_using_link_returns_email_verified_status(): void $user = $this->createUser(); // Email is verified by default from factory - $signedUrl = URL::temporarySignedRoute( - 'loginUsingLink', - now()->addMinutes(60), - ['id' => $user->id] - ); + $link = $this->magicLinkToken($user); - $response = $this->getJson($signedUrl); + $response = $this->postJson('/neev/loginUsingLink', ['token' => $link['token']]); $response->assertOk(); $response->assertJson([ @@ -105,14 +147,25 @@ public function test_login_using_link_returns_email_verified_status(): void ]); } - public function test_login_using_link_with_invalid_signature_returns_403(): void + public function test_login_using_link_with_invalid_token_returns_403(): void { - $user = $this->createUser(); + $this->createUser(); + + $response = $this->postJson('/neev/loginUsingLink', ['token' => 'not-a-real-token']); + + $response->assertStatus(403); + $response->assertJson([ + 'message' => 'Invalid or expired verification link.', + ]); + } - // Build a URL without a valid signature - $url = route('loginUsingLink', ['id' => $user->id]) . '?signature=invalidsignature'; + public function test_login_using_link_with_a_non_scalar_token_is_rejected_not_fatal(): void + { + $this->createUser(); - $response = $this->getJson($url); + // `token` is client-controlled: an array must be refused like any other + // bad token, not raise "Array to string conversion". + $response = $this->postJson('/neev/loginUsingLink', ['token' => ['a', 'b']]); $response->assertStatus(403); $response->assertJson([ @@ -120,17 +173,32 @@ public function test_login_using_link_with_invalid_signature_returns_403(): void ]); } - public function test_login_using_link_with_expired_signature_returns_403(): void + public function test_validate_with_a_non_scalar_token_is_rejected_not_fatal(): void + { + $response = $this->postJson('/neev/loginUsingLink/validate', ['token' => ['a', 'b']]); + + $response->assertOk(); + $response->assertJson([ + 'status' => MagicLinkResult::INVALID, + 'valid' => false, + ]); + } + + public function test_login_using_link_without_a_token_returns_403(): void + { + $this->createUser(); + + $this->postJson('/neev/loginUsingLink', [])->assertStatus(403); + } + + public function test_login_using_link_with_expired_token_returns_403(): void { $user = $this->createUser(); - $signedUrl = URL::temporarySignedRoute( - 'loginUsingLink', - now()->subMinutes(1), // Already expired - ['id' => $user->id] - ); + $link = $this->magicLinkToken($user); + $link['model']->forceFill(['expires_at' => now()->subMinute()])->save(); - $response = $this->getJson($signedUrl); + $response = $this->postJson('/neev/loginUsingLink', ['token' => $link['token']]); $response->assertStatus(403); $response->assertJson([ @@ -138,17 +206,36 @@ public function test_login_using_link_with_expired_signature_returns_403(): void ]); } + public function test_login_using_link_is_single_use(): void + { + $user = $this->createUser(); + + $link = $this->magicLinkToken($user); + + $this->postJson('/neev/loginUsingLink', ['token' => $link['token']])->assertOk(); + + // Replay: the token row was deleted on consumption. + $this->postJson('/neev/loginUsingLink', ['token' => $link['token']])->assertStatus(403); + } + + public function test_generating_a_new_link_invalidates_the_previous_one(): void + { + $user = $this->createUser(); + + $old = $this->magicLinkToken($user); + $new = $this->magicLinkToken($user); + + $this->postJson('/neev/loginUsingLink', ['token' => $old['token']])->assertStatus(403); + $this->postJson('/neev/loginUsingLink', ['token' => $new['token']])->assertOk(); + } + public function test_login_using_link_creates_login_attempt(): void { $user = $this->createUser(); - $signedUrl = URL::temporarySignedRoute( - 'loginUsingLink', - now()->addMinutes(60), - ['id' => $user->id] - ); + $link = $this->magicLinkToken($user); - $this->getJson($signedUrl); + $this->postJson('/neev/loginUsingLink', ['token' => $link['token']]); $this->assertDatabaseHas('login_attempts', [ 'user_id' => $user->id, @@ -161,13 +248,9 @@ public function test_login_using_link_creates_access_token(): void { $user = $this->createUser(); - $signedUrl = URL::temporarySignedRoute( - 'loginUsingLink', - now()->addMinutes(60), - ['id' => $user->id] - ); + $link = $this->magicLinkToken($user); - $this->getJson($signedUrl); + $this->postJson('/neev/loginUsingLink', ['token' => $link['token']]); $this->assertDatabaseHas('access_tokens', [ 'user_id' => $user->id, @@ -179,15 +262,125 @@ public function test_login_using_link_for_inactive_user_returns_validation_error { $user = $this->createUser(['active' => false]); - $signedUrl = URL::temporarySignedRoute( - 'loginUsingLink', - now()->addMinutes(60), - ['id' => $user->id] - ); + $link = $this->magicLinkToken($user); - $response = $this->getJson($signedUrl); + $response = $this->postJson('/neev/loginUsingLink', ['token' => $link['token']]); $response->assertUnprocessable(); $response->assertJsonValidationErrors(['email']); } + + // ----------------------------------------------------------------- + // GET|POST /neev/loginUsingLink/validate + // ----------------------------------------------------------------- + + public function test_validate_reports_pending_confirmation_without_consuming(): void + { + $user = $this->createUser(); + + $link = $this->magicLinkToken($user); + + $response = $this->getJson('/neev/loginUsingLink/validate?token=' . $link['token']); + + $response->assertOk(); + $response->assertJson([ + 'status' => MagicLinkResult::PENDING_CONFIRMATION, + 'valid' => false, + 'requires_confirmation' => true, + 'channel' => 'web', + 'email_verified' => true, + ]); + + $this->assertDatabaseCount('magic_link_tokens', 1); + } + + public function test_validate_reports_an_unknown_token_as_invalid(): void + { + $response = $this->getJson('/neev/loginUsingLink/validate?token=not-a-real-token'); + + $response->assertOk(); + $response->assertJson([ + 'status' => MagicLinkResult::INVALID, + 'valid' => false, + 'requires_confirmation' => false, + ]); + } + + // ----------------------------------------------------------------- + // Browser binding + // ----------------------------------------------------------------- + + public function test_binding_enabled_refuses_to_generate_without_a_binding_source(): void + { + config(['neev.magic_link.bind_to_browser' => true]); + + $user = $this->createUser(); + + // A session-less API request with no X-Device-Id has nothing to bind to. + // Minting here would produce a link that could never be redeemed. + $this->expectException(MagicLinkBindingException::class); + + app(MagicLinkManager::class)->forWeb($user, ['request' => Request::create('/neev/sendLoginLink', 'POST')]); + } + + public function test_refusing_to_generate_leaves_the_existing_link_intact(): void + { + $user = $this->createUser(); + $existing = $this->magicLinkToken($user); + + config(['neev.magic_link.bind_to_browser' => true]); + + try { + app(MagicLinkManager::class)->forWeb($user, ['request' => Request::create('/neev/sendLoginLink', 'POST')]); + $this->fail('Expected generation to fail without a binding source.'); + } catch (MagicLinkBindingException) { + // Expected. + } + + config(['neev.magic_link.bind_to_browser' => false]); + + // The user's existing link must survive a failed generation attempt. + $this->postJson('/neev/loginUsingLink', ['token' => $existing['token']])->assertOk(); + } + + public function test_bound_link_is_redeemable_by_the_same_device(): void + { + config(['neev.magic_link.bind_to_browser' => true]); + + $user = $this->createUser(); + + $request = Request::create('/neev/sendLoginLink', 'POST'); + $request->headers->set('X-Device-Id', 'device-abc'); + $link = app(MagicLinkManager::class)->forWeb($user, ['request' => $request]); + + $this->withHeader('X-Device-Id', 'device-abc') + ->postJson('/neev/loginUsingLink', ['token' => $link['token']]) + ->assertOk(); + } + + public function test_bound_link_is_rejected_from_a_different_device(): void + { + config(['neev.magic_link.bind_to_browser' => true]); + + $user = $this->createUser(); + + $request = Request::create('/neev/sendLoginLink', 'POST'); + $request->headers->set('X-Device-Id', 'device-abc'); + $link = app(MagicLinkManager::class)->forWeb($user, ['request' => $request]); + + $this->withHeader('X-Device-Id', 'device-xyz') + ->postJson('/neev/loginUsingLink', ['token' => $link['token']]) + ->assertStatus(403); + } + + public function test_api_send_without_a_binding_source_fails_cleanly_not_with_a_500(): void + { + config(['neev.magic_link.bind_to_browser' => true]); + + $user = $this->createUser(); + + $this->postJson('/neev/sendLoginLink', ['email' => $user->email]) + ->assertStatus(422) + ->assertJson(['message' => 'Unable to send a login link for this request.']); + } } diff --git a/tests/Feature/Auth/MagicLinkUnverifiedUserTest.php b/tests/Feature/Auth/MagicLinkUnverifiedUserTest.php new file mode 100644 index 0000000..26daa43 --- /dev/null +++ b/tests/Feature/Auth/MagicLinkUnverifiedUserTest.php @@ -0,0 +1,210 @@ +create(['email_verified_at' => null]); + } + + // ----------------------------------------------------------------- + // Default: refuse to send + // ----------------------------------------------------------------- + + public function test_api_send_to_unverified_user_is_refused(): void + { + Mail::fake(); + + $user = $this->unverifiedUser(); + + $response = $this->postJson('/neev/sendLoginLink', ['email' => $user->email]); + + $response->assertStatus(403); + $response->assertJson(['message' => 'Please verify your email address before using a login link.']); + + // No dead link is minted, and nothing is mailed. + Mail::assertNothingSent(); + $this->assertDatabaseCount('magic_link_tokens', 0); + } + + public function test_blade_send_to_unverified_user_shows_an_error(): void + { + Mail::fake(); + + $user = $this->unverifiedUser(); + + $this->post('/login/link', ['email' => $user->email]) + ->assertSessionHasErrors('message'); + + Mail::assertNothingSent(); + $this->assertDatabaseCount('magic_link_tokens', 0); + } + + public function test_generate_for_unverified_user_throws(): void + { + $this->expectException(MagicLinkUnverifiedException::class); + + $this->manager()->forWeb($this->unverifiedUser()); + } + + public function test_refusing_to_send_leaves_an_existing_link_intact(): void + { + $user = User::factory()->create(); + $existing = $this->manager()->forWeb($user); + + // The address loses verification after a link was already issued. + $user->forceFill(['email_verified_at' => null])->save(); + + try { + $this->manager()->forWeb($user); + $this->fail('Expected the send to be refused.'); + } catch (MagicLinkUnverifiedException) { + // Expected. + } + + // A refused send must not cost the user the link already in their inbox. + $this->assertDatabaseCount('magic_link_tokens', 1); + $this->assertNotNull( + \Ssntpl\Neev\Models\MagicLinkToken::findByToken($existing['token']), + 'The existing link must survive a refused send.' + ); + } + + public function test_verified_users_are_unaffected(): void + { + Mail::fake(); + + $user = User::factory()->create(); + + $this->postJson('/neev/sendLoginLink', ['email' => $user->email])->assertOk(); + + Mail::assertSent(LoginUsingLink::class); + } + + // ----------------------------------------------------------------- + // allow_unverified_users => true + // ----------------------------------------------------------------- + + public function test_when_allowed_the_link_is_sent(): void + { + config(['neev.magic_link.allow_unverified_users' => true]); + Mail::fake(); + + $user = $this->unverifiedUser(); + + $this->postJson('/neev/sendLoginLink', ['email' => $user->email])->assertOk(); + + Mail::assertSent(LoginUsingLink::class); + $this->assertDatabaseCount('magic_link_tokens', 1); + } + + public function test_when_allowed_redeeming_logs_in_and_verifies_the_email(): void + { + config(['neev.magic_link.allow_unverified_users' => true]); + + $user = $this->unverifiedUser(); + $link = $this->manager()->forWeb($user); + + $response = $this->postJson('/neev/loginUsingLink', ['token' => $link['token']]); + + $response->assertOk(); + $response->assertJson([ + 'auth_state' => 'authenticated', + 'email_verified' => true, + ]); + + $this->assertNotNull($user->fresh()->email_verified_at, 'Following the link proves inbox control.'); + } + + public function test_when_allowed_redeeming_fires_the_email_verified_event(): void + { + config(['neev.magic_link.allow_unverified_users' => true]); + + $fired = []; + $this->app['events']->listen(EmailVerified::class, function ($event) use (&$fired) { + $fired[] = $event; + }); + + $user = $this->unverifiedUser(); + $link = $this->manager()->forWeb($user); + + $this->postJson('/neev/loginUsingLink', ['token' => $link['token']])->assertOk(); + + $this->assertCount(1, $fired, 'EmailVerified should fire exactly once.'); + } + + public function test_when_allowed_the_blade_flow_verifies_and_logs_in(): void + { + config(['neev.magic_link.allow_unverified_users' => true]); + + $user = $this->unverifiedUser(); + $link = $this->manager()->forWeb($user); + + $this->post('/login-link/verify', ['token' => $link['token']]) + ->assertRedirect(config('neev.home')); + + $this->assertAuthenticatedAs($user); + $this->assertNotNull($user->fresh()->email_verified_at); + } + + public function test_an_already_verified_user_is_not_reverified(): void + { + $fired = []; + $this->app['events']->listen(EmailVerified::class, function ($event) use (&$fired) { + $fired[] = $event; + }); + + $user = User::factory()->create(); + $verifiedAt = $user->email_verified_at; + + $link = $this->manager()->forWeb($user); + $this->postJson('/neev/loginUsingLink', ['token' => $link['token']])->assertOk(); + + $this->assertCount(0, $fired); + $this->assertEquals($verifiedAt, $user->fresh()->email_verified_at); + } + + // ----------------------------------------------------------------- + // Send input validation + // ----------------------------------------------------------------- + + public function test_send_without_an_email_returns_a_validation_error(): void + { + $this->postJson('/neev/sendLoginLink', []) + ->assertUnprocessable() + ->assertJsonValidationErrors(['email']); + } + + public function test_send_with_a_non_string_email_returns_a_validation_error(): void + { + $this->postJson('/neev/sendLoginLink', ['email' => ['a@example.com']]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['email']); + } +} diff --git a/tests/Feature/Auth/MagicLinkWebTest.php b/tests/Feature/Auth/MagicLinkWebTest.php new file mode 100644 index 0000000..744e271 --- /dev/null +++ b/tests/Feature/Auth/MagicLinkWebTest.php @@ -0,0 +1,237 @@ + open -> confirm. + * + * The API flow is covered by MagicLinkTest; these tests exist because the web + * flow is a separate controller, route and view, and nothing else exercises it. + */ +class MagicLinkWebTest extends TestCase +{ + use RefreshDatabase; + + private function createUser(array $state = []): User + { + return User::factory()->create($state); + } + + private function magicLinkToken(User $user): array + { + return app(MagicLinkManager::class)->forWeb($user); + } + + // ----------------------------------------------------------------- + // POST /login/link (send) + // ----------------------------------------------------------------- + + public function test_send_login_link_mails_a_working_verify_url(): void + { + Mail::fake(); + + $user = $this->createUser(); + + $response = $this->post('/login/link', ['email' => $user->email]); + + $response->assertSessionHas('status', 'Login link has been sent.'); + $this->assertDatabaseCount('magic_link_tokens', 1); + + Mail::assertSent(LoginUsingLink::class, function (LoginUsingLink $mail) use ($user) { + return $mail->hasTo($user->email) + && str_contains($mail->url, '/login-link/verify') + && str_contains($mail->url, 'token='); + }); + } + + public function test_send_login_link_with_unknown_email_reports_an_error(): void + { + Mail::fake(); + + $response = $this->post('/login/link', ['email' => 'nobody@example.com']); + + $response->assertSessionHasErrors('message'); + Mail::assertNothingSent(); + } + + // ----------------------------------------------------------------- + // GET /login-link/verify (open — must never consume) + // ----------------------------------------------------------------- + + public function test_get_renders_the_confirmation_page_without_consuming_the_token(): void + { + $user = $this->createUser(); + $link = $this->magicLinkToken($user); + + $response = $this->get('/login-link/verify?token=' . $link['token']); + + $response->assertOk(); + $response->assertSee('Please confirm that you want to sign in.', escape: false); + $response->assertSee($link['token'], escape: false); + + // The whole point: an email scanner's prefetch must leave the link usable. + $this->assertGuest(); + $this->assertDatabaseCount('magic_link_tokens', 1); + } + + public function test_scanner_prefetch_followed_by_a_real_click_still_logs_the_user_in(): void + { + $user = $this->createUser(); + $link = $this->magicLinkToken($user); + + // Mail gateway prefetches the link... + $this->get('/login-link/verify?token=' . $link['token'])->assertOk(); + + // ...and the human who follows it is still able to sign in. + $response = $this->post('/login-link/verify', ['token' => $link['token']]); + + $response->assertRedirect(config('neev.home')); + $this->assertAuthenticatedAs($user); + } + + public function test_get_consumes_the_token_when_confirmation_is_disabled(): void + { + config(['neev.magic_link.require_confirmation' => false]); + + $user = $this->createUser(); + $link = $this->magicLinkToken($user); + + $response = $this->get('/login-link/verify?token=' . $link['token']); + + $response->assertRedirect(config('neev.home')); + $this->assertAuthenticatedAs($user); + $this->assertDatabaseCount('magic_link_tokens', 0); + } + + // ----------------------------------------------------------------- + // POST /login-link/verify (confirm) + // ----------------------------------------------------------------- + + public function test_confirming_logs_in_and_consumes_the_token(): void + { + $user = $this->createUser(); + $link = $this->magicLinkToken($user); + + $response = $this->post('/login-link/verify', ['token' => $link['token']]); + + $response->assertRedirect(config('neev.home')); + $this->assertAuthenticatedAs($user); + $this->assertDatabaseCount('magic_link_tokens', 0); + + $this->assertDatabaseHas('login_attempts', [ + 'user_id' => $user->id, + 'method' => 'magic auth', + 'is_success' => true, + ]); + } + + public function test_confirming_twice_fails_the_second_time(): void + { + $user = $this->createUser(); + $link = $this->magicLinkToken($user); + + $this->post('/login-link/verify', ['token' => $link['token']]) + ->assertRedirect(config('neev.home')); + + $this->post('/logout'); + + $this->post('/login-link/verify', ['token' => $link['token']]) + ->assertRedirect(route('login')) + ->assertSessionHasErrors('message'); + } + + public function test_expired_token_is_rejected(): void + { + $user = $this->createUser(); + $link = $this->magicLinkToken($user); + $link['model']->forceFill(['expires_at' => now()->subMinute()])->save(); + + $response = $this->post('/login-link/verify', ['token' => $link['token']]); + + $response->assertRedirect(route('login')); + $response->assertSessionHasErrors('message'); + $this->assertGuest(); + } + + public function test_unknown_token_is_rejected(): void + { + $this->createUser(); + + $response = $this->post('/login-link/verify', ['token' => 'not-a-real-token']); + + $response->assertRedirect(route('login')); + $response->assertSessionHasErrors('message'); + $this->assertGuest(); + } + + public function test_inactive_user_cannot_redeem_a_link(): void + { + $user = $this->createUser(['active' => false]); + $link = $this->magicLinkToken($user); + + $response = $this->post('/login-link/verify', ['token' => $link['token']]); + + $response->assertRedirect(route('login')); + $response->assertSessionHasErrors('message'); + $this->assertGuest(); + } + + public function test_generating_a_new_link_invalidates_the_previous_one(): void + { + $user = $this->createUser(); + + $old = $this->magicLinkToken($user); + $new = $this->magicLinkToken($user); + + $this->post('/login-link/verify', ['token' => $old['token']]) + ->assertSessionHasErrors('message'); + $this->assertGuest(); + + $this->post('/login-link/verify', ['token' => $new['token']]) + ->assertRedirect(config('neev.home')); + $this->assertAuthenticatedAs($user); + } + + public function test_an_already_authenticated_user_is_sent_home(): void + { + $user = $this->createUser(); + $link = $this->magicLinkToken($user); + + $response = $this->actingAs($user)->get('/login-link/verify?token=' . $link['token']); + + $response->assertRedirect(config('neev.home')); + + // The link is untouched — it was never redeemed. + $this->assertDatabaseCount('magic_link_tokens', 1); + } + + public function test_the_legacy_signed_url_route_is_gone(): void + { + $user = $this->createUser(); + + $this->get('/login/' . $user->id)->assertNotFound(); + } + + // ----------------------------------------------------------------- + // Token storage + // ----------------------------------------------------------------- + + public function test_only_the_token_hash_is_persisted(): void + { + $user = $this->createUser(); + $link = $this->magicLinkToken($user); + + $this->assertDatabaseMissing('magic_link_tokens', ['token' => $link['token']]); + $this->assertDatabaseHas('magic_link_tokens', [ + 'token' => MagicLinkToken::hashToken($link['token']), + ]); + } +} diff --git a/tests/Feature/Auth/SpaCookieLoginTest.php b/tests/Feature/Auth/SpaCookieLoginTest.php index 44a6150..ddd7228 100644 --- a/tests/Feature/Auth/SpaCookieLoginTest.php +++ b/tests/Feature/Auth/SpaCookieLoginTest.php @@ -3,8 +3,8 @@ namespace Ssntpl\Neev\Tests\Feature\Auth; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\URL; use Ssntpl\Neev\Models\User; +use Ssntpl\Neev\Services\MagicLink\MagicLinkManager; use Ssntpl\Neev\Services\SpaCsrfToken; use Ssntpl\Neev\Tests\TestCase; @@ -85,9 +85,12 @@ public function test_spa_registration_sets_cookie_and_omits_token(): void public function test_spa_magic_link_login_sets_cookie(): void { $user = User::factory()->create(['email_verified_at' => now()]); - $url = URL::temporarySignedRoute('loginUsingLink', now()->addMinutes(60), ['id' => $user->id]); + $link = app(MagicLinkManager::class)->forWeb($user); - $response = $this->withHeader('Origin', 'https://app.example.com')->getJson($url); + // POST is the confirmation step that actually consumes the link; a GET + // only validates it, so no session is established and no cookie issued. + $response = $this->withHeader('Origin', 'https://app.example.com') + ->postJson('/neev/loginUsingLink', ['token' => $link['token']]); $response->assertOk(); $response->assertJsonMissingPath('token');