Trois façons de se connecter : pseudo + mot de passe, Apple, Google. Toutes rendent le même JWT. Contrats d'origine :
back/AUTH.md, back/PROFILE.md.
| Variable | Défaut | Rôle |
|---|---|---|
JWT_SECRET |
valeur de développement (à remplacer en production) | clé HMAC des jetons |
JWT_DAYS |
30 | durée de vie d'un jeton |
APPLE_BUNDLE_ID |
identifiant de l'app | destinataire (aud) attendu des jetons Apple |
APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_KEY_FILE |
équipe Apple / vide / /run/secrets/slidz/apple_signin.p8 |
secret client Apple (échange de code, révocation) |
GOOGLE_CLIENT_IDS |
vide | aud acceptés pour Google, séparés par des virgules ; vide = Google refusé |
ADMIN_PSEUDOS |
vide | pseudos promus admin au démarrage |
AVATAR_DIR |
/code/media/avatars |
dossier des photos (volume Docker en production) |
ANTHROPIC_API_KEY |
vide | vérification des photos ; vide = pas de vérification |
| Élément | Valeur |
|---|---|
| Algorithme | HS256 (PyJWT), clé JWT_SECRET |
| Contenu | sub = users.id (32 hex), exp = maintenant + JWT_DAYS jours. Rien d'autre (ni iat, ni rôle) |
| Transport | Authorization: Bearer <jwt> ; /ws accepte aussi ?token= |
| Vérification | decode_token : signature + exp. Puis deps.user_from_header charge l'utilisateur et refuse s'il est absent ou deleted_at non NULL → 401 |
| Rafraîchissement | aucun. Après 30 jours, 401 ; l'app (onUnauthorized) se déconnecte |
| Révocation | aucune liste. La déconnexion est locale (l'app oublie le jeton). Seule la suppression du compte invalide les jetons (HTTP et /ws) |
Les droits (is_admin, abonnement) sont relus en base à chaque requête : pas besoin de nouveau jeton quand ils
changent. current_user met aussi à jour users.timezone depuis l'en-tête X-Timezone.
Code : back/app/security.py (create_token, decode_token), back/app/deps.py (user_from_header, current_user, admin_user)
POST /auth/register et POST /auth/login (Credentials : pseudo 1 à 40 caractères, mot de passe 4 à 72
caractères et 72 octets au plus, limite de bcrypt : un accent compte 2 octets ; au-delà, 422 « Mot de passe trop
long »).
| Réglage | Défaut | Rôle |
|---|---|---|
LOGIN_MAX_FAILS |
5 | mauvais mots de passe sur un pseudo avant blocage de la connexion |
LOGIN_LOCK_S |
900 (15 min) | durée de la fenêtre de comptage et du blocage |
clean_pseudo : strip(), puis @ ajouté devant s'il manque.new_pseudo, inscription, Apple, Google) : 3 à 20 caractères [A-Za-z0-9._] après le@ (PSEUDO_RE), sinon 422 avec la règle (« Pseudo : 3 à 20 caractères, lettres sans accent, chiffres, point ouuq_users_pseudo_lower sur lower(pseudo) (migration 0013).user_by_pseudo compare lower(pseudo) : @Slidz est refusé puisque @slidz existe, et @Meiryu = @meiryuADMIN_PSEUDOS, console).bcrypt.hashpw (sel gensalt() pardefault_name (@lucas.drift → « Lucas »). Renvoie {token, user}.bcrypt.checkpw. Pseudo inconnu ou mot de passelogin:fails:<pseudo en minuscules, avec @>redis.over_limit : INCR, EXPIRE LOGIN_LOCK_S au premier échec). Dès LOGIN_MAX_FAILS échecs, toutepassword_hash = "!" : aucun mot de passe ne correspond (comptes Apple/Google et comptes supprimés).PATCH /me ne modifie que name et bio). Pas de route de changement de mot de passe.POST /auth/apple {identity_token, authorization_code, pseudo?}, POST /auth/google {id_token, pseudo?}.
oauth._verify)kid lu dans l'en-tête non vérifié.appleid.apple.com/auth/keys, Google googleapis.com/oauth2/v3/certs), en cacheKEYS_TTL_S). kid inconnu → rechargement forcé.jwt.decode : signature, aud (APPLE_BUNDLE_ID ou GOOGLE_CLIENT_IDS), exp, iss, sub requis.iss dans la liste attendue. Échec → 401 « Connexion … refusée ».needs_pseudo (social_login)jeton valide ──► compte avec ce sub (et deleted_at NULL) ? ── oui ──► {token, user}
│ non
├─ pas de pseudo ──► {needs_pseudo: true} (l'app demande un pseudo)
└─ pseudo ──► 409 si pris ; sinon création (password_hash "!", sub) ──► {token, user}
sub est gardé (apple_sub ou google_sub, uniques). Ni e-mail, ni nom.authorization_code échangé contre apple_refresh_token (pour la révocation). Sans clé.p8 : pas d'échange, la connexion marche quand même. Connexion d'un compte sans jeton : nouvel essai d'échange.POST /auth/apple/notifications : JWT signé par Apple (iss, aud, iat requis). consent-revoked ouaccount-delete → delete_account du compte lié. Autres types ignorés.Code : back/app/routers/auth.py (register, login, social_login, apple, google, apple_notifications), back/app/services/oauth.py
DELETE /me (ou notification Apple) → accounts.delete_account. Le contenu partagé reste, signé « Joueur supprimé ».
| Étape | Effet |
|---|---|
leave_groups |
quitte chaque groupe ; owner transmis au plus ancien membre ; groupe supprimé s'il était seul ; message « Un joueur a supprimé son compte » |
| Lignes supprimées | devices, follows (deux sens), blocks (deux sens), user_categories, notifications, events, season_scores, user_badges, xp_events, daily_progress, user_challenges |
| Redis | ZREM dans toutes les clés lb:* (classements) |
| Photo | fichier effacé, avatar_url NULL |
| Anonymisation | pseudo = "@supprime.<12 premiers caractères de l'id>", name = "Joueur supprimé", bio vide, couleur grise |
| Accès | révocation Apple, apple_sub, google_sub, apple_refresh_token à NULL (le même compte Apple/Google peut recréer un compte neuf), password_hash = "!" |
| Droits | showcase_badge_ids vide, is_admin et is_subscriber faux, champs d'abonnement à NULL |
| Marque | deleted_at = now |
Ce qui reste : la ligne users (id, XP, niveau, jours joués, pays, fuseau, plateforme, version, dates), ses
questions, commentaires, likes, sauvegardes, réponses (answers), statistiques par thème, messages, parties jouées,
signalements envoyés, ses conversations privées (il en reste membre).
Effets : user_or_404 le traite comme introuvable ; reachable refuse d'écrire ou de jouer avec lui ; la recherche
filtre deleted_at ; /ws refuse son jeton.
Code : back/app/services/accounts.py (leave_groups, delete_account), back/app/routers/me.py (delete_me)
users.is_admin. Dépendance Admin (deps.admin_user) : 403 sinon. Toutes les routes /api/admin/*.main.py, lifespan, sous le verrou consultatif PostgreSQL 734001) : chaque pseudo deADMIN_PSEUDOS (user_by_pseudo) passe is_admin = true. Jamais l'inverse.POST /api/admin/users/{pseudo}/admin?value=true|false. Un admin ne peut pas se retirer ses propres droitsis_team), création de groupe sans Slidz+.is_founder : drapeau à part, posé par le seed (badge « Membre fondateur »). Aucun droit côté serveur.PUT /me/avatar (fichier), DELETE /me/avatar, console DELETE /api/admin/users/{pseudo}/avatar.
| Étape | Détail |
|---|---|
| Lecture | 8 Mo au plus (MAX_BYTES), sinon 413 ; pas une image → 422 ; plus de 40 Mpx refusé (bombe de décompression) |
| Normalisation | orientation EXIF appliquée, RGB, recadrage carré 512 px (LANCZOS), JPEG qualité 85 : plus aucune métadonnée |
| Stockage | AVATAR_DIR/<32 hex aléatoires>.jpg ; ancien fichier effacé ; avatar_url = "/avatars/<fichier>" |
| Service | StaticFiles monté sur /avatars (nom jamais réutilisé, donc pas de cache périmé) |
| Modération | après le commit, tâche asyncio du worker (check_in_background) : Claude (modèle MODERATION_MODEL) reçoit l'image ; verdict refus → photo retirée et entrée de boîte moderation |
| Sans clé ou en échec | la photo reste ; seuls les signalements et la console agissent |
refuse ne retire rien si la photo a changé entre-temps.
Code : back/app/services/avatars.py (normalize, set_avatar, check, refuse), back/app/routers/me.py (upload_avatar, remove_avatar), back/app/main.py (montage /avatars, ADMIN_PSEUDOS)
/auth/register, /auth/apple, /auth/google. /auth/login n'est limité que@supprime.… disponibles : la règle des pseudos les accepte ; rien n'empêche d'en prendre un.INSERT, sans capture de l'erreur d'unicitéADMIN_PSEUDOS ne retire jamais : enlever un pseudo de la variable ne le rétrograde pas.