Authentification & autorisation

Authentification & autorisation

Une seule notion d'utilisateur connecté (pas de « guards » multiples façon Laravel), une session par défaut, un jeton pour l'API quand il n'y a pas de navigateur, et Gate/Policies pour l'autorisation — vérifié contre Niang\Core\Auth, Gate, ApiToken et UrlSignature.

Auth : connexion par session

Niang\Core\Auth suppose par convention un modèle App\Models\User (changeable avec Auth::useModel(AutreModel::class)) :

php
Auth::attempt($email, $password);  // cherche l'utilisateur par email, vérifie Hash::check(), login() si ok
Auth::login($user);                // régénère la session, mémorise l'id
Auth::logout();                    // oublie la session, la régénère

Auth::check();   // bool
Auth::guest();   // bool, l'inverse
Auth::id();      // int|string|null
Auth::user();    // ?array — Model::find(Auth::id())

Extrait réel de AuthController::register() — hachage du mot de passe, connexion immédiate, puis un job différé et un événement pour le reste (voir la section Cache/Queue à venir) :

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 encapsule password_hash()/password_verify() — Argon2id si l'extension est disponible, sinon l'algorithme par défaut de PHP.

Se souvenir de moi

Avec remember: true, un cookie chiffré de 30 jours (remember_web) reconnecte l'utilisateur quand sa session a expiré. Il faut la colonne users.remember_token, ajoutée par une migration du framework (./bin/niang migrate).

php
// Dans le contrôleur de connexion (la case « Se souvenir de moi » est déjà sur /login)
Auth::attempt($data['email'], $data['password'], remember: $request->input('remember') === '1');

// Ou pour un utilisateur déjà vérifié
Auth::login($user, remember: true);

Auth::logout();            // déconnecte cet appareil ; les autres appareils mémorisés le restent
Auth::logoutEverywhere();  // invalide aussi le « se souvenir de moi » de tous les appareils

Le jeton est réutilisé par chaque appareil de l'utilisateur : se connecter sur un téléphone ne déconnecte pas l'ordinateur. Le cookie étant chiffré avec APP_KEY, lire la base ne suffit pas à en fabriquer un. Après un vol d'appareil ou un changement de mot de passe, appelez Auth::logoutEverywhere() ; les sessions déjà ouvertes ailleurs durent jusqu'à leur expiration (SESSION_LIFETIME).

Rehachage automatique des mots de passe

À chaque connexion réussie, un mot de passe haché avec d'anciens paramètres (bcrypt puis Argon2id devenu disponible, coût relevé...) est recalculé et enregistré : c'est le seul moment où le mot de passe en clair est connu. Hash::needsRehash($hash) fait la vérification. Un email inconnu coûte le même calcul qu'un mauvais mot de passe : la durée de la réponse ne révèle pas quels comptes existent.

Double authentification (TOTP)

Codes à 6 chiffres renouvelés toutes les 30 secondes (RFC 6238), compatibles avec Google Authenticator, Microsoft Authenticator, Aegis, 1Password... sans dépendance. Les pages sont fournies : /user/two-factor pour l'activer (mot de passe demandé, clé et lien otpauth://, 8 codes de secours affichés une seule fois, puis un premier code pour confirmer) et /two-factor-challenge, demandé après le mot de passe.

php
// Connexion : un mot de passe correct ne suffit plus si la 2FA est activée
if (Auth::attempt($email, $password)) {
    if (Auth::twoFactorPending()) {
        return Response::redirect('/two-factor-challenge');   // pas encore connecté
    }
}
Auth::completeTwoFactor($code);   // code de l'application ou code de secours : connecte

// Réglage du compte (pages déjà fournies sur /user/two-factor)
$setup = TwoFactor::enable($user);   // ['secret', 'uri' (otpauth://), 'recovery_codes'] à afficher une fois
TwoFactor::confirm($user, $code);    // premier code correct : la 2FA devient obligatoire
TwoFactor::disable($user);
TwoFactor::regenerateRecoveryCodes($user);

