Authentication and authorization
Authentication finds out who sent a request; authorization decides what they may do. XPSR's framework defines both, and packages add storage:
| Part | Where |
|---|---|
Sessions for browsers, password hashing, Authenticate and Guest middleware, policies |
the framework (Crystoline\Xpsr\Auth\) |
JWTs for APIs (the default api guard) |
crystoline/xpsr-auth-jwt |
| Revocable API tokens in a table, and users in a table | crystoline/xpsr-database |
| Users as Doctrine entities or Eloquent models | crystoline/xpsr-orm-doctrine, crystoline/xpsr-orm-eloquent |
New projects list Crystoline\Xpsr\Registry\AuthServiceRegistry in app.registry and have config/auth.php. The pages in routes/web.php run Authenticate::optional(), so they know who is signed in, and nothing else changes until you use it.
Users
Users come from a provider, configured in auth.providers (or auth.users, the short form for one provider named users):
// A table: crystoline/xpsr-database and DatabaseServiceRegistry.
'users' => ['driver' => 'database', 'table' => 'users', 'login' => 'email', 'password' => 'password'],
// Doctrine entities: crystoline/xpsr-orm-doctrine.
'users' => ['driver' => 'doctrine', 'entity' => App\Entity\User::class, 'login' => 'email'],
// Eloquent models: crystoline/xpsr-orm-eloquent.
'users' => ['driver' => 'eloquent', 'model' => App\Model\User::class, 'login' => 'email'],
The database provider returns Crystoline\Xpsr\Auth\GenericUser objects: $user->authIdentifier(), and $user->get('name') for any other column. The password column is never included. A users table needs a primary key, a unique login column, and a password column long enough for a hash (255 characters):
php xpsr make:migration create_users_table --create=users
For Doctrine, the entity implements Crystoline\Xpsr\Auth\UserInterface (authIdentifier() and authPasswordHash()), and also Crystoline\Xpsr\Orm\Doctrine\Auth\PasswordUpgradableInterface so that sign-in can replace outdated hashes. For Eloquent, the model implements UserInterface with the Crystoline\Xpsr\Orm\Eloquent\Auth\Authenticatable trait. Any other source works too: implement Crystoline\Xpsr\Auth\UserProviderInterface and name its service as the driver.
Logins are compared exactly. Store email addresses in lowercase and lowercase them at sign-in; the Lowercase input filter does.
Passwords
use Crystoline\Xpsr\Auth\PasswordHasher;
$hash = $hasher->hash($password); // Argon2id when PHP has it, otherwise bcrypt
Store the hash, never the password. Sign-in checks it with password_verify(), and when the hash was made with an older algorithm or cost, stores a new one.
Browsers: sign in with the session
use Crystoline\Xpsr\Auth\AuthManager;
public function store(ServerRequestInterface $request): ResponseInterface
{
$input = $this->validator->validate($request, SignIn::class);
if ($this->auth->attempt($request, $input->email, $input->password) === null) {
Session::of($request)->flash('error', 'Those details do not match.');
return $this->responses->redirect('/login');
}
return $this->responses->redirect($this->auth->intended($request));
}
public function destroy(ServerRequestInterface $request): ResponseInterface
{
$this->auth->logout($request);
return $this->responses->redirect('/');
}
attempt() checks the password and signs the user in under a new session ID. logout() ends the session. The session also keeps a fingerprint of the password hash, so changing a password signs the user out of their other browsers.
Routes:
use Crystoline\Xpsr\Auth\Http\Authenticate;
use Crystoline\Xpsr\Auth\Http\Guest;
use Crystoline\Xpsr\Cache\RateLimit\RateLimit;
// Inside the group in routes/web.php:
$routes->get('/login', [LoginController::class, 'create'])->middleware(Guest::class);
$routes->post('/login', [LoginController::class, 'store'])->middleware(Guest::class, RateLimit::named('login'));
$routes->post('/logout', [LoginController::class, 'destroy']);
$routes->get('/account', [AccountController::class, 'show'])->middleware(Authenticate::class);
Authenticaterefuses guests. A browser asking for a page is sent toauth.login_path, andintended()later returns it there. A script (JSONAccept, orX-Requested-With) or a form post gets 401 instead.Guestsends signed-in browsers toauth.home_path.RateLimit::named('login')(crystoline/xpsr-cache) limits password guessing; see the cache package's README.
Every form needs {{ csrf_field() }}, the login form included; see SESSIONS.md.
APIs: tokens
API clients send Authorization: Bearer <token> to routes behind the api guard:
// routes/api.php
$routes->group('/api', static function (RouteCollection $routes): void {
$routes->get('/tasks', [TaskController::class, 'index'])->middleware(RequireScopes::of('tasks:read'));
}, [Authenticate::guard('api')]);
A request without a valid token gets 401 with WWW-Authenticate: Bearer, or Bearer error="invalid_token" when it sent a bad token. A token without a listed scope gets 403 with error="insufficient_scope". Sessions allow every scope.
The api guard's driver is AUTH_API_DRIVER: jwt by default, or token.
jwt |
token |
|
|---|---|---|
| Package | crystoline/xpsr-auth-jwt |
crystoline/xpsr-database |
| Stored | nothing; the token is signed | a SHA-256 hash per token, in a table |
| Checking a request | signature and claims, then the user by ID | one lookup by hash, then the user |
| Ends | when it expires (auth.jwt.ttl, 1 hour) |
when revoked, or at its own expiry |
| Suits | short-lived tokens from a sign-in endpoint; other services can verify them | long-lived personal tokens, deploy scripts, integrations |
JWT
composer require crystoline/xpsr-auth-jwt
php xpsr auth:jwt-secret --write # sets AUTH_JWT_SECRET in .env
Add Crystoline\Xpsr\Auth\Jwt\JwtServiceRegistry to app.registry, after AuthServiceRegistry. A sign-in endpoint issues tokens:
use Crystoline\Xpsr\Auth\Jwt\JwtIssuer;
use Crystoline\Xpsr\Auth\UnauthenticatedException;
public function store(ServerRequestInterface $request): ResponseInterface
{
$input = $this->validator->validate($request, SignIn::class);
$user = $this->auth->verify($input->email, $input->password) ?? throw new UnauthenticatedException();
$token = $this->jwt->issue($user, ['tasks:read', 'tasks:write']);
return $this->responses->json(['token' => $token->token, 'expires_at' => $token->expiresAt->format(DATE_ATOM)]);
}
A JWT cannot be revoked before it expires, so keep ttl short. Changing the secret ends every token. See the package README for algorithms and options.
Database tokens
php xpsr make:token-table && php xpsr migrate
and set AUTH_API_DRIVER=token.
use Crystoline\Xpsr\Database\Auth\TokenRepository;
$new = $tokens->create($user, 'deploy script', ['tasks:read'], expiresAt: new DateTimeImmutable('+90 days'));
$new->plainText; // show it once: xpsr_ followed by 64 hexadecimal characters
$tokens->forUser($user); // name, scopes, created, last used, expiry
$tokens->revoke($new->token->id);
$tokens->revokeAll($user);
From the console:
php xpsr auth:token ada@example.com --name=deploy --scope=tasks:read --expires="90 days"
php xpsr auth:token-revoke 12
The table stores only a SHA-256 hash of each token, so a copy of it cannot be used to sign in.
Several kinds of user
When admins (or staff, or partners) live in their own table, give them their own provider and guard:
'guards' => [
'web' => ['driver' => 'session', 'provider' => 'users'],
'admin' => ['driver' => 'session', 'provider' => 'admins', 'login_path' => '/admin/login', 'home_path' => '/admin'],
'api' => ['driver' => 'jwt', 'provider' => 'users'],
],
'providers' => [
'users' => ['driver' => 'database', 'table' => 'users'],
'admins' => ['driver' => 'database', 'table' => 'admins'],
],
Each guard looks its users up in its own provider, and each session guard signs in under its own key, so one browser can be signed in as a user and as an admin independently:
$auth->attempt($request, $email, $password, guard: 'admin');
$auth->intended($request, guard: 'admin');
$auth->logout($request, 'admin'); // the web sign-in stays; the session moves to a new ID
$routes->group('/admin', static function (RouteCollection $routes): void {
$routes->get('/login', [AdminLoginController::class, 'create'])->middleware(Guest::guard('admin'));
$routes->post('/login', [AdminLoginController::class, 'store'])->middleware(Guest::guard('admin'));
$routes->group('', static function (RouteCollection $routes): void {
$routes->get('', [AdminController::class, 'index']);
}, [Authenticate::guard('admin')]);
});
Signing out of the last signed-in guard ends the whole session. A guard without provider uses users, or the only provider there is; login_path and home_path default to the top-level ones.
API tokens belong to the guard they were issued for: a JWT carries a guard claim, and a database token a guard column. A guard accepts only its own tokens, so an admin's token never counts on the users' API, even when both tables have a row with ID 7. Issue them for a guard with $jwt->issue($admin, guard: 'admin-api'), $tokens->create($admin, 'cli', guard: 'admin-api'), or --guard=admin-api on the console.
Policy methods take whichever user the request has. A method whose user parameter is typed Admin refuses a User (and the reverse), so one policy can serve both: public function delete(Admin|User $actor, Task $task): bool.
If admins are users with more rights, a role column on users and a policy before() that checks it is simpler than a second table.
In handlers
use Crystoline\Xpsr\Auth\Identity;
$identity = Identity::of($request); // 401 for a guest
$identity = Identity::find($request); // null for a guest
$identity->user; // the UserInterface
$identity->guard; // 'web' or 'api'
$identity->hasScope('tasks:write');
$identity->tokenId; // the JWT's jti or the token's ID; null for sessions
Authorization: policies
A policy is a class with one method per action. Methods take the user first, then the record:
namespace App\Policies;
use App\Entity\Task;
use App\Entity\User;
final class TaskPolicy
{
/** Runs first: true or false decides, null goes on to the method. */
public function before(User $user, string $ability): ?bool
{
return $user->isAdmin ? true : null;
}
public function view(?User $user, Task $task): bool // ?User: guests reach this method
{
return $task->public || $task->ownerId === $user?->id;
}
public function update(User $user, Task $task): bool
{
return $task->ownerId === $user->id;
}
public function create(User $user): bool
{
return true;
}
}
List it in config/auth.php:
'policies' => [App\Entity\Task::class => App\Policies\TaskPolicy::class],
and check with Crystoline\Xpsr\Auth\Access\Gate:
$user = Identity::of($request)->user;
$this->gate->authorize($user, 'update', $task); // 403 "You are not allowed to do this." when refused
if ($this->gate->allows($user, 'create', Task::class)) { // a class name: no record yet
// ...
}
An ability like view-any calls viewAny(). A policy for a parent class or an interface also covers its subclasses. Abilities that are not about a record are defined in a provider's boot():
$this->container->get(Gate::class)->define('view-reports', static fn(User $user): bool => $user->isAdmin);
Anything without a policy method or a defined ability is refused, and so are guests, unless the method's user parameter accepts null. Type the user parameter with your user class: the database provider's users are GenericUser.
Views
| Function | Gives |
|---|---|
{{ auth_user() }} |
The signed-in user, or null: {% if auth_user() %}Hello {{ auth_user().get('name') }}{% endif %}. |
{{ can('update', task) }} |
Whether the signed-in user may, by the policies. |
They see the user on routes behind Authenticate or Authenticate::optional(), which the skeleton's web group has.
Configuration
config/auth.php:
| Option | Default | Meaning |
|---|---|---|
default |
web |
The guard Authenticate and Guest use when none is named. |
guards |
web: session; api: AUTH_API_DRIVER (jwt) |
Each guard's driver (session, jwt, token, or a service implementing GuardFactoryInterface), provider, and optionally its own login_path and home_path. |
providers |
users: database on users (email, password) |
Where each kind of user is looked up; see Users and Several kinds of user. |
users |
The short form for a single provider named users, instead of providers. |
|
login_path, home_path |
/login, / |
Where Authenticate sends guests and Guest sends users, unless a guard sets its own. |
policies |
none | Model class to policy class. |
jwt |
Options for the jwt driver: secret, algorithm, ttl, and more in its README. |
|
tokens |
table api_tokens, the default connection |
Options for the token driver. |
Testing
public function testTheAccountPageShowsTheUser(): void
{
$this->actingAs(new GenericUser(7, null, ['name' => 'Ada']));
self::assertStringContainsString('Ada', (string) $this->get('/account')->getBody());
}
public function testTheApiNeedsTheReadScope(): void
{
$this->actingAs($user, 'api', ['tasks:write']);
self::assertSame(403, $this->request('GET', '/api/tasks', ['Accept' => 'application/json'])->getStatusCode());
}
actingAs($user, $guard, $scopes) authenticates every later request in the test, on every guard or one, without a session or token. To test the real sign-in, post the form with postForm(); the test keeps the session cookie. Crystoline\Xpsr\Auth\InMemoryUserProvider holds users for tests: register an instance as a service and name it in auth.users.driver.
Not included
Registration, password reset, and email verification are application code for now; password reset emails wait for the mail component. Social login, OAuth servers, two-factor sign-in, and "remember me" cookies are not provided.