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)) :
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) :
$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).
// 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.
// 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 :
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 :
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
$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) :
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 :
$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
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 :
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 :
$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é avecHash::make()cette fois — comparé, jamais recherché directement) garantit que le lien ne sert qu'une fois.
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 :
$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 :
// 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()
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 :
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 :
// 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.