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

Filter by extension

Filter by extension

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

Expand Down
65 changes: 65 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 65 additions & 1 deletion config/neev.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions database/factories/MagicLinkTokenFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace Ssntpl\Neev\Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;
use Ssntpl\Neev\Models\MagicLinkToken;

class MagicLinkTokenFactory extends Factory
{
protected $model = MagicLinkToken::class;

public function definition(): array
{
return [
'user_id' => 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)]]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class () extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('magic_link_tokens', function (Blueprint $table) {
$table->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');
}
};
3 changes: 3 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading