Authentification
POST /api/auth/register
Section titled “POST /api/auth/register”Crée un compte utilisateur. Déclenche l’envoi d’un email contenant un lien de confirmation. Le compte est créé en base avec status = 1, user_type = 1, confirmed_at = NULL — l’utilisateur ne peut pas se connecter tant qu’il n’a pas confirmé son email (POST /api/auth/confirm-email).
- Auth : aucune
- Action : RegisterAction
- Request body (forme plate ou JSON:API
data.attributes) :
{ "username": "havoc", "email": "user@example.com", "password": "MySuperPassw0rd!", "sponsorshipCode": "A3K7QM", "couponId": "WELCOME2026"}sponsorshipCode est optionnel (omission, null ou chaîne vide = pas de
parrain). Si fourni, il doit matcher un code existant de la table
sponsorship — sinon 422 (cf. codes d’erreur). Voir
section Parrainage pour le détail du format.
couponId est optionnel et non bloquant : un coupon inconnu, expiré
ou déjà épuisé ne fait JAMAIS échouer l’inscription. Le résultat de la
tentative est exposé dans meta.coupon sur la réponse 201. Voir
section Coupons pour le détail.
Politiques de validation
Section titled “Politiques de validation”Username (UsernamePolicy) :
- 3 à 32 caractères
- ASCII :
[a-z0-9._-]uniquement (forcé en minuscules à la persistance) - Doit commencer et finir par un caractère alphanumérique
- Pas de séparateurs consécutifs (
..,__,-_, …) - Unicité case-insensitive
Password (PasswordPolicy) :
- 12 à 72 octets (72 = limite bcrypt)
- Au moins : 1 minuscule, 1 majuscule, 1 chiffre, 1 caractère non alphanumérique
- Pas de NUL byte, pas d’espace en début/fin
- Ne doit pas contenir le username ni la partie locale de l’email
Email : filter_var(..., FILTER_VALIDATE_EMAIL) + max 255 caractères + unicité.
- Réponse
201:
{ "jsonapi": { "version": "1.1" }, "data": { "type": "users", "id": "<user-uuid>", "attributes": { "username": "havoc", "email": "user@example.com", "userType": 1, "status": 1, "joinedAt": "2026-06-06T12:34:56+00:00", "isConfirmed": false } }, "meta": { "confirmationEmailSent": true, "coupon": { "applied": true, "couponId": "WELCOME2026", "rewards": [{ "type": "experience", "value": 100 }] } }}meta.coupon n’est présent que si le client a fourni couponId. Sur échec :
"coupon": { "applied": false, "couponId": "WELCOME2026", "reason": "coupon.expired", "message": "Ce coupon a expiré."}- Réponse
400— corps non-JSON / non-objet - Réponse
422— un ou plusieurserrors[]avec :source.pointer = /data/attributes/<field>meta.codetypé pour i18n côté frontend- Codes possibles :
username.tooShort,username.tooLong,username.invalidCharacters,username.invalidBoundary,username.consecutiveSeparators,username.reserved,username.alreadyTakenemail.invalidFormat,email.alreadyTakenpassword.tooShort,password.tooLong,password.missingLowercase,password.missingUppercase,password.missingDigit,password.missingSpecial,password.invalidCharacters,password.invalidBoundary,password.containsUsername,password.containsEmailsponsorship.codeInvalid(forme du code incorrecte : longueur, caractères hors alphabet),sponsorship.codeNotFound(code bien formé mais inconnu de la tablesponsorship)
Usernames réservés
Section titled “Usernames réservés”Le code username.reserved est renvoyé quand le username demandé figure dans la blocklist statique config/username_blocklist.php (noms système/rôles, identité de marque, routes techniques), chargée dans UsernameBlocklist. La vérification s’applique partout où UsernamePolicy est utilisée (inscription, complétion de profil, changement de username). Le match porte sur la forme normalisée et sur une forme sans séparateurs (./_/-), de sorte que admin bloque aussi a.d.m.i.n. La liste évolue par PR ; aucune table DB en V1.
Note sur l’énumération
Section titled “Note sur l’énumération”Les erreurs username.alreadyTaken et email.alreadyTaken sont explicites — un attaquant peut donc tester l’existence d’un email. Choix assumé : les usernames sont publics dans une app sociale, et un endpoint d’« availability check » sera de toute façon nécessaire pour l’UX du formulaire. À durcir si le produit devient sensible.
Mailer en dev
Section titled “Mailer en dev”Tant qu’un vrai mailer n’est pas branché, l’email est écrit dans var/logs/mail.log (LogMailer). Pour récupérer le token de confirmation pendant les tests :
tail -n 30 var/logs/mail.logPOST /api/auth/confirm-email
Section titled “POST /api/auth/confirm-email”Consomme le token reçu par email et marque l’utilisateur comme confirmé (user.confirmed_at = NOW()). Le token est à usage unique et expire après 24h.
- Auth : aucune
- Action : ConfirmEmailAction
- Request body :
{ "token": "<base64url-token>" }(également accepté en forme JSON:API : data.attributes.token)
- Réponse
200:
{ "jsonapi": { "version": "1.1" }, "data": { "type": "users", "id": "<user-uuid>", "attributes": { "username": "havoc", "email": "user@example.com", "isConfirmed": true, "confirmedAt": "2026-06-06T13:00:00+00:00" } }}-
Réponse
400— token inconnu, expiré, ou déjà utilisé (même message pour les trois cas afin d’éviter un oracle) -
Réponse
422— attributtokenmanquant -
Notes :
- Une nouvelle demande de confirmation pour le même user invalide automatiquement tous les tokens précédents non utilisés (
invalidatePendingForUser). - Le token brut n’est jamais stocké : seul son SHA-256 (32 octets) est en base (table
email_confirmation).
- Une nouvelle demande de confirmation pour le même user invalide automatiquement tous les tokens précédents non utilisés (
POST /api/auth/resend-confirmation
Section titled “POST /api/auth/resend-confirmation”Renvoie un email de confirmation pour un compte existant et non encore confirmé.
- Auth : publique (l’utilisateur n’a pas pu se connecter puisqu’il n’est pas confirmé)
- Action : ResendConfirmationAction
- Request body (forme plate ou JSON:API
data.attributes) :
{ "email": "user@example.com" }Comportement anti-énumération
Section titled “Comportement anti-énumération”L’endpoint renvoie toujours la même réponse 202 Accepted, peu importe que :
- l’email soit inconnu ;
- le compte soit déjà confirmé (
confirmed_at IS NOT NULL) ; - une demande précédente ait été faite il y a moins de
cooldownSeconds(cooldown actif) ; - l’envoi de l’email ait réellement eu lieu.
Cela empêche un attaquant d’inférer l’existence d’un compte (ou son état) à partir du status code ou du corps de la réponse.
-
Cooldown 60 s par compte. Calcul :
MAX(email_confirmation.created_at) WHERE user_id = X. Si la dernière demande date d’il y a < 60 s, l’envoi est silencieusement supprimé (toujours202). -
Si l’envoi a lieu, toutes les confirmations pending du user sont invalidées (
used_at = NOW()) avant l’émission du nouveau token — un seul token actif à la fois (logique deEmailConfirmationService::issueFor()). -
Réponse
202:
{ "jsonapi": { "version": "1.1" }, "data": { "type": "confirmationResends", "id": "pending", "attributes": { "message": "If this email is registered and not yet confirmed, a new confirmation link has been sent." } }, "meta": { "cooldownSeconds": 60 }}-
Réponse
400— corps non-JSON -
Réponse
422—emailmanquant ou vide -
Notes :
- Le token est généré comme à l’inscription (32 octets CSPRNG, base64url, hash SHA-256 stocké) et expire après
EmailConfirmationService::LIFETIME_HOURS(24h). - L’email est envoyé via
ConfirmationEmailSender— même template que l’inscription pour cohérence. - L’ID retourné est volontairement la chaîne
"pending"(et non un UUID) : ne pas exposer d’identifiant de ressource créée afin de ne pas confirmer/infirmer l’existence du compte.
- Le token est généré comme à l’inscription (32 octets CSPRNG, base64url, hash SHA-256 stocké) et expire après
POST /api/auth/forgot-password
Section titled “POST /api/auth/forgot-password”Émet un email de réinitialisation de mot de passe pour un compte existant.
- Auth : publique
- Action : ForgotPasswordAction
- Request body (forme plate ou JSON:API
data.attributes) :
{ "email": "user@example.com" }Comportement anti-énumération
Section titled “Comportement anti-énumération”L’endpoint renvoie toujours 202 Accepted avec le même corps, peu importe que :
- l’email soit inconnu ;
- le compte soit OAuth-only (sans mot de passe réel — autorisé : permet d’ajouter un mot de passe) ;
- le compte ne soit pas encore confirmé (autorisé : la consommation du token confirmera l’email implicitement) ;
- une demande précédente ait été faite il y a moins de
cooldownSeconds(cooldown actif) ; - l’envoi de l’email ait réellement eu lieu.
-
Cooldown 60 s par compte. Calcul :
MAX(password_reset.created_at) WHERE user_id = X. Si la dernière demande date d’il y a < 60 s, l’envoi est silencieusement supprimé. -
À chaque envoi réussi, toutes les demandes pending du user sont invalidées (
used_at = NOW()) — un seul token actif à la fois. -
Réponse
202:
{ "jsonapi": { "version": "1.1" }, "data": { "type": "passwordResetRequests", "id": "pending", "attributes": { "message": "If this email is registered, a password-reset link has been sent." } }, "meta": { "cooldownSeconds": 60 }}-
Réponse
400— corps non-JSON -
Réponse
422—emailmanquant ou vide -
Notes :
- Le token est 32 octets CSPRNG, base64url, hash SHA-256 stocké dans
password_reset.token_hash(BINARY(32) UNIQUE). - Durée de vie :
PasswordResetService::LIFETIME_HOURS = 1heure. - L’email est envoyé via
PasswordResetEmailSenderqui rend le template Twigpassword_reset.twig. Le lien pointe versAPP_URL + /reset-password?token=....
- Le token est 32 octets CSPRNG, base64url, hash SHA-256 stocké dans
POST /api/auth/reset-password
Section titled “POST /api/auth/reset-password”Consomme un token de réinitialisation et applique un nouveau mot de passe.
- Auth : publique (le user est précisément en train de récupérer son accès)
- Action : ResetPasswordAction
- Request body (forme plate ou JSON:API
data.attributes) :
{ "token": "<plain token reçu par email>", "newPassword": "MyNewPassw0rd!"}Effets de bord
Section titled “Effets de bord”Sur succès, PasswordResetService::consume() effectue dans l’ordre :
- Remplace le mot de passe (bcrypt cost 12, met à jour
password_set_at). - Confirme l’email si le compte ne l’était pas (
confirmed_at = NOW()) — la possession du token prouve le contrôle de la boîte.meta.emailConfirmedByReset = truedans ce cas. - Révoque toutes les sessions du user (
DELETE FROM user_session WHERE user_id = X). L’utilisateur devra se reconnecter partout.meta.sessionsRevokeddonne le compte. - Marque le token consommé (
used_at = NOW()) — non rejouable.
- Réponse
200:
{ "jsonapi": { "version": "1.1" }, "data": { "type": "passwordResets", "id": "<user-uuid>", "attributes": { "changedAt": "2026-06-07T11:00:00+00:00" } }, "meta": { "sessionsRevoked": 2, "emailConfirmedByReset": false }}-
Réponse
400— corps non-JSON -
Réponse
401— token rejeté.meta.code:passwordReset.tokenInvalid— inconnupasswordReset.tokenExpired— au-delà de 1hpasswordReset.tokenUsed— déjà consommé
-
Réponse
422—tokenounewPasswordmanquant, ou échec de la PasswordPolicy. Codes :password.tooShort,password.tooLong,password.missingLowercase,password.missingUppercase,password.missingDigit,password.missingSpecial,password.invalidCharacters,password.invalidBoundary,password.containsUsername,password.containsEmail -
Notes :
- Aucun token de session n’est émis — l’utilisateur doit se reconnecter via
/auth/login. C’est volontaire (le reset peut résulter d’un compromis, on force une auth complète). - Pour un compte OAuth-only, le reset crée son premier mot de passe — il pourra ensuite se connecter par mot de passe ou par Google (au choix).
- Aucun token de session n’est émis — l’utilisateur doit se reconnecter via
POST /api/auth/oauth/google
Section titled “POST /api/auth/oauth/google”Échange un Google ID Token (obtenu côté frontend après le sign-in Google) contre un bearer token de session Hydrogen. Selon l’état du compte, l’endpoint peut connecter un utilisateur existant, lier une identité Google à un compte existant, ou créer un nouveau compte.
- Auth : aucune
- Action : GoogleLoginAction
- Pré-requis serveur : variable d’env
GOOGLE_OAUTH_CLIENT_ID(Client ID OAuth récupéré dans la Google Cloud Console). - Request body (forme plate ou JSON:API
data.attributes) :
{ "idToken": "<google id_token>" }Vérification du token
Section titled “Vérification du token”- Récupère les JWKS Google (
https://www.googleapis.com/oauth2/v3/certs), avec cache disque TTL 1h sousvar/cache/oauth/google_jwks.json. - Vérifie la signature RS256,
iss(accounts.google.comouhttps://accounts.google.com),aud(=GOOGLE_OAUTH_CLIENT_ID),exp(avec 60s de skew). - Extrait
sub,email,email_verified,name,given_name,family_name,picture,locale.
Logique de résolution (OAuthLoginService)
Section titled “Logique de résolution (OAuthLoginService)”| Cas | Condition | Résultat | Code HTTP | meta.oauthOutcome |
|---|---|---|---|---|
| Identité Google déjà liée | (provider, sub) existe en user_oauth_identity | sign-in du user lié | 200 | existingIdentity |
| Auto-link (C3) | email Google = email d’un user existant, et ce user a confirmed_at IS NOT NULL, et email_verified = true côté Google | lien créé puis sign-in | 200 | linkedToExistingUser |
| Auto-link refusé | email match un user, mais une des deux conditions C3 manque | 409 Conflict, l’utilisateur doit se connecter par mot de passe et lier Google manuellement plus tard | 409 | — |
| Nouveau compte | aucune correspondance | création d’un user avec username placeholder (g_XXXXXXXX, 4 octets aléatoires en hex), confirmed_at = NOW(), mot de passe inutilisable (bcrypt aléatoire), profile_completed_at = NULL | 201 | registered |
| Banni | user trouvé mais bannedUntil futur | refus | 403 | — |
- Réponse
200/201— même forme quePOST /api/auth/loginplus :
{ "jsonapi": { "version": "1.1" }, "data": { "type": "users", "id": "<user-uuid>", "attributes": { "username": "g_3f2a91b0", "email": "user@gmail.com", "confirmedAt": "2026-06-06T12:34:56+00:00", "profileCompletedAt": null, "isConfirmed": true, "isBanned": false /* + tous les autres champs habituels */ } }, "meta": { "token": "<base64url-token>", "sessionId": "<session-uuid>", "expiresAt": "2026-07-06T12:34:56+00:00", "oauthOutcome": "registered", "profileComplete": false }}- Réponse
401—Invalid Google ID token(signature invalide, expiré, mauvaisiss/aud, JWKS injoignable, etc. — message volontairement non détaillé) - Réponse
403—Account banned,meta.bannedUntil - Réponse
409—Account exists with this email,meta.code = "oauth.linkRefused"(un compte existe mais l’auto-link est refusé) - Réponse
422—idTokenmanquant ou vide
- Les comptes créés via Google ont un username placeholder (
g_<8-hex>) etprofile_completed_at = NULL. Un endpoint dédié (à venir) permettra à l’utilisateur de choisir son vrai username. - Le mot de passe stocké est un bcrypt d’octets aléatoires CSPRNG —
password_verifyéchoue toujours, donc impossible de se connecter via/api/auth/logintant que l’utilisateur n’a pas explicitement défini un mot de passe. - La table
user_oauth_identity(migration) stocke(provider, provider_user_id)UNIQUE — un même compte Google ne peut être lié qu’à un seul user Hydrogen.
Matrice des providers OAuth
Section titled “Matrice des providers OAuth”Récapitulatif des différences entre les trois providers supportés. Chaque cellule reflète une décision du framework Hydrogen, pas seulement une capacité du provider.
| Capacité | Apple | ||
|---|---|---|---|
| Type de jeton accepté | OIDC ID Token (JWT) | OIDC ID Token (JWT) | Access token opaque OU OIDC ID Token (JWT) |
| Flux côté frontend | un seul (idToken) | un seul (idToken) | deux : classic (web/Android) ou limited (iOS ≥13) |
email fourni | toujours | toujours | optionnel (l’utilisateur peut refuser le scope) |
email_verified signal côté provider | oui (booléen) | oui ("true"/"false" ou booléen, coercé) | non |
| Auto-link sur email existant (C3, sign-in) | oui si confirmé + verified | non, jamais (E.a strict) | non, jamais (E.a strict) |
| Lien manuel depuis le compte connecté | oui | oui | oui |
email_is_relay possible | non | oui (@privaterelay.appleid.com) | non |
| Prefix du username placeholder à la création | g_<8-hex> | a_<8-hex> | f_<8-hex> |
| Variables d’env requises | GOOGLE_OAUTH_CLIENT_ID | APPLE_OAUTH_CLIENT_IDS (liste) | FACEBOOK_APP_ID, FACEBOOK_APP_SECRET |
| JWKS | googleapis.com/oauth2/v3/certs | appleid.apple.com/auth/keys | facebook.com/.well-known/oauth/openid/jwks/ |
Politique E.a (strict) — Apple et Facebook ne sont jamais auto-liés à un compte Hydrogen existant qui aurait le même email. Raison : Apple Private Relay réduit l’assurance d’identité (l’utilisateur peut relayer un email vers n’importe quelle boîte) et Facebook ne renvoie aucun email_verified. Si un compte existe déjà avec cet email, la réponse est 409 oauth.linkRefused — le client doit demander une connexion par mot de passe puis appeler POST /api/users/me/oauth/<provider> depuis l’espace authentifié.
POST /api/auth/oauth/apple
Section titled “POST /api/auth/oauth/apple”Échange un Apple ID Token (issu de Sign in with Apple) contre un bearer token de session Hydrogen.
- Auth : aucune
- Action : AppleLoginAction
- Pré-requis serveur : variable d’env
APPLE_OAUTH_CLIENT_IDS(liste séparée par des virgules des Services ID / Bundle ID acceptés ; Apple permet à un seul Developer Team de signer pour plusieursaud, tous valides pour le mêmesub). - Request body (forme plate ou JSON:API
data.attributes) :
{ "idToken": "<apple id_token>" }Vérification du token
Section titled “Vérification du token”- Récupère les JWKS Apple (
https://appleid.apple.com/auth/keys), cache disque TTL 1h sousvar/cache/oauth/apple_jwks.json. - Vérifie la signature RS256,
iss = https://appleid.apple.com,aud ∈ APPLE_OAUTH_CLIENT_IDS,exp(skew 60s). - Extrait
sub,email,email_verified(coercé en booléen — Apple envoie parfois"true"/"false"sous forme de string).
Logique de résolution
Section titled “Logique de résolution”Identique à Google sauf que l’auto-link est désactivé (E.a strict) : un user Hydrogen existant avec le même email reçoit toujours 409 oauth.linkRefused, jamais linkedToExistingUser.
| Cas | Résultat | Code | meta.oauthOutcome |
|---|---|---|---|
| Identité Apple déjà liée | sign-in du user lié | 200 | existingIdentity |
| Email match user existant | refus auto-link (E.a) | 409 oauth.linkRefused | — |
| Nouveau compte | création (username = a_XXXXXXXX, confirmed_at = NOW(), mot de passe inutilisable) | 201 | registered |
| Banni | refus | 403 | — |
- Réponse
200/201— même forme que Google plusmeta.emailIsRelay: true|false(drapeau Private Relay). - Réponse
401— id_token non vérifiable - Réponse
403—Account banned - Réponse
409—oauth.linkRefused - Réponse
422—idTokenmanquant
Apple Private Relay
Section titled “Apple Private Relay”Quand l’utilisateur choisit « Hide My Email » au moment du consentement, Apple émet un alias <random>@privaterelay.appleid.com. Cet alias est traité comme un email normal côté Hydrogen (envois fonctionnent, MX d’Apple), mais :
- la colonne
user_oauth_identity.email_is_relay = 1est positionnée, - la meta de la réponse expose
emailIsRelay: true, - l’application cliente doit informer l’utilisateur que désactiver le forwarding lui ferait perdre l’accès au compte (clef de traduction
auth.oauth.apple.relayNotice).
POST /api/auth/oauth/facebook
Section titled “POST /api/auth/oauth/facebook”Échange un jeton Facebook contre un bearer token de session Hydrogen. Deux flux sont supportés, sélectionnés par le champ flow du body.
- Auth : aucune
- Action : FacebookLoginAction
- Pré-requis serveur :
FACEBOOK_APP_IDetFACEBOOK_APP_SECRET(le secret reste sur le serveur, utilisé pour construire l’app_token = <id>|<secret>consommé pardebug_token). - Request body :
// Flux classique (web SDK, Android){ "flow": "classic", "accessToken": "<facebook access token>" }
// Flux limited (iOS ≥ 13){ "flow": "limited", "idToken": "<facebook OIDC id_token>" }Vérification du token (FacebookVerifier)
Section titled “Vérification du token (FacebookVerifier)”- Flux
classic: POSThttps://graph.facebook.com/v19.0/debug_token?input_token=…&access_token=<app_id>|<app_secret>puis GET/v19.0/me?fields=id,email. Validations :is_valid = true,app_idcorrespond àFACEBOOK_APP_ID,expires_atstrictement dans le futur ou égal à0(long-lived tokens),profile.idégal auuser_idretourné pardebug_token. - Flux
limited: JWKShttps://www.facebook.com/.well-known/oauth/openid/jwks/(cache TTL 1h),iss = https://www.facebook.com,aud = FACEBOOK_APP_ID, skew 60s.
Dans les deux cas, l’email est optionnel — si l’utilisateur a refusé le scope email, la sortie normalisée FacebookProfile a email = null.
Logique de résolution
Section titled “Logique de résolution”| Cas | Résultat | Code | meta.oauthOutcome |
|---|---|---|---|
Pas d’email retourné (email = null) | refus, code oauth.facebook.emailMissing | 422 | — |
| Identité Facebook déjà liée | sign-in | 200 | existingIdentity |
| Email match user existant | refus auto-link (E.a) | 409 oauth.linkRefused | — |
| Nouveau compte | création (username = f_XXXXXXXX) | 201 | registered |
| Banni | refus | 403 | — |
- Réponse
401—Invalid Facebook token(signature/issuer/audience/expiration, oudebug_tokenrefuse) - Réponse
403—Account banned - Réponse
409—oauth.linkRefused - Réponse
422—flowmanquant/invalide, ouaccessToken/idTokenmanquant selon le flow, ouoauth.facebook.emailMissing
- Facebook ne fournit aucun signal
email_verifiedsur ses APIs publiques. Hydrogen part du principe que l’email Facebook n’est pas attesté — d’où la politique stricte (E.a) et le refus d’auto-link sur email existant. - Le name/profile.name de Facebook n’est pas récupéré : à l’inscription, on ne demande que
id+email, et l’utilisateur complétera son profil ensuite (politique B.a).
POST /api/auth/login
Section titled “POST /api/auth/login”Échange un couple email + password contre un bearer token de session.
- Auth : aucune
- Action : LoginAction
- Request body — deux formes acceptées :
Forme plate :
{ "email": "user@example.com", "password": "secret"}Forme JSON:API :
{ "data": { "type": "credentials", "attributes": { "email": "user@example.com", "password": "secret" } }}- Réponse
200— session créée :
{ "jsonapi": { "version": "1.1" }, "data": { "type": "users", "id": "<user-uuid>", "attributes": { "username": "havoc", "nickname": "Havoc", "email": "user@example.com", "displayName": "Havoc", "userType": "member", "status": "active", "isVerified": false, "experience": 0, "level": 1, "levelProgress": 0.00, "isConfirmed": true, "isBanned": false /* + name, firstname, sex, birthdate, bio, joinedAt, ... */ } }, "meta": { "token": "<base64url-token>", "sessionId": "<session-uuid>", "expiresAt": "2026-07-06T12:34:56+00:00" }}-
Réponse
400— corps non-JSON ou non-objet -
Réponse
401—Invalid credentials(email inconnu ou mauvais mot de passe — message volontairement ambigu pour ne pas faciliter l’énumération) -
Réponse
403—Account banned,meta.bannedUntilindique la date de fin (ounullsi permanent) ouEmail not confirmed,meta.code = "email.notConfirmed"(compte créé mais email pas encore validé viaPOST /api/auth/confirm-email) -
Réponse
422— un ou plusieurserrors[]avecsource.pointer = /data/attributes/emailou/data/attributes/password -
Réponse
429—Too many login attempts. HeaderRetry-After: <seconds>+meta.retryAfterSeconds+meta.triggeredBy("ip"ou"email"). Voir Rate limiting ci-dessous. -
Notes :
- Le mot de passe est re-hashé automatiquement si le coût bcrypt actuel (
12) ne correspond pas au hash en base. - Le token brut n’est jamais stocké en base — seul son SHA-256 (32 octets) l’est.
- Le mot de passe est re-hashé automatiquement si le coût bcrypt actuel (
Rate limiting
Section titled “Rate limiting”Sliding window de 15 minutes, deux buckets indépendants évalués en cascade (email d’abord, puis IP) :
| Bucket | Seuil | Effet |
|---|---|---|
| 5 échecs | Bloque toute tentative pour cet email pendant le reste de la fenêtre. | |
| ip | 20 échecs | Bloque toute tentative depuis cette IP pendant le reste de la fenêtre. |
- Seuls les échecs (
401 Invalid credentials) incrémentent les compteurs.400/422/403ne comptent pas. - Un login réussi vide le bucket email du compte (slate propre pour l’utilisateur légitime) ; le bucket IP reste — protège contre le credential spray multi-comptes.
- Réponse bloquée :
429+ header HTTPRetry-After: <secondes>+meta.retryAfterSeconds(même valeur) +meta.triggeredBy("email"ou"ip").
{ "jsonapi": { "version": "1.1" }, "errors": [ { "status": "429", "title": "Too many login attempts", "detail": "Login is temporarily blocked. Try again later.", "meta": { "retryAfterSeconds": 612, "triggeredBy": "email" } } ]}Limites définies dans LoginThrottle (MAX_PER_EMAIL, MAX_PER_IP, WINDOW_MINUTES). Stockage : table login_attempt (migration).
GET /api/auth/me
Section titled “GET /api/auth/me”Retourne l’utilisateur courant à partir du bearer token.
- Auth : requise (Bearer)
- Action : MeAction
- Réponse
200:
{ "jsonapi": { "version": "1.1" }, "data": { "type": "users", "id": "<user-uuid>", "attributes": { "username": "havoc", "nickname": "Havoc", "email": "user@example.com", "displayName": "Havoc", "userType": "member", "status": "active", "isVerified": false, "experience": 0, "level": 1, "levelProgress": 0.00, "isConfirmed": true, "isBanned": false } }}- Réponse
401— token absent / mal formé / expiré / révoqué - Effet de bord : chaque appel rafraîchit
last_used_atet fait glisserexpires_atde +30 jours.
GET /api/auth/sessions
Section titled “GET /api/auth/sessions”Liste toutes les sessions actives (non expirées) de l’utilisateur courant, triées par dernier usage décroissant. Sert à alimenter une page « Mes sessions actives » avec un bouton de révocation par session.
- Auth : requise (Bearer)
- Action : ListSessionsAction
- Réponse
200:
{ "jsonapi": { "version": "1.1" }, "data": [ { "type": "userSessions", "id": "<session-uuid-1>", "attributes": { "createdAt": "2026-06-06T10:00:00+00:00", "lastUsedAt": "2026-06-06T12:30:00+00:00", "expiresAt": "2026-07-06T12:30:00+00:00", "userAgent": "Mozilla/5.0 (...)", "ipAddress": "127.0.0.1", "isCurrent": true } }, { "type": "userSessions", "id": "<session-uuid-2>", "attributes": { "createdAt": "2026-06-05T08:15:00+00:00", "lastUsedAt": "2026-06-05T20:00:00+00:00", "expiresAt": "2026-07-05T20:00:00+00:00", "userAgent": "Hydrogen-iOS/1.0", "ipAddress": "2a01:e35:...", "isCurrent": false } } ], "meta": { "count": 2 }}- Réponse
401— token absent / invalide - Notes :
ipAddressest rendu en format lisible (inet_ntopsur les octets stockés enVARBINARY(16)), IPv4 ou IPv6.isCurrentflag la session associée au bearer token de la requête en cours — utile pour griser un bouton « révoquer » dans l’UI.- L’appel lui-même fait glisser
expiresAtde la session courante (effet de bord normal du middleware d’auth).
DELETE /api/auth/sessions/{id}
Section titled “DELETE /api/auth/sessions/{id}”Révoque une session précise par son UUID. La session doit appartenir à l’utilisateur courant — sinon 404 (volontaire : ne fuite pas l’existence des sessions d’autres comptes). Si l’{id} cible la session courante, le token utilisé devient immédiatement invalide.
- Auth : requise (Bearer)
- Action : RevokeSessionAction
- Path params :
id(string, UUID) — id de la session à révoquer (typiquement obtenu viaGET /api/auth/sessions).
- Request body : aucun
- Réponse
204— révoquée avec succès, pas de contenu - Réponse
400—{id}n’est pas un UUID valide - Réponse
404— aucune session avec cet id n’appartient au user courant (id inconnu ou appartient à un autre user — même réponse pour ne pas révéler l’existence) - Réponse
401— token absent / invalide - Notes :
- Pour révoquer toutes les autres sessions d’un coup, utiliser plutôt
POST /api/auth/logout-all(mais celui-ci tue aussi la session courante). - L’UI typique appelle cet endpoint depuis un bouton « Révoquer » sur chaque ligne de la liste rendue par
GET /api/auth/sessions.
- Pour révoquer toutes les autres sessions d’un coup, utiliser plutôt
POST /api/auth/logout
Section titled “POST /api/auth/logout”Révoque uniquement la session courante (celle associée au token utilisé).
- Auth : requise (Bearer)
- Action : LogoutAction
- Request body : aucun
- Réponse
204— pas de contenu - Réponse
401— token absent / invalide
POST /api/auth/logout-all
Section titled “POST /api/auth/logout-all”Révoque toutes les sessions de l’utilisateur courant (déconnexion de tous les appareils). Le token utilisé pour cet appel est lui-même invalidé.
- Auth : requise (Bearer)
- Action : LogoutAllAction
- Request body : aucun
- Réponse
200:
{ "jsonapi": { "version": "1.1" }, "data": { "type": "sessionRevocations", "id": "<user-uuid>", "attributes": { "revokedCount": 3 } }}- Réponse
401— token absent / invalide - Cas d’usage typiques : bouton « Se déconnecter de tous les appareils », rotation de mot de passe, suspicion de compromission.