Compare commits

...
7 Commits
Author SHA1 Message Date
nicolas 02b7af8253 Comptes joueurs (8a) : déploiement et documentation
- launcher-api-players.conf : complément systemd (LoadCredentialEncrypted).
- LISEZMOI : étape 3 undecies (préparation, ne pas activer en production
  avant 8g), commandes de secours, fichiers sur le VPS.
- Nouveau docs/PLAYER-ACCOUNTS-ARCHITECTURE.md.
- ADMIN-ARCHITECTURE : dépendances NuGet, phase 8a au § 13, § 14 mis à jour.
2026-11-10 22:55:41 +01:00
nicolas 723df05cf3 Comptes joueurs (8a) : tests
- Règles des mots de passe, adresses, références, jetons.
- Création, connexion, blocages, suspension, session mémorisée (rotation,
  réutilisation, clé d'appareil), appareils, mot de passe, désactivation, purge.
- Étanchéité : toutes les routes admin déclarées essayées avec des jetons joueurs
  (401 partout), et l'inverse.
- Chiffrement, clé maîtresse, empreintes ; vecteur officiel Argon2id (RFC 9106).
2026-11-10 22:55:19 +01:00
nicolas 85e69e6a2c Comptes joueurs (8a) : commandes de secours en ligne de commande
players-key, players-show, players-unlock, players-disable [--until], players-enable.
Compte désigné par sa référence ; l'adresse e-mail n'est jamais affichée.
Actions inscrites au journal d'audit (cli.players_*).
2026-11-10 22:55:08 +01:00
nicolas 7a6ad2247b Comptes joueurs (8a) : module Accounts et authentification séparée des admins
- Routes : défi, création de compte, connexion, renouvellement, déconnexion,
  « Mon compte », changement de mot de passe (dont exigé), appareils.
- Schéma PlayerSession distinct ; les règles admin désignent explicitement
  AdminSession et le gestionnaire admin ignore les jetons glp… .
- PlayerGuard : défis sans état à usage unique, preuve de travail, limites par IP,
  blocage miroir des adresses inconnues.
- Anti-force brute : 5 échecs → 15/30/60 min ; 20 échecs consécutifs → suspension
  des nouveaux appareils (levée par un admin) ; appareils de confiance jamais suspendus.
- Session mémorisée liée à la clé d'appareil, jeton remplacé à chaque usage,
  réutilisation détectée.
- Limite de débit « account », ouverture de la base au démarrage, comptes annoncés
  dans v1/info, purge du journal des comptes (12 mois, IP effacées après 6 mois).
- appsettings.json : section Players (Enabled = false).
2026-11-10 22:54:57 +01:00
nicolas 19f207d430 Comptes joueurs (8a) : stockage SQLite chiffré et empreintes Argon2id
- Paquets Microsoft.Data.Sqlite 8.0.34 et Konscious.Security.Cryptography.Argon2 1.3.1.
- PlayerSecrets : clé maîtresse hors du dossier des données (credential systemd),
  sous-clés HKDF, adresses chiffrées AES-256-GCM liées au compte, index aveugle.
- PlayerPasswordHasher : Argon2id 19 Mio × 2, sel aléatoire 128 bits par compte,
  poivre = clé secrète d'Argon2, format PHC, file de 4 calculs, calcul fictif
  pour une adresse inconnue.
- PlayerStore : players/players.db (WAL, secure_delete, 0600, schéma versionné),
  comptes, appareils, journal des comptes, compteurs d'échecs et suspension.
- Réglages LauncherApi:Players (désactivé par défaut) et leur validation.
2026-11-10 22:54:05 +01:00
nicolas a35e419133 Comptes joueurs (8a) : protocole launcher ⇄ serveur
- Messages signés par la clé d'appareil (en-tête GameLauncher/Player/…,
  distinct des messages admin), types de défi (création, connexion, renouvellement).
- Jetons joueurs glpa_ (accès), glpr_ (renouvellement), glpc_ (changement exigé),
  identifiants et références de compte PL-XXXX-XXXX.
- Objets échangés, routes v1/account/…, 13 codes d'erreur, ServerInfo.Accounts.
- SignedMessages.Build passe en internal (réutilisé par PlayerMessages).
2026-11-10 22:53:17 +01:00
nicolas a9d07f6df8 Comptes joueurs (8a) : règles des mots de passe et des adresses e-mail
- PasswordRules : 12 caractères et 3 types, ou 16 sans contrainte ; 128 au plus.
  Refus : dictionnaire NCSC (99 839 mots de passe) et ses variantes simples,
  mots de passe français courants, suites (répétitions, claviers, suites de
  chiffres ou de lettres), mots tirés de l'adresse ou des noms du studio et des jeux.
  Aucune normalisation Unicode : même calcul dans le launcher et sur le serveur.
- PlayerEmail : contrôle et normalisation (ASCII), masquage pour l'affichage.
- Dictionnaire intégré compressé (430 Ko), avec son origine et sa licence (MIT).
2026-11-10 22:52:37 +01:00
38 changed files with 4754 additions and 14 deletions

No files matched your search

