Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02b7af8253 | ||
|
|
723df05cf3 | ||
|
|
85e69e6a2c | ||
|
|
7a6ad2247b | ||
|
|
19f207d430 | ||
|
|
a35e419133 | ||
|
|
a9d07f6df8 |
No files matched your search
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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$<sel>$<empreinte></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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
Binary file not shown.
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user