Le secret est chiffré en base avec APP_KEY ; les codes de secours ne sont stockés que sous forme d'empreintes et servent une fois ; un code déjà utilisé est refusé (pas de rejeu). Le code doit être saisi dans les 5 minutes, et la route est limitée en débit. POST /api/tokens exige aussi le code (champ code) : un jeton API ne contourne pas la double authentification.

Pas de QR code intégré (il faudrait un encodeur) : la clé s'affiche par groupes de 4 caractères, et le lien otpauth:// ouvre directement l'application sur mobile. Pour un QR code, passez $setup['uri'] à la bibliothèque JavaScript de votre choix.

Connexion avec Google ou GitHub

Créez une application chez le fournisseur (Google, GitHub), déclarez l'URL de retour APP_URL/auth/google/callback (ou github), puis renseignez .env :

.env
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...

Le bouton « Continuer avec GitHub » apparaît sur /login ; les routes /auth/{provider}/redirect et /callback répondent 404 tant qu'un fournisseur n'est pas configuré. Le compte est retrouvé par son email, ou créé (email marqué vérifié, mot de passe aléatoire : « mot de passe oublié » permet d'en choisir un). Pour vos propres routes :

php
return OAuth::redirect('github');                  // vers la page d'autorisation
$profil = OAuth::user('github', $request);         // au retour : id, email, email_verified, name, avatar, raw
Auth::loginOrRequireTwoFactor($user);               // connecte, ou exige le code si la 2FA est activée

Seul un email vérifié par le fournisseur doit désigner un compte existant : sinon, créer chez lui une adresse non vérifiée identique à celle d'un de vos comptes suffirait à s'y connecter. Le contrôleur fourni applique cette règle ; faites de même dans vos routes ($profil['email_verified']). Le state à usage unique et PKCE protègent le retour contre les liens forgés et les codes interceptés.

Middlewares Authenticate / RedirectIfAuthenticated

php
$router->post('/posts', [PostController::class, 'store'], [Authenticate::class, VerifyCsrfToken::class]);
$router->get('/login', [AuthController::class, 'showLogin'], [RedirectIfAuthenticated::class]);

Authenticate laisse passer un utilisateur connecté ; sinon, redirige vers /login — ou lève une AuthenticationException (401) si la requête attend du JSON ($request->wantsJson() : en-tête Accept ou Content-Type JSON, ou toute URL sous app.api_prefix, /api par défaut), plutôt qu'une redirection HTML inutile pour un client API. Sous ce préfixe, les erreurs 404, 405 et 422 répondent aussi en JSON, même sans en-tête Accept. RedirectIfAuthenticated fait l'inverse : renvoie vers / un utilisateur déjà connecté qui arrive sur une page « invité » (connexion, inscription).

Authentification par jeton (API)

Pour un client hors navigateur (app mobile, script) : pas de session, un jeton opaque envoyé dans Authorization: Bearer <jeton>. Niang\Core\ApiToken le hache en HMAC-SHA256 (pas Hash::make() : un jeton doit être retrouvable en base par sa valeur, ce qu'un hash salé interdit structurellement) :

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' => 'Identifiants invalides.'], 401);
    }

    return $this->json(['token' => ApiToken::issue($user, $data['device_name'])], 201);
}

Le jeton en clair n'est visible qu'à l'émission — seul son hash est stocké. Il protège les routes qui en ont besoin, jamais un groupe entier :

php
$router->post('/tokens', [TokenController::class, 'store']);  // public : émet le jeton
$router->get('/me', function (Request $request): Response {
    return Response::json(['user' => Auth::user()]);
}, [AuthenticateWithToken::class]);

AuthenticateWithToken résout l'utilisateur du jeton et l'expose via Auth::resolveViaToken() — prioritaire sur la session pour la durée de la requête uniquement (réinitialisé dans un finally, jamais hérité par la requête suivante). Auth::user()/Auth::id() fonctionnent donc identiquement, que l'utilisateur vienne d'une session ou d'un jeton.

