Compare commits

...
39 Commits
Author SHA1 Message Date
nicolas 9ddc445de7 Docs : étape 8d 2026-11-11 11:49:35 +01:00
nicolas d6e96e63c1 UiSnapshot : scènes des comptes joueurs 2026-11-11 11:49:24 +01:00
nicolas 44b605b110 Menu d'administration : page « Comptes joueurs » (WPF) 2026-11-11 11:49:10 +01:00
nicolas d6e19fa7de Menu d'administration : page « Comptes joueurs » (ViewModels) et journal d'audit 2026-11-11 11:48:53 +01:00
nicolas f2e50295cb Fenêtre Compte : connexion avec un mot de passe temporaire 2026-11-11 11:48:18 +01:00
nicolas 5317956c56 Client API : méthodes des comptes joueurs 2026-11-11 11:47:54 +01:00
nicolas 1a005f5048 Tests serveur : comptes joueurs vus par les administrateurs 2026-11-11 11:47:44 +01:00
nicolas 26d80be5a3 Serveur : module PlayersAdmin (routes v1/players) 2026-11-11 11:47:30 +01:00
nicolas f2f80deac8 Serveur : connexion par mot de passe temporaire, motif de désactivation 2026-11-11 11:46:56 +01:00
nicolas 446eb703ff Serveur : schéma 3 (mot de passe temporaire), liste et compteurs des comptes 2026-11-11 11:46:34 +01:00
nicolas f98a35afe3 Contrats : permissions, routes et échanges du menu « Comptes joueurs » 2026-11-11 11:45:57 +01:00
nicolas dd3c1c780a Docs : étape 8c et README des comptes joueurs 2026-11-11 10:41:00 +01:00
nicolas f384618765 UiSnapshot : scènes de la double authentification 2026-11-11 10:40:51 +01:00
nicolas 234b45990b Fenêtre Compte : interface de la double authentification (WPF) 2026-11-11 10:40:37 +01:00
nicolas afc02147a8 Fenêtre Compte : écran du code et parcours de la double authentification (ViewModel) 2026-11-11 10:40:23 +01:00
nicolas e9f1f04898 Comptes : encodeur QR code maison 2026-11-11 10:40:04 +01:00
nicolas 8df16b8fbc Client : API et service de la double authentification 2026-11-11 10:39:46 +01:00
nicolas 87438a1593 Tests serveur : double authentification et séparation des jetons 2026-11-11 10:39:30 +01:00
nicolas be0a97b932 Serveur : commande de secours players-otp-reset 2026-11-11 10:39:00 +01:00
nicolas 52f6c0da25 Serveur : routes de la double authentification et connexion avec code 2026-11-11 10:38:42 +01:00
nicolas a8990ad8a8 Serveur : jeton de connexion en deux temps (glpo_) 2026-11-11 10:38:15 +01:00
nicolas 903d9c6159 Serveur : schéma 2 des comptes joueurs (secret OTP chiffré, codes de secours) 2026-11-11 10:38:03 +01:00
nicolas 44b086d5f0 Contrats : double authentification (TOTP, codes de secours, routes, DTO) 2026-11-11 10:37:37 +01:00
nicolas f125da4f60 Docs : comptes joueurs étape 8b 2026-11-11 09:51:26 +01:00
nicolas 0c6c686ceb UiSnapshot : captures de la fenêtre « Compte » et du bouton de la barre de titre 2026-11-11 09:51:14 +01:00
nicolas f07ffa156a Comptes joueurs 8b : tests du compte joueur côté launcher 2026-11-11 09:50:56 +01:00
nicolas 7885fe81e0 Comptes joueurs 8b : launcher – bouton et fenêtre « Compte », clé d'appareil, DPAPI 2026-11-11 09:50:35 +01:00
nicolas 2596736ddb Comptes joueurs 8b : module GameLauncher.Modules.Accounts (session, ViewModels) 2026-11-11 09:49:24 +01:00
nicolas c99e21c351 Comptes joueurs 8b : client de l'API joueurs 2026-11-11 09:48:38 +01:00
nicolas dd8bd00091 Comptes joueurs 8b : tests serveur (RGPD, étanchéité des nouvelles routes) 2026-11-11 09:48:16 +01:00
nicolas edcb902be5 Comptes joueurs 8b : serveur – activité, export, suppression, comptes inactifs 2026-11-11 09:47:49 +01:00
nicolas c00685eaec Comptes joueurs 8b : contrats (activité, export, suppression) 2026-11-11 09:46:48 +01:00
nicolas 02b7af8253 Comptes joueurs (8a) : déploiement et documentation
- launcher-api-players.conf : complément systemd (LoadCredentialEncrypted).
- LISEZMOI : étape 3 undecies (préparation, ne pas activer en production
  avant 8g), commandes de secours, fichiers sur le VPS.
- Nouveau docs/PLAYER-ACCOUNTS-ARCHITECTURE.md.
- ADMIN-ARCHITECTURE : dépendances NuGet, phase 8a au § 13, § 14 mis à jour.
2026-11-10 22:55:41 +01:00
nicolas 723df05cf3 Comptes joueurs (8a) : tests
- Règles des mots de passe, adresses, références, jetons.
- Création, connexion, blocages, suspension, session mémorisée (rotation,
  réutilisation, clé d'appareil), appareils, mot de passe, désactivation, purge.
- Étanchéité : toutes les routes admin déclarées essayées avec des jetons joueurs
  (401 partout), et l'inverse.
- Chiffrement, clé maîtresse, empreintes ; vecteur officiel Argon2id (RFC 9106).
2026-11-10 22:55:19 +01:00
nicolas 85e69e6a2c Comptes joueurs (8a) : commandes de secours en ligne de commande
players-key, players-show, players-unlock, players-disable [--until], players-enable.
Compte désigné par sa référence ; l'adresse e-mail n'est jamais affichée.
Actions inscrites au journal d'audit (cli.players_*).
2026-11-10 22:55:08 +01:00
nicolas 7a6ad2247b Comptes joueurs (8a) : module Accounts et authentification séparée des admins
- Routes : défi, création de compte, connexion, renouvellement, déconnexion,
  « Mon compte », changement de mot de passe (dont exigé), appareils.
- Schéma PlayerSession distinct ; les règles admin désignent explicitement
  AdminSession et le gestionnaire admin ignore les jetons glp… .
- PlayerGuard : défis sans état à usage unique, preuve de travail, limites par IP,
  blocage miroir des adresses inconnues.
- Anti-force brute : 5 échecs → 15/30/60 min ; 20 échecs consécutifs → suspension
  des nouveaux appareils (levée par un admin) ; appareils de confiance jamais suspendus.
- Session mémorisée liée à la clé d'appareil, jeton remplacé à chaque usage,
  réutilisation détectée.
- Limite de débit « account », ouverture de la base au démarrage, comptes annoncés
  dans v1/info, purge du journal des comptes (12 mois, IP effacées après 6 mois).
- appsettings.json : section Players (Enabled = false).
2026-11-10 22:54:57 +01:00
nicolas 19f207d430 Comptes joueurs (8a) : stockage SQLite chiffré et empreintes Argon2id
- Paquets Microsoft.Data.Sqlite 8.0.34 et Konscious.Security.Cryptography.Argon2 1.3.1.
- PlayerSecrets : clé maîtresse hors du dossier des données (credential systemd),
  sous-clés HKDF, adresses chiffrées AES-256-GCM liées au compte, index aveugle.
- PlayerPasswordHasher : Argon2id 19 Mio × 2, sel aléatoire 128 bits par compte,
  poivre = clé secrète d'Argon2, format PHC, file de 4 calculs, calcul fictif
  pour une adresse inconnue.
- PlayerStore : players/players.db (WAL, secure_delete, 0600, schéma versionné),
  comptes, appareils, journal des comptes, compteurs d'échecs et suspension.
- Réglages LauncherApi:Players (désactivé par défaut) et leur validation.
2026-11-10 22:54:05 +01:00
nicolas a35e419133 Comptes joueurs (8a) : protocole launcher ⇄ serveur
- Messages signés par la clé d'appareil (en-tête GameLauncher/Player/…,
  distinct des messages admin), types de défi (création, connexion, renouvellement).
- Jetons joueurs glpa_ (accès), glpr_ (renouvellement), glpc_ (changement exigé),
  identifiants et références de compte PL-XXXX-XXXX.
- Objets échangés, routes v1/account/…, 13 codes d'erreur, ServerInfo.Accounts.
- SignedMessages.Build passe en internal (réutilisé par PlayerMessages).
2026-11-10 22:53:17 +01:00
nicolas a9d07f6df8 Comptes joueurs (8a) : règles des mots de passe et des adresses e-mail
- PasswordRules : 12 caractères et 3 types, ou 16 sans contrainte ; 128 au plus.
  Refus : dictionnaire NCSC (99 839 mots de passe) et ses variantes simples,
  mots de passe français courants, suites (répétitions, claviers, suites de
  chiffres ou de lettres), mots tirés de l'adresse ou des noms du studio et des jeux.
  Aucune normalisation Unicode : même calcul dans le launcher et sur le serveur.
- PlayerEmail : contrôle et normalisation (ASCII), masquage pour l'affichage.
- Dictionnaire intégré compressé (430 Ko), avec son origine et sa licence (MIT).
2026-11-10 22:52:37 +01:00
99 changed files with 15821 additions and 37 deletions

No files matched your search

+11
View File
@@ -71,6 +71,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Admin"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Feedback", "src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj", "{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Accounts", "src\GameLauncher.Modules.Accounts\GameLauncher.Modules.Accounts.csproj", "{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "website", "website", "{8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Website", "website\GameLauncher.Website\GameLauncher.Website.csproj", "{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}"
@@ -188,6 +190,14 @@ Global
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|Any CPU.Build.0 = Release|Any CPU
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|x64.ActiveCfg = Release|x64
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|x64.Build.0 = Release|x64
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Debug|x64.ActiveCfg = Debug|x64
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Debug|x64.Build.0 = Debug|x64
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Release|Any CPU.Build.0 = Release|Any CPU
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Release|x64.ActiveCfg = Release|x64
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Release|x64.Build.0 = Release|x64
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|x64.ActiveCfg = Debug|x64
@@ -222,6 +232,7 @@ Global
{9EBFA383-7928-46F6-8958-F4B38287E380} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
+53 -7
View File
@@ -36,14 +36,15 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| `tools/ManifestBuilder` | Outil console de publication : génère `manifest.json`, l'archive ZIP et le dossier `objects/` |
| `tools/LauncherPublisher` | Outil console de publication **du launcher** : clés de signature, `launcher-update.json` signé (voir §7) |
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, groupes et permissions, messages signés, catalogue, notes de version (modèle et limites), contrôle des manifests publiés (voir §8) |
| `src/GameLauncher.ApiClient` | Client de la Launcher API : connexion par clé, requêtes signées, fichier de clé chiffré |
| `src/GameLauncher.ApiClient` | Client de la Launcher API : connexion par clé, requêtes signées, fichier de clé chiffré ; API des comptes joueurs (`PlayerAccountApi`) |
| `src/GameLauncher.Modules` | Socle des modules du launcher (sans WPF) : contrat d'un module, pages du menu d'administration, MVVM commun |
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux »), « Notes de version » (`Changelog/`) et « Publication » (`Publishing/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux »), « Notes de version » (`Changelog/`), « Publication » (`Publishing/`) et « Comptes joueurs » (`Players/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
| `src/GameLauncher.Modules.Feedback` | Module « Aide » côté joueur (sans WPF) : formulaire de demande (avis, demande d'évolution, bug, autre) et envoi anonyme ; fenêtre dans `src\GameLauncher\Views\FeedbackWindow.xaml` |
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
| `src/GameLauncher.Modules.Accounts` | Module « Compte » côté joueur (sans WPF) : connexion, création de compte, double authentification (TOTP, codes de secours, QR code), « Mon compte », session mémorisée ; fenêtre dans `src\GameLauncher\Views\AccountWindow.xaml` (voir §10) |
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration et des comptes joueurs, déployé sur le VPS (voir §8 et §10) |
| `website/GameLauncher.Website` | **Site vitrine** (ASP.NET Core Razor Pages) : présentation des jeux et du launcher, téléchargement, notes de version, actualités ; isolé de la Launcher API (voir §9) |
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
| `tools/UiSnapshot` | Captures de **toutes les fenêtres** (états de la fenêtre principale, dialogues, menu d'administration et ses pages) avec des données de démonstration, et rapport des problèmes de mise en page : `tools\ui-snapshot.bat` (voir `tools\UiSnapshot\LISEZMOI.md`) |
| `tools/UiSnapshot` | Captures de **toutes les fenêtres** (états de la fenêtre principale, dialogues, fenêtres « Aide » et « Compte », menu d'administration et ses pages) avec des données de démonstration, et rapport des problèmes de mise en page : `tools\ui-snapshot.bat` (voir `tools\UiSnapshot\LISEZMOI.md`) |
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
```
@@ -56,16 +57,17 @@ src/GameLauncher/
│ dont les pages « Jeux », « Notes de version » et « Publier une version »),
│ Maintenance.xaml (palette rouge / orange du mode maintenance)
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow,
│ FeedbackWindow (bouton « Aide »)
│ FeedbackWindow (bouton « Aide »), AccountWindow (bouton « Compte »)
│ └─ Admin/ AdminWindow, LoginView, EnrollView (menu d'administration ; ViewModels dans GameLauncher.Modules.Admin)
├─ ViewModels/ LauncherViewModel (jeux, thème actif, paramètres du launcher),
│ GameViewModel (machine à états d'un jeu),
│ LauncherSettingsViewModel / GameSettingsViewModel (les deux fenêtres de paramètres)
├─ Services/ ThemeManager, RegistryService, ShortcutService, InstanceChannel (raccourci → launcher
│ déjà ouvert), GameScopedServices, AdminWindowService (menu d'administration),
│ FeedbackWindowService (bouton « Aide »), interfaces
│ FeedbackWindowService (bouton « Aide »), AccountWindowService (bouton « Compte »), interfaces
└─ Platform/ DialogService, WindowService (implémentations WPF), TrayIcon (zone de notification),
ModuleDialogs, WpfUiDispatcher (services offerts aux modules)
ModuleDialogs, WpfUiDispatcher (services offerts aux modules),
PlayerDeviceKeyStore + DpapiProtector (clé d'appareil et session mémorisée des joueurs)
```
## 3. Publier une version du jeu (ManifestBuilder)
@@ -328,6 +330,7 @@ referme aussitôt. Il se ferme dès qu'il perd le premier plan (clic ailleurs) o
├─ self-update.json mise à jour du launcher (§7)
├─ launcher-state.json dernier jeu affiché (plusieurs jeux)
├─ window-modes.json plein écran ou fenêtré, par fenêtre (« main », « admin »)
├─ account\session.dat session mémorisée du compte joueur (chiffrée DPAPI, §10)
├─ logs\launcher.log journal du launcher (roue crantée › Ouvrir le dossier des journaux)
└─ Games\<GameId>\settings.json paramètres de chaque jeu (menu « … » › Paramètres du jeu)
```
@@ -535,6 +538,7 @@ Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4
**Mes appareils**, **Administrateurs** et **Journal d'audit**. Phase 7 livrée : la **signature du contenu**
servi aux joueurs (page **Signature**, voir plus bas). Ajoutées ensuite : la page **Fichiers du serveur**, puis le
bouton **Aide** des joueurs et la page **Demandes des joueurs**, puis le **mode maintenance** (voir plus bas).
Phase 8 en cours : les **comptes joueurs** (voir §10), dont la page **Comptes joueurs** du menu (étape 8d).
* **Ergonomie commune aux pages** : les boutons d'action (« Recharger », « Publier », « Enregistrer »,
« Actualiser », « Exporter… ») restent affichés en bas de la fenêtre, quelle que soit la position dans la
@@ -750,3 +754,45 @@ préférence sur un **autre VPS** que celui du launcher. Installation, nginx, HT
* Tests : `tests/GameLauncher.Tests/Website/WebsiteTests.cs` (signature, adresses, bornes, Markdown, actualités).
* Captures du launcher (`wwwroot/img/launcher/`) : reprises de `docs/preview*.png` ; à remplacer par des captures
à jour, directement en WebP : `tools\ui-snapshot.bat --filter "main-*" --format webp` (voir `tools\UiSnapshot\LISEZMOI.md`).
## 10. Comptes joueurs (phase 8, en cours)
Comptes **facultatifs** (adresse e-mail + mot de passe), jamais nécessaires pour jouer, distincts des comptes
d'administrateur. Architecture, sécurité et étapes : **`docs/PLAYER-ACCOUNTS-ARCHITECTURE.md`**. Étapes livrées :
8a (serveur), 8b (fenêtre « Compte » du launcher), 8c (double authentification), 8d (page « Comptes joueurs » du menu
d'administration). À venir : blocages (8e), demandes connectées (8f), mise en production (8g), e-mails (8h).
> **Désactivé par défaut** et à n'activer qu'après l'étape 8g : serveur `LauncherApi:Players:Enabled = false`,
> launcher `"playerAccounts": false` dans `launcher.json` (en Release, seule la valeur intégrée à l'exe compte).
* **Bouton « Compte »** (barre de titre, à côté de « Aide ») : connexion, création de compte (règles du mot de passe
vérifiées en direct), changement de mot de passe exigé, puis **« Mon compte »** : profil, appareils connectés,
activité récente (IP masquées), changement de mot de passe, double authentification, export des données (JSON) et
suppression du compte (mot de passe exigé pour ces deux actions).
* **Session mémorisée** (« Rester connecté sur ce PC ») : liée à une **clé d'appareil** (puce TPM sans code PIN,
sinon clé CNG non exportable) ; jeton de renouvellement chiffré DPAPI dans
`%AppData%\<Publisher>\GameLauncher\account\session.dat`. 90 jours sans utilisation, 1 an au maximum.
* **Double authentification** (facultative, « Mon compte » › *Double authentification*) : codes à 6 chiffres
d'une application d'authentification (Microsoft Authenticator, Google Authenticator, Aegis, 2FAS, Bitwarden…).
Activation : mot de passe → QR code (ou clé à saisir) → code de confirmation → **10 codes de secours** affichés
une seule fois (à copier ou enregistrer). À la connexion, le code est demandé après le mot de passe ; un code de
secours le remplace (usage unique). Désactivation et nouveaux codes : mot de passe + code.
* **Sécurité côté serveur** : mots de passe Argon2id + poivre, adresses et secrets OTP **chiffrés** (SQLite
`players.db`, clé maîtresse hors du dossier des données), anti-force brute (blocages progressifs, suspension des
nouveaux appareils), chaque code OTP accepté une seule fois, journal des comptes (12 mois, IP effacées après
6 mois), comptes inactifs mis en sommeil puis supprimés. Règles RGPD / CNIL : `docs/PLAYER-ACCOUNTS-ARCHITECTURE.md` § 10.
* **Page « Comptes joueurs »** du menu d'administration (permissions `players.read`, `players.manage`, `players.delete`) :
compteurs, liste filtrée par état, recherche par référence (`PL-XXXX-XXXX`) ou adresse e-mail **exacte** (jamais affichée
en entier), fiche (état, double authentification, échecs, appareils, activité avec les adresses IP ; chaque consultation
est inscrite au journal d'audit). Actions signées et confirmées : désactiver (durée, motif montré au joueur) / réactiver,
lever une suspension ou un blocage, exiger un nouveau mot de passe, **mot de passe temporaire** (1 h, une seule fois, pour
choisir un nouveau mot de passe : réponse à « mot de passe oublié » en attendant les e-mails), réinitialiser la double
authentification, déconnecter des appareils, supprimer le compte. Le joueur voit ces actions « par l'équipe ».
* Captures : `tools\ui-snapshot.bat --filter "account-*,main-account,admin-page-players-*"`.
| Pour… | Où |
|---|---|
| Préparer le VPS (clé maîtresse, réglages) | `server/deploy/LISEZMOI.md`, étape 3 undecies |
| Réglages (inscriptions, durées, émetteur OTP `OtpIssuer`…) | `server/GameLauncher.Server/appsettings.json` › `LauncherApi:Players` |
| Gérer un compte (désactiver, lever une suspension, mot de passe temporaire, double authentification, appareils, suppression) | Menu d'administration › **Comptes joueurs** |
| Secours sur le VPS : état, déblocage, désactivation / réactivation, double authentification | `GameLauncher.Server players-show` / `players-unlock` / `players-disable` / `players-enable` / `players-otp-reset` `--ref PL-XXXX-XXXX` |
+11 -3
View File
@@ -80,7 +80,8 @@ Dépendances : `Contracts` ne dépend de rien ; `ApiClient` → `Contracts` ; `S
(uniquement : le serveur n'utilise pas `Core`) ; `Core` → `Contracts` (règles du catalogue, phase 3) ;
`Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ; `Modules.Feedback` → `Modules` ;
`GameLauncher` → `Core`, `Modules`, `Modules.Admin`, `Modules.Feedback`.
Aucun paquet NuGet n'est nécessaire en dehors du projet de tests (xUnit).
Paquets NuGet : xUnit (projet de tests) ; depuis la phase 8, le serveur utilise aussi `Microsoft.Data.Sqlite` (Microsoft)
et `Konscious.Security.Cryptography.Argon2` (licence MIT) pour les comptes joueurs (`docs/PLAYER-ACCOUNTS-ARCHITECTURE.md`).
## 5. Modules
@@ -326,6 +327,9 @@ premier démarrage ou à la migration d'`admins.json` (format 1 → 2) avec les
| `feedback.manage` | ✔ | ✔ | | Demandes des joueurs : statut, note interne, suppression |
| `feedback.forms` | ✔ | ✔ | | Formulaires des demandes : modification et publication signée — § 11 sexies |
| `maintenance.manage` | ✔ | ✔ | | Mode maintenance du launcher : activation, désactivation, message (publication signée) — § 11 septies |
| `players.read` | ✔ | ✔ | | Comptes joueurs : liste, recherche, fiche (adresse masquée, appareils, activité avec IP ; consultations inscrites au journal) — `PLAYER-ACCOUNTS-ARCHITECTURE.md` § 3.7 |
| `players.manage` | ✔ | ✔ | | Comptes joueurs : désactiver / réactiver, lever une suspension, nouveau mot de passe exigé, mot de passe temporaire, réinitialiser l'OTP, déconnecter des appareils (requêtes signées) |
| `players.delete` | ✔ | | | Comptes joueurs : suppression définitive (requête signée) |
Une permission intégrée ajoutée après l'installation (ex. `feedback.*`) est donnée **automatiquement** au groupe
Propriétaire seulement : les groupes existants ne changent pas d'eux-mêmes (aucune élévation de droits sans l'action
@@ -1056,6 +1060,10 @@ 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 | ✅ |
| **8b** | Comptes joueurs — launcher : bouton « Compte », connexion / création de compte / nouveau mot de passe exigé, session mémorisée (clé d'appareil dans la puce TPM ou clé CNG non exportable, jeton chiffré DPAPI), « Mon compte » (profil, appareils, activité, export JSON, suppression) ; serveur : activité, export, suppression, `IPersonalDataProvider`, comptes inactifs (sommeil après 24 mois, suppression 12 mois plus tard). Bouton absent tant que `playerAccounts` vaut `false` dans le launcher.json intégré | ✅ |
| **8c** | Comptes joueurs — double authentification facultative : TOTP (6 chiffres, 30 s, toutes applications d'authentification), 10 codes de secours (empreintes HMAC), connexion en deux étapes (jeton `glpo_`, 5 essais, rien n'est révélé avant le code), activation par QR code (encodeur maison, aucune dépendance) depuis « Mon compte », désactivation et nouveaux codes (mot de passe + code), commande de secours `players-otp-reset` | ✅ |
| **8d** | Comptes joueurs — page « Comptes joueurs » du menu (module serveur `PlayersAdmin`, permissions `players.read` / `players.manage` / `players.delete`) : compteurs, liste filtrée, recherche par référence ou adresse **exacte** (corps de requête, jamais dans l'adresse ni l'audit), fiche (adresse masquée, appareils, activité avec IP, chaque consultation journalisée) ; désactivation (durée, motif montré au joueur, sans lien), réactivation, levée d'une suspension ou d'un blocage, nouveau mot de passe exigé, **mot de passe temporaire** (60 min, une seule fois, n'ouvre pas de session, ne remplace pas l'ancien ; empreinte HMAC, schéma v3), réinitialisation de l'OTP, déconnexion d'appareils, suppression ; catégorie « Comptes joueurs » du journal d'audit | ✅ |
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 +1072,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 |
+394
View File
@@ -0,0 +1,394 @@
# 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, § 3.7), 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 et par compte Windows** (`Platform\PlayerDeviceKeyStore.cs`) : dans la puce
TPM 2.0 (« Microsoft Platform Crypto Provider ») si elle existe, **sans code PIN**, jamais exportable ; sinon clé logicielle
du « Microsoft Software Key Storage Provider », marquée **non exportable** et protégée par Windows. Nom de clé propre aux
joueurs (`GameLauncher.PlayerDevice.<éditeur><dossier>.v1`), distinct des clés d'administrateur. 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 |
| `GET v1/account/activity` | session | 50 derniers événements du compte : type, résultat, appareil s'il est toujours connecté, IP **masquée** (`203.0.113.x`, IPv6 en /48), aucun détail interne |
| `POST v1/account/export` | session + **mot de passe** | Export JSON de toutes ses données (`GameLauncher/PlayerData/v1`, § 10) |
| `POST v1/account/delete` | session + **mot de passe** | Suppression définitive du compte (§ 10) |
| `POST v1/account/login/otp` | jeton « code de vérification » (`glpo_`) | Connexion, 2ᵉ étape : code TOTP ou code de secours (§ 3.6) |
| `POST v1/account/otp/setup` | session + **mot de passe** | Double authentification : secret et adresse `otpauth://` (renvoyés une fois) |
| `POST v1/account/otp/enable` | session | Activation par un premier code ; renvoie 10 codes de secours |
| `POST v1/account/otp/disable` | session + **mot de passe + code** | Désactivation |
| `POST v1/account/otp/recovery-codes` | session + **mot de passe + code** | Nouveaux codes de secours (les anciens sont annulés) |
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, le changement de mot de passe, l'export et la suppression, 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 (`account\session.dat`, chiffré DPAPI) si « Rester connecté » ; serveur : empreinte en base |
| Changement exigé | `glpc_` + 256 bits | 10 min, usage unique | mémoire |
| Code de vérification | `glpo_` + 256 bits | 5 min, 5 essais, usage unique | mémoire (2ᵉ étape de la connexion, § 3.6) |
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.
### 3.5 Launcher (étape 8b)
* **Bouton « Compte »** dans la barre de titre (icône de compte, pastille verte = connecté, orange = reconnexion ou
serveur injoignable), présent seulement si `"playerAccounts": true` **et** `apiUrl` sont dans le `launcher.json`
**intégré** (Release) ; masqué pendant une maintenance. Le launcher ne contacte l'API des comptes que si une session est
mémorisée (reprise au démarrage, sans bloquer l'affichage) ou quand le joueur ouvre la fenêtre.
* **Fenêtre « Compte »** (`Views\AccountWindow.xaml`, ViewModel `AccountWindowViewModel` du projet
`GameLauncher.Modules.Accounts`, sans WPF, testé) : connexion (« Rester connecté sur ce PC », aide « mot de passe
oublié »), création de compte (règles du mot de passe vérifiées **en direct** avec `PasswordRules`, confirmation,
acceptation des conditions), nouveau mot de passe exigé, puis **Mon compte** : Profil (adresse masquée, référence à
copier, dates, changement de mot de passe), Appareils (déconnecter un appareil, tous les appareils), Activité (adresses
IP masquées, essais refusés signalés), Mes données (export JSON, suppression définitive : case à cocher + confirmation
+ mot de passe).
* **Mots de passe** : jamais liés au ViewModel ; lus dans les `PasswordBox` au moment de l'action, puis effacés. Seul le
mot de passe tapé à la connexion est gardé en mémoire quelques minutes quand un administrateur exige d'en changer (le
serveur le redemande).
* **Session** (`PlayerAccountService`) : jeton d'accès en mémoire, renouvelé une minute avant son échéance ou aussitôt
après un redémarrage du serveur (un seul renouvellement à la fois) ; jeton de renouvellement enregistré **chiffré DPAPI**
(`Platform\DpapiProtector.cs`, utilisateur Windows, entropie `GameLauncher/Player/Session/v1`, écriture atomique) et
remplacé sur le disque à chaque renouvellement. Session ignorée si elle vient d'un autre serveur ou d'une autre clé
d'appareil ; illisible (autre utilisateur, autre PC) = supprimée. Sans « Rester connecté », rien n'est écrit et la
session est fermée sur le serveur à la sortie du launcher (3 s au plus). Session refusée au renouvellement (expirée,
déconnectée ailleurs, compte désactivé) : effacée, raison affichée à l'ouverture de la fenêtre ; serveur injoignable :
conservée (« hors ligne »), nouvel essai à la prochaine action.
* **Nom de l'appareil** envoyé au serveur : générique (« Windows 11 · GameLauncher 3.5.0 »), jamais le nom Windows du PC.
* Captures : `tools\ui-snapshot.bat --filter "account-*,main-account"`.
### 3.6 Double authentification (étape 8c)
* **Facultative**, activée par le joueur depuis « Mon compte › Profil ». **TOTP** (RFC 6238 : HMAC-SHA1, 6 chiffres,
30 s), compatible avec toutes les applications d'authentification (Microsoft Authenticator, Google Authenticator,
Aegis, 2FAS, Bitwarden…). Secret aléatoire de **160 bits**.
* **Activation en deux temps** : `otp/setup` (mot de passe exigé) génère le secret, le garde **chiffré** comme « en cours »
(10 min) et le renvoie **une seule fois** avec l'adresse `otpauth://totp/LanisysGames:j***@e***.fr?…` (adresse
masquée, émetteur `Players:OtpIssuer`) ; le launcher en dessine le **QR code** avec son propre encodeur
(`QrCode`, aucune dépendance, vérifié contre un décodeur indépendant) et affiche la clé à saisir. `otp/enable` exige un
premier code (preuve que l'application est bien configurée), active la double authentification et renvoie **10 codes
de secours** (`7KQ2-M9XD`, 40 bits, alphabet sans lettres ambiguës), affichés une seule fois : le launcher propose de les
enregistrer dans un fichier ou de les copier, et n'autorise « Terminer » qu'une fois la case « mis en lieu sûr » cochée.
* **Connexion** : mot de passe correct → `otp_required` + jeton `glpo_` (5 min, 5 essais) ; **rien d'autre** n'est
révélé ni modifié avant le code (état du compte, réveil d'un compte en sommeil, remise à zéro des compteurs). Puis
`login/otp` avec le code de l'application ou un code de secours → session (ou nouveau mot de passe exigé). La session
mémorisée se renouvelle **sans code** (l'appareil prouve sa clé) ; le code n'est demandé qu'avec le mot de passe.
* **Codes à usage unique** : pas de temps courant ± 1 (téléphone décalé de 30 s), comparaison à durée constante, et un
pas de temps déjà accepté est refusé (`totp_last_step`, mis à jour atomiquement) : un code intercepté ne sert pas deux
fois. Code de secours : consommé (une seule utilisation), nombre restant renvoyé et affiché (alerte à 3 ou moins).
* **Force brute** : un code faux compte comme un **échec de connexion** (mêmes compteurs, blocages et suspension que les
mots de passe, appareils de confiance à part) ; 5 essais au plus par jeton, puis reprise complète (preuve de travail,
mot de passe, Argon2id). Désactivation et nouveaux codes : mot de passe **et** code exigés ; un code faux depuis une
session ouverte compte comme un échec « appareil de confiance » (blocage puis fermeture des sessions).
* **Stockage** : secret chiffré AES-256-GCM (lié au compte, champ `totp`), codes de secours sous forme d'**empreinte
HMAC-SHA256** à clé secrète dérivée de la clé maîtresse (40 bits : sans la clé, impossible de les retrouver à partir
d'une copie de la base). Ni le secret ni les codes ne sont exportés (§ 10).
* **Téléphone et codes perdus** : page « Comptes joueurs » du menu › « Réinitialiser la double authentification » (§ 3.7)
ou `players-otp-reset --ref …` (§ 8), après vérification de l'identité par l'équipe.
### 3.7 Menu d'administration « Comptes joueurs » (étape 8d)
Module serveur `PlayersAdmin` (routes `v1/players/…`, schéma `AdminSession` explicite : aucun jeton joueur accepté,
vérifié par `PlayerSeparationTests`), page **« Comptes joueurs »** du menu (module `PlayersAdminModule`, ViewModel
`PlayersPanelViewModel`, sans WPF, testé). Trois permissions intégrées : `players.read`, `players.manage`,
`players.delete` (Propriétaire d'office ; Administrateur : lecture et gestion sur une installation neuve ; groupes existants
inchangés, à compléter depuis « Groupes et permissions »).
| Route | Permission | Signée | Rôle |
|---|---|:-:|---|
| `GET v1/players/overview` | `players.read` | | Compteurs (total, actifs, désactivés, suspendus, bloqués, en sommeil, nouveau mot de passe exigé, OTP, créés en 30 jours) et durées de conservation ; `enabled: false` si le service est désactivé (le reste répond 503) |
| `GET v1/players?filter=&cursor=&limit=` | `players.read` | | Liste, les plus récents d'abord (curseur `création:identifiant`), filtres `active`, `disabled`, `suspended`, `locked`, `inactive`, `password`, `otp` |
| `POST v1/players/search` | `players.read` | | Recherche par référence ou par adresse **exacte** (index aveugle) : dans le **corps** de la requête, jamais dans l'adresse (journaux nginx) ; inscrite à l'audit **sans** l'adresse |
| `GET v1/players/{référence}` | `players.read` | | Fiche : état, motif, auteur du changement, mot de passe, mot de passe temporaire en attente, OTP et codes restants, échecs et blocages, appareils, 100 dernières lignes d'activité avec l'IP **complète** |
| `POST v1/players/{référence}/disable` | `players.manage` | ✔ | Désactivation jusqu'à une date (10 ans au plus) ou indéfiniment, **motif facultatif montré au joueur** (une ligne, 200 caractères, aucun lien : `PlayerAdminRules.CleanReason`), chiffré (champ `status-reason`) ; sessions fermées |
| `POST v1/players/{référence}/enable` | `players.manage` | ✔ | Réactivation (désactivé ou en sommeil) |
| `POST v1/players/{référence}/unlock` | `players.manage` | ✔ | Levée d'une suspension (compte réactivé) ou des blocages temporaires (compteurs remis à zéro) |
| `POST v1/players/{référence}/password-requirement` | `players.manage` | ✔ | Nouveau mot de passe exigé (sessions ouvertes fermées, reconnexion automatique refusée d'ici là) ou plus exigé |
| `POST v1/players/{référence}/temporary-password` | `players.manage` | ✔ | Mot de passe temporaire (ci-dessous), renvoyé **une seule fois** |
| `POST v1/players/{référence}/otp-reset` | `players.manage` | ✔ | Double authentification et codes de secours supprimés |
| `POST v1/players/{référence}/logout` | `players.manage` | ✔ | Déconnexion d'un appareil (`deviceId`) ou de tous |
| `DELETE v1/players/{référence}` | `players.delete` | ✔ | Suppression définitive (mêmes effets que la suppression par le joueur, § 10) ; audit `players.delete` |
* **Adresse jamais affichée en entier** dans le menu (forme masquée seulement) ; la recherche par adresse exacte suffit
pour retrouver le compte d'un joueur qui écrit au support.
* **Consultations journalisées** : `players.view` (liste par filtre, fiche ; une fois par compte et par clé toutes les
10 minutes), `players.search` (référence ou adresse, trouvé ou non). Actions : `players.disable`, `players.enable`,
`players.unlock`, `players.password_required`, `players.temporary_password` (jamais le mot de passe), `players.otp_reset`,
`players.logout`, `players.delete` — catégorie **« Comptes joueurs »** de la page « Journal d'audit » (commandes
`cli.players_*` comprises).
* **Vu du joueur** (D8) : chaque action est inscrite dans son activité « par l'équipe » (`account.disabled`,
`account.unlocked`, `account.password_reset_required`, `account.temporary_password`, `device.removed_by_team`…), jamais avec
le nom de l'administrateur (seul le journal d'audit le garde, comme l'auteur du dernier changement d'état). Désactivé, il
voit à la connexion la date de fin et le motif.
* **Mot de passe temporaire** (D6) : 80 bits aléatoires, affiché `K7Q2-M9XD-4TPA-W3HN` (alphabet sans lettre ambiguë ;
casse, espaces et tirets tolérés à la saisie), **60 minutes**, un seul à la fois (un nouveau annule le précédent). Il est
accepté à la connexion **à la place** du mot de passe, mais n'ouvre jamais de session : la connexion renvoie le jeton
« changement exigé » (`temporaryPassword: true` ; code de vérification d'abord si la double authentification est
activée), puis le changement de mot de passe le **redemande** (l'ancien mot de passe n'y est pas accepté) et l'efface.
L'ancien mot de passe reste valable tant que le nouveau n'est pas choisi ; tout changement de mot de passe efface un
mot de passe temporaire en attente. Stockage : empreinte **HMAC-SHA256** à clé dérivée de la clé maîtresse
(`temporary-password/v1`, liée au compte), échéance ; empreintes échues effacées chaque jour (`RetentionService`).
Sa création lève les blocages temporaires (identité vérifiée par l'équipe) mais pas une suspension. Refusé pour un
compte désactivé. Le launcher adapte l'écran « nouveau mot de passe » et l'aide « mot de passe oublié ».
* Toutes les actions demandent une **confirmation** dans le menu ; la page affiche le mot de passe temporaire une seule
fois (copier, puis « Transmis, masquer » ; oublié dès qu'un autre compte est affiché).
* Captures : `tools\ui-snapshot.bat --filter "admin-page-players-*"`.
## 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, ou page « Comptes joueurs » du menu, § 3.7). 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 3 : double authentification en 2 (étape 8c), mot de passe temporaire en 3 (étape 8d)). 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é », secret OTP chiffré et dernier pas de temps accepté, secret en cours d'activation, état, échéance de désactivation, motif chiffré, auteur du changement d'état, empreinte et échéance du mot de passe temporaire,
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) ; `recovery_codes` (empreintes HMAC des codes
de secours, date d'utilisation) ; `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 dont le secret OTP et le motif de désactivation, empreintes des codes de secours et des mots de passe temporaires). 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`,
`players.self_delete` (compte supprimé par son titulaire), `players.inactive` (bilan quotidien des comptes inactifs),
`players.inactive_delete` (compte inactif supprimé) ; menu d'administration (8d) : `players.view`, `players.search`,
`players.disable`, `players.enable`, `players.unlock`, `players.password_required`, `players.temporary_password`,
`players.otp_reset`, `players.logout`, `players.delete` (§ 3.7).
* Événements de la double authentification (8c) : `account.otp_challenge` (mot de passe correct, code demandé : visible du
joueur dans « Activité », il signale un mot de passe connu d'un tiers), `account.otp_failed`, `account.otp_enabled`,
`account.otp_disabled`, `account.recovery_codes`, `account.recovery_code_used`, `account.otp_reset` ; audit
`cli.players_otp_reset`.
* Nouveaux événements du journal des comptes (8b) : `account.export`, `account.delete` (essai refusé), `account.inactive`,
`account.reactivated`. Libellés français communs au launcher et au menu : `PlayerActivityText` (Contracts).
* Événements de l'étape 8d : `account.password_reset_required`, `account.password_reset_cancelled`,
`account.temporary_password`, `account.temporary_password_used`, `device.removed_by_team` (actions « par l'équipe »).
Libellés et catégorie « Comptes joueurs » de la page « Journal d'audit » : `AuditTexts`.
* **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 |
| `players-otp-reset --ref …` | Désactive la double authentification (téléphone et codes de secours perdus, identité vérifiée au préalable) |
## 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 |
| `OtpIssuer` | `LanisysGames` | Nom affiché dans les applications d'authentification (sans « : ») |
| `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 |
| `InactiveDisableMonths`, `InactiveDeleteMonths` | 24, 12 | Comptes inactifs : mis en sommeil, puis supprimés (0 : désactivé) |
## 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.
* **Droits d'accès et de portabilité** (étape 8b) : « Mon compte › Mes données › Exporter » (`POST v1/account/export`,
mot de passe exigé car le document contient l'adresse complète) : compte (adresse en clair, dates, état et motif d'une
désactivation, nouveau mot de passe exigé, conditions acceptées), appareils (nom, identifiant de clé, dates), activité (1000 lignes au plus, avec IP et détails), sections des
autres modules, notes. Jamais l'empreinte du mot de passe, les jetons ni les clés publiques complètes. Export inscrit au
journal du compte.
* **Droit à l'effacement** (étape 8b) : « Supprimer mon compte » (`POST v1/account/delete`, mot de passe exigé) :
données des autres modules d'abord (`IPersonalDataProvider.Erase`), puis compte, appareils et journal du compte
(cascade SQLite, `secure_delete`), sessions fermées. Seule trace : une ligne `players.self_delete` du journal d'audit
(référence, IP, 12 mois). L'adresse redevient libre. Le jeu et ses sauvegardes sur le PC ne sont pas touchés.
* **`IPersonalDataProvider`** (serveur, `Modules\Accounts\PlayerPersonalData.cs`) : chaque futur module qui garde des
données liées à un compte (demandes connectées 8f, amis…) déclare son export et son effacement ; l'export et la
suppression du compte les couvrent sans modifier le module des comptes.
* **Comptes inactifs** (CNIL) : un compte **actif** sans aucune activité (connexion, renouvellement, création) depuis
`InactiveDisableMonths` (24 mois) est **mis en sommeil** (`inactive`, appareils déconnectés) ; une connexion avec le bon
mot de passe le **réveille**. Après `InactiveDeleteMonths` (12 mois) de sommeil, il est **supprimé** comme ci-dessus
(`players.inactive_delete`). Appliqué au démarrage puis chaque jour (`RetentionService`, ligne `players.inactive`). Les
comptes désactivés ou suspendus par un administrateur ne sont pas concernés. Le joueur ne peut pas encore être prévenu
avant l'échéance (module e-mail, étape 8h).
* É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 (page « Comptes joueurs »).
* 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, fenêtre de connexion / création / nouveau mot de passe exigé, session mémorisée (clé d'appareil TPM ou CNG non exportable, jeton chiffré DPAPI), « Mon compte » (profil, appareils, activité, export, suppression) ; serveur : activité, export, suppression, `IPersonalDataProvider`, comptes inactifs ; captures UiSnapshot ; tests | ✅ |
| **8c** | Double authentification : TOTP (RFC 6238), codes de secours, connexion en deux étapes, activation / désactivation / nouveaux codes depuis « Mon compte », encodeur de QR codes maison, `players-otp-reset`, schéma v2, UiSnapshot, tests | ✅ |
| **8d** | Menu d'administration « Comptes joueurs » (§ 3.7) : compteurs, liste, recherche par référence ou adresse exacte, fiche (activité avec IP), désactivation avec motif, réactivation, levée d'une suspension, nouveau mot de passe exigé, mot de passe temporaire (schéma v3), réinitialisation de l'OTP, appareils, suppression ; permissions `players.*` ; journal d'audit ; UiSnapshot ; tests | ✅ |
| 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.
+15 -2
View File
@@ -19,7 +19,8 @@ public static class AdminCli
private static readonly string[] Commands = { "add-owner", "add-key", "list", "revoke-admin", "revoke-key", "server-id", "help" };
public static bool IsCommand(string[] args) =>
args.Length > 0 && Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase);
args.Length > 0 && (Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase) ||
PlayersCli.Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase));
public static int Run(string[] args, TextWriter? output = null, string? dataDirectoryOverride = null)
{
@@ -29,6 +30,9 @@ public static class AdminCli
var command = args[0].ToLowerInvariant();
if (command == "help") return Help(o);
var options = ReadOptions(args);
// Clé maîtresse des comptes joueurs : n'utilise pas le dossier des données (peut être lancée par root, la sortie
// étant directement chiffrée par systemd-creds).
if (command == "players-key") return PlayersCli.Key(o, interactive: output is null);
// Lancé par erreur avec « sudo » (root) au lieu de « sudo -u deploy » : les fichiers recréés
// appartiendraient à root et le service ne pourrait plus les lire.
@@ -37,7 +41,7 @@ public static class AdminCli
"ne lancez pas cet outil en root : sudo -u deploy /opt/launcher-api/GameLauncher.Server " + command + " …");
var dataDirectory = dataDirectoryOverride ?? ResolveDataDirectory(options);
if (command != "add-owner" && command != "server-id" &&
if (command != "add-owner" && command != "server-id" && !command.StartsWith("players-", StringComparison.Ordinal) &&
!File.Exists(Path.Combine(dataDirectory, AdminStore.FileName)))
throw new InvalidOperationException(
$"aucun fichier {AdminStore.FileName} dans {dataDirectory} : mauvais dossier de données ? (option --data-dir)");
@@ -57,6 +61,12 @@ 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":
case "players-otp-reset":
return PlayersCli.Run(command, options, dataDirectory, audit, actor, o);
default: return Help(o);
}
}
@@ -219,6 +229,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,130 @@
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 (page « Comptes joueurs », étape 8d).
/// </summary>
internal static class PlayersCli
{
public static readonly string[] Commands =
{ "players-key", "players-show", "players-unlock", "players-disable", "players-enable", "players-otp-reset" };
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é.
players-otp-reset --ref <PL-XXXX-XXXX> Désactive la double authentification (téléphone et codes de secours perdus ;
identité vérifiée au préalable par l'équipe).
""";
/// <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;
case "players-otp-reset":
if (!account.OtpEnabled)
{
o.WriteLine($"Compte {reference} : double authentification déjà désactivée.");
return 0;
}
store.DisableOtp(account.Id);
store.AddEvent(account.Id, PlayerEvents.OtpReset, AuditResults.Ok, null,
details: $"par {actor} (outil en ligne de commande)");
audit.Write("cli.players_otp_reset", AuditResults.Ok, actorName: actor, target: reference,
details: "double authentification désactivée, codes de secours supprimés");
o.WriteLine($"Compte {reference} : double authentification désactivée (codes de secours supprimés). Le joueur se connecte " +
"avec son mot de passe et peut la réactiver depuis « Mon compte ».");
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)}" : "")}" +
(account.StatusChangedBy is { } by ? $" (par {by}, le {Date(account.StatusChangedAt)})" : ""));
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($" Double auth. : {(account.OtpEnabled ? $"activée ({store.CountRecoveryCodes(account.Id)} code(s) de secours)" : "désactivée")}");
if (account.TemporaryPasswordExpiresAt is { } temporary && account.TemporaryPasswordHash is not null)
o.WriteLine($" Mot de passe temp. : en attente, valable jusqu'au {Date(temporary)}");
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,9 @@ 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(),
new Modules.Accounts.PlayersAdminModule()
};
}
@@ -51,4 +53,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,88 @@ 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>Nom affiché dans les applications d'authentification (double authentification), sans « : ».</summary>
public string OtpIssuer { get; set; } = "LanisysGames";
/// <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>
/// Comptes inactifs (CNIL) : mis en sommeil après N mois sans aucune activité (appareils déconnectés ; une connexion
/// avec le bon mot de passe le réveille), puis supprimés définitivement après M mois de sommeil. 0 : désactivé.
/// </summary>
public int InactiveDisableMonths { get; set; } = 24;
public int InactiveDeleteMonths { get; set; } = 12;
}
/// <summary>Un jeu publié sur ce serveur.</summary>
public sealed class GameSettings
{
@@ -264,6 +349,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 +386,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 +401,73 @@ 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)");
// Au moins un an (au-delà de la durée maximale d'une session mémorisée) : un joueur régulier n'est jamais mis en sommeil.
Require(players.InactiveDisableMonths == 0 || players.InactiveDisableMonths is >= 12 and <= 120,
"Players:InactiveDisableMonths (0, ou 12 à 120)");
Require(players.InactiveDeleteMonths is >= 0 and <= 120, "Players:InactiveDeleteMonths (0 à 120)");
if (Contracts.ApiText.CleanLine(players.TermsVersion, 32) is null)
throw new InvalidOperationException($"{Section}:Players:TermsVersion : une version (ex. « 2026-11 ») est obligatoire.");
if (Contracts.ApiText.CleanLine(players.OtpIssuer, 64) is not { } issuer || issuer != players.OtpIssuer || issuer.Contains(':'))
throw new InvalidOperationException($"{Section}:Players:OtpIssuer : nom court sans « : » (ex. « LanisysGames »).");
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,11 @@ 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) ;
/// • comptes joueurs inactifs : mis en sommeil après <c>Players:InactiveDisableMonths</c> (24) sans activité, supprimés
/// après <c>Players:InactiveDeleteMonths</c> (12) de sommeil (« players.inactive »).
/// 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 +26,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 +82,35 @@ 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);
players.ClearExpiredTemporaryPasswords(_time.GetUtcNow()); // empreintes inutiles une fois échues
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");
}
try
{
var (dormant, deleted) = _services.GetRequiredService<Modules.Accounts.PlayerPersonalData>().ApplyInactivity(_time.GetUtcNow());
if (dormant > 0 || deleted > 0)
_audit.Write("players.inactive", AuditResults.Ok,
details: $"comptes joueurs inactifs : {dormant} mis en sommeil (aucune activité depuis {_options.Players.InactiveDisableMonths} mois), " +
$"{deleted} supprimé(s) (en sommeil depuis {_options.Players.InactiveDeleteMonths} mois)");
}
catch (Exception ex)
{
_logger.LogError(ex, "Politique des comptes joueurs inactifs 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;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,186 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
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>
/// Données d'un joueur détenues par un module (RGPD : droits d'accès, de portabilité et d'effacement). Chaque module qui
/// conserve des données liées à un compte joueur (demandes connectées et échanges à l'étape 8f, amis, sauvegardes…) déclare
/// une implémentation (<c>services.AddSingleton&lt;IPersonalDataProvider, …&gt;()</c>) : l'export et la suppression du compte
/// la couvrent alors automatiquement, sans modifier le module des comptes.
/// </summary>
public interface IPersonalDataProvider
{
/// <summary>Nom de la section dans l'export (« feedback »…), en minuscules.</summary>
string Name { get; }
/// <summary>Données du joueur, lisibles par machine (null : rien pour ce compte).</summary>
JsonNode? Export(PlayerAccountRecord account);
/// <summary>Efface (ou détache du compte) les données du joueur. Appelé AVANT la suppression du compte lui-même.</summary>
void Erase(PlayerAccountRecord account);
}
/// <summary>
/// Export et effacement des données d'un compte joueur, à la demande du joueur ou par la politique des comptes inactifs
/// (CNIL : mise en sommeil après <c>Players:InactiveDisableMonths</c> sans activité, suppression après
/// <c>Players:InactiveDeleteMonths</c> de sommeil). L'export contient TOUT ce que le serveur sait du compte, y compris
/// l'adresse e-mail complète ; jamais l'empreinte du mot de passe ni les secrets des sessions.
/// </summary>
public sealed class PlayerPersonalData
{
private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true };
private readonly PlayerStore _store;
private readonly PlayerSecrets _secrets;
private readonly PlayerSessionStore _sessions;
private readonly IReadOnlyList<IPersonalDataProvider> _providers;
private readonly AuditLog _audit;
private readonly LauncherApiOptions.PlayersSettings _settings;
public PlayerPersonalData(PlayerStore store, PlayerSecrets secrets, PlayerSessionStore sessions, IEnumerable<IPersonalDataProvider> providers,
AuditLog audit, IOptions<LauncherApiOptions> options)
{
_store = store;
_secrets = secrets;
_sessions = sessions;
_providers = providers.ToList();
_audit = audit;
_settings = options.Value.Players;
var duplicate = _providers.GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase).FirstOrDefault(g => g.Count() > 1);
if (duplicate is not null) throw new InvalidOperationException($"Données personnelles : section « {duplicate.Key} » déclarée deux fois.");
}
/// <summary>Document d'export (JSON indenté, UTF-8).</summary>
public byte[] Export(PlayerAccountRecord account, string origin, DateTimeOffset now)
{
var devices = _store.Devices(account.Id);
var document = new JsonObject
{
["format"] = PlayerDataExport.Format,
["exportedAt"] = now,
["service"] = origin,
["account"] = new JsonObject
{
["reference"] = account.Reference,
["email"] = _secrets.DecryptEmail(account.Id, account.EmailEncrypted),
["emailVerified"] = account.EmailVerified,
["createdAt"] = account.CreatedAt,
["lastLoginAt"] = account.LastLoginAt,
["passwordChangedAt"] = account.PasswordChangedAt,
["termsVersionAccepted"] = account.TermsVersion,
["status"] = account.Status,
["disabledUntil"] = account.DisabledUntil,
["statusReason"] = _secrets.TryDecryptStatusReason(account.Id, account.StatusReasonEncrypted),
["newPasswordRequired"] = account.MustChangePassword,
["twoFactorEnabled"] = account.OtpEnabled,
["recoveryCodesLeft"] = account.OtpEnabled ? _store.CountRecoveryCodes(account.Id) : 0
},
["devices"] = new JsonArray(devices.Select(d => (JsonNode)new JsonObject
{
["label"] = d.Label,
["deviceKeyId"] = d.KeyId,
["connectedAt"] = d.CreatedAt,
["lastUsedAt"] = d.LastUsedAt,
["rememberedUntil"] = d.RefreshExpiresAt
}).ToArray()),
["activity"] = new JsonArray(_store.Events(account.Id, 1000).Select(e => (JsonNode)new JsonObject
{
["at"] = e.At,
["type"] = e.Type,
["description"] = PlayerActivityText.Describe(e.Type, e.Result == AuditResults.Ok),
["result"] = e.Result,
["ip"] = e.Ip,
["deviceKeyId"] = e.DeviceKeyId,
["details"] = e.Details
}).ToArray())
};
var modules = new JsonObject();
foreach (var provider in _providers)
{
if (provider.Export(account) is { } data) modules[provider.Name] = data;
}
document["modules"] = modules;
document["notes"] = new JsonArray(
"Le mot de passe n'est jamais conservé : seule une empreinte irréversible existe sur le serveur, elle n'est pas exportée.",
$"Activité : {_settings.EventsRetentionMonths} mois au plus ; adresses IP effacées au bout de {_settings.EventsIpRetentionMonths} mois.",
"Les clés d'appareil privées ne quittent jamais votre PC : seul leur identifiant public figure ici.",
"Le secret de la double authentification et les codes de secours ne sont jamais exportés (le serveur ne garde " +
"qu'une empreinte des codes de secours).");
return JsonSerializer.SerializeToUtf8Bytes(document, Indented);
}
/// <summary>
/// Supprime DÉFINITIVEMENT le compte : données des modules, appareils, journal du compte, sessions ouvertes. Une ligne
/// du journal d'audit (référence seulement) garde la trace de la suppression.
/// </summary>
/// <param name="byPlayer">Vrai : à la demande du joueur ; faux : politique des comptes inactifs.</param>
public bool Erase(PlayerAccountRecord account, bool byPlayer, string? ip)
{
if (!EraseCore(account)) return false;
_audit.Write(byPlayer ? "players.self_delete" : "players.inactive_delete", AuditResults.Ok,
actorName: byPlayer ? "joueur" : "système", ip: ip, target: account.Reference,
details: byPlayer
? "compte joueur supprimé définitivement à la demande de son titulaire"
: $"compte joueur supprimé définitivement après {_settings.InactiveDeleteMonths} mois de sommeil (inactivité)");
return true;
}
/// <summary>
/// Suppression définitive décidée par un administrateur (menu d'administration, permission players.delete), inscrite au
/// journal d'audit avec son auteur. Mêmes effets que la suppression par le joueur.
/// </summary>
public bool EraseByAdmin(PlayerAccountRecord account, AuthenticatedAdmin actor, string? ip, string details)
{
if (!EraseCore(account)) return false;
_audit.Write("players.delete", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, ip, account.Reference, details);
return true;
}
/// <summary>Données des modules, sessions, puis compte (appareils, journal et codes en cascade). Faux si le compte n'existe plus.</summary>
private bool EraseCore(PlayerAccountRecord account)
{
foreach (var provider in _providers) provider.Erase(account);
_sessions.RemoveWhere(s => s.AccountId == account.Id);
if (!_store.DeleteAccount(account.Id)) return false;
_sessions.RemoveWhere(s => s.AccountId == account.Id); // session ouverte pendant la suppression
return true;
}
/// <summary>
/// Politique des comptes inactifs (au démarrage puis chaque jour) : mise en sommeil des comptes actifs sans activité
/// depuis <c>InactiveDisableMonths</c>, suppression de ceux en sommeil depuis <c>InactiveDeleteMonths</c>.
/// Renvoie (mis en sommeil, supprimés).
/// </summary>
public (int Dormant, int Deleted) ApplyInactivity(DateTimeOffset now)
{
if (_settings.InactiveDisableMonths <= 0) return (0, 0);
var dormant = 0;
foreach (var account in _store.FindInactive(now.AddMonths(-_settings.InactiveDisableMonths)))
{
if (!_store.MarkInactive(account.Id, now)) continue;
_sessions.RemoveWhere(s => s.AccountId == account.Id);
_store.AddEvent(account.Id, PlayerEvents.Inactive, AuditResults.Ok, null, null,
$"aucune activité depuis {_settings.InactiveDisableMonths} mois : appareils déconnectés");
dormant++;
}
var deleted = 0;
if (_settings.InactiveDeleteMonths > 0)
{
foreach (var account in _store.FindDormantSince(now.AddMonths(-_settings.InactiveDeleteMonths)))
{
if (Erase(account, byPlayer: false, ip: null)) deleted++;
}
}
return (dormant, deleted);
}
}
@@ -0,0 +1,459 @@
using System.Globalization;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Modules.Feedback;
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 vus par les ADMINISTRATEURS (étape 8d, page « Comptes joueurs » du menu d'administration).
/// • Lecture (players.read) : compteurs, liste filtrée, recherche par référence ou adresse e-mail EXACTE (POST : l'adresse
/// n'apparaît jamais dans une adresse de page ni dans les journaux du serveur web ; elle n'est pas inscrite à l'audit), fiche
/// (adresse masquée, appareils, activité avec adresses IP). Chaque consultation est inscrite au journal d'audit (une fois
/// par compte et par clé toutes les 10 minutes), sans l'adresse.
/// • Gestion (players.manage, requêtes signées) : désactiver (durée, motif montré au joueur) / réactiver, lever une suspension
/// ou un blocage, exiger un nouveau mot de passe, mot de passe temporaire (D6 : 60 min, une seule fois, ne remplace pas
/// l'ancien), réinitialiser la double authentification, déconnecter un appareil ou tous.
/// • Suppression définitive (players.delete, requête signée).
/// Chaque action est inscrite au journal d'audit (administrateur, référence) et au journal du compte, où le joueur la voit
/// comme venant de « l'équipe » (D8) : le nom de l'administrateur n'y figure jamais.
/// Aucune route n'accepte un jeton joueur (schéma AdminSession explicite, testé par PlayerSeparationTests).
/// </summary>
public sealed class PlayersAdminModule : IApiModule
{
public string Name => "PlayersAdmin";
private const int PageSize = 100;
/// <summary>Auteur inscrit dans le journal du compte (vu par le joueur, export compris).</summary>
private const string Team = "par l'équipe";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
{
// Services du module des comptes (AccountsModule) ; consultations : FeedbackConsultations (même règle des 10 minutes).
}
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.PlayersOverview, Overview).RequirePermission(AdminPermissions.PlayersRead);
api.MapGet(ApiRoutes.Players, List).RequirePermission(AdminPermissions.PlayersRead);
api.MapPost(ApiRoutes.PlayersSearch, Search).RequirePermission(AdminPermissions.PlayersRead);
api.MapGet(ApiRoutes.PlayerPattern, Get).RequirePermission(AdminPermissions.PlayersRead);
api.MapPost(ApiRoutes.PlayerDisablePattern, Disable).RequirePermission(AdminPermissions.PlayersManage).RequireSignedRequest();
api.MapPost(ApiRoutes.PlayerEnablePattern, Enable).RequirePermission(AdminPermissions.PlayersManage).RequireSignedRequest();
api.MapPost(ApiRoutes.PlayerUnlockPattern, Unlock).RequirePermission(AdminPermissions.PlayersManage).RequireSignedRequest();
api.MapPost(ApiRoutes.PlayerPasswordRequirementPattern, PasswordRequirement).RequirePermission(AdminPermissions.PlayersManage)
.RequireSignedRequest();
api.MapPost(ApiRoutes.PlayerTemporaryPasswordPattern, TemporaryPassword).RequirePermission(AdminPermissions.PlayersManage)
.RequireSignedRequest();
api.MapPost(ApiRoutes.PlayerOtpResetPattern, OtpReset).RequirePermission(AdminPermissions.PlayersManage).RequireSignedRequest();
api.MapPost(ApiRoutes.PlayerLogoutPattern, Logout).RequirePermission(AdminPermissions.PlayersManage).RequireSignedRequest();
api.MapDelete(ApiRoutes.PlayerPattern, Delete).RequirePermission(AdminPermissions.PlayersDelete).RequireSignedRequest();
}
private static IResult NotConfigured() => ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Les comptes joueurs ne sont pas activés sur ce serveur.");
private static IResult NotFound() => ApiResults.NotFound("Compte joueur introuvable.");
// ------------------------------------------------------------------ Lecture (players.read)
private static IResult Overview(IOptions<LauncherApiOptions> options, IServiceProvider services, TimeProvider time)
{
var settings = options.Value.Players;
var overview = new PlayersOverview
{
Enabled = settings.Enabled,
RegistrationEnabled = settings.Enabled && settings.RegistrationEnabled,
EventsRetentionMonths = settings.EventsRetentionMonths,
EventsIpRetentionMonths = settings.EventsIpRetentionMonths,
InactiveDisableMonths = settings.InactiveDisableMonths,
InactiveDeleteMonths = settings.InactiveDeleteMonths
};
if (!settings.Enabled) return Results.Json(overview, ApiJson.Options);
var counts = services.GetRequiredService<PlayerStore>().Counts(time.GetUtcNow());
return Results.Json(overview with
{
Total = counts.Total,
Active = counts.Active,
Disabled = counts.Disabled,
Suspended = counts.Suspended,
Inactive = counts.Inactive,
Locked = counts.Locked,
PasswordChangeRequired = counts.PasswordChangeRequired,
OtpEnabled = counts.OtpEnabled,
CreatedLast30Days = counts.CreatedLast30Days
}, ApiJson.Options);
}
private static IResult List(string? filter, string? cursor, int? limit, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, AuditLog audit, FeedbackConsultations consultations, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
if (string.IsNullOrEmpty(filter)) filter = null;
if (!PlayerAdminRules.IsValidFilter(filter)) return ApiResults.BadRequest("Filtre inconnu.");
(long, string)? after = null;
if (!string.IsNullOrEmpty(cursor))
{
var parts = cursor.Split(':');
if (cursor.Length > 60 || parts.Length != 2 || !long.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var created) ||
!PlayerIds.IsValidId(parts[1]))
return ApiResults.BadRequest("Curseur invalide.");
after = (created, parts[1]);
}
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
var now = time.GetUtcNow();
var size = Math.Clamp(limit ?? PageSize, 1, 200);
var (rows, total) = store.List(filter, after, size, now);
var last = rows.Count == size ? rows[^1].Account : null;
var page = new PlayerAdminPage
{
Items = rows.Select(r => Summary(r, secrets, now)).ToList(),
NextCursor = last is null ? null : $"{last.CreatedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)}:{last.Id}",
Total = total
};
var actor = context.Admin();
if (consultations.ShouldRecord(actor.Key.KeyId, "players:list:" + (filter ?? "all"), continuation: !string.IsNullOrEmpty(cursor)))
audit.Write("players.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
"liste des comptes", $"filtre « {FilterLabel(filter)} » : {page.Items.Count} compte(s) sur {total}");
return Results.Json(page, ApiJson.Options);
}
/// <summary>
/// Recherche par référence (« PL-7KQ2-M9XD ») ou par adresse e-mail EXACTE (index aveugle : l'adresse n'est ni enregistrée
/// ni inscrite à l'audit). Toujours inscrite au journal d'audit (comment le texte a été compris et le résultat).
/// </summary>
private static IResult Search(PlayerSearchRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var query = request.Query?.Trim() ?? "";
if (query.Length == 0 || query.Length > PlayerAdminRules.MaxSearchLength)
return ApiResults.BadRequest("Saisissez une référence (PL-XXXX-XXXX) ou une adresse e-mail complète.");
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
string kind;
PlayerAccountRecord? found;
if (PlayerIds.NormalizeReference(query) is { } reference)
{
kind = "reference";
found = store.FindByReference(reference);
}
else if (PlayerEmail.Normalize(query) is { } email)
{
kind = "email";
found = store.FindByEmailIndex(secrets.EmailIndex(email));
}
else
{
return ApiResults.BadRequest("Ni une référence (PL-XXXX-XXXX), ni une adresse e-mail complète.");
}
var actor = context.Admin();
audit.Write("players.search", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
found?.Reference ?? "aucun compte",
(kind == "email" ? "recherche par adresse e-mail" : "recherche par référence") + (found is null ? " : aucun compte trouvé" : " : compte trouvé"));
var now = time.GetUtcNow();
return Results.Json(new PlayerSearchResult
{
Kind = kind,
Items = found is not null && store.Row(found.Id) is { } row ? new List<PlayerAdminSummary> { Summary(row, secrets, now) } : new()
}, ApiJson.Options);
}
private static IResult Get(string reference, HttpContext context, IOptions<LauncherApiOptions> options, IServiceProvider services,
AuditLog audit, FeedbackConsultations consultations, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
if (Find(store, reference) is not { } account) return NotFound();
var actor = context.Admin();
// Fiche (adresses IP de l'activité : données personnelles) : inscrite au journal, une fois par compte et par clé toutes les
// 10 minutes.
if (consultations.ShouldRecord(actor.Key.KeyId, "players:item:" + account.Id, continuation: true))
audit.Write("players.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
account.Reference, "fiche consultée (appareils, activité)");
return Results.Json(Detail(account, store, services.GetRequiredService<PlayerSecrets>(), time.GetUtcNow()), ApiJson.Options);
}
// ------------------------------------------------------------------ Gestion (players.manage, requêtes signées)
private static IResult Disable(string reference, PlayerDisableRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerSessionStore sessions, AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
if (Find(store, reference) is not { } account) return NotFound();
var now = time.GetUtcNow();
if (request.Until is { } until && (until <= now || until > now.AddDays(PlayerAdminRules.MaxDisableDays)))
return ApiResults.BadRequest($"Date de fin : dans le futur, {PlayerAdminRules.MaxDisableDays / 365} ans au plus (sinon : indéfiniment).");
var reason = PlayerAdminRules.CleanReason(request.Reason, out var problem);
if (problem is not null) return ApiResults.BadRequest($"Motif : {problem}");
var actor = context.Admin();
var end = request.Until is { } u ? DateTimeOffset.FromUnixTimeSeconds(u.ToUnixTimeSeconds()) : (DateTimeOffset?)null;
store.SetStatus(account.Id, PlayerStatus.Disabled, end, reason is null ? null : secrets.EncryptStatusReason(account.Id, reason),
ChangedBy(actor), now);
sessions.RemoveWhere(s => s.AccountId == account.Id); // ses jetons sont de toute façon refusés dès la requête suivante
var period = end is { } e ? $"jusqu'au {Utc(e)}" : "indéfiniment";
store.AddEvent(account.Id, PlayerEvents.Disabled, AuditResults.Ok, null, null, $"{Team} ; {period}{(reason is null ? "" : " ; motif communiqué")}");
Audit(audit, context, actor, "players.disable", account,
$"{period} ; ancien état : {PlayerAccountStatuses.Label(account.Status)}{(reason is null ? " ; sans motif" : $" ; motif : {reason}")}");
return Updated(account.Id, store, secrets, now);
}
private static IResult Enable(string reference, HttpContext context, IOptions<LauncherApiOptions> options, IServiceProvider services,
AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
if (Find(store, reference) is not { } account) return NotFound();
if (account.Status is not (PlayerStatus.Disabled or PlayerStatus.Inactive))
return ApiResults.Error(409, ApiErrorCodes.Conflict,
account.Status == PlayerStatus.Suspended
? "Ce compte n'est pas désactivé mais suspendu : utilisez « Lever la suspension »."
: "Ce compte est déjà actif.");
var actor = context.Admin();
var now = time.GetUtcNow();
store.SetStatus(account.Id, PlayerStatus.Active, null, null, ChangedBy(actor), now);
store.AddEvent(account.Id, PlayerEvents.Enabled, AuditResults.Ok, null, null, Team);
Audit(audit, context, actor, "players.enable", account, $"ancien état : {PlayerAccountStatuses.Label(account.Status)}");
return Updated(account.Id, store, services.GetRequiredService<PlayerSecrets>(), now);
}
/// <summary>Lève une suspension (le compte redevient actif) et les blocages temporaires (trop d'essais).</summary>
private static IResult Unlock(string reference, HttpContext context, IOptions<LauncherApiOptions> options, IServiceProvider services,
AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
if (Find(store, reference) is not { } account) return NotFound();
var now = time.GetUtcNow();
var locked = account.LockedUntilAt(now) is not null || account.FailedCount > 0 || account.LockLevel > 0 || account.TrustedFailedCount > 0;
if (account.Status != PlayerStatus.Suspended && !locked)
return ApiResults.Error(409, ApiErrorCodes.Conflict, "Ce compte n'est ni suspendu, ni bloqué : rien à lever.");
var actor = context.Admin();
string details;
if (account.Status == PlayerStatus.Suspended)
{
store.SetStatus(account.Id, PlayerStatus.Active, null, null, ChangedBy(actor), now); // blocages remis à zéro aussi
details = "suspension levée (compte réactivé), blocages remis à zéro";
}
else
{
store.ClearLocks(account.Id);
details = "blocages et compteurs d'échecs remis à zéro";
}
store.AddEvent(account.Id, PlayerEvents.Unlocked, AuditResults.Ok, null, null, Team);
Audit(audit, context, actor, "players.unlock", account, details);
return Updated(account.Id, store, services.GetRequiredService<PlayerSecrets>(), now);
}
private static IResult PasswordRequirement(string reference, PlayerPasswordRequirementRequest request, HttpContext context,
IOptions<LauncherApiOptions> options, IServiceProvider services, PlayerSessionStore sessions, AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
if (Find(store, reference) is not { } account) return NotFound();
if (account.MustChangePassword == request.Required)
return ApiResults.Error(409, ApiErrorCodes.Conflict,
request.Required ? "Un nouveau mot de passe est déjà exigé." : "Aucun nouveau mot de passe n'est exigé.");
var actor = context.Admin();
store.SetMustChangePassword(account.Id, request.Required);
if (request.Required)
{
// Sessions ouvertes fermées : la reconnexion demande le mot de passe, puis le nouveau. Les reconnexions automatiques
// sont refusées tant que ce n'est pas fait.
sessions.RemoveWhere(s => s.AccountId == account.Id && s.Scope == PlayerScopes.Full);
store.AddEvent(account.Id, PlayerEvents.PasswordResetRequired, AuditResults.Ok, null, null, Team);
Audit(audit, context, actor, "players.password_required", account,
"nouveau mot de passe exigé à la prochaine connexion (sessions ouvertes fermées)");
}
else
{
store.AddEvent(account.Id, PlayerEvents.PasswordResetCancelled, AuditResults.Ok, null, null, Team);
Audit(audit, context, actor, "players.password_required", account, "nouveau mot de passe plus exigé");
}
return Updated(account.Id, store, services.GetRequiredService<PlayerSecrets>(), time.GetUtcNow());
}
/// <summary>
/// Mot de passe temporaire (D6) : renvoyé UNE fois (jamais enregistré en clair, ni inscrit à l'audit), valable une heure, il ne
/// sert qu'à choisir un nouveau mot de passe et remplace un éventuel précédent. Les blocages temporaires sont levés ; une
/// suspension reste à lever à part (nouvel appareil refusé sinon).
/// </summary>
private static IResult TemporaryPassword(string reference, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
if (Find(store, reference) is not { } account) return NotFound();
if (account.Status == PlayerStatus.Disabled)
return ApiResults.Error(409, ApiErrorCodes.Conflict, "Ce compte est désactivé : réactivez-le d'abord.");
var now = time.GetUtcNow();
var password = PlayerTemporaryPassword.New();
var expires = DateTimeOffset.FromUnixTimeSeconds((now + PlayerTemporaryPassword.Lifetime).ToUnixTimeSeconds());
store.SetTemporaryPassword(account.Id, secrets.TemporaryPasswordHash(account.Id, PlayerTemporaryPassword.Normalize(password)!), expires);
var actor = context.Admin();
store.AddEvent(account.Id, PlayerEvents.TemporaryPassword, AuditResults.Ok, null, null, $"{Team} ; valable jusqu'au {Utc(expires)}");
Audit(audit, context, actor, "players.temporary_password", account,
$"valable jusqu'au {Utc(expires)}{(account.TemporaryPasswordHash is not null ? " ; remplace le précédent" : "")} ; blocages levés" +
(account.OtpEnabled ? " ; double authentification toujours demandée" : ""));
return Results.Json(new PlayerTemporaryPasswordResponse
{
Password = password,
ExpiresAt = expires,
Account = Detail(store.FindById(account.Id)!, store, secrets, now)
}, ApiJson.Options);
}
private static IResult OtpReset(string reference, HttpContext context, IOptions<LauncherApiOptions> options, IServiceProvider services,
AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
if (Find(store, reference) is not { } account) return NotFound();
if (!account.OtpEnabled) return ApiResults.Error(409, ApiErrorCodes.Conflict, "La double authentification n'est pas activée sur ce compte.");
var actor = context.Admin();
store.DisableOtp(account.Id);
store.AddEvent(account.Id, PlayerEvents.OtpReset, AuditResults.Ok, null, null, Team);
Audit(audit, context, actor, "players.otp_reset", account, "double authentification désactivée, codes de secours supprimés");
return Updated(account.Id, store, services.GetRequiredService<PlayerSecrets>(), time.GetUtcNow());
}
private static IResult Logout(string reference, PlayerDevicesLogoutRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerSessionStore sessions, AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
if (Find(store, reference) is not { } account) return NotFound();
var actor = context.Admin();
if (request.DeviceId is { } deviceId)
{
if (!PlayerIds.IsValidId(deviceId) || store.FindDevice(deviceId) is not { } device || device.AccountId != account.Id)
return ApiResults.NotFound("Appareil introuvable (déjà déconnecté ?).");
store.DeleteDevice(device.Id);
sessions.RemoveDevices(new[] { device.Id });
store.AddEvent(account.Id, PlayerEvents.DevicesRemovedByTeam, AuditResults.Ok, null, device.KeyId, $"{Team} ; appareil « {device.Label} »");
Audit(audit, context, actor, "players.logout", account, $"appareil « {device.Label} » (clé {device.KeyId}) déconnecté");
}
else
{
var removed = store.DeleteDevices(account.Id);
sessions.RemoveWhere(s => s.AccountId == account.Id);
store.AddEvent(account.Id, PlayerEvents.DevicesRemovedByTeam, AuditResults.Ok, null, null, $"{Team} ; {removed.Count} appareil(s)");
Audit(audit, context, actor, "players.logout", account, $"tous les appareils déconnectés ({removed.Count})");
}
return Updated(account.Id, store, services.GetRequiredService<PlayerSecrets>(), time.GetUtcNow());
}
private static IResult Delete(string reference, HttpContext context, IOptions<LauncherApiOptions> options, IServiceProvider services)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
if (Find(store, reference) is not { } account) return NotFound();
var details = $"compte joueur supprimé définitivement par un administrateur (état : {PlayerAccountStatuses.Label(account.Status)} ; " +
$"créé le {Utc(account.CreatedAt)})";
if (!services.GetRequiredService<PlayerPersonalData>().EraseByAdmin(account, context.Admin(), context.ClientIp(), details))
return NotFound();
return Results.NoContent();
}
// ------------------------------------------------------------------ Outils
private static PlayerAccountRecord? Find(PlayerStore store, string reference) =>
PlayerIds.NormalizeReference(reference) is { } normalized ? store.FindByReference(normalized) : null;
private static string ChangedBy(AuthenticatedAdmin actor) => $"{actor.Admin.DisplayName} (menu d'administration)";
private static string Utc(DateTimeOffset date) => date.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture);
private static string FilterLabel(string? filter) =>
PlayerAdminRules.Filters.FirstOrDefault(f => f.Value == filter).Label ?? filter ?? "";
private static void Audit(AuditLog audit, HttpContext context, AuthenticatedAdmin actor, string action, PlayerAccountRecord account,
string details) =>
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), account.Reference, details);
private static IResult Updated(string accountId, PlayerStore store, PlayerSecrets secrets, DateTimeOffset now) =>
store.FindById(accountId) is { } account ? Results.Json(Detail(account, store, secrets, now), ApiJson.Options) : NotFound();
private static PlayerAdminSummary Summary(PlayerListRow row, PlayerSecrets secrets, DateTimeOffset now)
{
var a = row.Account;
return new PlayerAdminSummary
{
Reference = a.Reference,
EmailMasked = PlayerEmail.Mask(secrets.DecryptEmail(a.Id, a.EmailEncrypted)),
Status = a.Status,
DisabledUntil = a.Status == PlayerStatus.Disabled ? a.DisabledUntil : null,
CreatedAt = a.CreatedAt,
LastLoginAt = a.LastLoginAt,
LastActivityAt = row.LastActivityAt,
OtpEnabled = a.OtpEnabled,
MustChangePassword = a.MustChangePassword,
LockedUntil = a.LockedUntilAt(now),
DeviceCount = row.DeviceCount
};
}
private static PlayerAdminDetail Detail(PlayerAccountRecord account, PlayerStore store, PlayerSecrets secrets, DateTimeOffset now)
{
var devices = store.Devices(account.Id);
var labels = devices.GroupBy(d => d.KeyId, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First().Label, StringComparer.Ordinal);
var row = new PlayerListRow(account, devices.Count, devices.Count == 0 ? null : devices.Max(d => d.LastUsedAt));
return new PlayerAdminDetail
{
Summary = Summary(row, secrets, now),
EmailVerified = account.EmailVerified,
TermsVersion = account.TermsVersion,
PasswordChangedAt = account.PasswordChangedAt,
StatusReason = secrets.TryDecryptStatusReason(account.Id, account.StatusReasonEncrypted),
StatusChangedAt = account.StatusChangedAt,
StatusChangedBy = account.StatusChangedBy,
FailedCount = account.FailedCount,
LockLevel = account.LockLevel,
UnknownDevicesLockedUntil = account.LockedUntil is { } u && u > now ? u : null,
TrustedFailedCount = account.TrustedFailedCount,
TrustedDevicesLockedUntil = account.TrustedLockedUntil is { } t && t > now ? t : null,
RecoveryCodesLeft = account.OtpEnabled ? store.CountRecoveryCodes(account.Id) : 0,
TemporaryPasswordExpiresAt = account.TemporaryPasswordExpiresAt is { } e && e > now && account.TemporaryPasswordHash is not null ? e : null,
Devices = devices.Select(d => new PlayerAdminDevice
{
Id = d.Id,
Label = d.Label,
KeyId = d.KeyId,
CreatedAt = d.CreatedAt,
LastUsedAt = d.LastUsedAt,
RememberedUntil = d.RefreshExpiresAt
}).ToList(),
Events = store.Events(account.Id, PlayerAdminRules.DetailEvents).Select(e => new PlayerAdminEvent
{
At = e.At,
Type = e.Type,
Success = e.Result == AuditResults.Ok,
Ip = e.Ip,
DeviceKeyId = e.DeviceKeyId,
Device = e.DeviceKeyId is { } key && labels.TryGetValue(key, out var label) ? label : null,
Details = e.Details
}).ToList()
};
}
}
@@ -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,137 @@
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_… », « glpc_… » ou « glpo_… »). 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 (!PlayerSessionStore.IsPlayerToken(token))
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).
// Jeton de la 2ᵉ étape de la connexion : l'état du compte n'est révélé (ou un compte en sommeil réveillé) qu'APRÈS
// le code de vérification, par la route elle-même.
if (account is null || (session.Scope != PlayerScopes.Otp && 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) =>
Get(Context)?.Session.Scope == PlayerScopes.Otp
? ApiResults.WriteAsync(Context, 403, ApiErrorCodes.OtpRequired, "Saisissez d'abord le code de vérification.")
: 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";
public const string OtpPolicy = "player-otp";
/// <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);
/// <summary>Route réservée au jeton de la 2ᵉ étape de la connexion (code de vérification).</summary>
public static TBuilder RequirePlayerOtp<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
builder.RequireAuthorization(OtpPolicy);
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));
options.AddPolicy(OtpPolicy, p => p.AddAuthenticationSchemes(PlayerSessionHandler.SchemeName).RequireAuthenticatedUser()
.RequireClaim(PlayerSessionHandler.ScopeClaim, PlayerScopes.Otp));
});
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,199 @@
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>Seulement la 2ᵉ étape de la connexion : code de vérification (jeton « glpo_ »).</summary>
public const string Otp = "otp";
/// <summary>Préfixe des jetons d'une portée (un jeton ne change jamais de portée).</summary>
public static string PrefixOf(string scope) => scope switch
{
Full => PlayerTokens.AccessPrefix,
PasswordChange => PlayerTokens.PasswordChangePrefix,
Otp => PlayerTokens.OtpPrefix,
_ => throw new ArgumentOutOfRangeException(nameof(scope))
};
}
/// <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>
/// Connexion faite avec un mot de passe temporaire (portées « code de vérification » et « changement exigé ») : le
/// changement de mot de passe vérifie alors ce mot de passe temporaire au lieu de l'ancien.
/// </summary>
public bool ViaTemporaryPassword { get; init; }
private int _attempts;
/// <summary>Compte un essai (portée « code de vérification ») et renvoie le nombre d'essais faits.</summary>
public int CountAttempt() => Interlocked.Increment(ref _attempts);
}
/// <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);
/// <summary>Durée et nombre d'essais du jeton de la 2ᵉ étape de la connexion (au-delà : reprendre la connexion).</summary>
public static readonly TimeSpan OtpLifetime = TimeSpan.FromMinutes(5);
public const int MaxOtpAttempts = 5;
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, bool viaTemporaryPassword = false)
{
var token = PlayerTokens.PasswordChangePrefix + PlayerTokens.NewSecret();
var session = new PlayerAccessSession
{
TokenHash = Hash(token),
Scope = PlayerScopes.PasswordChange,
AccountId = accountId,
DevicePublicKey = devicePublicKey,
DeviceKeyId = deviceKeyId,
DeviceLabel = deviceLabel,
ViaTemporaryPassword = viaTemporaryPassword,
ExpiresAt = _time.GetUtcNow() + PasswordChangeLifetime
};
Add(session);
return (token, session);
}
/// <summary>
/// Jeton de la 2ᵉ étape de la connexion (double authentification activée) : mot de passe vérifié, il ne permet que
/// d'envoyer le code ; il retient l'appareil à enregistrer ensuite.
/// </summary>
public (string Token, PlayerAccessSession Session) CreateOtp(string accountId, string devicePublicKey, string deviceKeyId, string deviceLabel,
bool viaTemporaryPassword = false)
{
var token = PlayerTokens.OtpPrefix + PlayerTokens.NewSecret();
var session = new PlayerAccessSession
{
TokenHash = Hash(token),
Scope = PlayerScopes.Otp,
AccountId = accountId,
DevicePublicKey = devicePublicKey,
DeviceKeyId = deviceKeyId,
DeviceLabel = deviceLabel,
ViaTemporaryPassword = viaTemporaryPassword,
ExpiresAt = _time.GetUtcNow() + OtpLifetime
};
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 (!IsPlayerToken(token)) 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).
return token.StartsWith(PlayerScopes.PrefixOf(session.Scope), StringComparison.Ordinal) ? session : null;
}
/// <summary>Jeton présenté dans « Authorization » : accès, changement exigé ou code de vérification.</summary>
public static bool IsPlayerToken(string token) =>
token.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) ||
token.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal) ||
token.StartsWith(PlayerTokens.OtpPrefix, StringComparison.Ordinal);
public void Remove(PlayerAccessSession session) => _byHash.TryRemove(session.TokenHash, out _);
/// <summary>Ferme toutes les sessions d'un compte (ou d'appareils donnés).</summary>
public int RemoveWhere(Func<PlayerAccessSession, bool> predicate)
{
var removed = 0;
foreach (var (hash, session) in _byHash)
if (predicate(session) && _byHash.TryRemove(hash, out _)) removed++;
return removed;
}
public int RemoveDevices(IEnumerable<string> deviceIds)
{
var set = deviceIds.ToHashSet(StringComparer.Ordinal);
return set.Count == 0 ? 0 : RemoveWhere(s => s.DeviceId is { } id && set.Contains(id));
}
private void Add(PlayerAccessSession session)
{
if (_byHash.Count >= MaxSessions)
{
var now = _time.GetUtcNow();
RemoveWhere(s => s.ExpiresAt <= now);
if (_byHash.Count >= MaxSessions) throw new StoreBusyException("Trop de sessions de joueurs ouvertes.");
}
_byHash[session.TokenHash] = session;
}
internal static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
}
@@ -0,0 +1,124 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Server.Hosting;
using Konscious.Security.Cryptography;
namespace GameLauncher.Server.Storage.Players;
/// <summary>
/// Empreintes des mots de passe des joueurs : Argon2id (RFC 9106, cité par la CNIL et l'OWASP), sel aléatoire de 128 bits
/// par compte, et « poivre » = clé secrète d'Argon2 dérivée de la clé maîtresse (hors du dossier des données). Format PHC :
/// <c>$argon2id$v=19$m=19456,t=2,p=1,k=1$&lt;sel&gt;$&lt;empreinte&gt;</c> (k : version du poivre). Les paramètres sont
/// gardés avec l'empreinte : un relèvement des réglages s'applique à la connexion suivante de chaque joueur.
///
/// Les calculs (≈ 19 Mio de mémoire chacun) passent par une file bornée : un afflux de connexions ne peut pas épuiser
/// la mémoire ou le processeur du VPS (les requêtes en trop attendent, puis reçoivent 503).
/// </summary>
public sealed class PlayerPasswordHasher
{
private const int SaltLength = 16;
private const int HashLength = 32;
private const string Prefix = "$argon2id$v=19$";
private static readonly TimeSpan QueueTimeout = TimeSpan.FromSeconds(20);
private readonly PlayerSecrets _secrets;
private readonly LauncherApiOptions.PlayersSettings _settings;
private readonly SemaphoreSlim _slots;
public PlayerPasswordHasher(PlayerSecrets secrets, LauncherApiOptions.PlayersSettings settings)
{
_secrets = secrets;
_settings = settings;
_slots = new SemaphoreSlim(settings.MaxConcurrentHashes, settings.MaxConcurrentHashes);
}
/// <summary>Empreinte d'un nouveau mot de passe (UTF-8 du mot de passe tel que saisi).</summary>
public async Task<string> HashAsync(string password, CancellationToken ct = default)
{
var salt = RandomNumberGenerator.GetBytes(SaltLength);
var m = _settings.HashMemoryKib;
var t = _settings.HashIterations;
var p = _settings.HashParallelism;
var hash = await ComputeAsync(password, salt, m, t, p, ct);
return string.Create(CultureInfo.InvariantCulture,
$"{Prefix}m={m},t={t},p={p},k={PlayerSecrets.KeyVersion}${Base64Url.Encode(salt)}${Base64Url.Encode(hash)}");
}
/// <summary>
/// Vérifie un mot de passe. <c>NeedsRehash</c> : empreinte calculée avec d'anciens réglages (à remplacer après une
/// connexion réussie). Une empreinte illisible ne correspond à rien.
/// </summary>
public async Task<(bool Valid, bool NeedsRehash)> VerifyAsync(string password, string stored, CancellationToken ct = default)
{
if (!TryParse(stored, out var m, out var t, out var p, out var salt, out var expected))
{
await DummyAsync(password, ct);
return (false, false);
}
var actual = await ComputeAsync(password, salt, m, t, p, ct);
var valid = CryptographicOperations.FixedTimeEquals(actual, expected);
var rehash = valid && (m != _settings.HashMemoryKib || t != _settings.HashIterations || p != _settings.HashParallelism);
return (valid, rehash);
}
/// <summary>Même calcul pour une adresse inconnue : la durée de réponse ne révèle pas si le compte existe.</summary>
public Task DummyAsync(string password, CancellationToken ct = default) =>
ComputeAsync(password, RandomNumberGenerator.GetBytes(SaltLength), _settings.HashMemoryKib, _settings.HashIterations,
_settings.HashParallelism, ct);
private async Task<byte[]> ComputeAsync(string password, byte[] salt, int memoryKib, int iterations, int parallelism, CancellationToken ct)
{
var bytes = Encoding.UTF8.GetBytes(password);
if (!await _slots.WaitAsync(QueueTimeout, ct))
{
CryptographicOperations.ZeroMemory(bytes);
throw new StoreBusyException("Trop de calculs de mots de passe en attente.");
}
try
{
using var argon2 = new Argon2id(bytes)
{
Salt = salt,
KnownSecret = _secrets.PasswordPepper,
MemorySize = memoryKib,
Iterations = iterations,
DegreeOfParallelism = parallelism
};
return argon2.GetBytes(HashLength);
}
finally
{
_slots.Release();
CryptographicOperations.ZeroMemory(bytes);
}
}
private static bool TryParse(string stored, out int m, out int t, out int p, out byte[] salt, out byte[] hash)
{
m = t = p = 0;
salt = hash = Array.Empty<byte>();
if (string.IsNullOrEmpty(stored) || !stored.StartsWith(Prefix, StringComparison.Ordinal)) return false;
var parts = stored[Prefix.Length..].Split('$');
if (parts.Length != 3) return false;
int k = 0;
foreach (var pair in parts[0].Split(','))
{
var eq = pair.IndexOf('=');
if (eq <= 0 || !int.TryParse(pair[(eq + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out var value)) return false;
switch (pair[..eq])
{
case "m": m = value; break;
case "t": t = value; break;
case "p": p = value; break;
case "k": k = value; break;
default: return false;
}
}
if (m is < 8 or > 4_194_304 || t is < 1 or > 100 || p is < 1 or > 64 || k != PlayerSecrets.KeyVersion) return false;
return Base64Url.TryDecode(parts[1], out salt) && salt.Length == SaltLength &&
Base64Url.TryDecode(parts[2], out hash) && hash.Length == HashLength;
}
}
@@ -0,0 +1,157 @@
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>
/// En sommeil après une longue inactivité (<c>Players:InactiveDisableMonths</c>) : appareils déconnectés ; une connexion
/// avec le bon mot de passe le réveille. Supprimé au bout de <c>Players:InactiveDeleteMonths</c> de plus.
/// </summary>
public const string Inactive = "inactive";
}
/// <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; }
/// <summary>Motif de la désactivation (chiffré, lié au compte), montré au joueur à la connexion.</summary>
public string? StatusReasonEncrypted { get; init; }
/// <summary>Auteur du dernier changement d'état (administrateur, outil en ligne de commande, système).</summary>
public string? StatusChangedBy { 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>
/// Mot de passe temporaire créé par un administrateur (étape 8d) : empreinte HMAC et échéance. Il ne sert qu'à choisir un
/// nouveau mot de passe ; tout changement de mot de passe l'efface.
/// </summary>
public string? TemporaryPasswordHash { get; init; }
public DateTimeOffset? TemporaryPasswordExpiresAt { get; init; }
/// <summary>Blocage temporaire en cours (appareils inconnus ou de confiance) : sa fin, sinon null.</summary>
public DateTimeOffset? LockedUntilAt(DateTimeOffset now)
{
DateTimeOffset? end = null;
foreach (var until in new[] { LockedUntil, TrustedLockedUntil })
if (until is { } u && u > now && (end is null || u > end)) end = u;
return end;
}
}
/// <summary>Compte dans une liste du menu d'administration : appareils connectés et dernière utilisation de l'un d'eux.</summary>
public sealed record PlayerListRow(PlayerAccountRecord Account, int DeviceCount, DateTimeOffset? LastActivityAt);
/// <summary>Compteurs des comptes (page « Comptes joueurs »).</summary>
public sealed record PlayerCounts(int Total, int Active, int Disabled, int Suspended, int Inactive, int Locked, int PasswordChangeRequired,
int OtpEnabled, int CreatedLast30Days);
/// <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>
/// Double authentification d'un compte : secret en vigueur (chiffré) et dernier pas de temps accepté (un code ne sert qu'une
/// fois), secret en cours de configuration (chiffré) et date de début de cette configuration.
/// </summary>
public sealed record PlayerOtpState(string? SecretEncrypted, long? LastStep, string? PendingEncrypted, DateTimeOffset? PendingAt)
{
public bool Enabled => SecretEncrypted is not null;
}
/// <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";
public const string Export = "account.export";
public const string Delete = "account.delete";
public const string Inactive = "account.inactive";
public const string Reactivated = "account.reactivated";
public const string OtpChallenge = "account.otp_challenge";
public const string OtpFailed = "account.otp_failed";
public const string OtpEnabled = "account.otp_enabled";
public const string OtpDisabled = "account.otp_disabled";
public const string OtpReset = "account.otp_reset";
public const string RecoveryCodes = "account.recovery_codes";
public const string RecoveryCodeUsed = "account.recovery_code_used";
// Étape 8d : actions de l'équipe (menu d'administration) et mot de passe temporaire.
public const string PasswordResetRequired = "account.password_reset_required";
public const string PasswordResetCancelled = "account.password_reset_cancelled";
public const string TemporaryPassword = "account.temporary_password";
public const string TemporaryPasswordUsed = "account.temporary_password_used";
public const string DevicesRemovedByTeam = "device.removed_by_team";
}
@@ -0,0 +1,178 @@
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;
private readonly byte[] _recoveryKey;
private readonly byte[] _temporaryKey;
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");
_recoveryKey = Derive(masterKey, "recovery-codes/v1");
_temporaryKey = Derive(masterKey, "temporary-password/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)));
/// <summary>
/// Empreinte d'un code de secours NORMALISÉ (<see cref="Contracts.Accounts.PlayerOtp.NormalizeRecoveryCode"/>) :
/// HMAC-SHA256 avec une clé secrète, liée au compte. Les codes ne font que 40 bits : sans la clé, impossible de les
/// retrouver à partir d'une copie de la base.
/// </summary>
public string RecoveryCodeHash(string accountId, string normalizedCode) =>
Convert.ToHexString(HMACSHA256.HashData(_recoveryKey,
Encoding.UTF8.GetBytes($"GameLauncher/Player/RecoveryCode/v1\n{accountId}\n{normalizedCode}"))).ToLowerInvariant();
/// <summary>
/// Empreinte d'un mot de passe temporaire NORMALISÉ (<see cref="Contracts.Accounts.PlayerTemporaryPassword.Normalize"/>) :
/// HMAC-SHA256 avec une clé secrète, liée au compte. 80 bits aléatoires valables une heure : une empreinte rapide suffit
/// (rien à deviner hors ligne sans la clé).
/// </summary>
public string TemporaryPasswordHash(string accountId, string normalized) =>
Convert.ToHexString(HMACSHA256.HashData(_temporaryKey,
Encoding.UTF8.GetBytes($"GameLauncher/Player/TemporaryPassword/v1\n{accountId}\n{normalized}"))).ToLowerInvariant();
/// <summary>Motif d'une désactivation (montré au joueur), chiffré et lié au compte.</summary>
public string EncryptStatusReason(string accountId, string reason) => EncryptField(accountId, "status-reason", reason);
/// <summary>Motif déchiffré, ou null s'il est absent ou illisible (autre clé maîtresse).</summary>
public string? TryDecryptStatusReason(string accountId, string? value)
{
if (value is null) return null;
try
{
return DecryptField(accountId, "status-reason", value);
}
catch (CryptographicException)
{
return null;
}
}
/// <summary>Secret de la double authentification (Base32), chiffré et lié au compte.</summary>
public string EncryptOtpSecret(string accountId, string base32) => EncryptField(accountId, "totp", base32);
public string DecryptOtpSecret(string accountId, string value) => DecryptField(accountId, "totp", value);
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,783 @@
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 = 3;
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);
""");
}
if (version < 2)
{
// Étape 8c : double authentification (secret en cours de configuration) et codes de secours (empreintes HMAC).
Execute(tx, """
ALTER TABLE accounts ADD COLUMN totp_pending_enc TEXT;
ALTER TABLE accounts ADD COLUMN totp_pending_at INTEGER;
CREATE TABLE recovery_codes (
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
used_at INTEGER,
PRIMARY KEY (account_id, code_hash)
);
""");
}
if (version < 3)
{
// Étape 8d : mot de passe temporaire créé par un administrateur (empreinte HMAC et échéance).
Execute(tx, """
ALTER TABLE accounts ADD COLUMN temp_password_hash TEXT;
ALTER TABLE accounts ADD COLUMN temp_password_expires INTEGER;
""");
}
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, status_reason_enc, status_changed_by, " +
"temp_password_hash, temp_password_expires";
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é). Un mot de passe temporaire en attente est
/// effacé (il ne sert qu'une fois, à choisir ce nouveau mot de passe).
/// </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,
temp_password_hash = NULL, temp_password_expires = NULL
WHERE id = $id;
""", ("$h", passwordHash), ("$now", now.ToUnixTimeSeconds()), ("$m", mustChange ? 1 : 0), ("$id", accountId));
return true;
});
/// <summary>
/// Mot de passe temporaire (empreinte HMAC) valable jusqu'à <paramref name="expiresAt"/>, à la place d'un éventuel précédent ;
/// les blocages temporaires (trop d'essais) sont levés : l'identité du joueur a été vérifiée par l'équipe. L'état du compte
/// (suspension comprise) ne change pas.
/// </summary>
public bool SetTemporaryPassword(string accountId, string hash, DateTimeOffset expiresAt) => Locked(() =>
Execute(null, """
UPDATE accounts SET temp_password_hash = $h, temp_password_expires = $e, failed_count = 0, locked_until = NULL,
trusted_failed_count = 0, trusted_locked_until = NULL
WHERE id = $id;
""", ("$h", hash), ("$e", expiresAt.ToUnixTimeSeconds()), ("$id", accountId)) == 1);
/// <summary>Efface les mots de passe temporaires échus (conservation : rien d'inutile en base). Renvoie le nombre effacé.</summary>
public int ClearExpiredTemporaryPasswords(DateTimeOffset now) => Locked(() =>
Execute(null, "UPDATE accounts SET temp_password_hash = NULL, temp_password_expires = NULL WHERE temp_password_expires < $t;",
("$t", now.ToUnixTimeSeconds())));
/// <summary>Lève les blocages temporaires (trop d'essais) sans changer l'état du compte.</summary>
public bool ClearLocks(string accountId) => Locked(() =>
Execute(null, """
UPDATE accounts SET failed_count = 0, lock_level = 0, locked_until = NULL, trusted_failed_count = 0, trusted_locked_until = NULL
WHERE id = $id;
""", ("$id", accountId)) == 1);
/// <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);
/// <summary>
/// Supprime DÉFINITIVEMENT le compte : ses appareils et son journal partent avec lui (clés étrangères en cascade,
/// lignes écrasées sur le disque : secure_delete). Faux si le compte n'existe plus.
/// </summary>
public bool DeleteAccount(string accountId) => Locked(() => Execute(null, "DELETE FROM accounts WHERE id = $id;", ("$id", accountId)) == 1);
/// <summary>
/// Comptes ACTIFS sans aucune activité (création, connexion, utilisation d'un appareil) depuis <paramref name="before"/>.
/// Les comptes désactivés ou suspendus par un administrateur ne sont pas concernés (leur sort relève de l'administration).
/// </summary>
public IReadOnlyList<PlayerAccountRecord> FindInactive(DateTimeOffset before, int limit = 1000) => Locked(() =>
Query(null, $"""
SELECT {AccountColumns} FROM accounts a
WHERE status = 'active'
AND MAX(created_at, COALESCE(last_login_at, 0),
COALESCE((SELECT MAX(last_used_at) FROM devices d WHERE d.account_id = a.id), 0)) < $t
ORDER BY created_at LIMIT $l;
""", ReadAccount, ("$t", before.ToUnixTimeSeconds()), ("$l", Math.Clamp(limit, 1, 10_000))));
/// <summary>Met le compte en sommeil (inactivité) et déconnecte ses appareils, si son état n'a pas changé entre-temps.</summary>
public bool MarkInactive(string accountId, DateTimeOffset now)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
var changed = Execute(tx, """
UPDATE accounts SET status = 'inactive', disabled_until = NULL, status_changed_at = $now,
status_changed_by = 'système (inactivité)'
WHERE id = $id AND status = 'active';
""", ("$now", now.ToUnixTimeSeconds()), ("$id", accountId)) == 1;
if (changed) Execute(tx, "DELETE FROM devices WHERE account_id = $a;", ("$a", accountId));
tx.Commit();
return changed;
}
}
// ------------------------------------------------------------------ Menu d'administration (étape 8d)
/// <summary>Colonnes d'un compte préfixées par l'alias « a » (requêtes avec sous-requêtes sur les appareils).</summary>
private static readonly string AliasedAccountColumns = string.Join(", ", AccountColumns.Split(", ").Select(c => "a." + c.Trim()));
/// <summary>Condition SQL d'un filtre de la liste (<see cref="Contracts.Accounts.PlayerAdminRules.Filters"/>).</summary>
private static string FilterSql(string? filter) => filter switch
{
null => "1 = 1",
PlayerStatus.Active or PlayerStatus.Disabled or PlayerStatus.Suspended or PlayerStatus.Inactive => "a.status = $f",
"locked" => "(a.locked_until > $now OR a.trusted_locked_until > $now)",
"password" => "a.must_change_password = 1",
"otp" => "a.totp_secret_enc IS NOT NULL",
_ => throw new ArgumentException("Filtre inconnu.", nameof(filter))
};
/// <summary>
/// Page de la liste, les comptes les plus récents d'abord : curseur « date de création:identifiant » du dernier compte de la
/// page précédente. Renvoie aussi le nombre total de comptes du filtre.
/// </summary>
public (IReadOnlyList<PlayerListRow> Rows, int Total) List(string? filter, (long CreatedAt, string Id)? after, int limit, DateTimeOffset now) =>
Locked(() =>
{
var where = FilterSql(filter);
var parameters = new List<(string, object?)> { ("$f", filter ?? ""), ("$now", now.ToUnixTimeSeconds()) };
var total = Convert.ToInt32(Scalar(null, $"SELECT COUNT(*) FROM accounts a WHERE {where};", parameters.ToArray()),
CultureInfo.InvariantCulture);
var page = where;
if (after is { } cursor)
{
page += " AND (a.created_at < $c OR (a.created_at = $c AND a.id < $cid))";
parameters.Add(("$c", cursor.CreatedAt));
parameters.Add(("$cid", cursor.Id));
}
parameters.Add(("$l", Math.Clamp(limit, 1, 200)));
var rows = Query(null, $"""
SELECT {AliasedAccountColumns},
(SELECT COUNT(*) FROM devices d WHERE d.account_id = a.id),
(SELECT MAX(last_used_at) FROM devices d WHERE d.account_id = a.id)
FROM accounts a WHERE {page}
ORDER BY a.created_at DESC, a.id DESC LIMIT $l;
""", r => new PlayerListRow(ReadAccount(r), (int)r.GetInt64(AccountColumnCount), NullableDate(r, AccountColumnCount + 1)),
parameters.ToArray());
return ((IReadOnlyList<PlayerListRow>)rows, total);
});
/// <summary>Un compte avec ses compteurs d'appareils (résultat d'une recherche).</summary>
public PlayerListRow? Row(string accountId) => Locked(() =>
QuerySingle(null, $"""
SELECT {AliasedAccountColumns},
(SELECT COUNT(*) FROM devices d WHERE d.account_id = a.id),
(SELECT MAX(last_used_at) FROM devices d WHERE d.account_id = a.id)
FROM accounts a WHERE a.id = $id;
""", r => new PlayerListRow(ReadAccount(r), (int)r.GetInt64(AccountColumnCount), NullableDate(r, AccountColumnCount + 1)),
("$id", accountId)));
/// <summary>Compteurs des comptes par état (page « Comptes joueurs »).</summary>
public PlayerCounts Counts(DateTimeOffset now) => Locked(() =>
QuerySingle(null, """
SELECT COUNT(*),
COALESCE(SUM(status = 'active'), 0), COALESCE(SUM(status = 'disabled'), 0),
COALESCE(SUM(status = 'suspended'), 0), COALESCE(SUM(status = 'inactive'), 0),
COALESCE(SUM(locked_until > $now OR trusted_locked_until > $now), 0),
COALESCE(SUM(must_change_password = 1), 0), COALESCE(SUM(totp_secret_enc IS NOT NULL), 0),
COALESCE(SUM(created_at >= $month), 0)
FROM accounts;
""", r => new PlayerCounts((int)r.GetInt64(0), (int)r.GetInt64(1), (int)r.GetInt64(2), (int)r.GetInt64(3), (int)r.GetInt64(4),
(int)r.GetInt64(5), (int)r.GetInt64(6), (int)r.GetInt64(7), (int)r.GetInt64(8)),
("$now", now.ToUnixTimeSeconds()), ("$month", (now - TimeSpan.FromDays(30)).ToUnixTimeSeconds()))!);
/// <summary>Comptes en sommeil depuis avant <paramref name="before"/> (à supprimer).</summary>
public IReadOnlyList<PlayerAccountRecord> FindDormantSince(DateTimeOffset before, int limit = 1000) => Locked(() =>
Query(null, $"SELECT {AccountColumns} FROM accounts WHERE status = 'inactive' AND status_changed_at < $t ORDER BY status_changed_at LIMIT $l;",
ReadAccount, ("$t", before.ToUnixTimeSeconds()), ("$l", Math.Clamp(limit, 1, 10_000))));
// ------------------------------------------------------------------ Double authentification (étape 8c)
public PlayerOtpState ReadOtp(string accountId) => Locked(() =>
QuerySingle(null, "SELECT totp_secret_enc, totp_last_step, totp_pending_enc, totp_pending_at FROM accounts WHERE id = $id;",
r => new PlayerOtpState(r.IsDBNull(0) ? null : r.GetString(0), r.IsDBNull(1) ? null : r.GetInt64(1),
r.IsDBNull(2) ? null : r.GetString(2), NullableDate(r, 3)),
("$id", accountId)) ?? new PlayerOtpState(null, null, null, null));
/// <summary>Secret en cours de configuration (null : configuration abandonnée).</summary>
public void SetOtpPending(string accountId, string? pendingEncrypted, DateTimeOffset now) => Locked(() =>
Execute(null, "UPDATE accounts SET totp_pending_enc = $p, totp_pending_at = $t WHERE id = $id;",
("$p", (object?)pendingEncrypted ?? DBNull.Value), ("$t", pendingEncrypted is null ? DBNull.Value : now.ToUnixTimeSeconds()),
("$id", accountId)));
/// <summary>
/// Active la double authentification (sous transaction) : secret confirmé, pas de temps du code de confirmation
/// (déjà utilisé), configuration terminée, codes de secours remplacés.
/// </summary>
public void EnableOtp(string accountId, string secretEncrypted, long step, IEnumerable<string> recoveryHashes, DateTimeOffset now)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
Execute(tx, """
UPDATE accounts SET totp_secret_enc = $s, totp_last_step = $step, totp_pending_enc = NULL, totp_pending_at = NULL
WHERE id = $id;
""", ("$s", secretEncrypted), ("$step", step), ("$id", accountId));
InsertRecoveryCodes(tx, accountId, recoveryHashes, now);
tx.Commit();
}
}
/// <summary>Désactive la double authentification : secret, configuration en cours et codes de secours effacés.</summary>
public bool DisableOtp(string accountId)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
var changed = Execute(tx, """
UPDATE accounts SET totp_secret_enc = NULL, totp_last_step = NULL, totp_pending_enc = NULL, totp_pending_at = NULL
WHERE id = $id;
""", ("$id", accountId)) == 1;
Execute(tx, "DELETE FROM recovery_codes WHERE account_id = $a;", ("$a", accountId));
tx.Commit();
return changed;
}
}
/// <summary>
/// Retient le pas de temps d'un code accepté, SEULEMENT s'il est postérieur au dernier accepté : un code (même intercepté)
/// ne sert qu'une fois, y compris en cas de requêtes simultanées.
/// </summary>
public bool TryUseOtpStep(string accountId, long step) => Locked(() =>
Execute(null, """
UPDATE accounts SET totp_last_step = $s
WHERE id = $id AND totp_secret_enc IS NOT NULL AND (totp_last_step IS NULL OR totp_last_step < $s);
""", ("$s", step), ("$id", accountId)) == 1);
/// <summary>Remplace tous les codes de secours du compte (les anciens ne valent plus rien).</summary>
public void ReplaceRecoveryCodes(string accountId, IEnumerable<string> recoveryHashes, DateTimeOffset now)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
InsertRecoveryCodes(tx, accountId, recoveryHashes, now);
tx.Commit();
}
}
/// <summary>Utilise un code de secours (empreinte) : vrai s'il existait et n'avait jamais servi.</summary>
public bool TryUseRecoveryCode(string accountId, string codeHash, DateTimeOffset now) => Locked(() =>
Execute(null, "UPDATE recovery_codes SET used_at = $now WHERE account_id = $a AND code_hash = $h AND used_at IS NULL;",
("$now", now.ToUnixTimeSeconds()), ("$a", accountId), ("$h", codeHash)) == 1);
public int CountRecoveryCodes(string accountId) => Locked(() =>
Convert.ToInt32(Scalar(null, "SELECT COUNT(*) FROM recovery_codes WHERE account_id = $a AND used_at IS NULL;", ("$a", accountId)),
CultureInfo.InvariantCulture));
private void InsertRecoveryCodes(SqliteTransaction tx, string accountId, IEnumerable<string> hashes, DateTimeOffset now)
{
Execute(tx, "DELETE FROM recovery_codes WHERE account_id = $a;", ("$a", accountId));
foreach (var hash in hashes.Distinct(StringComparer.Ordinal))
Execute(tx, "INSERT INTO recovery_codes (account_id, code_hash, created_at, used_at) VALUES ($a, $h, $t, NULL);",
("$a", accountId), ("$h", hash), ("$t", now.ToUnixTimeSeconds()));
}
/// <summary>Nouvelle empreinte du mot de passe (paramètres de hachage relevés), sans changer la date du mot de passe.</summary>
public void RehashPassword(string accountId, string passwordHash) => Locked(() =>
Execute(null, "UPDATE accounts SET password_hash = $h WHERE id = $id;", ("$h", passwordHash), ("$id", accountId)));
// ------------------------------------------------------------------ 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),
StatusReasonEncrypted = r.IsDBNull(20) ? null : r.GetString(20),
StatusChangedBy = r.IsDBNull(21) ? null : r.GetString(21),
TemporaryPasswordHash = r.IsDBNull(22) ? null : r.GetString(22),
TemporaryPasswordExpiresAt = NullableDate(r, 23)
};
/// <summary>Nombre de colonnes de <see cref="AccountColumns"/> (les colonnes suivantes d'une requête viennent après).</summary>
private const int AccountColumnCount = 24;
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,28 @@
"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",
// Nom affiché dans les applications d'authentification (double authentification des joueurs).
"OtpIssuer": "LanisysGames",
"HashMemoryKib": 19456,
"HashIterations": 2,
"FailuresPerLock": 5,
"LockMinutes": 15,
"SuspendAfterLocks": 4,
"RefreshIdleDays": 90,
"RefreshMaxDays": 365,
"EventsRetentionMonths": 12,
"EventsIpRetentionMonths": 6,
// Comptes inactifs : mis en sommeil après 24 mois sans activité, supprimés 12 mois plus tard (0 : désactivé).
"InactiveDisableMonths": 24,
"InactiveDeleteMonths": 12
},
"Limits": {
"RequestsPerMinute": 300,
// Morceaux de 4 Mo envoyés pendant une publication (à la place de RequestsPerMinute) : 1500 ≈ 100 Mo/s.
+62
View File
@@ -501,6 +501,63 @@ 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.
5. **Launcher** (étape 8b) : le bouton « Compte » n'apparaît que dans un launcher publié avec `"playerAccounts": true`
dans `src\GameLauncher\launcher.json` (lu dans le fichier intégré à l'exe, comme `apiUrl`). Pour un essai, un launcher
**Debug** pointé sur un serveur de test suffit ; ne publie pas de launcher avec `true` avant l'ouverture des comptes.
Double authentification (étape 8c) : facultative, activée par chaque joueur dans « Mon compte ». Nom affiché dans les
applications d'authentification : `Players:OtpIssuer` (« LanisysGames » par défaut). Un joueur qui a perdu son téléphone
**et** ses codes de secours : après avoir vérifié son identité (par exemple depuis l'adresse e-mail du compte),
`sudo -u deploy /opt/launcher-api/GameLauncher.Server players-otp-reset --ref PL-XXXX-XXXX` (inscrit au journal d'audit).
La mise à jour du serveur fait passer la base des comptes au schéma 2 au premier démarrage (ajout de colonnes et d'une
table, sans perte) : sauvegarde `players.db` avant, comme pour toute mise à jour.
Page « Comptes joueurs » du menu d'administration (étape 8d) : recherche (référence ou adresse e-mail exacte), fiche,
désactivation avec motif montré au joueur, levée d'une suspension, mot de passe temporaire (valable 1 h, à transmettre au
joueur par message privé), réinitialisation de la double authentification, suppression. Permissions `players.read`,
`players.manage` et `players.delete` : le groupe Propriétaire les a d'office ; sur un serveur déjà installé, les autres
groupes ne les reçoivent pas d'eux-mêmes (page « Groupes et permissions » : par exemple lecture + gestion pour
« Administrateur », jamais la suppression sans raison). La mise à jour fait passer la base au **schéma 3** (deux colonnes
ajoutées, sans perte) : sauvegarde `players.db` avant. Les commandes `players-*` ci-dessous restent le secours.
Comptes inactifs : sans activité pendant `Players:InactiveDisableMonths` (24 mois), un compte est mis en sommeil (une
connexion le réveille), puis supprimé après `Players:InactiveDeleteMonths` (12 mois) ; `0` désactive la règle. Chaque
jour, le bilan est inscrit au journal d'audit (`players.inactive`). Les joueurs exportent leurs données et suppriment leur
compte eux-mêmes depuis le launcher (« Mon compte › Mes données ») : `players.self_delete` 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 +643,10 @@ 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` |
| Joueur sans téléphone ni codes de secours (identité vérifiée) | `… players-otp-reset --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 +694,7 @@ recréer les administrateurs.
├─ feedback-forms-history/ 20 dernières révisions publiées
├─ maintenance.json (+ .bak) mode maintenance publié (référence de la révision)
├─ maintenance-history/ 20 dernières révisions publiées
├─ players/players.db (+ -wal, -shm) comptes joueurs (SQLite, 600 ; adresses chiffrées, mots de passe hachés)
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
/srv/exovival/www/catalog/ (exemple) dossier public servi par nginx (deploy:www-data, 2750)
├─ catalog.json catalogue lu par les launchers (écrit par le service, 644)
+11
View File
@@ -0,0 +1,11 @@
# =============================================================================
# Comptes joueurs (phase 8) : clé maîtresse transmise au service par systemd.
# /etc/systemd/system/launcher-api.service.d/players.conf
#
# La clé est chiffrée sur le disque (systemd-creds, clé de l'hôte) dans /etc/credstore.encrypted (root, 700) ;
# systemd la déchiffre au démarrage dans un dossier en mémoire visible du seul service ($CREDENTIALS_DIRECTORY).
# Elle n'est jamais dans /var/lib/launcher-api : une copie des données ou des sauvegardes ne la contient pas.
# Mise en place : server/deploy/LISEZMOI.md, étape 3 undecies. Sans ce fichier, laisser Players:Enabled à false.
# =============================================================================
[Service]
LoadCredentialEncrypted=players-master-key:/etc/credstore.encrypted/players-master-key
@@ -1,6 +1,7 @@
using System.Security.Cryptography;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Files;
@@ -347,6 +348,67 @@ public sealed class AdminSession : IAsyncDisposable
new MaintenancePublishRequest { BaseRevision = baseRevision, Document = document with { Signature = signature } }, ct).ConfigureAwait(false);
}
// ------------------------------------------------------------------ Comptes joueurs (étape 8d)
/// <summary>Compteurs et réglages du service des comptes (Enabled faux : service désactivé sur le serveur).</summary>
public Task<PlayersOverview> GetPlayersOverviewAsync(CancellationToken ct = default) =>
GetAsync<PlayersOverview>(ApiRoutes.PlayersOverview, ct);
/// <param name="filter">Une valeur de <see cref="PlayerAdminRules.Filters"/> (null : tous les comptes).</param>
public Task<PlayerAdminPage> GetPlayersPageAsync(string? filter = null, string? cursor = null, int limit = 100, CancellationToken ct = default) =>
GetAsync<PlayerAdminPage>(ApiRoutes.PlayersPage(PlayerAdminRules.IsValidFilter(filter) ? filter : throw new ArgumentException("Filtre inconnu.", nameof(filter)),
cursor, limit), ct);
/// <summary>
/// Recherche par référence ou adresse e-mail EXACTE : dans le corps d'une requête POST, jamais dans l'adresse (journaux du
/// serveur web). Inscrite au journal d'audit (sans l'adresse).
/// </summary>
public Task<PlayerSearchResult> SearchPlayersAsync(string query, CancellationToken ct = default) =>
SendCoreAsync<PlayerSearchResult>(HttpMethod.Post, ApiRoutes.PlayersSearch,
LauncherApiClient.Serialize(new PlayerSearchRequest { Query = query }), signed: false, ct);
public Task<PlayerAdminDetail> GetPlayerAsync(string reference, CancellationToken ct = default) =>
GetAsync<PlayerAdminDetail>(ApiRoutes.Player(CheckReference(reference)), ct);
/// <param name="until">Fin de la désactivation (null : indéfiniment).</param>
/// <param name="reason">Motif montré au joueur (null : aucun), contrôlé par <see cref="PlayerAdminRules.CleanReason"/>.</param>
public Task<PlayerAdminDetail> DisablePlayerAsync(string reference, DateTimeOffset? until, string? reason, CancellationToken ct = default) =>
PlayerActionAsync<PlayerAdminDetail>(reference, "disable", new PlayerDisableRequest { Until = until, Reason = reason }, ct);
public Task<PlayerAdminDetail> EnablePlayerAsync(string reference, CancellationToken ct = default) =>
PlayerActionAsync<PlayerAdminDetail>(reference, "enable", new { }, ct);
/// <summary>Lève une suspension et les blocages temporaires (trop d'essais).</summary>
public Task<PlayerAdminDetail> UnlockPlayerAsync(string reference, CancellationToken ct = default) =>
PlayerActionAsync<PlayerAdminDetail>(reference, "unlock", new { }, ct);
/// <summary>Exige (ou n'exige plus) un nouveau mot de passe à la prochaine connexion.</summary>
public Task<PlayerAdminDetail> SetPlayerPasswordRequirementAsync(string reference, bool required, CancellationToken ct = default) =>
PlayerActionAsync<PlayerAdminDetail>(reference, "password-requirement", new PlayerPasswordRequirementRequest { Required = required }, ct);
/// <summary>Mot de passe temporaire (valable une heure, une seule fois) : renvoyé UNE fois, à transmettre au joueur.</summary>
public Task<PlayerTemporaryPasswordResponse> CreatePlayerTemporaryPasswordAsync(string reference, CancellationToken ct = default) =>
PlayerActionAsync<PlayerTemporaryPasswordResponse>(reference, "temporary-password", new { }, ct);
public Task<PlayerAdminDetail> ResetPlayerOtpAsync(string reference, CancellationToken ct = default) =>
PlayerActionAsync<PlayerAdminDetail>(reference, "otp-reset", new { }, ct);
/// <param name="deviceId">Appareil à déconnecter (null : tous).</param>
public Task<PlayerAdminDetail> LogoutPlayerDevicesAsync(string reference, string? deviceId, CancellationToken ct = default) =>
PlayerActionAsync<PlayerAdminDetail>(reference, "logout",
new PlayerDevicesLogoutRequest { DeviceId = deviceId is null || PlayerIds.IsValidId(deviceId) ? deviceId : throw new ArgumentException("Appareil invalide.", nameof(deviceId)) },
ct);
/// <summary>Supprime DÉFINITIVEMENT le compte et ses données (players.delete).</summary>
public Task DeletePlayerAsync(string reference, CancellationToken ct = default) =>
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.Player(CheckReference(reference)), null, ct);
private Task<T> PlayerActionAsync<T>(string reference, string action, object body, CancellationToken ct) =>
SendSignedAsync<T>(HttpMethod.Post, ApiRoutes.PlayerAction(CheckReference(reference), action), body, ct);
private static string CheckReference(string reference) =>
PlayerIds.NormalizeReference(reference) ?? throw new ArgumentException("Référence de compte invalide (PL-XXXX-XXXX).", nameof(reference));
/// <summary>Contenu d'une capture d'écran (à revérifier avec FeedbackImages.Sanitize avant de l'afficher).</summary>
public Task<byte[]> GetFeedbackAttachmentAsync(string gameId, string id, int index, CancellationToken ct = default) =>
SendCoreAsync<byte[]>(HttpMethod.Get, ApiRoutes.GameFeedbackAttachment(CheckGameId(gameId), CheckFeedbackId(id), index), null,
@@ -304,7 +304,7 @@ public sealed class LauncherApiClient : IDisposable
}
/// <summary>Le serveur doit annoncer la même origine que celle configurée (sinon les signatures seraient refusées).</summary>
private void EnsureSameOrigin(string serverOrigin)
internal void EnsureSameOrigin(string serverOrigin)
{
if (!string.Equals(serverOrigin, Origin, StringComparison.OrdinalIgnoreCase))
throw new ApiException(0, "origin_mismatch",
@@ -0,0 +1,264 @@
using System.Net.Http.Headers;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Security;
namespace GameLauncher.ApiClient;
/// <summary>
/// Clé d'appareil d'un joueur (ECDSA P-256, une par PC et par compte Windows) : créée dans la puce de sécurité (TPM) quand
/// elle existe, sinon clé logicielle non exportable protégée par Windows. La clé privée ne quitte jamais le PC.
/// Totalement distincte des clés d'administrateur (<see cref="IAdminSigner"/>) : jamais l'une pour l'autre.
/// </summary>
public interface IPlayerDeviceKey
{
/// <summary>Clé publique (SubjectPublicKeyInfo DER).</summary>
byte[] PublicKey { get; }
/// <summary>Identifiant de la clé (<see cref="AdminKeys.ComputeKeyId"/>).</summary>
string KeyId { get; }
/// <summary>Signature ECDSA P-256 / SHA-256 au format IEEE P1363 (64 octets).</summary>
ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default);
}
/// <summary>
/// Routes des comptes joueurs de la Launcher API (« v1/account/… »). Chaque création de compte, connexion et
/// renouvellement : défi du serveur (même origine que l'adresse configurée), preuve de travail résolue hors du fil de
/// l'interface, signature par la clé d'appareil. Les jetons sont passés par l'appelant (jeton d'accès gardé en mémoire,
/// jeton de renouvellement chiffré sur le PC) et ne partent que vers l'adresse de l'API (<see cref="LauncherApiClient.Resolve"/>).
/// </summary>
public sealed class PlayerAccountApi
{
/// <summary>Taille maximale acceptée pour l'export des données (le serveur borne l'activité à 1000 lignes).</summary>
public const int MaxExportBytes = 4 * 1024 * 1024;
private readonly LauncherApiClient _client;
public PlayerAccountApi(LauncherApiClient client) => _client = client;
public LauncherApiClient Client => _client;
public Task<PlayerLoginResponse> RegisterAsync(string email, string password, string termsVersion, IPlayerDeviceKey key,
string deviceLabel, CancellationToken ct = default) =>
SendWithDeviceAsync<PlayerLoginResponse>(PlayerChallengePurposes.Register, ApiRoutes.AccountRegister, key, deviceLabel,
(challenge, solution, proof) => new PlayerRegisterRequest
{
Email = email,
Password = password,
ChallengeId = challenge.ChallengeId,
Solution = solution,
AcceptedTermsVersion = termsVersion,
Device = proof
}, ct);
public Task<PlayerLoginResponse> LoginAsync(string email, string password, IPlayerDeviceKey key, string deviceLabel,
CancellationToken ct = default) =>
SendWithDeviceAsync<PlayerLoginResponse>(PlayerChallengePurposes.Login, ApiRoutes.AccountLogin, key, deviceLabel,
(challenge, solution, proof) => new PlayerLoginRequest
{
Email = email,
Password = password,
ChallengeId = challenge.ChallengeId,
Solution = solution,
Device = proof
}, ct);
/// <summary>Renouvellement de la session mémorisée : un nouveau jeton de renouvellement remplace celui présenté.</summary>
public async Task<PlayerSession> RefreshAsync(string refreshToken, IPlayerDeviceKey key, CancellationToken ct = default)
{
if (!PlayerTokens.TryParseRefresh(refreshToken, out _, out _))
throw new ArgumentException("Jeton de renouvellement mal formé.", nameof(refreshToken));
var (challenge, _) = await ChallengeAsync(PlayerChallengePurposes.Refresh, ct).ConfigureAwait(false);
var signature = await SignAsync(key, PlayerChallengePurposes.Refresh, challenge, ct).ConfigureAwait(false);
return Check(await _client.SendAsync<PlayerSession>(HttpMethod.Post, ApiRoutes.AccountRefresh,
LauncherApiClient.Serialize(new PlayerRefreshRequest
{
RefreshToken = refreshToken,
ChallengeId = challenge.ChallengeId,
Signature = signature
}), null, ct).ConfigureAwait(false));
}
public Task<PlayerMeResponse> MeAsync(string accessToken, CancellationToken ct = default) =>
_client.SendAsync<PlayerMeResponse>(HttpMethod.Get, ApiRoutes.AccountMe, null, Bearer(accessToken), ct);
public Task<PlayerActivityResponse> ActivityAsync(string accessToken, CancellationToken ct = default) =>
_client.SendAsync<PlayerActivityResponse>(HttpMethod.Get, ApiRoutes.AccountActivity, null, Bearer(accessToken), ct);
/// <param name="token">Jeton d'accès, ou jeton limité « changement de mot de passe exigé ».</param>
public async Task<PlayerChangePasswordResponse> ChangePasswordAsync(string token, string currentPassword, string newPassword,
CancellationToken ct = default)
{
var response = await _client.SendAsync<PlayerChangePasswordResponse>(HttpMethod.Post, ApiRoutes.AccountPassword,
LauncherApiClient.Serialize(new PlayerChangePasswordRequest { CurrentPassword = currentPassword, NewPassword = newPassword }),
Bearer(token), ct).ConfigureAwait(false);
if (response.Session is { } session) Check(session);
return response;
}
public async Task LogoutAsync(string accessToken, bool allDevices, CancellationToken ct = default) =>
await _client.SendAsync<NoContent>(HttpMethod.Post, ApiRoutes.AccountLogout,
LauncherApiClient.Serialize(new PlayerLogoutRequest { AllDevices = allDevices }), Bearer(accessToken), ct).ConfigureAwait(false);
public async Task RemoveDeviceAsync(string accessToken, string deviceId, CancellationToken ct = default)
{
if (!PlayerIds.IsValidId(deviceId)) throw new ArgumentException("Identifiant d'appareil invalide.", nameof(deviceId));
await _client.SendAsync<NoContent>(HttpMethod.Delete, ApiRoutes.AccountDevice(deviceId), null, Bearer(accessToken), ct)
.ConfigureAwait(false);
}
/// <summary>Export de toutes ses données (document JSON, vérifié avant d'être rendu), mot de passe exigé.</summary>
public async Task<byte[]> ExportAsync(string accessToken, string password, CancellationToken ct = default)
{
var bytes = await _client.SendAsync<byte[]>(HttpMethod.Post, ApiRoutes.AccountExport,
LauncherApiClient.Serialize(new PlayerPasswordConfirmation { Password = password }), Bearer(accessToken), ct,
timeout: TimeSpan.FromMinutes(1), maxResponseBytes: MaxExportBytes).ConfigureAwait(false);
try
{
using var document = JsonDocument.Parse(bytes);
if (document.RootElement.ValueKind != JsonValueKind.Object ||
!document.RootElement.TryGetProperty("format", out var format) || format.GetString() != PlayerDataExport.Format)
throw new JsonException("format inattendu");
}
catch (JsonException ex)
{
throw new ApiException(200, "invalid_response", "Le serveur a renvoyé un export illisible.", ex);
}
return bytes;
}
// ------------------------------------------------------------------ Double authentification (étape 8c)
/// <summary>Connexion, 2ᵉ étape : code de l'application d'authentification ou code de secours.</summary>
public async Task<PlayerLoginResponse> VerifyOtpAsync(string otpToken, string code, CancellationToken ct = default)
{
if (!otpToken.StartsWith(PlayerTokens.OtpPrefix, StringComparison.Ordinal))
throw new ArgumentException("Jeton de vérification invalide.", nameof(otpToken));
var response = await _client.SendAsync<PlayerLoginResponse>(HttpMethod.Post, ApiRoutes.AccountLoginOtp,
LauncherApiClient.Serialize(new PlayerOtpCodeRequest { Code = code }), Bearer(otpToken), ct).ConfigureAwait(false);
return Check(response);
}
/// <summary>Préparation (mot de passe exigé) : secret et adresse « otpauth:// », contrôlés avant d'être affichés.</summary>
public async Task<PlayerOtpSetupResponse> OtpSetupAsync(string accessToken, string password, CancellationToken ct = default)
{
var setup = await _client.SendAsync<PlayerOtpSetupResponse>(HttpMethod.Post, ApiRoutes.AccountOtpSetup,
LauncherApiClient.Serialize(new PlayerPasswordConfirmation { Password = password }), Bearer(accessToken), ct).ConfigureAwait(false);
if (!PlayerOtp.TryBase32Decode(setup.Secret, out var secret) || secret.Length < 16 || setup.Secret.Length > 64 ||
!setup.Uri.StartsWith("otpauth://totp/", StringComparison.Ordinal) || setup.Uri.Length > 512 ||
!setup.Uri.Contains("secret=" + setup.Secret, StringComparison.Ordinal) || setup.Uri.Any(char.IsControl))
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible (double authentification).");
return setup;
}
/// <summary>Activation (premier code) : codes de secours, à montrer une seule fois.</summary>
public async Task<IReadOnlyList<string>> OtpEnableAsync(string accessToken, string code, CancellationToken ct = default) =>
CheckCodes(await _client.SendAsync<PlayerRecoveryCodesResponse>(HttpMethod.Post, ApiRoutes.AccountOtpEnable,
LauncherApiClient.Serialize(new PlayerOtpCodeRequest { Code = code }), Bearer(accessToken), ct).ConfigureAwait(false));
public async Task OtpDisableAsync(string accessToken, string password, string code, CancellationToken ct = default) =>
await _client.SendAsync<NoContent>(HttpMethod.Post, ApiRoutes.AccountOtpDisable,
LauncherApiClient.Serialize(new PlayerOtpConfirmRequest { Password = password, Code = code }), Bearer(accessToken), ct)
.ConfigureAwait(false);
public async Task<IReadOnlyList<string>> RegenerateRecoveryCodesAsync(string accessToken, string password, string code,
CancellationToken ct = default) =>
CheckCodes(await _client.SendAsync<PlayerRecoveryCodesResponse>(HttpMethod.Post, ApiRoutes.AccountOtpRecoveryCodes,
LauncherApiClient.Serialize(new PlayerOtpConfirmRequest { Password = password, Code = code }), Bearer(accessToken), ct)
.ConfigureAwait(false));
private static IReadOnlyList<string> CheckCodes(PlayerRecoveryCodesResponse response)
{
if (response.RecoveryCodes is not { Count: > 0 and <= 50 } codes || codes.Any(c => PlayerOtp.NormalizeRecoveryCode(c) is null))
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible (codes de secours).");
return codes;
}
/// <summary>Suppression DÉFINITIVE du compte (mot de passe exigé).</summary>
public async Task DeleteAccountAsync(string accessToken, string password, CancellationToken ct = default) =>
await _client.SendAsync<NoContent>(HttpMethod.Post, ApiRoutes.AccountDelete,
LauncherApiClient.Serialize(new PlayerPasswordConfirmation { Password = password }), Bearer(accessToken), ct).ConfigureAwait(false);
// ------------------------------------------------------------------ Défi, preuve de travail, clé d'appareil
private async Task<T> SendWithDeviceAsync<T>(string purpose, string route, IPlayerDeviceKey key, string deviceLabel,
Func<PlayerChallengeResponse, string, PlayerDeviceProof, object> build, CancellationToken ct)
{
var (challenge, solution) = await ChallengeAsync(purpose, ct).ConfigureAwait(false);
var proof = new PlayerDeviceProof
{
PublicKey = Convert.ToBase64String(key.PublicKey),
Signature = await SignAsync(key, purpose, challenge, ct).ConfigureAwait(false),
Label = deviceLabel
};
var request = build(challenge, solution, proof);
var response = await _client.SendAsync<T>(HttpMethod.Post, route, JsonSerializer.SerializeToUtf8Bytes(request, request.GetType(), ApiJson.Options),
null, ct, timeout: TimeSpan.FromMinutes(1)).ConfigureAwait(false);
if (response is PlayerLoginResponse login) Check(login);
return response;
}
/// <summary>
/// Défi du serveur, contrôlé (usage, origine, difficulté, valeurs bornées), puis preuve de travail résolue hors du fil
/// de l'interface (« » si la difficulté est nulle : renouvellement).
/// </summary>
private async Task<(PlayerChallengeResponse Challenge, string Solution)> ChallengeAsync(string purpose, CancellationToken ct)
{
var challenge = await _client.SendAsync<PlayerChallengeResponse>(HttpMethod.Post, ApiRoutes.AccountChallenge,
LauncherApiClient.Serialize(new PlayerChallengeRequest { Purpose = purpose }), null, ct).ConfigureAwait(false);
if (challenge.Purpose != purpose || challenge.Difficulty is < 0 or > FeedbackProof.MaxDifficulty ||
!IsPlainToken(challenge.ChallengeId, 512) || !IsPlainToken(challenge.ServerId, 128) ||
!Base64Url.TryDecode(challenge.Nonce, out var nonce) || nonce.Length is 0 or > 64)
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible.");
_client.EnsureSameOrigin(challenge.Origin);
var solution = challenge.Difficulty == 0
? ""
: await Task.Run(() => FeedbackProof.Solve(nonce, challenge.Difficulty, ct), ct).ConfigureAwait(false);
return (challenge, solution);
}
private async Task<string> SignAsync(IPlayerDeviceKey key, string purpose, PlayerChallengeResponse challenge, CancellationToken ct)
{
var message = PlayerMessages.Device(purpose, challenge.Origin, challenge.ServerId, challenge.ChallengeId, challenge.Nonce, key.KeyId);
return Convert.ToBase64String(await key.SignAsync(message, ct).ConfigureAwait(false));
}
/// <summary>Réponse de connexion : jetons de la session, du changement exigé ou de la 2ᵉ étape bien formés.</summary>
private static PlayerLoginResponse Check(PlayerLoginResponse response)
{
if (response.Session is { } session) Check(session);
if (response.Status == PlayerLoginStatus.OtpRequired &&
(response.OtpToken is not { } otp || !otp.StartsWith(PlayerTokens.OtpPrefix, StringComparison.Ordinal) || !IsPlainToken(otp, PlayerTokens.MaxLength)))
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible (code de vérification).");
if (response.Status == PlayerLoginStatus.PasswordChangeRequired &&
(response.PasswordChangeToken is not { } change || !change.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal) ||
!IsPlainToken(change, PlayerTokens.MaxLength)))
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible (changement de mot de passe).");
return response;
}
/// <summary>Session reçue : jetons bien formés (ils seront renvoyés tels quels dans un en-tête puis enregistrés).</summary>
private static PlayerSession Check(PlayerSession session)
{
if (!session.AccessToken.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) || !IsPlainToken(session.AccessToken, PlayerTokens.MaxLength) ||
!PlayerTokens.TryParseRefresh(session.RefreshToken, out var deviceId, out _) || deviceId != session.DeviceId)
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible (session).");
return session;
}
private static Action<HttpRequestMessage> Bearer(string token)
{
if (!IsPlainToken(token, PlayerTokens.MaxLength) ||
!(token.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) || token.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal) ||
token.StartsWith(PlayerTokens.OtpPrefix, StringComparison.Ordinal)))
throw new ArgumentException("Jeton de session invalide.", nameof(token));
return request => request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
/// <summary>Jeton ou identifiant « simple » : caractères ASCII visibles uniquement, longueur bornée.</summary>
private static bool IsPlainToken(string? value, int maxLength) =>
value is { Length: > 0 } && value.Length <= maxLength && value.All(c => c is > ' ' and < '\u007f');
}
@@ -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,308 @@
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";
/// <summary>
/// Double authentification activée : <see cref="PlayerLoginResponse.OtpToken"/> permet seulement d'envoyer le code de
/// l'application d'authentification (ou un code de secours) à POST v1/account/login/otp, qui termine la connexion.
/// </summary>
public const string OtpRequired = "otp_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>Jeton de la 2ᵉ étape (code de vérification), si <see cref="Status"/> vaut <see cref="PlayerLoginStatus.OtpRequired"/>.</summary>
public string? OtpToken { get; init; }
public DateTimeOffset? OtpExpiresAt { get; init; }
/// <summary>Connexion terminée avec un code de secours : codes encore utilisables (sinon null).</summary>
public int? RecoveryCodesLeft { get; init; }
/// <summary>
/// Connexion avec un mot de passe temporaire donné par l'équipe (étape 8d) : <see cref="Status"/> vaut alors
/// <see cref="PlayerLoginStatus.PasswordChangeRequired"/> (ou <see cref="PlayerLoginStatus.OtpRequired"/> d'abord).
/// </summary>
public bool TemporaryPassword { 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>Codes de secours encore utilisables (double authentification activée), sinon 0.</summary>
public int RecoveryCodesLeft { 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; }
}
/// <summary>Mot de passe actuel, exigé pour les actions sensibles : export des données (POST v1/account/export), suppression du
/// compte (POST v1/account/delete).</summary>
public sealed record PlayerPasswordConfirmation
{
public string Password { get; init; } = "";
}
/// <summary>GET v1/account/activity : activité récente du compte, la plus récente d'abord.</summary>
public sealed record PlayerActivityResponse
{
public IReadOnlyList<PlayerActivityEntry> Entries { get; init; } = Array.Empty<PlayerActivityEntry>();
}
/// <summary>
/// Une ligne de l'activité du compte. Le libellé est calculé par le launcher (<see cref="PlayerActivityText"/>) ; aucun détail
/// interne n'est renvoyé, et l'adresse IP est MASQUÉE (« 203.0.113.x ») : elle peut être celle d'un tiers (essai de mot de passe).
/// </summary>
public sealed record PlayerActivityEntry
{
public DateTimeOffset At { get; init; }
/// <summary>Type d'événement (« account.login »…), voir <see cref="PlayerActivityText"/>.</summary>
public string Type { get; init; } = "";
/// <summary>Faux : tentative refusée (mot de passe incorrect, connexion bloquée…).</summary>
public bool Success { get; init; }
/// <summary>Nom générique de l'appareil s'il est toujours connecté au compte, sinon null.</summary>
public string? Device { get; init; }
/// <summary>Événement venu de l'appareil de la session en cours.</summary>
public bool ThisDevice { get; init; }
/// <summary>Adresse IP masquée, ou null (effacée après quelques mois, ou action du système).</summary>
public string? Ip { get; init; }
}
/// <summary>Document d'export des données d'un joueur (POST v1/account/export), lisible par machine (JSON).</summary>
public static class PlayerDataExport
{
/// <summary>Valeur de la propriété « format » du document.</summary>
public const string Format = "GameLauncher/PlayerData/v1";
/// <summary>Nom de fichier proposé à l'enregistrement (« donnees-compte-PL-7KQ2-M9XD-2026-11-11.json »).</summary>
public static string SuggestedFileName(string reference, DateTimeOffset date)
{
var safe = new string((reference ?? "").Where(c => char.IsAsciiLetterOrDigit(c) || c == '-').ToArray());
return $"donnees-compte-{(safe.Length > 0 ? safe : "joueur")}-{date:yyyy-MM-dd}.json";
}
}
/// <summary>Code de vérification : 6 chiffres de l'application d'authentification, ou code de secours (« 7KQ2-M9XD »).</summary>
public sealed record PlayerOtpCodeRequest
{
public string Code { get; init; } = "";
}
/// <summary>Mot de passe et code de vérification : désactivation, nouveaux codes de secours.</summary>
public sealed record PlayerOtpConfirmRequest
{
public string Password { get; init; } = "";
public string Code { get; init; } = "";
}
/// <summary>
/// Préparation de la double authentification (POST v1/account/otp/setup) : secret à enregistrer dans l'application
/// d'authentification (QR code de <see cref="Uri"/>, ou clé à saisir). Renvoyé UNE fois ; à confirmer par un premier code
/// avant <see cref="ExpiresAt"/>.
/// </summary>
public sealed record PlayerOtpSetupResponse
{
/// <summary>Secret en Base32 (RFC 4648).</summary>
public string Secret { get; init; } = "";
/// <summary>Adresse « otpauth://totp/… » (contenu du QR code).</summary>
public string Uri { get; init; } = "";
public string Issuer { get; init; } = "";
public string AccountName { get; init; } = "";
public int Digits { get; init; } = PlayerOtp.Digits;
public int Period { get; init; } = PlayerOtp.PeriodSeconds;
public DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>Codes de secours (activation, renouvellement) : affichés UNE fois, le serveur n'en garde qu'une empreinte.</summary>
public sealed record PlayerRecoveryCodesResponse
{
public IReadOnlyList<string> RecoveryCodes { get; init; } = Array.Empty<string>();
}
@@ -0,0 +1,47 @@
namespace GameLauncher.Contracts.Accounts;
/// <summary>
/// Libellés français des événements du journal des comptes joueurs (« Activité récente » du launcher, export des données,
/// fiche d'un compte dans le menu d'administration). Un type inconnu (serveur plus récent) est affiché tel quel.
/// </summary>
public static class PlayerActivityText
{
public static string Describe(string type, bool success) => type switch
{
"account.register" => "Création du compte",
"account.login" => success ? "Connexion" : "Connexion refusée (compte désactivé ou suspendu)",
"account.login_failed" => "Mot de passe incorrect",
"account.locked" => "Connexion bloquée temporairement (trop d'essais)",
"account.suspended" => "Nouveaux appareils suspendus (trop d'essais)",
"account.unlocked" => "Suspension ou blocage levé par l'équipe",
"account.disabled" => "Compte désactivé",
"account.enabled" => "Compte réactivé",
"account.inactive" => "Compte mis en sommeil (inactivité)",
"account.reactivated" => "Compte réveillé (connexion après une longue inactivité)",
"account.logout" => "Déconnexion",
"account.logout_all" => "Déconnexion de tous les appareils",
"account.password_change" => success ? "Mot de passe modifié" : "Changement de mot de passe refusé (mot de passe actuel incorrect)",
"account.password_change_required" => "Nouveau mot de passe exigé",
"account.export" => success ? "Export de vos données" : "Export refusé (mot de passe incorrect)",
"account.delete" => success ? "Suppression du compte" : "Suppression du compte refusée (mot de passe incorrect)",
"session.refresh" => success ? "Reconnexion automatique" : "Reconnexion automatique refusée",
"session.reuse" => "Jeton de connexion réutilisé : appareil déconnecté par précaution",
"device.removed" => "Appareil déconnecté",
"account.otp_challenge" => "Mot de passe correct, code de vérification demandé",
"account.otp_failed" => "Code de vérification incorrect",
"account.otp_enabled" => success ? "Double authentification activée" : "Activation de la double authentification refusée (mot de passe incorrect)",
"account.otp_disabled" => success ? "Double authentification désactivée" : "Désactivation de la double authentification refusée",
"account.otp_reset" => "Double authentification réinitialisée par l'équipe",
"account.recovery_codes" => success ? "Nouveaux codes de secours" : "Nouveaux codes de secours refusés",
"account.recovery_code_used" => "Connexion avec un code de secours",
"account.password_reset_required" => "Nouveau mot de passe exigé par l'équipe",
"account.password_reset_cancelled" => "Nouveau mot de passe plus exigé (décision de l'équipe)",
"account.temporary_password" => "Mot de passe temporaire créé par l'équipe",
"account.temporary_password_used" => "Connexion avec le mot de passe temporaire",
"device.removed_by_team" => "Appareil(s) déconnecté(s) par l'équipe",
_ => type
};
/// <summary>Vrai si le type a un libellé (sinon <see cref="Describe"/> renvoie le type tel quel).</summary>
public static bool IsKnown(string type) => Describe(type, true) != type;
}
@@ -0,0 +1,293 @@
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts.Feedback;
namespace GameLauncher.Contracts.Accounts;
// Comptes joueurs vus par les ADMINISTRATEURS (étape 8d, routes « v1/players/… », permissions players.*). L'adresse e-mail
// n'est jamais renvoyée en entier : seule sa forme masquée l'est ; la recherche par adresse exacte passe par le corps d'une
// requête POST. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md, § 3.7.
/// <summary>États d'un compte joueur (valeurs de l'API) et leurs libellés.</summary>
public static class PlayerAccountStatuses
{
public const string Active = "active";
public const string Disabled = "disabled";
public const string Suspended = "suspended";
public const string Inactive = "inactive";
public static string Label(string? status) => status switch
{
Active => "Actif",
Disabled => "Désactivé",
Suspended => "Suspendu (nouveaux appareils)",
Inactive => "En sommeil (inactivité)",
null or "" => "Inconnu",
_ => status
};
}
/// <summary>Règles des actions des administrateurs sur les comptes joueurs (mêmes contrôles dans le menu et sur le serveur).</summary>
public static class PlayerAdminRules
{
/// <summary>Motif d'une désactivation, communiqué au joueur à la connexion : une ligne, sans lien.</summary>
public const int MaxReasonLength = 200;
/// <summary>Désactivation temporaire : 10 ans au plus (au-delà : « indéfiniment »).</summary>
public const int MaxDisableDays = 3650;
/// <summary>Lignes d'activité renvoyées dans la fiche d'un compte.</summary>
public const int DetailEvents = 100;
/// <summary>Recherche : texte saisi (référence ou adresse e-mail).</summary>
public const int MaxSearchLength = 254;
/// <summary>Filtres de la liste (valeur, libellé) ; null : tous les comptes.</summary>
public static readonly IReadOnlyList<(string? Value, string Label)> Filters = new (string?, string)[]
{
(null, "Tous les comptes"),
(PlayerAccountStatuses.Active, "Actifs"),
(PlayerAccountStatuses.Disabled, "Désactivés"),
(PlayerAccountStatuses.Suspended, "Suspendus (nouveaux appareils)"),
("locked", "Bloqués temporairement (trop d'essais)"),
(PlayerAccountStatuses.Inactive, "En sommeil (inactivité)"),
("password", "Nouveau mot de passe exigé"),
("otp", "Double authentification activée")
};
public static bool IsValidFilter(string? filter) => filter is null || Filters.Any(f => f.Value == filter);
/// <summary>
/// Motif facultatif (vide : null). <paramref name="problem"/> non nul si le motif est refusé : plusieurs lignes, caractère
/// invisible, trop long, ou lien (le joueur ne doit pas être renvoyé vers un site depuis un message du support).
/// </summary>
public static string? CleanReason(string? reason, out string? problem)
{
problem = null;
if (string.IsNullOrWhiteSpace(reason)) return null;
if (reason.Contains('\n') || reason.Contains('\r'))
{
problem = "une seule ligne.";
return null;
}
var clean = ApiText.CleanLine(reason, MaxReasonLength);
if (clean is null)
{
problem = $"{MaxReasonLength} caractères au plus, sans caractère invisible.";
return null;
}
if (FeedbackLinks.Contains(clean))
{
problem = "aucun lien ni adresse de site (le motif est montré au joueur).";
return null;
}
return clean;
}
}
/// <summary>
/// Mot de passe temporaire créé par un administrateur (D6) : 80 bits aléatoires, affiché « K7Q2-M9XD-4TPA-W3HN » (alphabet
/// sans lettre ambiguë), valable <see cref="Lifetime"/>, il ne sert qu'à choisir un NOUVEAU mot de passe (il n'ouvre jamais
/// de session) et ne remplace pas l'ancien tant que ce n'est pas fait. Le serveur n'en garde qu'une empreinte HMAC.
/// </summary>
public static class PlayerTemporaryPassword
{
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(60);
private const string Crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
private const int Length = 16;
public static string New()
{
var bytes = RandomNumberGenerator.GetBytes(10);
var bits = new StringBuilder(Length + 3);
int buffer = 0, count = 0;
foreach (var b in bytes)
{
buffer = (buffer << 8) | b;
count += 8;
while (count >= 5)
{
if (bits.Length is 4 or 9 or 14) bits.Append('-');
bits.Append(Crockford[(buffer >> (count - 5)) & 31]);
count -= 5;
}
}
CryptographicOperations.ZeroMemory(bytes);
return bits.ToString();
}
/// <summary>
/// Forme normalisée (16 caractères, majuscules, sans tiret ni espace ; O → 0, I et L → 1), ou null si le texte saisi
/// n'est pas un mot de passe temporaire (c'est alors un mot de passe ordinaire, comparé tel quel).
/// </summary>
public static string? Normalize(string? input)
{
if (string.IsNullOrWhiteSpace(input) || input.Length > 40) return null;
var builder = new StringBuilder(Length);
foreach (var raw in input.Trim().ToUpperInvariant())
{
if (raw is ' ' or '-') continue;
var c = raw switch { 'O' => '0', 'I' or 'L' => '1', _ => raw };
if (!Crockford.Contains(c)) return null;
builder.Append(c);
}
return builder.Length == Length ? builder.ToString() : null;
}
}
// ------------------------------------------------------------------ Réponses
/// <summary>Compteurs et réglages du service (GET v1/players/overview).</summary>
public sealed record PlayersOverview
{
/// <summary>Service des comptes activé sur ce serveur (sinon tout le reste vaut 0).</summary>
public bool Enabled { get; init; }
public bool RegistrationEnabled { get; init; }
public int Total { get; init; }
public int Active { get; init; }
public int Disabled { get; init; }
public int Suspended { get; init; }
public int Inactive { get; init; }
public int Locked { get; init; }
public int PasswordChangeRequired { get; init; }
public int OtpEnabled { get; init; }
public int CreatedLast30Days { get; init; }
public int EventsRetentionMonths { get; init; }
public int EventsIpRetentionMonths { get; init; }
public int InactiveDisableMonths { get; init; }
public int InactiveDeleteMonths { get; init; }
}
/// <summary>Compte dans une liste (adresse masquée).</summary>
public sealed record PlayerAdminSummary
{
public string Reference { get; init; } = "";
public string EmailMasked { get; init; } = "";
public string Status { get; init; } = PlayerAccountStatuses.Active;
public DateTimeOffset? DisabledUntil { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastLoginAt { get; init; }
/// <summary>Dernière utilisation d'un appareil (reconnexion automatique comprise).</summary>
public DateTimeOffset? LastActivityAt { get; init; }
public bool OtpEnabled { get; init; }
public bool MustChangePassword { get; init; }
/// <summary>Fin du blocage temporaire en cours (trop d'essais), sinon null.</summary>
public DateTimeOffset? LockedUntil { get; init; }
public int DeviceCount { get; init; }
}
/// <summary>Page de la liste (GET v1/players).</summary>
public sealed record PlayerAdminPage
{
public List<PlayerAdminSummary> Items { get; init; } = new();
public string? NextCursor { get; init; }
/// <summary>Comptes correspondant au filtre.</summary>
public int Total { get; init; }
}
/// <summary>Recherche (POST v1/players/search) : référence « PL-XXXX-XXXX » ou adresse e-mail exacte.</summary>
public sealed record PlayerSearchRequest
{
public string Query { get; init; } = "";
}
public sealed record PlayerSearchResult
{
/// <summary>« reference » ou « email » (comment le texte a été compris).</summary>
public string Kind { get; init; } = "";
/// <summary>Le compte trouvé (0 ou 1).</summary>
public List<PlayerAdminSummary> Items { get; init; } = new();
}
/// <summary>Appareil connecté d'un compte.</summary>
public sealed record PlayerAdminDevice
{
public string Id { get; init; } = "";
public string Label { get; init; } = "";
public string KeyId { get; init; } = "";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset LastUsedAt { get; init; }
public DateTimeOffset RememberedUntil { get; init; }
}
/// <summary>Ligne du journal du compte (adresse IP complète : vue des administrateurs).</summary>
public sealed record PlayerAdminEvent
{
public DateTimeOffset At { get; init; }
public string Type { get; init; } = "";
public bool Success { get; init; }
public string? Ip { get; init; }
public string? DeviceKeyId { get; init; }
/// <summary>Nom de l'appareil si la clé est encore connectée.</summary>
public string? Device { get; init; }
public string? Details { get; init; }
}
/// <summary>Fiche d'un compte (GET v1/players/{reference}), renvoyée aussi après chaque action.</summary>
public sealed record PlayerAdminDetail
{
public PlayerAdminSummary Summary { get; init; } = new();
public bool EmailVerified { get; init; }
public string TermsVersion { get; init; } = "";
public DateTimeOffset PasswordChangedAt { get; init; }
/// <summary>Motif de la désactivation (communiqué au joueur), ou null.</summary>
public string? StatusReason { get; init; }
public DateTimeOffset? StatusChangedAt { get; init; }
public string? StatusChangedBy { get; init; }
public int FailedCount { get; init; }
public int LockLevel { get; init; }
public DateTimeOffset? UnknownDevicesLockedUntil { get; init; }
public int TrustedFailedCount { get; init; }
public DateTimeOffset? TrustedDevicesLockedUntil { get; init; }
public int RecoveryCodesLeft { get; init; }
/// <summary>Mot de passe temporaire en attente d'utilisation : son échéance (sinon null).</summary>
public DateTimeOffset? TemporaryPasswordExpiresAt { get; init; }
public List<PlayerAdminDevice> Devices { get; init; } = new();
public List<PlayerAdminEvent> Events { get; init; } = new();
}
// ------------------------------------------------------------------ Actions (requêtes signées, players.manage)
/// <summary>Désactivation (POST …/disable) : jusqu'à une date (null : indéfiniment), motif facultatif montré au joueur.</summary>
public sealed record PlayerDisableRequest
{
public DateTimeOffset? Until { get; init; }
public string? Reason { get; init; }
}
/// <summary>Exiger (ou ne plus exiger) un nouveau mot de passe à la prochaine connexion (POST …/password-requirement).</summary>
public sealed record PlayerPasswordRequirementRequest
{
public bool Required { get; init; }
}
/// <summary>Déconnexion d'un appareil (identifiant) ou de tous (null) (POST …/logout).</summary>
public sealed record PlayerDevicesLogoutRequest
{
public string? DeviceId { get; init; }
}
/// <summary>Mot de passe temporaire créé (POST …/temporary-password) : affiché UNE fois à l'administrateur.</summary>
public sealed record PlayerTemporaryPasswordResponse
{
public string Password { get; init; } = "";
public DateTimeOffset ExpiresAt { get; init; }
public PlayerAdminDetail Account { get; init; } = new();
}
@@ -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,114 @@
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_";
/// <summary>Jeton limité à la 2ᵉ étape de la connexion (code de vérification), 5 min, 5 essais : « glpo_ » + 256 bits.</summary>
public const string OtpPrefix = "glpo_";
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,145 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace GameLauncher.Contracts.Accounts;
/// <summary>
/// Double authentification des comptes joueurs (étape 8c) : codes à usage unique TOTP (RFC 6238 : HMAC-SHA1, 6 chiffres,
/// 30 secondes, compatibles avec toutes les applications d'authentification : Microsoft Authenticator, Google Authenticator,
/// Aegis, 2FAS, Bitwarden…) et codes de secours (« 7KQ2-M9XD », 40 bits, usage unique).
/// Le secret ne quitte le serveur qu'une fois, à l'activation (QR code / clé à saisir) ; il est ensuite chiffré en base.
/// </summary>
public static class PlayerOtp
{
public const int Digits = 6;
public const int PeriodSeconds = 30;
/// <summary>Taille du secret (160 bits, RFC 4226 § 4).</summary>
public const int SecretBytes = 20;
/// <summary>Pas de temps acceptés de part et d'autre de l'heure du serveur (± 30 s : horloge du téléphone décalée).</summary>
public const int Window = 1;
public const int RecoveryCodeCount = 10;
/// <summary>Durée de la configuration en cours (QR code affiché, code pas encore confirmé).</summary>
public static readonly TimeSpan SetupLifetime = TimeSpan.FromMinutes(10);
private const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
private const string Crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/// <summary>Pas de temps TOTP à l'instant donné.</summary>
public static long Step(DateTimeOffset time) => time.ToUnixTimeSeconds() / PeriodSeconds;
/// <summary>Code HOTP (RFC 4226) du pas <paramref name="step"/>, sur <see cref="Digits"/> chiffres.</summary>
public static string Code(ReadOnlySpan<byte> secret, long step, int digits = Digits)
{
Span<byte> counter = stackalloc byte[8];
BinaryPrimitives.WriteInt64BigEndian(counter, step);
Span<byte> hash = stackalloc byte[20];
HMACSHA1.HashData(secret, counter, hash);
var offset = hash[19] & 0x0F;
var binary = ((hash[offset] & 0x7F) << 24) | (hash[offset + 1] << 16) | (hash[offset + 2] << 8) | hash[offset + 3];
var modulo = 1;
for (var i = 0; i < digits; i++) modulo *= 10;
return (binary % modulo).ToString(System.Globalization.CultureInfo.InvariantCulture).PadLeft(digits, '0');
}
/// <summary>Code à 6 chiffres saisi (espaces et tirets ignorés), ou null s'il n'en est pas un.</summary>
public static string? NormalizeCode(string? input)
{
if (string.IsNullOrWhiteSpace(input) || input.Length > 20) return null;
var digits = new string(input.Where(c => c is not (' ' or '-' or ' ')).ToArray());
return digits.Length == Digits && digits.All(char.IsAsciiDigit) ? digits : null;
}
/// <summary>
/// Code de secours saisi, normalisé (« 7KQ2M9XD ») : majuscules, tirets et espaces ignorés, lettres ambiguës corrigées
/// (O → 0, I et L → 1) ; null s'il n'en est pas un.
/// </summary>
public static string? NormalizeRecoveryCode(string? input)
{
if (string.IsNullOrWhiteSpace(input) || input.Length > 24) return null;
var builder = new StringBuilder(8);
foreach (var raw in input.ToUpperInvariant())
{
if (raw is ' ' or '-' or ' ') continue;
var c = raw switch { 'O' => '0', 'I' or 'L' => '1', _ => raw };
if (!Crockford.Contains(c)) return null;
builder.Append(c);
}
return builder.Length == 8 ? builder.ToString() : null;
}
/// <summary>Nouveau code de secours (40 bits aléatoires), affiché « 7KQ2-M9XD ».</summary>
public static string NewRecoveryCode()
{
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 $"{new string(chars, 0, 4)}-{new string(chars, 4, 4)}";
}
/// <summary>Base32 (RFC 4648, majuscules, sans « = ») : format des secrets dans les applications d'authentification.</summary>
public static string Base32Encode(ReadOnlySpan<byte> data)
{
var builder = new StringBuilder((data.Length * 8 + 4) / 5);
int buffer = 0, bits = 0;
foreach (var b in data)
{
buffer = (buffer << 8) | b;
bits += 8;
while (bits >= 5)
{
builder.Append(Base32Alphabet[(buffer >> (bits - 5)) & 31]);
bits -= 5;
}
}
if (bits > 0) builder.Append(Base32Alphabet[(buffer << (5 - bits)) & 31]);
return builder.ToString();
}
public static bool TryBase32Decode(string? text, out byte[] data)
{
data = Array.Empty<byte>();
if (string.IsNullOrEmpty(text) || text.Length > 256) return false;
var clean = text.Replace(" ", "", StringComparison.Ordinal).TrimEnd('=').ToUpperInvariant();
var output = new List<byte>(clean.Length * 5 / 8);
int buffer = 0, bits = 0;
foreach (var c in clean)
{
var value = Base32Alphabet.IndexOf(c);
if (value < 0) return false;
buffer = (buffer << 5) | value;
bits += 5;
if (bits >= 8)
{
output.Add((byte)((buffer >> (bits - 8)) & 0xFF));
bits -= 8;
}
}
data = output.ToArray();
return true;
}
/// <summary>Clé à saisir à la main, par groupes de 4 (« JBSW Y3DP EHPK 3PXP »).</summary>
public static string FormatSecret(string base32) =>
string.Join(' ', Enumerable.Range(0, (base32.Length + 3) / 4).Select(i => base32.Substring(i * 4, Math.Min(4, base32.Length - i * 4))));
/// <summary>
/// Adresse « otpauth://totp/… » lue par les applications d'authentification (format « Key Uri » de Google, repris par
/// toutes) : émetteur et nom du compte encodés, paramètres explicites.
/// </summary>
public static string AuthUri(string issuer, string accountName, string secretBase32)
{
var label = Uri.EscapeDataString(issuer) + ":" + Uri.EscapeDataString(accountName);
return $"otpauth://totp/{label}?secret={secretBase32}&issuer={Uri.EscapeDataString(issuer)}&algorithm=SHA1&digits={Digits}&period={PeriodSeconds}";
}
}
@@ -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).
+94
View File
@@ -151,6 +151,100 @@ 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}";
/// <summary>Activité récente du compte (connexions, échecs, changements…), adresses IP masquées.</summary>
public const string AccountActivity = "v1/account/activity";
/// <summary>Export de toutes ses données (RGPD : accès et portabilité), mot de passe exigé. Document JSON.</summary>
public const string AccountExport = "v1/account/export";
/// <summary>Suppression définitive du compte par son titulaire (RGPD : effacement), mot de passe exigé.</summary>
public const string AccountDelete = "v1/account/delete";
/// <summary>Connexion, 2ᵉ étape : code de l'application d'authentification ou code de secours (jeton « glpo_ »).</summary>
public const string AccountLoginOtp = "v1/account/login/otp";
/// <summary>Double authentification : préparation (mot de passe exigé ; secret et adresse du QR code renvoyés une fois).</summary>
public const string AccountOtpSetup = "v1/account/otp/setup";
/// <summary>Double authentification : activation (premier code de l'application) ; renvoie les codes de secours.</summary>
public const string AccountOtpEnable = "v1/account/otp/enable";
/// <summary>Double authentification : désactivation (mot de passe + code exigés).</summary>
public const string AccountOtpDisable = "v1/account/otp/disable";
/// <summary>Nouveaux codes de secours (mot de passe + code exigés) ; les anciens ne valent plus rien.</summary>
public const string AccountOtpRecoveryCodes = "v1/account/otp/recovery-codes";
// ------------------------------------------------------------------ Comptes joueurs : administration (étape 8d)
// Routes d'ADMINISTRATION (jetons et requêtes signées des administrateurs, permissions players.*) : aucun jeton joueur
// n'y est accepté. Un compte y est désigné par sa référence (« PL-7KQ2-M9XD » : rien à encoder).
/// <summary>Compteurs et réglages du service des comptes (players.read).</summary>
public const string PlayersOverview = "v1/players/overview";
/// <summary>Liste des comptes, les plus récents d'abord (players.read).</summary>
public const string Players = "v1/players";
/// <summary>
/// Recherche par référence ou par adresse e-mail EXACTE (POST : l'adresse ne doit jamais apparaître dans une adresse de
/// page, ni donc dans les journaux du serveur web) ; players.read.
/// </summary>
public const string PlayersSearch = "v1/players/search";
/// <summary>Fiche d'un compte (GET, players.read) ; suppression définitive (DELETE signé, players.delete).</summary>
public const string PlayerPattern = "v1/players/{reference}";
public const string PlayerDisablePattern = "v1/players/{reference}/disable";
public const string PlayerEnablePattern = "v1/players/{reference}/enable";
public const string PlayerUnlockPattern = "v1/players/{reference}/unlock";
public const string PlayerPasswordRequirementPattern = "v1/players/{reference}/password-requirement";
public const string PlayerTemporaryPasswordPattern = "v1/players/{reference}/temporary-password";
public const string PlayerOtpResetPattern = "v1/players/{reference}/otp-reset";
public const string PlayerLogoutPattern = "v1/players/{reference}/logout";
/// <summary>Liste filtrée (<see cref="Accounts.PlayerAdminRules.Filters"/>) ; curseur encodé.</summary>
public static string PlayersPage(string? filter = null, string? cursor = null, int limit = 100)
{
var route = $"{Players}?limit={limit}";
if (!string.IsNullOrEmpty(filter)) route += $"&filter={Uri.EscapeDataString(filter)}";
if (!string.IsNullOrEmpty(cursor)) route += $"&cursor={Uri.EscapeDataString(cursor)}";
return route;
}
public static string Player(string reference) => $"v1/players/{reference}";
/// <summary>Action sur un compte (requête signée, players.manage) : « disable », « enable », « unlock »…</summary>
public static string PlayerAction(string reference, string action) => $"v1/players/{reference}/{action}";
// ------------------------------------------------------------------ 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,49 @@ 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";
/// <summary>Code de vérification (application d'authentification ou code de secours) incorrect ou déjà utilisé.</summary>
public const string OtpInvalid = "otp_invalid";
/// <summary>Le code de vérification doit d'abord être saisi (jeton « glpo_ » présenté à une autre route).</summary>
public const string OtpRequired = "otp_required";
}
@@ -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>
@@ -84,7 +84,8 @@ public static partial class AdminGroups
public static string? DefaultDescription(string groupId) => groupId switch
{
AdminRoles.Owner => "Tous les droits, y compris ceux des permissions créées plus tard. Groupe système : non modifiable.",
AdminRoles.Admin => "Jeux, notes de version, publication des versions, fichiers du serveur, demandes des joueurs et journal d'audit.",
AdminRoles.Admin => "Jeux, notes de version, publication des versions, fichiers du serveur, demandes et comptes des joueurs (sans " +
"suppression), mode maintenance et journal d'audit.",
AdminRoles.Editor => "Informations des jeux et notes de version.",
_ => null
};
@@ -40,10 +40,22 @@ public static class AdminPermissions
/// <summary>Mode maintenance du launcher : activation, désactivation et message affiché aux joueurs (publication signée).</summary>
public const string MaintenanceManage = "maintenance.manage";
/// <summary>Comptes joueurs : liste, recherche et fiche (adresse masquée, appareils, activité avec adresses IP) — phase 8d.</summary>
public const string PlayersRead = "players.read";
/// <summary>
/// Comptes joueurs : désactiver / réactiver, lever une suspension ou un blocage, exiger un nouveau mot de passe, mot de
/// passe temporaire, réinitialiser la double authentification, déconnecter des appareils (requêtes signées).
/// </summary>
public const string PlayersManage = "players.manage";
/// <summary>Comptes joueurs : suppression définitive d'un compte (requête signée).</summary>
public const string PlayersDelete = "players.delete";
public static readonly IReadOnlyList<string> All = new[]
{
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage, FeedbackForms,
MaintenanceManage
MaintenanceManage, PlayersRead, PlayersManage, PlayersDelete
};
/// <summary>Permission intégrée (vérifiée par le code : jamais supprimable).</summary>
@@ -62,6 +74,9 @@ public static class AdminPermissions
FeedbackManage => "Demandes des joueurs (traitement)",
FeedbackForms => "Formulaires des demandes",
MaintenanceManage => "Mode maintenance",
PlayersRead => "Comptes joueurs (lecture)",
PlayersManage => "Comptes joueurs (gestion)",
PlayersDelete => "Comptes joueurs (suppression)",
_ => null
};
@@ -84,6 +99,12 @@ public static class AdminPermissions
MaintenanceManage => "Activer ou désactiver le mode maintenance du launcher (tout est bloqué chez les joueurs, sauf la " +
"désinstallation des jeux) et modifier son message. Publication signée : la clé doit avoir " +
"l'autorisation « mode maintenance » du trousseau.",
PlayersRead => "Rechercher et consulter les comptes des joueurs : adresse masquée, état, appareils et activité avec les " +
"adresses IP (données personnelles). Chaque consultation est inscrite au journal d'audit.",
PlayersManage => "Désactiver ou réactiver un compte joueur, lever une suspension ou un blocage, exiger un nouveau mot de " +
"passe, créer un mot de passe temporaire, réinitialiser la double authentification, déconnecter des " +
"appareils (à donner avec la lecture).",
PlayersDelete => "Supprimer définitivement un compte joueur et toutes ses données (irréversible ; à donner avec la lecture).",
_ => null
};
}
@@ -28,7 +28,7 @@ public static class AdminRoles
AdminPermissions.AuditRead, AdminPermissions.GamesEdit,
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish, AdminPermissions.FilesManage,
AdminPermissions.FeedbackRead, AdminPermissions.FeedbackManage, AdminPermissions.FeedbackForms,
AdminPermissions.MaintenanceManage
AdminPermissions.MaintenanceManage, AdminPermissions.PlayersRead, AdminPermissions.PlayersManage
},
[Editor] = new[] { AdminPermissions.GamesEdit, AdminPermissions.ChangelogEdit }
};
@@ -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)
@@ -15,7 +15,7 @@ namespace GameLauncher.Core.Models;
/// </summary>
public sealed class LauncherCatalog
{
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl", "catalogUrl", "keyringUrl", "contentSigning", "keyringMinSequence" };
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl", "catalogUrl", "keyringUrl", "contentSigning", "keyringMinSequence", "playerAccounts" };
/// <summary>Nom par défaut du dossier des données du launcher (%AppData%\&lt;Publisher&gt;\GameLauncher).</summary>
public const string DefaultDataFolderName = "GameLauncher";
@@ -126,6 +126,13 @@ public sealed class LauncherCatalog
/// </summary>
public long KeyringMinSequence { get; private set; }
/// <summary>
/// « playerAccounts » : bouton « Compte » (comptes joueurs : connexion, création, « Mon compte »), avec l'API de
/// <see cref="ApiUrl"/>. Faux par défaut : à activer quand les comptes sont ouverts sur le serveur. Comme apiUrl, le
/// launcher publié ne le lit que dans le launcher.json intégré à l'exe.
/// </summary>
public bool PlayerAccounts { get; private set; }
/// <summary>Affichage modifié par le catalogue (voir Catalog.CatalogMerger).</summary>
internal void ApplyDisplay(string launcherName, string? defaultGameId, IReadOnlyList<LauncherConfig> games)
{
@@ -317,7 +324,8 @@ public sealed class LauncherCatalog
ApiUrlError = singleApiUrlError,
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var singleCatalogError),
CatalogUrlError = singleCatalogError,
DataFolderName = ReadDataFolder(root)
DataFolderName = ReadDataFolder(root),
PlayerAccounts = ReadFlag(root, "playerAccounts")
}.WithContentSigning(root);
}
@@ -372,7 +380,8 @@ public sealed class LauncherCatalog
ApiUrlError = apiUrlError,
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var catalogError),
CatalogUrlError = catalogError,
DataFolderName = ReadDataFolder(root)
DataFolderName = ReadDataFolder(root),
PlayerAccounts = ReadFlag(root, "playerAccounts")
}.WithContentSigning(root);
}
@@ -406,6 +415,10 @@ public sealed class LauncherCatalog
return this;
}
/// <summary>Booléen facultatif (faux si absent ou mal écrit).</summary>
private static bool ReadFlag(JsonObject root, string name) =>
Get(root, name) is JsonValue value && value.TryGetValue<bool>(out var flag) && flag;
/// <summary>« dataFolder » : nom simple de dossier (« GameLauncher » par défaut).</summary>
private static string ReadDataFolder(JsonObject root)
{
@@ -0,0 +1,433 @@
using System.Collections.ObjectModel;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Core.Utils;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Accounts;
/// <summary>Étapes de la carte « Double authentification » de « Mon compte › Profil ».</summary>
public enum TwoFactorStage
{
Idle,
/// <summary>Activation : mot de passe demandé.</summary>
Password,
/// <summary>Activation : QR code (ou clé à saisir) puis premier code de l'application.</summary>
Scan,
/// <summary>Codes de secours affichés (une seule fois) : à noter avant de terminer.</summary>
Codes,
/// <summary>Désactivation : mot de passe et code.</summary>
Disable,
/// <summary>Nouveaux codes de secours : mot de passe et code.</summary>
Regenerate
}
/// <summary>
/// Double authentification dans la fenêtre « Compte » (étape 8c) : 2ᵉ étape de la connexion (code de l'application ou code
/// de secours), puis activation (QR code calculé sur ce PC par <see cref="QrCode"/>), codes de secours, désactivation.
/// Le secret n'est gardé qu'en mémoire pendant l'activation, puis oublié ; les codes de secours aussi, une fois notés.
/// </summary>
public sealed partial class AccountWindowViewModel
{
private string? _otpToken;
private string? _otpPassword;
private DateTime _otpStarted;
private string _otpCode = "";
private TwoFactorStage _twoFactorStage;
private string? _qrPathData;
private string _otpSecret = "";
private string _otpAccountText = "";
private string _setupCode = "";
private string _confirmCode = "";
private bool _codesSaved;
public ObservableCollection<string> RecoveryCodes { get; } = new();
public RelayCommand StartOtpSetupCommand { get; private set; } = null!;
public RelayCommand StartOtpDisableCommand { get; private set; } = null!;
public RelayCommand StartRegenerateCodesCommand { get; private set; } = null!;
public RelayCommand CancelTwoFactorCommand { get; private set; } = null!;
public RelayCommand CopySecretCommand { get; private set; } = null!;
public RelayCommand CopyRecoveryCodesCommand { get; private set; } = null!;
public RelayCommand SaveRecoveryCodesCommand { get; private set; } = null!;
public RelayCommand FinishRecoveryCodesCommand { get; private set; } = null!;
private void InitializeTwoFactor()
{
StartOtpSetupCommand = new RelayCommand(() => StartTwoFactor(TwoFactorStage.Password), () => !IsBusy && !OtpEnabled);
StartOtpDisableCommand = new RelayCommand(() => StartTwoFactor(TwoFactorStage.Disable), () => !IsBusy && OtpEnabled);
StartRegenerateCodesCommand = new RelayCommand(() => StartTwoFactor(TwoFactorStage.Regenerate), () => !IsBusy && OtpEnabled);
CancelTwoFactorCommand = new RelayCommand(() => TwoFactorStage = TwoFactorStage.Idle, () => !IsBusy);
CopySecretCommand = new RelayCommand(() =>
{
_dialogs.CopyToClipboard(_otpSecret);
ClearMessages();
SuccessMessage = "Clé copiée : collez-la dans votre application d'authentification.";
}, () => _otpSecret.Length > 0);
CopyRecoveryCodesCommand = new RelayCommand(() =>
{
_dialogs.CopyToClipboard(RecoveryCodesText);
ClearMessages();
SuccessMessage = "Codes de secours copiés. Rangez-les en lieu sûr (gestionnaire de mots de passe), puis videz le presse-papiers.";
}, () => RecoveryCodes.Count > 0);
SaveRecoveryCodesCommand = new RelayCommand(SaveRecoveryCodes, () => RecoveryCodes.Count > 0);
FinishRecoveryCodesCommand = new RelayCommand(() =>
{
TwoFactorStage = TwoFactorStage.Idle;
ClearMessages();
SuccessMessage = OtpEnabled ? "Double authentification active : un code sera demandé à chaque connexion avec le mot de passe." : null;
}, () => CodesSaved);
}
// ------------------------------------------------------------------ Connexion, 2ᵉ étape
/// <summary>Code saisi (6 chiffres de l'application, ou code de secours « 7KQ2-M9XD »).</summary>
public string OtpCode
{
get => _otpCode;
set => SetProperty(ref _otpCode, value ?? "");
}
public int OtpCodeMaxLength => 24;
public string OtpHelpText =>
"Ouvrez votre application d'authentification et saisissez le code à 6 chiffres affiché pour " +
$"{_publisher}. Téléphone indisponible ? Saisissez un de vos codes de secours (ex. 7KQ2-M9XD).";
/// <summary>Mot de passe correct, code demandé : jeton de la 2ᵉ étape (5 minutes) et mot de passe gardés en mémoire.</summary>
private void BeginOtpStep(string otpToken, string password)
{
GoTo(AccountStep.Otp);
_otpToken = otpToken;
_otpPassword = password; // redemandé par le serveur si un nouveau mot de passe est exigé ensuite ; oublié après
_otpStarted = DateTime.UtcNow;
OtpCode = "";
}
public async Task VerifyOtpAsync()
{
if (IsBusy) return;
ClearMessages();
if (_otpToken is null || DateTime.UtcNow - _otpStarted > TimeSpan.FromMinutes(4.5))
{
GoTo(AccountStep.SignIn);
ErrorMessage = "Délai dépassé : reconnectez-vous.";
return;
}
if (PlayerOtp.NormalizeCode(OtpCode) is null && PlayerOtp.NormalizeRecoveryCode(OtpCode) is null)
{
ErrorMessage = "Saisissez le code à 6 chiffres de votre application, ou un code de secours (ex. 7KQ2-M9XD).";
return;
}
await RunAsync("Vérification du code…", async () =>
{
PlayerSignInResult result;
try
{
result = await _service.VerifyOtpAsync(_otpToken, OtpCode, RememberMe, _lifetime.Token);
}
catch (ApiException ex) when (ex.Status is 401 or 429 || ex.Code is ApiErrorCodes.AccountDisabled or ApiErrorCodes.AccountSuspended)
{
// Trop d'essais, connexion bloquée, ou compte désactivé : tout est à reprendre depuis le mot de passe.
GoTo(AccountStep.SignIn);
ErrorMessage = PlayerAccountTexts.Describe(ex);
return;
}
OtpCode = "";
if (result.PasswordChangeRequired)
{
BeginRequiredChange(result.PasswordChangeToken, _otpPassword, result.TemporaryPassword);
return;
}
await EnterAccountAsync();
if (result.RecoveryCodesLeft is { } left)
SuccessMessage = left switch
{
0 => "Connecté avec votre DERNIER code de secours : créez-en de nouveaux dès maintenant (Profil › Double authentification).",
<= 3 => $"Connecté avec un code de secours : il vous en reste {left}. Pensez à en créer de nouveaux.",
_ => $"Connecté avec un code de secours : il vous en reste {left}."
};
});
}
private void ForgetOtp()
{
_otpToken = null;
_otpPassword = null;
_otpCode = "";
}
// ------------------------------------------------------------------ Mon compte : double authentification
public bool OtpEnabled => _account?.OtpEnabled == true;
public int RecoveryCodesLeft => _account?.RecoveryCodesLeft ?? 0;
public bool OtpDisabled => !OtpEnabled;
public string TwoFactorStatusText => OtpEnabled
? $"Activée · {RecoveryCodesLeft} code{(RecoveryCodesLeft > 1 ? "s" : "")} de secours restant{(RecoveryCodesLeft > 1 ? "s" : "")}"
: "Désactivée : seul le mot de passe protège votre compte.";
/// <summary>Peu de codes de secours restants : à renouveler.</summary>
public bool ShowRecoveryWarning => OtpEnabled && RecoveryCodesLeft <= 3;
public string RecoveryWarningText => RecoveryCodesLeft == 0
? "Vous n'avez plus de code de secours : sans votre téléphone, vous ne pourriez plus vous connecter. Créez-en de nouveaux."
: $"Plus que {RecoveryCodesLeft} code{(RecoveryCodesLeft > 1 ? "s" : "")} de secours : créez-en de nouveaux.";
public TwoFactorStage TwoFactorStage
{
get => _twoFactorStage;
private set
{
if (!SetProperty(ref _twoFactorStage, value)) return;
foreach (var name in new[]
{
nameof(IsTwoFactorIdle), nameof(IsTwoFactorPassword), nameof(IsTwoFactorScan), nameof(IsTwoFactorCodes),
nameof(IsTwoFactorConfirm), nameof(TwoFactorConfirmTitle), nameof(TwoFactorConfirmText), nameof(TwoFactorConfirmButton),
nameof(IsTwoFactorDisable)
})
OnPropertyChanged(name);
if (value != TwoFactorStage.Scan) ForgetSetup();
if (value != TwoFactorStage.Codes) ForgetRecoveryCodes();
if (value != TwoFactorStage.Idle) ConfirmCode = "";
ClearPasswordsRequested?.Invoke();
}
}
public bool IsTwoFactorIdle => TwoFactorStage == TwoFactorStage.Idle;
public bool IsTwoFactorPassword => TwoFactorStage == TwoFactorStage.Password;
public bool IsTwoFactorScan => TwoFactorStage == TwoFactorStage.Scan;
public bool IsTwoFactorCodes => TwoFactorStage == TwoFactorStage.Codes;
public bool IsTwoFactorConfirm => TwoFactorStage is TwoFactorStage.Disable or TwoFactorStage.Regenerate;
public bool IsTwoFactorDisable => TwoFactorStage == TwoFactorStage.Disable;
public string TwoFactorConfirmTitle => TwoFactorStage == TwoFactorStage.Disable
? "Désactiver la double authentification"
: "Créer de nouveaux codes de secours";
public string TwoFactorConfirmText => TwoFactorStage == TwoFactorStage.Disable
? "Votre compte ne sera plus protégé que par le mot de passe. Vos codes de secours seront supprimés."
: "Vos anciens codes de secours ne fonctionneront plus.";
public string TwoFactorConfirmButton => TwoFactorStage == TwoFactorStage.Disable ? "Désactiver" : "Créer les codes";
/// <summary>QR code de l'adresse « otpauth:// » (tracé vectoriel, voir <see cref="QrCode.ToPathData"/>), pendant l'activation.</summary>
public string? QrPathData
{
get => _qrPathData;
private set => SetProperty(ref _qrPathData, value);
}
/// <summary>Clé à saisir à la main, par groupes de 4.</summary>
public string OtpSecretText => PlayerOtp.FormatSecret(_otpSecret);
/// <summary>Nom affiché dans l'application (« LanisysGames · j***@e***.fr »).</summary>
public string OtpAccountText
{
get => _otpAccountText;
private set => SetProperty(ref _otpAccountText, value);
}
public string SetupHelpText =>
"1. Installez une application d'authentification sur votre téléphone (Microsoft Authenticator, Google Authenticator, " +
"Aegis, 2FAS…). 2. Scannez ce QR code, ou saisissez la clé. 3. Tapez le code à 6 chiffres affiché.";
public string SetupCode
{
get => _setupCode;
set => SetProperty(ref _setupCode, value ?? "");
}
/// <summary>Code demandé pour désactiver ou créer de nouveaux codes de secours (application ou code de secours).</summary>
public string ConfirmCode
{
get => _confirmCode;
set => SetProperty(ref _confirmCode, value ?? "");
}
public string RecoveryCodesHelpText =>
"Chaque code ne sert qu'une fois, si votre téléphone est perdu ou indisponible. Ils ne seront plus jamais affichés : " +
"enregistrez-les ou copiez-les dans un gestionnaire de mots de passe.";
public string RecoveryCodesText => string.Join(Environment.NewLine, RecoveryCodes);
public bool CodesSaved
{
get => _codesSaved;
set
{
if (SetProperty(ref _codesSaved, value)) FinishRecoveryCodesCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Activation, 1ʳᵉ étape : mot de passe vérifié par le serveur, qui renvoie le secret (QR code calculé ici).</summary>
public async Task BeginOtpSetupAsync(string password)
{
if (IsBusy) return;
ClearMessages();
if (string.IsNullOrEmpty(password))
{
ErrorMessage = "Saisissez votre mot de passe.";
return;
}
await RunAsync("Préparation de la double authentification…", async () =>
{
var setup = await _service.BeginOtpSetupAsync(password, _lifetime.Token);
_otpSecret = setup.Secret;
OtpAccountText = $"{setup.Issuer} · {setup.AccountName}";
QrPathData = QrCode.Encode(setup.Uri).ToPathData();
OnPropertyChanged(nameof(OtpSecretText));
CopySecretCommand.RaiseCanExecuteChanged();
SetupCode = "";
TwoFactorStage = TwoFactorStage.Scan;
});
}
/// <summary>Activation, 2ᵉ étape : premier code de l'application ; les codes de secours s'affichent.</summary>
public async Task ConfirmOtpSetupAsync()
{
if (IsBusy) return;
ClearMessages();
if (PlayerOtp.NormalizeCode(SetupCode) is null)
{
ErrorMessage = "Saisissez le code à 6 chiffres affiché par l'application.";
return;
}
await RunAsync("Activation…", async () =>
{
var codes = await _service.EnableOtpAsync(SetupCode, _lifetime.Token);
await LoadMeAsync();
ShowRecoveryCodes(codes);
SuccessMessage = "Double authentification activée. Notez maintenant vos codes de secours.";
});
}
/// <summary>Désactivation, ou nouveaux codes de secours : mot de passe (vue) et code saisi.</summary>
public async Task ConfirmTwoFactorAsync(string password)
{
if (IsBusy || !IsTwoFactorConfirm) return;
ClearMessages();
if (string.IsNullOrEmpty(password))
{
ErrorMessage = "Saisissez votre mot de passe.";
return;
}
if (PlayerOtp.NormalizeCode(ConfirmCode) is null && PlayerOtp.NormalizeRecoveryCode(ConfirmCode) is null)
{
ErrorMessage = "Saisissez le code de votre application (ou un code de secours).";
return;
}
var disable = TwoFactorStage == TwoFactorStage.Disable;
if (disable && !_dialogs.Confirm("Désactiver la double authentification ?",
"Votre compte ne sera plus protégé que par le mot de passe.", "Désactiver", "Annuler", danger: true))
return;
await RunAsync(disable ? "Désactivation…" : "Création des codes…", async () =>
{
if (disable)
{
await _service.DisableOtpAsync(password, ConfirmCode, _lifetime.Token);
TwoFactorStage = TwoFactorStage.Idle;
await LoadMeAsync();
SuccessMessage = "Double authentification désactivée.";
return;
}
var codes = await _service.RegenerateRecoveryCodesAsync(password, ConfirmCode, _lifetime.Token);
await LoadMeAsync();
ShowRecoveryCodes(codes);
SuccessMessage = "Nouveaux codes de secours créés : les anciens ne fonctionnent plus.";
});
}
private void StartTwoFactor(TwoFactorStage stage)
{
ClearMessages();
IsChangingPassword = false;
TwoFactorStage = stage;
}
private void ShowRecoveryCodes(IReadOnlyList<string> codes)
{
TwoFactorStage = TwoFactorStage.Codes;
foreach (var code in codes) RecoveryCodes.Add(code);
CodesSaved = false;
OnPropertyChanged(nameof(RecoveryCodesText));
CopyRecoveryCodesCommand.RaiseCanExecuteChanged();
SaveRecoveryCodesCommand.RaiseCanExecuteChanged();
}
private void SaveRecoveryCodes()
{
var path = _dialogs.PickSaveFile("Enregistrer les codes de secours", "Fichier texte (*.txt)|*.txt",
$"codes-de-secours-{_publisher}-{Reference}.txt", Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments));
if (path is null) return;
try
{
File.WriteAllText(path,
$"Codes de secours du compte {_publisher} {EmailMasked} ({Reference}){Environment.NewLine}" +
$"Créés le {DateTimeOffset.Now:dd/MM/yyyy à HH:mm}. Chaque code ne sert qu'une fois.{Environment.NewLine}{Environment.NewLine}" +
RecoveryCodesText + Environment.NewLine);
ClearMessages();
SuccessMessage = $"Codes enregistrés dans {Path.GetFileName(path)}. Gardez ce fichier en lieu sûr (pas sur un PC partagé).";
CodesSaved = true;
Log.Info("Compte : codes de secours enregistrés dans un fichier choisi par le joueur.");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
ErrorMessage = PlayerAccountTexts.Describe(ex);
}
}
private void ForgetSetup()
{
_otpSecret = "";
_setupCode = "";
QrPathData = null;
OtpAccountText = "";
OnPropertyChanged(nameof(OtpSecretText));
OnPropertyChanged(nameof(SetupCode));
CopySecretCommand?.RaiseCanExecuteChanged();
}
private void ForgetRecoveryCodes()
{
if (RecoveryCodes.Count == 0) return;
RecoveryCodes.Clear();
CodesSaved = false;
OnPropertyChanged(nameof(RecoveryCodesText));
}
private void ResetTwoFactor()
{
TwoFactorStage = TwoFactorStage.Idle;
ForgetSetup();
ForgetRecoveryCodes();
}
private void RaiseTwoFactorCommands()
{
StartOtpSetupCommand?.RaiseCanExecuteChanged();
StartOtpDisableCommand?.RaiseCanExecuteChanged();
StartRegenerateCodesCommand?.RaiseCanExecuteChanged();
CancelTwoFactorCommand?.RaiseCanExecuteChanged();
}
/// <summary>Compte relu : état de la double authentification.</summary>
private void OnTwoFactorAccountChanged()
{
foreach (var name in new[]
{
nameof(OtpEnabled), nameof(OtpDisabled), nameof(RecoveryCodesLeft), nameof(TwoFactorStatusText), nameof(ShowRecoveryWarning),
nameof(RecoveryWarningText)
})
OnPropertyChanged(name);
RaiseTwoFactorCommands();
}
}
@@ -0,0 +1,927 @@
using System.Collections.ObjectModel;
using System.Globalization;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Core.Utils;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Accounts;
/// <summary>Écrans de la fenêtre « Compte ».</summary>
public enum AccountStep
{
Loading,
/// <summary>Comptes non ouverts sur le serveur, ou serveur injoignable (bouton « Réessayer »).</summary>
Unavailable,
SignIn,
/// <summary>Connexion, 2ᵉ étape : code de l'application d'authentification ou code de secours (double authentification).</summary>
Otp,
Register,
/// <summary>Nouveau mot de passe exigé par un administrateur (après une connexion réussie).</summary>
PasswordChange,
/// <summary>« Mon compte » (onglets <see cref="AccountSection"/>).</summary>
Account
}
/// <summary>Onglets de « Mon compte ».</summary>
public enum AccountSection
{
Profile,
Devices,
Activity,
Data
}
/// <summary>
/// Fenêtre « Compte » (bouton de la barre de titre) : connexion, création de compte, nouveau mot de passe exigé, puis
/// « Mon compte » (profil et mot de passe, appareils connectés, activité récente, export et suppression des données).
/// Les mots de passe ne sont JAMAIS gardés par ce ViewModel : la vue (PasswordBox) les passe aux méthodes au moment de
/// l'action puis efface ses champs (<see cref="ClearPasswordsRequested"/>) ; seul le mot de passe tapé à la connexion est
/// conservé quelques minutes quand un administrateur exige d'en changer (il est demandé par le serveur).
/// </summary>
public sealed partial class AccountWindowViewModel : ObservableObject, IDisposable
{
private static readonly CultureInfo French = CultureInfo.GetCultureInfo("fr-FR");
/// <summary>Durée de validité du jeton « changement exigé » (serveur : 10 minutes).</summary>
private static readonly TimeSpan PasswordChangeWindow = TimeSpan.FromMinutes(9);
private readonly IPlayerAccountService _service;
private readonly IModuleDialogs _dialogs;
private readonly IUiDispatcher _ui;
private readonly string _publisher;
private readonly CancellationTokenSource _lifetime = new();
private AccountStep _step = AccountStep.Loading;
private AccountSection _section = AccountSection.Profile;
private bool _isBusy;
private string _busyText = "";
private string? _errorMessage;
private string? _successMessage;
private string? _unavailableText;
private string _email = "";
private bool _rememberMe = true;
private bool _showForgotHelp;
private bool _acceptTerms;
private bool _registrationOpen;
private string _termsVersion = "";
private bool _isChangingPassword;
private bool _deleteConfirmed;
private bool _isActivityLoading;
private bool _activityLoaded;
private string? _changeToken;
private string? _changeCurrentPassword;
private DateTime _changeStarted;
private PlayerAccountInfo? _account;
public AccountWindowViewModel(IPlayerAccountService service, IModuleDialogs dialogs, IUiDispatcher ui, string publisher)
{
_service = service;
_dialogs = dialogs;
_ui = ui;
_publisher = string.IsNullOrWhiteSpace(publisher) ? "LanisysGames" : publisher.Trim();
CloseCommand = new RelayCommand(() => CloseRequested?.Invoke(), () => !IsBusy);
RetryCommand = new AsyncRelayCommand(InitializeAsync, () => !IsBusy);
ShowRegisterCommand = new RelayCommand(() => GoTo(AccountStep.Register), () => !IsBusy && RegistrationOpen);
ShowSignInCommand = new RelayCommand(() => GoTo(AccountStep.SignIn), () => !IsBusy);
ToggleForgotCommand = new RelayCommand(() => ShowForgotHelp = !ShowForgotHelp);
ShowChangePasswordCommand = new RelayCommand(() =>
{
ClearMessages();
if (TwoFactorStage != TwoFactorStage.Codes) TwoFactorStage = TwoFactorStage.Idle;
IsChangingPassword = true;
}, () => !IsBusy);
CancelChangePasswordCommand = new RelayCommand(() => IsChangingPassword = false, () => !IsBusy);
CopyReferenceCommand = new RelayCommand(CopyReference, () => !string.IsNullOrEmpty(Reference));
SignOutCommand = new AsyncRelayCommand(() => SignOutAsync(allDevices: false), () => !IsBusy);
SignOutAllCommand = new AsyncRelayCommand(() => SignOutAsync(allDevices: true), () => !IsBusy);
RefreshCommand = new AsyncRelayCommand(RefreshAccountAsync, () => !IsBusy);
InitializeTwoFactor();
_service.Changed += OnServiceChanged;
}
/// <summary>La vue efface ses champs de mot de passe (changement d'écran, action terminée).</summary>
public event Action? ClearPasswordsRequested;
public event Action? CloseRequested;
public PasswordRulesViewModel NewPassword { get; } = new();
public ObservableCollection<PlayerDeviceItem> Devices { get; } = new();
public ObservableCollection<PlayerActivityItem> Activity { get; } = new();
public RelayCommand CloseCommand { get; }
public AsyncRelayCommand RetryCommand { get; }
public RelayCommand ShowRegisterCommand { get; }
public RelayCommand ShowSignInCommand { get; }
public RelayCommand ToggleForgotCommand { get; }
public RelayCommand ShowChangePasswordCommand { get; }
public RelayCommand CancelChangePasswordCommand { get; }
public RelayCommand CopyReferenceCommand { get; }
public AsyncRelayCommand SignOutCommand { get; }
public AsyncRelayCommand SignOutAllCommand { get; }
public AsyncRelayCommand RefreshCommand { get; }
// ------------------------------------------------------------------ Écran
public AccountStep Step
{
get => _step;
private set
{
if (!SetProperty(ref _step, value)) return;
OnPropertyChanged(nameof(IsLoading));
OnPropertyChanged(nameof(IsUnavailable));
OnPropertyChanged(nameof(IsSignIn));
OnPropertyChanged(nameof(IsOtp));
OnPropertyChanged(nameof(IsRegister));
OnPropertyChanged(nameof(IsPasswordChange));
OnPropertyChanged(nameof(IsAccount));
OnPropertyChanged(nameof(Title));
OnPropertyChanged(nameof(Subtitle));
OnPropertyChanged(nameof(HeaderIcon));
ShowRegisterCommand.RaiseCanExecuteChanged();
}
}
public bool IsLoading => Step == AccountStep.Loading;
public bool IsUnavailable => Step == AccountStep.Unavailable;
public bool IsSignIn => Step == AccountStep.SignIn;
public bool IsOtp => Step == AccountStep.Otp;
public bool IsRegister => Step == AccountStep.Register;
public bool IsPasswordChange => Step == AccountStep.PasswordChange;
public bool IsAccount => Step == AccountStep.Account;
public string Title => Step switch
{
AccountStep.SignIn => "Se connecter",
AccountStep.Register => "Créer un compte",
AccountStep.Otp => "Code de vérification",
AccountStep.PasswordChange => "Nouveau mot de passe exigé",
AccountStep.Account => "Mon compte",
_ => "Compte"
};
public string Subtitle => Step switch
{
AccountStep.SignIn => $"Avec votre compte {_publisher}",
AccountStep.Register => "Gratuit : une adresse e-mail et un mot de passe suffisent",
AccountStep.PasswordChange => $"Demandé par l'équipe {_publisher}",
AccountStep.Otp => "Double authentification",
AccountStep.Account => EmailMasked,
AccountStep.Unavailable => "Service indisponible",
_ => "Connexion au serveur…"
};
/// <summary>Clé de l'icône d'en-tête (Themes\Icons.xaml).</summary>
public string HeaderIcon => Step switch
{
AccountStep.Register => "AccountKey",
AccountStep.PasswordChange => "ShieldKey",
AccountStep.Otp => "ShieldAccount",
_ => "AccountCircle"
};
public bool IsBusy
{
get => _isBusy;
private set
{
if (!SetProperty(ref _isBusy, value)) return;
OnPropertyChanged(nameof(IsIdle));
CloseCommand.RaiseCanExecuteChanged();
RetryCommand.RaiseCanExecuteChanged();
ShowRegisterCommand.RaiseCanExecuteChanged();
ShowSignInCommand.RaiseCanExecuteChanged();
ShowChangePasswordCommand.RaiseCanExecuteChanged();
CancelChangePasswordCommand.RaiseCanExecuteChanged();
SignOutCommand.RaiseCanExecuteChanged();
SignOutAllCommand.RaiseCanExecuteChanged();
RefreshCommand.RaiseCanExecuteChanged();
RaiseTwoFactorCommands();
}
}
/// <summary>Saisie et boutons utilisables (aucune action en cours).</summary>
public bool IsIdle => !IsBusy;
public string BusyText
{
get => _busyText;
private set => SetProperty(ref _busyText, value);
}
public string? ErrorMessage
{
get => _errorMessage;
private set
{
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
}
}
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
public string? SuccessMessage
{
get => _successMessage;
private set
{
if (SetProperty(ref _successMessage, value)) OnPropertyChanged(nameof(HasSuccess));
}
}
public bool HasSuccess => !string.IsNullOrEmpty(SuccessMessage);
public string? UnavailableText
{
get => _unavailableText;
private set => SetProperty(ref _unavailableText, value);
}
// ------------------------------------------------------------------ Connexion et création
/// <summary>Adresse e-mail saisie (connexion et création), gardée d'un écran à l'autre.</summary>
public string Email
{
get => _email;
set => SetProperty(ref _email, value ?? "");
}
public int EmailMaxLength => PlayerEmail.MaxLength;
public int PasswordMaxLength => PasswordRules.MaxLength;
/// <summary>« Rester connecté sur ce PC » (session mémorisée, chiffrée par Windows).</summary>
public bool RememberMe
{
get => _rememberMe;
set => SetProperty(ref _rememberMe, value);
}
public bool ShowForgotHelp
{
get => _showForgotHelp;
private set => SetProperty(ref _showForgotHelp, value);
}
public string ForgotHelpText =>
$"Contactez l'équipe {_publisher} (Discord, e-mail…) en indiquant l'adresse de votre compte : un administrateur pourra " +
"vous donner un mot de passe temporaire, valable une heure, à saisir ici à la place de votre mot de passe pour en choisir " +
"un nouveau. La réinitialisation par e-mail arrivera dans une prochaine version. Téléphone perdu (double " +
"authentification) ? Utilisez un de vos codes de secours.";
public bool RegistrationOpen
{
get => _registrationOpen;
private set
{
if (!SetProperty(ref _registrationOpen, value)) return;
OnPropertyChanged(nameof(RegistrationClosed));
ShowRegisterCommand.RaiseCanExecuteChanged();
}
}
public bool RegistrationClosed => !RegistrationOpen;
public bool AcceptTerms
{
get => _acceptTerms;
set => SetProperty(ref _acceptTerms, value);
}
public string TermsText => string.IsNullOrEmpty(_termsVersion)
? "J'accepte les conditions d'utilisation et la politique de confidentialité."
: $"J'accepte les conditions d'utilisation et la politique de confidentialité (version {_termsVersion}).";
public string PrivacyText =>
"Conservés : votre adresse e-mail (chiffrée), une empreinte irréversible du mot de passe, les appareils connectés et " +
"l'activité de connexion (12 mois, adresses IP 6 mois). Export et suppression à tout moment depuis « Mon compte ».";
public string RememberText => "Rester connecté sur ce PC";
/// <summary>Saisie de la vue : contrôle en direct du nouveau mot de passe (création, changement).</summary>
public void UpdateNewPassword(string password, string confirmation) =>
NewPassword.Update(password, confirmation, Step == AccountStep.Register ? Email : null);
public async Task SignInAsync(string password)
{
if (IsBusy) return;
ClearMessages();
if (PlayerEmail.Normalize(Email) is null)
{
ErrorMessage = "Adresse e-mail invalide.";
return;
}
if (string.IsNullOrEmpty(password))
{
ErrorMessage = "Saisissez votre mot de passe.";
return;
}
await RunAsync("Vérification anti-robot puis connexion…", async () =>
{
var result = await _service.SignInAsync(Email, password, RememberMe, _lifetime.Token);
if (result.OtpRequired)
{
BeginOtpStep(result.OtpToken!, password);
return;
}
if (result.PasswordChangeRequired)
{
BeginRequiredChange(result.PasswordChangeToken, password, result.TemporaryPassword);
return;
}
await EnterAccountAsync();
});
}
public async Task RegisterAsync(string password, string confirmation)
{
if (IsBusy) return;
ClearMessages();
if (PlayerEmail.Normalize(Email) is null)
{
ErrorMessage = "Adresse e-mail invalide.";
return;
}
NewPassword.Update(password, confirmation, Email);
if (!NewPassword.IsAccepted)
{
ErrorMessage = NewPassword.Problems.FirstOrDefault() ?? "Mot de passe refusé.";
return;
}
if (!NewPassword.ConfirmationMatches)
{
ErrorMessage = "Les deux mots de passe ne sont pas identiques.";
return;
}
if (!AcceptTerms)
{
ErrorMessage = "Acceptez les conditions d'utilisation et la politique de confidentialité pour créer votre compte.";
return;
}
await RunAsync("Vérification anti-robot puis création du compte (quelques secondes)…", async () =>
{
await _service.RegisterAsync(Email, password, _termsVersion, RememberMe, _lifetime.Token);
await EnterAccountAsync();
SuccessMessage = "Bienvenue ! Votre compte est créé.";
});
}
// ------------------------------------------------------------------ Nouveau mot de passe exigé
public string PasswordChangeText => _changeAfterTemporary
? "Vous vous êtes connecté avec le mot de passe temporaire donné par l'équipe : choisissez maintenant votre nouveau mot de " +
"passe (le mot de passe temporaire ne servira plus). Vos autres appareils seront déconnectés."
: $"Pour la sécurité de votre compte, l'équipe {_publisher} vous demande de choisir un nouveau mot de passe. " +
"Vos autres appareils seront déconnectés.";
/// <summary>Changement demandé après une connexion avec un mot de passe temporaire (texte de l'écran).</summary>
private bool _changeAfterTemporary;
/// <summary>Écran « nouveau mot de passe » : jeton limité, mot de passe saisi (redemandé par le serveur), origine.</summary>
private void BeginRequiredChange(string? token, string? currentPassword, bool temporary)
{
_changeToken = token;
_changeCurrentPassword = currentPassword; // exigé par le serveur pour le changement ; oublié ensuite
_changeStarted = DateTime.UtcNow;
_changeAfterTemporary = temporary;
OnPropertyChanged(nameof(PasswordChangeText));
GoTo(AccountStep.PasswordChange);
}
public async Task CompleteRequiredPasswordChangeAsync(string password, string confirmation)
{
if (IsBusy) return;
ClearMessages();
if (_changeToken is null || _changeCurrentPassword is null || DateTime.UtcNow - _changeStarted > PasswordChangeWindow)
{
ForgetRequiredChange();
GoTo(AccountStep.SignIn);
ErrorMessage = "Délai dépassé : reconnectez-vous pour choisir votre nouveau mot de passe.";
return;
}
NewPassword.Update(password, confirmation, null);
if (!NewPassword.IsValid)
{
ErrorMessage = NewPassword.IsAccepted ? "Les deux mots de passe ne sont pas identiques." : NewPassword.Problems.FirstOrDefault();
return;
}
await RunAsync("Enregistrement du nouveau mot de passe…", async () =>
{
await _service.CompleteRequiredPasswordChangeAsync(_changeToken, _changeCurrentPassword, password, RememberMe, _lifetime.Token);
ForgetRequiredChange();
await EnterAccountAsync();
SuccessMessage = "Nouveau mot de passe enregistré : vous êtes connecté.";
});
}
private void ForgetRequiredChange()
{
_changeToken = null;
_changeCurrentPassword = null;
}
// ------------------------------------------------------------------ Mon compte
public AccountSection Section
{
get => _section;
private set
{
if (!SetProperty(ref _section, value)) return;
OnPropertyChanged(nameof(IsProfileSection));
OnPropertyChanged(nameof(IsDevicesSection));
OnPropertyChanged(nameof(IsActivitySection));
OnPropertyChanged(nameof(IsDataSection));
ClearMessages();
IsChangingPassword = false;
DeleteConfirmed = false;
if (TwoFactorStage != TwoFactorStage.Codes) TwoFactorStage = TwoFactorStage.Idle; // codes affichés : à terminer
ClearPasswordsRequested?.Invoke();
if (value == AccountSection.Activity && !_activityLoaded) _ = LoadActivityAsync();
}
}
// Onglets (RadioButton : IsChecked lié dans les deux sens).
public bool IsProfileSection
{
get => Section == AccountSection.Profile;
set { if (value) Section = AccountSection.Profile; }
}
public bool IsDevicesSection
{
get => Section == AccountSection.Devices;
set { if (value) Section = AccountSection.Devices; }
}
public bool IsActivitySection
{
get => Section == AccountSection.Activity;
set { if (value) Section = AccountSection.Activity; }
}
public bool IsDataSection
{
get => Section == AccountSection.Data;
set { if (value) Section = AccountSection.Data; }
}
public string EmailMasked => _account?.EmailMasked ?? "";
public string Reference => _account?.Reference ?? "";
public string CreatedText => FormatDate(_account?.CreatedAt);
public string LastLoginText => FormatDate(_account?.LastLoginAt);
public string PasswordChangedText => FormatDate(_account?.PasswordChangedAt);
public bool NewDevicesSuspended => _account?.NewDevicesSuspended == true;
public string AvatarText => EmailMasked.Length > 0 ? EmailMasked[..1].ToUpperInvariant() : "?";
public string SuspendedText =>
"Par sécurité, les connexions depuis un NOUVEL appareil sont suspendues sur ce compte après de nombreux essais de mot de " +
$"passe. Cet appareil continue de fonctionner. Contactez l'équipe {_publisher} avec la référence {Reference} pour lever la suspension.";
public bool IsChangingPassword
{
get => _isChangingPassword;
private set
{
if (!SetProperty(ref _isChangingPassword, value)) return;
NewPassword.Reset();
ClearPasswordsRequested?.Invoke();
}
}
public async Task ChangePasswordAsync(string currentPassword, string password, string confirmation)
{
if (IsBusy) return;
ClearMessages();
if (string.IsNullOrEmpty(currentPassword))
{
ErrorMessage = "Saisissez votre mot de passe actuel.";
return;
}
NewPassword.Update(password, confirmation, null);
if (!NewPassword.IsValid)
{
ErrorMessage = NewPassword.IsAccepted ? "Les deux mots de passe ne sont pas identiques." : NewPassword.Problems.FirstOrDefault();
return;
}
await RunAsync("Enregistrement du nouveau mot de passe…", async () =>
{
var others = await _service.ChangePasswordAsync(currentPassword, password, _lifetime.Token);
IsChangingPassword = false;
await LoadMeAsync();
SuccessMessage = others switch
{
0 => "Mot de passe modifié.",
1 => "Mot de passe modifié. Votre autre appareil a été déconnecté.",
_ => $"Mot de passe modifié. Vos {others} autres appareils ont été déconnectés."
};
});
}
public bool IsActivityLoading
{
get => _isActivityLoading;
private set
{
if (SetProperty(ref _isActivityLoading, value)) OnPropertyChanged(nameof(IsActivityEmpty));
}
}
public bool IsActivityEmpty => !IsActivityLoading && _activityLoaded && Activity.Count == 0;
public bool DeleteConfirmed
{
get => _deleteConfirmed;
set => SetProperty(ref _deleteConfirmed, value);
}
public string ExportText =>
"Téléchargez toutes les données de votre compte (adresse e-mail, appareils, activité…) dans un fichier JSON lisible " +
"par machine. Votre mot de passe est demandé, car le fichier contient votre adresse complète.";
public string DeleteText =>
"Votre compte, vos appareils connectés et l'activité de votre compte sont effacés DÉFINITIVEMENT. Le jeu et ses " +
"sauvegardes sur ce PC ne sont pas touchés. Une simple trace de la suppression (référence du compte, date) est gardée " +
"12 mois dans le journal de sécurité.";
/// <summary>Export des données : emplacement choisi d'abord (annulable sans rien envoyer), puis téléchargement.</summary>
public async Task ExportAsync(string password)
{
if (IsBusy) return;
ClearMessages();
if (string.IsNullOrEmpty(password))
{
ErrorMessage = "Saisissez votre mot de passe pour exporter vos données.";
return;
}
var path = _dialogs.PickSaveFile("Exporter mes données", "Fichier JSON (*.json)|*.json",
PlayerDataExport.SuggestedFileName(Reference, DateTimeOffset.Now), Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments));
if (path is null) return;
await RunAsync("Préparation de l'export…", async () =>
{
var bytes = await _service.ExportAsync(password, _lifetime.Token);
await File.WriteAllBytesAsync(path, bytes, _lifetime.Token);
ClearPasswordsRequested?.Invoke();
SuccessMessage = $"Vos données ont été enregistrées dans {Path.GetFileName(path)}.";
Log.Info("Compte : données exportées.");
});
}
public async Task DeleteAccountAsync(string password)
{
if (IsBusy) return;
ClearMessages();
if (!DeleteConfirmed)
{
ErrorMessage = "Cochez la case pour confirmer que vous comprenez que la suppression est définitive.";
return;
}
if (string.IsNullOrEmpty(password))
{
ErrorMessage = "Saisissez votre mot de passe pour supprimer votre compte.";
return;
}
if (!_dialogs.Confirm("Supprimer définitivement le compte ?",
$"Le compte {EmailMasked} ({Reference}) sera supprimé définitivement. Cette action est irréversible.",
"Supprimer mon compte", "Annuler", danger: true))
return;
await RunAsync("Suppression du compte…", async () =>
{
await _service.DeleteAccountAsync(password, _lifetime.Token);
Email = "";
GoTo(AccountStep.SignIn);
SuccessMessage = "Votre compte a été supprimé définitivement.";
});
}
// ------------------------------------------------------------------ Chargement
/// <summary>Ouverture de la fenêtre (et « Réessayer ») : compte connecté, sinon état du service puis connexion.</summary>
public async Task InitializeAsync()
{
ClearMessages();
_ = Task.Run(() => PasswordRules.Check("préchargement-du-dictionnaire")); // dictionnaire prêt avant la première frappe
if (_service.State is PlayerAccountState.SignedIn or PlayerAccountState.Restoring or PlayerAccountState.Offline)
{
await RunAsync("Chargement du compte…", EnterAccountAsync, Step == AccountStep.Account ? null : AccountStep.Loading);
return;
}
Step = AccountStep.Loading;
IsBusy = true;
BusyText = "Connexion au serveur…";
try
{
var info = await _service.GetServiceInfoAsync(_lifetime.Token);
if (info is not { Enabled: true })
{
UnavailableText = "Les comptes joueurs ne sont pas encore ouverts. Revenez bientôt !";
Step = AccountStep.Unavailable;
return;
}
RegistrationOpen = info.RegistrationOpen;
_termsVersion = info.TermsVersion;
OnPropertyChanged(nameof(TermsText));
Step = AccountStep.SignIn;
if (_service.Notice is { } notice) ErrorMessage = notice;
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
{
// fenêtre fermée
}
catch (Exception ex)
{
UnavailableText = PlayerAccountTexts.Describe(ex);
Step = AccountStep.Unavailable;
}
finally
{
IsBusy = false;
}
}
private async Task EnterAccountAsync()
{
Section = AccountSection.Profile;
_activityLoaded = false;
Activity.Clear();
await LoadMeAsync();
GoTo(AccountStep.Account, keepMessages: true);
}
private async Task RefreshAccountAsync()
{
ClearMessages();
await RunAsync("Actualisation…", async () =>
{
await LoadMeAsync();
if (Section == AccountSection.Activity) await LoadActivityCoreAsync();
});
}
private async Task LoadMeAsync()
{
var me = await _service.GetMeAsync(_lifetime.Token);
SetAccount(me.Account);
Devices.Clear();
foreach (var device in me.Devices.OrderByDescending(d => d.Current).ThenByDescending(d => d.LastUsedAt))
Devices.Add(new PlayerDeviceItem(device, FormatDate(device.CreatedAt), FormatDate(device.LastUsedAt), RemoveDeviceAsync));
}
private async Task LoadActivityAsync()
{
if (IsBusy) return;
try
{
await LoadActivityCoreAsync();
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
{
// fenêtre fermée
}
catch (Exception ex)
{
ShowError(ex);
}
}
private async Task LoadActivityCoreAsync()
{
IsActivityLoading = true;
try
{
var activity = await _service.GetActivityAsync(_lifetime.Token);
Activity.Clear();
foreach (var entry in activity.Entries) Activity.Add(new PlayerActivityItem(entry, FormatDateTime(entry.At)));
_activityLoaded = true;
}
finally
{
IsActivityLoading = false;
OnPropertyChanged(nameof(IsActivityEmpty));
}
}
private async Task RemoveDeviceAsync(PlayerDeviceItem device)
{
if (IsBusy) return;
ClearMessages();
if (!_dialogs.Confirm("Déconnecter cet appareil ?",
$"« {device.Label} » devra se reconnecter avec le mot de passe pour accéder au compte.", "Déconnecter", "Annuler"))
return;
await RunAsync("Déconnexion de l'appareil…", async () =>
{
await _service.RemoveDeviceAsync(device.Id, _lifetime.Token);
if (_service.State == PlayerAccountState.SignedOut) return; // c'était cet appareil
await LoadMeAsync();
SuccessMessage = $"« {device.Label} » a été déconnecté.";
});
}
private async Task SignOutAsync(bool allDevices)
{
if (IsBusy) return;
ClearMessages();
if (allDevices && !_dialogs.Confirm("Déconnecter tous vos appareils ?",
"Tous les appareils connectés à votre compte, y compris celui-ci, devront se reconnecter avec le mot de passe.",
"Tout déconnecter", "Annuler", danger: true))
return;
await RunAsync(allDevices ? "Déconnexion de tous les appareils…" : "Déconnexion…", async () =>
{
await _service.SignOutAsync(allDevices, _lifetime.Token);
GoTo(AccountStep.SignIn);
SuccessMessage = allDevices ? "Tous vos appareils ont été déconnectés." : "Vous êtes déconnecté.";
});
}
private void CopyReference()
{
if (string.IsNullOrEmpty(Reference)) return;
_dialogs.CopyToClipboard(Reference);
ClearMessages();
SuccessMessage = $"Référence {Reference} copiée.";
}
// ------------------------------------------------------------------ Outils
/// <summary>Action longue : saisie bloquée, erreurs affichées en clair ; session perdue → écran de connexion.</summary>
private async Task RunAsync(string busyText, Func<Task> work, AccountStep? stepWhileBusy = null)
{
IsBusy = true;
BusyText = busyText;
if (stepWhileBusy is { } step) Step = step;
try
{
await work();
}
catch (PlayerSessionLostException ex)
{
GoTo(AccountStep.SignIn);
await EnsureServiceInfoAsync();
ErrorMessage = ex.Message;
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
{
// fenêtre fermée pendant l'action
}
catch (Exception ex)
{
if (Step == AccountStep.Loading) Step = _service.State == PlayerAccountState.SignedOut ? AccountStep.SignIn : AccountStep.Account;
ShowError(ex);
}
finally
{
IsBusy = false;
ClearPasswordsRequested?.Invoke();
}
}
/// <summary>Informations du service (création ouverte, conditions) si l'écran de connexion est atteint sans elles.</summary>
private async Task EnsureServiceInfoAsync()
{
if (!string.IsNullOrEmpty(_termsVersion)) return;
try
{
if (await _service.GetServiceInfoAsync(_lifetime.Token) is { } info)
{
RegistrationOpen = info.Enabled && info.RegistrationOpen;
_termsVersion = info.TermsVersion;
OnPropertyChanged(nameof(TermsText));
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Warn($"Compte : état du service illisible ({ex.Message}).");
}
}
private void ShowError(Exception ex)
{
ErrorMessage = PlayerAccountTexts.Describe(ex);
Log.Warn($"Compte : {ex.GetType().Name} : {ex.Message}");
}
private void GoTo(AccountStep step, bool keepMessages = false)
{
if (!keepMessages) ClearMessages();
if (step != AccountStep.PasswordChange) ForgetRequiredChange();
if (step != AccountStep.Otp) ForgetOtp();
if (step != AccountStep.Account) ResetTwoFactor();
if (step is AccountStep.SignIn or AccountStep.Register)
{
AcceptTerms = false;
ShowForgotHelp = false;
if (step == AccountStep.Register && !RegistrationOpen) step = AccountStep.SignIn;
}
IsChangingPassword = false;
DeleteConfirmed = false;
NewPassword.Reset();
Step = step;
ClearPasswordsRequested?.Invoke();
}
private void ClearMessages()
{
ErrorMessage = null;
SuccessMessage = null;
}
private void SetAccount(PlayerAccountInfo? account)
{
_account = account;
foreach (var name in new[]
{
nameof(EmailMasked), nameof(Reference), nameof(CreatedText), nameof(LastLoginText), nameof(PasswordChangedText),
nameof(NewDevicesSuspended), nameof(AvatarText), nameof(SuspendedText), nameof(Subtitle)
})
OnPropertyChanged(name);
OnTwoFactorAccountChanged();
CopyReferenceCommand.RaiseCanExecuteChanged();
}
/// <summary>Changement venu du service (session perdue pendant que la fenêtre est ouverte…).</summary>
private void OnServiceChanged() => _ui.Post(() =>
{
if (_service.State == PlayerAccountState.SignedOut && Step is AccountStep.Account && !IsBusy)
{
GoTo(AccountStep.SignIn);
ErrorMessage = _service.Notice ?? "Vous avez été déconnecté.";
}
else if (_service.Account is { } account && Step == AccountStep.Account)
{
SetAccount(account);
}
});
internal static string FormatDate(DateTimeOffset? date) =>
date is { } value && value != default ? value.ToLocalTime().ToString("d MMMM yyyy", French) : "—";
internal static string FormatDateTime(DateTimeOffset date) =>
date.ToLocalTime().ToString("d MMM yyyy 'à' HH:mm", French);
public void Dispose()
{
_service.Changed -= OnServiceChanged;
_lifetime.Cancel(); // actions en cours abandonnées (la source n'est pas libérée : une action peut encore la lire)
ForgetRequiredChange();
ForgetOtp();
ResetTwoFactor();
}
}
/// <summary>Un appareil connecté au compte (onglet « Appareils »).</summary>
public sealed class PlayerDeviceItem
{
public PlayerDeviceItem(PlayerDeviceInfo device, string createdText, string lastUsedText, Func<PlayerDeviceItem, Task> remove)
{
Id = device.Id;
Label = device.Label;
IsCurrent = device.Current;
Detail = $"Connecté le {createdText} · utilisé le {lastUsedText}";
RemoveCommand = new AsyncRelayCommand(() => remove(this));
}
public string Id { get; }
public string Label { get; }
public string Detail { get; }
/// <summary>Appareil de la session en cours (« Cet appareil » ; se déconnecte avec « Se déconnecter »).</summary>
public bool IsCurrent { get; }
public bool CanRemove => !IsCurrent;
public AsyncRelayCommand RemoveCommand { get; }
}
/// <summary>Une ligne de l'activité récente du compte.</summary>
public sealed class PlayerActivityItem
{
private static readonly HashSet<string> Alerts = new(StringComparer.Ordinal)
{
"account.locked", "account.suspended", "session.reuse", "account.disabled"
};
public PlayerActivityItem(PlayerActivityEntry entry, string dateText)
{
Title = PlayerActivityText.Describe(entry.Type, entry.Success);
IsWarning = !entry.Success || Alerts.Contains(entry.Type);
var where = entry.ThisDevice ? "cet appareil" : entry.Device ?? (entry.Ip is { } ip ? $"adresse {ip}" : null);
Detail = where is null ? dateText : $"{dateText} · {where}";
}
public string Title { get; }
public string Detail { get; }
/// <summary>Échec ou alerte de sécurité (icône orange).</summary>
public bool IsWarning { get; }
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Module « Compte » côté joueur (sans WPF) : connexion, création de compte, session mémorisée (jeton de renouvellement
chiffré sur le PC, lié à la clé de l'appareil), « Mon compte » (appareils, activité, mot de passe, export des données,
suppression du compte). Testable sans interface, contre la Launcher API démarrée en mémoire.
Ce qui dépend de Windows est fourni par le launcher : clé d'appareil (puce TPM ou clé logicielle CNG, Platform\PlayerDeviceKeyStore.cs)
et chiffrement de la session (DPAPI, Platform\DpapiProtector.cs) ; la fenêtre est Views\AccountWindow.xaml.
Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>GameLauncher.Modules.Accounts</RootNamespace>
<AssemblyName>GameLauncher.Modules.Accounts</AssemblyName>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="GameLauncher.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,103 @@
using GameLauncher.Contracts.Accounts;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Accounts;
/// <summary>
/// Contrôle EN DIRECT d'un nouveau mot de passe (création de compte, changement) avec les mêmes règles que le serveur
/// (<see cref="PasswordRules"/>) : problèmes restants, confirmation identique, jauge de solidité indicative. Le mot de passe
/// n'est jamais gardé ici : seule la vue (PasswordBox) le détient ; elle appelle <see cref="Update"/> à chaque frappe.
/// </summary>
public sealed class PasswordRulesViewModel : ObservableObject
{
private IReadOnlyList<string> _problems = Array.Empty<string>();
private bool _hasInput;
private bool _hasConfirmation;
private bool _confirmationMatches;
private int _strength;
/// <summary>Rappel des règles, affiché tant que rien n'est saisi.</summary>
public string RulesText =>
$"Au moins {PasswordRules.MinLength} caractères mêlant au moins {PasswordRules.RequiredClasses} sortes parmi minuscules, majuscules, " +
$"chiffres et symboles, ou une phrase de passe d'au moins {PasswordRules.MinLengthWithoutComposition} caractères. " +
"Les mots de passe trop courants sont refusés.";
/// <summary>Problèmes restants (messages du contrôle commun au launcher et au serveur).</summary>
public IReadOnlyList<string> Problems
{
get => _problems;
private set => SetProperty(ref _problems, value);
}
public bool HasInput
{
get => _hasInput;
private set => SetProperty(ref _hasInput, value);
}
public bool ShowRules => !HasInput;
public bool HasProblems => HasInput && Problems.Count > 0;
/// <summary>Mot de passe conforme aux règles (indépendamment de la confirmation).</summary>
public bool IsAccepted => HasInput && Problems.Count == 0;
public bool HasConfirmation
{
get => _hasConfirmation;
private set => SetProperty(ref _hasConfirmation, value);
}
public bool ConfirmationMatches
{
get => _confirmationMatches;
private set => SetProperty(ref _confirmationMatches, value);
}
public bool ShowConfirmationMismatch => HasConfirmation && !ConfirmationMatches;
/// <summary>Mot de passe accepté ET confirmation identique : le bouton d'envoi peut être utilisé.</summary>
public bool IsValid => IsAccepted && ConfirmationMatches;
/// <summary>Jauge 0 à 4 (indicative : longueur et variété au-delà du minimum).</summary>
public int Strength
{
get => _strength;
private set => SetProperty(ref _strength, value);
}
public string StrengthText => !HasInput ? "" : !IsAccepted ? "Refusé" : Strength switch
{
>= 4 => "Excellent",
3 => "Très bon",
_ => "Correct"
};
/// <param name="email">Adresse du compte (création) : un mot de passe qui en est tiré est refusé.</param>
public void Update(string password, string confirmation, string? email)
{
HasInput = !string.IsNullOrEmpty(password);
Problems = HasInput ? PasswordRules.Check(password, email) : Array.Empty<string>();
HasConfirmation = !string.IsNullOrEmpty(confirmation);
ConfirmationMatches = HasInput && string.Equals(password, confirmation, StringComparison.Ordinal);
Strength = !HasInput || Problems.Count > 0 ? 0 : Score(password);
OnPropertyChanged(nameof(ShowRules));
OnPropertyChanged(nameof(HasProblems));
OnPropertyChanged(nameof(IsAccepted));
OnPropertyChanged(nameof(ShowConfirmationMismatch));
OnPropertyChanged(nameof(IsValid));
OnPropertyChanged(nameof(StrengthText));
}
public void Reset() => Update("", "", null);
private static int Score(string password)
{
var classes = (password.Any(char.IsLower) ? 1 : 0) + (password.Any(char.IsUpper) ? 1 : 0) +
(password.Any(char.IsDigit) ? 1 : 0) + (password.Any(c => !char.IsLetterOrDigit(c)) ? 1 : 0);
var length = password.Length;
var score = 2;
if (length >= 16) score++;
if (length >= 20 || (length >= 14 && classes == 4)) score++;
return Math.Min(4, score);
}
}
@@ -0,0 +1,560 @@
using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Core.Utils;
namespace GameLauncher.Modules.Accounts;
/// <summary>État du compte joueur dans le launcher.</summary>
public enum PlayerAccountState
{
/// <summary>Aucun compte connecté.</summary>
SignedOut,
/// <summary>Session mémorisée en cours de renouvellement (démarrage du launcher).</summary>
Restoring,
SignedIn,
/// <summary>Session mémorisée, serveur injoignable : nouvel essai à la prochaine action.</summary>
Offline
}
/// <summary>
/// Résultat d'une connexion : connecté, code de vérification demandé (double authentification), ou nouveau mot de passe
/// exigé par un administrateur.
/// </summary>
/// <param name="PasswordChangeToken">Jeton limité (10 min) qui permet seulement de choisir le nouveau mot de passe.</param>
/// <param name="OtpToken">Jeton limité (5 min, 5 essais) qui permet seulement d'envoyer le code de vérification.</param>
/// <param name="RecoveryCodesLeft">Connexion faite avec un code de secours : codes restants.</param>
/// <param name="TemporaryPassword">Connexion avec un mot de passe temporaire donné par l'équipe : il ne sert qu'à choisir le nouveau.</param>
public sealed record PlayerSignInResult(bool PasswordChangeRequired, string? PasswordChangeToken = null, bool OtpRequired = false,
string? OtpToken = null, int? RecoveryCodesLeft = null, bool TemporaryPassword = false);
/// <summary>Compte joueur du launcher (implémenté par <see cref="PlayerAccountService"/>, simulé par les tests et UiSnapshot).</summary>
public interface IPlayerAccountService
{
PlayerAccountState State { get; }
/// <summary>Compte connecté (ou mémorisé hors ligne : référence et adresse masquée seulement), sinon null.</summary>
PlayerAccountInfo? Account { get; }
/// <summary>Raison de la dernière déconnexion imposée (session expirée, compte désactivé…), à afficher une fois.</summary>
string? Notice { get; }
/// <summary>Changement d'état ou de compte (peut être levé depuis un autre fil).</summary>
event Action? Changed;
/// <summary>État du service des comptes annoncé par le serveur (null : comptes non activés sur ce serveur).</summary>
Task<PlayerAccountsInfo?> GetServiceInfoAsync(CancellationToken ct = default);
/// <summary>Reprend la session mémorisée (démarrage du launcher), sans jamais lever d'erreur.</summary>
Task RestoreAsync(CancellationToken ct = default);
Task<PlayerSignInResult> SignInAsync(string email, string password, bool remember, CancellationToken ct = default);
Task RegisterAsync(string email, string password, string termsVersion, bool remember, CancellationToken ct = default);
/// <summary>Code de vérification (après <see cref="SignInAsync"/>, double authentification activée).</summary>
Task<PlayerSignInResult> VerifyOtpAsync(string otpToken, string code, bool remember, CancellationToken ct = default);
/// <summary>Double authentification : préparation (mot de passe exigé), activation (premier code), désactivation,
/// nouveaux codes de secours (mot de passe et code exigés).</summary>
Task<PlayerOtpSetupResponse> BeginOtpSetupAsync(string password, CancellationToken ct = default);
Task<IReadOnlyList<string>> EnableOtpAsync(string code, CancellationToken ct = default);
Task DisableOtpAsync(string password, string code, CancellationToken ct = default);
Task<IReadOnlyList<string>> RegenerateRecoveryCodesAsync(string password, string code, CancellationToken ct = default);
/// <summary>Nouveau mot de passe EXIGÉ (après <see cref="SignInAsync"/>) : la session s'ouvre ensuite.</summary>
Task CompleteRequiredPasswordChangeAsync(string passwordChangeToken, string currentPassword, string newPassword, bool remember,
CancellationToken ct = default);
/// <summary>Changement ordinaire : renvoie le nombre d'autres appareils déconnectés.</summary>
Task<int> ChangePasswordAsync(string currentPassword, string newPassword, CancellationToken ct = default);
Task<PlayerMeResponse> GetMeAsync(CancellationToken ct = default);
Task<PlayerActivityResponse> GetActivityAsync(CancellationToken ct = default);
Task RemoveDeviceAsync(string deviceId, CancellationToken ct = default);
/// <summary>Déconnexion de ce PC (ou de tous les appareils) ; la session locale est effacée même si le serveur est injoignable.</summary>
Task SignOutAsync(bool allDevices, CancellationToken ct = default);
Task<byte[]> ExportAsync(string password, CancellationToken ct = default);
/// <summary>Suppression DÉFINITIVE du compte ; la session locale est effacée.</summary>
Task DeleteAccountAsync(string password, CancellationToken ct = default);
}
/// <summary>Session perdue (expirée, révoquée, compte désactivé…) : le joueur doit se reconnecter.</summary>
public sealed class PlayerSessionLostException : Exception
{
public PlayerSessionLostException(string message, Exception? inner = null) : base(message, inner) { }
}
/// <summary>
/// Compte joueur du launcher. Le jeton d'accès (15 min) reste en mémoire ; le jeton de renouvellement est enregistré
/// chiffré (<see cref="PlayerSessionFile"/>) si le joueur a choisi « Rester connecté », sinon gardé en mémoire et la session
/// est fermée à la sortie du launcher (<see cref="Shutdown"/>). Chaque renouvellement est signé par la clé de l'appareil ;
/// le nouveau jeton remplace aussitôt l'ancien sur le disque (l'ancien, présenté de nouveau, ferait déconnecter l'appareil
/// par le serveur). Un renouvellement à la fois (verrou) ; jeton d'accès renouvelé une minute avant son échéance, ou
/// aussitôt si le serveur l'a oublié (redémarrage).
/// Le launcher ne contacte l'API des comptes que sur une action du joueur, ou pour reprendre une session mémorisée.
/// </summary>
public sealed class PlayerAccountService : IPlayerAccountService, IDisposable
{
private static readonly TimeSpan AccessMargin = TimeSpan.FromMinutes(1);
private readonly PlayerAccountApi _api;
private readonly IPlayerDeviceKeyStore _keys;
private readonly PlayerSessionFile _file;
private readonly string _deviceLabel;
private readonly TimeProvider _time;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly object _lock = new();
private IPlayerDeviceKey? _key;
private string? _access;
private DateTimeOffset _accessExpiresAt;
private string? _refresh;
private string? _deviceId;
private DateTimeOffset _refreshExpiresAt;
private bool _remember;
private PlayerAccountInfo? _account;
private PlayerAccountState _state = PlayerAccountState.SignedOut;
private string? _notice;
public PlayerAccountService(PlayerAccountApi api, IPlayerDeviceKeyStore keys, PlayerSessionFile file, string deviceLabel,
TimeProvider? time = null)
{
_api = api;
_keys = keys;
_file = file;
_deviceLabel = deviceLabel;
_time = time ?? TimeProvider.System;
}
public event Action? Changed;
public PlayerAccountState State
{
get { lock (_lock) return _state; }
}
public PlayerAccountInfo? Account
{
get { lock (_lock) return _account; }
}
public string? Notice
{
get { lock (_lock) return _notice; }
}
/// <summary>Session mémorisée sur ce PC (pour les tests et l'affichage).</summary>
public bool IsRemembered
{
get { lock (_lock) return _remember && _refresh is not null; }
}
public async Task<PlayerAccountsInfo?> GetServiceInfoAsync(CancellationToken ct = default) =>
(await _api.Client.GetInfoAsync(ct).ConfigureAwait(false)).Accounts;
// ------------------------------------------------------------------ Session mémorisée
public async Task RestoreAsync(CancellationToken ct = default)
{
StoredPlayerSession? stored;
try
{
stored = _file.Load();
if (stored is null) return;
if (!string.Equals(stored.Origin, _api.Client.Origin, StringComparison.OrdinalIgnoreCase))
{
Log.Info("Compte : session mémorisée d'un autre serveur, ignorée.");
_file.Delete();
return;
}
var key = await Task.Run(() => _keys.TryOpen(), ct).ConfigureAwait(false);
if (key is null || key.KeyId != stored.KeyId)
{
Log.Warn("Compte : clé de l'appareil absente ou remplacée, la session mémorisée est abandonnée.");
_file.Delete();
return;
}
lock (_lock)
{
_key = key;
_refresh = stored.RefreshToken;
_deviceId = stored.DeviceId;
_refreshExpiresAt = stored.RefreshExpiresAt;
_remember = true;
_account = new PlayerAccountInfo { Reference = stored.Reference, EmailMasked = stored.EmailMasked };
_state = PlayerAccountState.Restoring;
}
RaiseChanged();
}
catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException or PlatformNotSupportedException)
{
Log.Error("Compte : reprise de la session mémorisée", ex);
return;
}
try
{
await RenewAsync(force: true, ct).ConfigureAwait(false);
Log.Info("Compte : session reprise.");
}
catch (PlayerSessionLostException ex)
{
Log.Info($"Compte : session mémorisée refusée ({ex.Message}).");
}
catch (Exception ex)
{
Log.Warn($"Compte : serveur injoignable, session mémorisée conservée ({ex.Message}).");
}
}
// ------------------------------------------------------------------ Connexion, création de compte
public async Task<PlayerSignInResult> SignInAsync(string email, string password, bool remember, CancellationToken ct = default)
{
var key = await OpenKeyAsync(ct).ConfigureAwait(false);
var response = await _api.LoginAsync(email, password, key, _deviceLabel, ct).ConfigureAwait(false);
return Conclude(response, key, remember);
}
public async Task<PlayerSignInResult> VerifyOtpAsync(string otpToken, string code, bool remember, CancellationToken ct = default)
{
var key = await OpenKeyAsync(ct).ConfigureAwait(false);
var response = await _api.VerifyOtpAsync(otpToken, code, ct).ConfigureAwait(false);
return Conclude(response, key, remember);
}
/// <summary>Suite d'une connexion : code de vérification demandé, nouveau mot de passe exigé, ou session ouverte.</summary>
private PlayerSignInResult Conclude(PlayerLoginResponse response, IPlayerDeviceKey key, bool remember)
{
if (response.Status == PlayerLoginStatus.OtpRequired && response.OtpToken is { } otp)
return new PlayerSignInResult(false, OtpRequired: true, OtpToken: otp, TemporaryPassword: response.TemporaryPassword);
if (response.Status == PlayerLoginStatus.PasswordChangeRequired && response.PasswordChangeToken is { } token)
return new PlayerSignInResult(true, token, RecoveryCodesLeft: response.RecoveryCodesLeft, TemporaryPassword: response.TemporaryPassword);
Adopt(response.Session ?? throw new ApiException(0, "invalid_response", "Réponse du serveur illisible."), key, remember);
Log.Info(response.RecoveryCodesLeft is null ? "Compte : connecté." : "Compte : connecté avec un code de secours.");
return new PlayerSignInResult(false, RecoveryCodesLeft: response.RecoveryCodesLeft);
}
public Task<PlayerOtpSetupResponse> BeginOtpSetupAsync(string password, CancellationToken ct = default) =>
CallAsync(token => _api.OtpSetupAsync(token, password, ct), ct);
public async Task<IReadOnlyList<string>> EnableOtpAsync(string code, CancellationToken ct = default)
{
var codes = await CallAsync(token => _api.OtpEnableAsync(token, code, ct), ct).ConfigureAwait(false);
Log.Info("Compte : double authentification activée.");
return codes;
}
public async Task DisableOtpAsync(string password, string code, CancellationToken ct = default)
{
await CallAsync(async token =>
{
await _api.OtpDisableAsync(token, password, code, ct).ConfigureAwait(false);
return true;
}, ct).ConfigureAwait(false);
Log.Info("Compte : double authentification désactivée.");
}
public Task<IReadOnlyList<string>> RegenerateRecoveryCodesAsync(string password, string code, CancellationToken ct = default) =>
CallAsync(token => _api.RegenerateRecoveryCodesAsync(token, password, code, ct), ct);
public async Task RegisterAsync(string email, string password, string termsVersion, bool remember, CancellationToken ct = default)
{
var key = await OpenKeyAsync(ct).ConfigureAwait(false);
var response = await _api.RegisterAsync(email, password, termsVersion, key, _deviceLabel, ct).ConfigureAwait(false);
Adopt(response.Session ?? throw new ApiException(0, "invalid_response", "Réponse du serveur illisible."), key, remember);
Log.Info("Compte : créé et connecté.");
}
public async Task CompleteRequiredPasswordChangeAsync(string passwordChangeToken, string currentPassword, string newPassword, bool remember,
CancellationToken ct = default)
{
var key = await OpenKeyAsync(ct).ConfigureAwait(false);
var response = await _api.ChangePasswordAsync(passwordChangeToken, currentPassword, newPassword, ct).ConfigureAwait(false);
Adopt(response.Session ?? throw new ApiException(0, "invalid_response", "Réponse du serveur illisible."), key, remember);
Log.Info("Compte : nouveau mot de passe exigé enregistré, connecté.");
}
// ------------------------------------------------------------------ Compte connecté
public async Task<int> ChangePasswordAsync(string currentPassword, string newPassword, CancellationToken ct = default) =>
(await CallAsync(token => _api.ChangePasswordAsync(token, currentPassword, newPassword, ct), ct).ConfigureAwait(false)).DevicesSignedOut;
public async Task<PlayerMeResponse> GetMeAsync(CancellationToken ct = default)
{
var me = await CallAsync(token => _api.MeAsync(token, ct), ct).ConfigureAwait(false);
lock (_lock) _account = me.Account;
RaiseChanged();
return me;
}
public Task<PlayerActivityResponse> GetActivityAsync(CancellationToken ct = default) =>
CallAsync(token => _api.ActivityAsync(token, ct), ct);
public async Task RemoveDeviceAsync(string deviceId, CancellationToken ct = default)
{
await CallAsync(async token =>
{
await _api.RemoveDeviceAsync(token, deviceId, ct).ConfigureAwait(false);
return true;
}, ct).ConfigureAwait(false);
string? current;
lock (_lock) current = _deviceId;
if (deviceId == current) Clear(null); // cet appareil lui-même
}
public async Task SignOutAsync(bool allDevices, CancellationToken ct = default)
{
try
{
await CallAsync(async token =>
{
await _api.LogoutAsync(token, allDevices, ct).ConfigureAwait(false);
return true;
}, ct).ConfigureAwait(false);
}
catch (Exception ex) when (!allDevices && ex is ApiException or PlayerSessionLostException)
{
// Serveur injoignable ou session déjà fermée : ce PC est déconnecté quand même (le jeton est effacé ici).
Log.Warn($"Compte : déconnexion locale seulement ({ex.Message}).");
}
Clear(null);
Log.Info(allDevices ? "Compte : tous les appareils déconnectés." : "Compte : déconnecté.");
}
public Task<byte[]> ExportAsync(string password, CancellationToken ct = default) =>
CallAsync(token => _api.ExportAsync(token, password, ct), ct);
public async Task DeleteAccountAsync(string password, CancellationToken ct = default)
{
await CallAsync(async token =>
{
await _api.DeleteAccountAsync(token, password, ct).ConfigureAwait(false);
return true;
}, ct).ConfigureAwait(false);
Clear("Votre compte a été supprimé définitivement.");
Log.Info("Compte : supprimé à la demande du joueur.");
}
/// <summary>
/// Sortie du launcher : une session NON mémorisée est fermée sur le serveur (quelques secondes au plus), pour ne pas
/// laisser un appareil connecté que le joueur n'a pas voulu garder.
/// </summary>
public void Shutdown(TimeSpan timeout)
{
bool close;
lock (_lock) close = !_remember && _refresh is not null;
if (!close) return;
try
{
using var cts = new CancellationTokenSource(timeout);
Task.Run(() => SignOutAsync(false, cts.Token)).Wait(timeout);
}
catch (Exception ex)
{
Log.Warn($"Compte : fermeture de la session à la sortie impossible ({ex.Message}).");
}
}
public void Dispose() => _gate.Dispose();
// ------------------------------------------------------------------ Jetons
/// <summary>
/// Appel authentifié : jeton d'accès valable (renouvelé si besoin), puis appel ; un refus 401 (jeton oublié par le
/// serveur après un redémarrage) provoque UN renouvellement et un nouvel essai.
/// </summary>
private async Task<T> CallAsync<T>(Func<string, Task<T>> call, CancellationToken ct)
{
var token = await RenewAsync(force: false, ct).ConfigureAwait(false);
try
{
return await call(token).ConfigureAwait(false);
}
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
{
token = await RenewAsync(force: true, ct, staleToken: token).ConfigureAwait(false);
return await call(token).ConfigureAwait(false);
}
}
/// <summary>Jeton d'accès valable ; renouvellement signé par la clé de l'appareil si besoin (un seul à la fois).</summary>
/// <param name="staleToken">Jeton refusé par le serveur : renouvelé même s'il semble encore valable.</param>
private async Task<string> RenewAsync(bool force, CancellationToken ct, string? staleToken = null)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
string refresh;
IPlayerDeviceKey key;
lock (_lock)
{
var fresh = _access is not null && _time.GetUtcNow() + AccessMargin < _accessExpiresAt;
if (fresh && (staleToken is null ? !force : _access != staleToken)) return _access!;
if (_refresh is null || _key is null) throw new PlayerSessionLostException("Vous n'êtes pas connecté.");
refresh = _refresh;
key = _key;
}
PlayerSession session;
try
{
session = await _api.RefreshAsync(refresh, key, ct).ConfigureAwait(false);
}
catch (ApiException ex) when (IsSessionLost(ex))
{
Clear(PlayerAccountTexts.SessionLost(ex));
throw new PlayerSessionLostException(PlayerAccountTexts.SessionLost(ex), ex);
}
catch (ApiException)
{
// Serveur injoignable, en erreur ou qui limite les essais : la session est gardée, nouvel essai plus tard.
SetState(PlayerAccountState.Offline);
throw;
}
catch (CryptographicException ex)
{
// Clé de l'appareil devenue inutilisable (puce réinitialisée…) : la session ne peut plus être renouvelée.
Clear("La clé de cet appareil est devenue inutilisable : reconnectez-vous.");
throw new PlayerSessionLostException("La clé de cet appareil est devenue inutilisable : reconnectez-vous.", ex);
}
Adopt(session, key, remember: null);
return session.AccessToken;
}
finally
{
_gate.Release();
}
}
private static bool IsSessionLost(ApiException ex) =>
ex.Status == 401 ||
(ex.Status == 403 && ex.Code is ApiErrorCodes.AccountDisabled or ApiErrorCodes.AccountSuspended or ApiErrorCodes.PasswordChangeRequired);
private async Task<IPlayerDeviceKey> OpenKeyAsync(CancellationToken ct)
{
lock (_lock)
{
if (_key is not null) return _key;
}
var key = await Task.Run(() => _keys.OpenOrCreate(), ct).ConfigureAwait(false);
lock (_lock) _key ??= key;
return key;
}
/// <summary>Nouvelle session : jetons en mémoire, jeton de renouvellement enregistré (si mémorisée), état « connecté ».</summary>
/// <param name="remember">Null : inchangé (renouvellement).</param>
private void Adopt(PlayerSession session, IPlayerDeviceKey key, bool? remember)
{
bool save;
lock (_lock)
{
_key = key;
_access = session.AccessToken;
_accessExpiresAt = session.AccessExpiresAt;
_refresh = session.RefreshToken;
_deviceId = session.DeviceId;
_refreshExpiresAt = session.RefreshExpiresAt;
_account = session.Account;
_state = PlayerAccountState.SignedIn;
_notice = null;
if (remember is { } value) _remember = value;
save = _remember;
}
try
{
if (save)
_file.Save(new StoredPlayerSession(_api.Client.Origin, session.RefreshToken, session.DeviceId, key.KeyId,
session.Account.Reference, session.Account.EmailMasked, session.RefreshExpiresAt));
else
_file.Delete();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or CryptographicException)
{
// Session utilisable pendant cette exécution ; au prochain lancement, il faudra se reconnecter.
Log.Error("Compte : enregistrement de la session mémorisée impossible", ex);
}
RaiseChanged();
}
/// <summary>Oublie la session (mémoire et disque) ; la clé de l'appareil est conservée.</summary>
private void Clear(string? notice)
{
lock (_lock)
{
_access = null;
_refresh = null;
_deviceId = null;
_account = null;
_state = PlayerAccountState.SignedOut;
_notice = notice;
}
_file.Delete();
RaiseChanged();
}
private void SetState(PlayerAccountState state)
{
lock (_lock)
{
if (_state == state) return;
_state = state;
}
RaiseChanged();
}
private void RaiseChanged()
{
try
{
Changed?.Invoke();
}
catch (Exception ex)
{
Log.Error("Compte : notification du changement d'état", ex);
}
}
}
/// <summary>Messages français des erreurs du compte joueur.</summary>
public static class PlayerAccountTexts
{
public static string Describe(Exception ex) => ex switch
{
PlayerSessionLostException lost => lost.Message,
ApiException { Status: 0, Code: "unreachable" } => "Connexion au serveur impossible. Vérifiez votre connexion Internet, puis réessayez.",
ApiException { Status: 0, Code: "timeout" } => "Le serveur ne répond pas. Réessayez dans un instant.",
ApiException { Status: 0, Code: "origin_mismatch" } or ApiException { Code: "invalid_response" } =>
"Réponse inattendue du serveur. Réessayez plus tard ; si le problème continue, mettez le launcher à jour.",
ApiException { Code: ApiErrorCodes.NotConfigured } => "Les comptes joueurs ne sont pas encore ouverts.",
ApiException { Status: 401, Code: ApiErrorCodes.Unauthorized } => "Session expirée : reconnectez-vous.",
ApiException api => api.Message,
CryptographicException crypto => $"Clé de cet appareil inutilisable : {crypto.Message}",
IOException or UnauthorizedAccessException => $"Fichier inaccessible : {ex.Message}",
OperationCanceledException => "Opération annulée.",
_ => $"Erreur inattendue : {ex.Message}"
};
/// <summary>Raison d'une session refusée au renouvellement.</summary>
public static string SessionLost(ApiException ex) => ex.Code switch
{
ApiErrorCodes.AccountDisabled or ApiErrorCodes.AccountSuspended => ex.Message,
ApiErrorCodes.PasswordChangeRequired => "Un nouveau mot de passe est exigé : reconnectez-vous pour le choisir.",
ApiErrorCodes.DeviceProofInvalid => "Cet appareil n'a pas pu prouver son identité : reconnectez-vous.",
_ => "Votre session a expiré ou a été fermée depuis un autre appareil : reconnectez-vous."
};
}
@@ -0,0 +1,47 @@
using System.Security.Cryptography;
using GameLauncher.ApiClient;
namespace GameLauncher.Modules.Accounts;
/// <summary>
/// Clé d'appareil du joueur sur ce PC (une par compte Windows, partagée par les comptes joueurs utilisés sur ce PC).
/// Implémentation Windows dans le launcher (Platform\PlayerDeviceKeyStore.cs) : puce de sécurité (TPM 2.0) SANS code PIN
/// quand elle existe, sinon clé logicielle non exportable du fournisseur de Windows (protégée par la session Windows).
/// Les méthodes peuvent être lentes (accès à la puce) : jamais appelées sur le fil de l'interface.
/// </summary>
public interface IPlayerDeviceKeyStore
{
/// <summary>Ouvre la clé existante, ou null si elle n'existe pas (encore) sur ce PC.</summary>
IPlayerDeviceKey? TryOpen();
/// <summary>Ouvre la clé, en la créant si besoin.</summary>
IPlayerDeviceKey OpenOrCreate();
/// <summary>Supprime la clé (sans erreur si elle n'existe pas).</summary>
void Delete();
}
/// <summary>
/// Chiffrement local des secrets du joueur (jeton de renouvellement) : DPAPI de l'utilisateur Windows dans le launcher
/// (Platform\DpapiProtector.cs) ; un autre compte Windows du PC, ou une copie du fichier sur un autre PC, ne peut pas le lire.
/// </summary>
public interface ISecretProtector
{
byte[] Protect(byte[] data);
/// <exception cref="CryptographicException">Données illisibles (autre utilisateur, autre PC, fichier modifié).</exception>
byte[] Unprotect(byte[] data);
}
/// <summary>Nom générique de l'appareil envoyé au serveur (« Windows 11 · GameLauncher 3.4.2 ») : jamais le nom Windows du PC.</summary>
public static class PlayerDeviceLabel
{
public static string Create(string launcherVersion)
{
var os = OperatingSystem.IsWindows()
? Environment.OSVersion.Version.Build >= 22000 ? "Windows 11" : "Windows 10"
: "PC";
var version = new string((launcherVersion ?? "").Where(c => char.IsAsciiDigit(c) || c == '.').Take(20).ToArray());
return version.Length > 0 ? $"{os} · GameLauncher {version}" : $"{os} · GameLauncher";
}
}
@@ -0,0 +1,116 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Core.Utils;
namespace GameLauncher.Modules.Accounts;
/// <summary>Session mémorisée (« Rester connecté »), telle qu'enregistrée chiffrée sur le PC.</summary>
/// <param name="Origin">Serveur qui a ouvert la session (« https://launcher.lanisys.fr ») : ignorée si l'adresse de l'API change.</param>
/// <param name="KeyId">Clé d'appareil avec laquelle la session a été ouverte (sans elle, le jeton ne sert à rien).</param>
/// <param name="Reference">Référence et adresse masquée du compte : affichées hors ligne.</param>
public sealed record StoredPlayerSession(string Origin, string RefreshToken, string DeviceId, string KeyId, string Reference,
string EmailMasked, DateTimeOffset RefreshExpiresAt);
/// <summary>
/// Fichier de la session mémorisée : %AppData%\…\GameLauncher\account\session.dat, chiffré par <see cref="ISecretProtector"/>
/// (DPAPI de l'utilisateur Windows). Le jeton de renouvellement qu'il contient est inutilisable sans la clé de l'appareil
/// (non exportable) et change à chaque lancement. Écriture atomique (fichier temporaire puis remplacement) : une coupure
/// pendant l'écriture ne laisse jamais un fichier à moitié écrit.
/// </summary>
public sealed class PlayerSessionFile
{
public const string FolderName = "account";
public const string FileName = "session.dat";
/// <summary>En-tête du fichier (« GLS » + version du format).</summary>
private static readonly byte[] Magic = "GLS1"u8.ToArray();
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private readonly ISecretProtector _protector;
public PlayerSessionFile(string dataDirectory, ISecretProtector protector)
{
_protector = protector;
Path = System.IO.Path.Combine(dataDirectory, FolderName, FileName);
}
public string Path { get; }
/// <summary>Session enregistrée, ou null (absente, illisible, d'un autre utilisateur ou PC : le fichier est alors supprimé).</summary>
public StoredPlayerSession? Load()
{
byte[] content;
try
{
if (!File.Exists(Path)) return null;
content = File.ReadAllBytes(Path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"Compte : session mémorisée illisible ({ex.Message}).");
return null;
}
try
{
if (content.Length <= Magic.Length || content.Length > 64 * 1024 || !content.AsSpan(0, Magic.Length).SequenceEqual(Magic))
throw new InvalidDataException("en-tête inconnu");
var json = _protector.Unprotect(content[Magic.Length..]);
try
{
var session = JsonSerializer.Deserialize<StoredPlayerSession>(json, Json) ?? throw new InvalidDataException("vide");
if (!PlayerTokens.TryParseRefresh(session.RefreshToken, out var deviceId, out _) || deviceId != session.DeviceId ||
string.IsNullOrEmpty(session.Origin) || string.IsNullOrEmpty(session.KeyId))
throw new InvalidDataException("contenu incohérent");
return session;
}
finally
{
CryptographicOperations.ZeroMemory(json);
}
}
catch (Exception ex) when (ex is CryptographicException or InvalidDataException or JsonException)
{
Log.Warn($"Compte : session mémorisée inutilisable ({ex.Message}) : elle est supprimée.");
Delete();
return null;
}
}
public void Save(StoredPlayerSession session)
{
var json = JsonSerializer.SerializeToUtf8Bytes(session, Json);
byte[] protectedBytes;
try
{
protectedBytes = _protector.Protect(json);
}
finally
{
CryptographicOperations.ZeroMemory(json);
}
var directory = System.IO.Path.GetDirectoryName(Path)!;
Directory.CreateDirectory(directory);
var temp = Path + ".tmp";
using (var stream = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None))
{
stream.Write(Magic);
stream.Write(protectedBytes);
stream.Flush(flushToDisk: true);
}
File.Move(temp, Path, overwrite: true);
}
public void Delete()
{
FileUtil.TryDeleteFile(Path);
FileUtil.TryDeleteFile(Path + ".tmp");
}
/// <summary>Texte d'en-tête utilisé pour lier le chiffrement à cet usage (entropie DPAPI).</summary>
public static byte[] Purpose => Encoding.ASCII.GetBytes("GameLauncher/Player/Session/v1");
}
+475
View File
@@ -0,0 +1,475 @@
using System.Globalization;
using System.Text;
namespace GameLauncher.Modules.Accounts;
/// <summary>Niveau de correction d'erreurs d'un QR code (part du symbole qui peut être abîmée sans gêner la lecture).</summary>
public enum QrErrorCorrection
{
/// <summary>≈ 7 %.</summary>
Low,
/// <summary>≈ 15 % (défaut : bon compromis pour un écran).</summary>
Medium,
/// <summary>≈ 25 %.</summary>
Quartile,
/// <summary>≈ 30 %.</summary>
High
}
/// <summary>
/// Encodeur de QR codes (ISO/IEC 18004), écrit pour le launcher (décision D3 : aucune dépendance) : mode « octets »
/// (UTF-8), versions 1 à 40, quatre niveaux de correction, choix automatique du masque. Sert à afficher l'adresse
/// « otpauth:// » de la double authentification ; le symbole est calculé sur ce PC, le secret ne part vers aucun service.
/// </summary>
public sealed class QrCode
{
public const int MinVersion = 1;
public const int MaxVersion = 40;
private readonly bool[,] _modules; // [y, x]
private readonly bool[,] _isFunction;
private QrCode(int version, QrErrorCorrection level)
{
Version = version;
ErrorCorrection = level;
Size = version * 4 + 17;
_modules = new bool[Size, Size];
_isFunction = new bool[Size, Size];
}
public int Version { get; }
public int Size { get; }
public QrErrorCorrection ErrorCorrection { get; }
/// <summary>Masque retenu (0 à 7).</summary>
public int Mask { get; private set; }
/// <summary>Module sombre en (x, y) ; hors du symbole : clair (marge).</summary>
public bool this[int x, int y] => x >= 0 && y >= 0 && x < Size && y < Size && _modules[y, x];
public static QrCode Encode(string text, QrErrorCorrection level = QrErrorCorrection.Medium) =>
EncodeBytes(Encoding.UTF8.GetBytes(text ?? throw new ArgumentNullException(nameof(text))), level);
/// <param name="mask">Masque imposé (tests) ; null : celui qui rend le symbole le plus lisible.</param>
public static QrCode EncodeBytes(byte[] data, QrErrorCorrection level = QrErrorCorrection.Medium, int? mask = null)
{
if (mask is < 0 or > 7) throw new ArgumentOutOfRangeException(nameof(mask));
var version = MinVersion;
for (; ; version++)
{
if (version > MaxVersion) throw new ArgumentException("Texte trop long pour un QR code.", nameof(data));
var needed = 4 + CharCountBits(version) + data.Length * 8;
if (data.Length < (1 << CharCountBits(version)) && needed <= DataCodewords(version, level) * 8) break;
}
// Segment « octets », terminaison, remplissage.
var bits = new BitWriter();
bits.Append(0b0100, 4);
bits.Append(data.Length, CharCountBits(version));
foreach (var b in data) bits.Append(b, 8);
var capacity = DataCodewords(version, level) * 8;
bits.Append(0, Math.Min(4, capacity - bits.Length));
bits.Append(0, (8 - bits.Length % 8) % 8);
for (var pad = 0xEC; bits.Length < capacity; pad ^= 0xEC ^ 0x11) bits.Append(pad, 8);
var qr = new QrCode(version, level);
qr.DrawFunctionPatterns();
qr.DrawCodewords(qr.AddErrorCorrection(bits.ToBytes()));
if (mask is { } forced)
{
qr.ApplyMask(forced);
qr.DrawFormatBits(forced);
qr.Mask = forced;
return qr;
}
var best = 0;
var bestPenalty = int.MaxValue;
for (var m = 0; m < 8; m++)
{
qr.ApplyMask(m);
qr.DrawFormatBits(m);
var penalty = qr.Penalty();
if (penalty < bestPenalty)
{
best = m;
bestPenalty = penalty;
}
qr.ApplyMask(m); // XOR : annule le masque
}
qr.ApplyMask(best);
qr.DrawFormatBits(best);
qr.Mask = best;
return qr;
}
/// <summary>
/// Tracé vectoriel (syntaxe des chemins SVG / WPF) : un rectangle par suite horizontale de modules sombres, une unité
/// par module, marge claire de <paramref name="border"/> modules comprise dans les coordonnées.
/// </summary>
public string ToPathData(int border = 4)
{
var builder = new StringBuilder();
for (var y = 0; y < Size; y++)
{
for (var x = 0; x < Size; x++)
{
if (!_modules[y, x]) continue;
var start = x;
while (x + 1 < Size && _modules[y, x + 1]) x++;
var length = x - start + 1;
builder.Append(CultureInfo.InvariantCulture, $"M{start + border},{y + border}h{length}v1h-{length}z");
}
}
return builder.ToString();
}
// ------------------------------------------------------------------ Motifs fixes
private void DrawFunctionPatterns()
{
for (var i = 0; i < Size; i++)
{
SetFunction(6, i, i % 2 == 0);
SetFunction(i, 6, i % 2 == 0);
}
DrawFinder(3, 3);
DrawFinder(Size - 4, 3);
DrawFinder(3, Size - 4);
var positions = AlignmentPositions();
var count = positions.Length;
for (var i = 0; i < count; i++)
{
for (var j = 0; j < count; j++)
{
if ((i == 0 && j == 0) || (i == 0 && j == count - 1) || (i == count - 1 && j == 0)) continue; // coins des repères
DrawAlignment(positions[i], positions[j]);
}
}
DrawFormatBits(0); // réservés, remplacés après le choix du masque
DrawVersion();
}
private void DrawFinder(int cx, int cy)
{
for (var dy = -4; dy <= 4; dy++)
{
for (var dx = -4; dx <= 4; dx++)
{
var distance = Math.Max(Math.Abs(dx), Math.Abs(dy));
var x = cx + dx;
var y = cy + dy;
if (x >= 0 && x < Size && y >= 0 && y < Size) SetFunction(x, y, distance != 2 && distance != 4);
}
}
}
private void DrawAlignment(int cx, int cy)
{
for (var dy = -2; dy <= 2; dy++)
for (var dx = -2; dx <= 2; dx++)
SetFunction(cx + dx, cy + dy, Math.Max(Math.Abs(dx), Math.Abs(dy)) != 1);
}
private int[] AlignmentPositions()
{
if (Version == 1) return Array.Empty<int>();
var count = Version / 7 + 2;
var step = (Version * 8 + count * 3 + 5) / (count * 4 - 4) * 2;
var result = new int[count];
result[0] = 6;
for (int i = count - 1, position = Size - 7; i >= 1; i--, position -= step) result[i] = position;
return result;
}
private void DrawFormatBits(int mask)
{
var data = FormatLevelBits(ErrorCorrection) << 3 | mask;
var remainder = data;
for (var i = 0; i < 10; i++) remainder = (remainder << 1) ^ ((remainder >> 9) * 0x537);
var bits = ((data << 10) | remainder) ^ 0x5412;
for (var i = 0; i <= 5; i++) SetFunction(8, i, Bit(bits, i));
SetFunction(8, 7, Bit(bits, 6));
SetFunction(8, 8, Bit(bits, 7));
SetFunction(7, 8, Bit(bits, 8));
for (var i = 9; i < 15; i++) SetFunction(14 - i, 8, Bit(bits, i));
for (var i = 0; i < 8; i++) SetFunction(Size - 1 - i, 8, Bit(bits, i));
for (var i = 8; i < 15; i++) SetFunction(8, Size - 15 + i, Bit(bits, i));
SetFunction(8, Size - 8, true); // module toujours sombre
}
private void DrawVersion()
{
if (Version < 7) return;
var remainder = Version;
for (var i = 0; i < 12; i++) remainder = (remainder << 1) ^ ((remainder >> 11) * 0x1F25);
var bits = (Version << 12) | remainder;
for (var i = 0; i < 18; i++)
{
var dark = Bit(bits, i);
var a = Size - 11 + i % 3;
var b = i / 3;
SetFunction(a, b, dark);
SetFunction(b, a, dark);
}
}
private void SetFunction(int x, int y, bool dark)
{
_modules[y, x] = dark;
_isFunction[y, x] = true;
}
// ------------------------------------------------------------------ Données et correction d'erreurs
/// <summary>Blocs de données suivis de leur code de Reed-Solomon, entrelacés.</summary>
private byte[] AddErrorCorrection(byte[] data)
{
var level = (int)ErrorCorrection;
var blocks = ErrorCorrectionBlocks[level, Version];
var eccLength = EccCodewordsPerBlock[level, Version];
var raw = RawDataModules(Version) / 8;
var shortBlocks = blocks - raw % blocks;
var shortLength = raw / blocks;
var divisor = ReedSolomonDivisor(eccLength);
var allBlocks = new List<byte[]>(blocks);
for (int i = 0, k = 0; i < blocks; i++)
{
var length = shortLength - eccLength + (i < shortBlocks ? 0 : 1);
var block = new byte[shortLength + 1];
Array.Copy(data, k, block, 0, length);
k += length;
var ecc = ReedSolomonRemainder(block.AsSpan(0, length), divisor);
// Bloc court : une case vide, ignorée à l'entrelacement, garde les colonnes alignées.
Array.Copy(ecc, 0, block, shortLength + 1 - eccLength, eccLength);
allBlocks.Add(block);
}
var result = new byte[raw];
var index = 0;
for (var i = 0; i < shortLength + 1; i++)
{
for (var j = 0; j < allBlocks.Count; j++)
{
if (i != shortLength - eccLength || j >= shortBlocks) result[index++] = allBlocks[j][i];
}
}
return result;
}
private void DrawCodewords(byte[] data)
{
var i = 0;
for (var right = Size - 1; right >= 1; right -= 2)
{
if (right == 6) right = 5; // colonne du motif de synchronisation
for (var vertical = 0; vertical < Size; vertical++)
{
for (var j = 0; j < 2; j++)
{
var x = right - j;
var upward = ((right + 1) & 2) == 0;
var y = upward ? Size - 1 - vertical : vertical;
if (_isFunction[y, x] || i >= data.Length * 8) continue;
_modules[y, x] = Bit(data[i >> 3], 7 - (i & 7));
i++;
}
}
}
}
private void ApplyMask(int mask)
{
for (var y = 0; y < Size; y++)
{
for (var x = 0; x < Size; x++)
{
var invert = mask switch
{
0 => (x + y) % 2 == 0,
1 => y % 2 == 0,
2 => x % 3 == 0,
3 => (x + y) % 3 == 0,
4 => (x / 3 + y / 2) % 2 == 0,
5 => x * y % 2 + x * y % 3 == 0,
6 => (x * y % 2 + x * y % 3) % 2 == 0,
_ => ((x + y) % 2 + x * y % 3) % 2 == 0
};
if (invert && !_isFunction[y, x]) _modules[y, x] = !_modules[y, x];
}
}
}
/// <summary>Pénalités de la norme (suites, carrés, motifs trompeurs, équilibre clair / sombre) : le masque le moins pénalisé est retenu.</summary>
private int Penalty()
{
var result = 0;
for (var pass = 0; pass < 2; pass++)
{
for (var a = 0; a < Size; a++)
{
var run = 1;
var line = new bool[Size];
for (var b = 0; b < Size; b++) line[b] = pass == 0 ? _modules[a, b] : _modules[b, a];
for (var b = 1; b < Size; b++)
{
if (line[b] == line[b - 1])
{
run++;
if (run == 5) result += 3;
else if (run > 5) result++;
}
else
{
run = 1;
}
}
for (var b = 0; b + 11 <= Size; b++)
{
if (Matches(line, b, FinderLike) || Matches(line, b, FinderLikeReversed)) result += 40;
}
}
}
for (var y = 0; y < Size - 1; y++)
{
for (var x = 0; x < Size - 1; x++)
{
var color = _modules[y, x];
if (color == _modules[y, x + 1] && color == _modules[y + 1, x] && color == _modules[y + 1, x + 1]) result += 3;
}
}
var dark = 0;
foreach (var module in _modules) if (module) dark++;
var total = Size * Size;
var k = (Math.Abs(dark * 20 - total * 10) + total - 1) / total - 1;
return result + k * 10;
}
private static readonly bool[] FinderLike = { true, false, true, true, true, false, true, false, false, false, false };
private static readonly bool[] FinderLikeReversed = FinderLike.Reverse().ToArray();
private static bool Matches(bool[] line, int start, bool[] pattern)
{
for (var i = 0; i < pattern.Length; i++) if (line[start + i] != pattern[i]) return false;
return true;
}
// ------------------------------------------------------------------ Reed-Solomon sur GF(256), polynôme 0x11D
private static byte[] ReedSolomonDivisor(int degree)
{
var result = new byte[degree];
result[degree - 1] = 1;
var root = 1;
for (var i = 0; i < degree; i++)
{
for (var j = 0; j < degree; j++)
{
result[j] = Multiply(result[j], root);
if (j + 1 < degree) result[j] ^= result[j + 1];
}
root = Multiply(root, 0x02);
}
return result;
}
private static byte[] ReedSolomonRemainder(ReadOnlySpan<byte> data, byte[] divisor)
{
var result = new byte[divisor.Length];
foreach (var b in data)
{
var factor = b ^ result[0];
Array.Copy(result, 1, result, 0, result.Length - 1);
result[^1] = 0;
for (var i = 0; i < result.Length; i++) result[i] ^= Multiply(divisor[i], factor);
}
return result;
}
private static byte Multiply(int x, int y)
{
var z = 0;
for (var i = 7; i >= 0; i--)
{
z = (z << 1) ^ ((z >> 7) * 0x11D);
z ^= ((y >> i) & 1) * x;
}
return (byte)z;
}
// ------------------------------------------------------------------ Tables de la norme
private static int CharCountBits(int version) => version <= 9 ? 8 : 16;
private static int FormatLevelBits(QrErrorCorrection level) => level switch
{
QrErrorCorrection.Low => 1,
QrErrorCorrection.Medium => 0,
QrErrorCorrection.Quartile => 3,
_ => 2
};
/// <summary>Modules disponibles pour les données et la correction (hors motifs fixes et informations de format / version).</summary>
internal static int RawDataModules(int version)
{
var result = (16 * version + 128) * version + 64;
if (version >= 2)
{
var alignments = version / 7 + 2;
result -= (25 * alignments - 10) * alignments - 55;
if (version >= 7) result -= 36;
}
return result;
}
internal static int DataCodewords(int version, QrErrorCorrection level) =>
RawDataModules(version) / 8 - EccCodewordsPerBlock[(int)level, version] * ErrorCorrectionBlocks[(int)level, version];
private static readonly int[,] EccCodewordsPerBlock =
{
{ -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30 },
{ -1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28 },
{ -1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30 },
{ -1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30 }
};
private static readonly int[,] ErrorCorrectionBlocks =
{
{ -1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25 },
{ -1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49 },
{ -1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68 },
{ -1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81 }
};
private static bool Bit(int value, int index) => ((value >> index) & 1) != 0;
/// <summary>Suite de bits, du plus significatif au moins significatif.</summary>
private sealed class BitWriter
{
private readonly List<bool> _bits = new();
public int Length => _bits.Count;
public void Append(int value, int count)
{
for (var i = count - 1; i >= 0; i--) _bits.Add(((value >> i) & 1) != 0);
}
public byte[] ToBytes()
{
var result = new byte[_bits.Count / 8];
for (var i = 0; i < _bits.Count; i++)
if (_bits[i]) result[i >> 3] |= (byte)(0x80 >> (i & 7));
return result;
}
}
}
@@ -58,6 +58,7 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
new("files.", "Fichiers du serveur"),
new("keyring.", "Signature (trousseau)"),
new("feedback.", "Demandes des joueurs"),
new("players.", "Comptes joueurs"),
new("maintenance.", "Mode maintenance"),
new("audit.", "Journal d'audit (consultations, exports, purges)"),
new("security", "Refus de sécurité")
@@ -511,6 +512,25 @@ public static class AuditTexts
"maintenance.enable" => "Mode maintenance activé",
"maintenance.disable" => "Mode maintenance terminé",
"maintenance.update" => "Message de la maintenance modifié",
"players.view" => "Comptes joueurs consultés",
"players.search" => "Compte joueur recherché",
"players.disable" => "Compte joueur désactivé",
"players.enable" => "Compte joueur réactivé",
"players.unlock" => "Suspension ou blocage d'un compte joueur levé",
"players.password_required" => "Nouveau mot de passe d'un joueur exigé (ou plus exigé)",
"players.temporary_password" => "Mot de passe temporaire créé pour un joueur",
"players.otp_reset" => "Double authentification d'un joueur réinitialisée",
"players.logout" => "Appareils d'un joueur déconnectés",
"players.delete" => "Compte joueur supprimé",
"players.suspend" => "Compte joueur suspendu automatiquement (trop d'essais)",
"players.self_delete" => "Compte joueur supprimé par son titulaire",
"players.inactive" => "Comptes joueurs inactifs (mise en sommeil, suppression)",
"players.inactive_delete" => "Compte joueur inactif supprimé",
"players.events_purge" => "Ancienne activité des comptes joueurs supprimée (conservation)",
"cli.players_unlock" => "Compte joueur débloqué sur le VPS",
"cli.players_enable" => "Compte joueur réactivé sur le VPS",
"cli.players_disable" => "Compte joueur désactivé sur le VPS",
"cli.players_otp_reset" => "Double authentification d'un joueur réinitialisée sur le VPS",
"files.delete" => "Fichier du serveur supprimé",
"files.prune" => "Dossier « objects » nettoyé",
"request.signature" => "Requête signée refusée",
@@ -531,13 +551,15 @@ public static class AuditTexts
/// <summary>
/// Catégorie : préfixe de l'action (« auth. »…) ; « security » : refus de signature et tentatives regroupées ;
/// « audit. » : consultations et exports du journal (pas les tentatives regroupées, classées en sécurité).
/// « audit. » : consultations et exports du journal (pas les tentatives regroupées, classées en sécurité) ;
/// « players. » : comptes joueurs, commandes du VPS sur ces comptes comprises (aussi dans « Outil du VPS »).
/// </summary>
public static bool InCategory(string action, string? category) => category switch
{
null => true,
"security" => action is "request.signature" or "audit.suppressed",
"audit." => action is "audit.view" or "audit.export" or "audit.purge",
"players." => action.StartsWith("players.", StringComparison.Ordinal) || action.StartsWith("cli.players_", StringComparison.Ordinal),
_ => action.StartsWith(category, StringComparison.Ordinal)
};
@@ -0,0 +1,226 @@
using System.Collections.ObjectModel;
using System.Globalization;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Players;
/// <summary>Textes de dates des comptes (heure du PC).</summary>
internal static class PlayerTexts
{
public static string Date(DateTimeOffset date) => date.ToLocalTime().ToString("dd/MM/yyyy", CultureInfo.InvariantCulture);
public static string DateTime(DateTimeOffset date) => date.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
public static string Time(DateTimeOffset date) => date.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture);
public static string Plural(int count, string one, string many) => $"{count} {(count > 1 ? many : one)}";
/// <summary>« Actif », « Désactivé jusqu'au 18/11/2026 »…</summary>
public static string Status(PlayerAdminSummary summary) =>
summary.Status == PlayerAccountStatuses.Disabled
? summary.DisabledUntil is { } until ? $"Désactivé jusqu'au {Date(until)}" : "Désactivé"
: summary.Status == PlayerAccountStatuses.Suspended ? "Suspendu"
: PlayerAccountStatuses.Label(summary.Status);
}
/// <summary>Compte dans la liste (adresse masquée).</summary>
public sealed class PlayerListItem : ObservableObject
{
private PlayerAdminSummary _summary;
public PlayerListItem(PlayerAdminSummary summary) => _summary = summary;
public PlayerAdminSummary Summary
{
get => _summary;
set
{
_summary = value;
OnPropertyChanged(string.Empty); // toutes les propriétés
}
}
public string Reference => _summary.Reference;
public string EmailMasked => _summary.EmailMasked;
public string StatusLabel => PlayerTexts.Status(_summary);
public bool IsActive => _summary.Status == PlayerAccountStatuses.Active;
public bool IsDisabled => _summary.Status == PlayerAccountStatuses.Disabled;
public bool IsSuspended => _summary.Status == PlayerAccountStatuses.Suspended;
public bool IsInactive => _summary.Status == PlayerAccountStatuses.Inactive;
public bool IsLocked => _summary.LockedUntil is not null;
public bool OtpEnabled => _summary.OtpEnabled;
public bool MustChangePassword => _summary.MustChangePassword;
/// <summary>« Créé le 12/10/2026 · 2 appareils · connexion le 08/11/2026 ».</summary>
public string DetailText =>
$"Créé le {PlayerTexts.Date(_summary.CreatedAt)} · {PlayerTexts.Plural(_summary.DeviceCount, "appareil", "appareils")}" +
(_summary.LastLoginAt is { } login ? $" · connexion le {PlayerTexts.Date(login)}" : "");
/// <summary>Infobulle des pastilles (blocage, double authentification, nouveau mot de passe).</summary>
public string FlagsText => string.Join(" · ", new[]
{
IsLocked ? $"bloqué jusqu'à {PlayerTexts.Time(_summary.LockedUntil!.Value)} (trop d'essais)" : null,
OtpEnabled ? "double authentification activée" : null,
MustChangePassword ? "nouveau mot de passe exigé" : null
}.Where(t => t is not null));
}
/// <summary>Ligne « libellé : valeur » de la fiche (en orange si elle demande de l'attention).</summary>
public sealed record PlayerFact(string Label, string Value, bool Warning = false);
/// <summary>Appareil connecté d'un compte (déconnexion possible avec players.manage).</summary>
public sealed class PlayerDeviceItem
{
public PlayerDeviceItem(PlayerAdminDevice device, bool canManage, Func<PlayerDeviceItem, Task> logout)
{
Device = device;
CanManage = canManage;
LogoutCommand = new AsyncRelayCommand(() => logout(this), () => CanManage);
}
public PlayerAdminDevice Device { get; }
public string Id => Device.Id;
public string Label => Device.Label;
public bool CanManage { get; }
public string DetailText =>
$"Clé {Device.KeyId} · connecté le {PlayerTexts.Date(Device.CreatedAt)} · utilisé le {PlayerTexts.DateTime(Device.LastUsedAt)}" +
$" · mémorisé jusqu'au {PlayerTexts.Date(Device.RememberedUntil)}";
public AsyncRelayCommand LogoutCommand { get; }
}
/// <summary>Ligne de l'activité du compte (adresse IP complète).</summary>
public sealed class PlayerEventItem
{
public PlayerEventItem(PlayerAdminEvent e)
{
Event = e;
TimeText = PlayerTexts.DateTime(e.At);
Label = PlayerActivityText.Describe(e.Type, e.Success);
OriginText = string.Join(" · ", new[]
{
e.Ip is { } ip ? $"IP {ip}" : null,
e.Device is { } device ? $"appareil « {device} »" : e.DeviceKeyId is { } key ? $"clé {key}" : null
}.Where(t => t is not null));
}
public PlayerAdminEvent Event { get; }
public string TimeText { get; }
public string Label { get; }
/// <summary>Code technique (« account.login »), en infobulle.</summary>
public string Type => Event.Type;
public bool IsFailure => !Event.Success;
public string OriginText { get; }
public bool HasOrigin => OriginText.Length > 0;
public string Details => Event.Details ?? "";
public bool HasDetails => !string.IsNullOrEmpty(Event.Details);
}
/// <summary>Fiche d'un compte (lecture) et état des actions possibles.</summary>
public sealed class PlayerDetailView
{
public PlayerDetailView(PlayerAdminDetail detail, bool canManage, bool canDelete, Func<PlayerDeviceItem, Task> logoutDevice,
Action<string> copy)
{
Detail = detail;
CanManage = canManage;
CanDelete = canDelete;
var s = detail.Summary;
Devices = new ObservableCollection<PlayerDeviceItem>(detail.Devices.Select(d => new PlayerDeviceItem(d, canManage, logoutDevice)));
Events = detail.Events.Select(e => new PlayerEventItem(e)).ToList();
CopyReferenceCommand = new RelayCommand(() => copy(s.Reference));
var facts = new List<PlayerFact>
{
new("Créé le", $"{PlayerTexts.DateTime(s.CreatedAt)} (conditions version {detail.TermsVersion})"),
new("Dernière connexion", s.LastLoginAt is { } login ? PlayerTexts.DateTime(login) : "jamais"),
new("Dernière activité", s.LastActivityAt is { } used ? $"{PlayerTexts.DateTime(used)} (reconnexion automatique comprise)" : "aucun appareil connecté"),
new("Adresse e-mail", detail.EmailVerified ? "vérifiée" : "non vérifiée (module e-mail à venir)"),
new("Mot de passe", $"changé le {PlayerTexts.DateTime(detail.PasswordChangedAt)}" +
(s.MustChangePassword ? " · nouveau mot de passe exigé à la prochaine connexion" : ""), s.MustChangePassword)
};
if (detail.TemporaryPasswordExpiresAt is { } temporary)
facts.Add(new("Mot de passe temporaire", $"en attente, valable jusqu'à {PlayerTexts.Time(temporary)} (le {PlayerTexts.Date(temporary)})", true));
facts.Add(new("Double authentification", s.OtpEnabled
? $"activée · {PlayerTexts.Plural(detail.RecoveryCodesLeft, "code de secours restant", "codes de secours restants")}"
: "désactivée", s.OtpEnabled && detail.RecoveryCodesLeft <= 3));
facts.Add(new("Échecs de connexion", FailuresText(detail), s.LockedUntil is not null || detail.FailedCount > 0));
Facts = facts;
}
public PlayerAdminDetail Detail { get; }
public PlayerAdminSummary Summary => Detail.Summary;
public string Reference => Summary.Reference;
public string EmailMasked => Summary.EmailMasked;
public bool CanManage { get; }
public bool CanDelete { get; }
public string StatusLabel => PlayerTexts.Status(Summary);
public bool IsActive => Summary.Status == PlayerAccountStatuses.Active;
public bool IsDisabled => Summary.Status == PlayerAccountStatuses.Disabled;
public bool IsSuspended => Summary.Status == PlayerAccountStatuses.Suspended;
public bool IsInactive => Summary.Status == PlayerAccountStatuses.Inactive;
/// <summary>Explication de l'état (« Désactivé le 03/11/2026 à 14:02 par Nicolas (menu d'administration) »).</summary>
public string StatusText
{
get
{
var text = Summary.Status switch
{
PlayerAccountStatuses.Disabled => "Connexions refusées" + (Summary.DisabledUntil is { } until
? $" jusqu'au {PlayerTexts.DateTime(until)} (réactivation automatique)."
: " jusqu'à sa réactivation."),
PlayerAccountStatuses.Suspended => "Trop d'essais de mot de passe : les connexions depuis un NOUVEL appareil sont refusées ; " +
"les appareils déjà connectés fonctionnent.",
PlayerAccountStatuses.Inactive => "Mis en sommeil après une longue inactivité (appareils déconnectés) : une connexion avec le bon " +
"mot de passe le réveille.",
_ => "Compte utilisable."
};
if (Detail.StatusChangedAt is { } at && !IsActive)
text += $" Depuis le {PlayerTexts.DateTime(at)}{(Detail.StatusChangedBy is { } by ? $", par {by}" : "")}.";
return text;
}
}
public string? StatusReason => Detail.StatusReason;
public bool HasReason => !string.IsNullOrEmpty(Detail.StatusReason);
public IReadOnlyList<PlayerFact> Facts { get; }
public ObservableCollection<PlayerDeviceItem> Devices { get; }
public bool HasDevices => Devices.Count > 0;
public string DevicesTitle => $"Appareils connectés ({Devices.Count})";
public IReadOnlyList<PlayerEventItem> Events { get; }
public bool HasEvents => Events.Count > 0;
public string EventsTitle => $"Activité ({PlayerTexts.Plural(Events.Count, "ligne", "dernières lignes")})";
// ------------------------------------------------------------------ Actions possibles (affichage des boutons)
public bool CanEnable => CanManage && Summary.Status is PlayerAccountStatuses.Disabled or PlayerAccountStatuses.Inactive;
public bool CanUnlock => CanManage && (IsSuspended || Summary.LockedUntil is not null || Detail.FailedCount > 0 || Detail.LockLevel > 0 ||
Detail.TrustedFailedCount > 0);
public bool CanRequirePassword => CanManage && !Summary.MustChangePassword;
public bool CanCancelPasswordRequirement => CanManage && Summary.MustChangePassword;
public bool CanCreateTemporaryPassword => CanManage && !IsDisabled;
public bool CanResetOtp => CanManage && Summary.OtpEnabled;
public bool CanLogoutAll => CanManage && HasDevices;
public string DisableText => IsDisabled ? "Modifier la désactivation…" : "Désactiver…";
public string UnlockText => IsSuspended ? "Lever la suspension" : "Lever le blocage";
public RelayCommand CopyReferenceCommand { get; }
private static string FailuresText(PlayerAdminDetail d)
{
var parts = new List<string>();
if (d.FailedCount > 0 || d.LockLevel > 0)
parts.Add($"{d.FailedCount} en cours depuis des appareils inconnus, {PlayerTexts.Plural(d.LockLevel, "blocage", "blocages")}");
if (d.UnknownDevicesLockedUntil is { } unknown) parts.Add($"bloqué jusqu'à {PlayerTexts.Time(unknown)} (appareils inconnus)");
if (d.TrustedFailedCount > 0) parts.Add($"{d.TrustedFailedCount} depuis les appareils connectés");
if (d.TrustedDevicesLockedUntil is { } trusted) parts.Add($"bloqué jusqu'à {PlayerTexts.Time(trusted)} (appareils connectés)");
return parts.Count == 0 ? "aucun" : string.Join(" · ", parts);
}
}
@@ -0,0 +1,25 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Players;
/// <summary>
/// Module « Comptes joueurs » (étape 8d) : page de recherche, de consultation et de gestion des comptes des joueurs (lecture :
/// players.read ; actions : players.manage ; suppression : players.delete). La fenêtre « Compte » des joueurs est dans
/// GameLauncher.Modules.Accounts.
/// </summary>
public sealed class PlayersAdminModule : ILauncherModule
{
public string Id => "players";
public string DisplayName => "Comptes joueurs";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
PlayersPanelViewModel.PanelId,
"Comptes joueurs",
"Rechercher un compte (référence ou adresse e-mail exacte), le consulter, le désactiver, lever une suspension, donner un mot de passe temporaire…",
"AccountMultiple",
AdminPermissions.PlayersRead,
Order: 14,
context => new PlayersPanelViewModel(context)));
}
@@ -0,0 +1,608 @@
using System.Collections.ObjectModel;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Players;
/// <summary>
/// Page « Comptes joueurs » (étape 8d) :
/// • compteurs, liste filtrée par état (les plus récents d'abord, pages de 100), recherche par référence ou adresse e-mail
/// EXACTE (envoyée dans le corps d'une requête, jamais dans une adresse) ; les adresses ne sont jamais affichées en entier ;
/// • fiche : état, motif, mot de passe, double authentification, échecs, appareils et activité (adresses IP complètes) ;
/// chaque consultation est inscrite au journal d'audit par le serveur ;
/// • actions (players.manage, requêtes signées, confirmées) : désactiver (durée, motif montré au joueur) / réactiver, lever
/// une suspension ou un blocage, exiger un nouveau mot de passe, mot de passe temporaire (affiché une seule fois),
/// réinitialiser la double authentification, déconnecter un appareil ou tous ; suppression définitive (players.delete).
/// </summary>
public sealed class PlayersPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "players.accounts";
private const int PageSize = 100;
private readonly List<PlayerListItem> _loaded = new();
private PlayersOverview? _overview;
private Choice _filter;
private string _searchText = "";
private string? _searchResultText;
private string? _nextCursor;
private int _total;
private PlayerListItem? _selectedItem;
private PlayerDetailView? _detail;
private string? _statusMessage;
private int _loadVersion;
private bool _isDisableFormOpen;
private Choice _disableDuration;
private string _disableReason = "";
private string? _temporaryPassword;
private string? _temporaryPasswordText;
public PlayersPanelViewModel(IAdminContext context) : base(context)
{
Filters = PlayerAdminRules.Filters.Select(f => new Choice(f.Value, f.Label)).ToList();
_filter = Filters[0];
DisableDurations = new[]
{
new Choice("1", "24 heures"), new Choice("7", "7 jours"), new Choice("30", "30 jours"), new Choice("90", "90 jours"),
new Choice("365", "1 an"), new Choice(null, "Indéfiniment (jusqu'à la réactivation)")
};
_disableDuration = DisableDurations[1];
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy);
LoadMoreCommand = new AsyncRelayCommand(LoadMoreAsync, () => !IsBusy && _nextCursor is not null && !IsSearchResult);
SearchCommand = new AsyncRelayCommand(SearchAsync, () => !IsBusy && _searchText.Trim().Length > 0 && IsServiceEnabled);
ClearSearchCommand = new AsyncRelayCommand(ClearSearchAsync, () => !IsBusy && IsSearchResult);
OpenDisableCommand = new RelayCommand(() => IsDisableFormOpen = true, () => !IsBusy && _detail is { CanManage: true });
CancelDisableCommand = new RelayCommand(() => IsDisableFormOpen = false);
DisableCommand = new AsyncRelayCommand(DisableAsync, () => !IsBusy && _detail is { CanManage: true } && ReasonProblem is null);
EnableCommand = new AsyncRelayCommand(EnableAsync, () => !IsBusy && _detail is { CanEnable: true });
UnlockCommand = new AsyncRelayCommand(UnlockAsync, () => !IsBusy && _detail is { CanUnlock: true });
RequirePasswordCommand = new AsyncRelayCommand(() => SetPasswordRequirementAsync(true), () => !IsBusy && _detail is { CanRequirePassword: true });
CancelPasswordRequirementCommand = new AsyncRelayCommand(() => SetPasswordRequirementAsync(false),
() => !IsBusy && _detail is { CanCancelPasswordRequirement: true });
TemporaryPasswordCommand = new AsyncRelayCommand(TemporaryPasswordAsync, () => !IsBusy && _detail is { CanCreateTemporaryPassword: true });
ResetOtpCommand = new AsyncRelayCommand(ResetOtpAsync, () => !IsBusy && _detail is { CanResetOtp: true });
LogoutAllCommand = new AsyncRelayCommand(() => LogoutAsync(null), () => !IsBusy && _detail is { CanLogoutAll: true });
DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => !IsBusy && _detail is { CanDelete: true });
CopyTemporaryPasswordCommand = new RelayCommand(() =>
{
if (_temporaryPassword is { } password) Context.Dialogs.CopyToClipboard(password);
}, () => _temporaryPassword is not null);
DismissTemporaryPasswordCommand = new RelayCommand(ForgetTemporaryPassword);
}
public bool CanManage => Context.Session.Has(AdminPermissions.PlayersManage);
public bool CanDelete => Context.Session.Has(AdminPermissions.PlayersDelete);
// ------------------------------------------------------------------ Service et compteurs
/// <summary>Faux tant que le serveur n'a pas répondu, ou si les comptes joueurs n'y sont pas activés.</summary>
public bool IsServiceEnabled => _overview is { Enabled: true };
public bool ShowDisabledService => _overview is { Enabled: false };
public bool ShowRegistrationClosed => _overview is { Enabled: true, RegistrationEnabled: false };
/// <summary>Compteurs (« 1 248 comptes », « 12 désactivés »…), seulement ceux qui ne valent pas 0 (hors total).</summary>
public IReadOnlyList<PlayerFact> Counters { get; private set; } = Array.Empty<PlayerFact>();
public string RetentionText => _overview is not { Enabled: true } ? "Chaque consultation d'un compte est inscrite au journal d'audit." :
$"Activité conservée {_overview.EventsRetentionMonths} mois (adresses IP {_overview.EventsIpRetentionMonths} mois) · comptes " +
$"inactifs en sommeil après {_overview.InactiveDisableMonths} mois, supprimés {_overview.InactiveDeleteMonths} mois plus tard · " +
"chaque consultation est inscrite au journal d'audit.";
// ------------------------------------------------------------------ Filtre, recherche et liste
public IReadOnlyList<Choice> Filters { get; }
public Choice Filter
{
get => _filter;
set
{
if (value is null || Equals(value, _filter)) return;
if (IsBusy)
{
OnPropertyChanged(); // la liste de l'écran revient au filtre affiché
return;
}
_filter = value;
OnPropertyChanged();
ListLoad = ReloadListAsync();
}
}
/// <summary>Chargement lancé par le dernier changement de filtre (tests).</summary>
internal Task ListLoad { get; private set; } = Task.CompletedTask;
/// <summary>Référence (« PL-7KQ2-M9XD ») ou adresse e-mail complète.</summary>
public string SearchText
{
get => _searchText;
set
{
if (SetProperty(ref _searchText, value ?? "")) SearchCommand.RaiseCanExecuteChanged();
}
}
public int SearchMaxLength => PlayerAdminRules.MaxSearchLength;
/// <summary>La liste affiche le résultat d'une recherche (pas la liste filtrée).</summary>
public bool IsSearchResult => _searchResultText is not null;
public string? SearchResultText => _searchResultText;
public ObservableCollection<PlayerListItem> Items { get; } = new();
public PlayerListItem? SelectedItem
{
get => _selectedItem;
set
{
if (value is null || ReferenceEquals(value, _selectedItem)) return;
if (IsBusy)
{
OnPropertyChanged();
return;
}
_selectedItem = value;
OnPropertyChanged();
DetailLoad = LoadDetailAsync(value);
}
}
/// <summary>Chargement de la fiche lancé par la dernière sélection (tests).</summary>
internal Task DetailLoad { get; private set; } = Task.CompletedTask;
public PlayerDetailView? Detail
{
get => _detail;
private set
{
if (!SetProperty(ref _detail, value)) return;
OnPropertyChanged(nameof(HasDetail));
OnPropertyChanged(nameof(ShowNoSelection));
IsDisableFormOpen = false;
RaiseCommands();
}
}
public bool HasDetail => _detail is not null;
public bool ShowNoSelection => _detail is null && Items.Count > 0;
public bool HasItems => Items.Count > 0;
public override bool HasSplitLayout => HasItems;
public string? EmptyText => !IsServiceEnabled || IsBusy || Items.Count > 0 ? null
: IsSearchResult ? "Aucun compte ne correspond à cette référence ou à cette adresse."
: "Aucun compte pour ce filtre.";
/// <summary>« 1 248 comptes · 100 chargés » ou le résultat de la recherche.</summary>
public string CountText => IsSearchResult ? _searchResultText! :
$"{PlayerTexts.Plural(_total, "compte", "comptes")}" + (_loaded.Count < _total ? $" · {_loaded.Count} chargés" : "");
public bool CanLoadMore => _nextCursor is not null && !IsSearchResult;
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(_statusMessage);
public bool IsEditable => !IsBusy;
// ------------------------------------------------------------------ Désactivation (formulaire dans la fiche)
public bool IsDisableFormOpen
{
get => _isDisableFormOpen;
private set
{
if (!SetProperty(ref _isDisableFormOpen, value)) return;
if (value)
{
// Désactivation en cours : le formulaire reprend son motif.
DisableReason = _detail?.StatusReason ?? "";
}
OpenDisableCommand.RaiseCanExecuteChanged();
}
}
public IReadOnlyList<Choice> DisableDurations { get; }
public Choice DisableDuration
{
get => _disableDuration;
set
{
if (value is not null) SetProperty(ref _disableDuration, value);
}
}
/// <summary>Motif facultatif, montré au joueur à la connexion (une ligne, sans lien).</summary>
public string DisableReason
{
get => _disableReason;
set
{
if (!SetProperty(ref _disableReason, value ?? "")) return;
OnPropertyChanged(nameof(ReasonProblem));
OnPropertyChanged(nameof(HasReasonProblem));
DisableCommand.RaiseCanExecuteChanged();
}
}
public int ReasonMaxLength => PlayerAdminRules.MaxReasonLength;
/// <summary>Problème du motif saisi (contrôle en direct, mêmes règles que le serveur), sinon null.</summary>
public string? ReasonProblem
{
get
{
PlayerAdminRules.CleanReason(_disableReason, out var problem);
return problem is null ? null : $"Motif : {problem}";
}
}
public bool HasReasonProblem => ReasonProblem is not null;
// ------------------------------------------------------------------ Mot de passe temporaire (affiché une seule fois)
public bool HasTemporaryPassword => _temporaryPassword is not null;
public string? TemporaryPassword => _temporaryPassword;
public string? TemporaryPasswordText => _temporaryPasswordText;
// ------------------------------------------------------------------ Commandes
public AsyncRelayCommand ReloadCommand { get; }
public AsyncRelayCommand LoadMoreCommand { get; }
public AsyncRelayCommand SearchCommand { get; }
public AsyncRelayCommand ClearSearchCommand { get; }
public RelayCommand OpenDisableCommand { get; }
public RelayCommand CancelDisableCommand { get; }
public AsyncRelayCommand DisableCommand { get; }
public AsyncRelayCommand EnableCommand { get; }
public AsyncRelayCommand UnlockCommand { get; }
public AsyncRelayCommand RequirePasswordCommand { get; }
public AsyncRelayCommand CancelPasswordRequirementCommand { get; }
public AsyncRelayCommand TemporaryPasswordCommand { get; }
public AsyncRelayCommand ResetOtpCommand { get; }
public AsyncRelayCommand LogoutAllCommand { get; }
public AsyncRelayCommand DeleteCommand { get; }
public RelayCommand CopyTemporaryPasswordCommand { get; }
public RelayCommand DismissTemporaryPasswordCommand { get; }
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(IsEditable));
OnPropertyChanged(nameof(EmptyText));
RaiseCommands();
}
private void RaiseCommands()
{
foreach (var command in new[]
{
ReloadCommand, LoadMoreCommand, SearchCommand, ClearSearchCommand, DisableCommand, EnableCommand, UnlockCommand,
RequirePasswordCommand, CancelPasswordRequirementCommand, TemporaryPasswordCommand, ResetOtpCommand,
LogoutAllCommand, DeleteCommand
})
command.RaiseCanExecuteChanged();
OpenDisableCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
// ------------------------------------------------------------------ Chargement
/// <summary>Compteurs, puis la première page de la liste filtrée (si le service est activé).</summary>
public Task LoadAsync() => RunAsync(async () =>
{
var version = ++_loadVersion;
ApplyOverview(await Context.Session.GetPlayersOverviewAsync());
if (!IsServiceEnabled) return;
var page = await Context.Session.GetPlayersPageAsync(_filter.Value, null, PageSize);
if (version != _loadVersion) return; // filtre changé entre-temps
ClearList();
AddPage(page);
});
private async Task ReloadAsync()
{
var selected = _selectedItem?.Reference;
StatusMessage = null;
if (IsSearchResult && _searchText.Trim().Length > 0) await SearchAsync();
else await LoadAsync();
if (selected is not null && Items.FirstOrDefault(i => i.Reference == selected) is { } again)
{
_selectedItem = again;
OnPropertyChanged(nameof(SelectedItem));
await LoadDetailAsync(again);
}
}
private Task ReloadListAsync()
{
_searchResultText = null;
OnPropertyChanged(nameof(IsSearchResult));
OnPropertyChanged(nameof(SearchResultText));
StatusMessage = null;
return LoadAsync();
}
private Task LoadMoreAsync() => RunAsync(async () =>
{
if (_nextCursor is null || IsSearchResult) return;
var version = _loadVersion;
var page = await Context.Session.GetPlayersPageAsync(_filter.Value, _nextCursor, PageSize);
if (version != _loadVersion) return;
AddPage(page);
});
/// <summary>Recherche par référence ou adresse exacte : la liste n'affiche plus que le résultat.</summary>
private Task SearchAsync() => RunAsync(async () =>
{
var query = _searchText.Trim();
if (query.Length == 0) return;
StatusMessage = null;
var version = ++_loadVersion;
var result = await Context.Session.SearchPlayersAsync(query);
if (version != _loadVersion) return;
ClearList();
_searchResultText = result.Items.Count == 0
? "Aucun compte trouvé"
: result.Kind == "email" ? "Compte trouvé par son adresse e-mail" : "Compte trouvé par sa référence";
OnPropertyChanged(nameof(IsSearchResult));
OnPropertyChanged(nameof(SearchResultText));
_loaded.AddRange(result.Items.Select(s => new PlayerListItem(s)));
_total = result.Items.Count;
ApplyList();
if (Items.Count == 1)
{
_selectedItem = Items[0];
OnPropertyChanged(nameof(SelectedItem));
DetailLoad = LoadDetailCoreAsync(Items[0]);
await DetailLoad;
}
});
private async Task ClearSearchAsync()
{
SearchText = "";
await ReloadListAsync();
}
private Task LoadDetailAsync(PlayerListItem item) => RunAsync(() => LoadDetailCoreAsync(item));
private async Task LoadDetailCoreAsync(PlayerListItem item)
{
var detail = await Context.Session.GetPlayerAsync(item.Reference);
if (!ReferenceEquals(item, _selectedItem)) return; // autre compte choisi entre-temps
ForgetTemporaryPassword();
Show(detail);
}
private void ApplyOverview(PlayersOverview overview)
{
_overview = overview;
var counters = new List<PlayerFact> { new("comptes", overview.Total.ToString("N0")) };
void Add(int count, string label, bool warning = false)
{
if (count > 0) counters.Add(new PlayerFact(label, count.ToString("N0"), warning));
}
Add(overview.Active, overview.Active > 1 ? "actifs" : "actif");
Add(overview.Disabled, overview.Disabled > 1 ? "désactivés" : "désactivé");
Add(overview.Suspended, overview.Suspended > 1 ? "suspendus" : "suspendu", warning: true);
Add(overview.Locked, overview.Locked > 1 ? "bloqués (essais)" : "bloqué (essais)", warning: true);
Add(overview.Inactive, "en sommeil");
Add(overview.PasswordChangeRequired, "nouveau mot de passe exigé");
Add(overview.OtpEnabled, "double authentification");
Add(overview.CreatedLast30Days, overview.CreatedLast30Days > 1 ? "créés en 30 jours" : "créé en 30 jours");
Counters = counters;
foreach (var name in new[] { nameof(Counters), nameof(IsServiceEnabled), nameof(ShowDisabledService), nameof(ShowRegistrationClosed),
nameof(RetentionText), nameof(EmptyText) })
OnPropertyChanged(name);
SearchCommand.RaiseCanExecuteChanged();
}
private void ClearList()
{
_loaded.Clear();
_nextCursor = null;
_total = 0;
_selectedItem = null;
OnPropertyChanged(nameof(SelectedItem));
Detail = null;
ForgetTemporaryPassword();
ApplyList();
}
private void AddPage(PlayerAdminPage page)
{
_loaded.AddRange(page.Items.Select(s => new PlayerListItem(s)));
_nextCursor = page.NextCursor;
_total = page.Total;
ApplyList();
}
private void ApplyList()
{
Items.Clear();
foreach (var item in _loaded) Items.Add(item);
foreach (var name in new[] { nameof(HasItems), nameof(HasSplitLayout), nameof(EmptyText), nameof(CountText), nameof(CanLoadMore),
nameof(ShowNoSelection) })
OnPropertyChanged(name);
LoadMoreCommand.RaiseCanExecuteChanged();
ClearSearchCommand.RaiseCanExecuteChanged();
}
private void Show(PlayerAdminDetail detail)
{
Detail = new PlayerDetailView(detail, CanManage, CanDelete, LogoutDeviceAsync, text => Context.Dialogs.CopyToClipboard(text));
if (_loaded.FirstOrDefault(i => i.Reference == detail.Summary.Reference) is { } item) item.Summary = detail.Summary;
}
// ------------------------------------------------------------------ Actions (requêtes signées)
/// <summary>Action sur le compte affiché, puis fiche à jour, message et compteurs.</summary>
private Task ActAsync(Func<string, Task<PlayerAdminDetail>> action, Func<PlayerAdminDetail, string> message) => RunAsync(async () =>
{
if (_detail is not { } current) return;
StatusMessage = null;
var updated = await action(current.Reference);
Show(updated);
StatusMessage = message(updated);
await RefreshCountsAsync();
});
private async Task DisableAsync()
{
if (_detail is not { } detail) return;
if (PlayerAdminRules.CleanReason(_disableReason, out var problem) is var reason && problem is not null)
{
ErrorMessage = $"Motif : {problem}";
return;
}
DateTimeOffset? until = _disableDuration.Value is { } days ? DateTimeOffset.UtcNow.AddDays(int.Parse(days, System.Globalization.CultureInfo.InvariantCulture)) : null;
var period = until is { } end ? $"jusqu'au {PlayerTexts.DateTime(end)}" : "indéfiniment";
if (!Context.Dialogs.Confirm("Désactiver le compte ?",
$"Le compte {detail.Reference} sera désactivé {period} : ses sessions sont fermées et toute connexion est refusée" +
(reason is null ? "." : $" avec le motif « {reason} ».") + " Cette action est inscrite au journal d'audit.",
"Désactiver", danger: true))
return;
await ActAsync(r => Context.Session.DisablePlayerAsync(r, until, reason), d => $"Compte {d.Summary.Reference} désactivé {period}.");
}
private Task EnableAsync() =>
ActAsync(r => Context.Session.EnablePlayerAsync(r), d => $"Compte {d.Summary.Reference} réactivé.");
private Task UnlockAsync()
{
var suspended = _detail?.IsSuspended == true;
return ActAsync(r => Context.Session.UnlockPlayerAsync(r),
d => suspended ? $"Suspension du compte {d.Summary.Reference} levée." : $"Blocage du compte {d.Summary.Reference} levé.");
}
private async Task SetPasswordRequirementAsync(bool required)
{
if (_detail is not { } detail) return;
if (required && !Context.Dialogs.Confirm("Exiger un nouveau mot de passe ?",
$"À sa prochaine connexion, le titulaire du compte {detail.Reference} devra choisir un nouveau mot de passe (il lui faut " +
"l'actuel, ou un mot de passe temporaire). Ses sessions ouvertes sont fermées et la reconnexion automatique est refusée " +
"d'ici là ; ses autres appareils seront déconnectés au changement.", "Exiger"))
return;
await ActAsync(r => Context.Session.SetPlayerPasswordRequirementAsync(r, required),
d => required ? $"Nouveau mot de passe exigé pour le compte {d.Summary.Reference}." : $"Plus aucun nouveau mot de passe exigé pour {d.Summary.Reference}.");
}
private async Task TemporaryPasswordAsync()
{
if (_detail is not { } detail) return;
if (!Context.Dialogs.Confirm("Créer un mot de passe temporaire ?",
$"Vérifiez d'abord que vous parlez bien au titulaire du compte {detail.Reference} (adresse {detail.EmailMasked}). Le mot de " +
"passe temporaire sera affiché UNE seule fois, valable une heure, et ne servira qu'à choisir un nouveau mot de passe ; " +
"l'ancien reste valable d'ici là. Un mot de passe temporaire précédent est annulé." +
(detail.IsSuspended ? " Le compte est suspendu : levez aussi la suspension s'il se connecte depuis un nouvel appareil." : "") +
(detail.Summary.OtpEnabled ? " La double authentification restera demandée." : ""),
"Créer"))
return;
await RunAsync(async () =>
{
StatusMessage = null;
var created = await Context.Session.CreatePlayerTemporaryPasswordAsync(detail.Reference);
Show(created.Account);
_temporaryPassword = created.Password;
_temporaryPasswordText =
$"Valable jusqu'à {PlayerTexts.Time(created.ExpiresAt)} (une heure), une seule fois, pour choisir un nouveau mot de passe. " +
"Transmettez-le au joueur par un moyen sûr (message privé), jamais dans un salon public : il ne sera plus affiché.";
RaiseTemporaryPassword();
await RefreshCountsAsync();
});
}
private async Task ResetOtpAsync()
{
if (_detail is not { } detail) return;
if (!Context.Dialogs.Confirm("Réinitialiser la double authentification ?",
$"Seulement si le titulaire du compte {detail.Reference} a perdu son téléphone ET ses codes de secours, après avoir vérifié " +
"son identité : la double authentification et les codes de secours sont supprimés ; il pourra la réactiver depuis « Mon compte ».",
"Réinitialiser", danger: true))
return;
await ActAsync(r => Context.Session.ResetPlayerOtpAsync(r), d => $"Double authentification du compte {d.Summary.Reference} réinitialisée.");
}
private async Task LogoutAsync(PlayerDeviceItem? device)
{
if (_detail is not { } detail) return;
var text = device is null
? $"Tous les appareils du compte {detail.Reference} seront déconnectés (sessions mémorisées comprises)."
: $"L'appareil « {device.Label} » du compte {detail.Reference} sera déconnecté.";
if (!Context.Dialogs.Confirm(device is null ? "Déconnecter tous les appareils ?" : "Déconnecter cet appareil ?",
text + " Le joueur devra se reconnecter avec son mot de passe.", "Déconnecter"))
return;
await ActAsync(r => Context.Session.LogoutPlayerDevicesAsync(r, device?.Id),
d => device is null ? $"Tous les appareils du compte {d.Summary.Reference} sont déconnectés." : $"Appareil « {device.Label} » déconnecté.");
}
private Task LogoutDeviceAsync(PlayerDeviceItem device) => LogoutAsync(device);
private async Task DeleteAsync()
{
if (_detail is not { } detail) return;
if (!Context.Dialogs.Confirm("Supprimer définitivement le compte ?",
$"Le compte {detail.Reference} ({detail.EmailMasked}), ses appareils, son activité et ses données dans les autres modules " +
"seront supprimés DÉFINITIVEMENT : c'est irréversible. Seule une ligne du journal d'audit (référence) en gardera la trace. " +
"Le jeu et ses sauvegardes sur le PC du joueur ne sont pas touchés.", "Supprimer définitivement", danger: true))
return;
await RunAsync(async () =>
{
StatusMessage = null;
await Context.Session.DeletePlayerAsync(detail.Reference);
_loaded.RemoveAll(i => i.Reference == detail.Reference);
_total = Math.Max(0, _total - 1);
_selectedItem = null;
OnPropertyChanged(nameof(SelectedItem));
Detail = null;
ForgetTemporaryPassword();
ApplyList();
StatusMessage = $"Compte {detail.Reference} supprimé définitivement.";
await RefreshCountsAsync();
});
}
/// <summary>Compteurs après un changement (sans effet sur la page en cas d'échec).</summary>
private async Task RefreshCountsAsync()
{
try
{
ApplyOverview(await Context.Session.GetPlayersOverviewAsync());
}
catch (ApiException)
{
// compteurs indicatifs : le prochain rechargement les mettra à jour
}
}
private void ForgetTemporaryPassword()
{
if (_temporaryPassword is null) return;
_temporaryPassword = null;
_temporaryPasswordText = null;
RaiseTemporaryPassword();
}
private void RaiseTemporaryPassword()
{
OnPropertyChanged(nameof(HasTemporaryPassword));
OnPropertyChanged(nameof(TemporaryPassword));
OnPropertyChanged(nameof(TemporaryPasswordText));
CopyTemporaryPasswordCommand.RaiseCanExecuteChanged();
}
}
+69
View File
@@ -24,6 +24,7 @@ using GameLauncher.Modules.Admin.Files;
using GameLauncher.Modules.Admin.Maintenance;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.Signing;
using GameLauncher.Modules.Admin.Players;
using GameLauncher.Platform;
using GameLauncher.Services;
using GameLauncher.ViewModels;
@@ -59,6 +60,10 @@ public partial class App : Application, ILauncherRestartHost
private HttpDownloader? _http;
private LauncherUpdateService? _updates;
private AdminWindowService? _admin;
/// <summary>Bouton « Compte » (comptes joueurs) et son client de l'API ; null si les comptes ne sont pas activés.</summary>
private AccountWindowService? _accounts;
private GameLauncher.ApiClient.LauncherApiClient? _accountsClient;
private CatalogService? _catalogService;
private ContentTrustService? _trust;
@@ -403,6 +408,10 @@ public partial class App : Application, ILauncherRestartHost
if (launcher.IsMaintenance) return; // bouton masqué pendant une maintenance
feedback.Open(game.GameId, game.GameName, game.InstalledVersion);
};
// Bouton « Compte » (comptes joueurs) : « playerAccounts » ET la même adresse d'API, lus au même endroit. La session
// mémorisée est reprise sans attendre (le launcher ne contacte l'API des comptes que s'il en existe une).
StartPlayerAccounts(catalog, apiUrl, version, window, launcher);
}
window.DataContext = launcher;
@@ -411,6 +420,61 @@ public partial class App : Application, ILauncherRestartHost
return window;
}
// ------------------------------------------------------------------ Comptes joueurs
private void StartPlayerAccounts(LauncherCatalog catalog, string? apiUrl, string version, Views.MainWindow window, LauncherViewModel launcher)
{
if (apiUrl is null || !ResolvePlayerAccounts(catalog) || !Uri.TryCreate(apiUrl, UriKind.Absolute, out var apiUri)) return;
try
{
// Agent utilisateur générique (version du launcher seulement), comme le bouton « Aide ».
_accountsClient = new GameLauncher.ApiClient.LauncherApiClient(apiUri, $"GameLauncher/{version}");
var service = new GameLauncher.Modules.Accounts.PlayerAccountService(
new GameLauncher.ApiClient.PlayerAccountApi(_accountsClient),
new PlayerDeviceKeyStore(catalog.Publisher + catalog.DataFolderName),
new GameLauncher.Modules.Accounts.PlayerSessionFile(catalog.DataDirectory,
new DpapiProtector(GameLauncher.Modules.Accounts.PlayerSessionFile.Purpose)),
GameLauncher.Modules.Accounts.PlayerDeviceLabel.Create(version));
_accounts = new AccountWindowService(window, launcher, service, catalog.Publisher);
window.AccountRequested += () =>
{
if (launcher.IsMaintenance) return; // bouton masqué pendant une maintenance
_accounts?.Open();
};
_accounts.Start(_lifetime.Token);
}
catch (Exception ex)
{
Log.Error("Comptes joueurs", ex);
launcher.IsAccountAvailable = false;
}
}
/// <summary>
/// « playerAccounts ». Comme apiUrl : en Release, seulement celui du launcher.json intégré à l'exécutable (un fichier
/// posé à côté ne peut pas l'activer).
/// </summary>
private static bool ResolvePlayerAccounts(LauncherCatalog catalog)
{
#if DEBUG
return catalog.PlayerAccounts;
#else
try
{
using var embedded = Assembly.GetExecutingAssembly().GetManifestResourceStream(ConfigFileName);
var integrated = embedded is null ? null : LauncherCatalog.Load(embedded);
if (catalog.PlayerAccounts && integrated?.PlayerAccounts != true)
Log.Warn("« playerAccounts » du launcher.json externe ignoré : seul celui intégré à l'exécutable est utilisé.");
return integrated?.PlayerAccounts == true;
}
catch (Exception ex)
{
Log.Error("Comptes joueurs (launcher.json intégré)", ex);
return false;
}
#endif
}
// ------------------------------------------------------------------ Mode maintenance
/// <summary>Attente maximale du mode maintenance au démarrage (au-delà : appliqué à son arrivée).</summary>
@@ -663,6 +727,7 @@ public partial class App : Application, ILauncherRestartHost
new AdminModule(),
new CatalogAdminModule(),
new FeedbackAdminModule(),
new PlayersAdminModule(),
new ChangelogAdminModule(),
new PublishAdminModule(),
new FilesAdminModule(),
@@ -757,6 +822,10 @@ public partial class App : Application, ILauncherRestartHost
_instanceChannel = null;
_admin?.Dispose(); // ferme le menu d'administration : clé effacée de la mémoire
_admin = null;
_accounts?.Dispose(); // session non mémorisée fermée sur le serveur (3 s au plus)
_accounts = null;
_accountsClient?.Dispose();
_accountsClient = null;
_trayIcon?.Dispose(); // sinon l'icône resterait affichée jusqu'au survol
_trayIcon = null;
_launcher?.Dispose();
+25
View File
@@ -30,6 +30,31 @@ public sealed class StringToVisibilityConverter : IValueConverter
throw new NotSupportedException();
}
/// <summary>
/// Tracé vectoriel (« M4,4h7v1h-7z… ») → Geometry figée, ex. QR code de la double authentification
/// (<see cref="GameLauncher.Modules.Accounts.QrCode.ToPathData"/>). Texte vide ou invalide : rien.
/// </summary>
public sealed class PathDataConverter : IValueConverter
{
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not string { Length: > 0 } data) return null;
try
{
var geometry = Geometry.Parse(data);
geometry.Freeze();
return geometry;
}
catch (FormatException)
{
return null;
}
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
/// <summary>Clé d'icône ("Play") → Geometry (ressource "Icon.Play").</summary>
public sealed class IconKeyConverter : IValueConverter
{
+3
View File
@@ -54,6 +54,9 @@
<ProjectReference Include="..\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj" />
<!-- Module « Aide » (demandes des joueurs) : formulaire sans WPF ; la fenêtre est ici (Views\FeedbackWindow.xaml). -->
<ProjectReference Include="..\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj" />
<!-- Module « Compte » (comptes joueurs) : service et ViewModels sans WPF ; la fenêtre est ici (Views\AccountWindow.xaml),
ainsi que la clé d'appareil (Platform\PlayerDeviceKeyStore.cs) et le chiffrement DPAPI (Platform\DpapiProtector.cs). -->
<ProjectReference Include="..\GameLauncher.Modules.Accounts\GameLauncher.Modules.Accounts.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,89 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using GameLauncher.Modules.Accounts;
namespace GameLauncher.Platform;
/// <summary>
/// Chiffrement local par DPAPI (CryptProtectData de Windows), portée « utilisateur Windows courant » : seul ce compte
/// Windows, sur ce PC, peut relire les données. Entropie propre à l'usage (<see cref="PlayerSessionFile.Purpose"/>) : un
/// autre programme qui utilise DPAPI pour le même utilisateur ne déchiffre pas ces données par hasard. Aucune fenêtre
/// (CRYPTPROTECT_UI_FORBIDDEN). Appel direct de crypt32.dll : aucune dépendance supplémentaire.
/// </summary>
public sealed class DpapiProtector : ISecretProtector
{
private const int UiForbidden = 0x1;
private readonly byte[] _entropy;
public DpapiProtector(byte[] entropy) => _entropy = entropy.ToArray();
public byte[] Protect(byte[] data) => Run(data, protect: true);
public byte[] Unprotect(byte[] data) => Run(data, protect: false);
private byte[] Run(byte[] data, bool protect)
{
var input = Allocate(data);
var entropy = Allocate(_entropy);
var output = new DataBlob();
try
{
var ok = protect
? CryptProtectData(ref input, null, ref entropy, IntPtr.Zero, IntPtr.Zero, UiForbidden, ref output)
: CryptUnprotectData(ref input, IntPtr.Zero, ref entropy, IntPtr.Zero, IntPtr.Zero, UiForbidden, ref output);
if (!ok)
throw new CryptographicException(protect ? "Chiffrement Windows (DPAPI) impossible." : "Données illisibles pour cet utilisateur Windows.",
new Win32Exception(Marshal.GetLastWin32Error()));
var result = new byte[output.Length];
Marshal.Copy(output.Data, result, 0, output.Length);
return result;
}
finally
{
Free(ref input);
Free(ref entropy);
if (output.Data != IntPtr.Zero)
{
Marshal.Copy(new byte[output.Length], 0, output.Data, output.Length); // effacé avant d'être rendu à Windows
LocalFree(output.Data);
}
}
}
private static DataBlob Allocate(byte[] bytes)
{
var blob = new DataBlob { Length = bytes.Length, Data = Marshal.AllocHGlobal(Math.Max(1, bytes.Length)) };
Marshal.Copy(bytes, 0, blob.Data, bytes.Length);
return blob;
}
private static void Free(ref DataBlob blob)
{
if (blob.Data == IntPtr.Zero) return;
Marshal.Copy(new byte[blob.Length], 0, blob.Data, blob.Length);
Marshal.FreeHGlobal(blob.Data);
blob.Data = IntPtr.Zero;
}
[StructLayout(LayoutKind.Sequential)]
private struct DataBlob
{
public int Length;
public IntPtr Data;
}
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CryptProtectData(ref DataBlob dataIn, string? description, ref DataBlob entropy, IntPtr reserved,
IntPtr prompt, int flags, ref DataBlob dataOut);
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CryptUnprotectData(ref DataBlob dataIn, IntPtr description, ref DataBlob entropy, IntPtr reserved,
IntPtr prompt, int flags, ref DataBlob dataOut);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr LocalFree(IntPtr memory);
}
@@ -0,0 +1,175 @@
using System.Runtime.ExceptionServices;
using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Security;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Accounts;
namespace GameLauncher.Platform;
/// <summary>
/// Clé d'appareil des comptes JOUEURS (ECDSA P-256), une par compte Windows, via les fournisseurs de clés de Windows (CNG) :
/// 1. dans la puce de sécurité (TPM 2.0, « Microsoft Platform Crypto Provider ») quand elle existe : clé générée par la
/// puce, jamais exportable, SANS code PIN (le joueur n'a rien à saisir ; une copie des fichiers de Windows est
/// inutilisable sur un autre PC) ;
/// 2. sinon clé logicielle du « Microsoft Software Key Storage Provider », marquée NON exportable, protégée par Windows
/// (DPAPI de l'utilisateur).
/// Aucune fenêtre Windows (mode silencieux). Totalement distincte des clés d'administrateur (autre nom, jamais de code PIN,
/// jamais utilisée pour l'administration). Méthodes lentes avec la puce : appelées hors du fil de l'interface.
/// </summary>
public sealed class PlayerDeviceKeyStore : IPlayerDeviceKeyStore
{
private static readonly CngProvider[] Providers = { CngProvider.MicrosoftPlatformCryptoProvider, CngProvider.MicrosoftSoftwareKeyStorageProvider };
private readonly string _keyName;
/// <param name="scope">Éditeur et dossier de données du launcher : deux launchers différents n'ont pas la même clé.</param>
public PlayerDeviceKeyStore(string scope)
{
var clean = new string((scope ?? "").Where(char.IsAsciiLetterOrDigit).Take(48).ToArray());
_keyName = $"GameLauncher.PlayerDevice.{(clean.Length > 0 ? clean : "Default")}.v1";
}
public IPlayerDeviceKey? TryOpen()
{
foreach (var provider in Providers)
{
try
{
if (!CngKey.Exists(_keyName, provider, CngKeyOpenOptions.Silent)) continue;
return new Signer(CngKey.Open(_keyName, provider, CngKeyOpenOptions.Silent), provider == CngProvider.MicrosoftPlatformCryptoProvider);
}
catch (CryptographicException ex)
{
Log.Warn($"Compte : clé d'appareil illisible ({provider.Provider}) : {ex.Message}");
}
}
return null;
}
public IPlayerDeviceKey OpenOrCreate()
{
if (TryOpen() is { } existing) return existing;
try
{
var tpm = Create(CngProvider.MicrosoftPlatformCryptoProvider);
Log.Info("Compte : clé d'appareil créée dans la puce de sécurité (TPM).");
return tpm;
}
catch (Exception ex) when (ex is CryptographicException or PlatformNotSupportedException)
{
Log.Info($"Compte : puce de sécurité indisponible ({ex.Message}) : clé logicielle protégée par Windows.");
TryDelete(CngProvider.MicrosoftPlatformCryptoProvider);
}
var software = Create(CngProvider.MicrosoftSoftwareKeyStorageProvider);
Log.Info("Compte : clé d'appareil logicielle (non exportable) créée.");
return software;
}
public void Delete()
{
foreach (var provider in Providers) TryDelete(provider);
}
/// <summary>Crée la clé (non exportable, de l'utilisateur Windows) puis vérifie qu'elle signe.</summary>
private Signer Create(CngProvider provider)
{
var parameters = new CngKeyCreationParameters
{
Provider = provider,
KeyCreationOptions = CngKeyCreationOptions.None // clé de l'utilisateur Windows (pas de la machine)
};
if (provider != CngProvider.MicrosoftPlatformCryptoProvider)
{
// Clé logicielle : jamais exportable, signature seulement (la puce, elle, n'exporte jamais une clé qu'elle a créée).
parameters.ExportPolicy = CngExportPolicies.None;
parameters.KeyUsage = CngKeyUsages.Signing;
}
var key = CngKey.Create(CngAlgorithm.ECDsaP256, _keyName, parameters);
Signer? signer = null;
try
{
signer = new Signer(key, provider == CngProvider.MicrosoftPlatformCryptoProvider);
signer.SignNow(RandomNumberGenerator.GetBytes(32)); // la clé fonctionne vraiment
return signer;
}
catch
{
if (signer is not null) signer.Dispose();
else key.Dispose();
TryDelete(provider);
throw;
}
}
private void TryDelete(CngProvider provider)
{
try
{
if (!CngKey.Exists(_keyName, provider, CngKeyOpenOptions.Silent)) return;
using var key = CngKey.Open(_keyName, provider, CngKeyOpenOptions.Silent);
key.Delete();
}
catch (Exception ex) when (ex is CryptographicException or PlatformNotSupportedException)
{
Log.Warn($"Compte : clé d'appareil non supprimée ({provider.Provider}) : {ex.Message}");
}
}
/// <summary>Clé ouverte : signe hors du fil de l'interface, une signature à la fois.</summary>
private sealed class Signer : IPlayerDeviceKey, IDisposable
{
private readonly ECDsaCng _ecdsa;
private readonly SemaphoreSlim _gate = new(1, 1);
public Signer(CngKey key, bool inTpm)
{
try
{
PublicKey = TpmKeyStore.PublicKeyOf(key); // vérifie aussi qu'il s'agit bien d'une clé ECDSA P-256
KeyId = AdminKeys.ComputeKeyId(PublicKey);
InTpm = inTpm;
_ecdsa = new ECDsaCng(key);
}
finally
{
key.Dispose(); // ECDsaCng garde sa propre référence sur la poignée
}
}
public byte[] PublicKey { get; }
public string KeyId { get; }
public bool InTpm { get; }
public byte[] SignNow(byte[] message) => _ecdsa.SignData(message, HashAlgorithmName.SHA256); // IEEE P1363, 64 octets
public async ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
ExceptionDispatchInfo? failure = null;
var signature = await Task.Run(() =>
{
try
{
return SignNow(message);
}
catch (Exception ex)
{
failure = ExceptionDispatchInfo.Capture(ex);
return null;
}
}, ct).ConfigureAwait(false);
failure?.Throw();
return signature!;
}
finally
{
_gate.Release();
}
}
public void Dispose() => _ecdsa.Dispose();
}
}
+6
View File
@@ -345,6 +345,12 @@ public sealed class TpmKeyStore : IAdminTpm
var code => $"Puce de sécurité (TPM) inutilisable : {(ex.InnerException ?? ex).Message} (code 0x{code:X8})."
};
/// <summary>
/// Clé publique d'une clé CNG ECDSA P-256 (puce ou logicielle), lue au format brut de Windows puis encodée en SPKI.
/// Partagé avec la clé d'appareil des joueurs (<see cref="PlayerDeviceKeyStore"/>).
/// </summary>
internal static byte[] PublicKeyOf(CngKey key) => TpmSigner.ReadPublicKey(key);
// ------------------------------------------------------------------ Signataire
/// <summary>Clé ouverte dans la puce : elle signe (dans la puce), la poignée est fermée au verrouillage.</summary>
@@ -0,0 +1,107 @@
using System.Windows;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Accounts;
using GameLauncher.Platform;
using GameLauncher.ViewModels;
using GameLauncher.Views;
namespace GameLauncher.Services;
/// <summary>
/// Bouton « Compte » de la barre de titre : fenêtre de connexion / création de compte / « Mon compte » (fenêtre modale), et
/// état du compte affiché sur le bouton. Présent seulement si « playerAccounts » et « apiUrl » sont renseignés dans le
/// launcher.json INTÉGRÉ (Release). Le launcher ne contacte l'API des comptes que si une session est mémorisée (reprise au
/// démarrage, sans bloquer) ou quand le joueur ouvre la fenêtre.
/// </summary>
public sealed class AccountWindowService : IDisposable
{
/// <summary>Attente maximale, à la sortie du launcher, de la fermeture d'une session non mémorisée.</summary>
private static readonly TimeSpan ShutdownBudget = TimeSpan.FromSeconds(3);
private readonly MainWindow _owner;
private readonly LauncherViewModel _launcher;
private readonly PlayerAccountService _service;
private readonly string _publisher;
private AccountWindow? _window;
private bool _disposed;
public AccountWindowService(MainWindow owner, LauncherViewModel launcher, PlayerAccountService service, string publisher)
{
_owner = owner;
_launcher = launcher;
_service = service;
_publisher = publisher;
_service.Changed += OnChanged;
_launcher.IsAccountAvailable = true;
ApplyState();
}
/// <summary>Reprend la session mémorisée (après l'affichage de la fenêtre principale, sans la bloquer).</summary>
public void Start(CancellationToken ct) => _ = _service.RestoreAsync(ct);
public void Open()
{
if (_disposed) return;
if (_window is not null)
{
_window.Activate();
return;
}
AccountWindowViewModel? viewModel = null;
try
{
AccountWindow? window = null;
viewModel = new AccountWindowViewModel(_service, new ModuleDialogs(() => window), new WpfUiDispatcher(_owner.Dispatcher), _publisher);
window = new AccountWindow(viewModel);
if (_owner.IsVisible) window.Owner = _owner;
else window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
_window = window;
Log.Info("Ouverture de la fenêtre « Compte ».");
_owner.SetDimmed(true);
try
{
window.ShowDialog();
}
finally
{
_owner.SetDimmed(false);
_window = null;
}
}
catch (Exception ex)
{
_window = null;
viewModel?.Dispose();
Log.Error("Ouverture de la fenêtre « Compte »", ex);
ModernDialog.ShowMessage(_owner.IsVisible ? _owner : null, "Compte indisponible", ex.Message, DialogKind.Error);
}
}
private void OnChanged() => _owner.Dispatcher.InvokeAsync(ApplyState);
/// <summary>État affiché sur le bouton « Compte » (pastille, info-bulle).</summary>
private void ApplyState()
{
if (_disposed) return;
var account = _service.Account;
_launcher.ApplyAccountState(_service.State switch
{
PlayerAccountState.SignedIn => AccountButtonState.SignedIn,
PlayerAccountState.Restoring => AccountButtonState.Restoring,
PlayerAccountState.Offline => AccountButtonState.Offline,
_ => AccountButtonState.SignedOut
}, account?.EmailMasked);
}
/// <summary>Sortie du launcher : session non mémorisée fermée sur le serveur (quelques secondes au plus).</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_service.Changed -= OnChanged;
_window?.Close();
_service.Shutdown(ShutdownBudget);
_service.Dispose();
}
}
+448
View File
@@ -11,6 +11,7 @@
xmlns:afil="clr-namespace:GameLauncher.Modules.Admin.Files;assembly=GameLauncher.Modules.Admin"
xmlns:afb="clr-namespace:GameLauncher.Modules.Admin.Feedback;assembly=GameLauncher.Modules.Admin"
xmlns:amnt="clr-namespace:GameLauncher.Modules.Admin.Maintenance;assembly=GameLauncher.Modules.Admin"
xmlns:apl="clr-namespace:GameLauncher.Modules.Admin.Players;assembly=GameLauncher.Modules.Admin"
xmlns:conv="clr-namespace:GameLauncher.Converters">
<!--
@@ -2455,6 +2456,433 @@
</DockPanel>
</DataTemplate>
<!-- ============================================================ Page « Comptes joueurs » (players.read, étape 8d) -->
<!-- Compte dans la liste (adresse masquée) -->
<Style x:Key="AdminPlayerItemStyle" TargetType="ListBoxItem">
<Setter Property="Margin" Value="0,0,0,6" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd" CornerRadius="12" Padding="12,9" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border Width="30" Height="30" CornerRadius="15" VerticalAlignment="Top" Background="{DynamicResource BadgeBrush}">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.AccountCircle}" />
</Border>
<StackPanel Grid.Column="1" Margin="10,0,8,0">
<TextBlock x:Name="ItemTitle" Text="{Binding EmailMasked}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextPrimaryBrush}" />
<TextBlock Text="{Binding Reference}" FontSize="11.5" Margin="0,1,0,0" FontFamily="Cascadia Mono, Consolas, Courier New"
Foreground="{DynamicResource TextSecondaryBrush}" />
<TextBlock Text="{Binding DetailText}" FontSize="11.5" Margin="0,2,0,0" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextMutedBrush}" ToolTip="{Binding DetailText}" />
</StackPanel>
<StackPanel Grid.Column="2" VerticalAlignment="Top" HorizontalAlignment="Right">
<TextBlock x:Name="StatusText" Text="{Binding StatusLabel}" FontSize="11" FontWeight="SemiBold" HorizontalAlignment="Right"
Foreground="{DynamicResource SuccessBrush}" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,4,0,0" ToolTip="{Binding FlagsText}">
<Path Width="12" Height="12" Stretch="Uniform" Margin="6,0,0,0" Fill="{DynamicResource WarningBrush}"
Data="{StaticResource Icon.Lock}" Visibility="{Binding IsLocked, Converter={StaticResource BoolToVisibility}}" />
<Path Width="12" Height="12" Stretch="Uniform" Margin="6,0,0,0" Fill="{DynamicResource WarningBrush}"
Data="{StaticResource Icon.LockReset}" Visibility="{Binding MustChangePassword, Converter={StaticResource BoolToVisibility}}" />
<Path Width="12" Height="12" Stretch="Uniform" Margin="6,0,0,0" Fill="{DynamicResource Accent2Brush}"
Data="{StaticResource Icon.ShieldAccount}" Visibility="{Binding OtpEnabled, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</StackPanel>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource BorderStrongBrush}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceAltBrush}" />
</Trigger>
<DataTrigger Binding="{Binding IsDisabled}" Value="True">
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource DangerBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsSuspended}" Value="True">
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsInactive}" Value="True">
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
<Setter TargetName="ItemTitle" Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Compteur (« 1 248 comptes », « 3 suspendus ») -->
<DataTemplate x:Key="AdminPlayerCounterTemplate">
<Border x:Name="Chip" Style="{StaticResource AdminChipStyle}">
<TextBlock FontSize="12">
<Run Text="{Binding Value, Mode=OneWay}" FontWeight="SemiBold" Foreground="{DynamicResource TextPrimaryBrush}" />
<Run Text="{Binding Label, Mode=OneWay}" Foreground="{DynamicResource TextSecondaryBrush}" />
</TextBlock>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding Warning}" Value="True">
<Setter TargetName="Chip" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<DataTemplate DataType="{x:Type apl:PlayersPanelViewModel}">
<!-- Deux colonnes qui défilent séparément (HasSplitLayout) : liste à gauche, fiche à droite. -->
<DockPanel x:Name="PlayersRoot">
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,0">
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Service désactivé / création de compte fermée -->
<Border Style="{StaticResource AdminInfoBannerStyle}" Visibility="{Binding ShowDisabledService, Converter={StaticResource BoolToVisibility}}">
<TextBlock TextWrapping="Wrap" FontSize="12.5"
Text="Les comptes joueurs ne sont pas activés sur ce serveur (LauncherApi:Players:Enabled). Voir server/deploy/LISEZMOI.md, étape 3 undecies." />
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" Visibility="{Binding ShowRegistrationClosed, Converter={StaticResource BoolToVisibility}}">
<TextBlock TextWrapping="Wrap" FontSize="12.5"
Text="La création de compte est fermée (LauncherApi:Players:RegistrationEnabled) : les joueurs existants peuvent toujours se connecter." />
</Border>
<!-- Compteurs, recherche et filtre -->
<Border Style="{StaticResource AdminCardStyle}" Padding="16,14" Visibility="{Binding IsServiceEnabled, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<ItemsControl ItemsSource="{Binding Counters}" Focusable="False" ItemTemplate="{StaticResource AdminPlayerCounterTemplate}" Margin="0,0,0,4">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<Grid IsEnabled="{Binding IsEditable}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="260" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Rechercher (référence PL-XXXX-XXXX ou adresse e-mail complète)" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding SearchMaxLength}"
ui:TextAssist.Placeholder="PL-7KQ2-M9XD ou joueur@exemple.fr"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}">
<TextBox.InputBindings>
<KeyBinding Key="Enter" Command="{Binding SearchCommand}" />
</TextBox.InputBindings>
</TextBox>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Bottom" Margin="8,0,0,0">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Rechercher" ui:IconAssist.Icon="{StaticResource Icon.Locate}"
Command="{Binding SearchCommand}"
ToolTip="L'adresse est envoyée dans le corps de la requête et n'est jamais affichée en entier ; la recherche est inscrite au journal d'audit" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Toute la liste" Margin="8,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Close}" Command="{Binding ClearSearchCommand}"
Visibility="{Binding IsSearchResult, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
<StackPanel Grid.Column="2" Margin="10,0,0,0">
<TextBlock Text="Afficher" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding Filters}"
SelectedItem="{Binding Filter, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
<TextBlock Text="{Binding EmptyText}" Style="{StaticResource AdminHintStyle}" FontSize="12.5" Margin="4,4,0,16"
Visibility="{Binding EmptyText, Converter={StaticResource StringToVisibility}}" />
</StackPanel>
<Grid Visibility="{Binding HasItems, Converter={StaticResource BoolToVisibility}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="330" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{Binding CountText}" Style="{StaticResource SectionTitleStyle}" Margin="2,0,0,8" />
<Button DockPanel.Dock="Bottom" Style="{StaticResource SmallButtonStyle}" Content="Charger plus" Margin="0,4,8,0"
HorizontalAlignment="Left" ui:IconAssist.Icon="{StaticResource Icon.History}" Command="{Binding LoadMoreCommand}"
Visibility="{Binding CanLoadMore, Converter={StaticResource BoolToVisibility}}" />
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,8,0">
<ListBox ItemsSource="{Binding Items}" SelectedItem="{Binding SelectedItem, Mode=TwoWay}"
Style="{StaticResource AdminListBoxStyle}" IsEnabled="{Binding IsEditable}"
ItemContainerStyle="{StaticResource AdminPlayerItemStyle}" />
</ScrollViewer>
</DockPanel>
<!-- Fiche du compte sélectionné : remonte en haut à chaque changement -->
<ScrollViewer Grid.Column="1" Margin="10,0,0,0" VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,14,0"
ui:ScrollAssist.ResetOn="{Binding Detail}">
<Grid>
<TextBlock Text="Choisissez un compte dans la liste." Style="{StaticResource AdminHintStyle}" FontSize="12.5" Margin="4,8,0,0"
Visibility="{Binding ShowNoSelection, Converter={StaticResource BoolToVisibility}}" />
<StackPanel Visibility="{Binding HasDetail, Converter={StaticResource BoolToVisibility}}">
<!-- Mot de passe temporaire : affiché UNE fois -->
<Border Style="{StaticResource AdminCardStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding HasTemporaryPassword, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<DockPanel>
<Border DockPanel.Dock="Left" Style="{StaticResource AdminIconBadgeStyle}" Margin="0,0,14,0">
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.FormTextboxPassword}" />
</Border>
<StackPanel>
<TextBlock Text="Mot de passe temporaire" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="{Binding TemporaryPasswordText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
</StackPanel>
</DockPanel>
<DockPanel Margin="52,12,0,0">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
<Button Style="{StaticResource SmallButtonStyle}" Content="Copier" Margin="12,0,8,0"
ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}" Command="{Binding CopyTemporaryPasswordCommand}" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Transmis, masquer"
ui:IconAssist.Icon="{StaticResource Icon.EyeOffOutline}" Command="{Binding DismissTemporaryPasswordCommand}" />
</StackPanel>
<TextBox Text="{Binding TemporaryPassword, Mode=OneWay}" IsReadOnly="True" BorderThickness="0" Background="Transparent"
Padding="0" FontSize="22" FontWeight="SemiBold" FontFamily="Cascadia Mono, Consolas, Courier New"
Foreground="{DynamicResource TextPrimaryBrush}" CaretBrush="{DynamicResource TextPrimaryBrush}"
SelectionBrush="{DynamicResource AccentBrush}" IsUndoEnabled="False" VerticalAlignment="Center" />
</DockPanel>
</StackPanel>
</Border>
<!-- Identité, état et sécurité -->
<Border Style="{StaticResource AdminCardStyle}" DataContext="{Binding Detail}">
<StackPanel>
<DockPanel>
<Border DockPanel.Dock="Left" Style="{StaticResource AdminIconBadgeStyle}" Margin="0,0,14,0">
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.AccountCircle}" />
</Border>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButtonStyle}" Width="32" Height="32" Padding="8" VerticalAlignment="Top"
ToolTip="Copier la référence" ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}"
Command="{Binding CopyReferenceCommand}" />
<StackPanel>
<TextBlock Text="{Binding EmailMasked}" FontSize="15" FontWeight="SemiBold" TextWrapping="Wrap"
ToolTip="Adresse masquée : elle n'est jamais affichée en entier dans le menu" />
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<TextBlock Text="{Binding Reference}" FontSize="12" FontFamily="Cascadia Mono, Consolas, Courier New"
Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock Text=" · " FontSize="12" Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock Text="{Binding StatusLabel}" FontSize="12" FontWeight="SemiBold">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource SuccessBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsDisabled}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource DangerBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsSuspended}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsInactive}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</StackPanel>
</DockPanel>
<TextBlock Text="{Binding StatusText}" FontSize="12.5" TextWrapping="Wrap" Margin="52,12,0,0"
Foreground="{DynamicResource TextSecondaryBrush}" />
<Border Style="{StaticResource AdminInfoBannerStyle}" Margin="52,10,0,0" Padding="12,8"
Visibility="{Binding HasReason, Converter={StaticResource BoolToVisibility}}">
<TextBlock TextWrapping="Wrap" FontSize="12.5">
<Run Text="Motif communiqué au joueur :" Foreground="{DynamicResource TextSecondaryBrush}" />
<Run Text="{Binding StatusReason, Mode=OneWay}" FontWeight="SemiBold" />
</TextBlock>
</Border>
<ItemsControl ItemsSource="{Binding Facts}" Focusable="False" Margin="52,14,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,0,0,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="170" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Label}" Style="{StaticResource AdminLabelStyle}" Margin="0" />
<TextBlock x:Name="FactValue" Grid.Column="1" Text="{Binding Value}" Style="{StaticResource AdminValueStyle}" Margin="0" />
</Grid>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding Warning}" Value="True">
<Setter TargetName="FactValue" Property="Foreground" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Actions (players.manage) : requêtes signées, confirmées -->
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding CanManage, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<TextBlock Text="Actions" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
Text="Signées par votre clé et inscrites au journal d'audit. Le joueur les voit dans son activité comme venant de « l'équipe »." />
<WrapPanel IsEnabled="{Binding IsEditable}">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="{Binding Detail.DisableText}" Margin="0,0,8,8"
ui:IconAssist.Icon="{StaticResource Icon.AccountCancel}" Command="{Binding OpenDisableCommand}"
ToolTip="Refuse toute connexion jusqu'à une date ou indéfiniment ; motif facultatif montré au joueur" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Réactiver" Margin="0,0,8,8"
ui:IconAssist.Icon="{StaticResource Icon.AccountCheck}" Command="{Binding EnableCommand}"
Visibility="{Binding Detail.CanEnable, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="{Binding Detail.UnlockText}" Margin="0,0,8,8"
ui:IconAssist.Icon="{StaticResource Icon.AccountLockOpen}" Command="{Binding UnlockCommand}"
ToolTip="Le compte redevient utilisable depuis tout appareil ; compteurs d'échecs remis à zéro"
Visibility="{Binding Detail.CanUnlock, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Mot de passe temporaire" Margin="0,0,8,8"
ui:IconAssist.Icon="{StaticResource Icon.FormTextboxPassword}" Command="{Binding TemporaryPasswordCommand}"
ToolTip="Valable une heure, une seule fois, pour choisir un nouveau mot de passe (mot de passe oublié)"
Visibility="{Binding Detail.CanCreateTemporaryPassword, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Exiger un nouveau mot de passe" Margin="0,0,8,8"
ui:IconAssist.Icon="{StaticResource Icon.LockReset}" Command="{Binding RequirePasswordCommand}"
ToolTip="Mot de passe peut-être connu d'un tiers : à la prochaine connexion, le joueur devra en choisir un autre"
Visibility="{Binding Detail.CanRequirePassword, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Ne plus exiger de nouveau mot de passe" Margin="0,0,8,8"
ui:IconAssist.Icon="{StaticResource Icon.LockOpenVariant}" Command="{Binding CancelPasswordRequirementCommand}"
Visibility="{Binding Detail.CanCancelPasswordRequirement, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Réinitialiser la double authentification" Margin="0,0,8,8"
ui:IconAssist.Icon="{StaticResource Icon.ShieldOffOutline}" Command="{Binding ResetOtpCommand}"
ToolTip="Téléphone ET codes de secours perdus, identité vérifiée"
Visibility="{Binding Detail.CanResetOtp, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Déconnecter tous les appareils" Margin="0,0,8,8"
ui:IconAssist.Icon="{StaticResource Icon.Logout}" Command="{Binding LogoutAllCommand}"
Visibility="{Binding Detail.CanLogoutAll, Converter={StaticResource BoolToVisibility}}" />
</WrapPanel>
<!-- Désactivation : durée et motif -->
<Border CornerRadius="12" Padding="14,12" Margin="0,6,0,0" BorderThickness="1"
Background="{DynamicResource SurfaceAltBrush}" BorderBrush="{DynamicResource BorderStrongBrush}"
Visibility="{Binding IsDisableFormOpen, Converter={StaticResource BoolToVisibility}}">
<StackPanel IsEnabled="{Binding IsEditable}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="230" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Durée" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding DisableDurations}"
SelectedItem="{Binding DisableDuration, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
<StackPanel Grid.Column="1" Margin="12,0,0,0">
<TextBlock Text="Motif montré au joueur (facultatif, une ligne, sans lien)" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding ReasonMaxLength}"
ui:TextAssist.Placeholder="Ex. : triche en partie en ligne (avertissement du 03/11)"
Text="{Binding DisableReason, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</Grid>
<TextBlock Text="{Binding ReasonProblem}" FontSize="12" Margin="0,6,0,0" TextWrapping="Wrap" Foreground="{DynamicResource DangerBrush}"
Visibility="{Binding HasReasonProblem, Converter={StaticResource BoolToVisibility}}" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Annuler" Margin="0,0,8,0" Command="{Binding CancelDisableCommand}" />
<Button Style="{StaticResource DangerButtonStyle}" Content="Désactiver le compte"
ui:IconAssist.Icon="{StaticResource Icon.AccountCancel}" Command="{Binding DisableCommand}" />
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- Appareils connectés -->
<Border Style="{StaticResource AdminCardStyle}" DataContext="{Binding Detail}">
<StackPanel>
<TextBlock Text="{Binding DevicesTitle}" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="Aucun appareil connecté (le joueur devra se connecter avec son mot de passe)." Style="{StaticResource AdminHintStyle}"
Margin="0,6,0,0" Visibility="{Binding HasDevices, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<ItemsControl ItemsSource="{Binding Devices}" Focusable="False" Margin="0,10,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border CornerRadius="12" Padding="12,9" Margin="0,0,0,6" BorderThickness="1"
Background="{DynamicResource SurfaceAltBrush}" BorderBrush="{DynamicResource BorderBrush}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="18" Height="18" Stretch="Uniform" Margin="0,0,12,0" VerticalAlignment="Center"
Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.Laptop}" />
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Déconnecter" Margin="12,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Logout}" Command="{Binding LogoutCommand}"
Visibility="{Binding CanManage, Converter={StaticResource BoolToVisibility}}" />
<StackPanel>
<TextBlock Text="{Binding Label}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding DetailText}" FontSize="11.5" Margin="0,2,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Activité (adresses IP complètes : données personnelles) -->
<Border Style="{StaticResource AdminCardStyle}" DataContext="{Binding Detail}">
<StackPanel>
<TextBlock Text="{Binding EventsTitle}" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,10"
Text="Adresses IP complètes (le joueur ne voit que leur début) : à n'utiliser que pour la sécurité du compte." />
<ItemsControl ItemsSource="{Binding Events}" Focusable="False">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,0,0,8" ToolTip="{Binding Type}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="135" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Ellipse x:Name="Dot" Width="8" Height="8" Margin="0,5,10,0" VerticalAlignment="Top" Fill="{DynamicResource SuccessBrush}" />
<TextBlock Grid.Column="1" Text="{Binding TimeText}" FontSize="12" Foreground="{DynamicResource TextMutedBrush}" />
<StackPanel Grid.Column="2">
<TextBlock Text="{Binding Label}" FontSize="12.5" TextWrapping="Wrap" />
<TextBlock Text="{Binding OriginText}" FontSize="11.5" Margin="0,1,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}"
Visibility="{Binding HasOrigin, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding Details}" FontSize="11.5" Margin="0,1,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}"
Visibility="{Binding HasDetails, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</Grid>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsFailure}" Value="True">
<Setter TargetName="Dot" Property="Fill" Value="{DynamicResource DangerBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</ScrollViewer>
</Grid>
</DockPanel>
</DataTemplate>
<!-- ============================================================ Page « Formulaires des demandes » (feedback.forms) -->
<!-- Type de demande (liste de gauche) -->
@@ -3426,6 +3854,26 @@
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.PlayersPanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
<DockPanel Margin="0,0,0,6">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Recharger" Margin="0,0,10,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding ReloadCommand}"
ToolTip="Relire les comptes et la fiche affichée sur le serveur" />
<Button Style="{StaticResource DangerButtonStyle}" Content="Supprimer le compte"
ui:IconAssist.Icon="{StaticResource Icon.AccountRemove}" Command="{Binding DeleteCommand}"
ToolTip="Supprime DÉFINITIVEMENT le compte affiché et ses données (requête signée par votre clé)"
Visibility="{Binding CanDelete, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
<TextBlock Text="{Binding RetentionText}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" TextWrapping="Wrap" Margin="0,0,16,0" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="{Binding IsBusy}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.PublishPanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
+8
View File
@@ -105,4 +105,12 @@
<Geometry x:Key="Icon.Bug">M14,12H10V10H14M14,16H10V14H14M20,8H17.19C16.74,7.22 16.12,6.55 15.37,6.04L17,4.41L15.59,3L13.42,5.17C12.96,5.06 12.5,5 12,5C11.5,5 11.04,5.06 10.59,5.17L8.41,3L7,4.41L8.62,6.04C7.88,6.55 7.26,7.22 6.81,8H4V10H6.09C6.04,10.33 6,10.66 6,11V12H4V14H6V15C6,15.34 6.04,15.67 6.09,16H4V18H6.81C7.85,19.79 9.78,21 12,21C14.22,21 16.15,19.79 17.19,18H20V16H17.91C17.96,15.67 18,15.34 18,15V14H20V12H18V11C18,10.66 17.96,10.33 17.91,10H20V8Z</Geometry>
<Geometry x:Key="Icon.MessageQuestion">M4,2H20A2,2 0 0,1 22,4V16A2,2 0 0,1 20,18H16L12,22L8,18H4A2,2 0 0,1 2,16V4A2,2 0 0,1 4,2M12.19,5.5C11.3,5.5 10.59,5.68 10.05,6.04C9.5,6.4 9.22,7 9.27,7.69H11.24C11.24,7.41 11.34,7.2 11.5,7.06C11.7,6.92 11.92,6.85 12.19,6.85C12.5,6.85 12.77,6.93 12.95,7.11C13.13,7.28 13.22,7.5 13.22,7.8C13.22,8.08 13.14,8.33 13,8.54C12.83,8.76 12.62,8.94 12.36,9.08C11.84,9.4 11.5,9.68 11.29,9.92C11.1,10.16 11,10.5 11,11H13C13,10.72 13.05,10.5 13.14,10.32C13.23,10.15 13.4,10 13.66,9.85C14.12,9.64 14.5,9.36 14.79,9C15.08,8.63 15.23,8.24 15.23,7.8C15.23,7.1 14.96,6.54 14.42,6.12C13.88,5.71 13.13,5.5 12.19,5.5M11,12V14H13V12H11Z</Geometry>
<!-- Comptes joueurs (menu d'administration, étape 8d) -->
<Geometry x:Key="Icon.AccountMultiple">M16 17V19H2V17S2 13 9 13 16 17 16 17M12.5 7.5A3.5 3.5 0 1 0 9 11A3.5 3.5 0 0 0 12.5 7.5M15.94 13A5.32 5.32 0 0 1 18 17V19H22V17S22 13.37 15.94 13M15 4A3.39 3.39 0 0 0 13.07 4.59A5 5 0 0 1 13.07 10.41A3.39 3.39 0 0 0 15 11A3.5 3.5 0 0 0 15 4Z</Geometry>
<Geometry x:Key="Icon.AccountCancel">M10 4A4 4 0 0 0 6 8A4 4 0 0 0 10 12A4 4 0 0 0 14 8A4 4 0 0 0 10 4M17.5 13C15 13 13 15 13 17.5C13 20 15 22 17.5 22C20 22 22 20 22 17.5C22 15 20 13 17.5 13M10 14C5.58 14 2 15.79 2 18V20H11.5A6.5 6.5 0 0 1 11 17.5A6.5 6.5 0 0 1 11.95 14.14C11.32 14.06 10.68 14 10 14M17.5 14.5C19.16 14.5 20.5 15.84 20.5 17.5C20.5 18.06 20.35 18.58 20.08 19L16 14.92C16.42 14.65 16.94 14.5 17.5 14.5M14.92 16L19 20.08C18.58 20.35 18.06 20.5 17.5 20.5C15.84 20.5 14.5 19.16 14.5 17.5C14.5 16.94 14.65 16.42 14.92 16Z</Geometry>
<Geometry x:Key="Icon.AccountCheck">M21.1,12.5L22.5,13.91L15.97,20.5L12.5,17L13.9,15.59L15.97,17.67L21.1,12.5M10,17L13,20H3V18C3,15.79 6.58,14 11,14L12.89,14.11L10,17M11,4A4,4 0 0,1 15,8A4,4 0 0,1 11,12A4,4 0 0,1 7,8A4,4 0 0,1 11,4Z</Geometry>
<Geometry x:Key="Icon.LockReset">M12.63,2C18.16,2 22.64,6.5 22.64,12C22.64,17.5 18.16,22 12.63,22C9.12,22 6.05,20.18 4.26,17.43L5.84,16.18C7.25,18.47 9.76,20 12.64,20A8,8 0 0,0 20.64,12A8,8 0 0,0 12.64,4C8.56,4 5.2,7.06 4.71,11H7.47L3.73,14.73L0,11H2.69C3.19,5.95 7.45,2 12.63,2M15.59,10.24C16.09,10.25 16.5,10.65 16.5,11.16V15.77C16.5,16.27 16.09,16.69 15.58,16.69H10.05C9.54,16.69 9.13,16.27 9.13,15.77V11.16C9.13,10.65 9.54,10.25 10.04,10.24V9.23C10.04,7.7 11.29,6.46 12.81,6.46C14.34,6.46 15.59,7.7 15.59,9.23V10.24M12.81,7.86C12.06,7.86 11.44,8.47 11.44,9.23V10.24H14.19V9.23C14.19,8.47 13.57,7.86 12.81,7.86Z</Geometry>
<Geometry x:Key="Icon.FormTextboxPassword">M17,7H22V17H17V19A1,1 0 0,0 18,20H20V22H17.5C16.95,22 16,21.55 16,21C16,21.55 15.05,22 14.5,22H12V20H14A1,1 0 0,0 15,19V5A1,1 0 0,0 14,4H12V2H14.5C15.05,2 16,2.45 16,3C16,2.45 16.95,2 17.5,2H20V4H18A1,1 0 0,0 17,5V7M2,7H13V9H4V15H13V17H2V7M20,15V9H17V15H20M8.5,12A1.5,1.5 0 0,0 7,10.5A1.5,1.5 0 0,0 5.5,12A1.5,1.5 0 0,0 7,13.5A1.5,1.5 0 0,0 8.5,12M13,10.89C12.39,10.33 11.44,10.38 10.88,11C10.32,11.6 10.37,12.55 11,13.11C11.55,13.63 12.43,13.63 13,13.11V10.89Z</Geometry>
<Geometry x:Key="Icon.ShieldOffOutline">M1,4.27L3,6.27V11C3,16.55 6.84,21.74 12,23C13.87,22.54 15.57,21.56 16.97,20.24L19.23,22.5L20.5,21.22L2.28,3L1,4.27M12,21C8.25,20 5,15.54 5,11.22V8.27L15.59,18.86C14.53,19.89 13.3,20.65 12,21M21,5V11C21,13.28 20.35,15.5 19.23,17.4L17.77,15.95C18.54,14.5 19,12.84 19,11.22V6.3L12,3.18L7.16,5.34L5.65,3.82L12,1L21,5Z</Geometry>
<Geometry x:Key="Icon.AccountLockOpen">M6 8C6 5.79 7.79 4 10 4S14 5.79 14 8 12.21 12 10 12 6 10.21 6 8M12 18.2C12 17.24 12.5 16.34 13.2 15.74V14.5C13.2 14.45 13.22 14.39 13.22 14.34C12.23 14.12 11.15 14 10 14C5.58 14 2 15.79 2 18V20H12V18.2M22 18.3V21.8C22 22.4 21.4 23 20.7 23H15.2C14.6 23 14 22.4 14 21.7V18.2C14 17.6 14.6 17 15.2 17V14.5C15.2 13.1 16.6 12 18 12S20.8 13.1 20.8 14.5V15H19.5V14.5C19.5 13.7 18.8 13.2 18 13.2S16.5 13.7 16.5 14.5V17H20.8C21.4 17 22 17.6 22 18.3Z</Geometry>
</ResourceDictionary>
@@ -11,6 +11,15 @@ using GameLauncher.Services;
namespace GameLauncher.ViewModels;
/// <summary>État du compte joueur affiché sur le bouton « Compte » de la barre de titre.</summary>
public enum AccountButtonState
{
SignedOut,
Restoring,
SignedIn,
Offline
}
/// <summary>Effet visuel appliqué lors du changement de thème (implémenté par la fenêtre principale).</summary>
public interface IThemeTransition
{
@@ -35,6 +44,8 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
private IThemeTransition? _transition;
private GameViewModel? _selectedGame;
private MaintenanceDocument? _maintenance;
private AccountButtonState _accountState;
private string? _accountEmail;
public LauncherViewModel(LauncherCatalog catalog, IWindowService window, IDialogService dialogs,
LauncherPreferences preferences, string launcherVersion,
@@ -74,6 +85,39 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
/// <summary>Bouton « Aide » affiché : disponible, et pas de maintenance en cours.</summary>
public bool ShowHelpButton => IsHelpAvailable && !IsMaintenance;
// ------------------------------------------------------------------ Compte joueur (bouton « Compte »)
/// <summary>Comptes joueurs disponibles (« playerAccounts » et « apiUrl » dans le launcher.json intégré).</summary>
public bool IsAccountAvailable { get; set; }
/// <summary>Bouton « Compte » affiché : disponible, et pas de maintenance en cours.</summary>
public bool ShowAccountButton => IsAccountAvailable && !IsMaintenance;
public AccountButtonState AccountState => _accountState;
/// <summary>Pastille sur le bouton : connecté (verte) ou session mémorisée hors ligne / en reprise (orange).</summary>
public bool IsAccountSignedIn => _accountState == AccountButtonState.SignedIn;
public bool IsAccountPending => _accountState is AccountButtonState.Restoring or AccountButtonState.Offline;
public string AccountToolTip => _accountState switch
{
AccountButtonState.SignedIn => $"Mon compte · {_accountEmail}",
AccountButtonState.Restoring => "Compte : reconnexion…",
AccountButtonState.Offline => $"Mon compte · {_accountEmail} (serveur injoignable)",
_ => "Compte : se connecter ou créer un compte"
};
/// <summary>Nouvel état du compte (appelé sur le fil de l'interface).</summary>
public void ApplyAccountState(AccountButtonState state, string? emailMasked)
{
_accountState = state;
_accountEmail = string.IsNullOrEmpty(emailMasked) ? "connecté" : emailMasked;
OnPropertyChanged(nameof(AccountState));
OnPropertyChanged(nameof(IsAccountSignedIn));
OnPropertyChanged(nameof(IsAccountPending));
OnPropertyChanged(nameof(AccountToolTip));
}
// ------------------------------------------------------------------ Mode maintenance
/// <summary>
@@ -109,6 +153,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
if (wasActive == active) return;
OnPropertyChanged(nameof(IsMaintenance));
OnPropertyChanged(nameof(ShowHelpButton));
OnPropertyChanged(nameof(ShowAccountButton));
OpenLauncherSettingsCommand.RaiseCanExecuteChanged();
}
+673
View File
@@ -0,0 +1,673 @@
<Window x:Class="GameLauncher.Views.AccountWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:GameLauncher.Controls"
xmlns:conv="clr-namespace:GameLauncher.Converters"
Title="Compte"
Width="600"
SizeToContent="Height"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
ResizeMode="NoResize"
ShowInTaskbar="False"
WindowStartupLocation="CenterOwner"
FontFamily="{DynamicResource UiFont}"
Foreground="{DynamicResource TextPrimaryBrush}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal"
ui:WheelAssist.IsEnabled="True"
PreviewKeyDown="OnPreviewKeyDown">
<!--
Bouton « Compte » de la fenêtre principale. ViewModel : AccountWindowViewModel (GameLauncher.Modules.Accounts).
Écrans : connexion (puis code de vérification si la double authentification est activée), création de compte, nouveau
mot de passe exigé, « Mon compte » (onglets Profil avec la double authentification, Appareils, Activité, Mes données). Les mots de passe restent dans les PasswordBox (jamais liés au ViewModel) : lus au moment de l'action,
effacés ensuite (AccountWindow.xaml.cs). Tout texte venu du serveur est affiché tel quel (jamais interprété).
-->
<Window.Resources>
<!-- QR code de la double authentification : tracé calculé sur ce PC (QrCode.ToPathData) -->
<conv:PathDataConverter x:Key="PathData" />
<!-- Contrôle en direct du nouveau mot de passe (PasswordRulesViewModel) -->
<DataTemplate x:Key="PasswordRulesTemplate">
<StackPanel Margin="2,8,0,0">
<TextBlock Text="{Binding RulesText}" FontSize="11.5" TextWrapping="Wrap" Foreground="{DynamicResource TextMutedBrush}"
Visibility="{Binding ShowRules, Converter={StaticResource BoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding Problems}" Focusable="False"
Visibility="{Binding HasProblems, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,0,0,4">
<Path DockPanel.Dock="Left" Width="12" Height="12" Stretch="Uniform" VerticalAlignment="Top" Margin="0,2,8,0"
Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding}" FontSize="12" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}" />
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<DockPanel Visibility="{Binding IsAccepted, Converter={StaticResource BoolToVisibility}}">
<Path DockPanel.Dock="Left" Width="13" Height="13" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,8,0"
Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}" />
<TextBlock DockPanel.Dock="Right" Text="{Binding StrengthText}" FontSize="12" FontWeight="SemiBold" VerticalAlignment="Center"
Margin="10,0,0,0" Foreground="{DynamicResource SuccessBrush}" />
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="4" Maximum="4" Value="{Binding Strength, Mode=OneWay}"
VerticalAlignment="Center" />
</DockPanel>
<TextBlock Text="Les deux mots de passe ne sont pas identiques." FontSize="12" Margin="0,6,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource DangerBrush}"
Visibility="{Binding ShowConfirmationMismatch, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<!-- Libellé d'un champ -->
<Style x:Key="AccountLabelStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="12.5" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="0,0,0,6" />
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
</Style>
<!-- Carte d'une section de « Mon compte » -->
<Style x:Key="AccountCardStyle" TargetType="Border">
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Padding" Value="16,14" />
<Setter Property="Margin" Value="0,0,0,12" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Background" Value="{DynamicResource InputBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}" />
</Style>
</Window.Resources>
<Grid Margin="20">
<!-- Ombre -->
<Border CornerRadius="20" Background="{DynamicResource BackgroundBrush}">
<Border.Effect>
<DropShadowEffect BlurRadius="32" ShadowDepth="8" Direction="270" Opacity="0.6" Color="Black" />
</Border.Effect>
</Border>
<Border Style="{StaticResource DialogCardStyle}" Padding="28,26,14,22" MouseLeftButtonDown="OnDragMove">
<StackPanel>
<!-- ================================================================ En-tête -->
<Grid Margin="0,0,14,18">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border Width="48" Height="48" CornerRadius="14" Background="{DynamicResource AccentGradientBrush}">
<Grid>
<Path Data="{Binding HeaderIcon, Converter={StaticResource IconKey}}" Fill="{DynamicResource OnAccentBrush}"
Width="24" Height="24" Stretch="Uniform"
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<!-- Mon compte : initiale de l'adresse -->
<TextBlock Text="{Binding AvatarText}" FontSize="20" FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="{DynamicResource OnAccentBrush}"
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}" />
</Grid>
</Border>
<StackPanel Grid.Column="1" Margin="16,0,0,0" VerticalAlignment="Center">
<TextBlock Text="{Binding Title}" FontSize="19" FontWeight="SemiBold" />
<TextBlock Text="{Binding Subtitle}" FontSize="12.5" Margin="0,3,0,0" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
<Button Grid.Column="2" Style="{StaticResource IconButtonStyle}" VerticalAlignment="Top"
Width="32" Height="32" Padding="9" Margin="0,-6,-10,0" ToolTip="Fermer"
ui:IconAssist.Icon="{StaticResource Icon.Close}" Command="{Binding CloseCommand}" />
</Grid>
<!-- Onglets de « Mon compte » -->
<Border HorizontalAlignment="Left" CornerRadius="12" Padding="3" Margin="0,0,14,16" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}"
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal" IsEnabled="{Binding IsIdle}">
<RadioButton Style="{StaticResource AdminSegmentStyle}" GroupName="AccountTabs" Content="Profil"
ui:IconAssist.Icon="{StaticResource Icon.BadgeAccountHorizontal}" IsChecked="{Binding IsProfileSection}" />
<RadioButton Style="{StaticResource AdminSegmentStyle}" GroupName="AccountTabs" Content="Appareils"
ui:IconAssist.Icon="{StaticResource Icon.Laptop}" IsChecked="{Binding IsDevicesSection}" />
<RadioButton Style="{StaticResource AdminSegmentStyle}" GroupName="AccountTabs" Content="Activité"
ui:IconAssist.Icon="{StaticResource Icon.History}" IsChecked="{Binding IsActivitySection}" />
<RadioButton Style="{StaticResource AdminSegmentStyle}" GroupName="AccountTabs" Content="Mes données"
ui:IconAssist.Icon="{StaticResource Icon.FileLockOutline}" IsChecked="{Binding IsDataSection}" />
</StackPanel>
</Border>
<ScrollViewer x:Name="BodyScroll" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
Focusable="False" Padding="0,0,14,0">
<StackPanel>
<!-- Messages -->
<Border CornerRadius="12" Padding="14,10" Margin="0,0,0,14" Background="#26E5484D"
BorderBrush="{DynamicResource DangerBrush}" BorderThickness="1"
Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Top"
Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<Border CornerRadius="12" Padding="14,10" Margin="0,0,0,14" Background="#262BD48B"
BorderBrush="{DynamicResource SuccessBrush}" BorderThickness="1"
Visibility="{Binding HasSuccess, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Top"
Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}" />
<TextBlock Text="{Binding SuccessMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- ============================================ Chargement -->
<StackPanel Orientation="Horizontal" Margin="2,10,0,20"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibility}}">
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Width="90" Height="4" IsIndeterminate="True"
VerticalAlignment="Center" />
<TextBlock Text="{Binding BusyText}" FontSize="12.5" Margin="12,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
<!-- ============================================ Service indisponible -->
<StackPanel Margin="0,6,0,10" Visibility="{Binding IsUnavailable, Converter={StaticResource BoolToVisibility}}">
<Path Width="40" Height="40" Stretch="Uniform" HorizontalAlignment="Center" Fill="{DynamicResource TextMutedBrush}"
Data="{StaticResource Icon.Server}" />
<TextBlock Text="{Binding UnavailableText}" FontSize="13.5" Margin="0,14,0,16" HorizontalAlignment="Center"
TextAlignment="Center" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Réessayer" HorizontalAlignment="Center"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RetryCommand}" />
</StackPanel>
<!-- ============================================ Connexion -->
<StackPanel Visibility="{Binding IsSignIn, Converter={StaticResource BoolToVisibility}}" IsEnabled="{Binding IsIdle}">
<TextBlock Text="Adresse e-mail" Style="{StaticResource AccountLabelStyle}" />
<TextBox x:Name="SignInEmailBox" Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding EmailMaxLength}"
ui:TextAssist.Placeholder="vous@exemple.fr" Margin="0,0,0,14"
Text="{Binding Email, UpdateSourceTrigger=PropertyChanged}" KeyDown="OnSignInKeyDown" />
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource LinkButtonStyle}" Content="Mot de passe oublié ?" FontSize="12"
Command="{Binding ToggleForgotCommand}" />
<TextBlock Text="Mot de passe" Style="{StaticResource AccountLabelStyle}" Margin="0" VerticalAlignment="Center" />
</DockPanel>
<PasswordBox x:Name="SignInPasswordBox" Style="{StaticResource ModernPasswordBoxStyle}" MaxLength="{Binding PasswordMaxLength}"
KeyDown="OnSignInKeyDown" />
<Border CornerRadius="12" Padding="14,10" Margin="0,10,0,0" BorderThickness="1"
Background="{DynamicResource ChipBrush}" BorderBrush="{DynamicResource BorderStrongBrush}"
Visibility="{Binding ShowForgotHelp, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" VerticalAlignment="Top" Margin="0,1,10,0"
Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.Info}" />
<TextBlock Text="{Binding ForgotHelpText}" FontSize="12" TextWrapping="Wrap" />
</DockPanel>
</Border>
<CheckBox Style="{StaticResource ToggleSwitchStyle}" Margin="0,14,0,0" IsChecked="{Binding RememberMe}">
<StackPanel>
<TextBlock Text="{Binding RememberText}" FontSize="13" FontWeight="SemiBold" />
<TextBlock Text="Inutile de saisir votre mot de passe au prochain lancement. Décochez sur un PC partagé."
FontSize="11.5" Margin="0,2,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</CheckBox>
<Border Height="1" Background="{DynamicResource BorderBrush}" Margin="0,18,0,14" />
<DockPanel Visibility="{Binding RegistrationOpen, Converter={StaticResource BoolToVisibility}}">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Créer un compte"
ui:IconAssist.Icon="{StaticResource Icon.Plus}" Command="{Binding ShowRegisterCommand}" />
<TextBlock Text="Pas encore de compte ? C'est gratuit, et jamais obligatoire pour jouer." FontSize="12.5"
VerticalAlignment="Center" TextWrapping="Wrap" Margin="0,0,12,0"
Foreground="{DynamicResource TextSecondaryBrush}" />
</DockPanel>
<TextBlock Text="La création de compte est momentanément fermée." FontSize="12.5" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}"
Visibility="{Binding RegistrationClosed, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
<!-- ============================================ Création de compte -->
<StackPanel Visibility="{Binding IsRegister, Converter={StaticResource BoolToVisibility}}" IsEnabled="{Binding IsIdle}">
<TextBlock Text="Adresse e-mail" Style="{StaticResource AccountLabelStyle}" />
<TextBox x:Name="RegisterEmailBox" Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding EmailMaxLength}"
ui:TextAssist.Placeholder="vous@exemple.fr" Margin="0,0,0,14"
Text="{Binding Email, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="Mot de passe" Style="{StaticResource AccountLabelStyle}" />
<PasswordBox x:Name="RegisterPasswordBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
<TextBlock Text="Confirmation du mot de passe" Style="{StaticResource AccountLabelStyle}" Margin="0,14,0,6" />
<PasswordBox x:Name="RegisterConfirmBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
<ContentControl Content="{Binding NewPassword}" ContentTemplate="{StaticResource PasswordRulesTemplate}" Focusable="False" />
<CheckBox Style="{StaticResource ToggleSwitchStyle}" Margin="0,16,0,0" IsChecked="{Binding RememberMe}">
<TextBlock Text="{Binding RememberText}" FontSize="13" FontWeight="SemiBold" />
</CheckBox>
<CheckBox Style="{StaticResource FeedbackCheckBoxStyle}" Margin="0,12,0,0" IsChecked="{Binding AcceptTerms}">
<TextBlock Text="{Binding TermsText}" FontSize="12.5" TextWrapping="Wrap" />
</CheckBox>
<DockPanel Margin="0,14,0,0">
<Path DockPanel.Dock="Left" Width="14" Height="14" Stretch="Uniform" VerticalAlignment="Top" Margin="0,1,0,0"
Fill="{DynamicResource TextMutedBrush}" Data="{StaticResource Icon.ShieldCheck}" />
<TextBlock Text="{Binding PrivacyText}" FontSize="11.5" Margin="8,0,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
</DockPanel>
</StackPanel>
<!-- ============================================ Nouveau mot de passe exigé -->
<!-- ============================================ Connexion, 2ᵉ étape : code de vérification -->
<StackPanel Visibility="{Binding IsOtp, Converter={StaticResource BoolToVisibility}}" IsEnabled="{Binding IsIdle}">
<DockPanel Margin="0,0,0,16">
<Path DockPanel.Dock="Left" Width="18" Height="18" Stretch="Uniform" VerticalAlignment="Top" Margin="0,1,12,0"
Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.ShieldCheck}" />
<TextBlock Text="{Binding OtpHelpText}" FontSize="13" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}" />
</DockPanel>
<TextBlock Text="Code" Style="{StaticResource AccountLabelStyle}" />
<TextBox x:Name="OtpCodeBox" Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding OtpCodeMaxLength}"
FontSize="18" FontFamily="Cascadia Mono, Consolas, Courier New" HorizontalAlignment="Left" Width="220"
ui:TextAssist.Placeholder="123 456" InputMethod.IsInputMethodEnabled="False"
Text="{Binding OtpCode, UpdateSourceTrigger=PropertyChanged}" KeyDown="OnOtpKeyDown" />
<TextBlock Text="Le code change toutes les 30 secondes. S'il est refusé, vérifiez que l'heure de votre téléphone est réglée automatiquement."
FontSize="11.5" Margin="0,10,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
<StackPanel Visibility="{Binding IsPasswordChange, Converter={StaticResource BoolToVisibility}}" IsEnabled="{Binding IsIdle}">
<TextBlock Text="{Binding PasswordChangeText}" FontSize="13" Margin="0,0,0,16" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}" />
<TextBlock Text="Nouveau mot de passe" Style="{StaticResource AccountLabelStyle}" />
<PasswordBox x:Name="RequiredNewBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
<TextBlock Text="Confirmation" Style="{StaticResource AccountLabelStyle}" Margin="0,14,0,6" />
<PasswordBox x:Name="RequiredConfirmBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
<ContentControl Content="{Binding NewPassword}" ContentTemplate="{StaticResource PasswordRulesTemplate}" Focusable="False" />
</StackPanel>
<!-- ============================================ Mon compte -->
<StackPanel Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}">
<!-- ··· Profil -->
<StackPanel Visibility="{Binding IsProfileSection, Converter={StaticResource BoolToVisibility}}">
<Border CornerRadius="12" Padding="14,10" Margin="0,0,0,12" BorderThickness="1" Background="#26FFB547"
BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding NewDevicesSuspended, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Top" Margin="0,1,10,0"
Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.ShieldAccount}" />
<TextBlock Text="{Binding SuspendedText}" FontSize="12.5" TextWrapping="Wrap" />
</DockPanel>
</Border>
<Border Style="{StaticResource AccountCardStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="Adresse e-mail" Style="{StaticResource AdminLabelStyle}" />
<TextBlock Grid.Column="1" Text="{Binding EmailMasked}" Style="{StaticResource AdminValueStyle}" />
<TextBlock Grid.Row="1" Text="Référence" Style="{StaticResource AdminLabelStyle}" VerticalAlignment="Center" />
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="{Binding Reference}" FontSize="12.5" FontWeight="SemiBold" VerticalAlignment="Center"
FontFamily="Cascadia Mono, Consolas, Courier New" />
<Button Style="{StaticResource IconButtonStyle}" Width="28" Height="28" Padding="7" Margin="8,0,0,0"
ToolTip="Copier la référence (à communiquer au support)"
ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}" Command="{Binding CopyReferenceCommand}" />
</StackPanel>
<TextBlock Grid.Row="2" Text="Compte créé le" Style="{StaticResource AdminLabelStyle}" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding CreatedText}" Style="{StaticResource AdminValueStyle}" />
<TextBlock Grid.Row="3" Text="Dernière connexion" Style="{StaticResource AdminLabelStyle}" Margin="0,0,16,0" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding LastLoginText}" Style="{StaticResource AdminValueStyle}" Margin="0" />
</Grid>
</Border>
<!-- Mot de passe -->
<Border Style="{StaticResource AccountCardStyle}">
<StackPanel>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Changer"
ui:IconAssist.Icon="{StaticResource Icon.KeyVariant}" Command="{Binding ShowChangePasswordCommand}"
Visibility="{Binding IsChangingPassword, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Mot de passe" FontSize="13.5" FontWeight="SemiBold" />
<TextBlock FontSize="12" Margin="0,2,0,0" Foreground="{DynamicResource TextMutedBrush}"
Text="{Binding PasswordChangedText, StringFormat='Modifié le {0}'}" />
</StackPanel>
</DockPanel>
<StackPanel Margin="0,14,0,0" IsEnabled="{Binding IsIdle}"
Visibility="{Binding IsChangingPassword, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Mot de passe actuel" Style="{StaticResource AccountLabelStyle}" />
<PasswordBox x:Name="CurrentPasswordBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" />
<TextBlock Text="Nouveau mot de passe" Style="{StaticResource AccountLabelStyle}" Margin="0,14,0,6" />
<PasswordBox x:Name="ChangeNewBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
<TextBlock Text="Confirmation" Style="{StaticResource AccountLabelStyle}" Margin="0,14,0,6" />
<PasswordBox x:Name="ChangeConfirmBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
<ContentControl Content="{Binding NewPassword}" ContentTemplate="{StaticResource PasswordRulesTemplate}"
Focusable="False" />
<TextBlock Text="Vos autres appareils seront déconnectés." FontSize="11.5" Margin="0,10,0,0"
Foreground="{DynamicResource TextMutedBrush}" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Annuler" Margin="0,0,10,0"
Command="{Binding CancelChangePasswordCommand}" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Enregistrer"
ui:IconAssist.Icon="{StaticResource Icon.Check}" Click="OnChangePasswordClick" />
</StackPanel>
</StackPanel>
</StackPanel>
</Border>
<!-- Double authentification (TOTP + codes de secours) -->
<Border Style="{StaticResource AccountCardStyle}">
<StackPanel>
<DockPanel>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center"
Visibility="{Binding IsTwoFactorIdle, Converter={StaticResource BoolToVisibility}}">
<Button Style="{StaticResource SmallButtonStyle}" Content="Activer"
ui:IconAssist.Icon="{StaticResource Icon.ShieldKey}" Command="{Binding StartOtpSetupCommand}"
Visibility="{Binding OtpDisabled, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Nouveaux codes" Margin="0,0,8,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding StartRegenerateCodesCommand}"
Visibility="{Binding OtpEnabled, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Désactiver"
ui:IconAssist.Icon="{StaticResource Icon.LockOpenVariant}" Command="{Binding StartOtpDisableCommand}"
Visibility="{Binding OtpEnabled, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
<Grid DockPanel.Dock="Left" Width="16" Height="16" Margin="0,2,10,0" VerticalAlignment="Top">
<Path Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.ShieldCheck}"
Visibility="{Binding OtpEnabled, Converter={StaticResource BoolToVisibility}}" />
<Path Stretch="Uniform" Fill="{DynamicResource TextMutedBrush}" Data="{StaticResource Icon.ShieldAccount}"
Visibility="{Binding OtpDisabled, Converter={StaticResource BoolToVisibility}}" />
</Grid>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Double authentification" FontSize="13.5" FontWeight="SemiBold" />
<TextBlock Text="{Binding TwoFactorStatusText}" FontSize="12" Margin="0,2,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</DockPanel>
<!-- Peu de codes de secours restants -->
<Border CornerRadius="10" Padding="12,8" Margin="0,12,0,0" BorderThickness="1" Background="#26FFB547"
BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding ShowRecoveryWarning, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{Binding RecoveryWarningText}" FontSize="12" TextWrapping="Wrap" />
</Border>
<!-- Activation, 1 : mot de passe -->
<StackPanel Margin="0,14,0,0" IsEnabled="{Binding IsIdle}"
Visibility="{Binding IsTwoFactorPassword, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="En plus du mot de passe, un code à 6 chiffres de votre téléphone sera demandé à chaque connexion. Recommandé."
FontSize="12" Margin="0,0,0,12" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}" />
<TextBlock Text="Mot de passe" Style="{StaticResource AccountLabelStyle}" />
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource AccentButtonStyle}" Content="Continuer" Margin="10,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.ArrowRight}" Click="OnBeginOtpSetupClick" />
<Button DockPanel.Dock="Right" Style="{StaticResource SecondaryButtonStyle}" Content="Annuler" Margin="10,0,0,0"
Command="{Binding CancelTwoFactorCommand}" />
<PasswordBox x:Name="OtpSetupPasswordBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" />
</DockPanel>
</StackPanel>
<!-- Activation, 2 : QR code et premier code -->
<StackPanel Margin="0,14,0,0" IsEnabled="{Binding IsIdle}"
Visibility="{Binding IsTwoFactorScan, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{Binding SetupHelpText}" FontSize="12" Margin="0,0,0,12" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Fond blanc et marge : lisible par les applications quel que soit le thème -->
<Border Width="188" Height="188" CornerRadius="12" Background="White" Padding="6"
SnapsToDevicePixels="True" VerticalAlignment="Top">
<Path Data="{Binding QrPathData, Converter={StaticResource PathData}}" Fill="Black" Stretch="Uniform"
SnapsToDevicePixels="True" RenderOptions.EdgeMode="Aliased" />
</Border>
<StackPanel Grid.Column="1" Margin="16,0,0,0">
<TextBlock Text="Compte dans l'application" Style="{StaticResource AccountLabelStyle}" />
<TextBlock Text="{Binding OtpAccountText}" FontSize="12.5" Margin="0,0,0,12" TextWrapping="Wrap" />
<TextBlock Text="Clé à saisir (si le QR code ne passe pas)" Style="{StaticResource AccountLabelStyle}" />
<DockPanel Margin="0,0,0,14">
<Button DockPanel.Dock="Right" Style="{StaticResource IconButtonStyle}" Width="28" Height="28" Padding="7"
Margin="6,0,0,0" ToolTip="Copier la clé" VerticalAlignment="Top"
ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}" Command="{Binding CopySecretCommand}" />
<TextBlock Text="{Binding OtpSecretText}" FontSize="12.5" TextWrapping="Wrap" VerticalAlignment="Center"
FontFamily="Cascadia Mono, Consolas, Courier New" />
</DockPanel>
<TextBlock Text="Code affiché par l'application" Style="{StaticResource AccountLabelStyle}" />
<TextBox x:Name="SetupCodeBox" Style="{StaticResource ModernTextBoxStyle}" MaxLength="12" Width="160"
HorizontalAlignment="Left" FontSize="16" FontFamily="Cascadia Mono, Consolas, Courier New"
ui:TextAssist.Placeholder="123 456" InputMethod.IsInputMethodEnabled="False"
Text="{Binding SetupCode, UpdateSourceTrigger=PropertyChanged}" KeyDown="OnSetupCodeKeyDown" />
</StackPanel>
</Grid>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Annuler" Margin="0,0,10,0"
Command="{Binding CancelTwoFactorCommand}" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Activer"
ui:IconAssist.Icon="{StaticResource Icon.Check}" Click="OnConfirmOtpSetupClick" />
</StackPanel>
</StackPanel>
<!-- Codes de secours (affichés une seule fois) -->
<StackPanel Margin="0,14,0,0" Visibility="{Binding IsTwoFactorCodes, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Vos codes de secours" FontSize="13" FontWeight="SemiBold" />
<TextBlock Text="{Binding RecoveryCodesHelpText}" FontSize="12" Margin="0,4,0,12" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}" />
<Border CornerRadius="12" Padding="16,12" BorderThickness="1" HorizontalAlignment="Left"
Background="{DynamicResource SurfaceAltBrush}" BorderBrush="{DynamicResource BorderStrongBrush}">
<ItemsControl ItemsSource="{Binding RecoveryCodes}" Focusable="False">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="2" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" FontSize="15" Margin="0,3,28,3"
FontFamily="Cascadia Mono, Consolas, Courier New" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<StackPanel Orientation="Horizontal" Margin="0,12,0,0">
<Button Style="{StaticResource SmallButtonStyle}" Content="Enregistrer…" Margin="0,0,8,0"
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding SaveRecoveryCodesCommand}" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Copier"
ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}" Command="{Binding CopyRecoveryCodesCommand}" />
</StackPanel>
<CheckBox Style="{StaticResource FeedbackCheckBoxStyle}" Margin="0,14,0,0" IsChecked="{Binding CodesSaved}">
<TextBlock Text="J'ai mis mes codes de secours en lieu sûr." FontSize="12.5" TextWrapping="Wrap" />
</CheckBox>
<Button Style="{StaticResource AccentButtonStyle}" Content="Terminer" HorizontalAlignment="Right" Margin="0,8,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Check}" Command="{Binding FinishRecoveryCodesCommand}" />
</StackPanel>
<!-- Désactivation ou nouveaux codes : mot de passe et code -->
<StackPanel Margin="0,14,0,0" IsEnabled="{Binding IsIdle}"
Visibility="{Binding IsTwoFactorConfirm, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{Binding TwoFactorConfirmTitle}" FontSize="13" FontWeight="SemiBold" />
<TextBlock Text="{Binding TwoFactorConfirmText}" FontSize="12" Margin="0,4,0,12" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}" />
<TextBlock Text="Mot de passe" Style="{StaticResource AccountLabelStyle}" />
<PasswordBox x:Name="TwoFactorPasswordBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" />
<TextBlock Text="Code de l'application (ou code de secours)" Style="{StaticResource AccountLabelStyle}" Margin="0,14,0,6" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding OtpCodeMaxLength}" Width="220"
HorizontalAlignment="Left" FontSize="16" FontFamily="Cascadia Mono, Consolas, Courier New"
ui:TextAssist.Placeholder="123 456" InputMethod.IsInputMethodEnabled="False"
Text="{Binding ConfirmCode, UpdateSourceTrigger=PropertyChanged}" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Annuler" Margin="0,0,10,0"
Command="{Binding CancelTwoFactorCommand}" />
<Button Style="{StaticResource DangerButtonStyle}" Content="{Binding TwoFactorConfirmButton}"
ui:IconAssist.Icon="{StaticResource Icon.LockOpenVariant}" Click="OnConfirmTwoFactorClick"
Visibility="{Binding IsTwoFactorDisable, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource AccentButtonStyle}" Content="{Binding TwoFactorConfirmButton}"
ui:IconAssist.Icon="{StaticResource Icon.Check}" Click="OnConfirmTwoFactorClick"
Visibility="{Binding IsTwoFactorDisable, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
</StackPanel>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
<!-- ··· Appareils -->
<StackPanel Visibility="{Binding IsDevicesSection, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Appareils sur lesquels votre compte est connecté. Déconnectez ceux que vous ne reconnaissez pas, puis changez de mot de passe."
FontSize="12.5" Margin="0,0,0,12" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}" />
<ItemsControl ItemsSource="{Binding Devices}" Focusable="False" IsEnabled="{Binding IsIdle}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Style="{StaticResource AccountCardStyle}" Padding="14,12">
<DockPanel>
<Border DockPanel.Dock="Left" Width="38" Height="38" CornerRadius="11" Margin="0,0,14,0"
VerticalAlignment="Center" Background="{DynamicResource BadgeBrush}">
<Path Width="18" Height="18" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
Data="{StaticResource Icon.Laptop}" />
</Border>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Déconnecter"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Logout}"
Command="{Binding RemoveCommand}"
Visibility="{Binding CanRemove, Converter={StaticResource BoolToVisibility}}" />
<Border DockPanel.Dock="Right" CornerRadius="9" Padding="10,3" VerticalAlignment="Center" Margin="10,0,0,0"
Background="{DynamicResource AccentGradientBrush}"
Visibility="{Binding IsCurrent, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Cet appareil" FontSize="11.5" FontWeight="SemiBold"
Foreground="{DynamicResource OnAccentBrush}" />
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding Label}" FontSize="13.5" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding Detail}" FontSize="12" Margin="0,2,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Style="{StaticResource LinkButtonStyle}" HorizontalAlignment="Left" Margin="2,4,0,4" FontSize="12.5"
Foreground="{DynamicResource DangerBrush}" Content="Déconnecter tous mes appareils (celui-ci compris)"
ui:IconAssist.Icon="{StaticResource Icon.LinkOff}" Command="{Binding SignOutAllCommand}" />
</StackPanel>
<!-- ··· Activité -->
<StackPanel Visibility="{Binding IsActivitySection, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Dernières connexions et actions sur votre compte. Adresses IP masquées ; un essai de mot de passe que vous ne reconnaissez pas ? Changez de mot de passe."
FontSize="12.5" Margin="0,0,0,12" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}" />
<StackPanel Orientation="Horizontal" Margin="2,4,0,14"
Visibility="{Binding IsActivityLoading, Converter={StaticResource BoolToVisibility}}">
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Width="90" Height="4" IsIndeterminate="True"
VerticalAlignment="Center" />
<TextBlock Text="Chargement de l'activité…" FontSize="12.5" Margin="12,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
<TextBlock Text="Aucune activité enregistrée." FontSize="12.5" Foreground="{DynamicResource TextMutedBrush}"
Visibility="{Binding IsActivityEmpty, Converter={StaticResource BoolToVisibility}}" />
<Border Style="{StaticResource AccountCardStyle}" Padding="6,4"
Visibility="{Binding IsActivityLoading, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}">
<ItemsControl ItemsSource="{Binding Activity}" Focusable="False">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Margin="8,8">
<Grid DockPanel.Dock="Left" Width="16" Height="16" Margin="0,1,12,0" VerticalAlignment="Top">
<Path Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.CheckCircle}"
Visibility="{Binding IsWarning, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<Path Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
Visibility="{Binding IsWarning, Converter={StaticResource BoolToVisibility}}" />
</Grid>
<StackPanel>
<TextBlock Text="{Binding Title}" FontSize="13" TextWrapping="Wrap" />
<TextBlock Text="{Binding Detail}" FontSize="11.5" Margin="0,2,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
</StackPanel>
<!-- ··· Mes données -->
<StackPanel Visibility="{Binding IsDataSection, Converter={StaticResource BoolToVisibility}}" IsEnabled="{Binding IsIdle}">
<Border Style="{StaticResource AccountCardStyle}">
<StackPanel>
<TextBlock Text="Exporter mes données" FontSize="13.5" FontWeight="SemiBold" />
<TextBlock Text="{Binding ExportText}" FontSize="12" Margin="0,4,0,12" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock Text="Mot de passe" Style="{StaticResource AccountLabelStyle}" />
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SecondaryButtonStyle}" Content="Exporter…" Margin="10,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Download}" Click="OnExportClick" />
<PasswordBox x:Name="ExportPasswordBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" />
</DockPanel>
</StackPanel>
</Border>
<Border Style="{StaticResource AccountCardStyle}" BorderBrush="{DynamicResource DangerBrush}">
<StackPanel>
<TextBlock Text="Supprimer mon compte" FontSize="13.5" FontWeight="SemiBold" Foreground="{DynamicResource DangerBrush}" />
<TextBlock Text="{Binding DeleteText}" FontSize="12" Margin="0,4,0,12" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
<CheckBox Style="{StaticResource FeedbackCheckBoxStyle}" Margin="0,0,0,12" IsChecked="{Binding DeleteConfirmed}">
<TextBlock Text="Je comprends que la suppression est définitive." FontSize="12.5" TextWrapping="Wrap" />
</CheckBox>
<TextBlock Text="Mot de passe" Style="{StaticResource AccountLabelStyle}" />
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource DangerButtonStyle}" Content="Supprimer" Margin="10,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.AccountRemove}" Click="OnDeleteClick" />
<PasswordBox x:Name="DeletePasswordBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" />
</DockPanel>
</StackPanel>
</Border>
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
<!-- ================================================================ Boutons -->
<DockPanel Margin="0,16,14,0">
<Button DockPanel.Dock="Left" Style="{StaticResource LinkButtonStyle}" Content="J'ai déjà un compte" FontSize="12.5"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.ArrowLeft}" Command="{Binding ShowSignInCommand}"
Visibility="{Binding IsRegister, Converter={StaticResource BoolToVisibility}}" />
<Button DockPanel.Dock="Left" Style="{StaticResource LinkButtonStyle}" Content="Se déconnecter" FontSize="12.5"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Logout}" Command="{Binding SignOutCommand}"
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}" />
<Button DockPanel.Dock="Left" Style="{StaticResource LinkButtonStyle}" Content="Actualiser" FontSize="12.5" Margin="16,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}"
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<!-- Action en cours (preuve anti-robot, signature, serveur) -->
<StackPanel Orientation="Horizontal" Margin="0,0,14,0" VerticalAlignment="Center"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}">
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Width="70" Height="4" IsIndeterminate="{Binding IsBusy}"
VerticalAlignment="Center" />
</StackPanel>
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Fermer" Command="{Binding CloseCommand}"
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Annuler" Command="{Binding CloseCommand}"
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Se connecter" Margin="10,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Login}" Click="OnSignInClick" IsEnabled="{Binding IsIdle}"
Visibility="{Binding IsSignIn, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Créer mon compte" Margin="10,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.ArrowRight}" Click="OnRegisterClick" IsEnabled="{Binding IsIdle}"
Visibility="{Binding IsRegister, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Vérifier" Margin="10,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Check}" Click="OnVerifyOtpClick" IsEnabled="{Binding IsIdle}"
Visibility="{Binding IsOtp, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Enregistrer et me connecter" Margin="10,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Check}" Click="OnRequiredChangeClick" IsEnabled="{Binding IsIdle}"
Visibility="{Binding IsPasswordChange, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DockPanel>
<TextBlock Text="{Binding BusyText}" FontSize="11.5" Margin="0,8,14,0" HorizontalAlignment="Right" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</Border>
</Grid>
</Window>
@@ -0,0 +1,193 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Threading;
using GameLauncher.Modules.Accounts;
namespace GameLauncher.Views;
/// <summary>
/// Fenêtre « Compte » (voir <see cref="AccountWindowViewModel"/>). Les mots de passe restent dans les PasswordBox : lus au
/// moment de l'action, passés au ViewModel, puis effacés (<see cref="AccountWindowViewModel.ClearPasswordsRequested"/>).
/// Fermer la fenêtre abandonne une action en cours.
/// </summary>
public partial class AccountWindow : Window
{
/// <summary>Hauteur de base de la zone du contenu : au-delà, il défile au lieu d'agrandir la fenêtre.</summary>
private const double BodyMaxHeight = 520;
private readonly AccountWindowViewModel _viewModel;
private bool _shown;
public AccountWindow(AccountWindowViewModel viewModel)
{
InitializeComponent();
_viewModel = viewModel;
DataContext = viewModel;
viewModel.CloseRequested += Close;
viewModel.ClearPasswordsRequested += ClearPasswords;
viewModel.PropertyChanged += OnViewModelChanged;
SourceInitialized += (_, _) => FitToScreen();
Loaded += async (_, _) => await viewModel.InitializeAsync();
ContentRendered += (_, _) => _shown = true;
SizeChanged += (_, e) =>
{
if (_shown && e.HeightChanged) KeepCentered(e.PreviousSize.Height);
};
Closed += (_, _) =>
{
ClearPasswords();
viewModel.CloseRequested -= Close;
viewModel.ClearPasswordsRequested -= ClearPasswords;
viewModel.PropertyChanged -= OnViewModelChanged;
viewModel.Dispose();
};
}
// ------------------------------------------------------------------ Actions (mots de passe lus ici, jamais liés)
private async void OnSignInClick(object sender, RoutedEventArgs e) => await _viewModel.SignInAsync(SignInPasswordBox.Password);
private async void OnSignInKeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
e.Handled = true;
if (string.IsNullOrEmpty(SignInPasswordBox.Password))
{
SignInPasswordBox.Focus();
return;
}
await _viewModel.SignInAsync(SignInPasswordBox.Password);
}
private async void OnRegisterClick(object sender, RoutedEventArgs e) =>
await _viewModel.RegisterAsync(RegisterPasswordBox.Password, RegisterConfirmBox.Password);
private async void OnRequiredChangeClick(object sender, RoutedEventArgs e) =>
await _viewModel.CompleteRequiredPasswordChangeAsync(RequiredNewBox.Password, RequiredConfirmBox.Password);
private async void OnChangePasswordClick(object sender, RoutedEventArgs e) =>
await _viewModel.ChangePasswordAsync(CurrentPasswordBox.Password, ChangeNewBox.Password, ChangeConfirmBox.Password);
private async void OnExportClick(object sender, RoutedEventArgs e) => await _viewModel.ExportAsync(ExportPasswordBox.Password);
private async void OnDeleteClick(object sender, RoutedEventArgs e) => await _viewModel.DeleteAccountAsync(DeletePasswordBox.Password);
// Double authentification (étape 8c)
private async void OnVerifyOtpClick(object sender, RoutedEventArgs e) => await _viewModel.VerifyOtpAsync();
private async void OnOtpKeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
e.Handled = true;
await _viewModel.VerifyOtpAsync();
}
private async void OnBeginOtpSetupClick(object sender, RoutedEventArgs e) => await _viewModel.BeginOtpSetupAsync(OtpSetupPasswordBox.Password);
private async void OnConfirmOtpSetupClick(object sender, RoutedEventArgs e) => await _viewModel.ConfirmOtpSetupAsync();
private async void OnSetupCodeKeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
e.Handled = true;
await _viewModel.ConfirmOtpSetupAsync();
}
private async void OnConfirmTwoFactorClick(object sender, RoutedEventArgs e) => await _viewModel.ConfirmTwoFactorAsync(TwoFactorPasswordBox.Password);
/// <summary>Contrôle en direct du nouveau mot de passe de l'écran affiché (création, exigé, changement).</summary>
private void OnNewPasswordChanged(object sender, RoutedEventArgs e)
{
var (password, confirmation) = _viewModel.Step switch
{
AccountStep.Register => (RegisterPasswordBox, RegisterConfirmBox),
AccountStep.PasswordChange => (RequiredNewBox, RequiredConfirmBox),
_ => (ChangeNewBox, ChangeConfirmBox)
};
_viewModel.UpdateNewPassword(password.Password, confirmation.Password);
}
private void ClearPasswords()
{
foreach (var box in new[]
{
SignInPasswordBox, RegisterPasswordBox, RegisterConfirmBox, RequiredNewBox, RequiredConfirmBox, CurrentPasswordBox,
ChangeNewBox, ChangeConfirmBox, ExportPasswordBox, DeletePasswordBox, OtpSetupPasswordBox, TwoFactorPasswordBox
})
box.Clear();
}
// ------------------------------------------------------------------ Affichage
private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(AccountWindowViewModel.TwoFactorStage))
{
// Curseur dans le champ utile de l'étape de la double authentification.
Dispatcher.InvokeAsync(() =>
{
Control? field = _viewModel.TwoFactorStage switch
{
TwoFactorStage.Password => OtpSetupPasswordBox,
TwoFactorStage.Scan => SetupCodeBox,
TwoFactorStage.Disable or TwoFactorStage.Regenerate => TwoFactorPasswordBox,
_ => null
};
field?.Focus();
}, DispatcherPriority.Input);
return;
}
if (e.PropertyName is not (nameof(AccountWindowViewModel.Step) or nameof(AccountWindowViewModel.Section))) return;
BodyScroll.ScrollToTop();
// Curseur dans le premier champ de l'écran affiché.
Dispatcher.InvokeAsync(() =>
{
Control? target = _viewModel.Step switch
{
AccountStep.SignIn => string.IsNullOrEmpty(_viewModel.Email) ? SignInEmailBox : SignInPasswordBox,
AccountStep.Register => string.IsNullOrEmpty(_viewModel.Email) ? RegisterEmailBox : RegisterPasswordBox,
AccountStep.PasswordChange => RequiredNewBox,
AccountStep.Otp => OtpCodeBox,
_ => null
};
target?.Focus();
}, DispatcherPriority.Input);
}
/// <summary>Taille de base raisonnable, jamais plus grande que la zone utile de l'écran : au-delà, le contenu défile.</summary>
private void FitToScreen()
{
const double chrome = 250; // en-tête, onglets, boutons, marges et ombre
var available = Controls.WindowModeAssist.WorkArea(Owner ?? (System.Windows.Media.Visual)this).Height - chrome;
BodyScroll.MaxHeight = Math.Max(240, Math.Min(BodyMaxHeight, available));
}
/// <summary>
/// La hauteur change avec l'écran : la fenêtre grandit ou rétrécit autour de son centre et reste dans la zone utile de
/// l'écran (même règle que la fenêtre « Aide »). Une fenêtre placée hors des écrans (tools\UiSnapshot) n'est pas déplacée.
/// </summary>
private void KeepCentered(double previousHeight)
{
if (previousHeight <= 0 || double.IsNaN(Top) || double.IsNaN(Left) || WindowState != WindowState.Normal) return;
var work = Controls.WindowModeAssist.WorkArea(this);
if (work.Width < 1 || work.Height < 1 || !work.IntersectsWith(new Rect(Left, Top, ActualWidth, previousHeight))) return;
var top = Top + (previousHeight - ActualHeight) / 2;
Top = Math.Max(work.Top, Math.Min(top, work.Bottom - ActualHeight));
}
/// <summary>Échap ferme la fenêtre, sauf pendant une action en cours.</summary>
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Escape || _viewModel.IsBusy) return;
Close();
e.Handled = true;
}
private void OnDragMove(object sender, MouseButtonEventArgs e)
{
if (e.ButtonState == MouseButtonState.Pressed && e.OriginalSource is not (TextBox or PasswordBox)) DragMove();
}
}
+17
View File
@@ -276,6 +276,23 @@
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Aide : avis, idée, bug…" Margin="0,0,4,0"
ui:IconAssist.Icon="{StaticResource Icon.MessageQuestion}" Click="OnHelpClick"
Visibility="{Binding DataContext.ShowHelpButton, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
<!-- Compte joueur : connexion, création, « Mon compte » (absent sans « playerAccounts » et « apiUrl » dans le
launcher.json intégré, masqué pendant une maintenance). Pastille : verte = connecté, orange = reconnexion
ou serveur injoignable. -->
<Grid Margin="0,0,4,0"
Visibility="{Binding DataContext.ShowAccountButton, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}">
<Button x:Name="AccountButton" Style="{StaticResource IconButtonStyle}" Click="OnAccountClick"
ToolTip="{Binding DataContext.AccountToolTip, RelativeSource={RelativeSource AncestorType=Window}}"
ui:IconAssist.Icon="{StaticResource Icon.AccountCircle}" />
<Ellipse Width="9" Height="9" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,6,6,0"
IsHitTestVisible="False" Fill="{DynamicResource SuccessBrush}" Stroke="{DynamicResource SurfaceSolidBrush}"
StrokeThickness="1.5"
Visibility="{Binding DataContext.IsAccountSignedIn, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
<Ellipse Width="9" Height="9" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,6,6,0"
IsHitTestVisible="False" Fill="{DynamicResource WarningBrush}" Stroke="{DynamicResource SurfaceSolidBrush}"
StrokeThickness="1.5"
Visibility="{Binding DataContext.IsAccountPending, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
</Grid>
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Site officiel"
ui:IconAssist.Icon="{StaticResource Icon.Web}"
Command="{Binding OpenWebsiteCommand}"
@@ -84,6 +84,11 @@ public partial class MainWindow : Window, IThemeTransition
if (sender is FrameworkElement { DataContext: GameViewModel game }) HelpRequested?.Invoke(game);
}
/// <summary>Bouton « Compte » : connexion, création de compte, « Mon compte ».</summary>
public event Action? AccountRequested;
private void OnAccountClick(object sender, RoutedEventArgs e) => AccountRequested?.Invoke();
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
// Avec Alt, WPF signale la touche dans SystemKey (Key.System).
+5
View File
@@ -31,6 +31,11 @@
// pour retirer le menu d'administration.
"apiUrl": "https://launcher.lanisys.fr/api/",
// Comptes joueurs (bouton « Compte » : connexion, création de compte, « Mon compte ») avec la même API. Laisser à false
// tant que les comptes ne sont pas ouverts sur le serveur (LauncherApi:Players:Enabled, étapes 8a à 8g). Comme apiUrl,
// la version Release ne le lit QUE dans le launcher.json intégré à l'exécutable.
"playerAccounts": false,
// Catalogue des jeux : informations modifiables depuis le menu d'administration (nom affiché,
// accroche, présentation, liens, thème intégré, images, ordre…), lues au démarrage puis mises en
// cache. Comme apiUrl, la version Release ne la lit que dans le launcher.json intégré à l'exe.
@@ -0,0 +1,249 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Modules;
using GameLauncher.Modules.Accounts;
using GameLauncher.Tests.Admin;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Accounts;
/// <summary>
/// Fenêtre « Compte » sans interface (ViewModel réel, service réel, Launcher API en mémoire) : écrans, contrôles locaux avant
/// tout envoi, messages, onglets de « Mon compte », export, suppression, service fermé.
/// </summary>
public class AccountWindowViewModelTests
{
private const string Email = "joueur@example.fr";
private const string Password = PlayerTestServer.Password;
private static AccountWindowViewModel NewWindow(PlayerAccountService service, FakeDialogs dialogs) =>
new(service, dialogs, ImmediateDispatcher.Instance, "LanisysGames");
[Fact]
public async Task Registration_checks_everything_locally_before_creating_the_account()
{
await using var h = await AccountsHarness.StartAsync();
using var service = h.NewService();
var dialogs = new FakeDialogs();
using var vm = NewWindow(service, dialogs);
var clears = 0;
vm.ClearPasswordsRequested += () => clears++;
await vm.InitializeAsync();
Assert.Equal(AccountStep.SignIn, vm.Step);
Assert.True(vm.RegistrationOpen);
Assert.Contains("version 1", vm.TermsText);
vm.ShowRegisterCommand.Execute(null);
Assert.Equal(AccountStep.Register, vm.Step);
Assert.Equal("Créer un compte", vm.Title);
vm.Email = "pas-une-adresse";
await vm.RegisterAsync(Password, Password);
Assert.Equal("Adresse e-mail invalide.", vm.ErrorMessage);
vm.Email = Email;
vm.UpdateNewPassword("azerty123", "");
Assert.True(vm.NewPassword.HasProblems);
Assert.Equal("Refusé", vm.NewPassword.StrengthText);
await vm.RegisterAsync("azerty123", "azerty123");
Assert.True(vm.HasError);
vm.UpdateNewPassword(Password, "autre chose");
Assert.True(vm.NewPassword.IsAccepted);
Assert.True(vm.NewPassword.ShowConfirmationMismatch);
await vm.RegisterAsync(Password, "autre chose");
Assert.Equal("Les deux mots de passe ne sont pas identiques.", vm.ErrorMessage);
await vm.RegisterAsync(Password, Password);
Assert.Contains("Acceptez les conditions", vm.ErrorMessage);
Assert.Equal(0, h.Server.Store.CountAccounts()); // rien n'a été envoyé
vm.AcceptTerms = true;
await vm.RegisterAsync(Password, Password);
Assert.False(vm.HasError, vm.ErrorMessage);
Assert.Equal(AccountStep.Account, vm.Step);
Assert.Equal("Bienvenue ! Votre compte est créé.", vm.SuccessMessage);
Assert.Equal("j***@e***.fr", vm.Subtitle);
Assert.Matches("^PL-[0-9A-Z]{4}-[0-9A-Z]{4}$", vm.Reference);
Assert.Single(vm.Devices);
Assert.True(vm.Devices[0].IsCurrent);
Assert.False(vm.Devices[0].CanRemove);
Assert.True(clears > 0); // champs de mot de passe effacés après chaque action
vm.CopyReferenceCommand.Execute(null);
Assert.Equal(vm.Reference, dialogs.Clipboard);
}
[Fact]
public async Task Sign_in_errors_are_clear_and_the_account_tabs_load_on_demand()
{
await using var h = await AccountsHarness.StartAsync();
using (var first = h.NewService()) await first.RegisterAsync(Email, Password, "1", remember: false);
h.File.Delete();
using var service = h.NewService();
using var vm = NewWindow(service, new FakeDialogs());
await vm.InitializeAsync();
vm.Email = Email;
await vm.SignInAsync("");
Assert.Equal("Saisissez votre mot de passe.", vm.ErrorMessage);
await vm.SignInAsync("Mauvais-Mot-De-Passe-1");
Assert.Equal("Adresse e-mail ou mot de passe incorrect.", vm.ErrorMessage);
Assert.Equal(AccountStep.SignIn, vm.Step);
vm.ToggleForgotCommand.Execute(null);
Assert.True(vm.ShowForgotHelp);
Assert.Contains("mot de passe temporaire", vm.ForgotHelpText);
await vm.SignInAsync(Password);
Assert.Equal(AccountStep.Account, vm.Step);
Assert.True(vm.IsProfileSection);
Assert.Empty(vm.Activity);
vm.IsActivitySection = true;
await WaitUntil(() => !vm.IsActivityLoading && vm.Activity.Count > 0);
Assert.Contains(vm.Activity, a => a.Title == "Mot de passe incorrect" && a.IsWarning);
Assert.Contains(vm.Activity, a => a.Title == "Connexion" && a.Detail.EndsWith("cet appareil"));
await vm.SignOutCommand.ExecuteAsync();
Assert.Equal(AccountStep.SignIn, vm.Step);
Assert.Equal("Vous êtes déconnecté.", vm.SuccessMessage);
Assert.Equal(PlayerAccountState.SignedOut, service.State);
}
[Fact]
public async Task Required_password_change_then_ordinary_password_change()
{
await using var h = await AccountsHarness.StartAsync();
string reference;
using (var first = h.NewService())
{
await first.RegisterAsync(Email, Password, "1", remember: false);
reference = first.Account!.Reference;
}
h.Server.Store.SetMustChangePassword(h.Server.Store.FindByReference(reference)!.Id, true);
using var service = h.NewService();
using var vm = NewWindow(service, new FakeDialogs());
await vm.InitializeAsync();
vm.Email = Email;
await vm.SignInAsync(Password);
Assert.Equal(AccountStep.PasswordChange, vm.Step);
Assert.Equal("Nouveau mot de passe exigé", vm.Title);
await vm.CompleteRequiredPasswordChangeAsync(Password, Password);
Assert.Contains("différent", vm.ErrorMessage);
await vm.CompleteRequiredPasswordChangeAsync(PlayerTestServer.OtherPassword, PlayerTestServer.OtherPassword);
Assert.Equal(AccountStep.Account, vm.Step);
Assert.Contains("Nouveau mot de passe enregistré", vm.SuccessMessage);
// Changement ordinaire depuis « Profil ».
vm.ShowChangePasswordCommand.Execute(null);
Assert.True(vm.IsChangingPassword);
await vm.ChangePasswordAsync("Pas-Le-Bon-Mot-De-Passe", "Nouvelle-Phrase-De-Passe-42", "Nouvelle-Phrase-De-Passe-42");
Assert.Equal("Mot de passe actuel incorrect.", vm.ErrorMessage);
await vm.ChangePasswordAsync(PlayerTestServer.OtherPassword, "Nouvelle-Phrase-De-Passe-42", "Nouvelle-Phrase-De-Passe-42");
Assert.Equal("Mot de passe modifié.", vm.SuccessMessage);
Assert.False(vm.IsChangingPassword);
}
[Fact]
public async Task Export_writes_the_chosen_file_and_deletion_needs_two_confirmations()
{
await using var h = await AccountsHarness.StartAsync();
using var service = h.NewService();
await service.RegisterAsync(Email, Password, "1", remember: true);
var dialogs = new FakeDialogs();
using var vm = NewWindow(service, dialogs);
await vm.InitializeAsync();
Assert.Equal(AccountStep.Account, vm.Step);
vm.IsDataSection = true;
// Export : emplacement refusé = rien n'est demandé au serveur.
dialogs.NextSaveFile = null;
await vm.ExportAsync(Password);
Assert.False(vm.HasSuccess);
dialogs.NextSaveFile = Path.Combine(h.DataDirectory, "export.json");
await vm.ExportAsync(Password);
Assert.Contains("export.json", vm.SuccessMessage);
using (var json = JsonDocument.Parse(await File.ReadAllBytesAsync(dialogs.NextSaveFile)))
Assert.Equal(PlayerDataExport.Format, json.RootElement.GetProperty("format").GetString());
// Suppression : case à cocher, puis confirmation, puis mot de passe vérifié par le serveur.
await vm.DeleteAccountAsync(Password);
Assert.Contains("Cochez la case", vm.ErrorMessage);
vm.DeleteConfirmed = true;
dialogs.ConfirmAnswer = false;
await vm.DeleteAccountAsync(Password);
Assert.Equal(PlayerAccountState.SignedIn, service.State);
dialogs.ConfirmAnswer = true;
await vm.DeleteAccountAsync(Password);
Assert.Equal(AccountStep.SignIn, vm.Step);
Assert.Equal("Votre compte a été supprimé définitivement.", vm.SuccessMessage);
Assert.Equal(0, h.Server.Store.CountAccounts());
}
[Fact]
public async Task Session_lost_while_the_window_is_open_returns_to_sign_in()
{
await using var h = await AccountsHarness.StartAsync();
using var service = h.NewService();
await service.RegisterAsync(Email, Password, "1", remember: true);
using var vm = NewWindow(service, new FakeDialogs());
await vm.InitializeAsync();
// Déconnexion de tous les appareils depuis un autre PC, puis jeton d'accès échu.
using var laptop = new TestDevice();
var other = (await h.Server.LoginAsync(laptop, Email)).Session!;
using (await h.Server.SendAsync(HttpMethod.Post, ApiRoutes.AccountLogout, other.AccessToken, new PlayerLogoutRequest { AllDevices = true })) { }
h.Server.Api.Time.Advance(TimeSpan.FromMinutes(20));
await vm.RefreshCommand.ExecuteAsync();
Assert.Equal(AccountStep.SignIn, vm.Step);
Assert.Contains("reconnectez-vous", vm.ErrorMessage);
}
[Fact]
public async Task Closed_service_shows_a_friendly_message()
{
await using var h = await AccountsHarness.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Players:Enabled"] = "false" });
using var service = h.NewService();
using var vm = NewWindow(service, new FakeDialogs());
await vm.InitializeAsync();
Assert.Equal(AccountStep.Unavailable, vm.Step);
Assert.Contains("pas encore ouverts", vm.UnavailableText);
}
[Fact]
public async Task Closed_registration_hides_account_creation()
{
await using var h = await AccountsHarness.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Players:RegistrationEnabled"] = "false" });
using var service = h.NewService();
using var vm = NewWindow(service, new FakeDialogs());
await vm.InitializeAsync();
Assert.Equal(AccountStep.SignIn, vm.Step);
Assert.True(vm.RegistrationClosed);
Assert.False(vm.ShowRegisterCommand.CanExecute(null));
}
[Fact]
public void Password_rules_follow_the_shared_checks()
{
var rules = new PasswordRulesViewModel();
Assert.True(rules.ShowRules);
rules.Update("Exovival2026!!", "", "joueur@example.fr");
Assert.True(rules.HasProblems); // tiré du nom du jeu
rules.Update("une phrase de passe assez longue", "une phrase de passe assez longue", null);
Assert.True(rules.IsValid);
Assert.Equal(4, rules.Strength);
rules.Reset();
Assert.False(rules.HasInput);
Assert.False(rules.IsValid);
}
private static async Task WaitUntil(Func<bool> condition)
{
for (var i = 0; i < 200 && !condition(); i++) await Task.Delay(25);
Assert.True(condition());
}
}
@@ -0,0 +1,116 @@
using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Accounts;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Accounts;
/// <summary>
/// Compte joueur du launcher contre la Launcher API démarrée en mémoire : clé d'appareil logicielle (à la place de la puce
/// TPM / clé CNG de Windows), chiffrement AES-GCM à clé aléatoire (à la place de DPAPI), dossier de données temporaire.
/// </summary>
public sealed class AccountsHarness : IAsyncDisposable
{
private readonly List<LauncherApiClient> _clients = new();
private AccountsHarness(PlayerTestServer server)
{
Server = server;
DataDirectory = Path.Combine(Path.GetTempPath(), "launcher-accounts-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(DataDirectory);
}
public PlayerTestServer Server { get; }
public string DataDirectory { get; }
public SoftwareKeyStore Keys { get; } = new();
public TestProtector Protector { get; } = new();
public PlayerSessionFile File => new(DataDirectory, Protector);
public static async Task<AccountsHarness> StartAsync(IDictionary<string, string?>? settings = null) =>
new(await PlayerTestServer.StartAsync(settings));
/// <summary>Nouveau service (comme un nouveau lancement du launcher sur le même PC).</summary>
public PlayerAccountService NewService(Uri? apiUrl = null, HttpMessageHandler? handler = null)
{
var client = new LauncherApiClient(apiUrl ?? Server.Api.BaseUrl, "GameLauncher.Tests", handler);
_clients.Add(client);
return new PlayerAccountService(new PlayerAccountApi(client), Keys, File, "Windows 11 · GameLauncher tests", Server.Api.Time);
}
public async ValueTask DisposeAsync()
{
foreach (var client in _clients) client.Dispose();
await Server.DisposeAsync();
try
{
Directory.Delete(DataDirectory, recursive: true);
}
catch (IOException)
{
}
}
}
/// <summary>Clé d'appareil logicielle (une par « PC ») : même interface que la clé TPM / CNG du launcher.</summary>
public sealed class SoftwareKeyStore : IPlayerDeviceKeyStore
{
private Key? _key;
public int Created { get; private set; }
public IPlayerDeviceKey? TryOpen() => _key;
public IPlayerDeviceKey OpenOrCreate()
{
if (_key is null)
{
_key = new Key();
Created++;
}
return _key;
}
public void Delete() => _key = null;
private sealed class Key : IPlayerDeviceKey
{
private readonly ECDsa _ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
public Key()
{
PublicKey = _ecdsa.ExportSubjectPublicKeyInfo();
KeyId = AdminKeys.ComputeKeyId(PublicKey);
}
public byte[] PublicKey { get; }
public string KeyId { get; }
public ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default) => ValueTask.FromResult(AdminKeys.Sign(_ecdsa, message));
}
}
/// <summary>Chiffrement authentifié à clé aléatoire (remplace DPAPI) : un autre « utilisateur » (autre instance) ne lit rien.</summary>
public sealed class TestProtector : ISecretProtector
{
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
public byte[] Protect(byte[] data)
{
var nonce = RandomNumberGenerator.GetBytes(12);
var cipher = new byte[data.Length];
var tag = new byte[16];
using var aes = new AesGcm(_key, 16);
aes.Encrypt(nonce, data, cipher, tag);
return nonce.Concat(tag).Concat(cipher).ToArray();
}
public byte[] Unprotect(byte[] data)
{
if (data.Length < 28) throw new CryptographicException("trop court");
var plain = new byte[data.Length - 28];
using var aes = new AesGcm(_key, 16);
aes.Decrypt(data.AsSpan(0, 12), data.AsSpan(28), data.AsSpan(12, 16), plain);
return plain;
}
}
@@ -0,0 +1,258 @@
using System.Text;
using System.Text.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Modules.Accounts;
using GameLauncher.Server.Security;
using GameLauncher.Tests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace GameLauncher.Tests.Accounts;
/// <summary>
/// Compte joueur côté launcher (étape 8b) contre la vraie Launcher API : session mémorisée chiffrée et reprise au lancement
/// suivant, rotation du jeton de renouvellement, jeton d'accès renouvelé de lui-même, sessions perdues, hors ligne,
/// nouveau mot de passe exigé, export et suppression.
/// </summary>
public class PlayerAccountServiceTests
{
private const string Email = "joueur@example.fr";
private const string Password = PlayerTestServer.Password;
[Fact]
public async Task Registered_session_is_saved_encrypted_and_resumed_at_next_launch_with_a_new_token()
{
await using var h = await AccountsHarness.StartAsync();
using (var first = h.NewService())
{
await first.RegisterAsync(Email, Password, "1", remember: true);
Assert.Equal(PlayerAccountState.SignedIn, first.State);
Assert.Equal("j***@e***.fr", first.Account!.EmailMasked);
Assert.True(first.IsRemembered);
}
// Fichier chiffré : ni le jeton, ni l'adresse, ni la référence en clair.
var saved = h.File.Load()!;
var raw = await File.ReadAllBytesAsync(h.File.Path);
var text = Encoding.UTF8.GetString(raw);
Assert.DoesNotContain(saved.RefreshToken, text);
Assert.DoesNotContain(saved.Reference, text);
Assert.DoesNotContain("e***", text);
// Lancement suivant : session reprise, jeton de renouvellement remplacé sur le disque.
using var second = h.NewService();
var changes = 0;
second.Changed += () => changes++;
await second.RestoreAsync();
Assert.Equal(PlayerAccountState.SignedIn, second.State);
Assert.True(changes >= 2); // reprise puis connecté
var rotated = h.File.Load()!;
Assert.NotEqual(saved.RefreshToken, rotated.RefreshToken);
Assert.Equal(saved.DeviceId, rotated.DeviceId);
var me = await second.GetMeAsync();
Assert.Single(me.Devices);
Assert.True(me.Devices[0].Current);
Assert.Equal("Windows 11 · GameLauncher tests", me.Devices[0].Label);
}
[Fact]
public async Task Session_file_of_another_user_another_server_or_without_the_device_key_is_discarded()
{
await using var h = await AccountsHarness.StartAsync();
using (var service = h.NewService()) await service.RegisterAsync(Email, Password, "1", remember: true);
var saved = h.File.Load()!;
// Autre utilisateur Windows (autre clé DPAPI) : illisible, supprimé.
var other = new PlayerSessionFile(h.DataDirectory, new TestProtector());
Assert.Null(other.Load());
Assert.False(File.Exists(h.File.Path));
// Autre serveur : ignorée.
h.File.Save(saved with { Origin = "https://autre.exemple.fr" });
using (var service = h.NewService())
{
await service.RestoreAsync();
Assert.Equal(PlayerAccountState.SignedOut, service.State);
}
Assert.False(File.Exists(h.File.Path));
// Clé de l'appareil disparue (profil Windows réinitialisé) : abandonnée.
h.File.Save(saved);
h.Keys.Delete();
using (var service = h.NewService())
{
await service.RestoreAsync();
Assert.Equal(PlayerAccountState.SignedOut, service.State);
}
Assert.False(File.Exists(h.File.Path));
}
[Fact]
public async Task Session_closed_from_another_device_is_dropped_with_an_explanation()
{
await using var h = await AccountsHarness.StartAsync();
using (var service = h.NewService()) await service.RegisterAsync(Email, Password, "1", remember: true);
// Un autre appareil déconnecte tout.
using var laptop = new TestDevice();
var other = (await h.Server.LoginAsync(laptop, Email)).Session!;
using (await h.Server.SendAsync(HttpMethod.Post, ApiRoutes.AccountLogout, other.AccessToken, new PlayerLogoutRequest { AllDevices = true })) { }
using var next = h.NewService();
await next.RestoreAsync();
Assert.Equal(PlayerAccountState.SignedOut, next.State);
Assert.Contains("reconnectez-vous", next.Notice);
Assert.False(File.Exists(h.File.Path));
await Assert.ThrowsAsync<PlayerSessionLostException>(() => next.GetMeAsync());
}
[Fact]
public async Task Unreachable_server_keeps_the_remembered_session_offline()
{
await using var h = await AccountsHarness.StartAsync();
using (var service = h.NewService()) await service.RegisterAsync(Email, Password, "1", remember: true);
var saved = h.File.Load()!;
// Serveur injoignable (coupure réseau simulée).
using var offline = h.NewService(handler: new UnreachableHandler());
await offline.RestoreAsync();
Assert.Equal(PlayerAccountState.Offline, offline.State);
Assert.Equal(saved.Reference, offline.Account!.Reference);
Assert.Equal(saved.RefreshToken, h.File.Load()!.RefreshToken); // rien de perdu
}
[Fact]
public async Task Access_token_is_renewed_before_expiry_and_after_a_server_restart()
{
await using var h = await AccountsHarness.StartAsync();
using var service = h.NewService();
await service.RegisterAsync(Email, Password, "1", remember: true);
var token1 = h.File.Load()!.RefreshToken;
// Échéance du jeton d'accès (15 min) : renouvelé de lui-même.
h.Server.Api.Time.Advance(TimeSpan.FromMinutes(20));
await service.GetMeAsync();
var token2 = h.File.Load()!.RefreshToken;
Assert.NotEqual(token1, token2);
// Serveur redémarré (sessions en mémoire perdues) : un renouvellement, puis l'appel réussit.
h.Server.Api.Services.GetRequiredService<PlayerSessionStore>().RemoveWhere(_ => true);
var me = await service.GetMeAsync();
Assert.Single(me.Devices);
Assert.NotEqual(token2, h.File.Load()!.RefreshToken);
}
[Fact]
public async Task Session_not_remembered_is_never_written_and_is_closed_when_the_launcher_exits()
{
await using var h = await AccountsHarness.StartAsync();
var service = h.NewService();
await service.RegisterAsync(Email, Password, "1", remember: false);
Assert.Equal(PlayerAccountState.SignedIn, service.State);
Assert.False(File.Exists(h.File.Path));
var accountId = h.Server.Store.FindByReference(service.Account!.Reference)!.Id;
Assert.Single(h.Server.Store.Devices(accountId));
service.Shutdown(TimeSpan.FromSeconds(10));
Assert.Empty(h.Server.Store.Devices(accountId));
Assert.Equal(PlayerAccountState.SignedOut, service.State);
service.Dispose();
}
[Fact]
public async Task Wrong_password_and_required_password_change_flow()
{
await using var h = await AccountsHarness.StartAsync();
using (var service = h.NewService()) await service.RegisterAsync(Email, Password, "1", remember: true);
h.Server.Store.SetMustChangePassword(h.Server.Store.FindByReference(h.File.Load()!.Reference)!.Id, true);
h.File.Delete();
using var next = h.NewService();
var wrong = await Assert.ThrowsAsync<ApiException>(() => next.SignInAsync(Email, "Pas-Le-Bon-Mot-2-Passe", true));
Assert.Equal(ApiErrorCodes.LoginFailed, wrong.Code);
Assert.Equal("Adresse e-mail ou mot de passe incorrect.", PlayerAccountTexts.Describe(wrong));
var result = await next.SignInAsync(Email, Password, remember: true);
Assert.True(result.PasswordChangeRequired);
Assert.Equal(PlayerAccountState.SignedOut, next.State);
Assert.False(File.Exists(h.File.Path));
await next.CompleteRequiredPasswordChangeAsync(result.PasswordChangeToken!, Password, PlayerTestServer.OtherPassword, remember: true);
Assert.Equal(PlayerAccountState.SignedIn, next.State);
Assert.True(File.Exists(h.File.Path));
Assert.Equal(1, h.Keys.Created); // même clé d'appareil depuis le début
}
[Fact]
public async Task Export_then_deletion_from_the_launcher()
{
await using var h = await AccountsHarness.StartAsync();
using var service = h.NewService();
await service.RegisterAsync(Email, Password, "1", remember: true);
var reference = service.Account!.Reference;
var export = await service.ExportAsync(Password);
using (var json = JsonDocument.Parse(export))
Assert.Equal(Email, json.RootElement.GetProperty("account").GetProperty("email").GetString());
await Assert.ThrowsAsync<ApiException>(() => service.DeleteAccountAsync("Mauvais-Mot-De-Passe-9"));
Assert.Equal(PlayerAccountState.SignedIn, service.State);
await service.DeleteAccountAsync(Password);
Assert.Equal(PlayerAccountState.SignedOut, service.State);
Assert.False(File.Exists(h.File.Path));
Assert.Null(h.Server.Store.FindByReference(reference));
}
[Fact]
public async Task Signing_out_this_device_or_all_devices()
{
await using var h = await AccountsHarness.StartAsync();
using var service = h.NewService();
await service.RegisterAsync(Email, Password, "1", remember: true);
var accountId = h.Server.Store.FindByReference(service.Account!.Reference)!.Id;
using var laptop = new TestDevice();
await h.Server.LoginAsync(laptop, Email);
Assert.Equal(2, h.Server.Store.Devices(accountId).Count);
await service.SignOutAsync(allDevices: false);
Assert.Equal(PlayerAccountState.SignedOut, service.State);
Assert.False(File.Exists(h.File.Path));
Assert.Single(h.Server.Store.Devices(accountId)); // l'autre appareil reste connecté
await service.SignInAsync(Email, Password, remember: true);
await service.SignOutAsync(allDevices: true);
Assert.Empty(h.Server.Store.Devices(accountId));
}
[Fact]
public async Task Challenge_from_a_server_announcing_another_origin_is_refused()
{
await using var h = await AccountsHarness.StartAsync();
// Même serveur, joint par un autre nom : l'origine annoncée ne correspond pas à l'adresse configurée.
using var service = h.NewService(new Uri($"http://localhost:{h.Server.Api.BaseUrl.Port}/api/"));
var error = await Assert.ThrowsAsync<ApiException>(() => service.RegisterAsync(Email, Password, "1", true));
Assert.Equal("origin_mismatch", error.Code);
Assert.Equal(0, h.Server.Store.CountAccounts());
}
[Fact]
public void Device_label_is_generic()
{
var label = PlayerDeviceLabel.Create("3.4.2");
Assert.EndsWith("· GameLauncher 3.4.2", label);
Assert.DoesNotContain(Environment.MachineName, label);
Assert.Equal(label, PlayerDeviceLabel.Create("3.4.2<script>"));
Assert.EndsWith("· GameLauncher", PlayerDeviceLabel.Create(""));
}
}
/// <summary>Coupure réseau : chaque requête échoue comme un serveur injoignable.</summary>
internal sealed class UnreachableHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
throw new HttpRequestException("Aucune connexion n'a pu être établie (test).");
}
@@ -0,0 +1,89 @@
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Modules.Accounts;
namespace GameLauncher.Tests.Accounts;
/// <summary>
/// Encodeur de QR codes maison (double authentification). Les empreintes ci-dessous ont été relevées sur des symboles
/// vérifiés par un décodeur de référence indépendant (ZXing-C++ 3.1) : 43 symboles, versions 1 à 39, quatre niveaux de
/// correction, huit masques, tous relus à l'identique. Un changement de l'encodeur qui modifie ces symboles doit être
/// revérifié de la même façon avant de mettre à jour les empreintes.
/// </summary>
public class QrCodeTests
{
[Theory]
[InlineData("Hello, world!", QrErrorCorrection.Low, null, 1, 3, "be877faf76682454d5cab20fbabfd91e17af8165e33e235836bb496db6d72ee8")]
[InlineData("Été à Paris — ünïcødé ✓", QrErrorCorrection.High, null, 4, 2, "5b9c85b6fdf9f8b33bda7ceef3bbc2a3e03002888b3b5f602391c58d96d5c165")]
[InlineData("mask test 0", QrErrorCorrection.Quartile, 0, 1, 0, "a806dd0bceb24572dbc0501d98a69e2ae799648c300f6513583307e6013782f4")]
public void Symbols_match_the_independently_decoded_reference(string text, QrErrorCorrection level, int? mask, int version, int expectedMask,
string fingerprint)
{
var qr = QrCode.EncodeBytes(Encoding.UTF8.GetBytes(text), level, mask);
Assert.Equal(version, qr.Version);
Assert.Equal(expectedMask, qr.Mask);
Assert.Equal(fingerprint, Fingerprint(qr));
}
[Fact]
public void Otpauth_address_fits_a_small_symbol_with_finder_and_timing_patterns()
{
var uri = "otpauth://totp/LanisysGames:j%2A%2A%2A%40e%2A%2A%2A.fr?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP" +
"&issuer=LanisysGames&algorithm=SHA1&digits=6&period=30";
var qr = QrCode.Encode(uri);
Assert.InRange(qr.Version, 6, 9);
Assert.Equal(qr.Version * 4 + 17, qr.Size);
// Repères : carrés 7 × 7 (bord sombre, anneau clair, cœur 3 × 3 sombre) dans trois coins.
foreach (var (ox, oy) in new[] { (0, 0), (qr.Size - 7, 0), (0, qr.Size - 7) })
{
for (var y = 0; y < 7; y++)
{
for (var x = 0; x < 7; x++)
{
var ring = Math.Max(Math.Abs(x - 3), Math.Abs(y - 3));
Assert.Equal(ring != 2, qr[ox + x, oy + y]);
}
}
}
// Motifs de synchronisation, et module toujours sombre.
for (var i = 8; i < qr.Size - 8; i++)
{
Assert.Equal(i % 2 == 0, qr[i, 6]);
Assert.Equal(i % 2 == 0, qr[6, i]);
}
Assert.True(qr[8, qr.Size - 8]);
Assert.False(qr[-1, 0]); // marge
}
[Fact]
public void Capacity_grows_with_the_version_and_too_long_text_is_refused()
{
Assert.Equal(1, QrCode.Encode(new string('a', 14)).Version); // 1-M : 14 octets
Assert.Equal(2, QrCode.Encode(new string('a', 15)).Version);
Assert.Equal(40, QrCode.EncodeBytes(new byte[2331], QrErrorCorrection.Medium).Version); // 40-M : 2331 octets
Assert.Throws<ArgumentException>(() => QrCode.EncodeBytes(new byte[2332], QrErrorCorrection.Medium));
Assert.Equal(2956, QrCode.DataCodewords(40, QrErrorCorrection.Low));
Assert.Equal(16, QrCode.DataCodewords(1, QrErrorCorrection.Medium));
}
[Fact]
public void Path_data_draws_one_rectangle_per_horizontal_run_inside_the_margin()
{
var qr = QrCode.Encode("GameLauncher");
var path = qr.ToPathData(border: 4);
Assert.StartsWith("M4,4h7v1h-7z", path); // première ligne : bord supérieur du repère (7 modules sombres)
var dark = 0;
for (var y = 0; y < qr.Size; y++) for (var x = 0; x < qr.Size; x++) if (qr[x, y]) dark++;
var drawn = path.Split('z', StringSplitOptions.RemoveEmptyEntries)
.Sum(rect => int.Parse(rect[(rect.IndexOf('h') + 1)..rect.IndexOf('v')]));
Assert.Equal(dark, drawn);
}
private static string Fingerprint(QrCode qr)
{
var builder = new StringBuilder(qr.Size * qr.Size);
for (var y = 0; y < qr.Size; y++) for (var x = 0; x < qr.Size; x++) builder.Append(qr[x, y] ? '1' : '0');
return Convert.ToHexString(SHA256.HashData(Encoding.ASCII.GetBytes(builder.ToString()))).ToLowerInvariant();
}
}
@@ -0,0 +1,211 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Modules;
using GameLauncher.Modules.Accounts;
using GameLauncher.Tests.Admin;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Accounts;
/// <summary>
/// Double authentification côté launcher (étape 8c), contre la vraie Launcher API : activation depuis « Mon compte » (QR code,
/// premier code, codes de secours à noter), connexion en deux étapes, code de secours, désactivation.
/// </summary>
public class TwoFactorTests
{
private const string Email = "joueur@example.fr";
private const string Password = PlayerTestServer.Password;
private static byte[] SecretOf(AccountWindowViewModel vm)
{
Assert.True(PlayerOtp.TryBase32Decode(vm.OtpSecretText, out var secret));
return secret;
}
[Fact]
public async Task Service_handles_the_two_step_sign_in()
{
await using var h = await AccountsHarness.StartAsync();
using var service = h.NewService();
await service.RegisterAsync(Email, Password, "1", remember: true);
var setup = await service.BeginOtpSetupAsync(Password);
Assert.True(PlayerOtp.TryBase32Decode(setup.Secret, out var secret));
var codes = await service.EnableOtpAsync(h.Server.OtpCode(secret));
Assert.Equal(10, codes.Count);
Assert.True((await service.GetMeAsync()).Account.OtpEnabled);
await service.SignOutAsync(allDevices: false);
var first = await service.SignInAsync(Email, Password, remember: true);
Assert.True(first.OtpRequired);
Assert.Equal(PlayerAccountState.SignedOut, service.State);
Assert.False(File.Exists(h.File.Path)); // rien n'est enregistré avant le code
var wrong = await Assert.ThrowsAsync<ApiException>(() => service.VerifyOtpAsync(first.OtpToken!, "Z000-0000", true));
Assert.Equal(ApiErrorCodes.OtpInvalid, wrong.Code);
Assert.Equal("Code incorrect, expiré ou déjà utilisé.", PlayerAccountTexts.Describe(wrong));
var done = await service.VerifyOtpAsync(first.OtpToken!, codes[0], remember: true);
Assert.False(done.OtpRequired);
Assert.Equal(9, done.RecoveryCodesLeft);
Assert.Equal(PlayerAccountState.SignedIn, service.State);
Assert.True(File.Exists(h.File.Path));
// La session mémorisée se renouvelle sans code (il n'est demandé qu'avec le mot de passe).
using var next = h.NewService();
await next.RestoreAsync();
Assert.Equal(PlayerAccountState.SignedIn, next.State);
}
[Fact]
public async Task Activation_from_my_account_shows_a_qr_code_then_the_recovery_codes()
{
await using var h = await AccountsHarness.StartAsync();
using var service = h.NewService();
await service.RegisterAsync(Email, Password, "1", remember: true);
var dialogs = new FakeDialogs();
using var vm = new AccountWindowViewModel(service, dialogs, ImmediateDispatcher.Instance, "LanisysGames");
await vm.InitializeAsync();
Assert.False(vm.OtpEnabled);
Assert.Contains("Désactivée", vm.TwoFactorStatusText);
vm.StartOtpSetupCommand.Execute(null);
Assert.True(vm.IsTwoFactorPassword);
await vm.BeginOtpSetupAsync("Pas-Le-Bon-Mot-De-Passe");
Assert.Equal("Mot de passe actuel incorrect.", vm.ErrorMessage);
Assert.True(vm.IsTwoFactorPassword);
await vm.BeginOtpSetupAsync(Password);
Assert.True(vm.IsTwoFactorScan, vm.ErrorMessage);
Assert.StartsWith("M4,4h7v1h-7z", vm.QrPathData); // repère du QR code, marge de 4 modules
Assert.Equal("LanisysGames · j***@e***.fr", vm.OtpAccountText);
Assert.Matches("^([A-Z2-7]{4} )+[A-Z2-7]{1,4}$", vm.OtpSecretText);
vm.CopySecretCommand.Execute(null);
Assert.Equal(vm.OtpSecretText.Replace(" ", ""), dialogs.Clipboard); // copiée sans espaces
var secret = SecretOf(vm);
vm.SetupCode = "12";
await vm.ConfirmOtpSetupAsync();
Assert.Contains("6 chiffres", vm.ErrorMessage);
vm.SetupCode = h.Server.OtpCode(secret);
await vm.ConfirmOtpSetupAsync();
Assert.True(vm.IsTwoFactorCodes, vm.ErrorMessage);
Assert.Null(vm.QrPathData); // secret oublié dès l'activation
Assert.Equal(10, vm.RecoveryCodes.Count);
Assert.True(vm.OtpEnabled);
// « Terminer » seulement une fois les codes enregistrés (ou la case cochée).
Assert.False(vm.FinishRecoveryCodesCommand.CanExecute(null));
dialogs.NextSaveFile = Path.Combine(h.DataDirectory, "codes.txt");
vm.SaveRecoveryCodesCommand.Execute(null);
var saved = await File.ReadAllTextAsync(dialogs.NextSaveFile);
Assert.All(vm.RecoveryCodes, code => Assert.Contains(code, saved));
Assert.True(vm.CodesSaved);
vm.FinishRecoveryCodesCommand.Execute(null);
Assert.True(vm.IsTwoFactorIdle);
Assert.Empty(vm.RecoveryCodes); // oubliés
Assert.Equal("Activée · 10 codes de secours restants", vm.TwoFactorStatusText);
}
[Fact]
public async Task Sign_in_window_asks_for_the_code_and_accepts_a_recovery_code()
{
await using var h = await AccountsHarness.StartAsync();
IReadOnlyList<string> codes;
byte[] secret;
using (var first = h.NewService())
{
await first.RegisterAsync(Email, Password, "1", remember: false);
var setup = await first.BeginOtpSetupAsync(Password);
PlayerOtp.TryBase32Decode(setup.Secret, out secret);
codes = await first.EnableOtpAsync(h.Server.OtpCode(secret));
}
h.Server.NextOtpStep();
using var service = h.NewService();
using var vm = new AccountWindowViewModel(service, new FakeDialogs(), ImmediateDispatcher.Instance, "LanisysGames");
await vm.InitializeAsync();
vm.Email = Email;
await vm.SignInAsync(Password);
Assert.Equal(AccountStep.Otp, vm.Step);
Assert.Equal("Code de vérification", vm.Title);
vm.OtpCode = "abc";
await vm.VerifyOtpAsync();
Assert.Contains("code à 6 chiffres", vm.ErrorMessage);
vm.OtpCode = h.Server.OtpCode(secret) == "000000" ? "111111" : "000000";
await vm.VerifyOtpAsync();
Assert.Equal("Code incorrect, expiré ou déjà utilisé.", vm.ErrorMessage);
Assert.Equal(AccountStep.Otp, vm.Step);
vm.OtpCode = codes[1].ToLowerInvariant();
await vm.VerifyOtpAsync();
Assert.Equal(AccountStep.Account, vm.Step);
Assert.Equal("Connecté avec un code de secours : il vous en reste 9.", vm.SuccessMessage);
Assert.Equal(9, vm.RecoveryCodesLeft);
}
[Fact]
public async Task Too_many_wrong_codes_send_the_player_back_to_the_password()
{
await using var h = await AccountsHarness.StartAsync();
byte[] secret;
using (var first = h.NewService())
{
await first.RegisterAsync(Email, Password, "1", remember: false);
var setup = await first.BeginOtpSetupAsync(Password);
PlayerOtp.TryBase32Decode(setup.Secret, out secret);
await first.EnableOtpAsync(h.Server.OtpCode(secret));
}
using var service = h.NewService();
using var vm = new AccountWindowViewModel(service, new FakeDialogs(), ImmediateDispatcher.Instance, "LanisysGames");
await vm.InitializeAsync();
vm.Email = Email;
await vm.SignInAsync(Password);
var wrong = h.Server.OtpCode(secret) == "000000" ? "111111" : "000000";
for (var i = 0; i < 6 && vm.Step == AccountStep.Otp; i++)
{
vm.OtpCode = wrong;
await vm.VerifyOtpAsync();
}
Assert.Equal(AccountStep.SignIn, vm.Step);
Assert.Contains("reconnectez-vous", vm.ErrorMessage);
}
[Fact]
public async Task Disabling_needs_password_code_and_confirmation()
{
await using var h = await AccountsHarness.StartAsync();
using var service = h.NewService();
await service.RegisterAsync(Email, Password, "1", remember: true);
var setup = await service.BeginOtpSetupAsync(Password);
PlayerOtp.TryBase32Decode(setup.Secret, out var secret);
await service.EnableOtpAsync(h.Server.OtpCode(secret));
h.Server.NextOtpStep();
var dialogs = new FakeDialogs { ConfirmAnswer = false };
using var vm = new AccountWindowViewModel(service, dialogs, ImmediateDispatcher.Instance, "LanisysGames");
await vm.InitializeAsync();
// Nouveaux codes de secours.
vm.StartRegenerateCodesCommand.Execute(null);
Assert.Equal("Créer de nouveaux codes de secours", vm.TwoFactorConfirmTitle);
vm.ConfirmCode = h.Server.OtpCode(secret);
await vm.ConfirmTwoFactorAsync(Password);
Assert.True(vm.IsTwoFactorCodes, vm.ErrorMessage);
vm.CodesSaved = true;
vm.FinishRecoveryCodesCommand.Execute(null);
h.Server.NextOtpStep();
// Désactivation : refusée par la boîte de confirmation, puis acceptée.
vm.StartOtpDisableCommand.Execute(null);
Assert.True(vm.IsTwoFactorDisable);
vm.ConfirmCode = h.Server.OtpCode(secret);
await vm.ConfirmTwoFactorAsync(Password);
Assert.True(vm.OtpEnabled);
dialogs.ConfirmAnswer = true;
await vm.ConfirmTwoFactorAsync(Password);
Assert.False(vm.OtpEnabled, vm.ErrorMessage);
Assert.True(vm.IsTwoFactorIdle);
Assert.Equal("Double authentification désactivée.", vm.SuccessMessage);
}
}
@@ -0,0 +1,172 @@
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.Players;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Admin;
/// <summary>Page « Comptes joueurs » du menu d'administration (étape 8d), contre un vrai serveur avec les comptes activés.</summary>
public class PlayersPanelTests
{
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new PlayersAdminModule() });
private static async Task<(AdminShellViewModel Shell, PlayersPanelViewModel Page)> OpenAsync(PlayerTestServer server, TempFolder folder,
FakeDialogs dialogs)
{
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
AdminTestKit.RegisterOwner(server.Api, signer);
signer.Dispose();
var shell = new AdminShellViewModel(AdminTestKit.Environment(server.Api, folder.Path, dialogs, Modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(PlayersPanelViewModel.PanelId);
await workspace.Activation;
return (shell, Assert.IsType<PlayersPanelViewModel>(workspace.SelectedPanel!.ViewModel));
}
[Fact]
public async Task Page_finds_a_player_then_disables_enables_and_creates_a_temporary_password()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
using var other = new TestDevice();
await server.RegisterAsync(device, "camille@exemple.fr");
server.Api.Time.Advance(TimeSpan.FromMinutes(1));
await server.RegisterAsync(other, "leo@exemple.fr");
using var folder = new TempFolder();
var dialogs = new FakeDialogs();
var (shell, page) = await OpenAsync(server, folder, dialogs);
await using var _ = shell;
Assert.False(page.HasError, page.ErrorMessage);
Assert.True(page.IsServiceEnabled);
Assert.Equal(2, page.Items.Count);
Assert.Equal("l***@e***.fr", page.Items[0].EmailMasked); // le plus récent d'abord
Assert.Contains(page.Counters, c => c.Label == "comptes" && c.Value == "2");
Assert.Equal("2 comptes", page.CountText);
Assert.True(page.CanManage);
Assert.True(page.CanDelete);
// Recherche par adresse exacte : la fiche s'ouvre d'elle-même.
page.SearchText = "CAMILLE@exemple.fr";
await page.SearchCommand.ExecuteAsync();
Assert.True(page.IsSearchResult);
Assert.Equal("Compte trouvé par son adresse e-mail", page.CountText);
var detail = Assert.IsType<PlayerDetailView>(page.Detail);
Assert.Equal("c***@e***.fr", detail.EmailMasked);
Assert.Single(detail.Devices);
Assert.Contains(detail.Facts, f => f.Label == "Double authentification" && f.Value == "désactivée");
Assert.Contains(detail.Events, e => e.Label == "Création du compte" && e.OriginText.StartsWith("IP "));
detail.CopyReferenceCommand.Execute(null);
Assert.Equal(detail.Reference, dialogs.Clipboard);
// Désactivation : motif contrôlé en direct (lien refusé), confirmation, fiche à jour.
page.OpenDisableCommand.Execute(null);
Assert.True(page.IsDisableFormOpen);
page.DisableReason = "Voir www.exemple.com";
Assert.True(page.HasReasonProblem);
Assert.False(page.DisableCommand.CanExecute(null));
page.DisableReason = "Triche en partie en ligne";
page.DisableDuration = page.DisableDurations.Single(d => d.Value == "7");
await page.DisableCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains(dialogs.Confirmations, c => c.StartsWith("Désactiver le compte"));
Assert.True(page.Detail!.IsDisabled);
Assert.Equal("Triche en partie en ligne", page.Detail.StatusReason);
Assert.False(page.IsDisableFormOpen);
Assert.StartsWith("Désactivé jusqu'au", page.Items[0].StatusLabel);
Assert.False(page.TemporaryPasswordCommand.CanExecute(null)); // compte désactivé : d'abord le réactiver
Assert.Contains(page.Counters, c => c.Label == "désactivé");
await page.EnableCommand.ExecuteAsync();
Assert.True(page.Detail!.IsActive);
// Mot de passe temporaire : affiché une fois, copiable, oublié dès qu'on change de compte.
await page.TemporaryPasswordCommand.ExecuteAsync();
Assert.True(page.HasTemporaryPassword);
Assert.Equal(19, page.TemporaryPassword!.Length);
Assert.Contains(page.Detail!.Facts, f => f.Label == "Mot de passe temporaire" && f.Warning);
page.CopyTemporaryPasswordCommand.Execute(null);
Assert.Equal(page.TemporaryPassword, dialogs.Clipboard);
var login = await server.LoginAsync(device, "camille@exemple.fr", page.TemporaryPassword);
Assert.True(login.TemporaryPassword);
await page.ClearSearchCommand.ExecuteAsync();
Assert.False(page.IsSearchResult);
Assert.False(page.HasTemporaryPassword);
Assert.Equal(2, page.Items.Count);
// Suppression (confirmée) : retirée de la liste.
page.SelectedItem = page.Items[0];
await page.DetailLoad;
await page.DeleteCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Single(page.Items);
Assert.Contains(dialogs.Confirmations, c => c.StartsWith("Supprimer définitivement le compte"));
Assert.Null(server.Store.FindByReference(page.StatusMessage!.Split(' ')[1]));
}
[Fact]
public async Task Filters_and_refusals_keep_the_page_consistent()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var reference = (await server.RegisterAsync(device, "camille@exemple.fr")).Account.Reference;
server.Store.SetStatus(server.Store.FindByReference(reference)!.Id, "suspended", null, null, "système (tests)", server.Api.Time.GetUtcNow());
using var folder = new TempFolder();
var dialogs = new FakeDialogs();
var (shell, page) = await OpenAsync(server, folder, dialogs);
await using var _ = shell;
page.Filter = page.Filters.Single(f => f.Value == PlayerAccountStatuses.Disabled);
await page.ListLoad;
Assert.Empty(page.Items);
Assert.Equal("Aucun compte pour ce filtre.", page.EmptyText);
page.Filter = page.Filters.Single(f => f.Value == PlayerAccountStatuses.Suspended);
await page.ListLoad;
page.SelectedItem = Assert.Single(page.Items);
await page.DetailLoad;
Assert.True(page.Detail!.CanUnlock);
Assert.Equal("Lever la suspension", page.Detail.UnlockText);
// Confirmation refusée : rien n'est fait.
dialogs.ConfirmAnswer = false;
await page.ResetOtpCommand.ExecuteAsync(); // double authentification non activée : commande inactive
await page.LogoutAllCommand.ExecuteAsync();
Assert.Single(page.Detail!.Devices);
dialogs.ConfirmAnswer = true;
await page.UnlockCommand.ExecuteAsync();
Assert.True(page.Detail!.IsActive);
Assert.Equal($"Suspension du compte {reference} levée.", page.StatusMessage);
// Recherche sans résultat.
page.SearchText = "personne@exemple.fr";
await page.SearchCommand.ExecuteAsync();
Assert.Empty(page.Items);
Assert.Equal("Aucun compte ne correspond à cette référence ou à cette adresse.", page.EmptyText);
}
[Fact]
public void Player_audit_actions_have_labels_and_their_own_category()
{
foreach (var action in new[] { "players.view", "players.search", "players.disable", "players.enable", "players.unlock",
"players.password_required", "players.temporary_password", "players.otp_reset", "players.logout",
"players.delete", "players.suspend", "players.self_delete", "players.inactive", "players.events_purge",
"cli.players_unlock", "cli.players_otp_reset" })
Assert.NotEqual(action, AuditTexts.Action(action));
Assert.True(AuditTexts.InCategory("players.disable", "players."));
Assert.True(AuditTexts.InCategory("cli.players_disable", "players."));
Assert.False(AuditTexts.InCategory("feedback.view", "players."));
foreach (var type in new[] { "account.password_reset_required", "account.temporary_password", "account.temporary_password_used",
"device.removed_by_team" })
Assert.True(PlayerActivityText.IsKnown(type), type);
Assert.Contains(AdminPermissions.PlayersDelete, AdminRoles.PermissionsOf(AdminRoles.Owner));
Assert.DoesNotContain(AdminPermissions.PlayersDelete, AdminRoles.PermissionsOf(AdminRoles.Admin));
Assert.Contains(AdminPermissions.PlayersRead, AdminRoles.PermissionsOf(AdminRoles.Admin));
}
}
@@ -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,81 @@
using System.Text;
using GameLauncher.Contracts.Accounts;
namespace GameLauncher.Tests.Contracts;
/// <summary>Double authentification : TOTP (vecteurs officiels de la RFC 6238), Base32 (RFC 4648), codes de secours.</summary>
public class PlayerOtpTests
{
private static readonly byte[] RfcSecret = Encoding.ASCII.GetBytes("12345678901234567890");
[Theory]
[InlineData(59, "94287082")]
[InlineData(1111111109, "07081804")]
[InlineData(1111111111, "14050471")]
[InlineData(1234567890, "89005924")]
[InlineData(2000000000, "69279037")]
[InlineData(20000000000, "65353130")]
public void Totp_matches_rfc6238_sha1_vectors(long unixSeconds, string expected)
{
var step = PlayerOtp.Step(DateTimeOffset.FromUnixTimeSeconds(unixSeconds));
Assert.Equal(expected, PlayerOtp.Code(RfcSecret, step, 8));
Assert.Equal(expected[2..], PlayerOtp.Code(RfcSecret, step)); // 6 chiffres : les 6 derniers
}
[Theory]
[InlineData("", "")]
[InlineData("f", "MY")]
[InlineData("fo", "MZXQ")]
[InlineData("foo", "MZXW6")]
[InlineData("foob", "MZXW6YQ")]
[InlineData("fooba", "MZXW6YTB")]
[InlineData("foobar", "MZXW6YTBOI")]
public void Base32_matches_rfc4648(string text, string expected)
{
Assert.Equal(expected, PlayerOtp.Base32Encode(Encoding.ASCII.GetBytes(text)));
if (expected.Length == 0) return;
Assert.True(PlayerOtp.TryBase32Decode(expected.ToLowerInvariant() + "==", out var decoded));
Assert.Equal(text, Encoding.ASCII.GetString(decoded));
}
[Fact]
public void Base32_refuses_foreign_characters() => Assert.False(PlayerOtp.TryBase32Decode("MZXW1", out _));
[Theory]
[InlineData("123456", "123456")]
[InlineData(" 123 456 ", "123456")]
[InlineData("123-456", "123456")]
[InlineData("12345", null)]
[InlineData("1234567", null)]
[InlineData("12a456", null)]
[InlineData(null, null)]
public void Codes_are_normalized(string? input, string? expected) => Assert.Equal(expected, PlayerOtp.NormalizeCode(input));
[Theory]
[InlineData("7kq2-m9xd", "7KQ2M9XD")]
[InlineData(" 7KQ2 M9XD ", "7KQ2M9XD")]
[InlineData("OIL2-M9XD", "0112M9XD")]
[InlineData("7KQ2-M9X", null)]
[InlineData("7KQ2-M9XU", null)]
[InlineData("123456", null)]
public void Recovery_codes_are_normalized(string input, string? expected) =>
Assert.Equal(expected, PlayerOtp.NormalizeRecoveryCode(input));
[Fact]
public void New_recovery_codes_are_well_formed_and_random()
{
var codes = Enumerable.Range(0, 200).Select(_ => PlayerOtp.NewRecoveryCode()).ToList();
Assert.All(codes, c => Assert.Matches("^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$", c));
Assert.All(codes, c => Assert.NotNull(PlayerOtp.NormalizeRecoveryCode(c)));
Assert.Equal(codes.Count, codes.Distinct().Count());
}
[Fact]
public void Auth_uri_follows_the_key_uri_format()
{
var uri = PlayerOtp.AuthUri("LanisysGames", "j***@e***.fr", "JBSWY3DPEHPK3PXP");
Assert.Equal("otpauth://totp/LanisysGames:j%2A%2A%2A%40e%2A%2A%2A.fr?secret=JBSWY3DPEHPK3PXP&issuer=LanisysGames" +
"&algorithm=SHA1&digits=6&period=30", uri);
Assert.Equal("JBSW Y3DP EHPK 3PXP", PlayerOtp.FormatSecret("JBSWY3DPEHPK3PXP"));
}
}
@@ -7,6 +7,8 @@
et attaquée comme le ferait un client réel ou malveillant.
• Admin : module d'administration du launcher (clés, connexion, invitation, verrouillage,
écrans) contre ce même serveur, sans interface graphique.
• Accounts : compte joueur du launcher (session mémorisée chiffrée, renouvellement, fenêtre « Compte »)
contre ce même serveur, sans interface graphique.
• Website : site vitrine (signature de launcher-update.json, manifests, liseuse Markdown).
-->
<PropertyGroup>
@@ -34,6 +36,7 @@
<ProjectReference Include="..\..\server\GameLauncher.Server\GameLauncher.Server.csproj" />
<ProjectReference Include="..\..\src\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj" />
<ProjectReference Include="..\..\src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj" />
<ProjectReference Include="..\..\src\GameLauncher.Modules.Accounts\GameLauncher.Modules.Accounts.csproj" />
</ItemGroup>
<!--
@@ -0,0 +1,234 @@
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))!;
}
// ------------------------------------------------------------------ Double authentification (étape 8c)
/// <summary>Active la double authentification (préparation, puis premier code) : secret et codes de secours.</summary>
public async Task<(byte[] Secret, IReadOnlyList<string> RecoveryCodes)> EnableOtpAsync(string accessToken, string password = Password)
{
using var setup = await SendAsync(HttpMethod.Post, ApiRoutes.AccountOtpSetup, accessToken, new PlayerPasswordConfirmation { Password = password });
Assert.True(setup.IsSuccessStatusCode, $"préparation : {(int)setup.StatusCode} {await setup.Content.ReadAsStringAsync()}");
var config = (await setup.Content.ReadFromJsonAsync<PlayerOtpSetupResponse>(ApiJson.Options))!;
Assert.True(PlayerOtp.TryBase32Decode(config.Secret, out var secret));
using var enable = await SendAsync(HttpMethod.Post, ApiRoutes.AccountOtpEnable, accessToken, new PlayerOtpCodeRequest { Code = OtpCode(secret) });
Assert.True(enable.IsSuccessStatusCode, $"activation : {(int)enable.StatusCode} {await enable.Content.ReadAsStringAsync()}");
var codes = (await enable.Content.ReadFromJsonAsync<PlayerRecoveryCodesResponse>(ApiJson.Options))!;
return (secret, codes.RecoveryCodes);
}
/// <summary>Code de l'« application d'authentification » à l'heure (simulée) du serveur, décalé de <paramref name="steps"/> pas.</summary>
public string OtpCode(byte[] secret, int steps = 0) => PlayerOtp.Code(secret, PlayerOtp.Step(Api.Time.GetUtcNow()) + steps);
/// <summary>Avance l'heure simulée jusqu'au pas de temps suivant (un code ne sert qu'une fois par pas).</summary>
public void NextOtpStep() => Api.Time.Advance(TimeSpan.FromSeconds(PlayerOtp.PeriodSeconds));
public async Task<HttpResponseMessage> LoginOtpRawAsync(string otpToken, string code) =>
await SendAsync(HttpMethod.Post, ApiRoutes.AccountLoginOtp, otpToken, new PlayerOtpCodeRequest { Code = code });
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,321 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Cli;
using GameLauncher.Server.Storage.Players;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Server;
/// <summary>
/// Double authentification des comptes joueurs (étape 8c) : activation en deux temps, connexion en deux étapes, codes à
/// usage unique (rejeu refusé), codes de secours, échecs comptés comme des échecs de connexion, désactivation, nouveaux
/// codes, secret jamais exporté, réinitialisation en ligne de commande.
/// </summary>
public class PlayerOtpServerTests
{
private const string Email = "joueur@example.fr";
private static async Task<(PlayerTestServer Server, TestDevice Device, PlayerSession Session, byte[] Secret, IReadOnlyList<string> Codes)> SetupAsync(
IDictionary<string, string?>? settings = null)
{
var server = await PlayerTestServer.StartAsync(settings);
var device = new TestDevice();
var session = await server.RegisterAsync(device, Email);
var (secret, codes) = await server.EnableOtpAsync(session.AccessToken);
server.NextOtpStep();
return (server, device, session, secret, codes);
}
[Fact]
public async Task Activation_needs_the_password_then_a_first_code_and_returns_ten_recovery_codes()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var session = await server.RegisterAsync(device, Email);
using (var wrong = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountOtpSetup, session.AccessToken,
new PlayerPasswordConfirmation { Password = "Pas-Le-Bon-Mot-De-Passe" }))
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
using var setup = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountOtpSetup, session.AccessToken,
new PlayerPasswordConfirmation { Password = PlayerTestServer.Password });
var config = (await setup.Content.ReadFromJsonAsync<PlayerOtpSetupResponse>(ApiJson.Options))!;
Assert.True(PlayerOtp.TryBase32Decode(config.Secret, out var secret));
Assert.Equal(PlayerOtp.SecretBytes, secret.Length);
Assert.Equal("LanisysGames", config.Issuer);
Assert.Equal("j***@e***.fr", config.AccountName);
Assert.StartsWith("otpauth://totp/LanisysGames:", config.Uri);
Assert.Contains("secret=" + config.Secret, config.Uri);
// Pas encore activée : la connexion ne demande rien de plus (nouvelle session sur ce même appareil).
var relogin = await server.LoginAsync(device, Email);
Assert.Equal(PlayerLoginStatus.Ok, relogin.Status);
session = relogin.Session!;
using (var bad = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountOtpEnable, session.AccessToken,
new PlayerOtpCodeRequest { Code = "000000" == server.OtpCode(secret) ? "111111" : "000000" }))
Assert.Equal(ApiErrorCodes.OtpInvalid, (await TestApi.ReadErrorAsync(bad)).Code);
using var enable = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountOtpEnable, session.AccessToken,
new PlayerOtpCodeRequest { Code = server.OtpCode(secret, -1) }); // téléphone en retard de 30 s : accepté
Assert.Equal(HttpStatusCode.OK, enable.StatusCode);
var codes = (await enable.Content.ReadFromJsonAsync<PlayerRecoveryCodesResponse>(ApiJson.Options))!.RecoveryCodes;
Assert.Equal(PlayerOtp.RecoveryCodeCount, codes.Count);
Assert.Equal(codes.Count, codes.Distinct().Count());
var me = await server.MeAsync(session.AccessToken);
Assert.True(me.Account.OtpEnabled);
Assert.Equal(10, me.Account.RecoveryCodesLeft);
// Déjà activée : une nouvelle préparation est refusée.
using var again = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountOtpSetup, session.AccessToken,
new PlayerPasswordConfirmation { Password = PlayerTestServer.Password });
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
}
[Fact]
public async Task Setup_expires_after_ten_minutes()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var session = await server.RegisterAsync(device, Email);
using var setup = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountOtpSetup, session.AccessToken,
new PlayerPasswordConfirmation { Password = PlayerTestServer.Password });
var config = (await setup.Content.ReadFromJsonAsync<PlayerOtpSetupResponse>(ApiJson.Options))!;
PlayerOtp.TryBase32Decode(config.Secret, out var secret);
server.Api.Time.Advance(TimeSpan.FromMinutes(11));
var fresh = (await server.LoginAsync(device, Email)).Session!.AccessToken; // jeton d'accès échu entre-temps
using var enable = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountOtpEnable, fresh,
new PlayerOtpCodeRequest { Code = server.OtpCode(secret) });
var error = await TestApi.ReadErrorAsync(enable);
Assert.Equal(ApiErrorCodes.OtpInvalid, error.Code);
Assert.Contains("expirée", error.Message);
}
[Fact]
public async Task Login_asks_for_the_code_and_a_code_works_only_once()
{
var (server, device, session, secret, _) = await SetupAsync();
await using var _s = server;
using var _d = device;
var first = await server.LoginAsync(device, Email);
Assert.Equal(PlayerLoginStatus.OtpRequired, first.Status);
Assert.Null(first.Session);
Assert.StartsWith(PlayerTokens.OtpPrefix, first.OtpToken);
var accountId = server.Store.FindByReference(session.Account.Reference)!.Id;
// Le jeton de la 2ᵉ étape n'ouvre rien d'autre.
using (var me = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, first.OtpToken))
{
Assert.Equal(HttpStatusCode.Forbidden, me.StatusCode);
Assert.Equal(ApiErrorCodes.OtpRequired, (await TestApi.ReadErrorAsync(me)).Code);
}
var code = server.OtpCode(secret);
using (var ok = await server.LoginOtpRawAsync(first.OtpToken!, code))
{
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
var body = (await ok.Content.ReadFromJsonAsync<PlayerLoginResponse>(ApiJson.Options))!;
Assert.Equal(PlayerLoginStatus.Ok, body.Status);
Assert.Null(body.RecoveryCodesLeft);
Assert.True((await server.MeAsync(body.Session!.AccessToken)).Account.OtpEnabled);
}
Assert.Contains(server.Store.Events(accountId, 10), e => e.Type == PlayerEvents.OtpChallenge);
// Le même code (même pas de temps), sur une nouvelle connexion : refusé (rejeu).
var second = await server.LoginAsync(device, Email);
using (var replay = await server.LoginOtpRawAsync(second.OtpToken!, code))
Assert.Equal(ApiErrorCodes.OtpInvalid, (await TestApi.ReadErrorAsync(replay)).Code);
// Le jeton de la 1ʳᵉ connexion est consommé.
using (var reused = await server.LoginOtpRawAsync(first.OtpToken!, code))
Assert.Equal(HttpStatusCode.Unauthorized, reused.StatusCode);
server.NextOtpStep();
using (var next = await server.LoginOtpRawAsync(second.OtpToken!, server.OtpCode(secret)))
Assert.Equal(HttpStatusCode.OK, next.StatusCode);
}
[Fact]
public async Task Recovery_codes_work_once_and_the_remaining_count_is_returned()
{
var (server, device, _, _, codes) = await SetupAsync();
await using var _s = server;
using var _d = device;
var login = await server.LoginAsync(device, Email);
using (var ok = await server.LoginOtpRawAsync(login.OtpToken!, codes[3].ToLowerInvariant().Replace("-", " ")))
{
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
var body = (await ok.Content.ReadFromJsonAsync<PlayerLoginResponse>(ApiJson.Options))!;
Assert.Equal(9, body.RecoveryCodesLeft);
Assert.Equal(9, (await server.MeAsync(body.Session!.AccessToken)).Account.RecoveryCodesLeft);
}
var again = await server.LoginAsync(device, Email);
using var reused = await server.LoginOtpRawAsync(again.OtpToken!, codes[3]);
Assert.Equal(ApiErrorCodes.OtpInvalid, (await TestApi.ReadErrorAsync(reused)).Code);
}
[Fact]
public async Task Wrong_codes_count_as_login_failures_and_the_token_dies_after_five_tries()
{
var (server, device, session, secret, _) = await SetupAsync(new Dictionary<string, string?>
{
["LauncherApi:Players:TrustedFailuresPerLock"] = "4"
});
await using var _s = server;
using var _d = device;
var wrongCode = server.OtpCode(secret) == "000000" ? "111111" : "000000";
// Appareil de confiance : blocage après 4 échecs (réglage du test).
var login = await server.LoginAsync(device, Email);
for (var i = 0; i < 3; i++)
{
using var wrong = await server.LoginOtpRawAsync(login.OtpToken!, wrongCode);
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
}
using (var locked = await server.LoginOtpRawAsync(login.OtpToken!, wrongCode))
Assert.Equal(HttpStatusCode.TooManyRequests, locked.StatusCode);
var account = server.Store.FindByReference(session.Account.Reference)!;
Assert.NotNull(account.TrustedLockedUntil);
Assert.Contains(server.Store.Events(account.Id, 20), e => e.Type == PlayerEvents.OtpFailed);
// Après le blocage : 5 essais au plus par jeton.
server.Api.Time.Advance(TimeSpan.FromMinutes(20));
var next = await server.LoginAsync(device, Email);
for (var i = 0; i < 3; i++)
{
using var wrong = await server.LoginOtpRawAsync(next.OtpToken!, wrongCode);
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
}
server.Store.RecordSuccess(account.Id, server.Api.Time.GetUtcNow(), null); // compteurs remis à zéro : seul le jeton compte
using (var fourth = await server.LoginOtpRawAsync(next.OtpToken!, wrongCode)) Assert.Equal(HttpStatusCode.BadRequest, fourth.StatusCode);
using (var fifth = await server.LoginOtpRawAsync(next.OtpToken!, wrongCode)) Assert.Equal(HttpStatusCode.BadRequest, fifth.StatusCode);
using (var sixth = await server.LoginOtpRawAsync(next.OtpToken!, server.OtpCode(secret)))
Assert.Equal(HttpStatusCode.Unauthorized, sixth.StatusCode);
}
[Fact]
public async Task Account_state_is_revealed_only_after_the_code()
{
var (server, device, session, secret, _) = await SetupAsync();
await using var _s = server;
using var _d = device;
var account = server.Store.FindByReference(session.Account.Reference)!;
server.Store.SetStatus(account.Id, PlayerStatus.Disabled, null, null, "tests", server.Api.Time.GetUtcNow());
var login = await server.LoginAsync(device, Email);
Assert.Equal(PlayerLoginStatus.OtpRequired, login.Status); // rien n'est révélé avec le seul mot de passe
using var verify = await server.LoginOtpRawAsync(login.OtpToken!, server.OtpCode(secret));
Assert.Equal(HttpStatusCode.Forbidden, verify.StatusCode);
Assert.Equal(ApiErrorCodes.AccountDisabled, (await TestApi.ReadErrorAsync(verify)).Code);
}
[Fact]
public async Task Required_password_change_comes_after_the_code()
{
var (server, device, session, secret, _) = await SetupAsync();
await using var _s = server;
using var _d = device;
server.Store.SetMustChangePassword(server.Store.FindByReference(session.Account.Reference)!.Id, true);
var login = await server.LoginAsync(device, Email);
using var verify = await server.LoginOtpRawAsync(login.OtpToken!, server.OtpCode(secret));
var body = (await verify.Content.ReadFromJsonAsync<PlayerLoginResponse>(ApiJson.Options))!;
Assert.Equal(PlayerLoginStatus.PasswordChangeRequired, body.Status);
Assert.StartsWith(PlayerTokens.PasswordChangePrefix, body.PasswordChangeToken);
}
[Fact]
public async Task Disabling_and_new_recovery_codes_need_password_and_code()
{
var (server, device, session, secret, codes) = await SetupAsync();
await using var _s = server;
using var _d = device;
var access = session.AccessToken;
using (var noCode = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountOtpDisable, access,
new PlayerOtpConfirmRequest { Password = PlayerTestServer.Password, Code = "" }))
Assert.Equal(ApiErrorCodes.OtpInvalid, (await TestApi.ReadErrorAsync(noCode)).Code);
using (var noPassword = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountOtpDisable, access,
new PlayerOtpConfirmRequest { Password = "Pas-Le-Bon-Mot-De-Passe", Code = server.OtpCode(secret) }))
Assert.Equal(ApiErrorCodes.LoginFailed, (await TestApi.ReadErrorAsync(noPassword)).Code);
// Nouveaux codes de secours : les anciens ne valent plus rien.
using var regenerate = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountOtpRecoveryCodes, access,
new PlayerOtpConfirmRequest { Password = PlayerTestServer.Password, Code = server.OtpCode(secret) });
var fresh = (await regenerate.Content.ReadFromJsonAsync<PlayerRecoveryCodesResponse>(ApiJson.Options))!.RecoveryCodes;
Assert.Equal(10, fresh.Count);
Assert.Empty(fresh.Intersect(codes));
var login = await server.LoginAsync(device, Email);
using (var old = await server.LoginOtpRawAsync(login.OtpToken!, codes[0]))
Assert.Equal(ApiErrorCodes.OtpInvalid, (await TestApi.ReadErrorAsync(old)).Code);
// Désactivation avec un code de secours.
using (var disable = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountOtpDisable, access,
new PlayerOtpConfirmRequest { Password = PlayerTestServer.Password, Code = fresh[0] }))
Assert.Equal(HttpStatusCode.NoContent, disable.StatusCode);
var plain = await server.LoginAsync(device, Email);
Assert.Equal(PlayerLoginStatus.Ok, plain.Status);
var me = await server.MeAsync(plain.Session!.AccessToken);
Assert.False(me.Account.OtpEnabled);
Assert.Equal(0, me.Account.RecoveryCodesLeft);
var stored = server.Store.ReadOtp(server.Store.FindByReference(session.Account.Reference)!.Id);
Assert.Null(stored.SecretEncrypted);
Assert.Null(stored.PendingEncrypted);
}
[Fact]
public async Task Secret_and_recovery_codes_are_never_stored_in_clear_nor_exported()
{
var (server, device, session, secret, codes) = await SetupAsync();
await using var _s = server;
using var _d = device;
var base32 = PlayerOtp.Base32Encode(secret);
server.Store.Dispose(); // base fermée pour la relire telle quelle sur le disque
var text = string.Concat(Directory.GetFiles(Path.GetDirectoryName(server.Store.DatabasePath)!)
.Select(f => System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(f)))); // base, journal WAL éventuel
Assert.DoesNotContain(base32, text);
foreach (var code in codes)
{
Assert.DoesNotContain(code, text);
Assert.DoesNotContain(code.Replace("-", ""), text);
}
}
[Fact]
public async Task Export_mentions_two_factor_without_its_secrets()
{
var (server, device, session, secret, codes) = await SetupAsync();
await using var _s = server;
using var _d = device;
using var response = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountExport, session.AccessToken,
new PlayerPasswordConfirmation { Password = PlayerTestServer.Password });
var text = await response.Content.ReadAsStringAsync();
using var json = JsonDocument.Parse(text);
Assert.True(json.RootElement.GetProperty("account").GetProperty("twoFactorEnabled").GetBoolean());
Assert.Equal(10, json.RootElement.GetProperty("account").GetProperty("recoveryCodesLeft").GetInt32());
Assert.DoesNotContain(PlayerOtp.Base32Encode(secret), text);
Assert.DoesNotContain(codes[0], text);
}
[Fact]
public async Task Command_line_reset_disables_two_factor()
{
var (server, device, session, _, _) = await SetupAsync();
await using var _s = server;
using var _d = device;
var output = new StringWriter();
Assert.Equal(0, AdminCli.Run(new[] { "players-otp-reset", "--ref", session.Account.Reference }, output, server.Api.DataDirectory));
Assert.Equal(PlayerLoginStatus.Ok, (await server.LoginAsync(device, Email)).Status);
var account = server.Store.FindByReference(session.Account.Reference)!;
Assert.Contains(server.Store.Events(account.Id, 10), e => e.Type == PlayerEvents.OtpReset);
var audit = string.Concat(Directory.GetFiles(Path.Combine(server.Api.DataDirectory, "audit"), "audit-*.jsonl").Select(File.ReadAllText));
Assert.Contains("cli.players_otp_reset", audit);
}
}
@@ -0,0 +1,284 @@
using System.Net;
using System.Net.Http.Json;
using System.Reflection;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Modules.Accounts;
using GameLauncher.Server.Storage.Players;
using GameLauncher.Tests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace GameLauncher.Tests.Server;
/// <summary>
/// Comptes joueurs, étape 8b (RGPD) : activité récente (sans détail interne, adresses IP masquées), export de toutes ses
/// données (mot de passe exigé), suppression définitive du compte par son titulaire, comptes inactifs (mise en sommeil,
/// réveil par une connexion, suppression).
/// </summary>
public class PlayerPrivacyTests
{
private const string Email = "joueur@example.fr";
[Fact]
public async Task Activity_lists_recent_events_with_masked_addresses_and_no_internal_details()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var session = await server.RegisterAsync(device, Email);
using var stranger = new TestDevice("Windows 10 · autre PC");
using (var failed = await server.LoginRawAsync(stranger, Email, "Mauvais-Mot-De-Passe-1"))
Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode);
using var response = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountActivity, session.AccessToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
var activity = JsonSerializer.Deserialize<PlayerActivityResponse>(body, ApiJson.Options)!;
Assert.Equal(2, activity.Entries.Count);
var failure = activity.Entries[0];
Assert.Equal("account.login_failed", failure.Type);
Assert.False(failure.Success);
Assert.False(failure.ThisDevice);
Assert.Null(failure.Device); // appareil inconnu : aucun nom
Assert.Equal("127.0.0.x", failure.Ip);
var created = activity.Entries[1];
Assert.Equal("account.register", created.Type);
Assert.True(created.Success);
Assert.True(created.ThisDevice);
Assert.Equal(device.Label, created.Device);
// Aucun détail interne (« appareil inconnu », « mot de passe incorrect »…) ni adresse complète.
Assert.DoesNotContain("details", body, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("127.0.0.1", body);
Assert.DoesNotContain(device.KeyId, body);
}
[Theory]
[InlineData("203.0.113.42", "203.0.113.x")]
[InlineData("::ffff:198.51.100.7", "198.51.100.x")]
[InlineData("2001:db8:85a3:1234:5678:8a2e:370:7334", "2001:db8:85a3::/48")]
[InlineData("pas une adresse", null)]
[InlineData(null, null)]
public void Ip_addresses_are_masked_for_the_player(string? ip, string? expected) =>
Assert.Equal(expected, AccountsModule.MaskIp(ip));
[Fact]
public void Every_account_event_type_has_a_french_label()
{
var types = typeof(PlayerEvents).GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.IsLiteral).Select(f => (string)f.GetRawConstantValue()!).ToList();
Assert.True(types.Count >= 19);
foreach (var type in types) Assert.True(PlayerActivityText.IsKnown(type), $"« {type} » sans libellé (PlayerActivityText)");
Assert.Equal("type.inconnu", PlayerActivityText.Describe("type.inconnu", true));
}
[Fact]
public async Task Export_requires_the_password_and_contains_everything_but_secrets()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var session = await server.RegisterAsync(device, "Joueur@Example.fr");
using (var wrong = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountExport, session.AccessToken,
new PlayerPasswordConfirmation { Password = "Pas-Le-Bon-Mot-De-Passe" }))
{
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
Assert.Equal(ApiErrorCodes.LoginFailed, (await TestApi.ReadErrorAsync(wrong)).Code);
}
using (var missing = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountExport, session.AccessToken, new { }))
Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode);
using (var anonymous = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountExport, null,
new PlayerPasswordConfirmation { Password = PlayerTestServer.Password }))
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
using var response = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountExport, session.AccessToken,
new PlayerPasswordConfirmation { Password = PlayerTestServer.Password });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
var text = await response.Content.ReadAsStringAsync();
using var json = JsonDocument.Parse(text);
var root = json.RootElement;
Assert.Equal(PlayerDataExport.Format, root.GetProperty("format").GetString());
Assert.Equal(server.Api.Origin, root.GetProperty("service").GetString());
var account = root.GetProperty("account");
Assert.Equal(session.Account.Reference, account.GetProperty("reference").GetString());
Assert.Equal("joueur@example.fr", account.GetProperty("email").GetString()); // adresse complète (normalisée)
Assert.Equal(device.Label, root.GetProperty("devices")[0].GetProperty("label").GetString());
var types = root.GetProperty("activity").EnumerateArray().Select(e => e.GetProperty("type").GetString()).ToList();
Assert.Contains("account.register", types);
Assert.Contains("account.export", types); // l'essai refusé est inscrit
Assert.Equal(JsonValueKind.Object, root.GetProperty("modules").ValueKind);
// Jamais l'empreinte du mot de passe, ni les secrets des sessions, ni la clé publique complète de l'appareil.
var stored = server.Store.FindByReference(session.Account.Reference)!;
var storedDevice = server.Store.Devices(stored.Id).Single();
Assert.DoesNotContain("argon2", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(stored.PasswordHash, text);
Assert.DoesNotContain(storedDevice.RefreshHash, text);
Assert.DoesNotContain(storedDevice.PublicKey, text);
Assert.DoesNotContain(session.RefreshToken, text);
// L'export réussi est inscrit au journal du compte.
var events = server.Store.Events(stored.Id, 10);
Assert.Contains(events, e => e.Type == PlayerEvents.Export && e.Result == AuditResults.Ok);
Assert.Contains(events, e => e.Type == PlayerEvents.Export && e.Result == AuditResults.Denied);
}
[Fact]
public async Task Export_and_deletion_are_refused_with_the_limited_password_change_token()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var session = await server.RegisterAsync(device, Email);
server.Store.SetMustChangePassword(server.Store.FindByReference(session.Account.Reference)!.Id, true);
var limited = (await server.LoginAsync(device, Email)).PasswordChangeToken!;
foreach (var route in new[] { ApiRoutes.AccountExport, ApiRoutes.AccountDelete })
{
using var response = await server.SendAsync(HttpMethod.Post, route, limited,
new PlayerPasswordConfirmation { Password = PlayerTestServer.Password });
Assert.True(response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden, $"{route} : {(int)response.StatusCode}");
}
using var activity = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountActivity, limited);
Assert.True(activity.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden);
Assert.NotNull(server.Store.FindByReference(session.Account.Reference));
}
[Fact]
public async Task Deleting_the_account_erases_it_and_closes_every_session()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
using var laptop = new TestDevice("Windows 11 · portable");
var session = await server.RegisterAsync(device, Email);
var other = (await server.LoginAsync(laptop, Email)).Session!;
var accountId = server.Store.FindByReference(session.Account.Reference)!.Id;
using (var wrong = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountDelete, session.AccessToken,
new PlayerPasswordConfirmation { Password = PlayerTestServer.OtherPassword }))
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
Assert.NotNull(server.Store.FindById(accountId));
using (var deleted = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountDelete, session.AccessToken,
new PlayerPasswordConfirmation { Password = PlayerTestServer.Password }))
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
Assert.Null(server.Store.FindById(accountId));
Assert.Empty(server.Store.Devices(accountId));
Assert.Empty(server.Store.Events(accountId, 100)); // journal du compte effacé avec lui
// Plus aucune session : jetons d'accès des deux appareils, renouvellement, connexion.
foreach (var token in new[] { session.AccessToken, other.AccessToken })
{
using var me = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, token);
Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode);
}
using (var refresh = await server.RefreshRawAsync(laptop, other.RefreshToken))
Assert.Equal(HttpStatusCode.Unauthorized, refresh.StatusCode);
using (var login = await server.LoginRawAsync(device, Email))
Assert.Equal(HttpStatusCode.Unauthorized, login.StatusCode);
// Trace dans le journal d'audit : la référence seulement, jamais l'adresse.
var audit = await File.ReadAllTextAsync(Directory.GetFiles(Path.Combine(server.Api.DataDirectory, "audit"), "audit-*.jsonl").Single());
Assert.Contains("players.self_delete", audit);
Assert.Contains(session.Account.Reference, audit);
Assert.DoesNotContain("joueur@", audit);
// L'adresse est de nouveau libre.
using var again = new TestDevice();
var recreated = await server.RegisterAsync(again, Email);
Assert.NotEqual(session.Account.Reference, recreated.Account.Reference);
}
[Fact]
public async Task Guessing_the_password_from_a_stolen_session_locks_it_and_closes_the_sessions()
{
await using var server = await PlayerTestServer.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Players:TrustedFailuresPerLock"] = "3"
});
using var device = new TestDevice();
var session = await server.RegisterAsync(device, Email);
HttpStatusCode last = 0;
for (var i = 0; i < 3; i++)
{
using var response = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountDelete, session.AccessToken,
new PlayerPasswordConfirmation { Password = $"Essai-Au-Hasard-{i}-xyz" });
last = response.StatusCode;
}
Assert.Equal(HttpStatusCode.TooManyRequests, last);
using var me = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, session.AccessToken);
Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode);
Assert.NotNull(server.Store.FindByReference(session.Account.Reference)); // rien n'a été supprimé
}
[Fact]
public async Task Inactive_accounts_fall_asleep_wake_up_on_login_then_are_deleted()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var session = await server.RegisterAsync(device, Email);
using var banned = new TestDevice();
var bannedSession = await server.RegisterAsync(banned, "banni@example.fr");
var accountId = server.Store.FindByReference(session.Account.Reference)!.Id;
var bannedId = server.Store.FindByReference(bannedSession.Account.Reference)!.Id;
server.Store.SetStatus(bannedId, PlayerStatus.Disabled, null, null, "tests", server.Api.Time.GetUtcNow());
var privacy = server.Api.Services.GetRequiredService<PlayerPersonalData>();
// 23 mois : rien. 25 mois : en sommeil, appareils déconnectés (le compte désactivé par un administrateur n'est pas touché).
server.Api.Time.Advance(TimeSpan.FromDays(23 * 31));
Assert.Equal((0, 0), privacy.ApplyInactivity(server.Api.Time.GetUtcNow()));
server.Api.Time.Advance(TimeSpan.FromDays(2 * 31));
Assert.Equal((1, 0), privacy.ApplyInactivity(server.Api.Time.GetUtcNow()));
Assert.Equal(PlayerStatus.Inactive, server.Store.FindById(accountId)!.Status);
Assert.Equal(PlayerStatus.Disabled, server.Store.FindById(bannedId)!.Status);
Assert.Empty(server.Store.Devices(accountId));
// Une connexion avec le bon mot de passe le réveille.
var login = await server.LoginAsync(device, Email);
Assert.Equal(PlayerLoginStatus.Ok, login.Status);
Assert.Equal(PlayerStatus.Active, server.Store.FindById(accountId)!.Status);
Assert.Contains(server.Store.Events(accountId, 20), e => e.Type == PlayerEvents.Reactivated);
// De nouveau 25 mois sans activité, puis 12 mois de sommeil : supprimé définitivement.
server.Api.Time.Advance(TimeSpan.FromDays(25 * 31));
Assert.Equal((1, 0), privacy.ApplyInactivity(server.Api.Time.GetUtcNow()));
server.Api.Time.Advance(TimeSpan.FromDays(11 * 31));
Assert.Equal((0, 0), privacy.ApplyInactivity(server.Api.Time.GetUtcNow()));
server.Api.Time.Advance(TimeSpan.FromDays(2 * 31));
Assert.Equal((0, 1), privacy.ApplyInactivity(server.Api.Time.GetUtcNow()));
Assert.Null(server.Store.FindById(accountId));
Assert.NotNull(server.Store.FindById(bannedId));
var audit = string.Concat(Directory.GetFiles(Path.Combine(server.Api.DataDirectory, "audit"), "audit-*.jsonl").Select(File.ReadAllText));
Assert.Contains("players.inactive_delete", audit);
}
[Fact]
public async Task Inactivity_policy_can_be_turned_off()
{
await using var server = await PlayerTestServer.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Players:InactiveDisableMonths"] = "0"
});
using var device = new TestDevice();
var session = await server.RegisterAsync(device, Email);
server.Api.Time.Advance(TimeSpan.FromDays(10 * 365));
Assert.Equal((0, 0), server.Api.Services.GetRequiredService<PlayerPersonalData>().ApplyInactivity(server.Api.Time.GetUtcNow()));
Assert.Equal(PlayerStatus.Active, server.Store.FindByReference(session.Account.Reference)!.Status);
}
[Theory]
[InlineData("6")]
[InlineData("121")]
public async Task Inactivity_settings_are_validated(string months)
{
await Assert.ThrowsAnyAsync<Exception>(() => PlayerTestServer.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Players:InactiveDisableMonths"] = months
}));
}
}
@@ -0,0 +1,113 @@
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 ; accès, changement exigé, code de vérification,
/// renouvellement), 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;
await server.EnableOtpAsync(access);
var otp = (await server.LoginAsync(device, "joueur@example.fr")).OtpToken!; // jeton de la 2ᵉ étape (« glpo_ »)
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, otp, 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())), ("GET", ApiRoutes.AccountActivity),
("POST", ApiRoutes.AccountExport), ("POST", ApiRoutes.AccountDelete), ("POST", ApiRoutes.AccountLoginOtp),
("POST", ApiRoutes.AccountOtpSetup), ("POST", ApiRoutes.AccountOtpEnable), ("POST", ApiRoutes.AccountOtpDisable),
("POST", ApiRoutes.AccountOtpRecoveryCodes)
})
{
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));
}
}
@@ -0,0 +1,332 @@
using System.Net;
using System.Net.Http.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Storage.Players;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Server;
/// <summary>
/// Comptes joueurs vus par les administrateurs (étape 8d) : lecture (adresse masquée, consultations inscrites au journal),
/// actions (désactivation avec motif, levée d'une suspension, nouveau mot de passe exigé, mot de passe temporaire, double
/// authentification, appareils), suppression, permissions et requêtes signées.
/// </summary>
public class PlayersAdminTests
{
private const string Email = "Camille.Martin@Exemple.fr";
private static async Task<(PlayerTestServer Server, AdminSession Owner)> StartAsync()
{
var server = await PlayerTestServer.StartAsync();
var owner = await server.Api.LoginAsync(server.Api.AddOwner("Nicolas"));
return (server, owner);
}
[Fact]
public async Task Admin_lists_and_finds_players_without_ever_receiving_a_full_address()
{
var (server, owner) = await StartAsync();
await using var _ = server;
using var device = new TestDevice();
using var other = new TestDevice();
var first = await server.RegisterAsync(device, Email);
server.Api.Time.Advance(TimeSpan.FromMinutes(1));
await server.RegisterAsync(other, "leo@exemple.fr");
var overview = await owner.GetPlayersOverviewAsync();
Assert.True(overview.Enabled);
Assert.Equal(2, overview.Total);
Assert.Equal(2, overview.Active);
Assert.Equal(2, overview.CreatedLast30Days);
// Liste : la plus récente d'abord, adresses masquées, pages avec curseur.
var page = await owner.GetPlayersPageAsync(limit: 1);
Assert.Equal(2, page.Total);
Assert.Equal("l***@e***.fr", Assert.Single(page.Items).EmailMasked);
Assert.NotNull(page.NextCursor);
var next = await owner.GetPlayersPageAsync(cursor: page.NextCursor, limit: 1);
Assert.Equal(first.Account.Reference, Assert.Single(next.Items).Reference);
Assert.Equal(1, next.Items[0].DeviceCount);
Assert.Empty((await owner.GetPlayersPageAsync(PlayerAccountStatuses.Disabled)).Items);
// Recherche par adresse EXACTE (casse indifférente) et par référence ; adresse partielle : refusée.
var byEmail = await owner.SearchPlayersAsync(" camille.martin@exemple.FR ");
Assert.Equal("email", byEmail.Kind);
Assert.Equal(first.Account.Reference, Assert.Single(byEmail.Items).Reference);
Assert.Single((await owner.SearchPlayersAsync(first.Account.Reference.ToLowerInvariant())).Items);
Assert.Empty((await owner.SearchPlayersAsync("personne@exemple.fr")).Items);
var partial = await Assert.ThrowsAsync<ApiException>(() => owner.SearchPlayersAsync("camille"));
Assert.Equal(400, partial.Status);
// Fiche : appareils, activité avec l'adresse IP complète ; jamais l'adresse e-mail en clair.
var detail = await owner.GetPlayerAsync(first.Account.Reference);
Assert.Equal("c***@e***.fr", detail.Summary.EmailMasked);
Assert.Equal(device.Label, Assert.Single(detail.Devices).Label);
Assert.Contains(detail.Events, e => e.Type == PlayerEvents.Register && e.Ip is not null && !e.Ip.Contains('x'));
var rawSession = await server.Api.RawLoginAsync(server.Api.AddOwner("Lecture brute"));
using (var raw = await server.SendAsync(HttpMethod.Get, ApiRoutes.Player(first.Account.Reference), rawSession.Token))
Assert.DoesNotContain("camille.martin", (await raw.Content.ReadAsStringAsync()).ToLowerInvariant());
// Chaque consultation au journal, sans l'adresse recherchée.
var audit = await owner.GetAuditAsync(200);
Assert.Contains(audit.Entries, e => e.Action == "players.view" && e.Target == first.Account.Reference);
Assert.Contains(audit.Entries, e => e.Action == "players.view" && e.Target == "liste des comptes");
Assert.Equal(3, audit.Entries.Count(e => e.Action == "players.search"));
Assert.DoesNotContain(audit.Entries, e => (e.Details + e.Target).Contains("camille", StringComparison.OrdinalIgnoreCase) ||
(e.Details + e.Target).Contains("personne", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Disabling_shows_the_reason_to_the_player_and_enabling_restores_the_account()
{
var (server, owner) = await StartAsync();
await using var _ = server;
using var device = new TestDevice();
var session = await server.RegisterAsync(device, Email);
var reference = session.Account.Reference;
var link = await Assert.ThrowsAsync<ApiException>(() => owner.DisablePlayerAsync(reference, null, "Voir www.exemple.com"));
Assert.Equal(400, link.Status);
var past = await Assert.ThrowsAsync<ApiException>(() => owner.DisablePlayerAsync(reference, server.Api.Time.GetUtcNow().AddDays(-1), null));
Assert.Equal(400, past.Status);
var until = server.Api.Time.GetUtcNow().AddDays(7);
var disabled = await owner.DisablePlayerAsync(reference, until, "Triche en ligne (avertissement du 03/11)");
Assert.Equal(PlayerAccountStatuses.Disabled, disabled.Summary.Status);
Assert.Equal("Triche en ligne (avertissement du 03/11)", disabled.StatusReason);
Assert.Contains("Nicolas", disabled.StatusChangedBy);
// Session ouverte refusée aussitôt ; connexion refusée avec la date et le motif.
using (var me = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, session.AccessToken))
Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode);
using (var login = await server.LoginRawAsync(device, Email))
{
Assert.Equal(HttpStatusCode.Forbidden, login.StatusCode);
var error = await TestApi.ReadErrorAsync(login);
Assert.Equal(ApiErrorCodes.AccountDisabled, error.Code);
Assert.Contains("Motif : Triche en ligne", error.Message);
}
// Le joueur voit « par l'équipe », jamais le nom de l'administrateur (D8).
var events = server.Store.Events(server.Store.FindByReference(reference)!.Id, 20);
var line = Assert.Single(events, e => e.Type == PlayerEvents.Disabled);
Assert.Contains("par l'équipe", line.Details);
Assert.DoesNotContain("Nicolas", line.Details);
var enabled = await owner.EnablePlayerAsync(reference);
Assert.Equal(PlayerAccountStatuses.Active, enabled.Summary.Status);
Assert.Null(enabled.StatusReason);
Assert.Equal(PlayerLoginStatus.Ok, (await server.LoginAsync(device, Email)).Status);
var again = await Assert.ThrowsAsync<ApiException>(() => owner.EnablePlayerAsync(reference));
Assert.Equal(409, again.Status);
var audit = await owner.GetAuditAsync(100);
Assert.Contains(audit.Entries, e => e.Action == "players.disable" && e.Target == reference && e.Details!.Contains("Triche"));
Assert.Contains(audit.Entries, e => e.Action == "players.enable" && e.ActorName == "Nicolas");
}
[Fact]
public async Task Temporary_password_only_lets_the_player_choose_a_new_one()
{
var (server, owner) = await StartAsync();
await using var _ = server;
using var device = new TestDevice();
var reference = (await server.RegisterAsync(device, Email)).Account.Reference;
var created = await owner.CreatePlayerTemporaryPasswordAsync(reference);
Assert.Matches(@"^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$", created.Password);
Assert.Equal(created.ExpiresAt, created.Account.TemporaryPasswordExpiresAt);
Assert.DoesNotContain((await owner.GetAuditAsync(50)).Entries, e => (e.Details ?? "").Contains(created.Password));
// L'ancien mot de passe reste valable (il n'est pas remplacé).
Assert.Equal(PlayerLoginStatus.Ok, (await server.LoginAsync(device, Email)).Status);
// Mot de passe temporaire (minuscules, espaces tolérés) : seulement le choix d'un nouveau mot de passe.
var typed = created.Password.ToLowerInvariant().Replace("-", " ");
var login = await server.LoginAsync(device, Email, typed);
Assert.Equal(PlayerLoginStatus.PasswordChangeRequired, login.Status);
Assert.True(login.TemporaryPassword);
Assert.Null(login.Session);
using (var me = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, login.PasswordChangeToken))
Assert.Equal(HttpStatusCode.Forbidden, me.StatusCode); // jeton limité au changement de mot de passe
// L'ancien mot de passe n'est pas accepté à la place du mot de passe temporaire.
using (var wrong = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountPassword, login.PasswordChangeToken,
new PlayerChangePasswordRequest { CurrentPassword = PlayerTestServer.Password, NewPassword = PlayerTestServer.OtherPassword }))
Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode);
login = await server.LoginAsync(device, Email, created.Password);
using (var change = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountPassword, login.PasswordChangeToken,
new PlayerChangePasswordRequest { CurrentPassword = created.Password, NewPassword = PlayerTestServer.OtherPassword }))
{
Assert.True(change.IsSuccessStatusCode, await change.Content.ReadAsStringAsync());
var changed = (await change.Content.ReadFromJsonAsync<PlayerChangePasswordResponse>(ApiJson.Options))!;
Assert.NotNull(changed.Session);
}
// Ni l'ancien mot de passe, ni le mot de passe temporaire ne servent plus.
foreach (var password in new[] { PlayerTestServer.Password, created.Password })
{
using var refused = await server.LoginRawAsync(device, Email, password);
Assert.Equal(HttpStatusCode.Unauthorized, refused.StatusCode);
}
Assert.Equal(PlayerLoginStatus.Ok, (await server.LoginAsync(device, Email, PlayerTestServer.OtherPassword)).Status);
var detail = await owner.GetPlayerAsync(reference);
Assert.Null(detail.TemporaryPasswordExpiresAt);
Assert.Contains(detail.Events, e => e.Type == PlayerEvents.TemporaryPasswordUsed);
}
[Fact]
public async Task Temporary_password_expires_and_still_requires_the_second_factor()
{
var (server, owner) = await StartAsync();
await using var _ = server;
using var device = new TestDevice();
var session = await server.RegisterAsync(device, Email);
var reference = session.Account.Reference;
var (secret, _) = await server.EnableOtpAsync(session.AccessToken);
var created = await owner.CreatePlayerTemporaryPasswordAsync(reference);
server.NextOtpStep();
var login = await server.LoginAsync(device, Email, created.Password);
Assert.Equal(PlayerLoginStatus.OtpRequired, login.Status);
Assert.True(login.TemporaryPassword);
using (var otp = await server.LoginOtpRawAsync(login.OtpToken!, server.OtpCode(secret)))
{
var body = (await otp.Content.ReadFromJsonAsync<PlayerLoginResponse>(ApiJson.Options))!;
Assert.Equal(PlayerLoginStatus.PasswordChangeRequired, body.Status);
Assert.True(body.TemporaryPassword);
}
// Une heure plus tard : refusé comme un mauvais mot de passe.
server.Api.Time.Advance(PlayerTemporaryPassword.Lifetime + TimeSpan.FromMinutes(1));
using var expired = await server.LoginRawAsync(device, Email, created.Password);
Assert.Equal(HttpStatusCode.Unauthorized, expired.StatusCode);
Assert.Equal(ApiErrorCodes.LoginFailed, (await TestApi.ReadErrorAsync(expired)).Code);
}
[Fact]
public async Task Team_lifts_locks_requires_a_new_password_resets_two_factor_and_signs_out_devices()
{
var (server, owner) = await StartAsync();
await using var _ = server;
using var device = new TestDevice();
var session = await server.RegisterAsync(device, Email);
var reference = session.Account.Reference;
// Blocage (5 essais depuis un appareil inconnu), puis levée.
using (var stranger = new TestDevice("Inconnu"))
for (var i = 0; i < 5; i++)
using (await server.LoginRawAsync(stranger, Email, "mauvais mot de passe")) { }
Assert.Equal(1, (await owner.GetPlayersOverviewAsync()).Locked);
Assert.NotNull((await owner.GetPlayerAsync(reference)).Summary.LockedUntil);
Assert.Single((await owner.GetPlayersPageAsync("locked")).Items);
var unlocked = await owner.UnlockPlayerAsync(reference);
Assert.Null(unlocked.Summary.LockedUntil);
Assert.Equal(0, unlocked.FailedCount);
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() => owner.UnlockPlayerAsync(reference))).Status);
// Suspension levée : le compte redevient actif.
var id = server.Store.FindByReference(reference)!.Id;
server.Store.SetStatus(id, PlayerStatus.Suspended, null, null, "système (tests)", server.Api.Time.GetUtcNow());
Assert.Equal(PlayerAccountStatuses.Active, (await owner.UnlockPlayerAsync(reference)).Summary.Status);
// Nouveau mot de passe exigé : reconnexion automatique refusée tant que ce n'est pas fait.
Assert.True((await owner.SetPlayerPasswordRequirementAsync(reference, true)).Summary.MustChangePassword);
using (var refresh = await server.RefreshRawAsync(device, session.RefreshToken))
Assert.Equal(ApiErrorCodes.PasswordChangeRequired, (await TestApi.ReadErrorAsync(refresh)).Code);
Assert.False((await owner.SetPlayerPasswordRequirementAsync(reference, false)).Summary.MustChangePassword);
var fresh = await server.RefreshAsync(device, session.RefreshToken);
// Double authentification réinitialisée.
await server.EnableOtpAsync(fresh.AccessToken);
var reset = await owner.ResetPlayerOtpAsync(reference);
Assert.False(reset.Summary.OtpEnabled);
Assert.Equal(0, reset.RecoveryCodesLeft);
Assert.Equal(PlayerLoginStatus.Ok, (await server.LoginAsync(device, Email)).Status);
// Appareils déconnectés : la session mémorisée ne sert plus.
var detail = await owner.GetPlayerAsync(reference);
var signedOut = await owner.LogoutPlayerDevicesAsync(reference, Assert.Single(detail.Devices).Id);
Assert.Empty(signedOut.Devices);
var last = (await server.LoginAsync(device, Email)).Session!;
await owner.LogoutPlayerDevicesAsync(reference, null);
using (var revoked = await server.RefreshRawAsync(device, last.RefreshToken))
Assert.Equal(HttpStatusCode.Unauthorized, revoked.StatusCode);
Assert.Contains(server.Store.Events(id, 50), e => e.Type == PlayerEvents.DevicesRemovedByTeam);
}
[Fact]
public async Task Deleting_needs_its_own_permission_and_is_audited()
{
var (server, owner) = await StartAsync();
await using var _ = server;
using var device = new TestDevice();
var reference = (await server.RegisterAsync(device, Email)).Account.Reference;
// Groupe « Administrateur » : lecture et gestion, pas la suppression ; « Éditeur » : rien.
var (adminKey, _) = await server.Api.AddAdminAsync(owner, "Camille", AdminRoles.Admin);
await using var admin = await server.Api.LoginAsync(adminKey);
Assert.NotNull(await admin.GetPlayerAsync(reference));
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => admin.DeletePlayerAsync(reference))).Status);
var (editorKey, _) = await server.Api.AddAdminAsync(owner, "Léo", AdminRoles.Editor);
await using var editor = await server.Api.LoginAsync(editorKey);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => editor.GetPlayersOverviewAsync())).Status);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => editor.SearchPlayersAsync(Email))).Status);
await owner.DeletePlayerAsync(reference);
Assert.Null(server.Store.FindByReference(reference));
Assert.Equal(404, (await Assert.ThrowsAsync<ApiException>(() => owner.GetPlayerAsync(reference))).Status);
Assert.Contains((await owner.GetAuditAsync(50)).Entries, e => e.Action == "players.delete" && e.Target == reference && e.ActorName == "Nicolas");
using var login = await server.LoginRawAsync(device, Email);
Assert.Equal(HttpStatusCode.Unauthorized, login.StatusCode);
}
[Fact]
public async Task Actions_must_be_signed_by_the_admin_key()
{
var (server, owner) = await StartAsync();
await using var _ = server;
using var device = new TestDevice();
var reference = (await server.RegisterAsync(device, Email)).Account.Reference;
var key = server.Api.AddOwner("Sans signature");
var raw = await server.Api.RawLoginAsync(key);
using var unsigned = await server.Api.SendSignedRawAsync(raw, key, HttpMethod.Post, ApiRoutes.PlayerAction(reference, "disable"),
new PlayerDisableRequest(), includeSignature: false);
Assert.False(unsigned.IsSuccessStatusCode);
Assert.Equal(PlayerAccountStatuses.Active, (await owner.GetPlayerAsync(reference)).Summary.Status);
using var signed = await server.Api.SendSignedRawAsync(raw, key, HttpMethod.Post, ApiRoutes.PlayerAction(reference, "disable"),
new PlayerDisableRequest());
Assert.True(signed.IsSuccessStatusCode, await signed.Content.ReadAsStringAsync());
}
[Fact]
public async Task Without_player_accounts_the_page_says_so()
{
await using var api = await TestApi.StartAsync();
await using var owner = await api.LoginAsync(api.AddOwner());
Assert.False((await owner.GetPlayersOverviewAsync()).Enabled);
var error = await Assert.ThrowsAsync<ApiException>(() => owner.GetPlayersPageAsync());
Assert.Equal(503, error.Status);
}
[Fact]
public void Temporary_passwords_are_random_and_normalized()
{
var a = PlayerTemporaryPassword.New();
Assert.NotEqual(a, PlayerTemporaryPassword.New());
Assert.Equal(19, a.Length);
Assert.Equal(a.Replace("-", ""), PlayerTemporaryPassword.Normalize(" " + a.ToLowerInvariant().Replace('-', ' ') + " "));
Assert.Equal("0123456789ABCDEF", PlayerTemporaryPassword.Normalize("OI23-4567-89ab-cdef"));
Assert.Null(PlayerTemporaryPassword.Normalize("Tr0mbone-Lavande!"));
Assert.Null(PlayerTemporaryPassword.Normalize("ABCD-EFGH-JKMN"));
Assert.Null(PlayerAdminRules.CleanReason(" ", out var none));
Assert.Null(none);
Assert.Null(PlayerAdminRules.CleanReason("ligne 1\nligne 2", out var lines));
Assert.NotNull(lines);
Assert.Equal("Triche", PlayerAdminRules.CleanReason(" Triche ", out _));
}
}
+219
View File
@@ -4,6 +4,7 @@ using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
@@ -180,6 +181,28 @@ internal sealed class FakeApi : HttpMessageHandler
case ["v1", "maintenance"] when method == HttpMethod.Put:
return Json(_world.MaintenancePublished(Read<MaintenancePublishRequest>(body)));
// ---- Comptes joueurs (étape 8d) : lecture, actions acceptées et simulées en mémoire (rien n'est enregistré)
case ["v1", "players", "overview"] when get:
return Json(_world.PlayersOverview());
case ["v1", "players"] when get:
return Json(_world.PlayersPage(QueryValue(query, "filter")));
case ["v1", "players", "search"] when method == HttpMethod.Post:
return Json(_world.SearchPlayers(Read<PlayerSearchRequest>(body).Query));
case ["v1", "players", var reference] when get:
return _world.FindPlayer(reference) is { } player
? Json(player)
: Error(HttpStatusCode.NotFound, ApiErrorCodes.NotFound, "Compte joueur introuvable.");
case ["v1", "players", var reference] when method == HttpMethod.Delete:
return _world.DeletePlayer(reference) ? NoContent() : Error(HttpStatusCode.NotFound, ApiErrorCodes.NotFound, "Compte joueur introuvable.");
case ["v1", "players", var reference, "temporary-password"] when method == HttpMethod.Post:
return _world.FindPlayer(reference) is { } account
? Json(_world.TemporaryPassword(account))
: Error(HttpStatusCode.NotFound, ApiErrorCodes.NotFound, "Compte joueur introuvable.");
case ["v1", "players", var reference, var action] when method == HttpMethod.Post:
return _world.PlayerAction(reference, action, body) is { } updated
? Json(updated)
: Error(HttpStatusCode.NotFound, ApiErrorCodes.NotFound, "Compte joueur introuvable.");
case ["v1", "games", var gameId, "feedback"] when get:
return Json(_world.FeedbackList(gameId, QueryValue(query, "kind"), QueryValue(query, "status")));
case ["v1", "games", var gameId, "feedback", var feedbackId] when get:
@@ -837,6 +860,202 @@ internal sealed class SampleWorld
: null;
/// <summary>Traitement accepté (statut, note), rien n'est enregistré.</summary>
// ------------------------------------------------------------------ Comptes joueurs (étape 8d)
private List<PlayerAdminDetail>? _players;
private List<PlayerAdminDetail> Players() => _players ??= CreatePlayers();
public PlayersOverview PlayersOverview()
{
var players = Players().Select(p => p.Summary).ToList();
return new PlayersOverview
{
Enabled = true,
RegistrationEnabled = true,
Total = 1248,
Active = 1190 - players.Count(p => p.Status != PlayerAccountStatuses.Active),
Disabled = players.Count(p => p.Status == PlayerAccountStatuses.Disabled),
Suspended = players.Count(p => p.Status == PlayerAccountStatuses.Suspended),
Inactive = 43,
Locked = players.Count(p => p.LockedUntil is not null),
PasswordChangeRequired = players.Count(p => p.MustChangePassword),
OtpEnabled = 311,
CreatedLast30Days = 57,
EventsRetentionMonths = 12,
EventsIpRetentionMonths = 6,
InactiveDisableMonths = 24,
InactiveDeleteMonths = 12
};
}
public PlayerAdminPage PlayersPage(string? filter)
{
var items = Players().Select(p => p.Summary).Where(s => filter switch
{
null or "" => true,
"locked" => s.LockedUntil is not null,
"password" => s.MustChangePassword,
"otp" => s.OtpEnabled,
_ => s.Status == filter
}).ToList();
return new PlayerAdminPage { Items = items, Total = string.IsNullOrEmpty(filter) ? 1248 : items.Count, NextCursor = null };
}
public PlayerSearchResult SearchPlayers(string query)
{
var reference = PlayerIds.NormalizeReference(query);
var found = Players().FirstOrDefault(p => p.Summary.Reference == reference) ??
(query.Contains('@') ? Players().FirstOrDefault(p => p.Summary.EmailMasked.StartsWith(char.ToLowerInvariant(query.Trim()[0]))) : null);
return new PlayerSearchResult { Kind = reference is null ? "email" : "reference", Items = found is null ? new() : new() { found.Summary } };
}
public PlayerAdminDetail? FindPlayer(string reference) =>
Players().FirstOrDefault(p => p.Summary.Reference == PlayerIds.NormalizeReference(reference));
public bool DeletePlayer(string reference) => FindPlayer(reference) is { } player && Players().Remove(player);
public PlayerTemporaryPasswordResponse TemporaryPassword(PlayerAdminDetail player)
{
var expires = _now.AddHours(1);
var updated = Replace(player with { TemporaryPasswordExpiresAt = expires, FailedCount = 0, Summary = player.Summary with { LockedUntil = null } });
return new PlayerTemporaryPasswordResponse { Password = "K7Q2-M9XD-4TPA-W3HN", ExpiresAt = expires, Account = updated };
}
/// <summary>Action simulée sur un compte (état modifié en mémoire, comme le ferait le serveur).</summary>
public PlayerAdminDetail? PlayerAction(string reference, string action, byte[] body)
{
if (FindPlayer(reference) is not { } p) return null;
var s = p.Summary;
return Replace(action switch
{
"disable" => JsonSerializer.Deserialize<PlayerDisableRequest>(body, ApiJson.Options) is { } request
? p with
{
Summary = s with { Status = PlayerAccountStatuses.Disabled, DisabledUntil = request.Until },
StatusReason = request.Reason, StatusChangedAt = _now, StatusChangedBy = $"{OwnerName} (menu d'administration)"
}
: p,
"enable" or "unlock" => p with
{
Summary = s with { Status = PlayerAccountStatuses.Active, DisabledUntil = null, LockedUntil = null },
StatusReason = null, FailedCount = 0, LockLevel = 0, UnknownDevicesLockedUntil = null
},
"password-requirement" => p with { Summary = s with { MustChangePassword = !s.MustChangePassword } },
"otp-reset" => p with { Summary = s with { OtpEnabled = false }, RecoveryCodesLeft = 0 },
"logout" => p with { Devices = new(), Summary = s with { DeviceCount = 0 } },
_ => p
});
}
private PlayerAdminDetail Replace(PlayerAdminDetail updated)
{
var list = Players();
var index = list.FindIndex(p => p.Summary.Reference == updated.Summary.Reference);
if (index >= 0) list[index] = updated;
return updated;
}
/// <summary>
/// Comptes de démonstration (aucune donnée réelle : adresses masquées inventées, IP des plages de documentation) : actifs,
/// suspendu et bloqué après des essais depuis un appareil inconnu, désactivé avec motif, nouveau mot de passe exigé,
/// double authentification avec peu de codes de secours, en sommeil.
/// </summary>
private List<PlayerAdminDetail> CreatePlayers()
{
var list = new List<PlayerAdminDetail>();
var desktop = "Windows 11 · GameLauncher 3.0.0";
var laptop = "Windows 10 · GameLauncher 2.9.4";
PlayerAdminDevice Device(string label, double daysAgo, int n) => new()
{
Id = SampleData.Hex(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes($"device {list.Count} {n}")))[..32],
Label = label,
KeyId = SampleData.Hex(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes($"key {list.Count} {n}")))[..16],
CreatedAt = _now.AddDays(-daysAgo - 40),
LastUsedAt = _now.AddDays(-daysAgo),
RememberedUntil = _now.AddDays(90 - daysAgo)
};
void Add(string reference, string email, string status, double createdDaysAgo, double? loginDaysAgo, PlayerAdminDevice[] devices,
Func<PlayerAdminDetail, PlayerAdminDetail>? tweak = null, PlayerAdminEvent[]? events = null)
{
var detail = new PlayerAdminDetail
{
Summary = new PlayerAdminSummary
{
Reference = reference,
EmailMasked = email,
Status = status,
CreatedAt = _now.AddDays(-createdDaysAgo),
LastLoginAt = loginDaysAgo is { } d ? _now.AddDays(-d) : null,
LastActivityAt = devices.Length == 0 ? null : devices.Max(x => x.LastUsedAt),
DeviceCount = devices.Length
},
TermsVersion = "2026-11",
PasswordChangedAt = _now.AddDays(-createdDaysAgo / 2),
Devices = devices.ToList(),
Events = (events ?? new[]
{
Event(loginDaysAgo ?? createdDaysAgo, "account.login", true, "203.0.113.24", devices.FirstOrDefault()),
Event(createdDaysAgo, "account.register", true, "203.0.113.24", devices.FirstOrDefault(), "conditions version 2026-11")
}).ToList()
};
list.Add(tweak?.Invoke(detail) ?? detail);
}
PlayerAdminEvent Event(double daysAgo, string type, bool success, string? ip, PlayerAdminDevice? device = null, string? details = null) => new()
{
At = _now.AddDays(-daysAgo),
Type = type,
Success = success,
Ip = ip,
DeviceKeyId = device?.KeyId,
Device = device?.Label,
Details = details
};
var suspendedDevice = Device(desktop, 0.2, 1);
Add("PL-7KQ2-M9XD", "j***@e***.fr", PlayerAccountStatuses.Suspended, 212, 0.2, new[] { suspendedDevice }, d => d with
{
Summary = d.Summary with { OtpEnabled = true, LockedUntil = _now.AddMinutes(44) },
StatusChangedAt = _now.AddHours(-1),
StatusChangedBy = "système (échecs de connexion)",
FailedCount = 3, LockLevel = 4, UnknownDevicesLockedUntil = _now.AddMinutes(44),
RecoveryCodesLeft = 2
}, new[]
{
Event(0.03, "account.suspended", true, null, details: "20 échecs de connexion consécutifs depuis des appareils inconnus"),
Event(0.03, "account.locked", true, "198.51.100.77", details: "blocage n° 4 (appareils inconnus)"),
Event(0.04, "account.login_failed", false, "198.51.100.77", details: "mot de passe incorrect (appareil inconnu)"),
Event(0.05, "account.login_failed", false, "198.51.100.77", details: "mot de passe incorrect (appareil inconnu)"),
Event(0.2, "session.refresh", true, "203.0.113.24", suspendedDevice),
Event(1.5, "account.recovery_code_used", true, "203.0.113.24", suspendedDevice, "2 code(s) de secours restant(s)"),
Event(212, "account.register", true, "203.0.113.24", suspendedDevice, "conditions version 2026-11")
});
Add("PL-H4TB-0QZE", "c***@e***.fr", PlayerAccountStatuses.Disabled, 95, 9, new[] { Device(desktop, 9, 1) }, d => d with
{
Summary = d.Summary with { DisabledUntil = _now.AddDays(5) },
StatusReason = "Triche en partie en ligne (avertissement du 03/11)",
StatusChangedAt = _now.AddDays(-2),
StatusChangedBy = "Camille (menu d'administration)"
});
Add("PL-P8VN-3RWC", "l***@o***.fr", PlayerAccountStatuses.Active, 60, 1, new[] { Device(desktop, 1, 1), Device(laptop, 6, 2) },
d => d with { Summary = d.Summary with { MustChangePassword = true } });
Add("PL-2MXA-K7JD", "s***@g***.com", PlayerAccountStatuses.Active, 33, 0.5, new[] { Device(desktop, 0.5, 1) },
d => d with { Summary = d.Summary with { OtpEnabled = true }, RecoveryCodesLeft = 9 });
Add("PL-Z5FG-9HQT", "m***@p***.net", PlayerAccountStatuses.Inactive, 900, 760, Array.Empty<PlayerAdminDevice>(), d => d with
{
StatusChangedAt = _now.AddDays(-30),
StatusChangedBy = "système (inactivité)"
});
const string crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
for (var i = 0; i < 9; i++)
Add($"PL-{crockford[10 + i]}{crockford[20 + i]}{i}X-{crockford[15 + i]}9{i}Q", $"{(char)('a' + i)}***@e***.fr",
PlayerAccountStatuses.Active, 20 + i * 11, 2 + i, new[] { Device(i % 2 == 0 ? desktop : laptop, 2 + i, 1) });
return list;
}
public static FeedbackDetail FeedbackUpdated(FeedbackDetail current, FeedbackUpdateRequest request) => current with
{
Summary = current.Summary with
+4
View File
@@ -81,8 +81,12 @@ internal sealed class RecordingDialogs : IDialogService, IModuleDialogs
public void ShowMessage(string title, string message, ModuleMessageKind kind = ModuleMessageKind.Info) =>
_report($"Message du menu d'administration ({kind}) : {title} — {message}");
/// <summary>Vrai le temps d'une action voulue par une scène (ex. mot de passe temporaire) : la confirmation est acceptée.</summary>
public bool AnswerConfirmations { get; set; }
public bool Confirm(string title, string message, string confirmText, string cancelText = "Annuler", bool danger = false)
{
if (AnswerConfirmations) return true;
_report($"Confirmation demandée (refusée) : {title}");
return false;
}
+17 -4
View File
@@ -51,7 +51,7 @@ tools\ui-snapshot.bat --filter "main-*,launcher-update-verified" --format webp -
## Scènes
* **Fenêtre principale** (`main-*`) : non installé, à jour, mise à jour disponible, téléchargement en
* **Fenêtre principale** (`main-*`) : boutons « Aide » et « Compte » avec un joueur connecté (`main-account`), non installé, à jour, mise à jour disponible, téléchargement en
cours, préparation, installation introuvable, réparation nécessaire, jeu lancé, hors ligne, mise à jour
bloquée par sécurité, démarrage, **mode maintenance** (`main-maintenance` avec la précision de l'administrateur,
`main-maintenance-plain` sans précision, jeu non installé) ; `main-multi` : plusieurs jeux (exemple
@@ -67,6 +67,17 @@ tools\ui-snapshot.bat --filter "main-*,launcher-update-verified" --format webp -
importée, type masqué), formulaire publié avec tous les types de champs (`feedback-custom` : cases à
cocher, note sur 10, texte court, case oui/non ; type « autre » masqué), demande envoyée (avec l'avertissement d'une capture refusée). Envoi simulé, capture d'écran de
démonstration (ni fichier choisi, ni presse-papiers).
* **Fenêtre « Compte »** (`account-*`, bouton de la barre de titre) : connexion (`account-signin`), connexion refusée
avec l'aide « mot de passe oublié » (`account-signin-error`), création de compte avec un mot de passe refusé
(`account-register`) puis accepté (`account-register-valid`), nouveau mot de passe exigé (`account-password-change`) ou à choisir après un mot de passe temporaire
(`account-password-temporary`),
« Mon compte » : profil (`account-profile`), changement de mot de passe (`account-profile-password`), appareils
(`account-devices`), activité avec essais refusés (`account-activity`), mes données (`account-data`), nouveaux
appareils suspendus (`account-suspended`), comptes pas encore ouverts (`account-unavailable`) ; double authentification :
code demandé à la connexion (`account-otp`), activation avec le QR code dessiné par l'encodeur du launcher
(`account-otp-setup`), codes de secours (`account-otp-codes`), active avec peu de codes restants (`account-otp-enabled`),
désactivation (`account-otp-disable`). Compte **simulé**
(`Scenes\AccountScenes.cs`) : aucun réseau, aucune clé d'appareil, aucun fichier de session.
* **Menu d'administration** (`admin-*`) : connexion (normale, refusée, verrouillée), « J'ai une
invitation » (formulaire, empreinte), puis **chaque page déclarée par les modules** (`admin-page-<id>`,
lues dans `App.CreateModules` : une page ajoutée plus tard est capturée sans modifier l'outil ; la page
@@ -77,7 +88,9 @@ tools\ui-snapshot.bat --filter "main-*,launcher-update-verified" --format webp -
personnelle », aperçu tel que les joueurs le verront), puis un type de demande ajouté
(`admin-page-feedback-forms-new-type` : icône importée, captures d'écran désactivées, déplacé), la page
« Maintenance » (`admin-page-maintenance` : maintenance en cours ; `admin-page-maintenance-edit` : message avec
un lien, refusé, aperçu) ; rien n'est publié.
un lien, refusé, aperçu), la page « Comptes joueurs » (`admin-page-players-accounts` : compteurs et liste ;
`admin-page-players-accounts-detail` : compte suspendu, sa fiche et un mot de passe temporaire affiché une fois ;
`admin-page-players-accounts-disable` : désactivation avec un motif contenant un lien, refusé) ; rien n'est publié.
`--theme all` rend chaque scène dans chaque thème intégré (suffixe `@default`, `@exovival`).
@@ -100,8 +113,8 @@ L'outil sert au débogage : **chaque nouvelle fenêtre ou nouveau menu doit y ê
modification. Une page du menu d'administration est capturée automatiquement, mais ses routes de l'API sont
à simuler dans `Fakes\FakeApi.cs` (sinon le rapport les signale) et ses états intéressants (fiche ouverte,
élément choisi…) à ajouter dans `Scenes\AdminScenes.cs`. Une fenêtre du joueur s'ajoute dans
`Scenes\PlayerScenes.cs`, ou dans un ensemble de scènes à part (`Scenes\FeedbackScenes.cs`) déclaré dans
`SnapshotRunner.cs`.
`Scenes\PlayerScenes.cs`, ou dans un ensemble de scènes à part (`Scenes\FeedbackScenes.cs`, `Scenes\AccountScenes.cs`)
déclaré dans `SnapshotRunner.cs`.
## Limites
+333
View File
@@ -0,0 +1,333 @@
using System.Windows;
using System.Windows.Controls;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Modules;
using GameLauncher.Modules.Accounts;
using GameLauncher.Platform;
using GameLauncher.Views;
namespace UiSnapshot.Scenes;
/// <summary>
/// Fenêtre « Compte » du joueur (bouton de la barre de titre) : connexion (normale, refusée avec l'aide « mot de passe
/// oublié »), création de compte (mot de passe refusé, puis accepté), nouveau mot de passe exigé, « Mon compte » (profil,
/// changement de mot de passe, appareils, activité, mes données, suspension des nouveaux appareils), service fermé ; double
/// authentification (étape 8c) : code demandé à la connexion, activation avec QR code (calculé par le vrai encodeur), codes de
/// secours, double authentification active avec peu de codes restants, désactivation.
/// Vrai ViewModel et vraie fenêtre ; le compte est simulé (<see cref="SampleAccounts"/>) : aucun réseau, aucune clé,
/// aucun fichier.
/// </summary>
internal sealed class AccountScenes : ISceneSet
{
private readonly Fixture _fixture;
public AccountScenes(Fixture fixture) => _fixture = fixture;
/// <param name="SignedIn">Compte connecté à l'ouverture (sinon : écran de connexion).</param>
/// <param name="Otp">Double authentification déjà activée (avec <paramref name="CodesLeft"/> codes de secours).</param>
private sealed record Scene(string Id, string Title, bool SignedIn, Func<AccountWindow, AccountWindowViewModel, SampleAccounts, Task> Apply,
bool Suspended = false, bool Closed = false, bool Otp = false, int CodesLeft = 10);
private static readonly Scene[] Scenes =
{
new("account-signin", "Compte · connexion", false, (_, vm, _) =>
{
vm.Email = "joueur@exemple.fr";
return Task.CompletedTask;
}),
new("account-signin-error", "Compte · connexion refusée, aide « mot de passe oublié »", false, async (w, vm, _) =>
{
vm.Email = "joueur@exemple.fr";
await vm.SignInAsync("mauvais mot de passe");
vm.ToggleForgotCommand.Execute(null);
}),
new("account-register", "Compte · création, mot de passe refusé", false, (w, vm, _) =>
{
vm.ShowRegisterCommand.Execute(null);
vm.Email = "joueur@exemple.fr";
SetPasswords(w, "RegisterPasswordBox", "azerty2026", "RegisterConfirmBox", "azerty");
return Task.CompletedTask;
}),
new("account-register-valid", "Compte · création, mot de passe accepté, conditions acceptées", false, (w, vm, _) =>
{
vm.ShowRegisterCommand.Execute(null);
vm.Email = "joueur@exemple.fr";
SetPasswords(w, "RegisterPasswordBox", "Cerf-Volant-Bleu-77!", "RegisterConfirmBox", "Cerf-Volant-Bleu-77!");
vm.AcceptTerms = true;
return Task.CompletedTask;
}),
new("account-password-change", "Compte · nouveau mot de passe exigé par un administrateur", false, async (w, vm, accounts) =>
{
accounts.RequirePasswordChange = true;
vm.Email = "joueur@exemple.fr";
await vm.SignInAsync("mot de passe actuel");
SetPasswords(w, "RequiredNewBox", "Tournesol-Rapide-2026", "RequiredConfirmBox", "");
}),
new("account-password-temporary", "Compte · connexion avec un mot de passe temporaire donné par l'équipe", false, async (w, vm, accounts) =>
{
accounts.RequirePasswordChange = true;
accounts.TemporaryPassword = true;
vm.Email = "joueur@exemple.fr";
await vm.SignInAsync("K7Q2-M9XD-4TPA-W3HN");
SetPasswords(w, "RequiredNewBox", "", "RequiredConfirmBox", "");
}),
new("account-profile", "Mon compte · profil", true, (_, _, _) => Task.CompletedTask),
new("account-profile-password", "Mon compte · changement de mot de passe", true, (w, vm, _) =>
{
vm.ShowChangePasswordCommand.Execute(null);
SetPasswords(w, "ChangeNewBox", "Une-Phrase-De-Passe-Bien-Longue", "ChangeConfirmBox", "Une-Phrase-De-Passe-Bien-Longue");
return Task.CompletedTask;
}),
new("account-devices", "Mon compte · appareils connectés", true, (_, vm, _) =>
{
vm.IsDevicesSection = true;
return Task.CompletedTask;
}),
new("account-activity", "Mon compte · activité récente (essais refusés signalés)", true, async (_, vm, _) =>
{
vm.IsActivitySection = true;
for (var i = 0; i < 40 && (vm.IsActivityLoading || vm.Activity.Count == 0); i++) await Task.Delay(25);
}),
new("account-data", "Mon compte · mes données (export, suppression)", true, (_, vm, _) =>
{
vm.IsDataSection = true;
vm.DeleteConfirmed = true;
return Task.CompletedTask;
}),
new("account-suspended", "Mon compte · nouveaux appareils suspendus (trop d'essais)", true, (_, _, _) => Task.CompletedTask,
Suspended: true),
new("account-unavailable", "Compte · comptes joueurs pas encore ouverts", false, (_, _, _) => Task.CompletedTask, Closed: true),
new("account-otp", "Compte · connexion, code de vérification (double authentification)", false, async (_, vm, _) =>
{
vm.Email = "joueur@exemple.fr";
await vm.SignInAsync("mot de passe");
vm.OtpCode = "482 9";
}, Otp: true),
new("account-otp-setup", "Mon compte · activation de la double authentification (QR code)", true, async (_, vm, _) =>
{
vm.StartOtpSetupCommand.Execute(null);
await vm.BeginOtpSetupAsync("mot de passe");
vm.SetupCode = "482 913";
}),
new("account-otp-codes", "Mon compte · codes de secours affichés une seule fois", true, async (_, vm, _) =>
{
vm.StartOtpSetupCommand.Execute(null);
await vm.BeginOtpSetupAsync("mot de passe");
vm.SetupCode = "482913";
await vm.ConfirmOtpSetupAsync();
}),
new("account-otp-enabled", "Mon compte · double authentification active, peu de codes de secours", true,
(_, _, _) => Task.CompletedTask, Otp: true, CodesLeft: 2),
new("account-otp-disable", "Mon compte · désactivation de la double authentification", true, (_, vm, _) =>
{
vm.StartOtpDisableCommand.Execute(null);
vm.ConfirmCode = "482913";
return Task.CompletedTask;
}, Otp: true)
};
public IEnumerable<SceneInfo> List(IReadOnlyList<string?> themes) =>
themes.SelectMany(theme => Scenes.Select(s => new SceneInfo(s.Id + Capture.Suffix(theme), s.Title)));
public async Task RunAsync(Capture capture)
{
foreach (var theme in capture.Themes)
{
foreach (var scene in Scenes)
{
await capture.ShootAsync(scene.Id + Capture.Suffix(theme), scene.Title, async () =>
{
_fixture.ApplyTheme(theme);
var accounts = new SampleAccounts(scene.SignedIn, scene.Suspended, scene.Closed) { OtpEnabled = scene.Otp, CodesLeft = scene.CodesLeft };
AccountWindow? window = null;
var vm = new AccountWindowViewModel(accounts, new SilentDialogs(), new WpfUiDispatcher(Application.Current.Dispatcher),
"LanisysGames");
window = new AccountWindow(vm); // la fenêtre libère le ViewModel à sa fermeture
Capture.ShowOffscreen(window);
await capture.SettleAsync(); // ouverture : InitializeAsync (compte simulé)
await scene.Apply(window, vm, accounts);
if (vm.HasError && scene.Id != "account-signin-error") capture.Problem($"Fenêtre « Compte » : {vm.ErrorMessage}");
return window;
}, Capture.CloseAsync);
}
}
}
/// <summary>Saisie dans deux champs de mot de passe (la vue met à jour le contrôle en direct).</summary>
private static void SetPasswords(Window window, string first, string firstValue, string second, string secondValue)
{
if (window.FindName(first) is PasswordBox a) a.Password = firstValue;
if (window.FindName(second) is PasswordBox b) b.Password = secondValue;
}
/// <summary>Boîtes de dialogue sans effet (aucune fenêtre, rien dans le presse-papiers).</summary>
private sealed class SilentDialogs : IModuleDialogs
{
public void ShowMessage(string title, string message, ModuleMessageKind kind = ModuleMessageKind.Info) { }
public bool Confirm(string title, string message, string confirmText, string cancelText = "Annuler", bool danger = false) => false;
public string? PickOpenFile(string title, string filter, string? initialDirectory) => null;
public string? PickSaveFile(string title, string filter, string defaultFileName, string? initialDirectory) => null;
public void CopyToClipboard(string text) { }
public void OpenFolder(string path) { }
}
}
/// <summary>Compte joueur simulé (données de démonstration fixes, aucune requête).</summary>
internal sealed class SampleAccounts : IPlayerAccountService
{
private static readonly DateTimeOffset Now = new(2026, 11, 11, 18, 42, 0, TimeSpan.FromHours(1));
private readonly bool _suspended;
private readonly bool _closed;
private PlayerAccountState _state;
public SampleAccounts(bool signedIn, bool suspended, bool closed)
{
_state = signedIn ? PlayerAccountState.SignedIn : PlayerAccountState.SignedOut;
_suspended = suspended;
_closed = closed;
}
/// <summary>La connexion demande un nouveau mot de passe (exigé par un administrateur).</summary>
public bool RequirePasswordChange { get; set; }
/// <summary>… après une connexion avec un mot de passe temporaire (étape 8d).</summary>
public bool TemporaryPassword { get; set; }
/// <summary>Double authentification activée, et codes de secours restants.</summary>
public bool OtpEnabled { get; set; }
public int CodesLeft { get; set; } = 10;
/// <summary>Secret et codes de démonstration (aucune valeur réelle).</summary>
private const string SampleSecret = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP";
private static readonly string[] SampleCodes =
{ "7KQ2-M9XD", "H4TB-0QZE", "P8VN-3RWC", "2MXA-K7JD", "Z5FG-9HQT", "C3RN-W8YB", "V6DK-1PZM", "N9TE-4GXA", "B2YH-6JQR", "F7WM-5CKV" };
public PlayerAccountState State => _state;
public PlayerAccountInfo? Account => _state == PlayerAccountState.SignedIn ? Info : null;
public string? Notice => null;
public event Action? Changed;
private PlayerAccountInfo Info => new()
{
Reference = "PL-7KQ2-M9XD",
EmailMasked = "j***@e***.fr",
CreatedAt = Now.AddMonths(-7).AddDays(-3),
LastLoginAt = Now.AddDays(-2),
PasswordChangedAt = Now.AddMonths(-2),
NewDevicesSuspended = _suspended,
OtpEnabled = OtpEnabled,
RecoveryCodesLeft = OtpEnabled ? CodesLeft : 0
};
public Task<PlayerAccountsInfo?> GetServiceInfoAsync(CancellationToken ct = default) =>
Task.FromResult<PlayerAccountsInfo?>(_closed ? null : new PlayerAccountsInfo
{
Enabled = true,
RegistrationOpen = true,
TermsVersion = "2026-11",
PasswordRulesVersion = PasswordRules.Version
});
public Task RestoreAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task<PlayerSignInResult> SignInAsync(string email, string password, bool remember, CancellationToken ct = default)
{
if (OtpEnabled) return Task.FromResult(new PlayerSignInResult(false, OtpRequired: true, OtpToken: "glpo_demonstration"));
if (RequirePasswordChange) return Task.FromResult(new PlayerSignInResult(true, "glpc_demonstration", TemporaryPassword: TemporaryPassword));
throw new ApiException(401, ApiErrorCodes.LoginFailed, "Adresse e-mail ou mot de passe incorrect.");
}
public Task RegisterAsync(string email, string password, string termsVersion, bool remember, CancellationToken ct = default) => SignIn();
public Task CompleteRequiredPasswordChangeAsync(string token, string current, string next, bool remember, CancellationToken ct = default) =>
SignIn();
public Task<int> ChangePasswordAsync(string currentPassword, string newPassword, CancellationToken ct = default) => Task.FromResult(1);
public async Task<PlayerSignInResult> VerifyOtpAsync(string otpToken, string code, bool remember, CancellationToken ct = default)
{
await SignIn();
return new PlayerSignInResult(false);
}
public Task<PlayerOtpSetupResponse> BeginOtpSetupAsync(string password, CancellationToken ct = default) =>
Task.FromResult(new PlayerOtpSetupResponse
{
Secret = SampleSecret,
Uri = PlayerOtp.AuthUri("LanisysGames", "j***@e***.fr", SampleSecret),
Issuer = "LanisysGames",
AccountName = "j***@e***.fr",
ExpiresAt = Now.AddMinutes(10)
});
public Task<IReadOnlyList<string>> EnableOtpAsync(string code, CancellationToken ct = default)
{
OtpEnabled = true;
return Task.FromResult<IReadOnlyList<string>>(SampleCodes);
}
public Task DisableOtpAsync(string password, string code, CancellationToken ct = default)
{
OtpEnabled = false;
return Task.CompletedTask;
}
public Task<IReadOnlyList<string>> RegenerateRecoveryCodesAsync(string password, string code, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(SampleCodes);
public Task<PlayerMeResponse> GetMeAsync(CancellationToken ct = default) => Task.FromResult(new PlayerMeResponse
{
Account = Info,
Devices = new[]
{
new PlayerDeviceInfo
{
Id = new string('a', 32), Label = "Windows 11 · GameLauncher 3.5.0", CreatedAt = Now.AddMonths(-7), LastUsedAt = Now,
Current = true
},
new PlayerDeviceInfo
{
Id = new string('b', 32), Label = "Windows 10 · GameLauncher 3.4.2", CreatedAt = Now.AddMonths(-3), LastUsedAt = Now.AddDays(-12)
}
}
});
public Task<PlayerActivityResponse> GetActivityAsync(CancellationToken ct = default) => Task.FromResult(new PlayerActivityResponse
{
Entries = new[]
{
new PlayerActivityEntry { At = Now.AddMinutes(-3), Type = "session.refresh", Success = true, ThisDevice = true },
new PlayerActivityEntry { At = Now.AddDays(-1).AddHours(-4), Type = "account.login_failed", Ip = "203.0.113.x" },
new PlayerActivityEntry { At = Now.AddDays(-1).AddHours(-4).AddMinutes(1), Type = "account.locked", Success = true, Ip = "203.0.113.x" },
new PlayerActivityEntry
{
At = Now.AddDays(-2), Type = "account.login", Success = true, Device = "Windows 10 · GameLauncher 3.4.2", Ip = "198.51.100.x"
},
new PlayerActivityEntry { At = Now.AddMonths(-2), Type = "account.password_change", Success = true, ThisDevice = true },
new PlayerActivityEntry { At = Now.AddMonths(-7).AddDays(-3), Type = "account.register", Success = true, ThisDevice = true }
}
});
public Task RemoveDeviceAsync(string deviceId, CancellationToken ct = default) => Task.CompletedTask;
public Task SignOutAsync(bool allDevices, CancellationToken ct = default)
{
_state = PlayerAccountState.SignedOut;
Changed?.Invoke();
return Task.CompletedTask;
}
public Task<byte[]> ExportAsync(string password, CancellationToken ct = default) => Task.FromResult("{}"u8.ToArray());
public Task DeleteAccountAsync(string password, CancellationToken ct = default) => SignOutAsync(true, ct);
private Task SignIn()
{
_state = PlayerAccountState.SignedIn;
Changed?.Invoke();
return Task.CompletedTask;
}
}
+38 -1
View File
@@ -13,6 +13,7 @@ using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Changelog;
using GameLauncher.Modules.Admin.Feedback;
using GameLauncher.Modules.Admin.Maintenance;
using GameLauncher.Modules.Admin.Players;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.Signing;
using GameLauncher.Modules.Admin.ViewModels;
@@ -50,6 +51,8 @@ internal sealed class AdminScenes : ISceneSet
(PagePrefix + "feedback-forms-edit", "Administration · formulaire en cours de modification (champ ajouté, contrôles, aperçu)"),
(PagePrefix + "feedback-forms-new-type", "Administration · type de demande ajouté (icône importée, sans captures d'écran)"),
(PagePrefix + "maintenance-edit", "Administration · maintenance : message en cours de modification (lien refusé)"),
(PagePrefix + "players-accounts-detail", "Administration · compte joueur suspendu : fiche et mot de passe temporaire affiché une fois"),
(PagePrefix + "players-accounts-disable", "Administration · compte joueur : désactivation en cours (motif avec un lien refusé)"),
("admin-login-locked", "Administration · session verrouillée")
};
@@ -300,6 +303,40 @@ internal sealed class AdminScenes : ISceneSet
await ShootAllThemesAsync(maintenanceId, "Administration · maintenance : message en cours de modification (lien refusé)");
}
// ---- Comptes joueurs : compte suspendu (double authentification, peu de codes de secours, essais depuis une IP
// inconnue), mot de passe temporaire créé et affiché une seule fois ; puis désactivation d'un autre compte avec un
// motif contenant un lien (refusé, bouton désactivé). Rien n'est enregistré.
var playersId = PagePrefix + "players-accounts-detail";
var disableId = PagePrefix + "players-accounts-disable";
if (capture.WantsAny(new[] { playersId, disableId }.SelectMany(id => capture.Themes.Select(t => id + Capture.Suffix(t)))) &&
workspace.PanelItems.FirstOrDefault(p => p.Descriptor.Id == PlayersPanelViewModel.PanelId) is { } playersItem)
{
workspace.SelectedPanel = playersItem;
await WaitForPanelAsync(capture, playersItem, api);
if (playersItem.ViewModel is PlayersPanelViewModel players && players.Items.FirstOrDefault(i => i.IsSuspended) is { } suspended)
{
players.SelectedItem = suspended;
await WaitForPanelAsync(capture, playersItem, api);
dialogs.AnswerConfirmations = true; // confirmation de la création du mot de passe temporaire
await players.TemporaryPasswordCommand.ExecuteAsync();
dialogs.AnswerConfirmations = false;
await ShootAllThemesAsync(playersId, "Administration · compte joueur suspendu : fiche et mot de passe temporaire affiché une fois");
if (players.Items.FirstOrDefault(i => i.IsActive && i.MustChangePassword) is { } other)
{
players.SelectedItem = other;
await WaitForPanelAsync(capture, playersItem, api);
players.OpenDisableCommand.Execute(null);
players.DisableReason = "Triche signalée, voir discord.gg/exemple";
}
await ShootAllThemesAsync(disableId, "Administration · compte joueur : désactivation en cours (motif avec un lien refusé)");
}
else
{
capture.GlobalNotes.Add("⚠ Comptes joueurs : page ou compte suspendu introuvable (voir SampleWorld.CreatePlayers).");
}
}
// ---- Verrouillage
await shell.LockAsync("Session verrouillée après 10 minutes sans activité.");
await ShootAllThemesAsync("admin-login-locked", "Administration · session verrouillée");
@@ -344,7 +381,7 @@ internal sealed class AdminScenes : ISceneSet
return new ModuleCatalog(new ILauncherModule[]
{
new AdminModule(), new CatalogAdminModule(), new ChangelogAdminModule(), new PublishAdminModule(),
new AccountsAdminModule(), new SigningAdminModule(), new AuditAdminModule(), new FeedbackAdminModule()
new AccountsAdminModule(), new SigningAdminModule(), new AuditAdminModule(), new FeedbackAdminModule(), new PlayersAdminModule()
});
}
+11
View File
@@ -107,6 +107,17 @@ internal sealed class PlayerScenes : ISceneSet
DialogKind.Error);
}),
new("main-checking", "Fenêtre principale · démarrage (vérification)", (g, f) => { }),
// Boutons « Aide » et « Compte » de la barre de titre (comptes joueurs activés), joueur connecté (pastille verte).
new("main-account", "Fenêtre principale · boutons « Aide » et « Compte » (joueur connecté)", (g, f) =>
{
Installed(g, f, "1.4.2", "1.4.2");
State(g, LauncherState.Ready, "Prêt à jouer", "Version 1.4.2");
}, l =>
{
l.IsHelpAvailable = true;
l.IsAccountAvailable = true;
l.ApplyAccountState(AccountButtonState.SignedIn, "j***@e***.fr");
}),
// Mode maintenance (publié depuis le menu d'administration) : palette rouge / orange, carte au centre, tout bloqué
// sauf la désinstallation. Avec une précision de l'administrateur, puis sans (jeu non installé).
new("main-maintenance", "Fenêtre principale · mode maintenance (avec message)", (g, f) =>
+1 -1
View File
@@ -31,7 +31,7 @@ internal sealed class SnapshotRunner
var themes = ResolveThemes();
var repository = Fixture.FindRepositoryRoot();
using var fixture = new Fixture(repository);
var sets = new ISceneSet[] { new PlayerScenes(fixture), new FeedbackScenes(fixture), new AdminScenes(fixture) };
var sets = new ISceneSet[] { new PlayerScenes(fixture), new FeedbackScenes(fixture), new AccountScenes(fixture), new AdminScenes(fixture) };
if (_options.List)
{
+1 -1
View File
@@ -2,7 +2,7 @@
<!--
Outil de développement « UiSnapshot » : rend chaque écran du launcher en PNG (fenêtre principale dans
tous ses états, paramètres, boîtes de dialogue, mise à jour du launcher, fenêtre « Aide », menu
tous ses états, paramètres, boîtes de dialogue, mise à jour du launcher, fenêtres « Aide » et « Compte », menu
d'administration et chacune de ses pages), avec des données de démonstration, sans serveur ni compte réel, puis signale
les problèmes de mise en page (texte coupé, débordement, erreurs de liaison…).
Lancement : tools\ui-snapshot.bat (voir LISEZMOI.md).
+2 -1
View File
@@ -3,7 +3,7 @@ rem ============================================================================
rem Captures de toutes les fenêtres du launcher (outil tools\UiSnapshot).
rem Compile le launcher et l'outil, puis écrit dans artifacts\ui-snapshots :
rem - une image par écran, PNG ou WebP (états de la fenêtre principale, paramètres, dialogues,
rem mise à jour du launcher, menu d'administration et chacune de ses pages) ;
rem mise à jour du launcher, fenêtres « Aide » et « Compte », menu d'administration et chacune de ses pages) ;
rem - index.html : planche de toutes les captures ;
rem - report.md : problèmes détectés (texte coupé, débordement, liaisons…).
rem Données de démonstration : aucun serveur, aucune clé ni donnée réelle.
@@ -12,6 +12,7 @@ rem Exemples :
rem tools\ui-snapshot.bat
rem tools\ui-snapshot.bat --filter "main-*" --scale 2
rem tools\ui-snapshot.bat --filter "admin-page-*" --theme all --open
rem tools\ui-snapshot.bat --filter "account-*,main-account" (fenêtre « Compte » du joueur)
rem tools\ui-snapshot.bat --filter "main-*" --format webp --quality 85 (images du site vitrine)
rem tools\ui-snapshot.bat --list
rem ============================================================================