Authentication & authorization
Authentication & authorization
A single notion of logged-in user (no multiple Laravel-style “guards”), a session by default, a
token for the API when there is no browser, and Gate/Policies for authorization — checked against
Niang\Core\Auth, Gate, ApiToken and UrlSignature.
Auth: session login
Niang\Core\Auth assumes by convention an App\Models\User model (changeable
with Auth::useModel(OtherModel::class)):
Auth::attempt($email, $password); // finds the user by email, checks Hash::check(), login() if ok
Auth::login($user); // regenerates the session, remembers the id
Auth::logout(); // forgets the session, regenerates it
Auth::check(); // bool
Auth::guest(); // bool, the opposite
Auth::id(); // int|string|null
Auth::user(); // ?array — Model::find(Auth::id())
A real excerpt from AuthController::register() — password hashing, immediate login,
then a deferred job and an event for the rest (see
Cache, Queue, Mail, Storage, Events):
$userId = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
$user = User::find($userId);
Auth::login($user);
Niang\Core\Hash wraps password_hash()/password_verify() —
Argon2id if the extension is available, otherwise PHP's default algorithm.
Remember me
With remember: true, a 30-day encrypted cookie (remember_web) logs the user
back in when their session has expired. It needs the users.remember_token column, added
by a framework migration (./bin/niang migrate).
// In the login controller (the “Remember me” checkbox is already on /login)
Auth::attempt($data['email'], $data['password'], remember: $request->input('remember') === '1');
// Or for an already verified user
Auth::login($user, remember: true);
Auth::logout(); // logs out this device; other remembered devices stay logged in
Auth::logoutEverywhere(); // also revokes “remember me” on every device
The token is reused by every device of the user: logging in on a phone doesn't log the laptop out.
Since the cookie is encrypted with APP_KEY, reading the database isn't enough to forge
one. After a stolen device or a password change, call Auth::logoutEverywhere(); sessions
already open elsewhere last until they expire (SESSION_LIFETIME).
Automatic password rehashing
On every successful login, a password hashed with older settings (bcrypt, then Argon2id became
available, a higher cost...) is recomputed and saved: it's the only moment the plain password is
known. Hash::needsRehash($hash) performs the check. An unknown email costs the same
computation as a wrong password: response time doesn't reveal which accounts exist.
Two-factor authentication (TOTP)
6-digit codes renewed every 30 seconds (RFC 6238), compatible with Google Authenticator, Microsoft
Authenticator, Aegis, 1Password... with no dependency. The pages are provided:
/user/two-factor to enable it (password required, key and otpauth:// link,
8 recovery codes shown only once, then a first code to confirm) and /two-factor-challenge,
asked for after the password.
// Login: a correct password is no longer enough once 2FA is enabled
if (Auth::attempt($email, $password)) {
if (Auth::twoFactorPending()) {
return Response::redirect('/two-factor-challenge'); // not logged in yet
}
}
Auth::completeTwoFactor($code); // app code or recovery code: logs in
// Account settings (pages already provided at /user/two-factor)
$setup = TwoFactor::enable($user); // ['secret', 'uri' (otpauth://), 'recovery_codes'] to show once
TwoFactor::confirm($user, $code); // first correct code: 2FA becomes mandatory
TwoFactor::disable($user);
TwoFactor::regenerateRecoveryCodes($user);
The secret is encrypted in the database with APP_KEY; recovery codes are only stored as
hashes and work once; a code already used is refused (no replay). The code must be entered within 5
minutes, and the route is rate limited. POST /api/tokens also requires the code
(code field): an API token doesn't bypass two-factor authentication.
No built-in QR code (it would need an encoder): the key is shown in groups of 4 characters, and the
otpauth:// link opens the app directly on mobile. For a QR code, pass
$setup['uri'] to the JavaScript library of your choice.
Sign in with Google or GitHub
Create an application at the provider
(Google,
GitHub), declare the callback URL
APP_URL/auth/google/callback (or github), then fill in .env:
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
The "Continue with GitHub" button appears on /login; the /auth/{provider}/redirect
and /callback routes return 404 until a provider is configured. The account is found by its
email, or created (email marked verified, random password: "forgot password" lets the user choose one).
For your own routes:
return OAuth::redirect('github'); // to the authorization page
$profile = OAuth::user('github', $request); // on return: id, email, email_verified, name, avatar, raw
Auth::loginOrRequireTwoFactor($user); // logs in, or requires the code if 2FA is enabled
Only an email verified by the provider should designate an existing account:
otherwise, creating an unverified address there identical to one of your accounts would be enough to
log into it. The provided controller applies this rule; do the same in your routes
($profile['email_verified']). The single-use state and PKCE protect the
callback against forged links and intercepted codes.
Authenticate / RedirectIfAuthenticated middleware
$router->post('/posts', [PostController::class, 'store'], [Authenticate::class, VerifyCsrfToken::class]);
$router->get('/login', [AuthController::class, 'showLogin'], [RedirectIfAuthenticated::class]);
Authenticate lets a logged-in user through; otherwise, it redirects to
/login — or throws an AuthenticationException (401) if the request expects
JSON ($request->wantsJson(): a JSON Accept or Content-Type header,
or any URL under app.api_prefix, /api by default), rather than an HTML redirect that
is useless to an API client. Under that prefix, 404, 405 and 422 errors also answer in JSON, even without an
Accept header. RedirectIfAuthenticated does the opposite: it sends an already logged-in user
who lands on a “guest” page (login, registration) back to /.
Token authentication (API)
For a non-browser client (mobile app, script): no session, an opaque token sent in
Authorization: Bearer <token>. Niang\Core\ApiToken hashes it with
HMAC-SHA256 (not Hash::make(): a token must be findable in the database by its value,
which a salted hash structurally prevents):
public function store(Request $request): Response
{
$data = $this->validate($request, [
'email' => 'required|email',
'password' => 'required|string',
'device_name' => 'required|string',
]);
$user = User::where('email', $data['email'])[0] ?? null;
if ($user === null || !Hash::check($data['password'], $user['password'])) {
return $this->json(['message' => 'Invalid credentials.'], 401);
}
return $this->json(['token' => ApiToken::issue($user, $data['device_name'])], 201);
}
The plain-text token is only visible when it is issued — only its hash is stored. It protects the routes that need it, never a whole group:
$router->post('/tokens', [TokenController::class, 'store']); // public: issues the token
$router->get('/me', function (Request $request): Response {
return Response::json(['user' => Auth::user()]);
}, [AuthenticateWithToken::class]);
AuthenticateWithToken resolves the token's user and exposes it through
Auth::resolveViaToken() — taking precedence over the session for the duration of the
request only (reset in a finally, never inherited by the next request).
Auth::user()/Auth::id() therefore work identically, whether the user comes
from a session or a token.
Revoking a token
ApiToken::revoke(substr($request->header('Authorization'), 7)); // logs this device out
ApiToken::revokeAll(Auth::user()); // every device (theft, password changed)
Signed URLs
signedRoute($name, $params, $expiresInSeconds) (global helper — see
Views) signs a URL with HMAC-SHA256 (key APP_KEY), to
prove it comes from the application without prior authentication:
signedRoute('password.reset', ['token' => $token, 'email' => $email], 3600);
// -> /reset-password/{token}/{email}?expires=1758540000&signature=9f8e...
The signature covers the whole path and the query string (parameters sorted by
key): altering the slightest character — including {token} or {email} in
the path — invalidates the signature. The ValidateSignature middleware checks this on
the route:
$router->get('/reset-password/{token}/{email}', [AuthController::class, 'showResetPassword'], [ValidateSignature::class]);
A missing, altered or expired signature (expires in the past) → abort(403).
Password reset
Two combined protections, not just one:
- the URL signature proves the link was neither forged nor modified;
- a single-use token in the database (
password_reset_tokens, hashed withHash::make()this time — compared, never looked up directly) guarantees the link only works once.
public function sendResetLink(Request $request): Response
{
$data = $this->validate($request, ['email' => 'required|email']);
$user = User::where('email', $data['email'])[0] ?? null;
if ($user !== null) {
$token = bin2hex(random_bytes(32));
PasswordResetToken::deleteForEmail($data['email']);
PasswordResetToken::create(['email' => $data['email'], 'token_hash' => Hash::make($token)]);
$signedUrl = signedRoute('password.reset', ['token' => $token, 'email' => $data['email']], 3600);
Mail::to($data['email'])->send(new ResetPasswordMailable($signedUrl));
}
// Same response whether or not the account exists: never reveal which emails are registered.
return $this->redirect('/forgot-password')->with('success', '...');
}
Email verification
Same principle as a signed URL for the verification link (the account's {id}, signed
and expiring). App\Middleware\EnsureEmailIsVerified exists but is not attached
to any route by default — it is up to an application to use it explicitly (e.g. before a
checkout), by adding it after Authenticate::class in a route's middleware array:
$router->post('/checkout', [OrderController::class, 'store'], [Authenticate::class, EnsureEmailIsVerified::class]);
It calls abort(403) rather than redirecting: no assumption that the application has a “verify your email” page.
Gate and Policies
Two ways to register an authorization rule:
// A standalone rule
Gate::define('view-admin', fn (?array $user) => $user !== null && $user['role'] === 'admin');
// One class per resource, for several rules that add up
Gate::policy('post', PostPolicy::class); // 'post.delete' -> PostPolicy::delete()
class PostPolicy
{
public function delete(?array $user, array $post): bool
{
return $user !== null;
}
}
Checked with Gate::allows()/Gate::denies(), or Controller::authorize() which throws an AuthorizationException (403) directly:
Gate::allows('post.delete', $post); // bool
Gate::denies('post.delete', $post); // bool, the opposite
// in a controller:
$this->authorize('post.delete', $post); // throws AuthorizationException if denied
Gate::allows() always passes Auth::user() as the first argument of the
closure or policy method, followed by the arguments given to the call — a ?array, never
an object, as everywhere else in the ORM.
Roles and permissions
One role per user (users.role column, provided by the admin module of the shop and blog themes),
and each role's permissions in config/permissions.php:
// config/permissions.php
'roles' => [
'admin' => ['*'],
'editor' => ['posts.*', 'comments.moderate'],
'user' => [],
],
Auth::hasRole('admin'); // or ['admin', 'editor']
Auth::can('posts.update'); // = Gate::allows('posts.update')
$router->delete('/posts/{id}', [PostController::class, 'destroy'], [Authorize::class . ':posts.delete']);
Gate::allows() checks define() rules first, then Policies; role permissions only decide
when neither answers. Authorize sends a guest to /login (401 in JSON) and answers 403 to a
user without the permission.