preflow / auth
Preflow auth — pluggable guards, session auth, API tokens, password hashing
Requires
- php: >=8.4
- preflow/core: ^0.21 || @dev
- preflow/data: ^0.21 || @dev
Requires (Dev)
- nyholm/psr7: ^1.8
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.33.0
- v0.32.0
- v0.31.0
- v0.30.0
- v0.29.0
- v0.28.0
- v0.27.1
- v0.27.0
- v0.26.0
- v0.25.1
- v0.25.0
- v0.24.1
- v0.24.0
- v0.23.0
- v0.22.1
- v0.22.0
- v0.21.0
- v0.20.0
- v0.19.2
- v0.19.1
- v0.19.0
- v0.18.1
- v0.18.0
- v0.17.2
- v0.17.1
- v0.17.0
- v0.16.2
- v0.16.1
- v0.16.0
- v0.15.0
- v0.14.2
- v0.14.1
- v0.14.0
- v0.13.1
- v0.13.0
- v0.12.0
- v0.11.4
- v0.11.3
- v0.11.2
- v0.11.1
- v0.11.0
- v0.10.4
- v0.10.3
- v0.10.2
- v0.10.1
- v0.10.0
- v0.9.1
- v0.9.0
- v0.8.0
This package is auto-updated.
Last update: 2026-10-25 14:12:41 UTC
README
Pluggable authentication for Preflow applications. Session-based login, API token guards, password hashing, and PSR-15 middleware.
Installation
composer require preflow/auth
Requires preflow/core and preflow/data.
What it does
- Pluggable guard system — session and bearer token guards ship as defaults, implement
GuardInterfacefor custom auth - Session management with CSRF protection (both in
preflow/core) - Password hashing via
password_hash()/password_verify()with transparent rehash support - PSR-15 middleware for route-level auth (
AuthMiddleware) and guest-only pages (GuestMiddleware) - Template functions:
auth_user(),auth_check(),csrf_token(),csrf_field(),flash() - Auto-discovered by
Application::boot()whenconfig/auth.phpexists
Configuration
config/auth.php:
return [ 'default_guard' => 'session', 'guards' => [ 'session' => [ 'class' => Preflow\Auth\SessionGuard::class, 'provider' => 'data_manager', ], 'token' => [ 'class' => Preflow\Auth\TokenGuard::class, 'provider' => 'data_manager', ], ], 'providers' => [ 'data_manager' => [ 'class' => Preflow\Auth\DataManagerUserProvider::class, 'model' => App\Models\User::class, ], ], // Any other provider class is built from the container with its entry as `config`, // e.g. Preflow\Folio\Users\RecordUserProvider (members stored as a Folio type). // `'active' => 'active'` there names a boolean field: a record with it off never resolves. // A chain tries the named providers in order, first hit wins (no lock-out while moving // from .env users to records; see "User providers" below). A guard // uses it as `'provider' => 'chain'`. Once a staff record exists for an e-mail, that record // decides: disabled means locked out, and the config password for that e-mail no longer works // (a provider implementing ClaimsCredentials, like RecordUserProvider, stops the chain for // credentials it knows). // 'staff' => ['class' => RecordUserProvider::class, 'type' => 'staff', 'active' => 'active'], // 'config' => ['class' => ConfigUserProvider::class, 'users' => [...]], // 'chain' => ['class' => ChainUserProvider::class, 'providers' => ['staff', 'config']], 'password_hasher' => Preflow\Auth\NativePasswordHasher::class, 'session' => [ 'lifetime' => 7200, 'cookie' => 'preflow_session', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax', ], ];
User model
Implement the Authenticatable interface. Use AuthenticatableTrait for the common case:
use Preflow\Auth\Authenticatable; use Preflow\Auth\AuthenticatableTrait; use Preflow\Data\Model; use Preflow\Data\Attributes\{Entity, Id, Field}; use Preflow\Data\Transform\JsonTransformer; #[Entity(table: 'users', storage: 'default')] final class User extends Model implements Authenticatable { use AuthenticatableTrait; #[Id] public string $uuid = ''; #[Field(searchable: true)] public string $email = ''; #[Field] public string $passwordHash = ''; #[Field(transform: JsonTransformer::class)] public array $roles = []; #[Field] public ?string $createdAt = null; }
The trait assumes $uuid, $passwordHash, and $roles properties. Override methods if your schema differs.
API
Guards
// Resolve from container (after boot) $auth = $container->get(AuthManager::class); $guard = $auth->guard(); // default guard $guard = $auth->guard('token'); // named guard $user = $guard->user($request); // resolve user from request $guard->login($user, $request); // establish session $guard->logout($request); // invalidate session $guard->validate(['email' => $email, 'password' => $password]); // check credentials
SessionGuard
Stores user ID in session key _auth_user_id. Regenerates session on login (session fixation prevention). Invalidates session on logout.
TokenGuard
Reads Authorization: Bearer <token> header. Looks up SHA-256 hashed token in user_tokens table. Stateless — login() and logout() are no-ops.
// Create a token $plain = PersonalAccessToken::generatePlainToken(); $token = new PersonalAccessToken(); $token->uuid = bin2hex(random_bytes(16)); $token->tokenHash = PersonalAccessToken::hashToken($plain); $token->userId = $user->getAuthId(); $token->name = 'api-key'; $dm->save($token); // Return $plain to the user (only shown once)
User providers
ConfigUserProvider: users fromconfig/auth.php(users, usually one admin from.env).entries()returns the configured entries (the staff import reads them).DataManagerUserProvider: an entity class (model).RecordUserProvider(preflow/folio): users stored as a Folio type (type). With'active' => 'active'it reads that boolean field. A record whose field is off (false,0,'0','','false'or null) never resolves, by id or by credentials. Its sessions end on the next request. Without the key nothing changes.ChainUserProvider('providers' => ['staff', 'config']): tries the named providers in order, and the first hit wins. This lets a site move from an.envadmin to staff records without a lock-out window. Chains are built after the other providers, so they may name lazy providers and earlier chains. A name that isn't defined throws at boot.providers()returns the members.ClaimsCredentials(claims(array $credentials): bool), an optional interface: a provider that claims the credentials decides for them. The chain returns that provider's answer, even when it is null, and does not try the next one.RecordUserProviderclaims every e-mail it holds a record for, active or not. So once a staff record exists for an address, the record decides: a disabled record is locked out, and the config password for that address no longer works. ALazyUserProviderpasses the claim through (false while it is unavailable);inner()returns the built provider or null.
Remember me
A session guard gets a remember-me cookie when its config/auth.php entry has a remember
key (it needs preflow/data; without one there is no cookie):
'guards' => ['members' => [ 'class' => Preflow\Auth\SessionGuard::class, 'provider' => 'members', 'remember' => ['cookie' => 'remember_members', 'days' => 30, 'path' => '/'], ]],
$guard->login($user, $request, remember: true); // also queues the cookie
- The cookie is
{selector}:{validator}, 16 and 32 random bytes. Only the SHA-256 hash of the validator is stored (tableauth_remember, on theauthstorage alias whenconfig/data.phphas one, else the default). A site whose default storage is JSON but which migrates the auth tables into SQLite needs'auth' => 'sqlite'underaliasesinconfig/data.php; the same alias holds the tokens (auth_tokens). It isHttpOnly,SameSite=Lax, andSecurewhenAPP_SECUREis set. - When a request has a session but nobody is signed in on the guard,
ResolveUserMiddlewaresigns the user in from the cookie. Every session guard does this, so a member stays remembered beside a signed-in staff user. Pages without a session never get aSet-Cookie. - Each use rotates the validator under the same selector; the expiry stays.
- Theft detection. A wrong validator for a known selector means an old value was replayed after its owner used it: all the user's remember rows for the guard are deleted.
- Grace window. The previous validator stays valid for 60 seconds after a rotation, for parallel requests carrying one value; it does not rotate again, and a lost rotation keeps the member signed in.
logout()deletes the row and clears the cookie. Cookies reach error responses through the coreCookieQueue.- On SQL one conditional
UPDATEdecides a rotation, so two uses of one value never both rotate; on the JSON driver a file lock instorage/locksdoes.RememberMe::revokeAll()(e.g. after a password change) andprune()are also available.
Ending a user's other sessions
A user that implements Preflow\Auth\HasSessionEpoch (getSessionEpoch(): string) carries
an epoch. SessionGuard::login() keeps it in the session ({key}:epoch); user() signs a
session out (this guard only) once the user's epoch differs. Bump the epoch on a password
change or reset and sign the current request in again: every other session ends, this one
stays. A session without a stored epoch counts as "0". Revoke the remember rows too
(RememberMe::revokeAll()), or a cookie signs the other browsers back in.
Tokens
Preflow\Auth\Tokens\Tokens issues single-use, expiring tokens for links sent by email
(address confirmation, password reset). It is bound when preflow/data is configured; the
table auth_tokens is created by preflow migrate.
$tokens = $container->get(Preflow\Auth\Tokens\Tokens::class); $token = $tokens->issue('reset', (string) $user->getAuthId(), ttlSeconds: 3600); // put it in the link $subject = $tokens->consume($token, 'reset'); // the subject once, then null $tokens->revokeAll('reset', $subject); // e.g. after the password changed $tokens->prune(); // expired ones
- A token is 32 random bytes, base64url; only its SHA-256 hash is stored, as the row id.
consume()spends a token at most once: on SQL oneDELETEdecides by its affected rows, on the JSON driver a file lock does. An unknown, expired or wrong-purpose token returnsnulland nothing is deleted.- Tokens are never in the TypeCatalog and never exported.
Password hashing
$hasher = $container->get(PasswordHasherInterface::class); $hash = $hasher->hash('secret'); $hasher->verify('secret', $hash); // true $hasher->needsRehash($hash); // false (current algorithm)
Middleware
Protect routes with #[Middleware] attributes:
use Preflow\Routing\Attributes\{Route, Get, Middleware}; use Preflow\Auth\Http\AuthMiddleware; #[Route('/dashboard')] #[Middleware(AuthMiddleware::class)] final class DashboardController { #[Get('/')] public function index(ServerRequestInterface $request): ResponseInterface { $user = $request->getAttribute(Authenticatable::class); // ... } }
GuestMiddleware redirects authenticated users away (for login/register pages).
Templates
{% if auth_check() %}
Welcome, {{ auth_user().email }}
<form method="post" action="/logout">
{{ csrf_field() }}
<button type="submit">Logout</button>
</form>
{% endif %}
{% set error = flash('error') %}
{% if error %}
<p>{{ error }}</p>
{% endif %}
CSRF helpers
Breaking change in v0.11:
csrf_token()now returns the raw token string. Usecsrf_field()for forms.
| Function | Returns |
|---|---|
csrf_token() |
Raw token string — useful for fetch() headers or meta tags |
csrf_field() |
Complete <input type="hidden" name="_token" value="..."> element |
{# In a form #} {{ csrf_field() }} {# In a fetch() call #} <meta name="csrf-token" content="{{ csrf_token() }}">
Any template that previously used {{ csrf_token()|raw }} must be updated to {{ csrf_field() }}.
Permissions
Code asks one question, the same way everywhere:
$access->can('page.publish'); // Preflow\Auth\Access\Access, injected $access->can('invoice.view', $invoice); // a subject, for resolvers that check records
{% if can('page.publish') %}<button>Publish</button>{% endif %}
It asks about the user signed in for this request. The answer comes from the installed resolver:
- The default (no permission package): staff may do everything, members nothing.
preflow/roles: roles fromconfig/roles.php.preflow/rbac: roles and permissions stored in the database and edited in Folio; it sets its own resolver.
Every permission is declared, so role editors can list them and typos are caught:
$registry = $access->registry(); $registry->declare('invoice.view', 'View', 'Invoices'); $registry->declarePattern('invoice.*'); // groups the family (role editors, docs); names are still declared one by one
- Undeclared permissions: an undeclared permission throws
UndeclaredPermissionin debug. In production it is denied and logged once per request. - No auth: without
config/auth.php,can()is always true.
Staff and members. Each guard serves one population. Set it with
population in config/auth.php:
'guards' => [ 'session' => ['class' => SessionGuard::class, 'provider' => 'users', 'population' => 'staff'], 'members' => ['class' => SessionGuard::class, 'provider' => 'members', 'population' => 'members'], ],
A package that signs staff in, such as Folio, marks its guard as staff itself; config wins. A guard with no population serves members.
Every request asks each guard who is signed in, the default guard first; the first
user found counts (auth_user(), can(), AuthManager::guardName()). Sign members
in with their own guard, $auth->guard('members')->login($user, $request).
AuthMiddleware and GuestMiddleware check one guard, the default one unless
you name another: new AuthMiddleware($auth, 'members'). So a signed-in member
does not pass a staff route, and staff can still sign in as a member. Each
session guard keeps its own session key, so signing a member out leaves staff
signed in (the session id is renewed either way; the session ends when nobody is
left).
A custom resolver: implement PermissionResolver::allows($user, $population, $permission, $subject) and register it with $access->setResolver(...) in a
service provider.
Custom guards
Implement GuardInterface:
use Preflow\Auth\{GuardInterface, Authenticatable}; use Psr\Http\Message\ServerRequestInterface; final class LdapGuard implements GuardInterface { public function user(ServerRequestInterface $request): ?Authenticatable { /* ... */ } public function validate(array $credentials): bool { /* ... */ } public function login(Authenticatable $user, ServerRequestInterface $request): void { /* ... */ } public function logout(ServerRequestInterface $request): void { /* ... */ } }
Register it in config/auth.php under guards. It is built from the container
with its user provider as the provider argument; a class that is not a
GuardInterface fails at boot:
'guards' => ['ldap' => ['class' => LdapGuard::class, 'provider' => 'users', 'population' => 'staff']],
Testing
use Preflow\Testing\AuthTestHelpers; final class DashboardTest extends TestCase { use AuthTestHelpers; public function test_dashboard_for_a_signed_in_user(): void { $user = new ArrayUser('u1', 'u1@site.test', '', ['admin']); $request = $this->actingAs($user, (new Psr17Factory())->createServerRequest('GET', '/dashboard')); // Handled by the app, the user counts everywhere: AuthMiddleware, auth_user(), can(). $response = $this->app->handle($request); } }