Révoquer un jeton

php
ApiToken::revoke(substr($request->header('Authorization'), 7));   // déconnecte cet appareil
ApiToken::revokeAll(Auth::user());                                // tous les appareils (vol, mot de passe changé)

URLs signées

signedRoute($nom, $params, $expiresInSeconds) (helper global — voir Vues) signe une URL en HMAC-SHA256 (clé APP_KEY), pour prouver qu'elle vient de l'application sans authentification préalable :

php
signedRoute('password.reset', ['token' => $token, 'email' => $email], 3600);
// -> /reset-password/{token}/{email}?expires=1758540000&signature=9f8e...

La signature porte sur le chemin entier et la query string (paramètres triés par clé) : altérer le moindre caractère — y compris {token} ou {email} dans le chemin — invalide la signature. Le middleware ValidateSignature vérifie ça sur la route :

php
$router->get('/reset-password/{token}/{email}', [AuthController::class, 'showResetPassword'], [ValidateSignature::class]);

Une signature absente, altérée, ou expirée (expires dépassé) → abort(403).

Réinitialisation de mot de passe

Deux protections cumulées, pas une seule :

  • la signature d'URL prouve que le lien n'a pas été forgé ni modifié ;
  • un jeton à usage unique en base (password_reset_tokens, haché avec Hash::make() cette fois — comparé, jamais recherché directement) garantit que le lien ne sert qu'une fois.
app/Controllers/AuthController.php (extrait)
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));
    }

    // Même réponse, que le compte existe ou non : ne jamais révéler quels emails sont enregistrés.
    return $this->redirect('/forgot-password')->with('success', '...');
}

Vérification d'email

Même principe qu'une URL signée pour le lien de vérification ({id} du compte, signé et expirable). App\Middleware\EnsureEmailIsVerified existe mais n'est branchée sur aucune route par défaut — à une application d'y recourir explicitement (ex. avant un checkout), en l'ajoutant après Authenticate::class dans le tableau de middlewares d'une route :

php
$router->post('/checkout', [OrderController::class, 'store'], [Authenticate::class, EnsureEmailIsVerified::class]);

Elle lève abort(403) plutôt que de rediriger : pas d'hypothèse sur l'existence d'une page « vérifiez votre email » côté application.

Gate et Policies

Deux façons d'enregistrer une règle d'autorisation :

php
// Une règle isolée
Gate::define('view-admin', fn (?array $user) => $user !== null && $user['role'] === 'admin');

// Une classe par ressource, pour plusieurs règles qui s'accumulent
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;
    }
}

Vérification via Gate::allows()/Gate::denies(), ou Controller::authorize() qui lève une AuthorizationException (403) directement :

php
Gate::allows('post.delete', $post);   // bool
Gate::denies('post.delete', $post);   // bool, l'inverse

// dans un contrôleur :
$this->authorize('post.delete', $post); // lève AuthorizationException si refusé

Gate::allows() passe toujours Auth::user() en premier argument de la closure ou de la méthode de policy, suivi des arguments fournis à l'appel — un ?array, jamais un objet, comme partout ailleurs dans l'ORM.

Rôles et permissions

Un rôle par utilisateur (colonne users.role, fournie par le module d'administration des thèmes boutique et blog), et les permissions de chaque rôle dans config/permissions.php :

php
// config/permissions.php
'roles' => [
    'admin' => ['*'],
    'editor' => ['posts.*', 'comments.moderate'],
    'user' => [],
],

Auth::hasRole('admin');            // ou ['admin', 'editor']
Auth::can('posts.update');         // = Gate::allows('posts.update')
$router->delete('/posts/{id}', [PostController::class, 'destroy'], [Authorize::class . ':posts.delete']);

Gate::allows() consulte d'abord les règles define() puis les Policies ; les permissions de rôle ne décident que si aucune des deux ne répond. Authorize renvoie un invité vers /login (401 en JSON) et répond 403 à un utilisateur sans la permission.

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