+5 -3
View File
@@ -80,7 +80,8 @@ Dépendances : `Contracts` ne dépend de rien ; `ApiClient` → `Contracts` ; `S
(uniquement : le serveur n'utilise pas `Core`) ; `Core` → `Contracts` (règles du catalogue, phase 3) ;
`Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ; `Modules.Feedback` → `Modules` ;
`GameLauncher` → `Core`, `Modules`, `Modules.Admin`, `Modules.Feedback`.
Aucun paquet NuGet n'est nécessaire en dehors du projet de tests (xUnit).
Paquets NuGet : xUnit (projet de tests) ; depuis la phase 8, le serveur utilise aussi `Microsoft.Data.Sqlite` (Microsoft)
et `Konscious.Security.Cryptography.Argon2` (licence MIT) pour les comptes joueurs (`docs/PLAYER-ACCOUNTS-ARCHITECTURE.md`).
## 5. Modules
@@ -1056,6 +1057,7 @@ trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; re
| **7d** | Demandes des joueurs : bouton « Aide » (avis, évolution, bug, autre ; anonyme, preuve de travail, limites), module serveur `Feedback`, page « Demandes des joueurs », purge automatique (demandes et journal d'audit) | ✅ |
| **7e** | Formulaires des demandes modifiables depuis le menu (types, champs, icônes importées), signés (autorisation `forms`) | ✅ |
| **7f** | Mode maintenance du launcher : page « Maintenance », module serveur `Maintenance`, document signé (autorisation `maintenance`), blocage côté joueur sauf la désinstallation, thème rouge / orange | ✅ |
| **8a** | Comptes joueurs — socle serveur (module `Accounts`, schéma d'authentification `PlayerSession` totalement séparé des administrateurs, base SQLite chiffrée, Argon2id + poivre, session mémorisée liée à une clé d'appareil, anti-force brute, journal des comptes, outil en ligne de commande) ; désactivé par défaut. Suite (8b à 8h) : `docs/PLAYER-ACCOUNTS-ARCHITECTURE.md`, § 12 | ✅ |
Chaque phase compile, est testée et laisse le launcher utilisable. Pour les joueurs, rien ne
change : le menu d'administration est discret et ne contacte le serveur qu'une fois ouvert.
@@ -1064,8 +1066,8 @@ change : le menu d'administration est discret et ne contacte le serveur qu'une f
| Option | Pourquoi elle n'a pas été retenue |
|---|---|
| Identifiant + mot de passe + OTP | Mots de passe hameçonnables ; secrets OTP stockés sur le serveur |
| Identifiant + mot de passe + OTP (administrateurs) | Mots de passe hameçonnables ; secrets OTP stockés sur le serveur. Les **comptes joueurs** (phase 8), totalement séparés, utilisent adresse + mot de passe + OTP facultatif : ils n'ouvrent jamais le menu d'administration |
| Envoi SFTP direct depuis le launcher | Pas de droits par rôle, pas d'audit, pas de validation serveur, clé SFTP = accès total aux fichiers |
| Secret partagé dans l'exe | Extractible par n'importe quel joueur |
| Base de données (SQL) | Inutile à cette échelle : fichiers JSON écrits atomiquement, verrou inter-processus, sauvegarde. SQLite n'est pas exclu : sa légitimité est évaluée **module par module** (voir § 11 quinquies pour les demandes des joueurs) |
| Base de données (SQL) | Inutile à cette échelle : fichiers JSON écrits atomiquement, verrou inter-processus, sauvegarde. SQLite n'est pas exclu : sa légitimité est évaluée **module par module** (voir § 11 quinquies pour les demandes des joueurs) ; **retenu pour les comptes joueurs** (relations, unicité, volumes : `PLAYER-ACCOUNTS-ARCHITECTURE.md` § 6) |
| Thème XAML distant | Un XAML téléchargé peut référencer du code : seuls les thèmes intégrés sont sélectionnables |
+247
View File
@@ -0,0 +1,247 @@
# Comptes joueurs — Architecture (phase 8)
> Document de référence du module « comptes joueurs ». Plan validé le 2026-11-10 (copie du plan dans les documents du
> projet : `GameLauncher-Comptes-Joueurs-Plan.md`). Statut des étapes : § 12.
> À relire avec `ADMIN-ARCHITECTURE.md` (socle, principes de sécurité) et la fiche RGPD / CNIL du projet.
## 1. Besoin
* Compte joueur = **adresse e-mail + mot de passe**, **OTP (TOTP) facultatif** (étape 8c).
* **Jamais obligatoire** pour lancer le launcher, installer ou jouer (jeux solo : anonyme). Obligatoire seulement pour
la future partie sociale.
* Bouton « Aide » : **anonyme** (comme aujourd'hui, aucune réponse) **ou connecté** (suivi + échanges avec les
administrateurs) — étape 8f.
* Menu admin (étape 8d), avec permissions : supprimer, désactiver / réactiver (durée ou indéfini), forcer le changement
de mot de passe, mot de passe temporaire, réinitialiser l'OTP ; blocages par IP publique et par identifiants matériels
du PC (étape 8e).
* **Aucun lien** avec les comptes administrateurs : un compte joueur ne peut **jamais** ouvrir le menu d'administration.
* Dictionnaire public des pires mots de passe ; anti-force brute (blocage temporaire puis suspension levée par un
administrateur) ; **connexion mémorisée** (comme Steam) ; **audit** de chaque action joueur et administrateur.
## 2. Principes
1. **Le serveur est la seule barrière** : chaque règle est revérifiée côté serveur avec le même code partagé
(`GameLauncher.Contracts.Accounts`).
2. **Deux mondes étanches** : administrateurs = clé publique/privée et requêtes signées ; joueurs = mot de passe.
Schémas d'authentification ASP.NET distincts (`AdminSession` / `PlayerSession`), jetons de préfixes distincts
(`glpa_`, `glpr_`, `glpc_` pour les joueurs), stockages distincts (`admins.json` + sessions en mémoire / `players.db`),
routes distinctes (`v1/account/…`). Chaque règle d'autorisation d'administration **désigne explicitement** le schéma
`AdminSession` ; le gestionnaire administrateur ignore tout jeton commençant par `glp`. Un test essaie **toutes** les
routes d'administration déclarées avec des jetons joueurs (accès, changement exigé, renouvellement) : 401 attendu
partout (`PlayerSeparationTests`).
3. **Minimisation** : ni nom, ni date de naissance, ni adresse postale, ni nom Windows du PC. Le pseudo viendra avec le
module social.
4. **Une copie des données du serveur ne livre rien d'exploitable** : adresses chiffrées, mots de passe en Argon2id avec
un « poivre », clé maîtresse hors du dossier des données.
5. **Désactivé par défaut** (`LauncherApi:Players:Enabled = false`) ; création de compte désactivable séparément.
6. Le launcher ne contacte l'API que si le joueur l'a voulu (connexion, création) ou s'il a une session mémorisée.
## 3. Protocole
### 3.1 Clé d'appareil
Le launcher crée une clé ECDSA P-256 **par PC** (étape 8b : dans la puce TPM si elle existe, sans code PIN, non
exportable ; sinon clé logicielle protégée par DPAPI). La création de compte, la connexion et chaque renouvellement de
session sont **signés** par cette clé (`PlayerMessages.Device`) :
```
GameLauncher/Player/Device/v1
purpose:<register|login|refresh>
origin:https://launcher.lanisys.fr
server:<serverId>
challenge:<challengeId>
nonce:<nonce>
device:<keyId de la clé d'appareil>
```
En-tête propre aux joueurs (séparation de domaine avec `GameLauncher/Admin/…`). Bénéfices : un jeton de renouvellement
copié sur un autre PC est inutilisable ; seul un appareil déjà connecté avec succès est « de confiance » pour
l'anti-force brute.
### 3.2 Défi
`POST v1/account/challenge { purpose }` → `{ challengeId, nonce, purpose, difficulty, serverId, origin, expiresAt }`.
Défi **sans état** (usage, échéance, difficulté et nonce scellés par HMAC, secret en mémoire, domaine
`GameLauncher/Player/Challenge/v1`), valable 5 min, **à usage unique** (consommé seulement avec une signature d'appareil
valide). Preuve de travail (`FeedbackProof`) : 20 bits pour la création de compte, 16 pour la connexion, aucune pour le
renouvellement. Un défi d'un usage ne sert pas à un autre.
### 3.3 Routes
| Route | Accès | Rôle |
|---|---|---|
| `POST v1/account/challenge` | anonyme | Défi (§ 3.2) |
| `POST v1/account/register` | anonyme + défi + clé d'appareil | Création de compte → session ouverte |
| `POST v1/account/login` | anonyme + défi + clé d'appareil | Connexion → session, ou jeton « changement exigé » |
| `POST v1/account/refresh` | jeton de renouvellement + signature de l'appareil | Session renouvelée (jeton remplacé) |
| `POST v1/account/logout` | session | Déconnexion de cet appareil, ou de tous (`allDevices`) |
| `GET v1/account/me` | session | Compte (adresse **masquée**) et appareils connectés |
| `POST v1/account/password` | session ou jeton « changement exigé » | Nouveau mot de passe |
| `DELETE v1/account/devices/{deviceId}` | session | Déconnecter un appareil du compte |
Limite de débit propre (`account`, `Players:AuthRequestsPerMinute`, 20/min par IP, IPv6 par /64) sur le défi, la
création, la connexion, le renouvellement et le changement de mot de passe, en plus de la limite générale. `GET v1/info`
annonce `accounts` (activés, création ouverte, version des conditions, version des règles des mots de passe) ; absent
si les comptes sont désactivés.
### 3.4 Jetons et session mémorisée
| Jeton | Format | Durée | Où |
|---|---|---|---|
| Accès | `glpa_` + 256 bits | 15 min | mémoire du launcher ; serveur : empreinte en mémoire |
| Renouvellement | `glpr_` + identifiant d'appareil + `.` + 256 bits | 90 jours sans utilisation, 1 an au plus | PC (chiffré DPAPI, étape 8b) ; serveur : empreinte en base |
| Changement exigé | `glpc_` + 256 bits | 10 min, usage unique | mémoire |
Renouvellement : jeton **remplacé à chaque fois** (l'ancien devient « précédent ») ; un jeton précédent présenté de
nouveau = copie volée (ou réponse perdue) → l'appareil est déconnecté par précaution (`session.reuse`). Deux
renouvellements simultanés avec le même jeton : un seul réussit. Un redémarrage du service efface les jetons d'accès : le
launcher renouvelle sa session sans rien demander au joueur.
À chaque requête authentifiée : jeton valide **et** compte actif (ou suspendu, § 5) **et** appareil toujours enregistré
**et** aucun changement de mot de passe exigé. Une désactivation, une déconnexion ou un changement de mot de passe
prennent effet immédiatement.
## 4. Mots de passe et adresses
* **Règles** (`PasswordRules`, identiques launcher / serveur) : 12 caractères au moins avec 3 types parmi minuscules,
majuscules, chiffres, symboles ; ou 16 caractères sans contrainte (phrase de passe) ; 128 au plus ; pas de caractère
de contrôle. Refus : mots de passe du **dictionnaire** (« 100k-most-used-passwords-NCSC », 99 839 entrées, ressource
intégrée compressée, + une liste de mots de passe français courants) et leurs variantes simples (minuscules,
substitutions `0→o 1→i 3→e 4→a 5→s 7→t @→a $→s…`, chiffres et symboles retirés aux extrémités :
« P@ssw0rd2024! » ≡ « password ») ; suites triviales (répétitions, rangées de claviers AZERTY / QWERTY / QWERTZ, suites
de lettres ou de chiffres, motifs répétés) ; mot de passe tiré de l'adresse e-mail ou des noms du studio et des jeux
(moins de 8 caractères restants une fois ces mots retirés).
* **Aucune normalisation Unicode** : le serveur tourne en « globalisation invariante » (sans ICU), où elle n'est pas
disponible ; le mot de passe est contrôlé et haché tel qu'il est saisi, de la même façon partout.
* **Adresse** (`PlayerEmail`) : minuscules, caractères ASCII seulement (domaines internationaux sous forme `xn--`), un
seul `@`, 254 caractères au plus ; masquée pour l'affichage (`n***@e***.fr`). Non vérifiée tant que le module e-mail
n'existe pas (`emailVerified = false`).
* **Empreinte** (`PlayerPasswordHasher`) : Argon2id (RFC 9106 ; paquet `Konscious.Security.Cryptography.Argon2`, MIT),
19 Mio, 2 passes, parallélisme 1 (OWASP), sel 128 bits, **poivre = clé secrète d'Argon2** dérivée de la clé maîtresse.
Format PHC `$argon2id$v=19$m=19456,t=2,p=1,k=1$<sel>$<empreinte>` : un relèvement des réglages est appliqué à la
connexion suivante de chaque joueur. Calculs limités à 4 simultanés (file d'attente, 503 au-delà de 20 s) ; même calcul
pour une adresse inconnue (la durée de réponse ne révèle rien). Vecteur officiel de la RFC vérifié par un test
(`Argon2VectorTests`).
## 5. Anti-force brute
1. Limites de débit (nginx et ASP.NET) et preuve de travail.
2. **Par adresse IP** (en mémoire) : 30 échecs de connexion / 15 min tous comptes confondus → 429 ; 3 créations de
compte / heure et 5 / jour → 429 ; plafond global de 500 créations / jour → 503.
3. **Par compte, depuis un appareil inconnu** : blocage après **5 échecs consécutifs** (15 min, puis 30, puis 60 :
durée doublée à chaque blocage) ; au **4ᵉ blocage (20 échecs consécutifs)**, le compte est **suspendu**. Le blocage
s'applique avant tout calcul (429 + `Retry-After`).
4. **Appareils de confiance** (déjà connectés, prouvés par leur clé) : leurs échecs sont comptés à part (blocage de
15 min après 10 échecs), **jamais de suspension**.
5. **Suspension = connexions depuis un NOUVEL appareil refusées** jusqu'à ce qu'un administrateur la lève (outil en ligne
de commande dès maintenant, menu d'administration à l'étape 8d). Les appareils déjà connectés continuent de
fonctionner (`newDevicesSuspended` dans `GET v1/account/me`) : un tiers qui connaît l'adresse peut déclencher la
suspension, mais pas priver le joueur de son compte. Inscrite au journal d'audit (`players.suspend`, par référence).
6. **Rien ne révèle qu'une adresse a un compte** : message identique pour une adresse inconnue et un mauvais mot de
passe, même durée de calcul, et une adresse inconnue se **bloque exactement comme un compte réel** (blocage « miroir »
en mémoire). L'état du compte (désactivé, suspendu) n'est révélé qu'après un **mot de passe correct**. Seule exception
inévitable sans e-mail de confirmation : la création de compte indique qu'une adresse est déjà prise (atténué par les
points 1 et 2).
7. Une connexion réussie remet les compteurs à zéro.
## 6. Stockage
**SQLite retenu** pour ce module (analyse « module par module ») : relations (compte ⇄ appareils ⇄ journal, puis
conversations et blocages), unicité stricte des adresses sous transaction, volumes non bornés, recherches par index.
`Microsoft.Data.Sqlite` (Microsoft), sans Entity Framework, requêtes paramétrées uniquement.
* Fichier : `/var/lib/launcher-api/players/players.db` (dossier 0700, fichier 0600 créé avant l'ouverture ; les fichiers
`-wal` et `-shm` reprennent ses droits). Journal WAL, clés étrangères, `secure_delete` (lignes supprimées écrasées),
écriture synchrone complète. Schéma versionné (`PRAGMA user_version`, version 1). Une seule connexion protégée par un
verrou ; l'outil en ligne de commande peut ouvrir la base en même temps.
* Tables : `accounts` (identifiant 128 bits, référence `PL-XXXX-XXXX`, index aveugle et adresse chiffrée, empreinte du
mot de passe, « changement exigé », champs OTP réservés à l'étape 8c, état, échéance de désactivation, motif chiffré,
dates, version des conditions acceptée, compteurs d'échecs et blocages) ; `devices` (clé publique, nom générique,
empreintes du jeton de renouvellement en vigueur et précédent, échéances) ; `account_events` (journal, § 7).
* **Chiffrement** (`PlayerSecrets`) : clé maîtresse de 256 bits → sous-clés HKDF-SHA256 par usage (chiffrement des
adresses, index aveugle, poivre, champs libres). Adresse : AES-256-GCM, nonce aléatoire, **liée au compte** (données
associées : une valeur copiée sur un autre compte ne se déchiffre pas), préfixe de version de clé (`1.`). Recherche et
unicité par **index aveugle** HMAC-SHA256 de l'adresse normalisée.
* **Clé maîtresse hors du dossier des données** : credential systemd chiffré (`LoadCredentialEncrypted`,
`players-master-key`, lu dans `$CREDENTIALS_DIRECTORY`) ou `Players:MasterKeyFile` (chemin absolu). Le serveur refuse
de démarrer si la clé manque, est dans le dossier des données, est accessible à d'autres comptes, ou n'est pas une clé
de 32 octets. **À sauvegarder hors ligne** : perdue, les adresses sont illisibles et tous les mots de passe doivent
être réinitialisés. Commande : `GameLauncher.Server players-key`.
* Les demandes des joueurs restent en fichiers JSON (étape 8f : identifiant opaque du compte ajouté, messages dans
SQLite, chiffrés).
## 7. Audit
* **Actions des joueurs et du système sur un compte** → table `account_events` : création, connexion réussie ou
refusée, blocage, suspension, déconnexion (un appareil / tous), changement de mot de passe (dont exigé), appareil
déconnecté, renouvellement (une ligne par appareil et par jour au plus), réutilisation d'un jeton. Avec l'adresse IP et
l'identifiant de la clé d'appareil, **jamais l'adresse e-mail ni le mot de passe**. Les échecs sur une adresse sans
compte sont inscrits sans compte.
* **Actions des administrateurs et du système** → journal d'audit existant, par **référence** de compte : `players.suspend`
(suspension automatique), `cli.players_unlock`, `cli.players_enable`, `cli.players_disable`, `players.events_purge`.
Les libellés lisibles et la catégorie « Comptes joueurs » du journal du menu arrivent avec l'étape 8d.
* **Conservation** (`RetentionService`, au démarrage puis chaque jour) : journal des comptes 12 mois
(`Players:EventsRetentionMonths`), adresses IP effacées au-delà de 6 mois (`Players:EventsIpRetentionMonths`).
## 8. Outil en ligne de commande (secours, sur le VPS)
Un compte est désigné par sa **référence** ; l'adresse e-mail n'est jamais affichée (aucune clé maîtresse nécessaire).
| Commande | Rôle |
|---|---|
| `players-key` | Génère une clé maîtresse (sortie standard, à chiffrer avec `systemd-creds`) |
| `players-show --ref PL-XXXX-XXXX` | État, dates, échecs, appareils, derniers événements |
| `players-unlock --ref …` | Lève une suspension ou un blocage (compte réactivé) |
| `players-disable --ref … [--until AAAA-MM-JJ[THH:MM]]` | Désactive (UTC ; sans date : indéfiniment) |
| `players-enable --ref …` | Réactive |
## 9. Réglages (`LauncherApi:Players`)
| Réglage | Défaut | Rôle |
|---|---|---|
| `Enabled` | `false` | Service des comptes |
| `RegistrationEnabled` | `true` | Création de compte ouverte |
| `MasterKeyFile` | vide | Clé maîtresse (vide : credential systemd `players-master-key`) |
| `TermsVersion` | `1` | Version des conditions à accepter à la création |
| `HashMemoryKib`, `HashIterations`, `HashParallelism` | 19456, 2, 1 | Argon2id (coût minimal refusé en dessous de 19 Mio × 2) |
| `MaxConcurrentHashes` | 4 | Calculs simultanés |
| `RegisterProofDifficulty`, `LoginProofDifficulty` | 20, 16 | Preuves de travail |
| `FailuresPerLock`, `LockMinutes`, `SuspendAfterLocks` | 5, 15, 4 | Anti-force brute (appareils inconnus) |
| `TrustedFailuresPerLock` | 10 | Appareils de confiance |
| `FailedLoginsPerIpPer15Minutes` | 30 | Échecs par IP |
| `RegistrationsPerIpPerHour`, `…PerDay`, `MaxRegistrationsPerDay` | 3, 5, 500 | Créations de compte |
| `AuthRequestsPerMinute` | 20 | Limite de débit des routes `account` |
| `AccessTokenMinutes`, `RefreshIdleDays`, `RefreshMaxDays` | 15, 90, 365 | Sessions |
| `MaxDevicesPerAccount` | 20 | Au-delà, le moins récemment utilisé est déconnecté |
| `EventsRetentionMonths`, `EventsIpRetentionMonths` | 12, 6 | Conservation du journal des comptes |
## 10. RGPD / CNIL
* Base légale : exécution du contrat (compte, échanges) ; intérêt légitime (journaux de sécurité, anti-force brute,
blocages). Aucun traitement par consentement.
* Étape 8b : export JSON de ses données et suppression du compte depuis le launcher ; interface commune
`IPersonalDataProvider` ; comptes inactifs (désactivés après 2 ans, supprimés 1 an plus tard).
* Étape 8g : registre des traitements, politique de confidentialité et CGU, brouillon d'AIPD (mineurs, identifiants
matériels, messagerie).
## 11. Limites assumées
* Adresse non vérifiée tant que le module e-mail n'existe pas (étape 8h) : n'importe qui peut créer un compte avec une
adresse qui n'est pas la sienne ; l'administrateur peut le supprimer (étape 8d).
* La création de compte révèle qu'une adresse est déjà utilisée (§ 5, point 6).
* Rotation de la clé maîtresse : prévue par le format (version de clé), pas encore outillée.
## 12. Étapes
| Étape | Contenu | Statut |
|---|---|---|
| **8a** | Socle serveur : contrats (`Contracts/Accounts`), module `Accounts`, schéma `PlayerSession`, base SQLite chiffrée, Argon2id + poivre, création / connexion / renouvellement / déconnexion / mot de passe / appareils, anti-force brute, journal des comptes, purge, outil en ligne de commande, tests (dont l'étanchéité sur toutes les routes d'administration) | ✅ |
| 8b | Launcher : bouton Compte, connexion, création, session mémorisée (clé TPM / DPAPI), « Mon compte », export et suppression | à venir |
| 8c | OTP (TOTP, codes de secours, QR maison) | à venir |
| 8d | Menu d'administration « Comptes joueurs » | à venir |
| 8e | Blocages par IP et par identifiants matériels (un blocage depuis une fiche vise les deux) | à venir |
| 8f | Demandes connectées et échanges avec les administrateurs | à venir |
| 8g | Mise en production (nginx, credential, sauvegardes, textes RGPD, AIPD, revue de sécurité indépendante) | à venir |
| 8h | E-mails (vérification, réinitialisation autonome) | futur |
Activation en production : après 8a, 8b, 8c, 8d et 8g au minimum.
+14 -2
View File
@@ -19,7 +19,8 @@ public static class AdminCli
private static readonly string[] Commands = { "add-owner", "add-key", "list", "revoke-admin", "revoke-key", "server-id", "help" };
public static bool IsCommand(string[] args) =>
args.Length > 0 && Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase);
args.Length > 0 && (Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase) ||
PlayersCli.Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase));
public static int Run(string[] args, TextWriter? output = null, string? dataDirectoryOverride = null)
{
@@ -29,6 +30,9 @@ public static class AdminCli
var command = args[0].ToLowerInvariant();
if (command == "help") return Help(o);
var options = ReadOptions(args);
// Clé maîtresse des comptes joueurs : n'utilise pas le dossier des données (peut être lancée par root, la sortie
// étant directement chiffrée par systemd-creds).
if (command == "players-key") return PlayersCli.Key(o, interactive: output is null);
// Lancé par erreur avec « sudo » (root) au lieu de « sudo -u deploy » : les fichiers recréés
// appartiendraient à root et le service ne pourrait plus les lire.
@@ -37,7 +41,7 @@ public static class AdminCli
"ne lancez pas cet outil en root : sudo -u deploy /opt/launcher-api/GameLauncher.Server " + command + " …");
var dataDirectory = dataDirectoryOverride ?? ResolveDataDirectory(options);
if (command != "add-owner" && command != "server-id" &&
if (command != "add-owner" && command != "server-id" && !command.StartsWith("players-", StringComparison.Ordinal) &&
!File.Exists(Path.Combine(dataDirectory, AdminStore.FileName)))
throw new InvalidOperationException(
$"aucun fichier {AdminStore.FileName} dans {dataDirectory} : mauvais dossier de données ? (option --data-dir)");
@@ -57,6 +61,11 @@ public static class AdminCli
case "server-id":
o.WriteLine(ServerIdentity.LoadOrCreate(dataDirectory));
return 0;
case "players-show":
case "players-unlock":
case "players-disable":
case "players-enable":
return PlayersCli.Run(command, options, dataDirectory, audit, actor, o);
default: return Help(o);
}
}
@@ -219,6 +228,9 @@ public static class AdminCli
revoke-key --key <identifiant> Révoque une clé (poste perdu ou volé).
server-id Affiche l'identifiant de ce serveur.
Comptes joueurs (désignés par leur référence ; l'adresse e-mail n'est jamais affichée) :
""" + "\n" + PlayersCli.HelpText + "\n" + """
Option commune : --data-dir <dossier> (sinon LauncherApi:DataDirectory d'appsettings.json).
""");
return 0;
@@ -0,0 +1,108 @@
using System.Globalization;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Storage;
using GameLauncher.Server.Storage.Players;
namespace GameLauncher.Server.Cli;
/// <summary>
/// Commandes de secours des comptes joueurs (sur le VPS, sous le compte du service). Elles désignent un compte par sa
/// RÉFÉRENCE (« PL-7KQ2-M9XD ») et n'affichent jamais l'adresse e-mail : elles n'ont pas besoin de la clé maîtresse.
/// Le service n'a pas besoin d'être arrêté (SQLite gère l'accès simultané) ; l'état du compte est relu à chaque requête.
/// La gestion courante se fait depuis le menu d'administration (étape 8d).
/// </summary>
internal static class PlayersCli
{
public static readonly string[] Commands = { "players-key", "players-show", "players-unlock", "players-disable", "players-enable" };
public const string HelpText = """
players-key Génère une clé maîtresse des comptes joueurs (à chiffrer avec systemd-creds, voir LISEZMOI).
players-show --ref <PL-XXXX-XXXX> État d'un compte joueur (sans son adresse e-mail).
players-unlock --ref <PL-XXXX-XXXX> Lève une suspension ou un blocage (compte réactivé).
players-disable --ref <PL-XXXX-XXXX> [--until AAAA-MM-JJ[THH:MM]] Désactive un compte (UTC ; sans --until : indéfiniment).
players-enable --ref <PL-XXXX-XXXX> Réactive un compte désactivé.
""";
/// <summary>Nouvelle clé maîtresse : sur la sortie standard (à rediriger vers systemd-creds), mode d'emploi sur stderr.</summary>
public static int Key(TextWriter o, bool interactive)
{
o.WriteLine(PlayerSecrets.NewMasterKey());
if (interactive)
Console.Error.WriteLine("Clé maîtresse générée (32 octets, Base64). À chiffrer immédiatement, sans la laisser sur le disque, ex. :\n" +
" GameLauncher.Server players-key | sudo systemd-creds encrypt --name=players-master-key - " +
"/etc/credstore.encrypted/players-master-key\n" +
"Gardez-en une copie HORS LIGNE (gestionnaire de mots de passe) : perdue, les adresses e-mail sont " +
"illisibles et tous les mots de passe doivent être réinitialisés.");
return 0;
}
public static int Run(string command, Dictionary<string, string> options, string dataDirectory, AuditLog audit, string actor, TextWriter o)
{
var path = Path.Combine(dataDirectory, PlayerStore.FolderName, PlayerStore.FileName);
if (!File.Exists(path)) throw new InvalidOperationException($"aucune base des comptes joueurs ({path}).");
using var store = new PlayerStore(dataDirectory, TimeProvider.System);
var reference = PlayerIds.NormalizeReference(Get(options, "ref"))
?? throw new ArgumentException("--ref <PL-XXXX-XXXX> obligatoire (référence du compte).");
var account = store.FindByReference(reference) ?? throw new InvalidOperationException($"aucun compte {reference}.");
var now = DateTimeOffset.UtcNow;
switch (command)
{
case "players-show":
Show(store, account, o);
return 0;
case "players-unlock":
case "players-enable":
store.SetStatus(account.Id, PlayerStatus.Active, null, null, actor, now);
store.AddEvent(account.Id, command == "players-unlock" ? PlayerEvents.Unlocked : PlayerEvents.Enabled, AuditResults.Ok, null,
details: $"par {actor} (outil en ligne de commande)");
audit.Write("cli." + command.Replace('-', '_'), AuditResults.Ok, actorName: actor, target: reference,
details: $"ancien état : {account.Status}");
o.WriteLine($"Compte {reference} réactivé (blocages remis à zéro).");
return 0;
case "players-disable":
DateTimeOffset? until = null;
if (Get(options, "until") is { Length: > 0 } text)
{
if (!DateTimeOffset.TryParseExact(text, new[] { "yyyy-MM-dd", "yyyy-MM-dd'T'HH:mm" }, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) || date <= now)
throw new ArgumentException("--until : date future attendue, AAAA-MM-JJ ou AAAA-MM-JJTHH:MM (UTC).");
until = date;
}
store.SetStatus(account.Id, PlayerStatus.Disabled, until, null, actor, now);
store.AddEvent(account.Id, PlayerEvents.Disabled, AuditResults.Ok, null,
details: $"par {actor} (outil en ligne de commande){(until is { } u ? $" jusqu'au {u:yyyy-MM-dd HH:mm} UTC" : " indéfiniment")}");
audit.Write("cli.players_disable", AuditResults.Ok, actorName: actor, target: reference,
details: until is { } end ? $"jusqu'au {end:yyyy-MM-dd HH:mm} UTC" : "indéfiniment");
o.WriteLine($"Compte {reference} désactivé{(until is { } d ? $" jusqu'au {d:yyyy-MM-dd HH:mm} UTC" : " indéfiniment")} " +
"(ses sessions sont refusées dès la requête suivante).");
return 0;
}
throw new ArgumentException($"commande inconnue : {command}");
}
private static void Show(PlayerStore store, PlayerAccountRecord account, TextWriter o)
{
static string Date(DateTimeOffset? d) => d is { } v ? v.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) : "—";
var devices = store.Devices(account.Id);
o.WriteLine($"Compte {account.Reference}");
o.WriteLine($" État : {account.Status}{(account.DisabledUntil is { } u ? $" jusqu'au {Date(u)}" : "")}");
o.WriteLine($" Créé le : {Date(account.CreatedAt)}");
o.WriteLine($" Dernière connexion : {Date(account.LastLoginAt)}");
o.WriteLine($" Mot de passe changé : {Date(account.PasswordChangedAt)}{(account.MustChangePassword ? " (nouveau mot de passe exigé)" : "")}");
o.WriteLine($" Échecs (inconnus) : {account.FailedCount} en cours, {account.LockLevel} blocage(s), bloqué jusqu'au {Date(account.LockedUntil)}");
o.WriteLine($" Échecs (confiance) : {account.TrustedFailedCount} en cours, bloqué jusqu'au {Date(account.TrustedLockedUntil)}");
o.WriteLine($" Appareils connectés : {devices.Count}");
foreach (var device in devices)
o.WriteLine($" · {device.Label} (clé {device.KeyId}) — dernière utilisation {Date(device.LastUsedAt)}");
o.WriteLine(" Derniers événements :");
foreach (var e in store.Events(account.Id, 10))
o.WriteLine($" {Date(e.At)} {e.Type} {e.Result} {e.Details}");
}
private static string? Get(Dictionary<string, string> options, string name) =>
options.TryGetValue(name, out var value) ? value : null;
}
@@ -25,6 +25,18 @@
<ProjectReference Include="..\..\src\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
</ItemGroup>
<!--
Comptes joueurs (phase 8, docs\PLAYER-ACCOUNTS-ARCHITECTURE.md) :
• Microsoft.Data.Sqlite (Microsoft) : base players.db ; embarque la bibliothèque native SQLite (SQLitePCLRaw,
bibliothèque e_sqlite3 de linux-x64 incluse à la publication autonome) ;
• Konscious.Security.Cryptography.Argon2 (licence MIT, 100 % C#) : empreintes Argon2id des mots de passe (RFC 9106),
absentes de .NET 8.
-->
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.34" />
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="GameLauncher.Tests" />
</ItemGroup>
@@ -33,7 +33,8 @@ public static class ApiModules
new Modules.Signing.SigningModule(),
new Modules.Files.FilesModule(),
new Modules.Feedback.FeedbackModule(),
new Modules.Maintenance.MaintenanceModule()
new Modules.Maintenance.MaintenanceModule(),
new Modules.Accounts.AccountsModule()
};
}
@@ -51,4 +52,8 @@ public static class RateLimitPolicies
/// <summary>Demandes des joueurs : défis et envois anonymes (en plus de la limite générale).</summary>
public const string Feedback = "feedback";
/// <summary>Comptes joueurs : défis, création, connexion, renouvellement, changement de mot de passe (en plus de la
/// limite générale).</summary>
public const string Account = "account";
}
@@ -53,6 +53,9 @@ public sealed class LauncherApiOptions
/// <summary>Journal d'audit : durée de conservation (purge automatique).</summary>
public AuditSettings Audit { get; set; } = new();
/// <summary>Comptes joueurs (phase 8) : désactivés par défaut. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.</summary>
public PlayersSettings Players { get; set; } = new();
/// <summary>
/// Jeux dont le manifest.json publié peut être modifié par l'API (notes de version, phase 4), par
/// identifiant de jeu (GameId de launcher.json). Absent : notes non modifiables en ligne.
@@ -177,6 +180,78 @@ public sealed class LauncherApiOptions
public int RetentionMonths { get; set; } = 12;
}
/// <summary>
/// Comptes joueurs : adresse e-mail + mot de passe, connexion mémorisée liée à une clé d'appareil. Données dans
/// « players/players.db » (SQLite, dossier des données privées), adresses e-mail chiffrées, mots de passe hachés
/// (Argon2id + clé secrète). La clé maîtresse est hors du dossier des données (credential systemd chiffré).
/// </summary>
public sealed class PlayersSettings
{
/// <summary>Service activé (faux par défaut : les routes « v1/account/… » répondent 503 « not_configured »).</summary>
public bool Enabled { get; set; }
/// <summary>Création de compte ouverte (la connexion reste possible si faux).</summary>
public bool RegistrationEnabled { get; set; } = true;
/// <summary>
/// Fichier de la clé maîtresse (32 octets en Base64). Vide : « players-master-key » dans le dossier des credentials
/// systemd ($CREDENTIALS_DIRECTORY, voir server/deploy/LISEZMOI.md). Jamais dans le dossier des données, jamais
/// lisible par le groupe ou les autres comptes.
/// </summary>
public string MasterKeyFile { get; set; } = "";
/// <summary>Version des conditions d'utilisation / politique de confidentialité à accepter à la création du compte.</summary>
public string TermsVersion { get; set; } = "1";
/// <summary>Argon2id : mémoire (Kio), passes, parallélisme. OWASP : 19 Mio et 2 passes au minimum (ou équivalent).</summary>
public int HashMemoryKib { get; set; } = 19456;
public int HashIterations { get; set; } = 2;
public int HashParallelism { get; set; } = 1;
/// <summary>Calculs de mot de passe simultanés (les suivants attendent) : protège le VPS d'un afflux de connexions.</summary>
public int MaxConcurrentHashes { get; set; } = 4;
/// <summary>Preuve de travail exigée à la création de compte et à la connexion (bits à zéro).</summary>
public int RegisterProofDifficulty { get; set; } = 20;
public int LoginProofDifficulty { get; set; } = 16;
/// <summary>Durée de validité d'un défi joueur.</summary>
public int ChallengeLifetimeSeconds { get; set; } = 300;
/// <summary>Anti-force brute (appareils inconnus) : blocage après N échecs consécutifs, durée doublée à chaque
/// blocage ; au blocage n° SuspendAfterLocks, le compte est SUSPENDU pour les nouveaux appareils (seul un
/// administrateur lève la suspension ; les appareils déjà connectés continuent de fonctionner).</summary>
public int FailuresPerLock { get; set; } = 5;
public int LockMinutes { get; set; } = 15;
public int SuspendAfterLocks { get; set; } = 4;
/// <summary>Appareils de confiance (déjà connectés) : blocage temporaire après N échecs, jamais de suspension.</summary>
public int TrustedFailuresPerLock { get; set; } = 10;
/// <summary>Échecs de connexion acceptés par adresse IP (IPv6 : /64) sur 15 minutes, tous comptes confondus.</summary>
public int FailedLoginsPerIpPer15Minutes { get; set; } = 30;
/// <summary>Créations de compte par adresse IP (IPv6 : /64) par heure et par jour, et au total par jour.</summary>
public int RegistrationsPerIpPerHour { get; set; } = 3;
public int RegistrationsPerIpPerDay { get; set; } = 5;
public int MaxRegistrationsPerDay { get; set; } = 500;
/// <summary>Requêtes par minute et par adresse IP sur les routes de défi, création, connexion et renouvellement.</summary>
public int AuthRequestsPerMinute { get; set; } = 20;
/// <summary>Jeton d'accès (minutes) ; session mémorisée : jours sans utilisation, puis durée maximale (jours).</summary>
public int AccessTokenMinutes { get; set; } = 15;
public int RefreshIdleDays { get; set; } = 90;
public int RefreshMaxDays { get; set; } = 365;
/// <summary>Appareils connectés par compte (au-delà, le moins récemment utilisé est déconnecté).</summary>
public int MaxDevicesPerAccount { get; set; } = 20;
/// <summary>Journal des comptes : conservation (mois), et effacement des adresses IP au-delà de N mois.</summary>
public int EventsRetentionMonths { get; set; } = 12;
public int EventsIpRetentionMonths { get; set; } = 6;
}
/// <summary>Un jeu publié sur ce serveur.</summary>
public sealed class GameSettings
{
@@ -264,6 +339,9 @@ public sealed class LauncherApiOptions
public IReadOnlyDictionary<string, string> GameManifestFiles { get; private set; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
/// <summary>Fichier de la clé maîtresse des comptes joueurs (null : comptes désactivés), calculé par <see cref="Validate"/>.</summary>
public string? PlayersMasterKeyFullPath { get; private set; }
/// <summary>Jeux acceptés par la réception des demandes des joueurs, calculés par <see cref="Validate"/>.</summary>
public IReadOnlySet<string> FeedbackGameIds { get; private set; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -298,6 +376,7 @@ public sealed class LauncherApiOptions
ValidateGames();
ValidateFiles();
ValidateFeedback();
ValidatePlayers();
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
@@ -312,6 +391,67 @@ public sealed class LauncherApiOptions
Require(Audit.RetentionMonths is >= 1 and <= 120, "Audit:RetentionMonths (1 à 120)");
}
private void ValidatePlayers()
{
var players = Players;
PlayersMasterKeyFullPath = null;
Require(players.HashMemoryKib is >= 7168 and <= 1_048_576, "Players:HashMemoryKib (7168 à 1048576)");
Require(players.HashIterations is >= 1 and <= 20, "Players:HashIterations (1 à 20)");
// OWASP : coût au moins équivalent à 19 Mio × 2 passes (ex. 12 Mio × 3, 9 Mio × 4, 7 Mio × 5).
Require((long)players.HashMemoryKib * players.HashIterations >= 19456L * 2, "Players:HashMemoryKib × HashIterations (au moins 38912)");
Require(players.HashParallelism is >= 1 and <= 16, "Players:HashParallelism (1 à 16)");
Require(players.MaxConcurrentHashes is >= 1 and <= 64, "Players:MaxConcurrentHashes (1 à 64)");
Require(players.RegisterProofDifficulty is >= Contracts.Feedback.FeedbackProof.MinDifficulty and <= Contracts.Feedback.FeedbackProof.MaxDifficulty &&
players.LoginProofDifficulty is >= Contracts.Feedback.FeedbackProof.MinDifficulty and <= Contracts.Feedback.FeedbackProof.MaxDifficulty,
$"Players:*ProofDifficulty ({Contracts.Feedback.FeedbackProof.MinDifficulty} à {Contracts.Feedback.FeedbackProof.MaxDifficulty})");
Require(players.ChallengeLifetimeSeconds is >= 30 and <= 3600, "Players:ChallengeLifetimeSeconds (30 à 3600)");
Require(players.FailuresPerLock is >= 3 and <= 20, "Players:FailuresPerLock (3 à 20)");
Require(players.LockMinutes is >= 1 and <= 1440, "Players:LockMinutes (1 à 1440)");
Require(players.SuspendAfterLocks is >= 2 and <= 20, "Players:SuspendAfterLocks (2 à 20)");
Require(players.TrustedFailuresPerLock is >= 3 and <= 50, "Players:TrustedFailuresPerLock (3 à 50)");
Require(players.FailedLoginsPerIpPer15Minutes is >= 5 and <= 10_000, "Players:FailedLoginsPerIpPer15Minutes (5 à 10000)");
Require(players.RegistrationsPerIpPerHour is >= 1 and <= 1000 && players.RegistrationsPerIpPerDay >= players.RegistrationsPerIpPerHour &&
players.RegistrationsPerIpPerDay <= 10_000, "Players:RegistrationsPerIp*");
Require(players.MaxRegistrationsPerDay is >= 1 and <= 1_000_000, "Players:MaxRegistrationsPerDay");
Require(players.AuthRequestsPerMinute is >= 1 and <= 1_000_000, "Players:AuthRequestsPerMinute");
Require(players.AccessTokenMinutes is >= 1 and <= 60, "Players:AccessTokenMinutes (1 à 60)");
Require(players.RefreshIdleDays is >= 1 and <= 365, "Players:RefreshIdleDays (1 à 365)");
Require(players.RefreshMaxDays >= players.RefreshIdleDays && players.RefreshMaxDays <= 730, "Players:RefreshMaxDays (RefreshIdleDays à 730)");
Require(players.MaxDevicesPerAccount is >= 1 and <= 100, "Players:MaxDevicesPerAccount (1 à 100)");
Require(players.EventsRetentionMonths is >= 1 and <= 120, "Players:EventsRetentionMonths (1 à 120)");
Require(players.EventsIpRetentionMonths is >= 1 && players.EventsIpRetentionMonths <= players.EventsRetentionMonths,
"Players:EventsIpRetentionMonths (1 à EventsRetentionMonths)");
if (Contracts.ApiText.CleanLine(players.TermsVersion, 32) is null)
throw new InvalidOperationException($"{Section}:Players:TermsVersion : une version (ex. « 2026-11 ») est obligatoire.");
if (!players.Enabled) return;
string file;
if (!string.IsNullOrWhiteSpace(players.MasterKeyFile))
{
file = players.MasterKeyFile.Trim();
if (!Path.IsPathRooted(file))
throw new InvalidOperationException($"{Section}:Players:MasterKeyFile doit être un chemin absolu.");
}
else if (Environment.GetEnvironmentVariable("CREDENTIALS_DIRECTORY") is { Length: > 0 } credentials)
{
file = Path.Combine(credentials, "players-master-key");
}
else
{
throw new InvalidOperationException(
$"{Section}:Players:Enabled = true, mais aucune clé maîtresse : credential systemd « players-master-key » " +
"(LoadCredentialEncrypted, voir server/deploy/LISEZMOI.md) ou Players:MasterKeyFile.");
}
var full = Path.GetFullPath(file);
var directory = Path.GetDirectoryName(full) ?? "";
var keyDirectory = RealPath(directory);
var dataDirectory = RealPath(DataDirectoryFullPath);
if (IsSameOrInside(keyDirectory, dataDirectory))
throw new InvalidOperationException(
$"{Section}:Players:MasterKeyFile : la clé maîtresse ne doit pas être dans le dossier des données (une copie des données ne doit pas la contenir).");
PlayersMasterKeyFullPath = full;
}
private void ValidateFeedback()
{
var feedback = Feedback;
@@ -9,7 +9,9 @@ namespace GameLauncher.Server.Hosting;
/// puis toutes les 24 heures :
/// • journal d'audit : fichiers mensuels plus anciens que <c>LauncherApi:Audit:RetentionMonths</c> (12 mois) ;
/// • demandes des joueurs : closes depuis <c>Feedback:RetentionClosedMonths</c> (12), et toutes au-delà de
/// <c>Feedback:RetentionMaxMonths</c> (24).
/// <c>Feedback:RetentionMaxMonths</c> (24) ;
/// • journal des comptes joueurs (si activés) : lignes au-delà de <c>Players:EventsRetentionMonths</c> (12), adresses IP
/// effacées au-delà de <c>Players:EventsIpRetentionMonths</c> (6).
/// Chaque purge qui supprime quelque chose est inscrite au journal (« audit.purge », « feedback.purge »). Une erreur est
/// écrite dans journalctl sans arrêter le service ; la purge suivante réessaie.
/// </summary>
@@ -22,12 +24,14 @@ public sealed class RetentionService : BackgroundService
private readonly LauncherApiOptions _options;
private readonly TimeProvider _time;
private readonly ILogger<RetentionService> _logger;
private readonly IServiceProvider _services;
public RetentionService(AuditLog audit, FeedbackStore feedback, IOptions<LauncherApiOptions> options, TimeProvider time,
ILogger<RetentionService> logger)
ILogger<RetentionService> logger, IServiceProvider services)
{
_audit = audit;
_feedback = feedback;
_services = services;
_options = options.Value;
_time = time;
_logger = logger;
@@ -76,5 +80,21 @@ public sealed class RetentionService : BackgroundService
{
_logger.LogError(ex, "Purge des demandes des joueurs impossible");
}
if (!_options.Players.Enabled) return;
try
{
var players = _services.GetRequiredService<Storage.Players.PlayerStore>();
var (deleted, anonymized) = players.PurgeEvents(_time.GetUtcNow(), _options.Players.EventsRetentionMonths,
_options.Players.EventsIpRetentionMonths);
if (deleted > 0 || anonymized > 0)
_audit.Write("players.events_purge", AuditResults.Ok,
details: $"journal des comptes joueurs : {deleted} ligne(s) supprimée(s), {anonymized} adresse(s) IP effacée(s) ; " +
$"conservation {_options.Players.EventsRetentionMonths} mois, IP {_options.Players.EventsIpRetentionMonths} mois");
}
catch (Exception ex)
{
_logger.LogError(ex, "Purge du journal des comptes joueurs impossible");
}
}
}
@@ -86,6 +86,8 @@ public static class ServerApp
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.EnrollmentsPerHour, TimeSpan.FromHours(1))));
limiter.AddPolicy(RateLimitPolicies.Feedback, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Feedback.RequestsPerMinute, TimeSpan.FromMinutes(1))));
limiter.AddPolicy(RateLimitPolicies.Account, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Players.AuthRequestsPerMinute, TimeSpan.FromMinutes(1))));
});
// Protection des données d'ASP.NET Core (cookies, jetons anti-falsification) : inutilisée par l'API,
@@ -123,6 +125,13 @@ public static class ServerApp
var admins = app.Services.GetRequiredService<AdminStore>().Read();
app.Logger.LogInformation("Launcher API : origine {Origin}, serveur {ServerId}, {Count} administrateur(s), données {Data}",
identity.Origin, identity.ServerId, admins.Admins.Count, options.DataDirectoryFullPath);
// Comptes joueurs : clé maîtresse et base ouvertes dès le démarrage (clé absente ou base illisible : pas de démarrage).
if (options.Players.Enabled)
{
var players = app.Services.GetRequiredService<Storage.Players.PlayerStore>();
app.Services.GetRequiredService<Storage.Players.PlayerPasswordHasher>();
app.Logger.LogInformation("Comptes joueurs activés : {Count} compte(s), base {Database}", players.CountAccounts(), players.DatabasePath);
}
if (!admins.UsableOwners().Any())
app.Logger.LogWarning("Aucun propriétaire actif : créez-le avec « GameLauncher.Server add-owner » (voir server/deploy/LISEZMOI.md).");
return app;
@@ -0,0 +1,614 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using GameLauncher.Server.Storage.Players;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Accounts;
/// <summary>
/// Comptes joueurs (phase 8a) : création de compte, connexion par adresse e-mail et mot de passe, connexion mémorisée
/// (jeton de renouvellement lié à une clé d'appareil), déconnexion, changement de mot de passe, appareils connectés.
///
/// Sécurité (détail : docs\PLAYER-ACCOUNTS-ARCHITECTURE.md) :
/// • routes anonymes protégées par un défi sans état + preuve de travail + preuve de possession de la clé d'appareil
/// (<see cref="PlayerGuard"/>), limites par adresse IP ;
/// • anti-force brute : blocage temporaire (durée doublée), puis SUSPENSION que seul un administrateur lève. La
/// suspension bloque les connexions depuis un NOUVEL appareil ; les appareils déjà connectés (« de confiance »,
/// prouvés par leur clé) continuent de fonctionner : un tiers qui connaît l'adresse peut déclencher la suspension,
/// mais pas priver le joueur de son compte. Une adresse inconnue se comporte exactement comme un compte réel ;
/// • réponses identiques pour une adresse inconnue et un mauvais mot de passe (même durée de calcul) ; l'état du compte
/// (désactivé, suspendu) n'est révélé qu'après un mot de passe correct ;
/// • adresses e-mail chiffrées, mots de passe en Argon2id + poivre, clé maîtresse hors du dossier des données ;
/// • journal des comptes (actions des joueurs) dans la base, sans adresse e-mail ; actions du système et des
/// administrateurs dans le journal d'audit, par référence de compte.
/// Totalement séparé des administrateurs : aucune route d'administration n'accepte un jeton joueur.
/// </summary>
public sealed class AccountsModule : IApiModule
{
public string Name => "Accounts";
/// <summary>Longueur maximale acceptée d'un mot de passe SAISI (bien au-delà des règles : borne le travail du serveur).</summary>
private const int MaxSubmittedPasswordLength = 1024;
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
{
services.AddSingleton<PlayerGuard>();
services.AddSingleton<PlayerSessionStore>();
services.AddSingleton(sp =>
{
var api = sp.GetRequiredService<IOptions<LauncherApiOptions>>().Value;
return PlayerSecrets.Load(api.PlayersMasterKeyFullPath
?? throw new InvalidOperationException("Comptes joueurs désactivés : aucune clé maîtresse."));
});
services.AddSingleton(sp => new PlayerPasswordHasher(sp.GetRequiredService<PlayerSecrets>(),
sp.GetRequiredService<IOptions<LauncherApiOptions>>().Value.Players));
services.AddSingleton<PlayerStore>();
services.AddPlayerAuthentication();
}
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapPost(ApiRoutes.AccountChallenge, Challenge).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
api.MapPost(ApiRoutes.AccountRegister, Register).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
api.MapPost(ApiRoutes.AccountLogin, Login).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
api.MapPost(ApiRoutes.AccountRefresh, Refresh).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
api.MapPost(ApiRoutes.AccountLogout, Logout).RequirePlayer();
api.MapGet(ApiRoutes.AccountMe, Me).RequirePlayer();
api.MapPost(ApiRoutes.AccountPassword, ChangePassword).RequirePlayerOrPasswordChange().RequireRateLimiting(RateLimitPolicies.Account);
api.MapDelete(ApiRoutes.AccountDevicePattern, DeleteDevice).RequirePlayer();
}
private static IResult Disabled() => ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Les comptes joueurs ne sont pas activés sur ce serveur.");
private static IResult LoginFailed() => ApiResults.Error(401, ApiErrorCodes.LoginFailed, "Adresse e-mail ou mot de passe incorrect.");
private static IResult TooManyAttempts(HttpContext context, DateTimeOffset until, DateTimeOffset now)
{
var wait = until - now;
var seconds = Math.Max(1, (int)Math.Ceiling(wait.TotalSeconds));
context.Response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
var minutes = Math.Max(1, (int)Math.Ceiling(wait.TotalMinutes));
return ApiResults.Error(429, ApiErrorCodes.LoginLocked,
$"Trop d'essais : la connexion est bloquée pendant encore {minutes} minute{(minutes > 1 ? "s" : "")}.");
}
private static IResult RateLimited(HttpContext context, TimeSpan retryAfter, string message)
{
context.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
return ApiResults.Error(429, ApiErrorCodes.RateLimited, message);
}
// ------------------------------------------------------------------ Défi
private static IResult Challenge(PlayerChallengeRequest request, IOptions<LauncherApiOptions> options, PlayerGuard guard,
ServerIdentity server)
{
if (!options.Value.Players.Enabled) return Disabled();
if (!PlayerChallengePurposes.IsValid(request.Purpose)) return ApiResults.BadRequest("Usage du défi inconnu.");
return Results.Json(guard.CreateChallenge(request.Purpose, server.ServerId, server.Origin), ApiJson.Options);
}
// ------------------------------------------------------------------ Création de compte
private static async Task<IResult> Register(PlayerRegisterRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerGuard guard, PlayerSessionStore sessions, ServerIdentity server, TimeProvider time)
{
var settings = options.Value.Players;
if (!settings.Enabled) return Disabled();
if (!settings.RegistrationEnabled)
return ApiResults.Error(403, ApiErrorCodes.RegistrationClosed, "La création de compte est momentanément fermée.");
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
var hasher = services.GetRequiredService<PlayerPasswordHasher>();
var ip = context.ClientIp();
// 1. Défi et preuve de travail, puis possession de la clé d'appareil, puis consommation du défi.
var device = CheckDevice(guard, server, PlayerChallengePurposes.Register, request.ChallengeId, request.Solution, request.Device);
if (device.Error is { } deviceError) return deviceError;
var proof = device.Proof!;
// 2. Contenu : conditions acceptées, adresse, mot de passe (mêmes règles que le launcher).
if (!string.Equals(request.AcceptedTermsVersion, settings.TermsVersion, StringComparison.Ordinal))
return ApiResults.Error(400, ApiErrorCodes.TermsNotAccepted,
"Acceptez les conditions d'utilisation et la politique de confidentialité en vigueur pour créer un compte.");
if (PlayerEmail.Normalize(request.Email) is not { } email)
return ApiResults.Error(400, ApiErrorCodes.EmailInvalid, "Adresse e-mail invalide.");
if (request.Password is not { Length: > 0 and <= MaxSubmittedPasswordLength })
return ApiResults.Error(400, ApiErrorCodes.PasswordRejected, "Mot de passe invalide.");
var errors = PasswordRules.Check(request.Password, email);
if (errors.Count > 0) return ApiResults.Error(400, ApiErrorCodes.PasswordRejected, string.Join(" ", errors));
// 3. Limites par adresse IP (en mémoire) puis plafond quotidien global.
var now = time.GetUtcNow();
if (!guard.TryCountRegistration(context.RateLimitPartition(), out var retryAfter))
return RateLimited(context, retryAfter, "Plusieurs comptes ont été créés depuis votre connexion récemment : réessayez plus tard.");
if (store.CountCreatedSince(now - TimeSpan.FromDays(1)) >= settings.MaxRegistrationsPerDay)
return ApiResults.Error(503, ApiErrorCodes.RegistrationClosed,
"Trop de comptes ont été créés aujourd'hui : réessayez demain.");
// 4. Adresse libre ? (la création révèle forcément qu'une adresse est prise : limites ci-dessus + preuve de travail).
var emailIndex = secrets.EmailIndex(email);
if (store.FindByEmailIndex(emailIndex) is not null)
{
store.AddEvent(null, PlayerEvents.Register, AuditResults.Denied, ip, proof.KeyId, "adresse déjà utilisée");
return ApiResults.Error(409, ApiErrorCodes.EmailTaken, "Cette adresse e-mail est déjà utilisée par un compte.");
}
// 5. Empreinte (calcul coûteux, hors verrou), puis création sous transaction (unicité revérifiée).
var hash = await hasher.HashAsync(request.Password, context.RequestAborted);
var accountId = PlayerIds.NewId();
var account = new PlayerAccountRecord
{
Id = accountId,
Reference = PlayerIds.NewReference(),
EmailIndex = emailIndex,
EmailEncrypted = secrets.EncryptEmail(accountId, email),
PasswordHash = hash,
PasswordChangedAt = now,
CreatedAt = now,
LastLoginAt = now,
TermsVersion = settings.TermsVersion
};
var (deviceRecord, refreshToken) = NewDevice(accountId, proof, settings, now);
if (!store.TryCreate(account, deviceRecord, PlayerIds.NewReference, out var created))
return ApiResults.Error(409, ApiErrorCodes.EmailTaken, "Cette adresse e-mail est déjà utilisée par un compte.");
store.AddEvent(created.Id, PlayerEvents.Register, AuditResults.Ok, ip, proof.KeyId,
$"conditions version {settings.TermsVersion} ; appareil « {proof.Label} »");
return Results.Json(new PlayerLoginResponse
{
Status = PlayerLoginStatus.Ok,
Session = OpenSession(created, deviceRecord, refreshToken, sessions, secrets)
}, ApiJson.Options);
}
// ------------------------------------------------------------------ Connexion
private static async Task<IResult> Login(PlayerLoginRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerGuard guard, PlayerSessionStore sessions, ServerIdentity server, AuditLog audit, TimeProvider time)
{
var settings = options.Value.Players;
if (!settings.Enabled) return Disabled();
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
var hasher = services.GetRequiredService<PlayerPasswordHasher>();
var ip = context.ClientIp();
var partition = context.RateLimitPartition();
var device = CheckDevice(guard, server, PlayerChallengePurposes.Login, request.ChallengeId, request.Solution, request.Device);
if (device.Error is { } deviceError) return deviceError;
var proof = device.Proof!;
if (guard.IsIpThrottled(partition, out var ipRetry))
return RateLimited(context, ipRetry, "Trop d'échecs de connexion depuis votre connexion Internet : réessayez plus tard.");
var now = time.GetUtcNow();
var password = request.Password is { Length: > 0 and <= MaxSubmittedPasswordLength } p ? p : null;
var email = PlayerEmail.Normalize(request.Email);
if (email is null || password is null)
{
if (password is not null) await hasher.DummyAsync(password, context.RequestAborted);
guard.CountFailedLogin(partition);
return LoginFailed();
}
var emailIndex = secrets.EmailIndex(email);
var account = store.FindByEmailIndex(emailIndex);
if (account is null)
{
// Adresse sans compte : même calcul, même blocage, même réponse qu'un mauvais mot de passe.
if (guard.UnknownLockedUntil(emailIndex) is { } unknownUntil) return TooManyAttempts(context, unknownUntil, now);
await hasher.DummyAsync(password, context.RequestAborted);
guard.CountFailedLogin(partition);
store.AddEvent(null, PlayerEvents.LoginFailed, AuditResults.Denied, ip, proof.KeyId, "adresse sans compte");
return guard.CountUnknownFailure(emailIndex) is { } lockedNow ? TooManyAttempts(context, lockedNow, now) : LoginFailed();
}
var trusted = store.FindDeviceByKey(account.Id, proof.KeyId) is not null;
if ((trusted ? account.TrustedLockedUntil : account.LockedUntil) is { } until && until > now)
{
store.AddEvent(account.Id, PlayerEvents.LoginFailed, AuditResults.Denied, ip, proof.KeyId, "connexion bloquée (trop d'essais)");
return TooManyAttempts(context, until, now);
}
var (valid, rehash) = await hasher.VerifyAsync(password, account.PasswordHash, context.RequestAborted);
if (!valid)
{
var outcome = store.RecordFailure(account.Id, trusted, now, settings);
guard.CountFailedLogin(partition);
store.AddEvent(account.Id, PlayerEvents.LoginFailed, AuditResults.Denied, ip, proof.KeyId,
trusted ? "mot de passe incorrect (appareil de confiance)" : "mot de passe incorrect (appareil inconnu)");
if (outcome.Suspended)
{
var count = settings.FailuresPerLock * settings.SuspendAfterLocks;
store.AddEvent(account.Id, PlayerEvents.Suspended, AuditResults.Ok, null, null,
$"{count} échecs de connexion consécutifs depuis des appareils inconnus");
audit.Write("players.suspend", AuditResults.Ok, actorName: "système", ip: ip, target: account.Reference,
details: $"compte joueur suspendu automatiquement (nouveaux appareils) : {count} échecs de connexion consécutifs " +
"depuis des appareils inconnus");
}
if (outcome.LockedUntil is { } lockedUntil)
{
store.AddEvent(account.Id, PlayerEvents.Locked, AuditResults.Ok, ip, proof.KeyId,
$"blocage n° {outcome.LockLevel} jusqu'au {lockedUntil.UtcDateTime:yyyy-MM-dd HH:mm} UTC" +
(trusted ? " (appareils de confiance)" : " (appareils inconnus)"));
return TooManyAttempts(context, lockedUntil, now);
}
return LoginFailed();
}
// Mot de passe correct : l'état du compte peut maintenant être révélé.
if (StateRefusal(store, account, now, trusted) is { } refusal)
{
store.AddEvent(account.Id, PlayerEvents.Login, AuditResults.Denied, ip, proof.KeyId, refusal.Reason);
return refusal.Result;
}
store.RecordSuccess(account.Id, now, rehash ? await hasher.HashAsync(password, context.RequestAborted) : null);
account = store.FindById(account.Id) ?? account;
if (account.MustChangePassword)
{
var (changeToken, change) = sessions.CreatePasswordChange(account.Id, proof.PublicKey, proof.KeyId, proof.Label);
store.AddEvent(account.Id, PlayerEvents.PasswordChangeForced, AuditResults.Ok, ip, proof.KeyId,
"connexion acceptée : nouveau mot de passe exigé avant d'ouvrir la session");
return Results.Json(new PlayerLoginResponse
{
Status = PlayerLoginStatus.PasswordChangeRequired,
PasswordChangeToken = changeToken,
PasswordChangeExpiresAt = change.ExpiresAt
}, ApiJson.Options);
}
var (deviceRecord, refreshToken) = NewDevice(account.Id, proof, settings, now);
var removed = store.SaveDevice(deviceRecord, settings.MaxDevicesPerAccount);
sessions.RemoveDevices(removed);
store.AddEvent(account.Id, PlayerEvents.Login, AuditResults.Ok, ip, proof.KeyId,
$"appareil « {proof.Label} »{(trusted ? "" : " (nouvel appareil)")}" +
(account.Status == PlayerStatus.Suspended ? " ; compte suspendu pour les nouveaux appareils" : ""));
return Results.Json(new PlayerLoginResponse
{
Status = PlayerLoginStatus.Ok,
Session = OpenSession(account, deviceRecord, refreshToken, sessions, secrets)
}, ApiJson.Options);
}
/// <summary>
/// Refus lié à l'état du compte, révélé seulement à qui connaît le mot de passe ou possède une session mémorisée :
/// désactivé (tout est refusé), suspendu (seuls les appareils de confiance passent). Une désactivation arrivée à
/// échéance est levée au passage.
/// </summary>
private static (IResult Result, string Reason)? StateRefusal(PlayerStore store, PlayerAccountRecord account, DateTimeOffset now,
bool trustedDevice)
{
switch (account.Status)
{
case PlayerStatus.Active:
return null;
case PlayerStatus.Suspended when trustedDevice:
return null;
case PlayerStatus.Disabled when account.DisabledUntil is { } end && end <= now:
store.SetStatus(account.Id, PlayerStatus.Active, null, null, "système (fin de la désactivation)", now);
store.AddEvent(account.Id, PlayerEvents.Enabled, AuditResults.Ok, null, null, "fin de la désactivation temporaire");
return null;
case PlayerStatus.Disabled:
var until = account.DisabledUntil is { } date
? $" jusqu'au {date.UtcDateTime.ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture)} (UTC)"
: "";
return (ApiResults.Error(403, ApiErrorCodes.AccountDisabled,
$"Ce compte est désactivé{until}. Contactez le support en indiquant la référence {account.Reference}."),
"compte désactivé");
default:
return (ApiResults.Error(403, ApiErrorCodes.AccountSuspended,
"Par sécurité, les connexions depuis un nouvel appareil sont suspendues sur ce compte après de nombreux " +
"essais de mot de passe. Connectez-vous depuis un appareil déjà utilisé, ou contactez le support en indiquant " +
$"la référence {account.Reference} : un administrateur pourra lever la suspension."),
"compte suspendu (nouvel appareil)");
}
}
// ------------------------------------------------------------------ Renouvellement (connexion mémorisée)
private static IResult Refresh(PlayerRefreshRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerGuard guard, PlayerSessionStore sessions, ServerIdentity server, TimeProvider time)
{
var settings = options.Value.Players;
if (!settings.Enabled) return Disabled();
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
var ip = context.ClientIp();
var revoked = ApiResults.Error(401, ApiErrorCodes.SessionRevoked, "Session expirée ou révoquée : reconnectez-vous.");
if (!PlayerTokens.TryParseRefresh(request.RefreshToken, out var deviceId, out var secret)) return revoked;
if (guard.Validate(request.ChallengeId, PlayerChallengePurposes.Refresh, null) is not { } nonce)
return ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Défi absent, expiré ou invalide : réessayez.");
if (store.FindDevice(deviceId) is not { } device) return revoked;
// Possession de la clé de l'appareil : un jeton copié sur un autre PC ne sert à rien.
var signature = AdminKeys.DecodeSignature(request.Signature);
byte[] spki;
try
{
spki = Convert.FromBase64String(device.PublicKey);
}
catch (FormatException)
{
return revoked;
}
if (signature is null || !AdminKeys.Verify(spki,
PlayerMessages.Device(PlayerChallengePurposes.Refresh, server.Origin, server.ServerId, request.ChallengeId, nonce, device.KeyId),
signature))
{
store.AddEvent(device.AccountId, PlayerEvents.Refresh, AuditResults.Denied, ip, device.KeyId, "signature de l'appareil invalide");
return ApiResults.Error(401, ApiErrorCodes.DeviceProofInvalid, "Cet appareil n'a pas pu prouver son identité : reconnectez-vous.");
}
if (!guard.TryConsume(request.ChallengeId))
return ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Défi déjà utilisé : réessayez.");
var now = time.GetUtcNow();
var hash = HashSecret(secret);
if (device.PreviousRefreshHash is { } previous && FixedEquals(hash, previous))
{
// Jeton déjà remplacé et présenté de nouveau : copie volée (ou réponse perdue). L'appareil est déconnecté.
store.DeleteDevice(device.Id);
sessions.RemoveDevices(new[] { device.Id });
store.AddEvent(device.AccountId, PlayerEvents.RefreshReuse, AuditResults.Denied, ip, device.KeyId,
"jeton de renouvellement déjà utilisé : appareil déconnecté par précaution");
return revoked;
}
if (!FixedEquals(hash, device.RefreshHash)) return revoked;
if (now >= device.RefreshExpiresAt || now >= device.RefreshAbsoluteExpiresAt)
{
store.DeleteDevice(device.Id);
store.AddEvent(device.AccountId, PlayerEvents.Refresh, AuditResults.Denied, ip, device.KeyId, "session mémorisée expirée");
return revoked;
}
var account = store.FindById(device.AccountId);
if (account is null) return revoked;
if (StateRefusal(store, account, now, trustedDevice: true) is { } refusal)
{
store.AddEvent(account.Id, PlayerEvents.Refresh, AuditResults.Denied, ip, device.KeyId, refusal.Reason);
return refusal.Result;
}
if (account.MustChangePassword)
return ApiResults.Error(403, ApiErrorCodes.PasswordChangeRequired,
"Un nouveau mot de passe est exigé : reconnectez-vous pour le choisir.");
var newSecret = PlayerTokens.NewSecret();
var idle = Min(now + TimeSpan.FromDays(settings.RefreshIdleDays), device.RefreshAbsoluteExpiresAt);
if (!store.RotateRefresh(device.Id, device.RefreshHash, HashSecret(newSecret), idle, now)) return revoked;
// Journal : un renouvellement par appareil et par jour au plus (le launcher en fait un à chaque lancement).
if (device.LastUsedAt.UtcDateTime.Date != now.UtcDateTime.Date)
store.AddEvent(account.Id, PlayerEvents.Refresh, AuditResults.Ok, ip, device.KeyId, $"appareil « {device.Label} »");
var rotated = device with { RefreshExpiresAt = idle, LastUsedAt = now };
return Results.Json(OpenSession(account, rotated, PlayerTokens.FormatRefresh(device.Id, newSecret), sessions, secrets), ApiJson.Options);
}
// ------------------------------------------------------------------ Session ouverte
private static IResult Logout(PlayerLogoutRequest? request, HttpContext context, IServiceProvider services, PlayerSessionStore sessions)
{
var player = context.Player();
var store = services.GetRequiredService<PlayerStore>();
var ip = context.ClientIp();
if (request?.AllDevices == true)
{
var removed = store.DeleteDevices(player.Account.Id);
sessions.RemoveWhere(s => s.AccountId == player.Account.Id);
store.AddEvent(player.Account.Id, PlayerEvents.LogoutAll, AuditResults.Ok, ip, player.Device?.KeyId,
$"{removed.Count} appareil(s) déconnecté(s)");
}
else if (player.Device is { } device)
{
store.DeleteDevice(device.Id);
sessions.RemoveDevices(new[] { device.Id });
store.AddEvent(player.Account.Id, PlayerEvents.Logout, AuditResults.Ok, ip, device.KeyId, $"appareil « {device.Label} »");
}
return Results.NoContent();
}
private static IResult Me(HttpContext context, IServiceProvider services)
{
var player = context.Player();
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
return Results.Json(new PlayerMeResponse
{
Account = Info(player.Account, secrets),
Devices = store.Devices(player.Account.Id).Select(d => new PlayerDeviceInfo
{
Id = d.Id,
Label = d.Label,
CreatedAt = d.CreatedAt,
LastUsedAt = d.LastUsedAt,
Current = d.Id == player.Device?.Id
}).ToList()
}, ApiJson.Options);
}
private static async Task<IResult> ChangePassword(PlayerChangePasswordRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerSessionStore sessions, TimeProvider time)
{
var settings = options.Value.Players;
var player = context.Player();
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
var hasher = services.GetRequiredService<PlayerPasswordHasher>();
var ip = context.ClientIp();
var account = player.Account;
var deviceKey = player.Device?.KeyId ?? player.Session.DeviceKeyId;
var now = time.GetUtcNow();
if (account.TrustedLockedUntil is { } until && until > now) return TooManyAttempts(context, until, now);
if (request.CurrentPassword is not { Length: > 0 and <= MaxSubmittedPasswordLength } ||
request.NewPassword is not { Length: > 0 and <= MaxSubmittedPasswordLength })
return ApiResults.BadRequest("Mot de passe actuel et nouveau mot de passe obligatoires.");
var (valid, _) = await hasher.VerifyAsync(request.CurrentPassword, account.PasswordHash, context.RequestAborted);
if (!valid)
{
// Session volée qui tente de deviner le mot de passe : blocage puis fermeture des sessions du compte.
var outcome = store.RecordFailure(account.Id, trusted: true, now, settings);
store.AddEvent(account.Id, PlayerEvents.PasswordChange, AuditResults.Denied, ip, deviceKey, "mot de passe actuel incorrect");
if (outcome.LockedUntil is { } locked)
{
sessions.RemoveWhere(s => s.AccountId == account.Id);
return TooManyAttempts(context, locked, now);
}
return ApiResults.Error(400, ApiErrorCodes.LoginFailed, "Mot de passe actuel incorrect.");
}
var email = secrets.DecryptEmail(account.Id, account.EmailEncrypted);
var errors = PasswordRules.Check(request.NewPassword, email).ToList();
if (errors.Count == 0 &&
string.Equals(request.NewPassword, request.CurrentPassword, StringComparison.Ordinal))
errors.Add("Le nouveau mot de passe doit être différent de l'actuel.");
if (errors.Count > 0) return ApiResults.Error(400, ApiErrorCodes.PasswordRejected, string.Join(" ", errors));
var hash = await hasher.HashAsync(request.NewPassword, context.RequestAborted);
store.SetPassword(account.Id, hash, now);
store.RecordSuccess(account.Id, now, null);
if (player.Session.Scope == PlayerScopes.PasswordChange)
{
// Changement EXIGÉ : tous les appareils sont déconnectés, puis la session s'ouvre sur cet appareil.
var removed = store.DeleteDevices(account.Id);
sessions.RemoveWhere(s => s.AccountId == account.Id);
var proof = new DeviceProofResult(player.Session.DevicePublicKey!, player.Session.DeviceKeyId!, player.Session.DeviceLabel!);
var (deviceRecord, refreshToken) = NewDevice(account.Id, proof, settings, now);
store.SaveDevice(deviceRecord, settings.MaxDevicesPerAccount);
store.AddEvent(account.Id, PlayerEvents.PasswordChange, AuditResults.Ok, ip, proof.KeyId,
$"nouveau mot de passe (exigé) ; {removed.Count} autre(s) appareil(s) déconnecté(s)");
var refreshed = store.FindById(account.Id) ?? account;
return Results.Json(new PlayerChangePasswordResponse
{
Session = OpenSession(refreshed, deviceRecord, refreshToken, sessions, secrets),
DevicesSignedOut = removed.Count
}, ApiJson.Options);
}
// Changement ordinaire : les AUTRES appareils sont déconnectés (celui-ci reste connecté).
var others = store.DeleteDevices(account.Id, exceptDeviceId: player.Device!.Id);
sessions.RemoveWhere(s => s.AccountId == account.Id && s.DeviceId != player.Device.Id);
store.AddEvent(account.Id, PlayerEvents.PasswordChange, AuditResults.Ok, ip, deviceKey,
$"nouveau mot de passe ; {others.Count} autre(s) appareil(s) déconnecté(s)");
return Results.Json(new PlayerChangePasswordResponse { DevicesSignedOut = others.Count }, ApiJson.Options);
}
private static IResult DeleteDevice(string deviceId, HttpContext context, IServiceProvider services, PlayerSessionStore sessions)
{
var player = context.Player();
var store = services.GetRequiredService<PlayerStore>();
if (!PlayerIds.IsValidId(deviceId) || store.FindDevice(deviceId) is not { } device || device.AccountId != player.Account.Id)
return ApiResults.NotFound("Appareil introuvable.");
store.DeleteDevice(device.Id);
sessions.RemoveDevices(new[] { device.Id });
store.AddEvent(player.Account.Id, PlayerEvents.DeviceRemoved, AuditResults.Ok, context.ClientIp(), player.Device?.KeyId,
$"appareil « {device.Label} » déconnecté{(device.Id == player.Device?.Id ? " (celui-ci)" : "")}");
return Results.NoContent();
}
// ------------------------------------------------------------------ Outils
/// <summary>Clé d'appareil vérifiée : clé publique (Base64 canonique), identifiant, nom générique.</summary>
private sealed record DeviceProofResult(string PublicKey, string KeyId, string Label);
/// <summary>
/// Défi (sceau, usage, échéance, preuve de travail) puis signature de la clé d'appareil, puis consommation du défi
/// (un défi ne sert qu'une fois, et seulement avec une signature valide).
/// </summary>
private static (DeviceProofResult? Proof, IResult? Error) CheckDevice(PlayerGuard guard, ServerIdentity server, string purpose,
string? challengeId, string? solution, PlayerDeviceProof? device)
{
if (guard.Validate(challengeId, purpose, solution) is not { } nonce)
return (null, ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Vérification anti-robot échouée ou expirée : réessayez."));
var deviceError = ApiResults.Error(400, ApiErrorCodes.DeviceProofInvalid, "Clé de l'appareil invalide.");
if (device is null) return (null, deviceError);
byte[] spki;
try
{
spki = AdminKeys.ParsePublicKey(device.PublicKey);
}
catch (CryptographicException)
{
return (null, deviceError);
}
var keyId = AdminKeys.ComputeKeyId(spki);
var signature = AdminKeys.DecodeSignature(device.Signature);
if (signature is null ||
!AdminKeys.Verify(spki, PlayerMessages.Device(purpose, server.Origin, server.ServerId, challengeId!, nonce, keyId), signature))
return (null, deviceError);
if (!guard.TryConsume(challengeId!))
return (null, ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Vérification déjà utilisée : réessayez."));
var label = ApiText.CleanLine(device.Label, ApiText.MaxKeyLabelLength) ?? "Appareil";
return (new DeviceProofResult(Convert.ToBase64String(spki), keyId, label), null);
}
private static (PlayerDeviceRecord Device, string RefreshToken) NewDevice(string accountId, DeviceProofResult proof,
LauncherApiOptions.PlayersSettings settings, DateTimeOffset now)
{
var deviceId = PlayerIds.NewId();
var secret = PlayerTokens.NewSecret();
var absolute = now + TimeSpan.FromDays(settings.RefreshMaxDays);
var device = new PlayerDeviceRecord
{
Id = deviceId,
AccountId = accountId,
KeyId = proof.KeyId,
PublicKey = proof.PublicKey,
Label = proof.Label,
CreatedAt = now,
LastUsedAt = now,
RefreshHash = HashSecret(secret),
RefreshExpiresAt = Min(now + TimeSpan.FromDays(settings.RefreshIdleDays), absolute),
RefreshAbsoluteExpiresAt = absolute
};
return (device, PlayerTokens.FormatRefresh(deviceId, secret));
}
private static PlayerSession OpenSession(PlayerAccountRecord account, PlayerDeviceRecord device, string refreshToken,
PlayerSessionStore sessions, PlayerSecrets secrets)
{
var (accessToken, access) = sessions.CreateAccess(account.Id, device.Id);
return new PlayerSession
{
AccessToken = accessToken,
AccessExpiresAt = access.ExpiresAt,
RefreshToken = refreshToken,
RefreshExpiresAt = device.RefreshExpiresAt,
DeviceId = device.Id,
Account = Info(account, secrets)
};
}
private static PlayerAccountInfo Info(PlayerAccountRecord account, PlayerSecrets secrets) => new()
{
Reference = account.Reference,
EmailMasked = PlayerEmail.Mask(secrets.DecryptEmail(account.Id, account.EmailEncrypted)),
EmailVerified = account.EmailVerified,
CreatedAt = account.CreatedAt,
LastLoginAt = account.LastLoginAt,
PasswordChangedAt = account.PasswordChangedAt,
OtpEnabled = account.OtpEnabled,
NewDevicesSuspended = account.Status == PlayerStatus.Suspended
};
/// <summary>Empreinte d'un secret de jeton de renouvellement (seule valeur conservée).</summary>
private static string HashSecret(string secret) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("GameLauncher/Player/Refresh/v1\n" + secret))).ToLowerInvariant();
private static bool FixedEquals(string a, string b) =>
CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(a), Encoding.ASCII.GetBytes(b));
private static DateTimeOffset Min(DateTimeOffset a, DateTimeOffset b) => a <= b ? a : b;
}
@@ -1,9 +1,11 @@
using System.Reflection;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.System;
@@ -21,13 +23,23 @@ public sealed class SystemModule : IApiModule
var plus = version.IndexOf('+');
if (plus > 0) version = version[..plus]; // sans l'empreinte du commit
api.MapGet(ApiRoutes.Info, (ServerIdentity server) => Results.Json(new ServerInfo
api.MapGet(ApiRoutes.Info, (ServerIdentity server, IOptions<LauncherApiOptions> options) => Results.Json(new ServerInfo
{
ServerId = server.ServerId,
Origin = server.Origin,
ApiVersion = ApiRoutes.Version,
ServerVersion = version,
KeyAlgorithms = new[] { AdminKeys.Algorithm }
KeyAlgorithms = new[] { AdminKeys.Algorithm },
// Comptes joueurs : annoncés seulement s'ils sont activés (le launcher n'affiche le bouton « Compte » qu'alors).
Accounts = options.Value.Players.Enabled
? new PlayerAccountsInfo
{
Enabled = true,
RegistrationOpen = options.Value.Players.RegistrationEnabled,
TermsVersion = options.Value.Players.TermsVersion,
PasswordRulesVersion = PasswordRules.Version
}
: null
}, ApiJson.Options));
api.MapGet(ApiRoutes.Health, () => Results.Json(new HealthStatus(), ApiJson.Options));
@@ -63,7 +63,13 @@ public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSc
if (!header.StartsWith("Bearer ", StringComparison.Ordinal))
return Task.FromResult(AuthenticateResult.Fail("Schéma d'authentification inattendu."));
var session = _sessions.Find(header["Bearer ".Length..].Trim());
var token = header["Bearer ".Length..].Trim();
// Jeton de JOUEUR (« glp… ») : jamais cherché parmi les sessions d'administrateurs. Un compte joueur ne peut ouvrir
// aucune route d'administration (voir PlayerSessionHandler).
if (token.StartsWith("glp", StringComparison.Ordinal))
return Task.FromResult(AuthenticateResult.NoResult());
var session = _sessions.Find(token);
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session absente ou expirée."));
var database = _store.Read();
@@ -115,12 +121,16 @@ public static class AdminAuthorization
{
services.AddAuthentication(AdminSessionHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, AdminSessionHandler>(AdminSessionHandler.SchemeName, null);
// Schéma désigné explicitement dans chaque règle : une route d'administration n'accepte QUE la session d'un
// administrateur, jamais celle d'un joueur (schéma « PlayerSession »), même si le schéma par défaut changeait.
services.AddAuthorization(options =>
{
options.AddPolicy(AdminSessionHandler.SchemeName, p => p.RequireAuthenticatedUser());
options.AddPolicy(AdminSessionHandler.SchemeName,
p => p.AddAuthenticationSchemes(AdminSessionHandler.SchemeName).RequireAuthenticatedUser());
foreach (var permission in AdminPermissions.All)
options.AddPolicy(PolicyName(permission),
p => p.RequireAuthenticatedUser().RequireClaim(AdminSessionHandler.PermissionClaim, permission));
p => p.AddAuthenticationSchemes(AdminSessionHandler.SchemeName).RequireAuthenticatedUser()
.RequireClaim(AdminSessionHandler.PermissionClaim, permission));
});
return services;
}
@@ -0,0 +1,128 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage.Players;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Security;
/// <summary>Joueur authentifié pour la requête en cours.</summary>
public sealed record AuthenticatedPlayer(PlayerAccessSession Session, PlayerAccountRecord Account, PlayerDeviceRecord? Device);
/// <summary>
/// Schéma « PlayerSession » : jeton joueur (« Authorization: Bearer glpa_… » ou « glpc_… »). TOTALEMENT distinct du
/// schéma des administrateurs (<see cref="AdminSessionHandler"/>) : autre stockage, autres préfixes, et les routes
/// d'administration n'utilisent jamais ce schéma (leurs règles désignent explicitement « AdminSession »). Un compte joueur
/// ne peut donc ouvrir aucune route d'administration, quels que soient ses droits ou son état.
///
/// À chaque requête : jeton valide ET compte actif (ou suspendu : la suspension ne vise que les nouveaux appareils) ET
/// (session complète) appareil toujours enregistré et aucun changement de mot de passe exigé. Une désactivation ou une
/// déconnexion prend effet immédiatement.
/// </summary>
public sealed class PlayerSessionHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "PlayerSession";
public const string ScopeClaim = "player_scope";
private const string ContextKey = "GameLauncher.Player";
private readonly PlayerSessionStore _sessions;
private readonly IServiceProvider _services;
private readonly LauncherApiOptions _options;
public PlayerSessionHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder,
PlayerSessionStore sessions, IServiceProvider services, IOptions<LauncherApiOptions> apiOptions)
: base(options, logger, encoder)
{
_sessions = sessions;
_services = services;
_options = apiOptions.Value;
}
public static AuthenticatedPlayer? Get(HttpContext context) =>
context.Items.TryGetValue(ContextKey, out var value) ? value as AuthenticatedPlayer : null;
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!_options.Players.Enabled) return Task.FromResult(AuthenticateResult.NoResult());
var header = Request.Headers.Authorization.ToString();
if (!header.StartsWith("Bearer ", StringComparison.Ordinal)) return Task.FromResult(AuthenticateResult.NoResult());
var token = header["Bearer ".Length..].Trim();
if (!token.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) &&
!token.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal))
return Task.FromResult(AuthenticateResult.NoResult()); // pas un jeton joueur
var session = _sessions.Find(token);
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session joueur absente ou expirée."));
var store = _services.GetRequiredService<PlayerStore>();
var account = store.FindById(session.AccountId);
// Suspendu : les appareils déjà connectés gardent l'accès (seuls les nouveaux appareils sont bloqués).
if (account is null || account.Status is not (PlayerStatus.Active or PlayerStatus.Suspended))
{
_sessions.RemoveWhere(s => s.AccountId == session.AccountId);
return Task.FromResult(AuthenticateResult.Fail("Compte supprimé, désactivé ou suspendu."));
}
PlayerDeviceRecord? device = null;
if (session.Scope == PlayerScopes.Full)
{
device = session.DeviceId is { } deviceId ? store.FindDevice(deviceId) : null;
if (device is null || device.AccountId != account.Id || account.MustChangePassword)
{
_sessions.Remove(session);
return Task.FromResult(AuthenticateResult.Fail("Appareil déconnecté ou nouveau mot de passe exigé."));
}
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, account.Id),
new(ScopeClaim, session.Scope)
};
Context.Items[ContextKey] = new AuthenticatedPlayer(session, account, device);
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName)));
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties) =>
ApiResults.WriteAsync(Context, 401, ApiErrorCodes.Unauthorized, "Connexion au compte requise (session absente ou expirée).");
protected override Task HandleForbiddenAsync(AuthenticationProperties properties) =>
ApiResults.WriteAsync(Context, 403, ApiErrorCodes.PasswordChangeRequired,
"Choisissez d'abord un nouveau mot de passe.");
}
public static class PlayerAuthorization
{
public const string PlayerPolicy = "player";
public const string PasswordChangePolicy = "player-password-change";
/// <summary>Route réservée à un joueur connecté (session complète).</summary>
public static TBuilder RequirePlayer<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
builder.RequireAuthorization(PlayerPolicy);
/// <summary>Route ouverte à une session complète OU au jeton limité « changement de mot de passe exigé ».</summary>
public static TBuilder RequirePlayerOrPasswordChange<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
builder.RequireAuthorization(PasswordChangePolicy);
public static IServiceCollection AddPlayerAuthentication(this IServiceCollection services)
{
services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, PlayerSessionHandler>(PlayerSessionHandler.SchemeName, null);
services.AddAuthorization(options =>
{
options.AddPolicy(PlayerPolicy, p => p.AddAuthenticationSchemes(PlayerSessionHandler.SchemeName).RequireAuthenticatedUser()
.RequireClaim(PlayerSessionHandler.ScopeClaim, PlayerScopes.Full));
options.AddPolicy(PasswordChangePolicy, p => p.AddAuthenticationSchemes(PlayerSessionHandler.SchemeName).RequireAuthenticatedUser()
.RequireClaim(PlayerSessionHandler.ScopeClaim, PlayerScopes.Full, PlayerScopes.PasswordChange));
});
return services;
}
/// <summary>Joueur authentifié de la requête (les routes protégées en ont toujours un).</summary>
public static AuthenticatedPlayer Player(this HttpContext context) =>
PlayerSessionHandler.Get(context) ?? throw new InvalidOperationException("Route non protégée par RequirePlayer.");
}
@@ -0,0 +1,242 @@
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Security;
/// <summary>
/// Protections des routes ANONYMES des comptes joueurs (création, connexion, renouvellement) :
/// • défis SANS ÉTAT (usage, échéance, difficulté et nonce scellés par HMAC, secret en mémoire, domaine propre aux
/// joueurs) : en demander ne coûte rien au serveur ; seuls les défis utilisés sont mémorisés (refus du rejeu) ;
/// • preuve de travail (création de compte, connexion) : coûteuse pour un robot, négligeable pour un joueur ;
/// • compteurs en mémoire par adresse IP (IPv6 : /64) : créations de compte, échecs de connexion ;
/// • blocage « miroir » des adresses inconnues : une adresse sans compte se bloque exactement comme un compte réel
/// (la réponse « trop d'essais » ne révèle donc pas qu'un compte existe).
/// </summary>
public sealed class PlayerGuard
{
private const int NonceLength = 16;
private const int MacLength = 16;
private const int PayloadLength = 1 + 8 + 1 + NonceLength; // usage + échéance + difficulté + nonce
private const int MaxTracked = 100_000;
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Player/Challenge/v1\n");
private readonly byte[] _secret = RandomNumberGenerator.GetBytes(32);
private readonly ConcurrentDictionary<string, DateTimeOffset> _used = new(StringComparer.Ordinal);
private readonly SlidingCounters _registrations = new();
private readonly SlidingCounters _failedLogins = new();
private readonly Dictionary<string, (int Failed, int Level, DateTimeOffset? Until)> _unknown = new(StringComparer.Ordinal);
private readonly LauncherApiOptions.PlayersSettings _settings;
private readonly TimeProvider _time;
public PlayerGuard(IOptions<LauncherApiOptions> options, TimeProvider time)
{
_settings = options.Value.Players;
_time = time;
}
// ------------------------------------------------------------------ Défis
public PlayerChallengeResponse CreateChallenge(string purpose, string serverId, string origin)
{
var code = PurposeCode(purpose);
var difficulty = purpose switch
{
PlayerChallengePurposes.Register => _settings.RegisterProofDifficulty,
PlayerChallengePurposes.Login => _settings.LoginProofDifficulty,
_ => 0
};
var expires = _time.GetUtcNow() + TimeSpan.FromSeconds(_settings.ChallengeLifetimeSeconds);
var payload = new byte[PayloadLength];
payload[0] = code;
BinaryPrimitives.WriteInt64BigEndian(payload.AsSpan(1), expires.ToUnixTimeSeconds());
payload[9] = (byte)difficulty;
RandomNumberGenerator.Fill(payload.AsSpan(10));
var id = new byte[PayloadLength + MacLength];
payload.CopyTo(id, 0);
Mac(payload).CopyTo(id, PayloadLength);
return new PlayerChallengeResponse
{
ChallengeId = Base64Url.Encode(id),
Nonce = Base64Url.Encode(payload.AsSpan(10)),
Purpose = purpose,
Difficulty = difficulty,
ServerId = serverId,
Origin = origin,
ExpiresAt = DateTimeOffset.FromUnixTimeSeconds(expires.ToUnixTimeSeconds())
};
}
/// <summary>
/// Vérifie le défi (sceau, usage, échéance) et la preuve de travail, SANS le consommer. Renvoie le nonce (à vérifier
/// dans la signature de l'appareil), ou null.
/// </summary>
public string? Validate(string? challengeId, string purpose, string? solution)
{
if (challengeId is not { Length: > 0 and <= 64 } || !Base64Url.TryDecode(challengeId, out var id) || id.Length != PayloadLength + MacLength)
return null;
var payload = id.AsSpan(0, PayloadLength);
if (!CryptographicOperations.FixedTimeEquals(Mac(payload), id.AsSpan(PayloadLength))) return null;
if (payload[0] != PurposeCode(purpose)) return null;
var now = _time.GetUtcNow();
var expires = DateTimeOffset.FromUnixTimeSeconds(BinaryPrimitives.ReadInt64BigEndian(payload[1..9]));
if (expires <= now) return null;
int difficulty = payload[9];
if (purpose != PlayerChallengePurposes.Refresh)
{
// Difficulté scellée à la création ; jamais en dessous du minimum pour la création de compte et la connexion.
if (difficulty < FeedbackProof.MinDifficulty || !FeedbackProof.Verify(payload[10..], difficulty, solution)) return null;
}
return Base64Url.Encode(payload[10..]);
}
/// <summary>Consomme le défi (après vérification de la signature de l'appareil). Faux s'il a déjà servi (rejeu).</summary>
public bool TryConsume(string challengeId)
{
var now = _time.GetUtcNow();
if (_used.Count >= MaxTracked)
{
foreach (var (key, until) in _used)
if (until <= now) _used.TryRemove(key, out _);
if (_used.Count >= MaxTracked) throw new StoreBusyException("Trop de défis récents.");
}
return _used.TryAdd(challengeId, now + TimeSpan.FromSeconds(_settings.ChallengeLifetimeSeconds + 5));
}
private static byte PurposeCode(string purpose) => purpose switch
{
PlayerChallengePurposes.Register => 1,
PlayerChallengePurposes.Login => 2,
PlayerChallengePurposes.Refresh => 3,
_ => throw new ArgumentException("Usage de défi inconnu.", nameof(purpose))
};
private byte[] Mac(ReadOnlySpan<byte> payload)
{
var data = new byte[Domain.Length + payload.Length];
Domain.CopyTo(data, 0);
payload.CopyTo(data.AsSpan(Domain.Length));
return HMACSHA256.HashData(_secret, data)[..MacLength];
}
// ------------------------------------------------------------------ Créations de compte par adresse IP
/// <summary>Compte une création de compte pour cette adresse. Faux (rien n'est compté) si la limite horaire ou
/// quotidienne est atteinte ; <paramref name="retryAfter"/> : délai avant la prochaine place.</summary>
public bool TryCountRegistration(string partition, out TimeSpan retryAfter) =>
_registrations.TryAdd(partition, _time.GetUtcNow(),
(TimeSpan.FromHours(1), _settings.RegistrationsPerIpPerHour), (TimeSpan.FromDays(1), _settings.RegistrationsPerIpPerDay),
out retryAfter);
// ------------------------------------------------------------------ Échecs de connexion par adresse IP
/// <summary>Vrai si cette adresse a trop échoué récemment (toutes adresses e-mail confondues).</summary>
public bool IsIpThrottled(string partition, out TimeSpan retryAfter) =>
_failedLogins.IsFull(partition, _time.GetUtcNow(), TimeSpan.FromMinutes(15), _settings.FailedLoginsPerIpPer15Minutes, out retryAfter);
public void CountFailedLogin(string partition) =>
_failedLogins.Add(partition, _time.GetUtcNow(), TimeSpan.FromMinutes(15));
// ------------------------------------------------------------------ Adresses inconnues (blocage miroir)
/// <summary>Fin du blocage d'une adresse SANS compte (même règle que pour un compte réel), ou null.</summary>
public DateTimeOffset? UnknownLockedUntil(string emailIndex)
{
lock (_unknown)
return _unknown.TryGetValue(emailIndex, out var state) && state.Until > _time.GetUtcNow() ? state.Until : null;
}
/// <summary>Échec sur une adresse sans compte : compté comme pour un compte (sans suspension, qui n'est jamais
/// révélée avant un mot de passe correct). Renvoie la fin du blocage déclenché, ou null.</summary>
public DateTimeOffset? CountUnknownFailure(string emailIndex)
{
var now = _time.GetUtcNow();
lock (_unknown)
{
if (_unknown.Count >= MaxTracked)
{
foreach (var key in _unknown.Where(p => p.Value.Until is null || p.Value.Until <= now).Select(p => p.Key).ToList())
_unknown.Remove(key);
if (_unknown.Count >= MaxTracked) _unknown.Clear();
}
_unknown.TryGetValue(emailIndex, out var state);
var failed = state.Failed + 1;
var level = state.Level;
DateTimeOffset? until = null;
if (failed >= _settings.FailuresPerLock)
{
failed = 0;
level++;
until = now + Storage.Players.PlayerStore.LockDuration(_settings, level);
}
_unknown[emailIndex] = (failed, level, until ?? state.Until);
return until;
}
}
/// <summary>Compteurs en mémoire sur des fenêtres glissantes (aucune adresse n'est écrite sur le disque).</summary>
private sealed class SlidingCounters
{
private readonly Dictionary<string, List<DateTimeOffset>> _events = new(StringComparer.Ordinal);
public bool TryAdd(string key, DateTimeOffset now, (TimeSpan Window, int Max) first, (TimeSpan Window, int Max) second,
out TimeSpan retryAfter)
{
lock (_events)
{
var times = Get(key, now, second.Window);
retryAfter = TimeSpan.Zero;
foreach (var (window, max) in new[] { first, second })
{
var recent = times.Where(t => now - t < window).OrderBy(t => t).ToList();
if (recent.Count >= max)
{
retryAfter = recent[recent.Count - max] + window - now;
return false;
}
}
times.Add(now);
return true;
}
}
public bool IsFull(string key, DateTimeOffset now, TimeSpan window, int max, out TimeSpan retryAfter)
{
lock (_events)
{
retryAfter = TimeSpan.Zero;
if (!_events.TryGetValue(key, out var times)) return false;
var recent = times.Where(t => now - t < window).OrderBy(t => t).ToList();
if (recent.Count < max) return false;
retryAfter = recent[recent.Count - max] + window - now;
return true;
}
}
public void Add(string key, DateTimeOffset now, TimeSpan window)
{
lock (_events) Get(key, now, window).Add(now);
}
private List<DateTimeOffset> Get(string key, DateTimeOffset now, TimeSpan keep)
{
if (_events.Count >= MaxTracked)
{
foreach (var stale in _events.Where(p => p.Value.All(t => now - t >= keep)).Select(p => p.Key).ToList())
_events.Remove(stale);
if (_events.Count >= MaxTracked) throw new StoreBusyException("Trop d'adresses suivies.");
}
if (!_events.TryGetValue(key, out var times)) _events[key] = times = new List<DateTimeOffset>();
times.RemoveAll(t => now - t >= keep);
return times;
}
}
}
@@ -0,0 +1,145 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Security;
/// <summary>Portée d'un jeton joueur.</summary>
public static class PlayerScopes
{
/// <summary>Session complète (jeton « glpa_ »).</summary>
public const string Full = "full";
/// <summary>Seulement le changement de mot de passe exigé (jeton « glpc_ »).</summary>
public const string PasswordChange = "password_change";
}
/// <summary>Jeton joueur valide (en mémoire uniquement : seule l'empreinte du jeton est gardée).</summary>
public sealed class PlayerAccessSession
{
public required string TokenHash { get; init; }
public required string Scope { get; init; }
public required string AccountId { get; init; }
/// <summary>Appareil de la session (portée complète).</summary>
public string? DeviceId { get; init; }
/// <summary>Clé publique et nom de l'appareil (portée « changement exigé » : l'appareil est enregistré une fois le
/// mot de passe changé).</summary>
public string? DevicePublicKey { get; init; }
public string? DeviceKeyId { get; init; }
public string? DeviceLabel { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>
/// Jetons d'accès des joueurs, en mémoire (un redémarrage du service les efface : le launcher renouvelle sa session avec
/// son jeton de renouvellement, sans rien demander au joueur). Totalement séparés des sessions des administrateurs
/// (<see cref="SessionStore"/>) : autre stockage, autre préfixe, autre schéma d'authentification.
/// </summary>
public sealed class PlayerSessionStore
{
private const int MaxSessions = 200_000;
private static readonly TimeSpan PasswordChangeLifetime = TimeSpan.FromMinutes(10);
private readonly ConcurrentDictionary<string, PlayerAccessSession> _byHash = new(StringComparer.Ordinal);
private readonly TimeProvider _time;
private readonly LauncherApiOptions.PlayersSettings _settings;
public PlayerSessionStore(IOptions<LauncherApiOptions> options, TimeProvider time)
{
_time = time;
_settings = options.Value.Players;
}
public int Count => _byHash.Count;
/// <summary>Jeton d'accès d'une session complète sur un appareil.</summary>
public (string Token, PlayerAccessSession Session) CreateAccess(string accountId, string deviceId)
{
var token = PlayerTokens.AccessPrefix + PlayerTokens.NewSecret();
var session = new PlayerAccessSession
{
TokenHash = Hash(token),
Scope = PlayerScopes.Full,
AccountId = accountId,
DeviceId = deviceId,
ExpiresAt = _time.GetUtcNow() + TimeSpan.FromMinutes(_settings.AccessTokenMinutes)
};
Add(session);
return (token, session);
}
/// <summary>Jeton limité au changement de mot de passe exigé (10 min), qui retient l'appareil à enregistrer ensuite.</summary>
public (string Token, PlayerAccessSession Session) CreatePasswordChange(string accountId, string devicePublicKey, string deviceKeyId,
string deviceLabel)
{
var token = PlayerTokens.PasswordChangePrefix + PlayerTokens.NewSecret();
var session = new PlayerAccessSession
{
TokenHash = Hash(token),
Scope = PlayerScopes.PasswordChange,
AccountId = accountId,
DevicePublicKey = devicePublicKey,
DeviceKeyId = deviceKeyId,
DeviceLabel = deviceLabel,
ExpiresAt = _time.GetUtcNow() + PasswordChangeLifetime
};
Add(session);
return (token, session);
}
/// <summary>Session du jeton, si elle est encore valide.</summary>
public PlayerAccessSession? Find(string? token)
{
if (string.IsNullOrEmpty(token) || token.Length > PlayerTokens.MaxLength) return null;
if (!token.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) &&
!token.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal))
return null;
var hash = Hash(token);
if (!_byHash.TryGetValue(hash, out var session)) return null;
if (session.ExpiresAt <= _time.GetUtcNow())
{
_byHash.TryRemove(hash, out _);
return null;
}
// Le préfixe doit correspondre à la portée (un jeton ne change jamais de portée).
var expectedPrefix = session.Scope == PlayerScopes.Full ? PlayerTokens.AccessPrefix : PlayerTokens.PasswordChangePrefix;
return token.StartsWith(expectedPrefix, StringComparison.Ordinal) ? session : null;
}
public void Remove(PlayerAccessSession session) => _byHash.TryRemove(session.TokenHash, out _);
/// <summary>Ferme toutes les sessions d'un compte (ou d'appareils donnés).</summary>
public int RemoveWhere(Func<PlayerAccessSession, bool> predicate)
{
var removed = 0;
foreach (var (hash, session) in _byHash)
if (predicate(session) && _byHash.TryRemove(hash, out _)) removed++;
return removed;
}
public int RemoveDevices(IEnumerable<string> deviceIds)
{
var set = deviceIds.ToHashSet(StringComparer.Ordinal);
return set.Count == 0 ? 0 : RemoveWhere(s => s.DeviceId is { } id && set.Contains(id));
}
private void Add(PlayerAccessSession session)
{
if (_byHash.Count >= MaxSessions)
{
var now = _time.GetUtcNow();
RemoveWhere(s => s.ExpiresAt <= now);
if (_byHash.Count >= MaxSessions) throw new StoreBusyException("Trop de sessions de joueurs ouvertes.");
}
_byHash[session.TokenHash] = session;
}
internal static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
}
@@ -0,0 +1,124 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Server.Hosting;
using Konscious.Security.Cryptography;
namespace GameLauncher.Server.Storage.Players;
/// <summary>
/// Empreintes des mots de passe des joueurs : Argon2id (RFC 9106, cité par la CNIL et l'OWASP), sel aléatoire de 128 bits
/// par compte, et « poivre » = clé secrète d'Argon2 dérivée de la clé maîtresse (hors du dossier des données). Format PHC :
/// <c>$argon2id$v=19$m=19456,t=2,p=1,k=1$&lt;sel&gt;$&lt;empreinte&gt;</c> (k : version du poivre). Les paramètres sont
/// gardés avec l'empreinte : un relèvement des réglages s'applique à la connexion suivante de chaque joueur.
///
/// Les calculs (≈ 19 Mio de mémoire chacun) passent par une file bornée : un afflux de connexions ne peut pas épuiser
/// la mémoire ou le processeur du VPS (les requêtes en trop attendent, puis reçoivent 503).
/// </summary>
public sealed class PlayerPasswordHasher
{
private const int SaltLength = 16;
private const int HashLength = 32;
private const string Prefix = "$argon2id$v=19$";
private static readonly TimeSpan QueueTimeout = TimeSpan.FromSeconds(20);
private readonly PlayerSecrets _secrets;
private readonly LauncherApiOptions.PlayersSettings _settings;
private readonly SemaphoreSlim _slots;
public PlayerPasswordHasher(PlayerSecrets secrets, LauncherApiOptions.PlayersSettings settings)
{
_secrets = secrets;
_settings = settings;
_slots = new SemaphoreSlim(settings.MaxConcurrentHashes, settings.MaxConcurrentHashes);
}
/// <summary>Empreinte d'un nouveau mot de passe (UTF-8 du mot de passe tel que saisi).</summary>
public async Task<string> HashAsync(string password, CancellationToken ct = default)
{
var salt = RandomNumberGenerator.GetBytes(SaltLength);
var m = _settings.HashMemoryKib;
var t = _settings.HashIterations;
var p = _settings.HashParallelism;
var hash = await ComputeAsync(password, salt, m, t, p, ct);
return string.Create(CultureInfo.InvariantCulture,
$"{Prefix}m={m},t={t},p={p},k={PlayerSecrets.KeyVersion}${Base64Url.Encode(salt)}${Base64Url.Encode(hash)}");
}
/// <summary>
/// Vérifie un mot de passe. <c>NeedsRehash</c> : empreinte calculée avec d'anciens réglages (à remplacer après une
/// connexion réussie). Une empreinte illisible ne correspond à rien.
/// </summary>
public async Task<(bool Valid, bool NeedsRehash)> VerifyAsync(string password, string stored, CancellationToken ct = default)
{
if (!TryParse(stored, out var m, out var t, out var p, out var salt, out var expected))
{
await DummyAsync(password, ct);
return (false, false);
}
var actual = await ComputeAsync(password, salt, m, t, p, ct);
var valid = CryptographicOperations.FixedTimeEquals(actual, expected);
var rehash = valid && (m != _settings.HashMemoryKib || t != _settings.HashIterations || p != _settings.HashParallelism);
return (valid, rehash);
}
/// <summary>Même calcul pour une adresse inconnue : la durée de réponse ne révèle pas si le compte existe.</summary>
public Task DummyAsync(string password, CancellationToken ct = default) =>
ComputeAsync(password, RandomNumberGenerator.GetBytes(SaltLength), _settings.HashMemoryKib, _settings.HashIterations,
_settings.HashParallelism, ct);
private async Task<byte[]> ComputeAsync(string password, byte[] salt, int memoryKib, int iterations, int parallelism, CancellationToken ct)
{
var bytes = Encoding.UTF8.GetBytes(password);
if (!await _slots.WaitAsync(QueueTimeout, ct))
{
CryptographicOperations.ZeroMemory(bytes);
throw new StoreBusyException("Trop de calculs de mots de passe en attente.");
}
try
{
using var argon2 = new Argon2id(bytes)
{
Salt = salt,
KnownSecret = _secrets.PasswordPepper,
MemorySize = memoryKib,
Iterations = iterations,
DegreeOfParallelism = parallelism
};
return argon2.GetBytes(HashLength);
}
finally
{
_slots.Release();
CryptographicOperations.ZeroMemory(bytes);
}
}
private static bool TryParse(string stored, out int m, out int t, out int p, out byte[] salt, out byte[] hash)
{
m = t = p = 0;
salt = hash = Array.Empty<byte>();
if (string.IsNullOrEmpty(stored) || !stored.StartsWith(Prefix, StringComparison.Ordinal)) return false;
var parts = stored[Prefix.Length..].Split('$');
if (parts.Length != 3) return false;
int k = 0;
foreach (var pair in parts[0].Split(','))
{
var eq = pair.IndexOf('=');
if (eq <= 0 || !int.TryParse(pair[(eq + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out var value)) return false;
switch (pair[..eq])
{
case "m": m = value; break;
case "t": t = value; break;
case "p": p = value; break;
case "k": k = value; break;
default: return false;
}
}
if (m is < 8 or > 4_194_304 || t is < 1 or > 100 || p is < 1 or > 64 || k != PlayerSecrets.KeyVersion) return false;
return Base64Url.TryDecode(parts[1], out salt) && salt.Length == SaltLength &&
Base64Url.TryDecode(parts[2], out hash) && hash.Length == HashLength;
}
}
@@ -0,0 +1,94 @@
namespace GameLauncher.Server.Storage.Players;
/// <summary>États d'un compte joueur.</summary>
public static class PlayerStatus
{
public const string Active = "active";
/// <summary>Désactivé par un administrateur (jusqu'à une date, ou indéfiniment).</summary>
public const string Disabled = "disabled";
/// <summary>
/// Suspendu après trop d'échecs de connexion depuis des appareils inconnus : connexions depuis un NOUVEL appareil
/// refusées jusqu'à ce qu'un administrateur lève la suspension ; les appareils déjà connectés continuent de fonctionner.
/// </summary>
public const string Suspended = "suspended";
}
/// <summary>Compte joueur tel qu'enregistré (adresse chiffrée ; jamais renvoyé tel quel par l'API).</summary>
public sealed record PlayerAccountRecord
{
public required string Id { get; init; }
public required string Reference { get; init; }
public required string EmailIndex { get; init; }
public required string EmailEncrypted { get; init; }
public bool EmailVerified { get; init; }
public required string PasswordHash { get; init; }
public DateTimeOffset PasswordChangedAt { get; init; }
public bool MustChangePassword { get; init; }
public bool OtpEnabled { get; init; }
public string Status { get; init; } = PlayerStatus.Active;
public DateTimeOffset? DisabledUntil { get; init; }
public DateTimeOffset? StatusChangedAt { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastLoginAt { get; init; }
public required string TermsVersion { get; init; }
/// <summary>Échecs consécutifs depuis des appareils inconnus (série en cours), et nombre de blocages successifs.</summary>
public int FailedCount { get; init; }
public int LockLevel { get; init; }
public DateTimeOffset? LockedUntil { get; init; }
/// <summary>Échecs consécutifs depuis des appareils de confiance, et leur blocage temporaire.</summary>
public int TrustedFailedCount { get; init; }
public DateTimeOffset? TrustedLockedUntil { get; init; }
}
/// <summary>Appareil connecté (clé publique de l'appareil et session mémorisée).</summary>
public sealed record PlayerDeviceRecord
{
public required string Id { get; init; }
public required string AccountId { get; init; }
public required string KeyId { get; init; }
/// <summary>Clé publique de l'appareil (SPKI P-256, Base64).</summary>
public required string PublicKey { get; init; }
public required string Label { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset LastUsedAt { get; init; }
/// <summary>SHA-256 du jeton de renouvellement en vigueur, et du précédent (détection d'une réutilisation).</summary>
public required string RefreshHash { get; init; }
public string? PreviousRefreshHash { get; init; }
public DateTimeOffset RefreshExpiresAt { get; init; }
public DateTimeOffset RefreshAbsoluteExpiresAt { get; init; }
}
/// <summary>Ligne du journal des comptes.</summary>
public sealed record PlayerEventRecord(long Id, string? AccountId, DateTimeOffset At, string Type, string Result, string? Ip, string? DeviceKeyId,
string? Details);
/// <summary>Résultat d'un échec de connexion pour un compte existant.</summary>
public sealed record PlayerFailureOutcome(DateTimeOffset? LockedUntil, bool Suspended, int LockLevel);
/// <summary>Types d'événements du journal des comptes (actions des joueurs et du système).</summary>
public static class PlayerEvents
{
public const string Register = "account.register";
public const string Login = "account.login";
public const string LoginFailed = "account.login_failed";
public const string Locked = "account.locked";
public const string Suspended = "account.suspended";
public const string Unlocked = "account.unlocked";
public const string Disabled = "account.disabled";
public const string Enabled = "account.enabled";
public const string Logout = "account.logout";
public const string LogoutAll = "account.logout_all";
public const string PasswordChange = "account.password_change";
public const string PasswordChangeForced = "account.password_change_required";
public const string Refresh = "session.refresh";
public const string RefreshReuse = "session.reuse";
public const string DeviceRemoved = "device.removed";
}
@@ -0,0 +1,134 @@
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
namespace GameLauncher.Server.Storage.Players;
/// <summary>
/// Clés des comptes joueurs, toutes dérivées (HKDF-SHA256) d'une clé maîtresse de 256 bits gardée HORS du dossier des
/// données (credential systemd chiffré, déchiffré par systemd au démarrage dans un dossier en mémoire visible du seul
/// service). Une copie de /var/lib/launcher-api ou de ses sauvegardes ne permet donc ni de lire les adresses e-mail, ni
/// de tester des mots de passe hors ligne (le « poivre » des empreintes en dérive aussi).
///
/// Une sous-clé par usage : chiffrement des adresses, index aveugle (recherche par adresse sans la stocker en clair),
/// poivre des mots de passe, et pour les étapes suivantes : secrets OTP, notes des administrateurs, messages.
/// </summary>
public sealed class PlayerSecrets
{
/// <summary>Version de la clé maîtresse, écrite devant chaque valeur chiffrée (rotation future).</summary>
public const int KeyVersion = 1;
private const int KeyLength = 32;
private const int NonceLength = 12;
private const int TagLength = 16;
private static readonly byte[] HkdfSalt = Encoding.ASCII.GetBytes("GameLauncher/Player/Keys/v1");
private readonly byte[] _emailKey;
private readonly byte[] _indexKey;
private readonly byte[] _pepper;
private readonly byte[] _fieldKey;
public PlayerSecrets(ReadOnlySpan<byte> masterKey)
{
if (masterKey.Length != KeyLength) throw new CryptographicException("La clé maîtresse des comptes joueurs doit faire 32 octets.");
if (masterKey.IndexOfAnyExcept((byte)0) < 0) throw new CryptographicException("Clé maîtresse vide (que des zéros).");
_emailKey = Derive(masterKey, "email-encryption/v1");
_indexKey = Derive(masterKey, "email-index/v1");
_pepper = Derive(masterKey, "password-pepper/v1");
_fieldKey = Derive(masterKey, "field-encryption/v1");
}
/// <summary>
/// Lit la clé maîtresse (Base64 de 32 octets). Sous Linux, le fichier ne doit être ni lisible ni modifiable par les
/// autres comptes, ni modifiable par le groupe (un credential systemd n'est lisible que par le service).
/// </summary>
public static PlayerSecrets Load(string path)
{
if (!File.Exists(path)) throw new InvalidOperationException($"Clé maîtresse des comptes joueurs introuvable : {path}");
if (!OperatingSystem.IsWindows())
{
var mode = File.GetUnixFileMode(path);
const UnixFileMode forbidden = UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute;
if ((mode & forbidden) != 0)
throw new InvalidOperationException(
$"Clé maîtresse des comptes joueurs accessible à d'autres comptes ({path}) : droits 0400 ou 0600 attendus.");
}
var text = File.ReadAllText(path).Trim();
byte[] key;
try
{
key = Convert.FromBase64String(text);
}
catch (FormatException)
{
throw new InvalidOperationException("Clé maîtresse des comptes joueurs illisible : 32 octets en Base64 attendus (commande players-key).");
}
try
{
return new PlayerSecrets(key);
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
}
/// <summary>Nouvelle clé maîtresse (commande « players-key »), en Base64.</summary>
public static string NewMasterKey() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(KeyLength));
/// <summary>Poivre des empreintes de mots de passe (« clé secrète » d'Argon2id).</summary>
public byte[] PasswordPepper => _pepper;
/// <summary>
/// Index aveugle d'une adresse NORMALISÉE : HMAC-SHA256 avec une clé secrète. Permet de retrouver un compte et
/// d'imposer l'unicité des adresses sans conserver l'adresse en clair ; sans la clé, impossible de tester une adresse.
/// </summary>
public string EmailIndex(string normalizedEmail) =>
Convert.ToHexString(HMACSHA256.HashData(_indexKey, Encoding.UTF8.GetBytes("GameLauncher/Player/Email/v1\n" + normalizedEmail)))
.ToLowerInvariant();
/// <summary>Chiffre l'adresse d'un compte (liée à ce compte : une valeur copiée sur un autre compte ne se déchiffre pas).</summary>
public string EncryptEmail(string accountId, string normalizedEmail) =>
Encrypt(_emailKey, Encoding.UTF8.GetBytes(normalizedEmail), Associated("email", accountId));
public string DecryptEmail(string accountId, string value) =>
Encoding.UTF8.GetString(Decrypt(_emailKey, value, Associated("email", accountId)));
/// <summary>Chiffre un champ libre d'un compte (motifs, notes…), lié au compte et au nom du champ.</summary>
public string EncryptField(string accountId, string field, string text) =>
Encrypt(_fieldKey, Encoding.UTF8.GetBytes(text), Associated(field, accountId));
public string DecryptField(string accountId, string field, string value) =>
Encoding.UTF8.GetString(Decrypt(_fieldKey, value, Associated(field, accountId)));
private static byte[] Associated(string field, string accountId) =>
Encoding.UTF8.GetBytes($"GameLauncher/Player/Field/v1\n{field}\n{accountId}");
private static byte[] Derive(ReadOnlySpan<byte> masterKey, string purpose) =>
HKDF.DeriveKey(HashAlgorithmName.SHA256, masterKey.ToArray(), KeyLength, HkdfSalt, Encoding.ASCII.GetBytes(purpose));
/// <summary>Format : « 1. » (version de la clé) + Base64Url(nonce ‖ chiffré ‖ étiquette), AES-256-GCM.</summary>
private static string Encrypt(byte[] key, byte[] plaintext, byte[] associated)
{
var buffer = new byte[NonceLength + plaintext.Length + TagLength];
var nonce = buffer.AsSpan(0, NonceLength);
RandomNumberGenerator.Fill(nonce);
using var aes = new AesGcm(key, TagLength);
aes.Encrypt(nonce, plaintext, buffer.AsSpan(NonceLength, plaintext.Length), buffer.AsSpan(NonceLength + plaintext.Length), associated);
return $"{KeyVersion}.{Base64Url.Encode(buffer)}";
}
private static byte[] Decrypt(byte[] key, string value, byte[] associated)
{
var dot = value.IndexOf('.');
if (dot <= 0 || value[..dot] != KeyVersion.ToString(System.Globalization.CultureInfo.InvariantCulture) ||
!Base64Url.TryDecode(value[(dot + 1)..], out var buffer) || buffer.Length < NonceLength + TagLength)
throw new CryptographicException("Valeur chiffrée illisible ou d'une autre version de clé.");
var length = buffer.Length - NonceLength - TagLength;
var plaintext = new byte[length];
using var aes = new AesGcm(key, TagLength);
aes.Decrypt(buffer.AsSpan(0, NonceLength), buffer.AsSpan(NonceLength, length), buffer.AsSpan(NonceLength + length), plaintext, associated);
return plaintext;
}
}
@@ -0,0 +1,520 @@
using System.Globalization;
using GameLauncher.Server.Hosting;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage.Players;
/// <summary>
/// Base des comptes joueurs : SQLite (players/players.db dans les données privées, dossier 0700, fichier 0600).
///
/// Pourquoi SQLite ici (et pas des fichiers JSON comme les autres modules) : relations (compte ⇄ appareils ⇄ journal, et
/// bientôt conversations, blocages), unicité stricte des adresses sous transaction, volumes non bornés, recherches par
/// index. Réglages : journal WAL, clés étrangères, effacement sécurisé (secure_delete : une ligne supprimée est écrasée
/// sur le disque), écriture synchrone complète. Requêtes PARAMÉTRÉES uniquement.
///
/// Une seule connexion, protégée par un verrou : SQLite n'accepte qu'un écrivain à la fois, les requêtes sont courtes,
/// et les calculs coûteux (empreintes de mots de passe) sont faits AVANT d'entrer ici. L'outil en ligne de commande peut
/// ouvrir la base en même temps (attente bornée).
///
/// Le contenu sensible est chiffré AVANT d'arriver ici (adresse e-mail : <see cref="PlayerSecrets"/>) : cette classe ne
/// connaît pas la clé maîtresse.
/// </summary>
public sealed class PlayerStore : IDisposable
{
public const string FolderName = "players";
public const string FileName = "players.db";
/// <summary>Version du schéma (PRAGMA user_version).</summary>
public const int SchemaVersion = 1;
private readonly object _lock = new();
private readonly SqliteConnection _db;
private readonly TimeProvider _time;
public PlayerStore(IOptions<LauncherApiOptions> options, TimeProvider time) : this(options.Value.DataDirectoryFullPath, time) { }
public PlayerStore(string dataDirectory, TimeProvider time)
{
_time = time;
SecureFiles.EnsureDirectory(dataDirectory);
var folder = Path.Combine(dataDirectory, FolderName);
SecureFiles.EnsureDirectory(folder);
DatabasePath = Path.Combine(folder, FileName);
// Fichier créé en 0600 AVANT que SQLite ne l'ouvre (les fichiers -wal et -shm reprennent ses droits).
if (!File.Exists(DatabasePath)) SecureFiles.TryCreateNew(DatabasePath, "");
_db = new SqliteConnection(new SqliteConnectionStringBuilder
{
DataSource = DatabasePath,
Mode = SqliteOpenMode.ReadWriteCreate,
Pooling = false
}.ToString());
_db.Open();
try
{
Execute(null, "PRAGMA busy_timeout = 5000;");
Scalar(null, "PRAGMA journal_mode = WAL;");
Execute(null, "PRAGMA foreign_keys = ON; PRAGMA secure_delete = ON; PRAGMA synchronous = FULL;");
Migrate();
}
catch
{
_db.Dispose();
throw;
}
}
public string DatabasePath { get; }
public void Dispose()
{
lock (_lock) _db.Dispose();
}
// ------------------------------------------------------------------ Schéma
private void Migrate()
{
var version = Convert.ToInt64(Scalar(null, "PRAGMA user_version;"), CultureInfo.InvariantCulture);
if (version > SchemaVersion)
throw new InvalidOperationException(
$"Base des comptes joueurs en version {version}, plus récente que ce serveur ({SchemaVersion}) : mettez le serveur à jour.");
if (version == SchemaVersion) return;
using var tx = _db.BeginTransaction();
if (version < 1)
{
Execute(tx, """
CREATE TABLE accounts (
id TEXT PRIMARY KEY,
reference TEXT NOT NULL UNIQUE,
email_index TEXT NOT NULL UNIQUE,
email_enc TEXT NOT NULL,
email_verified INTEGER NOT NULL DEFAULT 0,
password_hash TEXT NOT NULL,
password_changed_at INTEGER NOT NULL,
must_change_password INTEGER NOT NULL DEFAULT 0,
totp_secret_enc TEXT,
totp_last_step INTEGER,
status TEXT NOT NULL,
disabled_until INTEGER,
status_reason_enc TEXT,
status_changed_at INTEGER,
status_changed_by TEXT,
created_at INTEGER NOT NULL,
last_login_at INTEGER,
terms_version TEXT NOT NULL,
failed_count INTEGER NOT NULL DEFAULT 0,
lock_level INTEGER NOT NULL DEFAULT 0,
locked_until INTEGER,
trusted_failed_count INTEGER NOT NULL DEFAULT 0,
trusted_locked_until INTEGER
);
CREATE INDEX ix_accounts_created ON accounts(created_at);
CREATE INDEX ix_accounts_status ON accounts(status);
CREATE TABLE devices (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
key_id TEXT NOT NULL,
public_key TEXT NOT NULL,
label TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL,
refresh_hash TEXT NOT NULL,
previous_refresh_hash TEXT,
refresh_expires_at INTEGER NOT NULL,
refresh_absolute_expires_at INTEGER NOT NULL,
UNIQUE (account_id, key_id)
);
CREATE INDEX ix_devices_account ON devices(account_id);
CREATE TABLE account_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT REFERENCES accounts(id) ON DELETE CASCADE,
at INTEGER NOT NULL,
type TEXT NOT NULL,
result TEXT NOT NULL,
ip TEXT,
device_key TEXT,
details TEXT
);
CREATE INDEX ix_events_account ON account_events(account_id, at);
CREATE INDEX ix_events_at ON account_events(at);
""");
}
Execute(tx, $"PRAGMA user_version = {SchemaVersion};");
tx.Commit();
}
// ------------------------------------------------------------------ Comptes
private const string AccountColumns =
"id, reference, email_index, email_enc, email_verified, password_hash, password_changed_at, must_change_password, " +
"totp_secret_enc IS NOT NULL, status, disabled_until, status_changed_at, created_at, last_login_at, terms_version, " +
"failed_count, lock_level, locked_until, trusted_failed_count, trusted_locked_until";
public PlayerAccountRecord? FindByEmailIndex(string emailIndex) => Locked(() =>
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE email_index = $v;", ReadAccount, ("$v", emailIndex)));
public PlayerAccountRecord? FindById(string accountId) => Locked(() =>
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE id = $v;", ReadAccount, ("$v", accountId)));
public PlayerAccountRecord? FindByReference(string reference) => Locked(() =>
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE reference = $v;", ReadAccount, ("$v", reference)));
public int CountAccounts() => Locked(() => Convert.ToInt32(Scalar(null, "SELECT COUNT(*) FROM accounts;"), CultureInfo.InvariantCulture));
public int CountCreatedSince(DateTimeOffset since) => Locked(() =>
Convert.ToInt32(Scalar(null, "SELECT COUNT(*) FROM accounts WHERE created_at >= $t;", ("$t", since.ToUnixTimeSeconds())),
CultureInfo.InvariantCulture));
/// <summary>
/// Crée le compte et son premier appareil, sous transaction. Faux si l'adresse (index) est déjà utilisée. La référence
/// est retirée au sort de nouveau en cas de collision (40 bits : rare).
/// </summary>
public bool TryCreate(PlayerAccountRecord account, PlayerDeviceRecord device, Func<string> newReference, out PlayerAccountRecord created)
{
created = account;
lock (_lock)
{
using var tx = _db.BeginTransaction();
if (Scalar(tx, "SELECT 1 FROM accounts WHERE email_index = $v;", ("$v", account.EmailIndex)) is not null) return false;
var reference = account.Reference;
for (var attempt = 0; Scalar(tx, "SELECT 1 FROM accounts WHERE reference = $v;", ("$v", reference)) is not null; attempt++)
{
if (attempt > 20) throw new InvalidOperationException("Impossible de tirer une référence de compte libre.");
reference = newReference();
}
created = account with { Reference = reference };
Execute(tx, """
INSERT INTO accounts (id, reference, email_index, email_enc, email_verified, password_hash, password_changed_at,
must_change_password, status, created_at, last_login_at, terms_version)
VALUES ($id, $ref, $idx, $enc, 0, $hash, $pwd, 0, $status, $created, $login, $terms);
""",
("$id", created.Id), ("$ref", created.Reference), ("$idx", created.EmailIndex), ("$enc", created.EmailEncrypted),
("$hash", created.PasswordHash), ("$pwd", created.PasswordChangedAt.ToUnixTimeSeconds()), ("$status", PlayerStatus.Active),
("$created", created.CreatedAt.ToUnixTimeSeconds()), ("$login", Db(created.LastLoginAt)), ("$terms", created.TermsVersion));
InsertDevice(tx, device);
tx.Commit();
return true;
}
}
/// <summary>Connexion réussie : compteurs d'échecs remis à zéro, date de connexion, empreinte éventuellement remplacée.</summary>
public void RecordSuccess(string accountId, DateTimeOffset now, string? newPasswordHash) => Locked(() =>
{
Execute(null, """
UPDATE accounts SET failed_count = 0, lock_level = 0, locked_until = NULL, trusted_failed_count = 0,
trusted_locked_until = NULL, last_login_at = $now,
password_hash = COALESCE($hash, password_hash)
WHERE id = $id;
""", ("$now", now.ToUnixTimeSeconds()), ("$hash", (object?)newPasswordHash ?? DBNull.Value), ("$id", accountId));
return true;
});
/// <summary>
/// Échec de connexion (mot de passe faux) sur un compte existant, sous transaction. Appareil inconnu : blocage tous les
/// <paramref name="failuresPerLock"/> échecs (durée doublée à chaque fois), suspension au blocage n°
/// <paramref name="suspendAfterLocks"/>. Appareil de confiance : blocage temporaire seulement.
/// </summary>
public PlayerFailureOutcome RecordFailure(string accountId, bool trusted, DateTimeOffset now, LauncherApiOptions.PlayersSettings settings)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
var account = QuerySingle(tx, $"SELECT {AccountColumns} FROM accounts WHERE id = $v;", ReadAccount, ("$v", accountId))
?? throw new InvalidOperationException("Compte disparu.");
PlayerFailureOutcome outcome;
if (trusted)
{
var count = account.TrustedFailedCount + 1;
DateTimeOffset? until = null;
if (count >= settings.TrustedFailuresPerLock)
{
until = now + TimeSpan.FromMinutes(settings.LockMinutes);
count = 0;
}
Execute(tx, "UPDATE accounts SET trusted_failed_count = $c, trusted_locked_until = COALESCE($u, trusted_locked_until) WHERE id = $id;",
("$c", count), ("$u", Db(until)), ("$id", accountId));
outcome = new PlayerFailureOutcome(until, false, account.LockLevel);
}
else
{
var count = account.FailedCount + 1;
var level = account.LockLevel;
DateTimeOffset? until = null;
var suspended = false;
if (count >= settings.FailuresPerLock)
{
count = 0;
level++;
if (level >= settings.SuspendAfterLocks && account.Status != PlayerStatus.Suspended) suspended = true;
// Le blocage temporaire s'applique AUSSI au moment de la suspension et après : vu de l'extérieur, un compte
// suspendu se comporte comme une adresse inconnue (PlayerGuard.CountUnknownFailure), la suspension
// n'est révélée qu'après un mot de passe correct.
until = now + LockDuration(settings, level);
}
Execute(tx, """
UPDATE accounts SET failed_count = $c, lock_level = $l, locked_until = COALESCE($u, locked_until),
status = CASE WHEN $s = 1 THEN 'suspended' ELSE status END,
status_changed_at = CASE WHEN $s = 1 THEN $now ELSE status_changed_at END,
status_changed_by = CASE WHEN $s = 1 THEN 'système (échecs de connexion)' ELSE status_changed_by END,
disabled_until = CASE WHEN $s = 1 THEN NULL ELSE disabled_until END
WHERE id = $id;
""", ("$c", count), ("$l", level), ("$u", Db(until)), ("$s", suspended ? 1 : 0), ("$now", now.ToUnixTimeSeconds()),
("$id", accountId));
outcome = new PlayerFailureOutcome(until, suspended, level);
}
tx.Commit();
return outcome;
}
}
/// <summary>
/// Durée du blocage n° <paramref name="level"/> (1, 2, 3…) : <c>LockMinutes</c> doublée à chaque blocage, plafonnée
/// au niveau précédant la suspension et à 24 h. Même règle pour les adresses inconnues (blocage miroir).
/// </summary>
public static TimeSpan LockDuration(LauncherApiOptions.PlayersSettings settings, int level)
{
var capped = Math.Clamp(level, 1, Math.Max(1, settings.SuspendAfterLocks - 1));
return TimeSpan.FromMinutes(Math.Min(24 * 60, settings.LockMinutes * Math.Pow(2, capped - 1)));
}
/// <summary>Nouveau mot de passe : empreinte, date, « changement exigé » levé (ou posé).</summary>
public void SetPassword(string accountId, string passwordHash, DateTimeOffset now, bool mustChange = false) => Locked(() =>
{
Execute(null, "UPDATE accounts SET password_hash = $h, password_changed_at = $now, must_change_password = $m WHERE id = $id;",
("$h", passwordHash), ("$now", now.ToUnixTimeSeconds()), ("$m", mustChange ? 1 : 0), ("$id", accountId));
return true;
});
/// <summary>Exige (ou non) un nouveau mot de passe à la prochaine connexion.</summary>
public bool SetMustChangePassword(string accountId, bool value) => Locked(() =>
Execute(null, "UPDATE accounts SET must_change_password = $m WHERE id = $id;", ("$m", value ? 1 : 0), ("$id", accountId)) == 1);
/// <summary>Change l'état du compte (désactivation, réactivation, levée d'une suspension) ; remet à zéro les blocages
/// quand le compte redevient actif.</summary>
public bool SetStatus(string accountId, string status, DateTimeOffset? until, string? reasonEncrypted, string changedBy, DateTimeOffset now) =>
Locked(() => Execute(null, """
UPDATE accounts SET status = $s, disabled_until = $u, status_reason_enc = $r, status_changed_at = $now, status_changed_by = $by,
failed_count = CASE WHEN $s = 'active' THEN 0 ELSE failed_count END,
lock_level = CASE WHEN $s = 'active' THEN 0 ELSE lock_level END,
locked_until = CASE WHEN $s = 'active' THEN NULL ELSE locked_until END,
trusted_failed_count = CASE WHEN $s = 'active' THEN 0 ELSE trusted_failed_count END,
trusted_locked_until = CASE WHEN $s = 'active' THEN NULL ELSE trusted_locked_until END
WHERE id = $id;
""", ("$s", status), ("$u", Db(until)), ("$r", (object?)reasonEncrypted ?? DBNull.Value), ("$now", now.ToUnixTimeSeconds()),
("$by", changedBy), ("$id", accountId)) == 1);
// ------------------------------------------------------------------ Appareils
private const string DeviceColumns =
"id, account_id, key_id, public_key, label, created_at, last_used_at, refresh_hash, previous_refresh_hash, " +
"refresh_expires_at, refresh_absolute_expires_at";
public PlayerDeviceRecord? FindDevice(string deviceId) => Locked(() =>
QuerySingle(null, $"SELECT {DeviceColumns} FROM devices WHERE id = $v;", ReadDevice, ("$v", deviceId)));
public PlayerDeviceRecord? FindDeviceByKey(string accountId, string keyId) => Locked(() =>
QuerySingle(null, $"SELECT {DeviceColumns} FROM devices WHERE account_id = $a AND key_id = $k;", ReadDevice,
("$a", accountId), ("$k", keyId)));
public IReadOnlyList<PlayerDeviceRecord> Devices(string accountId) => Locked(() =>
Query(null, $"SELECT {DeviceColumns} FROM devices WHERE account_id = $a ORDER BY last_used_at DESC;", ReadDevice, ("$a", accountId)));
/// <summary>
/// Ouvre une session mémorisée sur un appareil : appareil créé, ou remplacé s'il existait (même clé : nouvelle
/// connexion depuis le même PC). Au-delà de <paramref name="maxDevices"/>, les appareils les moins récemment utilisés
/// sont déconnectés. Renvoie les identifiants des appareils retirés (sessions à fermer).
/// </summary>
public IReadOnlyList<string> SaveDevice(PlayerDeviceRecord device, int maxDevices)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
var removed = Query(tx, "SELECT id FROM devices WHERE account_id = $a AND key_id = $k;", r => r.GetString(0),
("$a", device.AccountId), ("$k", device.KeyId)).ToList();
Execute(tx, "DELETE FROM devices WHERE account_id = $a AND key_id = $k;", ("$a", device.AccountId), ("$k", device.KeyId));
InsertDevice(tx, device);
var excess = Query(tx, "SELECT id FROM devices WHERE account_id = $a AND id <> $id ORDER BY last_used_at DESC, created_at DESC;",
r => r.GetString(0), ("$a", device.AccountId), ("$id", device.Id)).Skip(Math.Max(0, maxDevices - 1)).ToList();
foreach (var id in excess) Execute(tx, "DELETE FROM devices WHERE id = $id;", ("$id", id));
tx.Commit();
removed.AddRange(excess);
return removed;
}
}
/// <summary>
/// Remplace le jeton de renouvellement SI celui présenté est toujours celui en vigueur (deux renouvellements simultanés
/// avec le même jeton : un seul réussit).
/// </summary>
public bool RotateRefresh(string deviceId, string expectedHash, string newHash, DateTimeOffset idleExpiry, DateTimeOffset now) => Locked(() =>
Execute(null, """
UPDATE devices SET previous_refresh_hash = refresh_hash, refresh_hash = $new, refresh_expires_at = $exp, last_used_at = $now
WHERE id = $id AND refresh_hash = $old;
""", ("$new", newHash), ("$exp", idleExpiry.ToUnixTimeSeconds()), ("$now", now.ToUnixTimeSeconds()), ("$id", deviceId),
("$old", expectedHash)) == 1);
public bool DeleteDevice(string deviceId) => Locked(() => Execute(null, "DELETE FROM devices WHERE id = $id;", ("$id", deviceId)) == 1);
/// <summary>Déconnecte tous les appareils du compte (sauf <paramref name="exceptDeviceId"/>) ; renvoie leurs identifiants.</summary>
public IReadOnlyList<string> DeleteDevices(string accountId, string? exceptDeviceId = null)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
var ids = Query(tx, "SELECT id FROM devices WHERE account_id = $a AND id <> $e;", r => r.GetString(0),
("$a", accountId), ("$e", exceptDeviceId ?? "")).ToList();
Execute(tx, "DELETE FROM devices WHERE account_id = $a AND id <> $e;", ("$a", accountId), ("$e", exceptDeviceId ?? ""));
tx.Commit();
return ids;
}
}
private void InsertDevice(SqliteTransaction tx, PlayerDeviceRecord device) =>
Execute(tx, """
INSERT INTO devices (id, account_id, key_id, public_key, label, created_at, last_used_at, refresh_hash, previous_refresh_hash,
refresh_expires_at, refresh_absolute_expires_at)
VALUES ($id, $a, $k, $pk, $label, $created, $used, $rh, NULL, $exp, $abs);
""", ("$id", device.Id), ("$a", device.AccountId), ("$k", device.KeyId), ("$pk", device.PublicKey), ("$label", device.Label),
("$created", device.CreatedAt.ToUnixTimeSeconds()), ("$used", device.LastUsedAt.ToUnixTimeSeconds()), ("$rh", device.RefreshHash),
("$exp", device.RefreshExpiresAt.ToUnixTimeSeconds()), ("$abs", device.RefreshAbsoluteExpiresAt.ToUnixTimeSeconds()));
// ------------------------------------------------------------------ Journal des comptes
/// <summary>
/// Ajoute une ligne au journal des comptes. Jamais d'adresse e-mail ni de mot de passe : identifiant du compte, type,
/// résultat, adresse IP (effacée au bout de quelques mois), clé d'appareil, détail court.
/// </summary>
public void AddEvent(string? accountId, string type, string result, string? ip, string? deviceKeyId = null, string? details = null)
{
var now = _time.GetUtcNow();
Locked(() => Execute(null, """
INSERT INTO account_events (account_id, at, type, result, ip, device_key, details) VALUES ($a, $at, $t, $r, $ip, $k, $d);
""", ("$a", (object?)accountId ?? DBNull.Value), ("$at", now.ToUnixTimeSeconds()), ("$t", type), ("$r", result),
("$ip", (object?)Trim(ip, 64) ?? DBNull.Value), ("$k", (object?)Trim(deviceKeyId, 32) ?? DBNull.Value),
("$d", (object?)Trim(details, 300) ?? DBNull.Value)));
}
/// <summary>Journal d'un compte, le plus récent d'abord.</summary>
public IReadOnlyList<PlayerEventRecord> Events(string accountId, int limit) => Locked(() =>
Query(null, "SELECT id, account_id, at, type, result, ip, device_key, details FROM account_events WHERE account_id = $a ORDER BY id DESC LIMIT $l;",
ReadEvent, ("$a", accountId), ("$l", Math.Clamp(limit, 1, 1000))));
/// <summary>Lignes du journal postérieures à <paramref name="afterId"/> (tous comptes, tests et outils).</summary>
public IReadOnlyList<PlayerEventRecord> EventsAfter(long afterId, int limit) => Locked(() =>
Query(null, "SELECT id, account_id, at, type, result, ip, device_key, details FROM account_events WHERE id > $i ORDER BY id LIMIT $l;",
ReadEvent, ("$i", afterId), ("$l", Math.Clamp(limit, 1, 10_000))));
/// <summary>
/// Conservation : lignes plus anciennes que <paramref name="months"/> supprimées, adresses IP plus anciennes que
/// <paramref name="ipMonths"/> effacées. Renvoie (supprimées, anonymisées).
/// </summary>
public (int Deleted, int Anonymized) PurgeEvents(DateTimeOffset now, int months, int ipMonths) => Locked(() =>
{
var deleted = Execute(null, "DELETE FROM account_events WHERE at < $t;", ("$t", now.AddMonths(-months).ToUnixTimeSeconds()));
var anonymized = Execute(null, "UPDATE account_events SET ip = NULL WHERE ip IS NOT NULL AND at < $t;",
("$t", now.AddMonths(-ipMonths).ToUnixTimeSeconds()));
return (deleted, anonymized);
});
private static string? Trim(string? value, int max)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var clean = new string(value.Select(c => char.IsControl(c) ? ' ' : c).ToArray()).Trim();
return clean.Length <= max ? clean : clean[..max];
}
// ------------------------------------------------------------------ Accès SQLite
private T Locked<T>(Func<T> work)
{
lock (_lock) return work();
}
private int Execute(SqliteTransaction? tx, string sql, params (string Name, object? Value)[] parameters)
{
using var command = Command(tx, sql, parameters);
return command.ExecuteNonQuery();
}
private object? Scalar(SqliteTransaction? tx, string sql, params (string Name, object? Value)[] parameters)
{
using var command = Command(tx, sql, parameters);
return command.ExecuteScalar();
}
private List<T> Query<T>(SqliteTransaction? tx, string sql, Func<SqliteDataReader, T> read, params (string Name, object? Value)[] parameters)
{
using var command = Command(tx, sql, parameters);
using var reader = command.ExecuteReader();
var rows = new List<T>();
while (reader.Read()) rows.Add(read(reader));
return rows;
}
private T? QuerySingle<T>(SqliteTransaction? tx, string sql, Func<SqliteDataReader, T> read, params (string Name, object? Value)[] parameters)
where T : class =>
Query(tx, sql, read, parameters).FirstOrDefault();
private SqliteCommand Command(SqliteTransaction? tx, string sql, (string Name, object? Value)[] parameters)
{
var command = _db.CreateCommand();
command.CommandText = sql;
command.Transaction = tx;
foreach (var (name, value) in parameters) command.Parameters.AddWithValue(name, value ?? DBNull.Value);
return command;
}
private static object Db(DateTimeOffset? value) => value is { } date ? date.ToUnixTimeSeconds() : DBNull.Value;
private static DateTimeOffset Date(SqliteDataReader r, int i) => DateTimeOffset.FromUnixTimeSeconds(r.GetInt64(i));
private static DateTimeOffset? NullableDate(SqliteDataReader r, int i) => r.IsDBNull(i) ? null : Date(r, i);
private static PlayerAccountRecord ReadAccount(SqliteDataReader r) => new()
{
Id = r.GetString(0),
Reference = r.GetString(1),
EmailIndex = r.GetString(2),
EmailEncrypted = r.GetString(3),
EmailVerified = r.GetInt64(4) != 0,
PasswordHash = r.GetString(5),
PasswordChangedAt = Date(r, 6),
MustChangePassword = r.GetInt64(7) != 0,
OtpEnabled = r.GetInt64(8) != 0,
Status = r.GetString(9),
DisabledUntil = NullableDate(r, 10),
StatusChangedAt = NullableDate(r, 11),
CreatedAt = Date(r, 12),
LastLoginAt = NullableDate(r, 13),
TermsVersion = r.GetString(14),
FailedCount = (int)r.GetInt64(15),
LockLevel = (int)r.GetInt64(16),
LockedUntil = NullableDate(r, 17),
TrustedFailedCount = (int)r.GetInt64(18),
TrustedLockedUntil = NullableDate(r, 19)
};
private static PlayerDeviceRecord ReadDevice(SqliteDataReader r) => new()
{
Id = r.GetString(0),
AccountId = r.GetString(1),
KeyId = r.GetString(2),
PublicKey = r.GetString(3),
Label = r.GetString(4),
CreatedAt = Date(r, 5),
LastUsedAt = Date(r, 6),
RefreshHash = r.GetString(7),
PreviousRefreshHash = r.IsDBNull(8) ? null : r.GetString(8),
RefreshExpiresAt = Date(r, 9),
RefreshAbsoluteExpiresAt = Date(r, 10)
};
private static PlayerEventRecord ReadEvent(SqliteDataReader r) => new(
r.GetInt64(0), r.IsDBNull(1) ? null : r.GetString(1), Date(r, 2), r.GetString(3), r.GetString(4),
r.IsDBNull(5) ? null : r.GetString(5), r.IsDBNull(6) ? null : r.GetString(6), r.IsDBNull(7) ? null : r.GetString(7));
}
@@ -72,6 +72,23 @@
"Audit": {
"RetentionMonths": 12
},
// Comptes joueurs (phase 8) : désactivés par défaut. NE PAS ACTIVER EN PRODUCTION avant la fin de l'étape 8g
// (docs/PLAYER-ACCOUNTS-ARCHITECTURE.md). Activés, ils exigent la clé maîtresse : credential systemd chiffré
// « players-master-key » (server/deploy/LISEZMOI.md, étape 3 undecies), jamais dans le dossier des données.
"Players": {
"Enabled": false,
"RegistrationEnabled": true,
"TermsVersion": "1",
"HashMemoryKib": 19456,
"HashIterations": 2,
"FailuresPerLock": 5,
"LockMinutes": 15,
"SuspendAfterLocks": 4,
"RefreshIdleDays": 90,
"RefreshMaxDays": 365,
"EventsRetentionMonths": 12,
"EventsIpRetentionMonths": 6
},
"Limits": {
"RequestsPerMinute": 300,
// Morceaux de 4 Mo envoyés pendant une publication (à la place de RequestsPerMinute) : 1500 ≈ 100 Mo/s.
+37
View File
@@ -501,6 +501,39 @@ Comportement des launchers :
Historique des 20 dernières publications : `maintenance-history/` dans les données privées. Audit : `maintenance.enable`,
`maintenance.disable`, `maintenance.update` (message modifié pendant la maintenance).
## 3 undecies. Comptes joueurs : préparation (VPS)
> **Ne pas activer en production** avant la fin de l'étape 8g (launcher, OTP, menu d'administration, textes RGPD,
> nginx). Cette étape sert à préparer le VPS ou à essayer le serveur sur un VPS de test. Architecture :
> `docs/PLAYER-ACCOUNTS-ARCHITECTURE.md`.
Les comptes joueurs (adresse e-mail + mot de passe) sont stockés dans `/var/lib/launcher-api/players/players.db`
(SQLite, 600) : adresses **chiffrées**, mots de passe hachés (Argon2id + « poivre »). Toutes les clés dérivent d'une
**clé maîtresse** gardée **hors** du dossier des données, chiffrée par systemd (clé propre à ce VPS).
1. **Clé maîtresse** (une seule fois). Elle s'affiche une fois pour que tu en gardes une copie **hors ligne**
(gestionnaire de mots de passe) : perdue, les adresses sont illisibles et tous les mots de passe doivent être
réinitialisés ; sur un nouveau VPS, il faudra la rechiffrer avec `systemd-creds`.
```sh
sudo mkdir -p /etc/credstore.encrypted && sudo chmod 700 /etc/credstore.encrypted
KEY=$(sudo -u deploy /opt/launcher-api/GameLauncher.Server players-key)
echo "$KEY" # ← copie-la dans ton gestionnaire de mots de passe
printf '%s' "$KEY" | sudo systemd-creds encrypt --name=players-master-key - /etc/credstore.encrypted/players-master-key
unset KEY
```
2. **Complément systemd** : copie `server/deploy/launcher-api-players.conf` en
`/etc/systemd/system/launcher-api.service.d/players.conf`, puis `sudo systemctl daemon-reload`.
3. **Réglages** : dans `appsettings.Production.json`, section `LauncherApi` :
`"Players": { "Enabled": true, "TermsVersion": "2026-11" }` (version des conditions d'utilisation que les joueurs
acceptent). `"RegistrationEnabled": false` ferme la création de compte en gardant la connexion.
4. `sudo systemctl restart launcher-api` puis `journalctl -u launcher-api -n 20` : la ligne « Comptes joueurs activés »
doit apparaître. Clé absente, rangée dans le dossier des données ou lisible par d'autres comptes : le service refuse
de démarrer et l'explique dans le journal.
Sauvegarde : `players.db` contient des données personnelles chiffrées ; une sauvegarde cohérente se fait service arrêté
(ou, service en marche, avec `sqlite3 players.db ".backup /chemin"`, paquet `sqlite3`), et doit elle-même être chiffrée (étape 8g). Commandes de secours :
étape 9.
## 4. Créer ta clé d'administrateur (PC)
Double-clique `tools\admin-key.bat`. Choisis une phrase de passe (12 caractères minimum). Il crée :
@@ -586,6 +619,9 @@ pas, le script remet la précédente en place.
| Un administrateur a perdu tous ses PC | Il crée une nouvelle clé et t'envoie son `.pub`, puis `sudo -u deploy /opt/launcher-api/GameLauncher.Server add-key --admin <identifiant> --public-key /tmp/paul.pub` (vérifie l'empreinte avec lui) |
| Toutes tes clés perdues | Nouvelle clé (étape 4), puis `add-owner` (étape 5) ; révoque ensuite les anciennes clés |
| Plus personne ne peut gérer les groupes | Tout propriétaire le peut toujours (groupe système, toutes les permissions) ; sinon `add-owner` (étape 5) |
| Compte joueur : voir son état (sans son adresse) | `sudo -u deploy /opt/launcher-api/GameLauncher.Server players-show --ref PL-XXXX-XXXX` |
| Compte joueur suspendu (trop d'essais) ou bloqué | `sudo -u deploy /opt/launcher-api/GameLauncher.Server players-unlock --ref PL-XXXX-XXXX` |
| Compte joueur à désactiver / réactiver | `… players-disable --ref PL-XXXX-XXXX [--until AAAA-MM-JJ]` / `… players-enable --ref PL-XXXX-XXXX` |
Toujours `sudo -u deploy …` : lancé en root, l'outil refuse (les fichiers recréés appartiendraient à
root et le service ne pourrait plus les lire). Il affiche le dossier de données utilisé ; il lit les
@@ -633,6 +669,7 @@ recréer les administrateurs.
├─ feedback-forms-history/ 20 dernières révisions publiées
├─ maintenance.json (+ .bak) mode maintenance publié (référence de la révision)
├─ maintenance-history/ 20 dernières révisions publiées
├─ players/players.db (+ -wal, -shm) comptes joueurs (SQLite, 600 ; adresses chiffrées, mots de passe hachés)
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
/srv/exovival/www/catalog/ (exemple) dossier public servi par nginx (deploy:www-data, 2750)
├─ catalog.json catalogue lu par les launchers (écrit par le service, 644)
+11
View File
@@ -0,0 +1,11 @@
# =============================================================================
# Comptes joueurs (phase 8) : clé maîtresse transmise au service par systemd.
# /etc/systemd/system/launcher-api.service.d/players.conf
#
# La clé est chiffrée sur le disque (systemd-creds, clé de l'hôte) dans /etc/credstore.encrypted (root, 700) ;
# systemd la déchiffre au démarrage dans un dossier en mémoire visible du seul service ($CREDENTIALS_DIRECTORY).
# Elle n'est jamais dans /var/lib/launcher-api : une copie des données ou des sauvegardes ne la contient pas.
# Mise en place : server/deploy/LISEZMOI.md, étape 3 undecies. Sans ce fichier, laisser Players:Enabled à false.
# =============================================================================
[Service]
LoadCredentialEncrypted=players-master-key:/etc/credstore.encrypted/players-master-key
@@ -0,0 +1,319 @@
using System.Globalization;
using System.IO.Compression;
using System.Text;
namespace GameLauncher.Contracts.Accounts;
/// <summary>
/// Règles des mots de passe des comptes joueurs, appliquées À L'IDENTIQUE par le launcher (retour immédiat pendant la
/// saisie) et par le serveur (seul juge). Inspirées de la recommandation CNIL n° 2022-100 et de l'OWASP :
/// • 12 caractères au moins avec 3 types parmi minuscules, majuscules, chiffres, symboles ; ou 16 caractères au moins
/// sans contrainte (phrase de passe) ; 128 au plus ;
/// • refus des mots de passe les plus utilisés (dictionnaire public de 100 000 mots de passe) et de leurs variantes
/// simples (« P@ssw0rd2024! ») ;
/// • refus des suites triviales (répétitions, suites de clavier ou de chiffres) ;
/// • refus d'un mot de passe tiré de l'adresse e-mail ou du nom du jeu / du studio.
/// Le mot de passe est contrôlé et haché TEL QU'IL EST SAISI (aucune normalisation Unicode) : le serveur tourne en mode
/// « globalisation invariante » (sans ICU), où la normalisation n'est pas disponible ; launcher et serveur appliquent
/// donc exactement le même calcul. Les claviers Windows produisent des caractères accentués déjà composés.
/// </summary>
public static class PasswordRules
{
/// <summary>Version des règles (annoncée par le serveur ; changer si les règles évoluent).</summary>
public const string Version = "1";
public const int MinLength = 12;
public const int MinLengthWithoutComposition = 16;
public const int MaxLength = 128;
public const int RequiredClasses = 3;
/// <summary>Taille maximale d'un mot de passe en UTF-8 (128 caractères de 4 octets).</summary>
public const int MaxUtf8Bytes = 512;
/// <summary>Longueur minimale d'un « cœur » comparé au dictionnaire (en dessous, la comparaison n'a pas de sens).</summary>
private const int MinCoreLength = 4;
/// <summary>Après retrait de l'adresse e-mail ou du nom du jeu, il doit rester au moins ce nombre de caractères.</summary>
private const int MinRemainingLength = 8;
/// <summary>Mots propres au studio et aux jeux : un mot de passe construit dessus est refusé.</summary>
private static readonly string[] ProductWords =
{
"lanisysgames", "lanisys", "gamelauncher", "launcher", "exovivalzombiereborn", "exovivalzombie", "exovival",
"zombiereborn", "zombie"
};
/// <summary>Mots de passe français très courants (complément du dictionnaire, majoritairement anglophone).</summary>
private static readonly string[] FrenchWords =
{
"azerty", "azertyuiop", "motdepasse", "monmotdepasse", "soleil", "doudou", "loulou", "chouchou", "bonjour",
"coucou", "jetaime", "marseille", "paris", "camille", "chocolat", "princesse", "nicolas", "julien", "doudoune",
"bisounours", "vacances", "football", "olympique", "toulouse", "bordeaux", "nantes", "liberte", "france",
"motdepasse123", "123soleil", "chaton", "cheval", "minou", "nounours", "tresor", "amour", "ordinateur"
};
/// <summary>Rangées de claviers (AZERTY, QWERTY, QWERTZ) et suites courantes.</summary>
private static readonly string[] Sequences =
{
"azertyuiop", "qsdfghjklm", "wxcvbn", "qwertyuiop", "asdfghjkl", "zxcvbnm", "qwertzuiop", "yxcvbnm",
"1234567890", "0123456789", "abcdefghijklmnopqrstuvwxyz", "&é\"'(-è_çà", "azertyuiopqsdfghjklmwxcvbn",
"qwertyuiopasdfghjklzxcvbnm", "1qaz2wsx3edc4rfv", "1a2z3e4r5t6y", "a1z2e3r4t5y6"
};
/// <summary>
/// Erreurs du mot de passe (messages en français, prêts à afficher). Liste vide : mot de passe accepté.
/// <paramref name="email"/> : adresse du compte (facultative), pour refuser un mot de passe qui en est tiré.
/// </summary>
public static IReadOnlyList<string> Check(string? password, string? email = null)
{
var errors = new List<string>();
if (string.IsNullOrEmpty(password))
{
errors.Add("Le mot de passe est obligatoire.");
return errors;
}
var normalized = password;
var length = RuneCount(normalized);
if (normalized.Any(char.IsControl) || HasInvalidUnicode(normalized))
errors.Add("Le mot de passe contient un caractère non autorisé (retour à la ligne, tabulation…).");
if (length > MaxLength || Encoding.UTF8.GetByteCount(normalized) > MaxUtf8Bytes)
{
errors.Add($"Le mot de passe ne doit pas dépasser {MaxLength} caractères.");
return errors;
}
if (length < MinLength)
{
errors.Add($"Le mot de passe doit contenir au moins {MinLength} caractères.");
return errors;
}
if (length < MinLengthWithoutComposition && CountClasses(normalized) < RequiredClasses)
errors.Add($"Entre {MinLength} et {MinLengthWithoutComposition - 1} caractères, le mot de passe doit mélanger au moins " +
$"{RequiredClasses} types de caractères parmi : minuscules, majuscules, chiffres, symboles. " +
$"Vous pouvez aussi choisir une phrase de passe d'au moins {MinLengthWithoutComposition} caractères.");
if (errors.Count > 0) return errors;
var lower = normalized.ToLowerInvariant();
if (IsTrivial(lower))
errors.Add("Le mot de passe est une suite trop simple (répétition, suite de clavier, de lettres ou de chiffres).");
else if (IsCommon(lower))
errors.Add("Ce mot de passe fait partie des mots de passe les plus utilisés (ou en est une simple variante) : " +
"choisissez-en un autre.");
else if (IsDerivedFrom(lower, email))
errors.Add("Le mot de passe ressemble trop à votre adresse e-mail ou au nom du jeu : choisissez-en un autre.");
return errors;
}
/// <summary>Vrai si le mot de passe est accepté.</summary>
public static bool IsAcceptable(string? password, string? email = null) => Check(password, email).Count == 0;
// ------------------------------------------------------------------ Composition
private static int RuneCount(string text)
{
var count = 0;
foreach (var _ in text.EnumerateRunes()) count++;
return count;
}
/// <summary>Demi-caractère (surrogate) isolé : texte mal formé.</summary>
private static bool HasInvalidUnicode(string text)
{
for (var i = 0; i < text.Length; i++)
{
if (char.IsHighSurrogate(text[i]))
{
if (i + 1 >= text.Length || !char.IsLowSurrogate(text[i + 1])) return true;
i++;
}
else if (char.IsLowSurrogate(text[i]))
{
return true;
}
}
return false;
}
private static int CountClasses(string text)
{
bool lower = false, upper = false, digit = false, other = false;
foreach (var rune in text.EnumerateRunes())
{
if (Rune.IsLower(rune)) lower = true;
else if (Rune.IsUpper(rune)) upper = true;
else if (Rune.IsDigit(rune)) digit = true;
else other = true; // symboles, espaces, lettres sans casse
}
return (lower ? 1 : 0) + (upper ? 1 : 0) + (digit ? 1 : 0) + (other ? 1 : 0);
}
// ------------------------------------------------------------------ Suites triviales
private static bool IsTrivial(string lower)
{
var compact = new string(lower.Where(c => !char.IsWhiteSpace(c)).ToArray());
if (compact.Distinct().Count() <= 3) return true; // « aaaaaaaaaaaa », « abababababab », « 121212121212 »
foreach (var candidate in new[] { compact, Core(compact), Leet(Core(compact)) })
{
if (candidate.Length < MinCoreLength) continue;
if (IsRepeatedPattern(candidate)) return true;
foreach (var sequence in Sequences)
{
if (IsInsideRepeated(candidate, sequence) || IsInsideRepeated(candidate, Reverse(sequence))) return true;
}
}
return false;
}
/// <summary>Texte formé d'un même motif répété (« azeazeazeaze », « 123412341234 »).</summary>
private static bool IsRepeatedPattern(string text)
{
for (var size = 1; size <= text.Length / 2; size++)
{
if (text.Length % size != 0) continue;
var pattern = text[..size];
var repeated = true;
for (var i = size; i < text.Length && repeated; i += size)
repeated = string.CompareOrdinal(text, i, pattern, 0, size) == 0;
if (repeated) return true;
}
return false;
}
/// <summary>Le texte est contenu dans la suite répétée (« 345678901234 » dans « 1234567890 » × 3).</summary>
private static bool IsInsideRepeated(string text, string sequence)
{
var builder = new StringBuilder();
while (builder.Length < text.Length + sequence.Length) builder.Append(sequence);
return builder.ToString().Contains(text, StringComparison.Ordinal);
}
private static string Reverse(string text)
{
var chars = text.ToCharArray();
Array.Reverse(chars);
return new string(chars);
}
// ------------------------------------------------------------------ Dictionnaire
private static bool IsCommon(string lower)
{
var words = Blocklist.Value;
foreach (var candidate in Candidates(lower))
if (candidate.Length >= MinCoreLength && words.Contains(candidate)) return true;
return false;
}
/// <summary>Formes comparées au dictionnaire : telle quelle, sans chiffres/symboles au début et à la fin, puis
/// avec les substitutions courantes (« p@ssw0rd » → « password »).</summary>
private static IEnumerable<string> Candidates(string lower)
{
yield return lower;
var compact = new string(lower.Where(c => !char.IsWhiteSpace(c)).ToArray());
yield return compact;
var core = Core(compact);
yield return core;
yield return Leet(core);
yield return Core(Leet(compact));
}
/// <summary>Retire chiffres, espaces et symboles au début et à la fin (« !!dragon2024 » → « dragon »).</summary>
internal static string Core(string text)
{
var start = 0;
var end = text.Length;
while (start < end && !char.IsLetter(text[start])) start++;
while (end > start && !char.IsLetter(text[end - 1])) end--;
return text[start..end];
}
/// <summary>Substitutions courantes chiffres/symboles → lettres.</summary>
internal static string Leet(string text)
{
var chars = text.ToCharArray();
for (var i = 0; i < chars.Length; i++)
{
chars[i] = chars[i] switch
{
'0' => 'o',
'1' or '!' or '|' => 'i',
'3' or '€' => 'e',
'4' or '@' => 'a',
'5' or '$' => 's',
'7' or '+' => 't',
'8' => 'b',
'9' => 'g',
_ => chars[i]
};
}
return new string(chars);
}
/// <summary>
/// Dictionnaire chargé à la première utilisation (ressource intégrée, ~100 000 entrées) : chaque entrée est gardée
/// en minuscules et sous sa forme normalisée (sans chiffres/symboles aux extrémités, substitutions retirées).
/// </summary>
private static readonly Lazy<HashSet<string>> Blocklist = new(LoadBlocklist, LazyThreadSafetyMode.ExecutionAndPublication);
/// <summary>Nombre de formes connues du dictionnaire (diagnostic et tests ; le charge s'il ne l'est pas encore).</summary>
public static int BlocklistSize => Blocklist.Value.Count;
private static HashSet<string> LoadBlocklist()
{
var set = new HashSet<string>(StringComparer.Ordinal);
void Add(string word)
{
var lower = word.ToLowerInvariant();
if (lower.Length >= MinCoreLength) set.Add(lower);
var core = Leet(Core(lower));
if (core.Length >= MinCoreLength) set.Add(core);
}
using (var stream = typeof(PasswordRules).Assembly.GetManifestResourceStream(BlocklistResource)
?? throw new InvalidOperationException("Dictionnaire des mots de passe absent de GameLauncher.Contracts."))
using (var gzip = new GZipStream(stream, CompressionMode.Decompress))
using (var reader = new StreamReader(gzip, Encoding.UTF8))
{
while (reader.ReadLine() is { } line)
if (line.Length > 0) Add(line);
}
foreach (var word in FrenchWords) Add(word);
return set; // les mots du studio et des jeux sont traités à part (IsDerivedFrom), avec un message plus précis
}
internal const string BlocklistResource = "GameLauncher.Contracts.Accounts.password-blocklist.txt.gz";
// ------------------------------------------------------------------ Adresse e-mail et nom du jeu
private static bool IsDerivedFrom(string lower, string? email)
{
var words = new List<string>(ProductWords);
if (PlayerEmail.Normalize(email) is { } normalized)
{
var at = normalized.IndexOf('@');
var local = normalized[..at];
words.Add(local);
words.AddRange(local.Split('.', '_', '-', '+').Where(p => p.Length >= 3));
var domainLabel = normalized[(at + 1)..].Split('.')[0];
if (domainLabel.Length >= 4) words.Add(domainLabel);
}
foreach (var form in new[] { lower, Leet(lower) })
{
var remaining = new string(form.Where(char.IsLetterOrDigit).ToArray());
var removedAny = false;
foreach (var word in words.Where(w => w.Length >= 3).OrderByDescending(w => w.Length))
{
var clean = new string(word.Where(char.IsLetterOrDigit).ToArray());
if (clean.Length < 3 || !remaining.Contains(clean, StringComparison.Ordinal)) continue;
remaining = remaining.Replace(clean, "", StringComparison.Ordinal);
removedAny = true;
}
if (removedAny && remaining.Length < MinRemainingLength) return true;
}
return false;
}
}
@@ -0,0 +1,196 @@
namespace GameLauncher.Contracts.Accounts;
// Comptes joueurs (phase 8). Routes sous « v1/account/… » : aucune n'est une route d'administration, et aucune route
// d'administration n'accepte un jeton joueur. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.
/// <summary>État du service des comptes, annoncé dans GET v1/info (null : serveur sans comptes joueurs).</summary>
public sealed record PlayerAccountsInfo
{
/// <summary>Connexion possible (le launcher affiche le bouton « Compte »).</summary>
public bool Enabled { get; init; }
/// <summary>Création de compte ouverte.</summary>
public bool RegistrationOpen { get; init; }
/// <summary>Version des conditions d'utilisation et de la politique de confidentialité à accepter à la création.</summary>
public string TermsVersion { get; init; } = "";
/// <summary>Version des règles des mots de passe (<see cref="PasswordRules.Version"/>).</summary>
public string PasswordRulesVersion { get; init; } = "";
}
/// <summary>Demande d'un défi (POST v1/account/challenge).</summary>
public sealed record PlayerChallengeRequest
{
/// <summary>Une valeur de <see cref="PlayerChallengePurposes"/>.</summary>
public string Purpose { get; init; } = "";
}
/// <summary>
/// Défi joueur, SANS ÉTAT sur le serveur, à usage unique : il porte l'usage, l'échéance et la difficulté de la preuve de
/// travail (0 : aucune, renouvellement de session). Le launcher résout la preuve de travail
/// (<see cref="Feedback.FeedbackProof"/>) puis signe <see cref="PlayerMessages.Device"/> avec sa clé d'appareil.
/// </summary>
public sealed record PlayerChallengeResponse
{
public string ChallengeId { get; init; } = "";
public string Nonce { get; init; } = "";
public string Purpose { get; init; } = "";
public int Difficulty { get; init; }
public string ServerId { get; init; } = "";
public string Origin { get; init; } = "";
public DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>Preuve de possession de la clé d'appareil (création de compte, connexion).</summary>
public sealed record PlayerDeviceProof
{
/// <summary>Clé publique de l'appareil (SPKI ECDSA P-256, Base64).</summary>
public string PublicKey { get; init; } = "";
/// <summary>Signature (Base64, 64 octets) de <see cref="PlayerMessages.Device"/>.</summary>
public string Signature { get; init; } = "";
/// <summary>Nom générique de l'appareil (« Windows 11 · GameLauncher 3.4.0 »), jamais le nom Windows du PC.</summary>
public string? Label { get; init; }
}
/// <summary>Création de compte (POST v1/account/register).</summary>
public sealed record PlayerRegisterRequest
{
public string Email { get; init; } = "";
public string Password { get; init; } = "";
public string ChallengeId { get; init; } = "";
/// <summary>Solution de la preuve de travail du défi.</summary>
public string Solution { get; init; } = "";
/// <summary>Version des conditions acceptées par le joueur (doit être celle annoncée par le serveur).</summary>
public string AcceptedTermsVersion { get; init; } = "";
public PlayerDeviceProof Device { get; init; } = new();
}
/// <summary>Connexion (POST v1/account/login).</summary>
public sealed record PlayerLoginRequest
{
public string Email { get; init; } = "";
public string Password { get; init; } = "";
public string ChallengeId { get; init; } = "";
public string Solution { get; init; } = "";
public PlayerDeviceProof Device { get; init; } = new();
}
/// <summary>Renouvellement de session au lancement du launcher (POST v1/account/refresh).</summary>
public sealed record PlayerRefreshRequest
{
public string RefreshToken { get; init; } = "";
public string ChallengeId { get; init; } = "";
/// <summary>Signature (Base64) de <see cref="PlayerMessages.Device"/> (usage « refresh ») par la clé de l'appareil.</summary>
public string Signature { get; init; } = "";
}
/// <summary>Résultats possibles d'une connexion réussie (mot de passe correct).</summary>
public static class PlayerLoginStatus
{
/// <summary>Connecté : <see cref="PlayerLoginResponse.Session"/>.</summary>
public const string Ok = "ok";
/// <summary>
/// Un administrateur exige un nouveau mot de passe : <see cref="PlayerLoginResponse.PasswordChangeToken"/> permet
/// seulement d'appeler POST v1/account/password ; la session est ouverte une fois le mot de passe changé.
/// </summary>
public const string PasswordChangeRequired = "password_change_required";
}
public sealed record PlayerLoginResponse
{
public string Status { get; init; } = PlayerLoginStatus.Ok;
public PlayerSession? Session { get; init; }
public string? PasswordChangeToken { get; init; }
public DateTimeOffset? PasswordChangeExpiresAt { get; init; }
}
/// <summary>Session ouverte (création de compte, connexion, renouvellement, changement de mot de passe exigé).</summary>
public sealed record PlayerSession
{
/// <summary>Jeton d'accès (« Authorization: Bearer … »), court, à garder en mémoire uniquement.</summary>
public string AccessToken { get; init; } = "";
public DateTimeOffset AccessExpiresAt { get; init; }
/// <summary>
/// Jeton de renouvellement (connexion mémorisée), remplacé à chaque renouvellement : à enregistrer chiffré sur le PC
/// (DPAPI) à la place du précédent. Inutilisable sans la clé de l'appareil.
/// </summary>
public string RefreshToken { get; init; } = "";
/// <summary>Échéance du jeton de renouvellement (sans utilisation ; repoussée à chaque renouvellement, dans la limite d'un an).</summary>
public DateTimeOffset RefreshExpiresAt { get; init; }
public string DeviceId { get; init; } = "";
public PlayerAccountInfo Account { get; init; } = new();
}
/// <summary>Compte du joueur connecté (adresse masquée : l'adresse complète n'est jamais renvoyée).</summary>
public sealed record PlayerAccountInfo
{
/// <summary>Référence du compte (« PL-7KQ2-M9XD »), à communiquer au support.</summary>
public string Reference { get; init; } = "";
public string EmailMasked { get; init; } = "";
public bool EmailVerified { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastLoginAt { get; init; }
public DateTimeOffset PasswordChangedAt { get; init; }
public bool OtpEnabled { get; init; }
/// <summary>
/// Suspension de sécurité après de nombreux essais de mot de passe : les connexions depuis un NOUVEL appareil sont
/// refusées jusqu'à ce qu'un administrateur la lève ; cet appareil-ci (déjà connecté) continue de fonctionner.
/// </summary>
public bool NewDevicesSuspended { get; init; }
}
/// <summary>Un appareil connecté au compte (« Mes appareils »).</summary>
public sealed record PlayerDeviceInfo
{
public string Id { get; init; } = "";
public string Label { get; init; } = "";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset LastUsedAt { get; init; }
/// <summary>Appareil de la session en cours.</summary>
public bool Current { get; init; }
}
/// <summary>GET v1/account/me.</summary>
public sealed record PlayerMeResponse
{
public PlayerAccountInfo Account { get; init; } = new();
public IReadOnlyList<PlayerDeviceInfo> Devices { get; init; } = Array.Empty<PlayerDeviceInfo>();
}
/// <summary>Changement de mot de passe (POST v1/account/password), avec une session ou un jeton « changement exigé ».</summary>
public sealed record PlayerChangePasswordRequest
{
public string CurrentPassword { get; init; } = "";
public string NewPassword { get; init; } = "";
}
public sealed record PlayerChangePasswordResponse
{
/// <summary>Session ouverte après un changement EXIGÉ (jeton limité) ; null pour un changement ordinaire.</summary>
public PlayerSession? Session { get; init; }
/// <summary>Nombre d'autres appareils déconnectés.</summary>
public int DevicesSignedOut { get; init; }
}
/// <summary>Déconnexion (POST v1/account/logout).</summary>
public sealed record PlayerLogoutRequest
{
/// <summary>Vrai : tous les appareils du compte (« Déconnecter tous mes appareils »).</summary>
public bool AllDevices { get; init; }
}
@@ -0,0 +1,75 @@
namespace GameLauncher.Contracts.Accounts;
/// <summary>
/// Adresse e-mail d'un compte joueur : contrôle et forme normalisée (launcher et serveur). La forme normalisée sert
/// d'identifiant de connexion : une même adresse saisie en majuscules ou avec des espaces donne le même compte.
/// Règles volontairement simples et strictes : caractères latins sans accents (domaines internationaux sous leur forme
/// « xn-- »), un seul « @ », 254 caractères au plus.
/// </summary>
public static class PlayerEmail
{
public const int MaxLength = 254;
public const int MaxLocalLength = 64;
private const string LocalSymbols = "!#$%&'*+-/=?^_`{|}~.";
/// <summary>Adresse normalisée (minuscules, sans espaces autour), ou null si elle n'est pas valable.</summary>
public static string? Normalize(string? input)
{
if (string.IsNullOrWhiteSpace(input) || input.Length > MaxLength + 16) return null;
// Pas de normalisation Unicode (indisponible sur le serveur en globalisation invariante) : seuls les caractères
// ASCII sont acceptés, de la même façon par le launcher et par le serveur.
var text = input.Trim().ToLowerInvariant();
if (text.Length > MaxLength) return null;
var at = text.IndexOf('@');
if (at <= 0 || at != text.LastIndexOf('@') || at == text.Length - 1) return null;
var local = text[..at];
var domain = text[(at + 1)..];
if (domain.EndsWith('.')) domain = domain[..^1];
if (!IsValidLocal(local) || !IsValidDomain(domain)) return null;
var result = local + "@" + domain;
return result.Length <= MaxLength ? result : null;
}
public static bool IsValid(string? input) => Normalize(input) is not null;
/// <summary>
/// Adresse masquée pour l'affichage (« n***@e***.fr ») : première lettre de la partie locale et du domaine,
/// extension conservée. Chaîne vide si l'adresse n'est pas valable.
/// </summary>
public static string Mask(string? email)
{
if (Normalize(email) is not { } normalized) return "";
var at = normalized.IndexOf('@');
var local = normalized[..at];
var domain = normalized[(at + 1)..];
var lastDot = domain.LastIndexOf('.');
return $"{local[0]}***@{domain[0]}***{domain[lastDot..]}";
}
private static bool IsValidLocal(string local)
{
if (local.Length is 0 or > MaxLocalLength) return false;
if (local[0] == '.' || local[^1] == '.' || local.Contains("..", StringComparison.Ordinal)) return false;
foreach (var c in local)
if (!(c is >= 'a' and <= 'z' or >= '0' and <= '9' || LocalSymbols.Contains(c))) return false;
return true;
}
private static bool IsValidDomain(string domain)
{
if (domain.Length is 0 or > 253) return false;
var labels = domain.Split('.');
if (labels.Length < 2) return false;
foreach (var label in labels)
{
if (label.Length is 0 or > 63 || label[0] == '-' || label[^1] == '-') return false;
foreach (var c in label)
if (!(c is >= 'a' and <= 'z' or >= '0' and <= '9' or '-')) return false;
}
var tld = labels[^1];
return tld.StartsWith("xn--", StringComparison.Ordinal) || (tld.Length >= 2 && tld.All(c => c is >= 'a' and <= 'z'));
}
}
@@ -0,0 +1,111 @@
using System.Security.Cryptography;
using GameLauncher.Contracts.Security;
namespace GameLauncher.Contracts.Accounts;
/// <summary>
/// Messages signés par la CLÉ D'APPAREIL d'un joueur (ECDSA P-256 créée par le launcher, dans la puce TPM si elle existe).
/// En-tête propre aux joueurs (« GameLauncher/Player/… ») : une signature de joueur ne vaut jamais pour un message
/// d'administrateur (« GameLauncher/Admin/… »), et inversement.
///
/// La signature prouve que la requête vient de l'appareil qui possède la clé : un fichier de session copié sur un autre PC
/// ne permet pas de renouveler la session, et seul un appareil déjà utilisé avec succès est reconnu « de confiance » par
/// la protection contre la force brute.
/// </summary>
public static class PlayerMessages
{
public const string DeviceHeader = "GameLauncher/Player/Device/v1";
/// <summary>Message signé par la clé d'appareil pour une création de compte, une connexion ou un renouvellement.</summary>
/// <param name="purpose">Une valeur de <see cref="PlayerChallengePurposes"/>.</param>
/// <param name="deviceKeyId">Identifiant de la clé d'appareil (<see cref="AdminKeys.ComputeKeyId"/>).</param>
public static byte[] Device(string purpose, string origin, string serverId, string challengeId, string nonce, string deviceKeyId) =>
SignedMessages.Build(DeviceHeader,
("purpose", purpose), ("origin", origin), ("server", serverId), ("challenge", challengeId), ("nonce", nonce),
("device", deviceKeyId));
}
/// <summary>Usages d'un défi joueur (le défi est scellé pour un seul usage).</summary>
public static class PlayerChallengePurposes
{
public const string Register = "register";
public const string Login = "login";
public const string Refresh = "refresh";
public static bool IsValid(string? purpose) => purpose is Register or Login or Refresh;
}
/// <summary>
/// Formats des jetons joueurs. Préfixes distincts de tout jeton administrateur : le serveur rejette un jeton joueur
/// présenté à une route d'administration sans même le chercher, et inversement.
/// </summary>
public static class PlayerTokens
{
/// <summary>Jeton d'accès (15 min, en mémoire seulement côté launcher) : « glpa_ » + 256 bits.</summary>
public const string AccessPrefix = "glpa_";
/// <summary>Jeton de renouvellement (gardé chiffré sur le PC) : « glpr_ » + identifiant d'appareil + « . » + 256 bits.</summary>
public const string RefreshPrefix = "glpr_";
/// <summary>Jeton limité au changement de mot de passe exigé (10 min) : « glpc_ » + 256 bits.</summary>
public const string PasswordChangePrefix = "glpc_";
public const int MaxLength = 128;
public static string NewSecret() => Base64Url.Encode(RandomNumberGenerator.GetBytes(32));
/// <summary>Jeton de renouvellement : identifiant de l'appareil (32 hex) et secret, ou faux si mal formé.</summary>
public static bool TryParseRefresh(string? token, out string deviceId, out string secret)
{
deviceId = secret = "";
if (token is not { Length: > 40 and <= MaxLength } || !token.StartsWith(RefreshPrefix, StringComparison.Ordinal)) return false;
var body = token[RefreshPrefix.Length..];
var dot = body.IndexOf('.');
if (dot != 32) return false;
deviceId = body[..dot];
secret = body[(dot + 1)..];
return PlayerIds.IsValidId(deviceId) && Base64Url.TryDecode(secret, out var bytes) && bytes.Length == 32;
}
public static string FormatRefresh(string deviceId, string secret) => RefreshPrefix + deviceId + "." + secret;
}
/// <summary>Identifiants des comptes joueurs.</summary>
public static class PlayerIds
{
private const string Crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/// <summary>Identifiant interne (compte, appareil) : 128 bits en hexadécimal minuscule.</summary>
public static string NewId() => Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
public static bool IsValidId(string? id) => id is { Length: 32 } && id.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f');
/// <summary>
/// Référence affichée aux administrateurs et au joueur (« PL-7KQ2-M9XD », 40 bits, sans lettre ambiguë) : désigne un
/// compte dans le journal d'audit sans jamais y écrire l'adresse e-mail.
/// </summary>
public static string NewReference()
{
var bytes = RandomNumberGenerator.GetBytes(5);
ulong value = 0;
foreach (var b in bytes) value = (value << 8) | b;
var chars = new char[8];
for (var i = 7; i >= 0; i--)
{
chars[i] = Crockford[(int)(value & 31)];
value >>= 5;
}
return $"PL-{new string(chars, 0, 4)}-{new string(chars, 4, 4)}";
}
/// <summary>Référence normalisée (majuscules, tirets), ou null si elle n'est pas valable.</summary>
public static string? NormalizeReference(string? reference)
{
if (string.IsNullOrWhiteSpace(reference) || reference.Length > 20) return null;
var text = reference.Trim().ToUpperInvariant();
if (text.Length != 12 || !text.StartsWith("PL-", StringComparison.Ordinal) || text[7] != '-') return null;
foreach (var c in text[3..7] + text[8..])
if (!Crockford.Contains(c)) return null;
return text;
}
}
@@ -0,0 +1,19 @@
password-blocklist.txt.gz — dictionnaire des mots de passe les plus utilisés
=========================================================================
Contenu : les 99 839 mots de passe de la liste « 100k-most-used-passwords-NCSC.txt » (un par ligne, UTF-8, doublons
retirés, ordre d'origine conservé), compressés en gzip.
Origine :
• Liste publiée par le NCSC (National Cyber Security Centre, Royaume-Uni) : les 100 000 mots de passe les plus
fréquents dans les fuites recensées par le service « Have I Been Pwned » (Pwned Passwords).
• Copie utilisée : dépôt SecLists (https://github.com/danielmiessler/SecLists),
fichier Passwords/Common-Credentials/100k-most-used-passwords-NCSC.txt — licence MIT,
© 2018 Daniel Miessler.
Usage : GameLauncher.Contracts.Accounts.PasswordRules (même contrôle dans le launcher et sur le serveur). La liste est
normalisée au chargement (minuscules, substitutions courantes 0→o, 4→a, @→a…, chiffres et symboles de début et de fin
retirés) : les variantes simples (« P@ssw0rd2024! ») sont refusées comme l'original.
Mise à jour : remplacer le fichier .gz par une version plus récente de la même liste (même format), puis relancer les
tests (Contracts/PasswordRulesTests).
+29
View File
@@ -151,6 +151,35 @@ public static class ApiRoutes
/// <summary>Mode maintenance : lecture (tout administrateur connecté), publication signée (PUT, maintenance.manage).</summary>
public const string Maintenance = "v1/maintenance";
// ------------------------------------------------------------------ Comptes joueurs (phase 8)
// Routes des JOUEURS (jetons « glpa_ ») : aucune n'est une route d'administration, et les routes d'administration
// n'acceptent jamais un jeton joueur. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.
/// <summary>Défi joueur (création de compte, connexion, renouvellement) : anonyme, sans état sur le serveur.</summary>
public const string AccountChallenge = "v1/account/challenge";
/// <summary>Création de compte (anonyme : preuve de travail + clé d'appareil).</summary>
public const string AccountRegister = "v1/account/register";
/// <summary>Connexion par adresse e-mail et mot de passe (anonyme : preuve de travail + clé d'appareil).</summary>
public const string AccountLogin = "v1/account/login";
/// <summary>Renouvellement de la session mémorisée (jeton de renouvellement + signature de la clé d'appareil).</summary>
public const string AccountRefresh = "v1/account/refresh";
public const string AccountLogout = "v1/account/logout";
/// <summary>Compte connecté et ses appareils.</summary>
public const string AccountMe = "v1/account/me";
/// <summary>Changement de mot de passe (session, ou jeton « changement exigé »).</summary>
public const string AccountPassword = "v1/account/password";
public const string AccountDevicePattern = "v1/account/devices/{deviceId}";
/// <summary>Un appareil du compte (DELETE : le déconnecter). Identifiant : 32 caractères hexadécimaux.</summary>
public static string AccountDevice(string deviceId) => $"v1/account/devices/{deviceId}";
// ------------------------------------------------------------------ Publication d'une version
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
public const string PublicationPattern = "v1/publications/{id}";
@@ -15,6 +15,9 @@ public sealed record ServerInfo
/// <summary>Algorithmes de clé acceptés (« ECDSA-P256-SHA256 »).</summary>
public IReadOnlyList<string> KeyAlgorithms { get; init; } = Array.Empty<string>();
/// <summary>Comptes joueurs (phase 8) : null si le serveur ne les propose pas (ou serveur plus ancien).</summary>
public Accounts.PlayerAccountsInfo? Accounts { get; init; }
}
public sealed record HealthStatus
@@ -77,4 +80,43 @@ public static class ApiErrorCodes
/// <summary>Demandes des joueurs : défi anti-spam absent, expiré, déjà utilisé ou mal résolu.</summary>
public const string ProofInvalid = "proof_invalid";
// ------------------------------------------------------------------ Comptes joueurs (phase 8)
/// <summary>Adresse e-mail mal formée.</summary>
public const string EmailInvalid = "email_invalid";
/// <summary>Adresse déjà utilisée par un autre compte (création de compte).</summary>
public const string EmailTaken = "email_taken";
/// <summary>Mot de passe refusé par <see cref="Accounts.PasswordRules"/> (message : les raisons).</summary>
public const string PasswordRejected = "password_rejected";
/// <summary>Adresse ou mot de passe incorrect (message volontairement identique dans les deux cas).</summary>
public const string LoginFailed = "login_failed";
/// <summary>Trop d'essais : connexion bloquée temporairement (en-tête Retry-After).</summary>
public const string LoginLocked = "login_locked";
/// <summary>Compte désactivé par un administrateur (révélé seulement après un mot de passe correct).</summary>
public const string AccountDisabled = "account_disabled";
/// <summary>Compte suspendu après trop d'essais (connexion depuis un NOUVEL appareil refusée) : seul un administrateur
/// peut lever la suspension ; les appareils déjà connectés continuent de fonctionner.</summary>
public const string AccountSuspended = "account_suspended";
/// <summary>Un nouveau mot de passe est exigé avant toute autre action.</summary>
public const string PasswordChangeRequired = "password_change_required";
/// <summary>Session mémorisée révoquée, expirée ou réutilisée (vol suspecté) : nouvelle connexion nécessaire.</summary>
public const string SessionRevoked = "session_revoked";
/// <summary>Création de compte fermée, ou bloquée pour cette adresse IP / cet appareil.</summary>
public const string RegistrationClosed = "registration_closed";
/// <summary>Conditions d'utilisation non acceptées ou dans une version dépassée.</summary>
public const string TermsNotAccepted = "terms_not_accepted";
/// <summary>Preuve de possession de la clé d'appareil absente ou invalide.</summary>
public const string DeviceProofInvalid = "device_proof_invalid";
}
@@ -13,4 +13,9 @@
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<!-- Comptes joueurs : dictionnaire des mots de passe les plus utilisés (PasswordRules), voir Accounts\Resources\password-blocklist.SOURCE.txt. -->
<ItemGroup>
<EmbeddedResource Include="Accounts\Resources\password-blocklist.txt.gz" LogicalName="GameLauncher.Contracts.Accounts.password-blocklist.txt.gz" />
</ItemGroup>
</Project>
@@ -68,7 +68,7 @@ public static class SignedMessages
/// <summary>Origine normalisée d'une adresse : « https://hote[:port] », sans chemin ni barre finale.</summary>
public static string NormalizeOrigin(Uri uri) => uri.GetLeftPart(UriPartial.Authority).TrimEnd('/').ToLowerInvariant();
private static byte[] Build(string header, params (string Name, string Value)[] fields)
internal static byte[] Build(string header, params (string Name, string Value)[] fields)
{
var sb = new StringBuilder(header).Append('\n');
foreach (var (name, value) in fields)
@@ -0,0 +1,168 @@
using GameLauncher.Contracts.Accounts;
namespace GameLauncher.Tests.Contracts;
/// <summary>Règles partagées des comptes joueurs : mots de passe, adresses e-mail, références, jetons.</summary>
public class PlayerAccountRulesTests
{
[Theory]
[InlineData("Tr0mbone-Lavande!")] // 17 caractères, 4 types
[InlineData("Vélo-Jaune-42-Cerf")] // accents
[InlineData("le chat dort sur la radio")] // phrase de passe, sans contrainte de types
[InlineData("Kq7#mP2x!Lz9")] // 12 caractères, 4 types
public void Strong_passwords_are_accepted(string password)
{
Assert.Empty(PasswordRules.Check(password, "joueur@example.fr"));
}
[Theory]
[InlineData("Court1!")] // moins de 12 caractères
[InlineData("toutenminuscule")] // 15 caractères, un seul type
[InlineData("Majusculelune")] // 13 caractères, deux types seulement
public void Short_or_uniform_passwords_are_refused(string password)
{
Assert.NotEmpty(PasswordRules.Check(password));
}
[Fact]
public void Twelve_characters_with_three_kinds_are_enough()
{
Assert.Empty(PasswordRules.Check("Majuscule-Lune")); // minuscules + majuscules + symbole
}
[Fact]
public void Sixteen_characters_need_no_composition()
{
Assert.Empty(PasswordRules.Check("TOUTENMAJUSCULEZ"));
Assert.NotEmpty(PasswordRules.Check("TOUTENMAJUSCULE")); // 15 : un seul type
}
[Theory]
[InlineData("Password1234!")]
[InlineData("P@ssw0rd2024!")]
[InlineData("Dragon2024!!")]
[InlineData("Football2024!")]
[InlineData("iloveyou2023!!")]
[InlineData("Motdepasse2026!")]
[InlineData("Soleil-2024-!!")]
public void Common_passwords_and_simple_variants_are_refused(string password)
{
var errors = PasswordRules.Check(password);
Assert.Single(errors);
Assert.Contains("les plus utilisés", errors[0]);
}
[Theory]
[InlineData("aaaaaaaaaaaaaaaa")]
[InlineData("abababababababab")]
[InlineData("1234567890123456")]
[InlineData("qwertyuiopasdfgh")]
[InlineData("azertyuiopqsdfgh")]
[InlineData("ZyxwvutsrqponmlK")]
[InlineData("azeazeazeazeazea")]
[InlineData("Azerty123456!")]
public void Trivial_sequences_are_refused(string password)
{
var errors = PasswordRules.Check(password);
Assert.Single(errors);
Assert.Contains("suite trop simple", errors[0]);
}
[Theory]
[InlineData("nicolas.deville1990", "nicolas.deville@example.fr")]
[InlineData("LanisysGames2026!", null)]
[InlineData("Exovival-Zombie-2026", null)]
public void Passwords_derived_from_the_email_or_the_game_are_refused(string password, string? email)
{
var errors = PasswordRules.Check(password, email);
Assert.Single(errors);
Assert.Contains("ressemble trop", errors[0]);
}
[Fact]
public void Control_characters_and_oversized_passwords_are_refused()
{
Assert.NotEmpty(PasswordRules.Check("Abcdef\n123456!"));
Assert.NotEmpty(PasswordRules.Check(new string('x', 120) + "Ab1!Cd2?Ef3"));
Assert.NotEmpty(PasswordRules.Check(""));
Assert.NotEmpty(PasswordRules.Check(null));
}
[Fact]
public void Blocklist_is_embedded_and_loaded()
{
Assert.True(PasswordRules.BlocklistSize > 90_000);
}
[Theory]
[InlineData(" Nicolas.Deville@Example.FR ", "nicolas.deville@example.fr")]
[InlineData("joueur+exovival@mail.example.com", "joueur+exovival@mail.example.com")]
[InlineData("a@b.co", "a@b.co")]
[InlineData("x@xn--caf-dma.fr", "x@xn--caf-dma.fr")]
public void Emails_are_normalized(string input, string expected)
{
Assert.Equal(expected, PlayerEmail.Normalize(input));
}
[Theory]
[InlineData("pas-une-adresse")]
[InlineData("a@b")]
[InlineData("a@@b.fr")]
[InlineData(".a@b.fr")]
[InlineData("a..b@c.fr")]
[InlineData("a b@c.fr")]
[InlineData("é@c.fr")]
[InlineData("joueur@example.fr")] // caractères « pleine chasse » : refusés (pas de normalisation Unicode)
[InlineData("a@-c.fr")]
[InlineData("a@c.f")]
[InlineData("a@c.123")]
[InlineData("")]
public void Invalid_emails_are_refused(string input)
{
Assert.Null(PlayerEmail.Normalize(input));
}
[Fact]
public void Email_is_masked_for_display()
{
Assert.Equal("n***@e***.fr", PlayerEmail.Mask("Nicolas@Example.fr"));
Assert.Equal("", PlayerEmail.Mask("invalide"));
}
[Fact]
public void References_are_random_and_normalizable()
{
var a = PlayerIds.NewReference();
var b = PlayerIds.NewReference();
Assert.NotEqual(a, b);
Assert.Matches("^PL-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$", a);
Assert.Equal(a, PlayerIds.NormalizeReference(a.ToLowerInvariant()));
Assert.Null(PlayerIds.NormalizeReference("PL-IIII-OOOO")); // lettres ambiguës exclues
Assert.Null(PlayerIds.NormalizeReference("XX-1234-5678"));
}
[Fact]
public void Refresh_tokens_carry_their_device_and_are_strictly_parsed()
{
var id = PlayerIds.NewId();
var secret = PlayerTokens.NewSecret();
var token = PlayerTokens.FormatRefresh(id, secret);
Assert.True(PlayerTokens.TryParseRefresh(token, out var parsedId, out var parsedSecret));
Assert.Equal(id, parsedId);
Assert.Equal(secret, parsedSecret);
Assert.False(PlayerTokens.TryParseRefresh(PlayerTokens.AccessPrefix + secret, out _, out _));
Assert.False(PlayerTokens.TryParseRefresh(PlayerTokens.RefreshPrefix + "zz" + id[2..] + "." + secret, out _, out _));
Assert.False(PlayerTokens.TryParseRefresh(PlayerTokens.RefreshPrefix + id + "." + secret[..10], out _, out _));
Assert.False(PlayerTokens.TryParseRefresh(null, out _, out _));
}
[Fact]
public void Device_message_is_domain_separated_from_admin_messages()
{
var player = System.Text.Encoding.UTF8.GetString(
PlayerMessages.Device(PlayerChallengePurposes.Login, "https://o", "srv", "c", "n", "0123456789abcdef"));
Assert.StartsWith(PlayerMessages.DeviceHeader + "\npurpose:login\n", player);
Assert.DoesNotContain("GameLauncher/Admin", player);
}
}
@@ -0,0 +1,210 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Storage.Players;
using Microsoft.Extensions.DependencyInjection;
namespace GameLauncher.Tests.Infrastructure;
/// <summary>
/// Launcher API avec les comptes joueurs activés : clé maîtresse dans un dossier temporaire SÉPARÉ du dossier des données
/// (le serveur refuse une clé rangée avec les données), preuves de travail faciles, limites relevées.
/// </summary>
public sealed class PlayerTestServer : IAsyncDisposable
{
public const string Password = "Tr0mbone-Lavande!";
public const string OtherPassword = "Cerf-Volant-Bleu-77";
private readonly string _keyDirectory;
private PlayerTestServer(TestApi api, string keyDirectory, string keyFile)
{
Api = api;
_keyDirectory = keyDirectory;
KeyFile = keyFile;
}
public TestApi Api { get; }
public string KeyFile { get; }
public PlayerStore Store => Api.Services.GetRequiredService<PlayerStore>();
public static string NewKeyFile(out string directory)
{
directory = Path.Combine(Path.GetTempPath(), "launcher-api-keys", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var file = Path.Combine(directory, "players-master-key");
File.WriteAllText(file, PlayerSecrets.NewMasterKey());
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(file, UnixFileMode.UserRead | UnixFileMode.UserWrite);
return file;
}
public static async Task<PlayerTestServer> StartAsync(IDictionary<string, string?>? settings = null)
{
var keyFile = NewKeyFile(out var keyDirectory);
var values = new Dictionary<string, string?>
{
["LauncherApi:Players:Enabled"] = "true",
["LauncherApi:Players:MasterKeyFile"] = keyFile,
["LauncherApi:Players:RegisterProofDifficulty"] = "8",
["LauncherApi:Players:LoginProofDifficulty"] = "8",
["LauncherApi:Players:AuthRequestsPerMinute"] = "100000",
["LauncherApi:Players:RegistrationsPerIpPerHour"] = "1000",
["LauncherApi:Players:RegistrationsPerIpPerDay"] = "1000",
["LauncherApi:Players:FailedLoginsPerIpPer15Minutes"] = "10000",
// Coût minimal accepté (OWASP : 7 Mio × 5 passes équivaut à 19 Mio × 2) : tests plus rapides.
["LauncherApi:Players:HashMemoryKib"] = "7168",
["LauncherApi:Players:HashIterations"] = "6"
};
foreach (var (key, value) in settings ?? new Dictionary<string, string?>()) values[key] = value;
try
{
return new PlayerTestServer(await TestApi.StartAsync(values), keyDirectory, keyFile);
}
catch
{
Directory.Delete(keyDirectory, recursive: true);
throw;
}
}
// ------------------------------------------------------------------ Requêtes d'un joueur
public async Task<PlayerChallengeResponse> ChallengeAsync(string purpose)
{
using var response = await Api.Http.PostAsync(ApiRoutes.AccountChallenge, TestApi.Json(new PlayerChallengeRequest { Purpose = purpose }));
Assert.True(response.IsSuccessStatusCode, $"défi : {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
return (await response.Content.ReadFromJsonAsync<PlayerChallengeResponse>(ApiJson.Options))!;
}
public static string Solve(PlayerChallengeResponse challenge)
{
Assert.True(Base64Url.TryDecode(challenge.Nonce, out var nonce));
return challenge.Difficulty == 0 ? "" : FeedbackProof.Solve(nonce, challenge.Difficulty);
}
public async Task<HttpResponseMessage> RegisterRawAsync(TestDevice device, string email, string password = Password, string terms = "1",
Func<PlayerRegisterRequest, PlayerRegisterRequest>? tamper = null)
{
var challenge = await ChallengeAsync(PlayerChallengePurposes.Register);
var request = new PlayerRegisterRequest
{
Email = email,
Password = password,
ChallengeId = challenge.ChallengeId,
Solution = Solve(challenge),
AcceptedTermsVersion = terms,
Device = device.Proof(PlayerChallengePurposes.Register, challenge)
};
return await Api.Http.PostAsync(ApiRoutes.AccountRegister, TestApi.Json(tamper?.Invoke(request) ?? request));
}
public async Task<PlayerSession> RegisterAsync(TestDevice device, string email, string password = Password)
{
using var response = await RegisterRawAsync(device, email, password);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.StatusCode == HttpStatusCode.OK, $"création : {(int)response.StatusCode} {body}");
var result = System.Text.Json.JsonSerializer.Deserialize<PlayerLoginResponse>(body, ApiJson.Options)!;
Assert.Equal(PlayerLoginStatus.Ok, result.Status);
return result.Session!;
}
public async Task<HttpResponseMessage> LoginRawAsync(TestDevice device, string email, string password = Password,
Func<PlayerLoginRequest, PlayerLoginRequest>? tamper = null)
{
var challenge = await ChallengeAsync(PlayerChallengePurposes.Login);
var request = new PlayerLoginRequest
{
Email = email,
Password = password,
ChallengeId = challenge.ChallengeId,
Solution = Solve(challenge),
Device = device.Proof(PlayerChallengePurposes.Login, challenge)
};
return await Api.Http.PostAsync(ApiRoutes.AccountLogin, TestApi.Json(tamper?.Invoke(request) ?? request));
}
public async Task<PlayerLoginResponse> LoginAsync(TestDevice device, string email, string password = Password)
{
using var response = await LoginRawAsync(device, email, password);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.StatusCode == HttpStatusCode.OK, $"connexion : {(int)response.StatusCode} {body}");
return System.Text.Json.JsonSerializer.Deserialize<PlayerLoginResponse>(body, ApiJson.Options)!;
}
public async Task<HttpResponseMessage> RefreshRawAsync(TestDevice device, string refreshToken)
{
var challenge = await ChallengeAsync(PlayerChallengePurposes.Refresh);
Assert.Equal(0, challenge.Difficulty);
var request = new PlayerRefreshRequest
{
RefreshToken = refreshToken,
ChallengeId = challenge.ChallengeId,
Signature = device.Proof(PlayerChallengePurposes.Refresh, challenge).Signature
};
return await Api.Http.PostAsync(ApiRoutes.AccountRefresh, TestApi.Json(request));
}
public async Task<PlayerSession> RefreshAsync(TestDevice device, string refreshToken)
{
using var response = await RefreshRawAsync(device, refreshToken);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.StatusCode == HttpStatusCode.OK, $"renouvellement : {(int)response.StatusCode} {body}");
return System.Text.Json.JsonSerializer.Deserialize<PlayerSession>(body, ApiJson.Options)!;
}
/// <summary>Requête avec un jeton (joueur ou administrateur) dans « Authorization: Bearer ».</summary>
public async Task<HttpResponseMessage> SendAsync(HttpMethod method, string route, string? token, object? body = null)
{
var request = new HttpRequestMessage(method, route);
if (token is not null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (body is not null) request.Content = TestApi.Json(body);
return await Api.Http.SendAsync(request);
}
public async Task<PlayerMeResponse> MeAsync(string accessToken)
{
using var response = await SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, accessToken);
Assert.True(response.IsSuccessStatusCode, $"me : {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
return (await response.Content.ReadFromJsonAsync<PlayerMeResponse>(ApiJson.Options))!;
}
public async ValueTask DisposeAsync()
{
await Api.DisposeAsync();
try
{
Directory.Delete(_keyDirectory, recursive: true);
}
catch (IOException)
{
// dossier temporaire
}
}
}
/// <summary>Appareil d'un joueur de test : sa clé d'appareil (ECDSA P-256), comme celle que crée le launcher.</summary>
public sealed class TestDevice : IDisposable
{
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
public TestDevice(string label = "Windows 11 · GameLauncher tests") => Label = label;
public string Label { get; }
public byte[] PublicKey => _key.ExportSubjectPublicKeyInfo();
public string KeyId => AdminKeys.ComputeKeyId(PublicKey);
public PlayerDeviceProof Proof(string purpose, PlayerChallengeResponse challenge) => new()
{
PublicKey = Convert.ToBase64String(PublicKey),
Label = Label,
Signature = Convert.ToBase64String(AdminKeys.Sign(_key,
PlayerMessages.Device(purpose, challenge.Origin, challenge.ServerId, challenge.ChallengeId, challenge.Nonce, KeyId)))
};
public void Dispose() => _key.Dispose();
}
@@ -0,0 +1,27 @@
using Konscious.Security.Cryptography;
namespace GameLauncher.Tests.Server;
/// <summary>
/// Contrôle de la bibliothèque Argon2 utilisée pour les mots de passe des joueurs : vecteur de test officiel d'Argon2id
/// (RFC 9106, § 5.3), avec clé secrète et données associées comme pour notre « poivre ». Si une mise à jour du paquet
/// changeait le calcul, ce test échouerait avant que les joueurs ne puissent plus se connecter.
/// </summary>
public class Argon2VectorTests
{
[Fact]
public void Argon2id_matches_the_rfc_9106_test_vector()
{
using var argon2 = new Argon2id(Enumerable.Repeat((byte)0x01, 32).ToArray())
{
Salt = Enumerable.Repeat((byte)0x02, 16).ToArray(),
KnownSecret = Enumerable.Repeat((byte)0x03, 8).ToArray(),
AssociatedData = Enumerable.Repeat((byte)0x04, 12).ToArray(),
MemorySize = 32,
Iterations = 3,
DegreeOfParallelism = 4
};
Assert.Equal("0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659",
Convert.ToHexString(argon2.GetBytes(32)).ToLowerInvariant());
}
}
@@ -0,0 +1,674 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Cli;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage.Players;
using GameLauncher.Tests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace GameLauncher.Tests.Server;
/// <summary>Comptes joueurs (étape 8a) : création, connexion, session mémorisée, anti-force brute, mot de passe, appareils.</summary>
public class PlayerAccountsTests
{
private const string Email = "joueur@example.fr";
private static async Task<ApiError> Error(HttpResponseMessage response) => await TestApi.ReadErrorAsync(response);
// ------------------------------------------------------------------ Activation
[Fact]
public async Task Accounts_are_disabled_by_default()
{
await using var api = await TestApi.StartAsync();
using var response = await api.Http.PostAsync(ApiRoutes.AccountChallenge,
TestApi.Json(new PlayerChallengeRequest { Purpose = PlayerChallengePurposes.Login }));
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
Assert.Equal(ApiErrorCodes.NotConfigured, (await Error(response)).Code);
Assert.Null((await api.InfoAsync()).Accounts);
}
[Fact]
public async Task Info_announces_accounts_when_enabled()
{
await using var server = await PlayerTestServer.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Players:TermsVersion"] = "2026-11" });
var info = (await server.Api.InfoAsync()).Accounts;
Assert.NotNull(info);
Assert.True(info!.Enabled);
Assert.True(info.RegistrationOpen);
Assert.Equal("2026-11", info.TermsVersion);
Assert.Equal(PasswordRules.Version, info.PasswordRulesVersion);
}
[Fact]
public async Task Enabled_without_master_key_does_not_start()
{
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Players:Enabled"] = "true"
}));
Assert.Contains("clé maîtresse", error.Message);
}
[Fact]
public async Task Master_key_inside_the_data_directory_is_refused()
{
var data = Path.Combine(Path.GetTempPath(), "launcher-api-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(data);
try
{
var key = Path.Combine(data, "players-master-key");
File.WriteAllText(key, PlayerSecrets.NewMasterKey());
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:DataDirectory"] = data,
["LauncherApi:Players:Enabled"] = "true",
["LauncherApi:Players:MasterKeyFile"] = key
}));
Assert.Contains("dossier des données", error.Message);
}
finally
{
Directory.Delete(data, recursive: true);
}
}
// ------------------------------------------------------------------ Création de compte
[Fact]
public async Task Register_opens_a_session_and_stores_the_email_encrypted()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var session = await server.RegisterAsync(device, " Joueur@Example.FR ");
Assert.StartsWith(PlayerTokens.AccessPrefix, session.AccessToken);
Assert.StartsWith(PlayerTokens.RefreshPrefix, session.RefreshToken);
Assert.Equal("j***@e***.fr", session.Account.EmailMasked);
Assert.Matches("^PL-", session.Account.Reference);
Assert.False(session.Account.EmailVerified);
var me = await server.MeAsync(session.AccessToken);
Assert.Equal(session.Account.Reference, me.Account.Reference);
var only = Assert.Single(me.Devices);
Assert.True(only.Current);
Assert.Equal(device.Label, only.Label);
// Ni l'adresse ni le mot de passe n'apparaissent en clair dans la base (ni dans son journal WAL).
var folder = Path.Combine(server.Api.DataDirectory, PlayerStore.FolderName);
foreach (var file in Directory.GetFiles(folder))
{
var bytes = await ReadSharedAsync(file);
var text = Encoding.UTF8.GetString(bytes);
Assert.DoesNotContain(Email, text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("joueur@", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(PlayerTestServer.Password, text);
}
// Journal du compte : création inscrite, avec l'adresse IP, sans adresse e-mail.
var events = server.Store.EventsAfter(0, 100);
var created = Assert.Single(events, e => e.Type == PlayerEvents.Register);
Assert.Equal(AuditResults.Ok, created.Result);
Assert.NotNull(created.Ip);
Assert.All(events, e => Assert.DoesNotContain("@", e.Details ?? ""));
}
private static async Task<byte[]> ReadSharedAsync(string path)
{
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
return memory.ToArray();
}
[Fact]
public async Task Register_refuses_bad_input()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
using (var weak = await server.RegisterRawAsync(device, Email, "P@ssw0rd2024!"))
{
Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode);
Assert.Equal(ApiErrorCodes.PasswordRejected, (await Error(weak)).Code);
}
using (var email = await server.RegisterRawAsync(device, "pas-une-adresse"))
Assert.Equal(ApiErrorCodes.EmailInvalid, (await Error(email)).Code);
using (var terms = await server.RegisterRawAsync(device, Email, terms: "0"))
Assert.Equal(ApiErrorCodes.TermsNotAccepted, (await Error(terms)).Code);
using (var proof = await server.RegisterRawAsync(device, Email, tamper: r => r with { Solution = "0" }))
Assert.Equal(ApiErrorCodes.ProofInvalid, (await Error(proof)).Code);
using (var signature = await server.RegisterRawAsync(device, Email,
tamper: r => r with { Device = r.Device with { Signature = Convert.ToBase64String(new byte[64]) } }))
Assert.Equal(ApiErrorCodes.DeviceProofInvalid, (await Error(signature)).Code);
using var other = new TestDevice();
using (var stolenKey = await server.RegisterRawAsync(device, Email,
tamper: r => r with { Device = r.Device with { PublicKey = Convert.ToBase64String(other.PublicKey) } }))
Assert.Equal(ApiErrorCodes.DeviceProofInvalid, (await Error(stolenKey)).Code);
Assert.Equal(0, server.Store.CountAccounts());
}
[Fact]
public async Task Same_email_cannot_be_registered_twice_whatever_its_case()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
await server.RegisterAsync(device, Email);
using var again = await server.RegisterRawAsync(device, "JOUEUR@example.fr", PlayerTestServer.OtherPassword);
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
Assert.Equal(ApiErrorCodes.EmailTaken, (await Error(again)).Code);
}
[Fact]
public async Task Challenge_is_single_use_and_bound_to_its_purpose()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var challenge = await server.ChallengeAsync(PlayerChallengePurposes.Register);
var request = new PlayerRegisterRequest
{
Email = Email,
Password = PlayerTestServer.Password,
ChallengeId = challenge.ChallengeId,
Solution = PlayerTestServer.Solve(challenge),
AcceptedTermsVersion = "1",
Device = device.Proof(PlayerChallengePurposes.Register, challenge)
};
using (var first = await server.Api.Http.PostAsync(ApiRoutes.AccountRegister, TestApi.Json(request)))
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
using (var replay = await server.Api.Http.PostAsync(ApiRoutes.AccountRegister, TestApi.Json(request with { Email = "autre@example.fr" })))
Assert.Equal(ApiErrorCodes.ProofInvalid, (await Error(replay)).Code);
// Un défi « création » ne sert pas à se connecter.
var registerChallenge = await server.ChallengeAsync(PlayerChallengePurposes.Register);
using var login = await server.Api.Http.PostAsync(ApiRoutes.AccountLogin, TestApi.Json(new PlayerLoginRequest
{
Email = Email,
Password = PlayerTestServer.Password,
ChallengeId = registerChallenge.ChallengeId,
Solution = PlayerTestServer.Solve(registerChallenge),
Device = device.Proof(PlayerChallengePurposes.Login, registerChallenge)
}));
Assert.Equal(ApiErrorCodes.ProofInvalid, (await Error(login)).Code);
}
[Fact]
public async Task Registrations_are_limited_per_ip_address()
{
await using var server = await PlayerTestServer.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Players:RegistrationsPerIpPerHour"] = "2",
["LauncherApi:Players:RegistrationsPerIpPerDay"] = "2"
});
using var device = new TestDevice();
await server.RegisterAsync(device, "a@example.fr");
await server.RegisterAsync(device, "b@example.fr");
using var third = await server.RegisterRawAsync(device, "c@example.fr");
Assert.Equal(HttpStatusCode.TooManyRequests, third.StatusCode);
Assert.NotNull(third.Headers.RetryAfter);
}
[Fact]
public async Task Registration_can_be_closed_while_login_stays_open()
{
await using var server = await PlayerTestServer.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Players:RegistrationEnabled"] = "false"
});
using var device = new TestDevice();
using var response = await server.RegisterRawAsync(device, Email);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(ApiErrorCodes.RegistrationClosed, (await Error(response)).Code);
Assert.False((await server.Api.InfoAsync()).Accounts!.RegistrationOpen);
}
// ------------------------------------------------------------------ Connexion et anti-force brute
[Fact]
public async Task Login_succeeds_and_failures_look_the_same_for_unknown_addresses()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
await server.RegisterAsync(pc, Email);
using var laptop = new TestDevice("Portable");
var ok = await server.LoginAsync(laptop, "JOUEUR@example.fr");
Assert.Equal(PlayerLoginStatus.Ok, ok.Status);
Assert.Equal(2, (await server.MeAsync(ok.Session!.AccessToken)).Devices.Count);
using var wrong = await server.LoginRawAsync(laptop, Email, PlayerTestServer.OtherPassword);
using var unknown = await server.LoginRawAsync(laptop, "personne@example.fr");
Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, unknown.StatusCode);
var wrongError = await Error(wrong);
var unknownError = await Error(unknown);
Assert.Equal(ApiErrorCodes.LoginFailed, wrongError.Code);
Assert.Equal(wrongError.Code, unknownError.Code);
Assert.Equal(wrongError.Message, unknownError.Message);
}
[Fact]
public async Task Repeated_failures_lock_new_devices_with_increasing_delays()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
await server.RegisterAsync(pc, Email);
using var attacker = new TestDevice("Attaquant");
for (var i = 1; i <= 4; i++)
{
using var failure = await server.LoginRawAsync(attacker, Email, "Mauvais-Mot-2-Passe" + i);
Assert.Equal(HttpStatusCode.Unauthorized, failure.StatusCode);
}
using (var fifth = await server.LoginRawAsync(attacker, Email, "Mauvais-Mot-2-Passe5"))
{
Assert.Equal(HttpStatusCode.TooManyRequests, fifth.StatusCode);
Assert.Equal(ApiErrorCodes.LoginLocked, (await Error(fifth)).Code);
Assert.Equal(TimeSpan.FromMinutes(15), fifth.Headers.RetryAfter!.Delta);
}
// Bloqué même avec le bon mot de passe, depuis un appareil inconnu…
using (var locked = await server.LoginRawAsync(attacker, Email))
Assert.Equal(HttpStatusCode.TooManyRequests, locked.StatusCode);
// … mais pas depuis l'appareil du joueur (appareil de confiance).
Assert.Equal(PlayerLoginStatus.Ok, (await server.LoginAsync(pc, Email)).Status);
// La connexion réussie a remis les compteurs à zéro : le blocage est levé.
server.Api.Time.Advance(TimeSpan.FromMinutes(1));
using (var after = await server.LoginRawAsync(attacker, Email))
Assert.Equal(HttpStatusCode.OK, after.StatusCode);
}
[Fact]
public async Task Second_lock_lasts_twice_as_long()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
await server.RegisterAsync(pc, Email);
using var attacker = new TestDevice();
await FailAsync(server, attacker, 5);
server.Api.Time.Advance(TimeSpan.FromMinutes(16));
await FailAsync(server, attacker, 4);
using var tenth = await server.LoginRawAsync(attacker, Email, "Encore-Faux-1234");
Assert.Equal(TimeSpan.FromMinutes(30), tenth.Headers.RetryAfter!.Delta);
}
private static async Task FailAsync(PlayerTestServer server, TestDevice device, int count, string email = Email)
{
for (var i = 0; i < count; i++)
{
using var response = await server.LoginRawAsync(device, email, $"Faux-Mot-De-Passe-{i}");
Assert.True(response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.TooManyRequests);
}
}
[Fact]
public async Task Unknown_addresses_are_locked_exactly_like_real_accounts()
{
await using var server = await PlayerTestServer.StartAsync();
using var attacker = new TestDevice();
await FailAsync(server, attacker, 4, "personne@example.fr");
using var fifth = await server.LoginRawAsync(attacker, "personne@example.fr", "Faux-Mot-De-Passe-5");
Assert.Equal(HttpStatusCode.TooManyRequests, fifth.StatusCode);
Assert.Equal(ApiErrorCodes.LoginLocked, (await Error(fifth)).Code);
Assert.Equal(TimeSpan.FromMinutes(15), fifth.Headers.RetryAfter!.Delta);
}
[Fact]
public async Task Continued_failures_suspend_new_devices_until_an_administrator_lifts_it()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var session = await server.RegisterAsync(pc, Email);
using var attacker = new TestDevice();
for (var lockNumber = 1; lockNumber <= 4; lockNumber++)
{
await FailAsync(server, attacker, 5);
server.Api.Time.Advance(TimeSpan.FromHours(2));
}
var account = server.Store.FindByReference(session.Account.Reference)!;
Assert.Equal(PlayerStatus.Suspended, account.Status);
// Bon mot de passe depuis un NOUVEL appareil : suspension révélée (seulement à qui connaît le mot de passe).
using (var refused = await server.LoginRawAsync(attacker, Email))
{
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);
var error = await Error(refused);
Assert.Equal(ApiErrorCodes.AccountSuspended, error.Code);
Assert.Contains(session.Account.Reference, error.Message);
}
// L'appareil du joueur continue de fonctionner (l'attaquant ne peut pas le priver de son compte).
var me = await server.MeAsync((await server.RefreshAsync(pc, session.RefreshToken)).AccessToken);
Assert.True(me.Account.NewDevicesSuspended);
Assert.Equal(PlayerLoginStatus.Ok, (await server.LoginAsync(pc, Email)).Status);
// Inscrit au journal d'audit des administrateurs, par référence (jamais l'adresse).
var audit = await File.ReadAllTextAsync(Directory.GetFiles(Path.Combine(server.Api.DataDirectory, "audit"), "audit-*.jsonl").Single());
Assert.Contains("players.suspend", audit);
Assert.Contains(session.Account.Reference, audit);
Assert.DoesNotContain("joueur@", audit);
// Levée par l'outil en ligne de commande (le menu d'administration viendra à l'étape 8d).
var output = new StringWriter();
Assert.Equal(0, AdminCli.Run(new[] { "players-unlock", "--ref", session.Account.Reference }, output, server.Api.DataDirectory));
Assert.Equal(PlayerLoginStatus.Ok, (await server.LoginAsync(attacker, Email)).Status);
}
[Fact]
public async Task Trusted_device_failures_lock_briefly_but_never_suspend()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var session = await server.RegisterAsync(pc, Email);
for (var round = 0; round < 3; round++)
{
await FailAsync(server, pc, 10);
server.Api.Time.Advance(TimeSpan.FromMinutes(20));
}
Assert.Equal(PlayerStatus.Active, server.Store.FindByReference(session.Account.Reference)!.Status);
}
[Fact]
public async Task Failed_logins_are_limited_per_ip_address_across_accounts()
{
await using var server = await PlayerTestServer.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Players:FailedLoginsPerIpPer15Minutes"] = "5"
});
using var attacker = new TestDevice();
for (var i = 0; i < 5; i++)
{
using var failure = await server.LoginRawAsync(attacker, $"victime{i}@example.fr");
Assert.Equal(HttpStatusCode.Unauthorized, failure.StatusCode);
}
using var throttled = await server.LoginRawAsync(attacker, "victime9@example.fr");
Assert.Equal(HttpStatusCode.TooManyRequests, throttled.StatusCode);
Assert.Equal(ApiErrorCodes.RateLimited, (await Error(throttled)).Code);
}
// ------------------------------------------------------------------ Session mémorisée
[Fact]
public async Task Refresh_rotates_the_token_and_detects_reuse()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var first = await server.RegisterAsync(pc, Email);
var second = await server.RefreshAsync(pc, first.RefreshToken);
Assert.NotEqual(first.RefreshToken, second.RefreshToken);
Assert.Equal(first.DeviceId, second.DeviceId);
await server.MeAsync(second.AccessToken);
// L'ancien jeton présenté de nouveau (copie volée) : l'appareil est déconnecté par précaution.
using (var reuse = await server.RefreshRawAsync(pc, first.RefreshToken))
Assert.Equal(ApiErrorCodes.SessionRevoked, (await Error(reuse)).Code);
using (var after = await server.RefreshRawAsync(pc, second.RefreshToken))
Assert.Equal(HttpStatusCode.Unauthorized, after.StatusCode);
using (var me = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, second.AccessToken))
Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode);
Assert.Contains(server.Store.EventsAfter(0, 100), e => e.Type == PlayerEvents.RefreshReuse);
}
[Fact]
public async Task Refresh_token_is_useless_without_the_device_key()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var session = await server.RegisterAsync(pc, Email);
using var thief = new TestDevice();
using var stolen = await server.RefreshRawAsync(thief, session.RefreshToken);
Assert.Equal(HttpStatusCode.Unauthorized, stolen.StatusCode);
Assert.Equal(ApiErrorCodes.DeviceProofInvalid, (await Error(stolen)).Code);
// Le vrai appareil n'est pas pénalisé.
await server.RefreshAsync(pc, session.RefreshToken);
}
[Fact]
public async Task Access_token_is_short_lived_and_session_expires_after_inactivity()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var session = await server.RegisterAsync(pc, Email);
server.Api.Time.Advance(TimeSpan.FromMinutes(16));
using (var expired = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, session.AccessToken))
Assert.Equal(HttpStatusCode.Unauthorized, expired.StatusCode);
var renewed = await server.RefreshAsync(pc, session.RefreshToken);
await server.MeAsync(renewed.AccessToken);
server.Api.Time.Advance(TimeSpan.FromDays(91));
using var stale = await server.RefreshRawAsync(pc, renewed.RefreshToken);
Assert.Equal(ApiErrorCodes.SessionRevoked, (await Error(stale)).Code);
}
[Fact]
public async Task Refresh_is_logged_at_most_once_a_day_per_device()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var session = await server.RegisterAsync(pc, Email);
server.Api.Time.Advance(TimeSpan.FromDays(1));
var a = await server.RefreshAsync(pc, session.RefreshToken);
var b = await server.RefreshAsync(pc, a.RefreshToken);
await server.RefreshAsync(pc, b.RefreshToken);
Assert.Single(server.Store.EventsAfter(0, 100), e => e.Type == PlayerEvents.Refresh);
}
// ------------------------------------------------------------------ Déconnexion et appareils
[Fact]
public async Task Logout_ends_this_device_or_all_devices()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
using var laptop = new TestDevice("Portable");
var pcSession = await server.RegisterAsync(pc, Email);
var laptopSession = (await server.LoginAsync(laptop, Email)).Session!;
using (var logout = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountLogout, laptopSession.AccessToken, new PlayerLogoutRequest()))
Assert.Equal(HttpStatusCode.NoContent, logout.StatusCode);
using (var gone = await server.RefreshRawAsync(laptop, laptopSession.RefreshToken))
Assert.Equal(HttpStatusCode.Unauthorized, gone.StatusCode);
Assert.Single((await server.MeAsync(pcSession.AccessToken)).Devices);
var again = (await server.LoginAsync(laptop, Email)).Session!;
using (var all = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountLogout, again.AccessToken, new PlayerLogoutRequest { AllDevices = true }))
Assert.Equal(HttpStatusCode.NoContent, all.StatusCode);
using (var pcGone = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, pcSession.AccessToken))
Assert.Equal(HttpStatusCode.Unauthorized, pcGone.StatusCode);
using (var pcRefresh = await server.RefreshRawAsync(pc, pcSession.RefreshToken))
Assert.Equal(HttpStatusCode.Unauthorized, pcRefresh.StatusCode);
}
[Fact]
public async Task A_device_can_be_signed_out_from_another_one()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
using var laptop = new TestDevice("Portable");
var pcSession = await server.RegisterAsync(pc, Email);
var laptopSession = (await server.LoginAsync(laptop, Email)).Session!;
using (var removed = await server.SendAsync(HttpMethod.Delete, ApiRoutes.AccountDevice(laptopSession.DeviceId), pcSession.AccessToken))
Assert.Equal(HttpStatusCode.NoContent, removed.StatusCode);
using (var gone = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, laptopSession.AccessToken))
Assert.Equal(HttpStatusCode.Unauthorized, gone.StatusCode);
// L'appareil d'un autre compte est introuvable.
using var stranger = new TestDevice();
var other = await server.RegisterAsync(stranger, "autre@example.fr");
using var foreign = await server.SendAsync(HttpMethod.Delete, ApiRoutes.AccountDevice(other.DeviceId), pcSession.AccessToken);
Assert.Equal(HttpStatusCode.NotFound, foreign.StatusCode);
}
[Fact]
public async Task Oldest_devices_are_signed_out_beyond_the_limit()
{
await using var server = await PlayerTestServer.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Players:MaxDevicesPerAccount"] = "2" });
using var a = new TestDevice("A");
using var b = new TestDevice("B");
using var c = new TestDevice("C");
var first = await server.RegisterAsync(a, Email);
server.Api.Time.Advance(TimeSpan.FromMinutes(1));
await server.LoginAsync(b, Email);
server.Api.Time.Advance(TimeSpan.FromMinutes(1));
var third = (await server.LoginAsync(c, Email)).Session!;
var devices = (await server.MeAsync(third.AccessToken)).Devices;
Assert.Equal(new[] { "C", "B" }, devices.Select(d => d.Label).ToArray());
using var oldest = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, first.AccessToken);
Assert.Equal(HttpStatusCode.Unauthorized, oldest.StatusCode);
}
// ------------------------------------------------------------------ Mot de passe
[Fact]
public async Task Changing_the_password_signs_out_the_other_devices()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
using var laptop = new TestDevice("Portable");
var pcSession = await server.RegisterAsync(pc, Email);
var laptopSession = (await server.LoginAsync(laptop, Email)).Session!;
using (var wrong = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountPassword, pcSession.AccessToken,
new PlayerChangePasswordRequest { CurrentPassword = "Pas-Le-Bon-123", NewPassword = PlayerTestServer.OtherPassword }))
Assert.Equal(ApiErrorCodes.LoginFailed, (await Error(wrong)).Code);
using (var weak = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountPassword, pcSession.AccessToken,
new PlayerChangePasswordRequest { CurrentPassword = PlayerTestServer.Password, NewPassword = "azertyuiop1234" }))
Assert.Equal(ApiErrorCodes.PasswordRejected, (await Error(weak)).Code);
using (var same = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountPassword, pcSession.AccessToken,
new PlayerChangePasswordRequest { CurrentPassword = PlayerTestServer.Password, NewPassword = PlayerTestServer.Password }))
Assert.Equal(ApiErrorCodes.PasswordRejected, (await Error(same)).Code);
using (var ok = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountPassword, pcSession.AccessToken,
new PlayerChangePasswordRequest { CurrentPassword = PlayerTestServer.Password, NewPassword = PlayerTestServer.OtherPassword }))
{
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
var result = (await ok.Content.ReadFromJsonAsync<PlayerChangePasswordResponse>(ApiJson.Options))!;
Assert.Equal(1, result.DevicesSignedOut);
Assert.Null(result.Session);
}
await server.MeAsync(pcSession.AccessToken); // cet appareil reste connecté
using (var other = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, laptopSession.AccessToken))
Assert.Equal(HttpStatusCode.Unauthorized, other.StatusCode);
using (var old = await server.LoginRawAsync(laptop, Email))
Assert.Equal(HttpStatusCode.Unauthorized, old.StatusCode);
Assert.Equal(PlayerLoginStatus.Ok, (await server.LoginAsync(laptop, Email, PlayerTestServer.OtherPassword)).Status);
}
[Fact]
public async Task Required_password_change_opens_the_session_only_once_done()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var initial = await server.RegisterAsync(pc, Email);
var account = server.Store.FindByReference(initial.Account.Reference)!;
Assert.True(server.Store.SetMustChangePassword(account.Id, true));
// Les sessions en cours sont refusées, le renouvellement aussi.
using (var me = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, initial.AccessToken))
Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode);
using (var refresh = await server.RefreshRawAsync(pc, initial.RefreshToken))
Assert.Equal(ApiErrorCodes.PasswordChangeRequired, (await Error(refresh)).Code);
var login = await server.LoginAsync(pc, Email);
Assert.Equal(PlayerLoginStatus.PasswordChangeRequired, login.Status);
Assert.Null(login.Session);
Assert.StartsWith(PlayerTokens.PasswordChangePrefix, login.PasswordChangeToken);
// Le jeton limité ne donne accès à rien d'autre.
using (var limited = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, login.PasswordChangeToken))
Assert.Equal(HttpStatusCode.Forbidden, limited.StatusCode);
using var change = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountPassword, login.PasswordChangeToken,
new PlayerChangePasswordRequest { CurrentPassword = PlayerTestServer.Password, NewPassword = PlayerTestServer.OtherPassword });
Assert.Equal(HttpStatusCode.OK, change.StatusCode);
var result = (await change.Content.ReadFromJsonAsync<PlayerChangePasswordResponse>(ApiJson.Options))!;
Assert.NotNull(result.Session);
await server.MeAsync(result.Session!.AccessToken);
Assert.False(server.Store.FindById(account.Id)!.MustChangePassword);
// Le jeton limité ne sert qu'une fois.
using var reuse = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountPassword, login.PasswordChangeToken,
new PlayerChangePasswordRequest { CurrentPassword = PlayerTestServer.OtherPassword, NewPassword = "Troisieme-Mot-De-Passe-9" });
Assert.Equal(HttpStatusCode.Unauthorized, reuse.StatusCode);
}
// ------------------------------------------------------------------ Désactivation (outil en ligne de commande)
[Fact]
public async Task Disabled_account_is_refused_immediately_and_reactivated_at_the_end_date()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var session = await server.RegisterAsync(pc, Email);
var until = server.Api.Time.GetUtcNow().AddDays(3).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm", System.Globalization.CultureInfo.InvariantCulture);
// L'outil en ligne de commande lit l'heure réelle : l'échéance est calculée sur la même horloge que celle du serveur de test.
var output = new StringWriter();
Assert.Equal(0, AdminCli.Run(new[] { "players-disable", "--ref", session.Account.Reference, "--until", until }, output, server.Api.DataDirectory));
using (var me = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, session.AccessToken))
Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode);
using (var login = await server.LoginRawAsync(pc, Email))
{
Assert.Equal(HttpStatusCode.Forbidden, login.StatusCode);
Assert.Equal(ApiErrorCodes.AccountDisabled, (await Error(login)).Code);
}
using (var wrongPassword = await server.LoginRawAsync(pc, Email, "Faux-Mot-De-Passe-1"))
Assert.Equal(ApiErrorCodes.LoginFailed, (await Error(wrongPassword)).Code); // état non révélé sans le bon mot de passe
server.Api.Time.Advance(TimeSpan.FromDays(4));
Assert.Equal(PlayerLoginStatus.Ok, (await server.LoginAsync(pc, Email)).Status);
Assert.Equal(PlayerStatus.Active, server.Store.FindByReference(session.Account.Reference)!.Status);
}
[Fact]
public async Task Command_line_shows_an_account_without_its_email()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var session = await server.RegisterAsync(pc, Email);
var output = new StringWriter();
Assert.Equal(0, AdminCli.Run(new[] { "players-show", "--ref", session.Account.Reference.ToLowerInvariant() }, output, server.Api.DataDirectory));
var text = output.ToString();
Assert.Contains(session.Account.Reference, text);
Assert.Contains("active", text);
Assert.DoesNotContain("joueur@", text);
var missing = new StringWriter();
Assert.Equal(2, AdminCli.Run(new[] { "players-show", "--ref", "PL-0000-0000" }, missing, server.Api.DataDirectory));
}
// ------------------------------------------------------------------ Conservation
[Fact]
public async Task Account_events_lose_their_ip_after_six_months_and_disappear_after_twelve()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
await server.RegisterAsync(pc, Email);
var retention = server.Api.Services.GetServices<IHostedService>().OfType<RetentionService>().Single();
server.Api.Time.Advance(TimeSpan.FromDays(200));
retention.RunOnce();
var events = server.Store.EventsAfter(0, 100);
Assert.NotEmpty(events);
Assert.All(events, e => Assert.Null(e.Ip));
server.Api.Time.Advance(TimeSpan.FromDays(200));
retention.RunOnce();
Assert.Empty(server.Store.EventsAfter(0, 100));
}
}
@@ -0,0 +1,107 @@
using System.Net;
using System.Text.RegularExpressions;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Security;
using GameLauncher.Tests.Infrastructure;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace GameLauncher.Tests.Server;
/// <summary>
/// Étanchéité entre comptes joueurs et administrateurs : un jeton joueur, quel qu'il soit, n'ouvre AUCUNE route
/// d'administration (toutes les routes déclarées sont essayées), et un jeton d'administrateur n'ouvre aucune route joueur.
/// </summary>
public class PlayerSeparationTests
{
[Fact]
public async Task Player_tokens_never_open_an_administration_route()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var session = await server.RegisterAsync(device, "joueur@example.fr");
var account = server.Store.FindByReference(session.Account.Reference)!;
server.Store.SetMustChangePassword(account.Id, true);
var limited = (await server.LoginAsync(device, "joueur@example.fr")).PasswordChangeToken!;
server.Store.SetMustChangePassword(account.Id, false);
var access = (await server.LoginAsync(device, "joueur@example.fr")).Session!.AccessToken;
var adminRoutes = AdministrationRoutes(server.Api).ToList();
Assert.True(adminRoutes.Count >= 40, $"{adminRoutes.Count} routes d'administration trouvées seulement");
foreach (var token in new[] { access, limited, session.RefreshToken })
{
foreach (var (method, path) in adminRoutes)
{
using var response = await server.SendAsync(new HttpMethod(method), path, token, method is "GET" or "DELETE" ? null : new { });
Assert.True(response.StatusCode == HttpStatusCode.Unauthorized,
$"{method} {path} avec un jeton joueur ({token[..5]}…) : {(int)response.StatusCode}");
}
}
}
[Fact]
public async Task Administrator_tokens_never_open_a_player_route()
{
await using var server = await PlayerTestServer.StartAsync();
using var owner = server.Api.AddOwner();
var admin = await server.Api.RawLoginAsync(owner);
foreach (var (method, path) in new[]
{
("GET", ApiRoutes.AccountMe), ("POST", ApiRoutes.AccountLogout), ("POST", ApiRoutes.AccountPassword),
("DELETE", ApiRoutes.AccountDevice(PlayerIds.NewId()))
})
{
using var response = await server.SendAsync(new HttpMethod(method), path, admin.Token, method == "POST" ? new { } : null);
Assert.True(response.StatusCode == HttpStatusCode.Unauthorized, $"{method} {path} avec un jeton d'administrateur : {(int)response.StatusCode}");
}
// Un jeton d'administrateur présenté comme jeton de renouvellement est refusé.
using var refresh = await server.RefreshRawAsync(new TestDevice(), admin.Token);
Assert.Equal(HttpStatusCode.Unauthorized, refresh.StatusCode);
}
[Fact]
public async Task Player_token_cannot_be_used_on_the_admin_session_route_even_with_signature_headers()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var session = await server.RegisterAsync(device, "joueur@example.fr");
using var request = new HttpRequestMessage(HttpMethod.Get, ApiRoutes.AuthMe);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session.AccessToken);
using var response = await server.Api.Http.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Equal(ApiErrorCodes.Unauthorized, (await TestApi.ReadErrorAsync(response)).Code);
}
/// <summary>
/// Toutes les routes déclarées qui exigent une autorisation d'ADMINISTRATEUR (règle « AdminSession » ou « perm:… »),
/// avec des valeurs d'exemple à la place des paramètres.
/// </summary>
private static IEnumerable<(string Method, string Path)> AdministrationRoutes(TestApi api)
{
var sources = api.Services.GetRequiredService<EndpointDataSource>();
foreach (var endpoint in sources.Endpoints.OfType<RouteEndpoint>())
{
var policies = endpoint.Metadata.GetOrderedMetadata<IAuthorizeData>().Select(a => a.Policy ?? "").ToList();
if (!policies.Any(p => p == AdminSessionHandler.SchemeName || p.StartsWith("perm:", StringComparison.Ordinal))) continue;
Assert.DoesNotContain(PlayerAuthorization.PlayerPolicy, policies);
var template = endpoint.RoutePattern.RawText ?? "";
var path = Regex.Replace(template, @"\{([^}:]+)(:[^}]+)?\}", m => m.Groups[2].Value switch
{
":guid" => Guid.NewGuid().ToString("D"),
":int" => "1",
_ => "abc0123456789abcdef0123456789abc"
});
if (path.StartsWith(ApiRoutes.Prefix + "/", StringComparison.Ordinal)) path = path[(ApiRoutes.Prefix.Length + 1)..];
path = path.TrimStart('/');
foreach (var method in endpoint.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods ?? new[] { "GET" })
yield return (method, path);
}
}
}
@@ -0,0 +1,115 @@
using System.Security.Cryptography;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage.Players;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Server;
/// <summary>Secrets des comptes joueurs : clé maîtresse, chiffrement des champs, index aveugle, empreintes des mots de passe.</summary>
public class PlayerStorageTests
{
private static PlayerSecrets NewSecrets() => new(RandomNumberGenerator.GetBytes(32));
private static LauncherApiOptions.PlayersSettings FastSettings() => new()
{
HashMemoryKib = 7168,
HashIterations = 6,
HashParallelism = 1,
MaxConcurrentHashes = 2
};
[Fact]
public void Email_is_encrypted_and_bound_to_its_account()
{
var secrets = NewSecrets();
var a = secrets.EncryptEmail("compte-a", "joueur@example.fr");
var b = secrets.EncryptEmail("compte-a", "joueur@example.fr");
Assert.NotEqual(a, b); // nonce aléatoire
Assert.StartsWith("1.", a);
Assert.DoesNotContain("joueur", a);
Assert.Equal("joueur@example.fr", secrets.DecryptEmail("compte-a", a));
// Copiée sur un autre compte, ou déchiffrée avec une autre clé maîtresse : refusée.
Assert.ThrowsAny<CryptographicException>(() => secrets.DecryptEmail("compte-b", a));
Assert.ThrowsAny<CryptographicException>(() => NewSecrets().DecryptEmail("compte-a", a));
// Les champs libres utilisent une autre sous-clé.
Assert.ThrowsAny<CryptographicException>(() => secrets.DecryptField("compte-a", "email", a));
}
[Fact]
public void Email_index_is_deterministic_but_secret()
{
var secrets = NewSecrets();
Assert.Equal(secrets.EmailIndex("joueur@example.fr"), secrets.EmailIndex("joueur@example.fr"));
Assert.NotEqual(secrets.EmailIndex("joueur@example.fr"), secrets.EmailIndex("joueur2@example.fr"));
Assert.NotEqual(secrets.EmailIndex("joueur@example.fr"), NewSecrets().EmailIndex("joueur@example.fr"));
Assert.Matches("^[0-9a-f]{64}$", secrets.EmailIndex("joueur@example.fr"));
}
[Fact]
public void Master_key_file_is_validated()
{
var file = PlayerTestServer.NewKeyFile(out var directory);
try
{
Assert.NotNull(PlayerSecrets.Load(file));
File.WriteAllText(file, "pas du base64 !");
Assert.Throws<InvalidOperationException>(() => PlayerSecrets.Load(file));
File.WriteAllText(file, Convert.ToBase64String(new byte[16]));
Assert.ThrowsAny<Exception>(() => PlayerSecrets.Load(file));
File.WriteAllText(file, Convert.ToBase64String(new byte[32])); // que des zéros
Assert.ThrowsAny<CryptographicException>(() => PlayerSecrets.Load(file));
if (!OperatingSystem.IsWindows())
{
File.WriteAllText(file, PlayerSecrets.NewMasterKey());
File.SetUnixFileMode(file, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.OtherRead);
var error = Assert.Throws<InvalidOperationException>(() => PlayerSecrets.Load(file));
Assert.Contains("accessible à d'autres comptes", error.Message);
}
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public async Task Password_hash_uses_argon2id_with_pepper_and_records_its_parameters()
{
var secrets = NewSecrets();
var hasher = new PlayerPasswordHasher(secrets, FastSettings());
var hash = await hasher.HashAsync("Tr0mbone-Lavande!");
Assert.Matches(@"^\$argon2id\$v=19\$m=7168,t=6,p=1,k=1\$[A-Za-z0-9_-]{22}\$[A-Za-z0-9_-]{43}$", hash);
Assert.NotEqual(hash, await hasher.HashAsync("Tr0mbone-Lavande!")); // sel aléatoire
Assert.Equal((true, false), await hasher.VerifyAsync("Tr0mbone-Lavande!", hash));
Assert.Equal((false, false), await hasher.VerifyAsync("tr0mbone-lavande!", hash));
// Aucune normalisation Unicode (indisponible sur le serveur) : des caractères « pleine chasse » sont un autre mot de passe.
Assert.False((await hasher.VerifyAsync("Tr0mbone-Lavande!", hash)).Valid);
// Sans le poivre (autre clé maîtresse), l'empreinte ne correspond plus : une copie de la base seule est inutile.
Assert.False((await new PlayerPasswordHasher(NewSecrets(), FastSettings()).VerifyAsync("Tr0mbone-Lavande!", hash)).Valid);
Assert.False((await hasher.VerifyAsync("Tr0mbone-Lavande!", "n'importe quoi")).Valid);
}
[Fact]
public async Task Hash_made_with_older_parameters_is_flagged_for_rehash()
{
var secrets = NewSecrets();
var old = await new PlayerPasswordHasher(secrets, FastSettings()).HashAsync("Tr0mbone-Lavande!");
var stronger = FastSettings();
stronger.HashIterations = 7;
Assert.Equal((true, true), await new PlayerPasswordHasher(secrets, stronger).VerifyAsync("Tr0mbone-Lavande!", old));
}
[Fact]
public void Weak_hash_settings_are_refused_at_startup()
{
var options = new LauncherApiOptions { PublicOrigin = "https://launcher.example.fr", DataDirectory = Path.GetTempPath() };
options.Players.HashMemoryKib = 8192;
options.Players.HashIterations = 2; // 16 Mio : moins que 19 Mio × 2
Assert.Throws<InvalidOperationException>(() => options.Validate(AppContext.BaseDirectory));
}
}