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)):

php
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):

php
$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).

php
// 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.

php
// 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:

.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:

php
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

php
$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):

app/Controllers/Api/TokenController.php
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:

php
$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

php
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:

php
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:

php
$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 with Hash::make() this time — compared, never looked up directly) guarantees the link only works once.
app/Controllers/AuthController.php (excerpt)
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:

php
$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:

php
// 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()
app/Policies/PostPolicy.php
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:

php
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:

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.

⏱ 4.76 ms 🗄 0 requête(s) SQL 🧠 4.00 MB ↩ 200