Compare commits

...
64 Commits
Author SHA1 Message Date
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
fred b398e4059a Merge pull request 'Dev/3.4.2' (#150) from dev/3.4.2 into master
Reviewed-on: #150
Reviewed-by: Deville <fred@lanisys.fr>
2026-11-10 20:58:21 +01:00
nicolas 69f943ea53 Notes de version du launcher 3.4.2 2026-11-10 20:52:22 +01:00
nicolas 4c368cb1e0 Documentation : audit des formulaires des demandes 2026-11-10 20:52:13 +01:00
nicolas eca812a718 UiSnapshot : exemples d'audit des formulaires 2026-11-10 20:51:58 +01:00
nicolas e4f2d443e8 Tests : audit détaillé des formulaires 2026-11-10 20:51:46 +01:00
nicolas 6e7b480db5 Menu d'administration : libellés des actions d'audit des types de demande 2026-11-10 20:51:33 +01:00
nicolas 1c7735d25a Launcher API : audit détaillé des formulaires des demandes (par type et par champ) 2026-11-10 20:51:16 +01:00
nicolas 7b153b81ee Anonymisation de xbhost en LanisysGames 2026-11-10 20:14:46 +01:00
nicolas 8fe67053e1 Merge pull request 'Dev/3.4.1' (#144) from dev/3.4.1 into master
Reviewed-on: #144
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 16:59:13 +01:00
nicolas 927df6c2b2 Version 3.4.1 2026-11-10 16:55:01 +01:00
nicolas 197f5186d0 UiSnapshot : captures en WebP (--format webp, --quality)
- --format png|webp (png par défaut), --quality 1-100 (85 par défaut,
  100 = sans perte) ; transparence conservée, pages .p2.webp comprises.
- Encodage WebP par SkiaSharp 3.119.1 (Microsoft, MIT) : WIC lit le WebP sans
  savoir l'écrire. Outil de développement uniquement.
- index.html et report.md reconnaissent PNG et WebP ; une série complète
  efface aussi les anciens .webp.
- LISEZMOI : captures pour le site vitrine ; README §5 et §9 mis à jour.
2026-11-10 16:50:13 +01:00
nicolas 98cf17c294 Merge pull request 'Web/1.0.0' (#143) from web/1.0.0 into master
Reviewed-on: #143
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 16:29:33 +01:00
nicolas 4da86bcc6a README : site vitrine (§9)
Ligne GameLauncher.Website dans la structure de la solution, nouvelle section
§9 : pages, thèmes, isolation de la Launcher API, téléchargement vérifié,
sécurité HTTP, service durci, liseuse Markdown, tests, captures à mettre à jour.
2026-11-10 16:17:16 +01:00
nicolas 52761f53b6 Site vitrine : publication et déploiement sur le VPS
- publish-site.bat : exe autonome linux-x64, archive et empreinte SHA-256.
- installer-site.sh :
  - archive vérifiée par son empreinte ;
  - binaires dans /opt/lanisys-site (propriétaire root) ;
  - appsettings.Production.json conservé d'une version à l'autre ;
  - sorties réseau autorisées recalculées (serveur du launcher + DNS) dans
    sortie.conf ;
  - retour à la version précédente si le site ne répond pas.
- lanisys-site.service :
  - compte temporaire (DynamicUser), système en lecture seule, aucun privilège ;
  - dossier personnel /var/lib/lanisys-site (700), nécessaire à .NET pour
    vérifier un certificat HTTPS sous Debian 11 ;
  - connexions sortantes limitées au serveur du launcher ;
  - mémoire limitée.
- nginx-site.conf :
  - redirection http → https et domaine nu → www ;
  - HTTP/2 compatible nginx 1.18 (listen … http2) ;
  - TLS 1.2 et 1.3, renouvellement Let's Encrypt par webroot ;
  - lectures seulement, fichiers cachés jamais servis, gzip, limite de
    requêtes (nginx-site-zones.conf).
- LISEZMOI.md : pourquoi un VPS séparé, domaine, publication, installation,
  nginx et HTTPS, mentions légales, journaux, mise à jour, écriture des
  actualités, ajout d'un jeu, test en local, dépannage, fichiers sur le VPS.
  Debian 11, 12 ou 13.
2026-11-10 16:16:58 +01:00
nicolas cf75a22f19 Tests : site vitrine
Les sources du site (Services, Options) et la clé publique sont compilées
dans le projet de tests, sans référence au projet du site : une référence
copierait l'appsettings.json du site à la place de celui de la Launcher API
dans le dossier des tests.

Tests :
- launcher-update.json : signature valide, contenu modifié, autre clé,
  exécutable hors du serveur (autre hôte, http, autre port, paramètres),
  produit inattendu, clé intégrée ;
- manifest : bornes et nettoyage des notes, autre jeu, fichier illisible ;
- réseau : adresses https uniquement, lecture bornée, message d'erreur
  détaillé (certificat refusé) ;
- Markdown : HTML brut, liens javascript:, data: et //, injection
  d'attribut, images externes, syntaxe prise en charge ;
- actualités : en-tête, noms de fichiers invalides, brouillons.
2026-11-10 16:16:26 +01:00
nicolas 009c84de9f Site vitrine : premières actualités
- Ouverture du site.
- Exovival Zombie Reborn 1.8 : zombies rôdeurs, ouïe des zombies, armes,
  soins.
- Launcher 3.4 : versions prioritaires.
2026-11-10 16:15:28 +01:00
nicolas 9f9b9b9d4d Site vitrine : images, polices et icônes
- Polices auto-hébergées Sora, Barlow et Barlow Condensed (WOFF2), avec leurs
  licences OFL : aucune requête vers un service externe.
- Captures d'Exovival : couloir de l'école, encre UV, images des cassettes VHS
  sans les dates qui révèlent un code, et vues de l'abri en développement. Le
  tout en WebP.
- Captures du launcher (docs/preview*.png et UiSnapshot) en WebP.
- Favicon et icônes reprises du launcher, images de partage (Open Graph) du
  launcher et d'Exovival.
2026-11-10 16:14:55 +01:00
nicolas 12ab8377c9 Site vitrine : pages, thèmes par univers et script
Pages :
- accueil ;
- /launcher : présentation des fonctions joueur, téléchargement vérifié
  (version, taille, empreinte SHA-256), première installation, notes de la
  dernière version, lien vers les publications Gitea, questions fréquentes ;
- /jeux et /jeux/exovival-zombie-reborn : histoire, contenu, cassettes VHS,
  extension « L'abri » en développement, configuration ;
- /jeux/<jeu>/notes-de-version : toutes les versions ;
- page générique des jeux en préparation (projet multijoueur) ;
- actualités, mentions légales, confidentialité, erreurs 404 et 500.

Thèmes par page (data-theme) :
- launcher : violet et cyan, police Sora ;
- exovival : noir et vert, Barlow Condensed ; en-tête « lampe UV » qui révèle
  le message de Léa sous le pointeur ;
- multi : ambre.

Notes de version affichées comme dans le launcher (titre, sous-titre,
introduction, sections, étiquettes), textes toujours encodés.

Bouton « Nous contacter » : simple lien mailto. Aucun formulaire.

Responsive jusqu'au mobile, focus clavier visible, mouvement réduit respecté.
Script sans dépendance (menu mobile, copie de l'empreinte, lampe UV),
compatible avec la CSP (aucun script en ligne).
2026-11-10 16:14:00 +01:00
nicolas 5aa22df409 Site vitrine : nouveau projet GameLauncher.Website (ASP.NET Core Razor Pages)
Projet ajouté à la solution (dossier « website »), sans paquet NuGet tiers.
Références : GameLauncher.Contracts et GameLauncher.Core.

Isolé de la Launcher API : le site ne l'appelle jamais. En arrière-plan, côté
serveur, il lit seulement les fichiers publics launcher-update.json et
manifest.json :
- toutes les 10 minutes, avec requêtes conditionnelles (ETag, Last-Modified) ;
- https uniquement, aucune redirection suivie, taille et délai bornés.
Les pages ne déclenchent aucune requête sortante.

launcher-update.json :
- signature vérifiée avec la clé update-signing.pub, intégrée à l'exe du site,
  par le même code que le launcher (UpdateSignature) ;
- l'exécutable doit être sur le même serveur, en https ;
- produit attendu facultatif (Site:Launcher:Product) ;
- fichier refusé : téléchargement retiré ; serveur injoignable : dernière
  version valide conservée.

Notes de version : bornées et nettoyées comme dans le launcher
(ChangelogLimits.Normalize). Sections réservées à l'équipe masquées pour le
launcher (Site:Launcher:HiddenSections).

Certificat HTTPS du serveur du launcher : même règle que par défaut ; en cas de
refus, le journal détaille la chaîne de certificats (TlsDiagnostics) et la
cause complète de l'erreur réseau.

Actualités en Markdown (Content/News) : liseuse maison SafeMarkdown.
- tout le texte est encodé ;
- liens http(s), mailto et chemins du site seulement ;
- images du site (/img/) seulement.

Sécurité HTTP :
- CSP stricte (aucun script en ligne, aucune ressource externe), HSTS,
  nosniff, X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP/CORP ;
- GET/HEAD seulement (405 sinon) ;
- limite de requêtes par IP ;
- aucun cookie, clés de protection des données en mémoire ;
- en-tête Server retiré.

Autres :
- réglages contrôlés au démarrage (SiteOptionsValidator) ;
- dates en français sans dépendance à ICU (InvariantGlobalization) ;
- robots.txt, sitemap.xml, /healthz réservé à la machine locale.
2026-11-10 16:12:47 +01:00
nicolas 3df009912b Git : fins de ligne LF pour website/deploy/*.conf
Les configurations nginx du site vitrine sont copiées telles quelles sur le VPS :
même règle que server/deploy/*.conf.
2026-11-10 16:10:33 +01:00
nicolas 89570947aa Merge pull request 'Dev/3.4.0' (#142) from dev/3.4.0 into master
Reviewed-on: #142
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 13:49:24 +01:00
nicolas 14b2167218 Launcher 3.4.0 : mises à jour prioritaires
- Version 3.3.1 → 3.4.0
- Notes de version (notes-launcher.txt, RELEASE-3.4.0.md)
- README §7 : section « Version prioritaire », sécurité, publication
2026-11-10 13:35:25 +01:00
nicolas 2e4d2ee76e UiSnapshot : scène launcher-update-priority 2026-11-10 13:34:37 +01:00
nicolas 7d9032fe20 Publication : options --priority et --clear-priority
- LauncherPublisher publish : --priority (confirmation o/N), --clear-priority,
  --yes ; priorité reprise du launcher-update.json précédent s'il est signé
- Ligne « Priorité » dans publish et verify
- publish-launcher-update.bat : paramètres --priority, --clear-priority,
  --help (shift /1 pour garder %~dp0)
2026-11-10 13:34:13 +01:00
nicolas ccd6b1a430 Interface : pastille « Prioritaire » dans la fenêtre de mise à jour du launcher
Sous-titre dédié précisant que le réglage du joueur ne change pas.
2026-11-10 13:33:02 +01:00
nicolas e4a21c6f5d Launcher : version prioritaire installée même si la MAJ auto est désactivée
- Recherche au démarrage même option désactivée ; seule une version
  prioritaire est alors installée, self-update.json jamais modifié
- Erreur de téléchargement d'une version prioritaire : retentée au
  démarrage suivant
- Version prioritaire non installée : avertissement dans le bandeau et
  statut orange dans les Paramètres du launcher
2026-11-10 13:32:42 +01:00
nicolas 1a7f45c38b Tests : versions prioritaires du launcher (8 tests, 12 cas)
Signature, compatibilité sans priorité, launchers forcés ou non,
validation, reprise / retrait par l'outil de publication,
self-update.json sans champ de priorité.
2026-11-10 13:32:05 +01:00
nicolas b48d4222db Core : version prioritaire du launcher dans launcher-update.json (signée)
- LauncherRelease.PriorityVersion : dernière version prioritaire publiée,
  dans le payload signé (ignorée par les launchers < 3.4.0)
- IsPriorityRelease, IsPriorityFor(), TryGetPriorityVersion()
- ResolvePriorityVersion() : marquer, reprendre ou retirer la priorité
  d'une publication à l'autre
- Validate() refuse une version prioritaire illisible ou supérieure à la
  version publiée
- LauncherUpdateCheck.IsPriority
2026-11-10 13:31:36 +01:00
nicolas 51eecbb57a Merge pull request 'Dev/3.3.1' (#139) from dev/3.3.1 into master
Reviewed-on: #139
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 12:30:21 +01:00
nicolas edd2633455 chore(release): launcher 3.3.1 2026-11-10 12:18:55 +01:00
nicolas 0e26e6eb11 docs: rafraîchissement du trousseau (3.3.1) 2026-11-10 12:18:22 +01:00
nicolas 916b53446a fix(trust): retélécharger le trousseau quand une clé connue n'est pas autorisée, âge max 30 min 2026-11-10 12:17:50 +01:00
nicolas 0e371518b5 Merge pull request 'Dev/3.3.0' (#138) from dev/3.3.0 into master
Reviewed-on: #138
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 11:50:25 +01:00
nicolas 153e498345 Tests et documentation du mode maintenance
- 11 nouveaux tests (367 au total) ; SigningPanelTests : ligne « Mode maintenance » en dernier
- README, ADMIN-ARCHITECTURE (§ 7, § 11 septies, § 13), notes-launcher 3.3.0
2026-11-10 11:40:43 +01:00
nicolas 1e9f49a733 ui-snapshot : scènes du mode maintenance
main-maintenance, main-maintenance-plain, admin-page-maintenance, admin-page-maintenance-edit ;
routes v1/maintenance simulées ; ligne « Mode maintenance » dans admin-page-signing
2026-11-10 11:37:50 +01:00
nicolas 5f6c667030 Launcher 3.3.0 : mode maintenance
- Thème rouge / orange, carte « Maintenance en cours » au centre, bouton « MAINTENANCE »
- Tout est bloqué sauf la désinstallation et le menu d'administration
  (commandes désactivées + refus à l'entrée de chaque action)
- État lu avant la recherche des mises à jour (4 s au plus), puis relu périodiquement
- Page « Maintenance » et bandeau du tableau de bord (Admin.xaml), icône TrafficCone
- Version 3.3.0
2026-11-10 11:37:28 +01:00
nicolas 93dd844221 Menu admin : page « Maintenance »
- Activer (confirmation), modifier le message, terminer ; contrôle du trousseau des joueurs
- Le launcher de ce PC relit l'état aussitôt (NotifyMaintenanceChanged)
- Tableau de bord : bandeau « Maintenance en cours »
- Page « Signature » : ligne « Mode maintenance » ; journal d'audit : catégorie dédiée
2026-11-10 11:36:01 +01:00
nicolas 5ad8025946 Core : lecture et vérification du mode maintenance ; client de l'API
- MaintenanceService : lecture au démarrage puis toutes les 1 à 2 min,
  signature vérifiée (contentSigning), retour en arrière refusé, hors ligne = pas de maintenance
- ContentTrustService.VerifyMaintenanceAsync
- AdminSession : GetMaintenanceAsync, PublishMaintenanceAsync
2026-11-10 11:34:55 +01:00
nicolas 080000e13c Déploiement : bloc nginx maintenance.json et étape 3 decies 2026-11-10 11:34:30 +01:00
nicolas 3422b92f81 Serveur : module Maintenance (GET / PUT v1/maintenance)
- Publication signée (maintenance.manage + document signé par la clé de la session)
- Révision attendue (409), début inchangé pendant une maintenance
- Fichier public maintenance.json à côté du trousseau, historique de 20 révisions
- Audit maintenance.enable / update / disable
- GET v1/signing renvoie la signature du mode maintenance
2026-11-10 11:34:12 +01:00
nicolas 683cc7a9dc Contrats : document du mode maintenance, permission et autorisation de signature
- MaintenanceDocument / MaintenanceState / MaintenancePublishRequest
- MaintenanceRules : règles communes (message 300 car., 6 lignes, sans lien, condensé signé)
- Permission maintenance.manage (Propriétaire, Administrateur sur installation neuve)
- Type de contenu et autorisation du trousseau « maintenance »
- Route v1/maintenance ; SigningState.Maintenance
2026-11-10 11:33:04 +01:00
nicolas 7bc229de53 Merge pull request 'Dev/3.2.1' (#136) from dev/3.2.1 into master
Reviewed-on: #136
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 10:03:15 +01:00
nicolas 83e3d61634 Version 3.2.1 et notes de mise à jour 2026-11-10 10:01:53 +01:00
nicolas b088c6caa7 Docs et notes 3.2.0 : page « Signature » 2026-11-10 09:48:15 +01:00
nicolas 658a1cce72 ui-snapshot : formulaires dans la page « Signature » 2026-11-10 09:47:47 +01:00
nicolas bac8ebeb55 Admin : formulaires des demandes dans la page « Signature » 2026-11-10 09:47:26 +01:00
nicolas 76eaa48342 Signature : état de signature des formulaires des demandes (serveur et contrat) 2026-11-10 09:46:55 +01:00
nicolas 97df0ba721 Merge pull request 'Dev/3.2.0' (#135) from dev/3.2.0 into master
Reviewed-on: #135
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 09:25:36 +01:00
217 changed files with 18713 additions and 313 deletions

No files matched your search

+1
View File
@@ -68,3 +68,4 @@
*.sh text eol=lf
*.service text eol=lf
server/deploy/*.conf text eol=lf
website/deploy/*.conf text eol=lf
+3 -3
View File
@@ -3,9 +3,9 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Company>Xbhost</Company>
<Authors>Xbhost</Authors>
<Copyright>© Xbhost</Copyright>
<Company>LanisysGames</Company>
<Authors>LanisysGames</Authors>
<Copyright>© LanisysGames</Copyright>
<Version>1.0.0</Version>
</PropertyGroup>
</Project>
+35
View File
@@ -33,6 +33,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
tools\ui-snapshot.bat = tools\ui-snapshot.bat
tools\sign-keyring.bat = tools\sign-keyring.bat
tools\publish-server.bat = tools\publish-server.bat
tools\publish-site.bat = tools\publish-site.bat
server-example\web.config = server-example\web.config
EndProjectSection
EndProject
@@ -70,6 +71,21 @@ 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}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14}"
ProjectSection(SolutionItems) = preProject
website\deploy\LISEZMOI.md = website\deploy\LISEZMOI.md
website\deploy\installer-site.sh = website\deploy\installer-site.sh
website\deploy\lanisys-site.service = website\deploy\lanisys-site.service
website\deploy\nginx-site.conf = website\deploy\nginx-site.conf
website\deploy\nginx-site-zones.conf = website\deploy\nginx-site-zones.conf
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -174,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
@@ -182,6 +206,14 @@ Global
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|Any CPU.Build.0 = Release|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.ActiveCfg = Release|x64
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.Build.0 = Release|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.ActiveCfg = Debug|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.Build.0 = Debug|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.Build.0 = Release|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.ActiveCfg = Release|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -200,7 +232,10 @@ 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}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
+85 -7
View File
@@ -32,7 +32,7 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| Projet | Rôle |
|---|---|
| `src/GameLauncher` | Application WPF (fenêtres, thème, ViewModels, registre, raccourci) |
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`) |
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`), mode maintenance (`Maintenance/`) |
| `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) |
@@ -41,6 +41,7 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| `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.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) |
| `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`) |
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
@@ -52,7 +53,8 @@ src/GameLauncher/
├─ Assets/ background.png (thème par défaut), Themes/exovival-background.jpg, launcher.ico
├─ Themes/ Default.xaml et Exovival.xaml (thèmes), Icons.xaml (icônes), Controls.xaml (styles),
│ Changelog.xaml (modèles des notes de version), Admin.xaml (menu d'administration,
│ dont les pages « Jeux », « Notes de version » et « Publier une version »)
│ 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 »)
│ └─ Admin/ AdminWindow, LoginView, EnrollView (menu d'administration ; ViewModels dans GameLauncher.Modules.Admin)
@@ -366,7 +368,7 @@ fait au démarrage suivant. Les entrées *Applications installées* (`Uninstall\
| Vignette d'un jeu (liste des jeux) | `launcher.json` → `"icon"` (image carrée) ; sinon fond du thème + initiales |
| Icône | Remplacez `Assets/launcher.ico` |
| Icônes de l'interface | `Themes/Icons.xaml` (tracés 24×24, compatibles [Material Design Icons](https://pictogrammers.com/library/mdi/)) |
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran (ou WebP avec `--format webp`) dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` ; sans recompiler : menu d'administration › **Jeux** (§8) |
| Fréquence des recherches de mises à jour | `launcher.json` → `updateCheckIntervalMinutes` |
| Dossier des données du jeu Unity (« Société\Produit ») | `launcher.json` → `gameDataFolder` |
@@ -428,7 +430,7 @@ Le launcher (un seul `GameLauncher.exe`) vérifie au démarrage s'il existe une
lui-même, la télécharge, l'installe et **redémarre tout seul**. Réglable dans **Paramètres du launcher** (roue crantée) :
interrupteur « Mettre à jour le launcher automatiquement » (commun à tous les jeux) et bouton
**« Vérifier les mises à jour »** (puis « Installer la x.y.z »). Option désactivée : un bandeau signale
simplement qu'une nouvelle version existe.
simplement qu'une nouvelle version existe — sauf pour une **version prioritaire** (ci-dessous), installée quand même.
### Sécurité
@@ -440,6 +442,7 @@ simplement qu'une nouvelle version existe.
| Pas de rétrogradation | Seule une version **strictement supérieure** est installée ; le produit (`launcherId`) doit correspondre. |
| Tailles bornées | `launcher-update.json` ≤ 256 Ko ; un serveur qui envoie plus que la taille annoncée est coupé (vaut aussi pour les fichiers du jeu). |
| Retour arrière automatique | Si la nouvelle version ne démarre pas dans les 60 s (ou n'est pas la version annoncée), elle est arrêtée et l'ancienne remise en place. Cette version n'est plus proposée automatiquement (bouton manuel uniquement). |
| Version prioritaire signée | L'indication « prioritaire » fait partie du contenu signé : un serveur compromis ne peut ni imposer une mise à jour, ni retirer la priorité d'un correctif. Elle ne permet pas d'installer autre chose qu'une version signée, strictement supérieure. |
### Déroulement
@@ -462,6 +465,22 @@ La mise à jour est refusée tant qu'un jeu est lancé ou qu'un téléchargement
est dans un dossier protégé (Program Files), il ne peut pas se remplacer : le message l'explique.
Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la version publiée se remplace.
### Version prioritaire (correctif critique)
Une version publiée avec **`--priority`** est installée au démarrage **même chez les joueurs qui ont
désactivé** « Mettre à jour le launcher automatiquement ». Exemple : une faille critique est corrigée en 3.4.0.
| Point | Comportement |
|---|---|
| Réglage du joueur | **Jamais modifié** : `autoUpdate` reste à `false` dans `self-update.json`. Effet ponctuel : une fois le launcher à jour, les versions normales suivantes ne sont plus installées automatiquement chez lui. |
| Versions suivantes | La priorité est **reprise** par les publications suivantes (`priorityVersion` dans `launcher-update.json`) : si la 3.5.0 (normale) sort après le correctif 3.4.0, un launcher encore en 3.3.x passe directement en 3.5.0 ; un launcher déjà en 3.4.0 n'est pas concerné. `--clear-priority` arrête la reprise (priorité publiée par erreur). |
| Fenêtre de mise à jour | Pastille **« Prioritaire »** et texte « Mise à jour importante… votre réglage ne change pas ». « Plus tard » reste possible : nouvelle tentative au démarrage suivant, bandeau d'avertissement en attendant. |
| Erreur de téléchargement | Retentée au démarrage suivant (une version normale n'est plus retentée automatiquement). |
| La nouvelle version ne démarre pas | Retour arrière, puis plus de tentative automatique (sinon 60 s d'attente à chaque lancement) : bandeau d'avertissement et statut orange dans les Paramètres du launcher, installation par le bouton. |
| Dossier protégé (Program Files) | Pas d'installation possible sans droits administrateur : bandeau d'avertissement. |
| Option désactivée | Le launcher interroge désormais le serveur au démarrage (`launcher-update.json`, 1 à 2 Ko, 6 s au plus) pour savoir si une version prioritaire existe. |
| Launchers antérieurs à 3.4.0 | Ignorent l'indication : ils ne suivent que leur propre réglage. La priorité ne vaut que pour les joueurs déjà en 3.4.0 ou plus. |
### Mise en place (une seule fois)
1. **Clés** : double-cliquez `tools\keygen-launcher.bat`. Choisissez une phrase de passe (12 caractères
@@ -489,6 +508,9 @@ Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la ver
signé par votre clé. Les launchers antérieurs à 1.1.3 reçoivent toujours le titre et la liste « à plat ».
3. Double-cliquez `tools\publish-launcher-update.bat` : compilation, signature (phrase de passe demandée),
puis envoi WinSCP — l'exe d'abord, `launcher-update.json` **en dernier**.
**Correctif critique** : depuis une invite de commandes, `tools\publish-launcher-update.bat --priority`
(confirmation demandée avant la signature). `--clear-priority` ne reprend pas la priorité déjà publiée.
Le résumé affiché indique la priorité (« normale », « PRIORITAIRE » ou « version prioritaire x.y.z reprise »).
Vérification à tout moment :
`dotnet run --project tools\LauncherPublisher -- verify --manifest https://…/launcher/launcher-update.json --public-key src\GameLauncher\Security\update-signing.pub`
@@ -512,7 +534,7 @@ Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4
**Notes de version**. Phase 5 livrée : la page **Publier une version**. Phase 6 livrée : les pages
**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** (voir plus bas).
bouton **Aide** des joueurs et la page **Demandes des joueurs**, puis le **mode maintenance** (voir plus bas).
* **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
@@ -520,6 +542,18 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
molette fait défiler ce qui est sous le curseur : elle ne change jamais une liste déroulante fermée (même si
elle vient d'être utilisée), et une liste ou un champ qui ne peut plus défiler laisse défiler la page.
* **Page « Maintenance »** (permission `maintenance.manage`) : **active ou désactive le mode maintenance de tous les
launchers** (tous les jeux), avec une précision facultative pour les joueurs (6 lignes, 300 caractères, aucun lien ;
aperçu en direct). Chez les joueurs : fond et accents **rouge / orange**, carte « Maintenance en cours — merci de
revenir plus tard » au centre (avec la précision et l'heure de début), bouton principal « MAINTENANCE » ; installation,
mises à jour (automatiques comprises), vérification, réparation, localisation, lancement (raccourcis compris),
paramètres, bouton « Aide » et site officiel **bloqués** ; seules la **désinstallation** (menu « … » ou Windows) et le
menu d'administration restent disponibles. Lu au démarrage avant la recherche des mises à jour, puis toutes les 2 min
(1 min pendant une maintenance) ; le launcher de l'administrateur bascule aussitôt. **Signé** (autorisation « mode
maintenance » du trousseau) et vérifié comme le catalogue ; hors ligne au démarrage : pas de maintenance. Les
téléchargements commencés se terminent, les parties en cours ne sont pas fermées, les mises à jour du launcher
lui-même continuent. Le tableau de bord rappelle une maintenance en cours. Mise en place : `server/deploy/LISEZMOI.md`,
étape 3 decies.
* **Bouton « Aide »** (joueurs, barre de titre, bulle avec un point d'interrogation) : **avis**, **demande
d'évolution**, **bug** ou **autre**, pour le **jeu sélectionné** (affiché, non modifiable pendant la saisie). Champs
prédéfinis selon le type (note en étoiles, catégorie, gravité, fréquence, étapes pour reproduire…), contrôlés avant
@@ -534,7 +568,8 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
leurs champs (zone de texte, texte court, liste déroulante, cases à cocher, case oui/non, étoiles), modifiables ou
masqués, avec un aperçu identique à la fenêtre des joueurs et un contrôle en direct ; communs à tous les jeux, **signés** (autorisation « formulaires » du trousseau) et vérifiés par le launcher
à l'ouverture de la fenêtre « Aide », sans aucun lien autorisé dans les libellés (`server/deploy/LISEZMOI.md`,
étape 3 nonies). Chaque demande garde le formulaire tel qu'à l'envoi.
étape 3 nonies). Chaque demande garde le formulaire tel qu'à l'envoi. Journal d'audit : chaque publication indique
les types ajoutés, modifiés ou supprimés par leur nom, avec le détail de chaque champ modifié.
* **Page « Demandes des joueurs »** (permission `feedback.read` ; traitement : `feedback.manage`) : choix du jeu
(nombre de nouvelles demandes sur chaque tuile), filtres (type, statut, recherche), fiche complète, statut (Nouvelle,
En cours, Traitée, Rejetée, Doublon), note interne, suppression, captures d'écran (miniature, agrandissement). Une
@@ -627,7 +662,9 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
depuis la page **Signature** (propriétaires), qui montre aussi l'état de chaque contenu. Côté joueur,
`"keyringUrl"` et `"contentSigning"` (`"warn"` puis `"require"`) dans `launcher.json` (intégré à l'exe) ;
en « require », un manifest non signé, mal signé ou plus ancien que le dernier vu est refusé (le jeu installé
reste jouable), et un catalogue refusé est ignoré. Après le retrait d'une clé compromise, relevez
reste jouable), et un catalogue refusé est ignoré. Le trousseau est retéléchargé sans redémarrer le launcher quand
un contenu est signé par une clé absente ou non autorisée pour ce contenu (au plus une fois par minute), et au plus
toutes les 30 minutes (une clé retirée n'est plus acceptée par un launcher laissé ouvert). Après le retrait d'une clé compromise, relevez
`"keyringMinSequence"` au numéro du nouveau trousseau. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 quinquies.
* **Page « Journal d'audit »** (permission `audit.read` : propriétaires et administrateurs) : connexions,
invitations, approbations, publications, modifications et refus, les plus récents d'abord (par pages
@@ -672,3 +709,44 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
| Créer sa clé, tester la connexion, inviter/approuver | Menu d'administration du launcher (pages « Mes appareils », « Administrateurs », « Groupes et permissions ») ; en ligne de commande : `tools\admin-key.bat` (`help` pour la liste) |
| Signer le trousseau des clés autorisées à signer le contenu | Page « Signature » › « Exporter la demande… », puis glisser le fichier sur `tools\sign-keyring.bat` (clé racine, hors ligne ; nouvelles clés signalées, à vérifier de vive voix), puis « Publier le trousseau signé… » |
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
## 9. Site vitrine
Site public du studio : `website/GameLauncher.Website` (ASP.NET Core Razor Pages, .NET 8), à déployer de
préférence sur un **autre VPS** que celui du launcher. Installation, nginx, HTTPS, mentions légales :
**`website/deploy/LISEZMOI.md`**. Publication : `tools\publish-site.bat`. En local : projet de démarrage
*GameLauncher.Website*, F5 → `http://127.0.0.1:5090/`.
| Page | Contenu |
|---|---|
| `/` | Accueil : téléchargement du launcher, jeux, dernières actualités |
| `/launcher` | Présentation du launcher (fonctions joueur), téléchargement, première installation, notes de la **dernière** version, lien vers les publications Gitea, questions fréquentes |
| `/jeux`, `/jeux/exovival-zombie-reborn` | Liste des jeux ; page d'Exovival (histoire, contenu, cassettes, extension « L'abri » en développement, configuration) |
| `/jeux/<jeu>/notes-de-version` | **Toutes** les notes de version du jeu, lues dans son `manifest.json` |
| `/jeux/projet-multijoueur` | Jeu en préparation (page générique, `Site:Games` d'`appsettings.json`) |
| `/actualites` | Articles en Markdown : `website/GameLauncher.Website/Content/News/*.md` |
| `/mentions-legales`, `/confidentialite` | Éditeur, hébergeur ; données (aucun cookie, journaux 14 jours) |
* **Thème par univers** : `launcher` (violet / cyan, comme le thème « default »), `exovival` (noir, vert, titres
condensés ; en-tête « lampe UV » qui révèle un message à l'encre invisible) et `multi` (jeu en préparation).
* **Isolé de la Launcher API** : le site ne l'appelle jamais. Seuls `launcher-update.json` et les `manifest.json`
sont lus, **côté serveur**, en arrière-plan (toutes les 10 min, requêtes conditionnelles, taille et délai bornés,
https uniquement, aucune redirection suivie). Les pages ne déclenchent aucune requête sortante.
* **Téléchargement du launcher** : affiché seulement si la signature de `launcher-update.json` est valide (même
clé publique et même code que le launcher, clé intégrée à l'exécutable du site) et si l'exe est sur le même
serveur. Version, taille et empreinte SHA-256 affichées. Fichier refusé = bouton retiré ; serveur injoignable =
dernière version valide conservée. Les sections « Serveur », « Développement »… des notes du launcher ne sont
pas affichées (`Site:Launcher:HiddenSections`).
* **Notes de version** : mêmes bornes et même nettoyage que dans le launcher (`ChangelogLimits.Normalize`), textes
toujours encodés (jamais interprétés comme du HTML).
* **Sécurité HTTP** : politique de sécurité du contenu stricte (aucun script en ligne, aucune ressource externe,
polices servies par le site), HSTS, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, `Permissions-Policy` ;
lecture seule (`GET`/`HEAD`, le reste en 405) ; limite de requêtes par IP (site + nginx) ; aucun cookie ;
aucune donnée écrite sur le disque.
* **Service** (`website/deploy/lanisys-site.service`) : compte temporaire systemd, système en lecture seule,
connexions sortantes limitées au serveur du launcher (liste écrite par `installer-site.sh`).
* **Liseuse Markdown** des actualités (`Services/SafeMarkdown.cs`, sans dépendance) : HTML brut affiché tel quel,
liens `javascript:`/`data:` retirés, images du site uniquement.
* 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`).
+97 -7
View File
@@ -68,7 +68,7 @@ Conséquences :
| `src/GameLauncher.Modules` | net8.0 | **Nouveau (phase 2).** Socle des modules, sans WPF : `ILauncherModule`, registre, pages d'administration, boîtes de dialogue abstraites, MVVM commun |
| `src/GameLauncher.Modules.Admin` | net8.0 | **Nouveau (phase 2).** Module « Administration » : services (clés, connexion, invitation, verrouillage) et ViewModels, testés sans interface |
| `src/GameLauncher.Modules.Feedback` | net8.0 | **Nouveau (7d).** Module « Aide » côté joueur : formulaire de demande (avis, évolution, bug, autre) généré à partir des formulaires partagés, envoi anonyme (§ 11 quinquies) |
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) |
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) ; **7f** : lecture et vérification du mode maintenance (`Maintenance/`) |
| `src/GameLauncher.Contracts` | net8.0 | **Nouveau.** Protocole partagé launcher ⇄ serveur : routes, DTO, rôles et permissions, messages signés, contrôle des clés publiques ; catalogue (phase 3) ; modèle et bornes des notes de version (phase 4) |
| `src/GameLauncher.ApiClient` | net8.0 | **Nouveau.** Client HTTP de l'API (connexion par défi signé, requêtes signées), fichier de clé chiffré |
| `server/GameLauncher.Server` | net8.0 (ASP.NET Core) | **Nouveau.** Launcher API : modules serveur, authentification, administrateurs, audit, outil en ligne de commande |
@@ -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
@@ -324,6 +325,8 @@ premier démarrage ou à la migration d'`admins.json` (format 1 → 2) avec les
| `files.manage` | ✔ | ✔ | | Fichiers du serveur : consultation du site, suppression des fichiers inutilisés, nettoyage d'« objects » |
| `feedback.read` | ✔ | ✔ | | Demandes des joueurs : lecture (consultations inscrites au journal) — § 11 quinquies |
| `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 |
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
@@ -825,7 +828,7 @@ avant refusent encore un trousseau contenant `forms` et gardent le précédent (
| Route | Accès | Signée | Rôle |
|---|---|:-:|---|
| `GET v1/feedback/forms` | administrateur connecté | | Formulaires en vigueur, publication possible, adresse publique, signataire |
| `PUT v1/feedback/forms` | `feedback.forms` | ✔ | Publication : `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 sinon), audit `feedback.forms` (changements par type) |
| `PUT v1/feedback/forms` | `feedback.forms` | ✔ | Publication : `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 sinon), audit `feedback.forms` (révision, types ajoutés, modifiés et supprimés par leur nom, signature), puis une ligne par type de demande : `feedback.form_create`, `feedback.form_update` ou `feedback.form_delete`, cible « nom du type », détail de chaque changement (nom, description, réglages, champ ajouté, supprimé ou modifié avec ce qui a changé : libellé, type, obligatoire, aide, longueur, étoiles, choix, aperçu), sur plusieurs lignes numérotées (1/2, 2/2…) au-delà de 480 caractères |
**Serveur** (`Storage/FeedbackFormsStore`) : référence privée `feedback-forms.json` (+ `.bak`, historique de 20
révisions), copie **publique** à côté du trousseau (`FeedbackFormsPublicFileFullPath`, 644, atomique ; public d'abord).
@@ -866,6 +869,78 @@ autorisation « formulaires »), signature par la clé de ce PC, requête signé
« Recharger », rien n'est écrasé). « Annuler les modifications », « Revenir à l'origine » (formulaires d'origine dans
l'éditeur, à publier ensuite), « Recharger ». Libellé d'audit : « Formulaires des demandes publiés ».
### 11 septies. Mode maintenance du launcher (7f ✅)
**Besoin** : depuis le menu, un bouton pour mettre **tous les launchers** (tous les jeux) en maintenance : thème rouge /
orange, « Maintenance en cours — merci de revenir plus tard » au centre, et **tout bloqué** sauf la **désinstallation
des jeux** et le **menu d'administration** ; puis la terminer.
**Document** (`Contracts/Maintenance/MaintenanceDocument`) : `maintenance.json` = format `gamelauncher-maintenance`,
version 1, **révision**, `enabled`, `message` (facultatif), `since` (début, à la seconde ; absent hors maintenance),
`updatedAt` (posé par le serveur, hors signature), signature ; révision 0 = jamais publié (inactif). Règles
(`MaintenanceRules`, communes au menu, au serveur et au launcher) : format et version, révision ≥ 1 ; message
nettoyé comme les textes des demandes (`FeedbackRules.CleanMultiline` : caractères de contrôle, invisibles, de mise en
forme, d'inversion du sens d'écriture, d'usage privé refusés), **300 caractères, 6 lignes, aucun lien**
(`FeedbackLinks`) ; maintenance active : début présent, à la seconde, pas plus de 10 min dans le futur (serveur) ;
inactive : ni début ni message ; **8 Ko** au plus (sous la limite de 64 Ko de l'API : pas de bloc nginx d'API). Titre et
sous-titre affichés aux joueurs **fixes** (dans le code) : seul le message est publié. Condensé signé : format, version,
révision, état, message, début.
**Signature** : type de contenu `maintenance` (identifiant signé `maintenance`) et autorisation du trousseau
**`maintenance`** (permission `maintenance.manage` : Propriétaire d'office, Administrateur sur une installation neuve,
pas l'Éditeur). Les launchers 3.2 acceptent un trousseau qui la contient (autorisation inconnue sans effet) ; ceux
d'avant le refusent et gardent le précédent (publier la 3.3 avant de re-signer). Un serveur compromis ne peut donc ni
déclencher une fausse maintenance, ni prolonger une maintenance chez les launchers en « require » (date de signature
non antérieure à la dernière acceptée, révision non inférieure).
| Route | Accès | Signée | Rôle |
|---|---|:-:|---|
| `GET v1/maintenance` | administrateur connecté | | État en vigueur, publication possible, adresse publique, signataire (tableau de bord) |
| `PUT v1/maintenance` | `maintenance.manage` | ✔ | `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 `revision_conflict` sinon), début inchangé pendant une maintenance ; audit `maintenance.enable`, `maintenance.disable` ou `maintenance.update` |
**Serveur** (`Modules/Maintenance/MaintenanceModule`, `Storage/MaintenanceStore`) : référence privée `maintenance.json`
(+ `.bak`, historique de 20 révisions `maintenance-history/`), copie **publique** à côté du trousseau
(`MaintenancePublicFileFullPath`, 644, atomique ; public d'abord) ; nom réservé (refus au démarrage si le catalogue ou
le trousseau s'appellent ainsi). Document signé obligatoire. Le serveur ne bloque rien lui-même : l'API reste utilisable
(c'est elle qui termine la maintenance). `GET v1/signing` renvoie aussi sa signature (`SigningState.Maintenance`).
SQLite écarté : un seul petit document, publié en fichier de toute façon. nginx : `location = /catalog/maintenance.json`,
sans cache.
**Launcher (joueur)** : `Core/Maintenance/MaintenanceService` lit `maintenance.json` (adresse déduite du trousseau,
sinon du catalogue) **au démarrage**, en parallèle du catalogue et de la mise à jour du launcher, et l'attend **4 s au
plus avant la recherche des mises à jour des jeux** (pas d'attente pour `--uninstall`) ; puis toutes les **2 min** (1 min
pendant une maintenance) et aussitôt après une publication depuis le menu de ce PC (`IAdminContext.NotifyMaintenanceChanged`).
8 Ko au plus, 8 s au plus ; règles contrôlées même si signé ; signature vérifiée par
`ContentTrustService.VerifyMaintenanceAsync` (autorisation `maintenance`, date de signature non antérieure) ; révision
inférieure à l'état connu refusée ; même fichier que la fois précédente (déjà accepté) : rien à revérifier ; fichier
refusé : revérifié à chaque relecture (un trousseau re-signé avec l'autorisation `maintenance` pendant que le launcher
tourne est retéléchargé et pris en compte, 3.3.1). **Rien n'est gardé sur le
disque** : injoignable au démarrage = pas de maintenance (le jeu installé reste jouable hors ligne, un joueur sans
connexion n'est jamais bloqué) ; pendant que le launcher tourne, une coupure ou un fichier refusé garde le dernier état
vérifié, un 404 termine la maintenance.
Blocage (`LauncherViewModel.ApplyMaintenance`, `GameViewModel.IsMaintenance`) : commandes désactivées (sauf
`UninstallCommand`, l'annulation d'une opération commencée et la fermeture du bandeau) **et** refus à l'entrée de chaque
action (installation, mise à jour, réparation, vérification, localisation, oubli, lancement, raccourci du Bureau,
paramètres, recherche de mises à jour, `RunOperationAsync` sauf la désinstallation) avec un bandeau d'explication ;
recherche automatique et mise à jour automatique suspendues, relancées à la fin de la maintenance ; paramètres du
launcher, bouton « Aide » et site officiel masqués ou désactivés. Non interrompus : opération déjà commencée (toujours
annulable), jeu déjà lancé, mise à jour du launcher lui-même (signée par la clé racine : moyen de corriger un launcher
défectueux). Affichage : `Themes/Maintenance.xaml` (palette rouge / orange, mêmes clés que les thèmes) ajouté aux
ressources de la fenêtre principale seulement, carte au centre (cône de chantier `Icon.TrafficCone`, bandes de
chantier, titre, sous-titre, message en texte brut sélectionnable, « Depuis le … », rappel « la désinstallation reste
possible »), bouton principal « MAINTENANCE » désactivé ; début et fin avec le fondu enchaîné d'un changement de jeu.
**Ce n'est pas une barrière de sécurité** : un launcher modifié peut l'ignorer ; les serveurs du jeu doivent refuser
eux-mêmes les connexions si nécessaire.
**Menu d'administration** : module `MaintenanceAdminModule`, page **« Maintenance »** (`MaintenancePanelViewModel`,
permission `maintenance.manage`, ordre 5, icône cône) : état publié (en cours depuis…, par…, terminée le…), message
(compteur, contrôle en direct, aperçu de la carte), « Activer la maintenance » (confirmation détaillée), « Modifier le
message », « Terminer la maintenance » (confirmation), contrôle du trousseau des joueurs avant chaque publication
(`PlayerSigningCheck`), 409 : état relu, saisie gardée. Tableau de bord : bandeau « Maintenance en cours » pour tout
administrateur (bouton « Gérer » avec la permission). Page « Signature » : ligne « Mode maintenance ». Journal d'audit :
catégorie « Mode maintenance ».
## 12. Signature de bout en bout pour les joueurs (phase 7 ✅)
Sans signature, une prise de contrôle du VPS permettrait de distribuer un faux jeu. Désormais :
@@ -894,6 +969,13 @@ côté du catalogue, 0644, atomique) et un historique ; audit `keyring.publish`
des administrateurs expose désormais la clé publique des clés **actives** seulement (celle d'une clé en attente
permettrait de calculer l'empreinte à dicter).
**Contenu en ligne** (page Signature, `GET v1/signing` : `SigningState`) : versions en ligne de chaque jeu, catalogue,
puis **formulaires des demandes** (`FeedbackFormsSignatureState` : révision, condensé, signature, signataire ;
révision 0 = formulaires d'origine intégrés au launcher, rien à signer ; fichier de référence illisible signalé).
Chaque contenu est vérifié par le launcher avec le trousseau publié (accepté ou refusé par les joueurs, et pourquoi)
et avec le trousseau proposé (« sa clé ne sera plus dans le trousseau proposé » ; l'export de la demande le rappelle).
Un serveur antérieur à la 3.2.1 n'envoie pas les formulaires : la ligne n'apparaît pas.
**Signature du contenu** (`ContentSigning`) : propriété `signature` `{ keyId, signedAt, value }` dans le JSON
(atomique avec le contenu, ignorée par les anciens launchers), sur le message `GameLauncher/Content/v1`
(type, identifiant du jeu ou « catalog », date à la seconde, clé, condensé). Condensés canoniques, calculés par
@@ -937,8 +1019,12 @@ Trousseau téléchargé au démarrage (en parallèle du catalogue), vérifié (s
s'il est plus ancien que le dernier vu, différent sous le même numéro, ou plus ancien que `keyringMinSequence`
(launcher.json intégré, à relever après le retrait d'une clé compromise) ; hors ligne : dernier trousseau vu
(revérifié à la lecture du cache). Aucune erreur de téléchargement (délai, réponse invalide, bogue) ne remonte à
l'appelant : le cache est utilisé. Contenu signé par une clé absente du trousseau : nouveau téléchargement, au plus
une fois toutes les 5 minutes. Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
l'appelant : le cache est utilisé. Nouveau téléchargement du trousseau (mêmes contrôles) : après un échec réseau
(1 min), quand un contenu est signé par une clé absente du trousseau **ou présente sans l'autorisation de ce contenu**
(clé ajoutée ou autorisation donnée depuis : au plus une fois par minute, `KeyChangeRetryDelay`), et quand le trousseau
en mémoire a plus de 30 minutes (`KeyringMaxAge`, à la vérification suivante : un launcher laissé ouvert cesse de faire
confiance à une clé retirée). Avant la 3.3.1, une autorisation ajoutée à une clé déjà connue n'était vue qu'au
redémarrage (mode maintenance refusé à chaque relecture après la re-signature du trousseau). Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
vérifiés : clé du trousseau autorisée, signature valide, **date de signature ≥ la dernière acceptée** pour ce
contenu (un serveur compromis ne peut pas resservir une ancienne version légitime mais vulnérable). « warn » :
journal seulement ; « require » : manifest refusé (`ContentSecurityException`, message « Mise à jour bloquée par
@@ -969,6 +1055,10 @@ trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; re
| **7b** | Page « Fichiers du serveur » (suppression des fichiers inutilisés, nettoyage d'« objects »), période du journal d'audit, barres d'actions fixes et retour en haut, molette sans effet sur les listes fermées | ✅ |
| **7c** | Groupes d'administrateurs et permissions personnalisées (page « Groupes et permissions », module serveur `Groups`, `admins.json` format 2) ; envoi des images du catalogue depuis la page « Jeux » (« Parcourir… », images nommées par leur empreinte et vérifiées par les joueurs) | ✅ |
| **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é | ✅ |
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.
@@ -977,8 +1067,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 |
+300
View File
@@ -0,0 +1,300 @@
# Comptes joueurs — Architecture (phase 8)
> Document de référence du module « comptes joueurs ». Plan validé le 2026-11-10 (copie du plan dans les documents du
> projet : `GameLauncher-Comptes-Joueurs-Plan.md`). Statut des étapes : § 12.
> À relire avec `ADMIN-ARCHITECTURE.md` (socle, principes de sécurité) et la fiche RGPD / CNIL du projet.
## 1. Besoin
* Compte joueur = **adresse e-mail + mot de passe**, **OTP (TOTP) facultatif** (étape 8c).
* **Jamais obligatoire** pour lancer le launcher, installer ou jouer (jeux solo : anonyme). Obligatoire seulement pour
la future partie sociale.
* Bouton « Aide » : **anonyme** (comme aujourd'hui, aucune réponse) **ou connecté** (suivi + échanges avec les
administrateurs) — étape 8f.
* Menu admin (étape 8d), avec permissions : supprimer, désactiver / réactiver (durée ou indéfini), forcer le changement
de mot de passe, mot de passe temporaire, réinitialiser l'OTP ; blocages par IP publique et par identifiants matériels
du PC (étape 8e).
* **Aucun lien** avec les comptes administrateurs : un compte joueur ne peut **jamais** ouvrir le menu d'administration.
* Dictionnaire public des pires mots de passe ; anti-force brute (blocage temporaire puis suspension levée par un
administrateur) ; **connexion mémorisée** (comme Steam) ; **audit** de chaque action joueur et administrateur.
## 2. Principes
1. **Le serveur est la seule barrière** : chaque règle est revérifiée côté serveur avec le même code partagé
(`GameLauncher.Contracts.Accounts`).
2. **Deux mondes étanches** : administrateurs = clé publique/privée et requêtes signées ; joueurs = mot de passe.
Schémas d'authentification ASP.NET distincts (`AdminSession` / `PlayerSession`), jetons de préfixes distincts
(`glpa_`, `glpr_`, `glpc_` pour les joueurs), stockages distincts (`admins.json` + sessions en mémoire / `players.db`),
routes distinctes (`v1/account/…`). Chaque règle d'autorisation d'administration **désigne explicitement** le schéma
`AdminSession` ; le gestionnaire administrateur ignore tout jeton commençant par `glp`. Un test essaie **toutes** les
routes d'administration déclarées avec des jetons joueurs (accès, changement exigé, renouvellement) : 401 attendu
partout (`PlayerSeparationTests`).
3. **Minimisation** : ni nom, ni date de naissance, ni adresse postale, ni nom Windows du PC. Le pseudo viendra avec le
module social.
4. **Une copie des données du serveur ne livre rien d'exploitable** : adresses chiffrées, mots de passe en Argon2id avec
un « poivre », clé maîtresse hors du dossier des données.
5. **Désactivé par défaut** (`LauncherApi:Players:Enabled = false`) ; création de compte désactivable séparément.
6. Le launcher ne contacte l'API que si le joueur l'a voulu (connexion, création) ou s'il a une session mémorisée.
## 3. Protocole
### 3.1 Clé d'appareil
Le launcher crée une clé ECDSA P-256 **par PC 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) |
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 |
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"`.
## 4. Mots de passe et adresses
* **Règles** (`PasswordRules`, identiques launcher / serveur) : 12 caractères au moins avec 3 types parmi minuscules,
majuscules, chiffres, symboles ; ou 16 caractères sans contrainte (phrase de passe) ; 128 au plus ; pas de caractère
de contrôle. Refus : mots de passe du **dictionnaire** (« 100k-most-used-passwords-NCSC », 99 839 entrées, ressource
intégrée compressée, + une liste de mots de passe français courants) et leurs variantes simples (minuscules,
substitutions `0→o 1→i 3→e 4→a 5→s 7→t @→a $→s…`, chiffres et symboles retirés aux extrémités :
« P@ssw0rd2024! » ≡ « password ») ; suites triviales (répétitions, rangées de claviers AZERTY / QWERTY / QWERTZ, suites
de lettres ou de chiffres, motifs répétés) ; mot de passe tiré de l'adresse e-mail ou des noms du studio et des jeux
(moins de 8 caractères restants une fois ces mots retirés).
* **Aucune normalisation Unicode** : le serveur tourne en « globalisation invariante » (sans ICU), où elle n'est pas
disponible ; le mot de passe est contrôlé et haché tel qu'il est saisi, de la même façon partout.
* **Adresse** (`PlayerEmail`) : minuscules, caractères ASCII seulement (domaines internationaux sous forme `xn--`), un
seul `@`, 254 caractères au plus ; masquée pour l'affichage (`n***@e***.fr`). Non vérifiée tant que le module e-mail
n'existe pas (`emailVerified = false`).
* **Empreinte** (`PlayerPasswordHasher`) : Argon2id (RFC 9106 ; paquet `Konscious.Security.Cryptography.Argon2`, MIT),
19 Mio, 2 passes, parallélisme 1 (OWASP), sel 128 bits, **poivre = clé secrète d'Argon2** dérivée de la clé maîtresse.
Format PHC `$argon2id$v=19$m=19456,t=2,p=1,k=1$<sel>$<empreinte>` : un relèvement des réglages est appliqué à la
connexion suivante de chaque joueur. Calculs limités à 4 simultanés (file d'attente, 503 au-delà de 20 s) ; même calcul
pour une adresse inconnue (la durée de réponse ne révèle rien). Vecteur officiel de la RFC vérifié par un test
(`Argon2VectorTests`).
## 5. Anti-force brute
1. Limites de débit (nginx et ASP.NET) et preuve de travail.
2. **Par adresse IP** (en mémoire) : 30 échecs de connexion / 15 min tous comptes confondus → 429 ; 3 créations de
compte / heure et 5 / jour → 429 ; plafond global de 500 créations / jour → 503.
3. **Par compte, depuis un appareil inconnu** : blocage après **5 échecs consécutifs** (15 min, puis 30, puis 60 :
durée doublée à chaque blocage) ; au **4ᵉ blocage (20 échecs consécutifs)**, le compte est **suspendu**. Le blocage
s'applique avant tout calcul (429 + `Retry-After`).
4. **Appareils de confiance** (déjà connectés, prouvés par leur clé) : leurs échecs sont comptés à part (blocage de
15 min après 10 échecs), **jamais de suspension**.
5. **Suspension = connexions depuis un NOUVEL appareil refusées** jusqu'à ce qu'un administrateur la lève (outil en ligne
de commande dès maintenant, menu d'administration à l'étape 8d). Les appareils déjà connectés continuent de
fonctionner (`newDevicesSuspended` dans `GET v1/account/me`) : un tiers qui connaît l'adresse peut déclencher la
suspension, mais pas priver le joueur de son compte. Inscrite au journal d'audit (`players.suspend`, par référence).
6. **Rien ne révèle qu'une adresse a un compte** : message identique pour une adresse inconnue et un mauvais mot de
passe, même durée de calcul, et une adresse inconnue se **bloque exactement comme un compte réel** (blocage « miroir »
en mémoire). L'état du compte (désactivé, suspendu) n'est révélé qu'après un **mot de passe correct**. Seule exception
inévitable sans e-mail de confirmation : la création de compte indique qu'une adresse est déjà prise (atténué par les
points 1 et 2).
7. Une connexion réussie remet les compteurs à zéro.
## 6. Stockage
**SQLite retenu** pour ce module (analyse « module par module ») : relations (compte ⇄ appareils ⇄ journal, puis
conversations et blocages), unicité stricte des adresses sous transaction, volumes non bornés, recherches par index.
`Microsoft.Data.Sqlite` (Microsoft), sans Entity Framework, requêtes paramétrées uniquement.
* Fichier : `/var/lib/launcher-api/players/players.db` (dossier 0700, fichier 0600 créé avant l'ouverture ; les fichiers
`-wal` et `-shm` reprennent ses droits). Journal WAL, clés étrangères, `secure_delete` (lignes supprimées écrasées),
écriture synchrone complète. Schéma versionné (`PRAGMA user_version`, version 1). Une seule connexion protégée par un
verrou ; l'outil en ligne de commande peut ouvrir la base en même temps.
* Tables : `accounts` (identifiant 128 bits, référence `PL-XXXX-XXXX`, index aveugle et adresse chiffrée, empreinte du
mot de passe, « changement exigé », champs OTP réservés à l'étape 8c, état, échéance de désactivation, motif chiffré,
dates, version des conditions acceptée, compteurs d'échecs et blocages) ; `devices` (clé publique, nom générique,
empreintes du jeton de renouvellement en vigueur et précédent, échéances) ; `account_events` (journal, § 7).
* **Chiffrement** (`PlayerSecrets`) : clé maîtresse de 256 bits → sous-clés HKDF-SHA256 par usage (chiffrement des
adresses, index aveugle, poivre, champs libres). Adresse : AES-256-GCM, nonce aléatoire, **liée au compte** (données
associées : une valeur copiée sur un autre compte ne se déchiffre pas), préfixe de version de clé (`1.`). Recherche et
unicité par **index aveugle** HMAC-SHA256 de l'adresse normalisée.
* **Clé maîtresse hors du dossier des données** : credential systemd chiffré (`LoadCredentialEncrypted`,
`players-master-key`, lu dans `$CREDENTIALS_DIRECTORY`) ou `Players:MasterKeyFile` (chemin absolu). Le serveur refuse
de démarrer si la clé manque, est dans le dossier des données, est accessible à d'autres comptes, ou n'est pas une clé
de 32 octets. **À sauvegarder hors ligne** : perdue, les adresses sont illisibles et tous les mots de passe doivent
être réinitialisés. Commande : `GameLauncher.Server players-key`.
* Les demandes des joueurs restent en fichiers JSON (étape 8f : identifiant opaque du compte ajouté, messages dans
SQLite, chiffrés).
## 7. Audit
* **Actions des joueurs et du système sur un compte** → table `account_events` : création, connexion réussie ou
refusée, blocage, suspension, déconnexion (un appareil / tous), changement de mot de passe (dont exigé), appareil
déconnecté, renouvellement (une ligne par appareil et par jour au plus), réutilisation d'un jeton. Avec l'adresse IP et
l'identifiant de la clé d'appareil, **jamais l'adresse e-mail ni le mot de passe**. Les échecs sur une adresse sans
compte sont inscrits sans compte.
* **Actions des administrateurs et du système** → journal d'audit existant, par **référence** de compte : `players.suspend`
(suspension automatique), `cli.players_unlock`, `cli.players_enable`, `cli.players_disable`, `players.events_purge`,
`players.self_delete` (compte supprimé par son titulaire), `players.inactive` (bilan quotidien des comptes inactifs),
`players.inactive_delete` (compte inactif supprimé).
* 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 futur menu : `PlayerActivityText` (Contracts).
Les libellés lisibles et la catégorie « Comptes joueurs » du journal du menu arrivent avec l'étape 8d.
* **Conservation** (`RetentionService`, au démarrage puis chaque jour) : journal des comptes 12 mois
(`Players:EventsRetentionMonths`), adresses IP effacées au-delà de 6 mois (`Players:EventsIpRetentionMonths`).
## 8. Outil en ligne de commande (secours, sur le VPS)
Un compte est désigné par sa **référence** ; l'adresse e-mail n'est jamais affichée (aucune clé maîtresse nécessaire).
| Commande | Rôle |
|---|---|
| `players-key` | Génère une clé maîtresse (sortie standard, à chiffrer avec `systemd-creds`) |
| `players-show --ref PL-XXXX-XXXX` | État, dates, échecs, appareils, derniers événements |
| `players-unlock --ref …` | Lève une suspension ou un blocage (compte réactivé) |
| `players-disable --ref … [--until AAAA-MM-JJ[THH:MM]]` | Désactive (UTC ; sans date : indéfiniment) |
| `players-enable --ref …` | Réactive |
## 9. Réglages (`LauncherApi:Players`)
| Réglage | Défaut | Rôle |
|---|---|---|
| `Enabled` | `false` | Service des comptes |
| `RegistrationEnabled` | `true` | Création de compte ouverte |
| `MasterKeyFile` | vide | Clé maîtresse (vide : credential systemd `players-master-key`) |
| `TermsVersion` | `1` | Version des conditions à accepter à la création |
| `HashMemoryKib`, `HashIterations`, `HashParallelism` | 19456, 2, 1 | Argon2id (coût minimal refusé en dessous de 19 Mio × 2) |
| `MaxConcurrentHashes` | 4 | Calculs simultanés |
| `RegisterProofDifficulty`, `LoginProofDifficulty` | 20, 16 | Preuves de travail |
| `FailuresPerLock`, `LockMinutes`, `SuspendAfterLocks` | 5, 15, 4 | Anti-force brute (appareils inconnus) |
| `TrustedFailuresPerLock` | 10 | Appareils de confiance |
| `FailedLoginsPerIpPer15Minutes` | 30 | Échecs par IP |
| `RegistrationsPerIpPerHour`, `…PerDay`, `MaxRegistrationsPerDay` | 3, 5, 500 | Créations de compte |
| `AuthRequestsPerMinute` | 20 | Limite de débit des routes `account` |
| `AccessTokenMinutes`, `RefreshIdleDays`, `RefreshMaxDays` | 15, 90, 365 | Sessions |
| `MaxDevicesPerAccount` | 20 | Au-delà, le moins récemment utilisé est déconnecté |
| `EventsRetentionMonths`, `EventsIpRetentionMonths` | 12, 6 | Conservation du journal des comptes |
| `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, 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 (étape 8d).
* La création de compte révèle qu'une adresse est déjà utilisée (§ 5, point 6).
* Rotation de la clé maîtresse : prévue par le format (version de clé), pas encore outillée.
## 12. Étapes
| Étape | Contenu | Statut |
|---|---|---|
| **8a** | Socle serveur : contrats (`Contracts/Accounts`), module `Accounts`, schéma `PlayerSession`, base SQLite chiffrée, Argon2id + poivre, création / connexion / renouvellement / déconnexion / mot de passe / appareils, anti-force brute, journal des comptes, purge, outil en ligne de commande, tests (dont l'étanchéité sur toutes les routes d'administration) | ✅ |
| **8b** | Launcher : bouton Compte, 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 | OTP (TOTP, codes de secours, QR maison) | à venir |
| 8d | Menu d'administration « Comptes joueurs » | à venir |
| 8e | Blocages par IP et par identifiants matériels (un blocage depuis une fiche vise les deux) | à venir |
| 8f | Demandes connectées et échanges avec les administrateurs | à venir |
| 8g | Mise en production (nginx, credential, sauvegardes, textes RGPD, AIPD, revue de sécurité indépendante) | à venir |
| 8h | E-mails (vérification, réinitialisation autonome) | futur |
Activation en production : après 8a, 8b, 8c, 8d et 8g au minimum.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 416 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

After

Width:  |  Height:  |  Size: 395 KiB

+14 -2
View File
@@ -19,7 +19,8 @@ public static class AdminCli
private static readonly string[] Commands = { "add-owner", "add-key", "list", "revoke-admin", "revoke-key", "server-id", "help" };
public static bool IsCommand(string[] args) =>
args.Length > 0 && Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase);
args.Length > 0 && (Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase) ||
PlayersCli.Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase));
public static int Run(string[] args, TextWriter? output = null, string? dataDirectoryOverride = null)
{
@@ -29,6 +30,9 @@ public static class AdminCli
var command = args[0].ToLowerInvariant();
if (command == "help") return Help(o);
var options = ReadOptions(args);
// Clé maîtresse des comptes joueurs : n'utilise pas le dossier des données (peut être lancée par root, la sortie
// étant directement chiffrée par systemd-creds).
if (command == "players-key") return PlayersCli.Key(o, interactive: output is null);
// Lancé par erreur avec « sudo » (root) au lieu de « sudo -u deploy » : les fichiers recréés
// appartiendraient à root et le service ne pourrait plus les lire.
@@ -37,7 +41,7 @@ public static class AdminCli
"ne lancez pas cet outil en root : sudo -u deploy /opt/launcher-api/GameLauncher.Server " + command + " …");
var dataDirectory = dataDirectoryOverride ?? ResolveDataDirectory(options);
if (command != "add-owner" && command != "server-id" &&
if (command != "add-owner" && command != "server-id" && !command.StartsWith("players-", StringComparison.Ordinal) &&
!File.Exists(Path.Combine(dataDirectory, AdminStore.FileName)))
throw new InvalidOperationException(
$"aucun fichier {AdminStore.FileName} dans {dataDirectory} : mauvais dossier de données ? (option --data-dir)");
@@ -57,6 +61,11 @@ public static class AdminCli
case "server-id":
o.WriteLine(ServerIdentity.LoadOrCreate(dataDirectory));
return 0;
case "players-show":
case "players-unlock":
case "players-disable":
case "players-enable":
return PlayersCli.Run(command, options, dataDirectory, audit, actor, o);
default: return Help(o);
}
}
@@ -219,6 +228,9 @@ public static class AdminCli
revoke-key --key <identifiant> Révoque une clé (poste perdu ou volé).
server-id Affiche l'identifiant de ce serveur.
Comptes joueurs (désignés par leur référence ; l'adresse e-mail n'est jamais affichée) :
""" + "\n" + PlayersCli.HelpText + "\n" + """
Option commune : --data-dir <dossier> (sinon LauncherApi:DataDirectory d'appsettings.json).
""");
return 0;
@@ -0,0 +1,108 @@
using System.Globalization;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Storage;
using GameLauncher.Server.Storage.Players;
namespace GameLauncher.Server.Cli;
/// <summary>
/// Commandes de secours des comptes joueurs (sur le VPS, sous le compte du service). Elles désignent un compte par sa
/// RÉFÉRENCE (« PL-7KQ2-M9XD ») et n'affichent jamais l'adresse e-mail : elles n'ont pas besoin de la clé maîtresse.
/// Le service n'a pas besoin d'être arrêté (SQLite gère l'accès simultané) ; l'état du compte est relu à chaque requête.
/// La gestion courante se fait depuis le menu d'administration (étape 8d).
/// </summary>
internal static class PlayersCli
{
public static readonly string[] Commands = { "players-key", "players-show", "players-unlock", "players-disable", "players-enable" };
public const string HelpText = """
players-key Génère une clé maîtresse des comptes joueurs (à chiffrer avec systemd-creds, voir LISEZMOI).
players-show --ref <PL-XXXX-XXXX> État d'un compte joueur (sans son adresse e-mail).
players-unlock --ref <PL-XXXX-XXXX> Lève une suspension ou un blocage (compte réactivé).
players-disable --ref <PL-XXXX-XXXX> [--until AAAA-MM-JJ[THH:MM]] Désactive un compte (UTC ; sans --until : indéfiniment).
players-enable --ref <PL-XXXX-XXXX> Réactive un compte désactivé.
""";
/// <summary>Nouvelle clé maîtresse : sur la sortie standard (à rediriger vers systemd-creds), mode d'emploi sur stderr.</summary>
public static int Key(TextWriter o, bool interactive)
{
o.WriteLine(PlayerSecrets.NewMasterKey());
if (interactive)
Console.Error.WriteLine("Clé maîtresse générée (32 octets, Base64). À chiffrer immédiatement, sans la laisser sur le disque, ex. :\n" +
" GameLauncher.Server players-key | sudo systemd-creds encrypt --name=players-master-key - " +
"/etc/credstore.encrypted/players-master-key\n" +
"Gardez-en une copie HORS LIGNE (gestionnaire de mots de passe) : perdue, les adresses e-mail sont " +
"illisibles et tous les mots de passe doivent être réinitialisés.");
return 0;
}
public static int Run(string command, Dictionary<string, string> options, string dataDirectory, AuditLog audit, string actor, TextWriter o)
{
var path = Path.Combine(dataDirectory, PlayerStore.FolderName, PlayerStore.FileName);
if (!File.Exists(path)) throw new InvalidOperationException($"aucune base des comptes joueurs ({path}).");
using var store = new PlayerStore(dataDirectory, TimeProvider.System);
var reference = PlayerIds.NormalizeReference(Get(options, "ref"))
?? throw new ArgumentException("--ref <PL-XXXX-XXXX> obligatoire (référence du compte).");
var account = store.FindByReference(reference) ?? throw new InvalidOperationException($"aucun compte {reference}.");
var now = DateTimeOffset.UtcNow;
switch (command)
{
case "players-show":
Show(store, account, o);
return 0;
case "players-unlock":
case "players-enable":
store.SetStatus(account.Id, PlayerStatus.Active, null, null, actor, now);
store.AddEvent(account.Id, command == "players-unlock" ? PlayerEvents.Unlocked : PlayerEvents.Enabled, AuditResults.Ok, null,
details: $"par {actor} (outil en ligne de commande)");
audit.Write("cli." + command.Replace('-', '_'), AuditResults.Ok, actorName: actor, target: reference,
details: $"ancien état : {account.Status}");
o.WriteLine($"Compte {reference} réactivé (blocages remis à zéro).");
return 0;
case "players-disable":
DateTimeOffset? until = null;
if (Get(options, "until") is { Length: > 0 } text)
{
if (!DateTimeOffset.TryParseExact(text, new[] { "yyyy-MM-dd", "yyyy-MM-dd'T'HH:mm" }, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) || date <= now)
throw new ArgumentException("--until : date future attendue, AAAA-MM-JJ ou AAAA-MM-JJTHH:MM (UTC).");
until = date;
}
store.SetStatus(account.Id, PlayerStatus.Disabled, until, null, actor, now);
store.AddEvent(account.Id, PlayerEvents.Disabled, AuditResults.Ok, null,
details: $"par {actor} (outil en ligne de commande){(until is { } u ? $" jusqu'au {u:yyyy-MM-dd HH:mm} UTC" : " indéfiniment")}");
audit.Write("cli.players_disable", AuditResults.Ok, actorName: actor, target: reference,
details: until is { } end ? $"jusqu'au {end:yyyy-MM-dd HH:mm} UTC" : "indéfiniment");
o.WriteLine($"Compte {reference} désactivé{(until is { } d ? $" jusqu'au {d:yyyy-MM-dd HH:mm} UTC" : " indéfiniment")} " +
"(ses sessions sont refusées dès la requête suivante).");
return 0;
}
throw new ArgumentException($"commande inconnue : {command}");
}
private static void Show(PlayerStore store, PlayerAccountRecord account, TextWriter o)
{
static string Date(DateTimeOffset? d) => d is { } v ? v.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) : "—";
var devices = store.Devices(account.Id);
o.WriteLine($"Compte {account.Reference}");
o.WriteLine($" État : {account.Status}{(account.DisabledUntil is { } u ? $" jusqu'au {Date(u)}" : "")}");
o.WriteLine($" Créé le : {Date(account.CreatedAt)}");
o.WriteLine($" Dernière connexion : {Date(account.LastLoginAt)}");
o.WriteLine($" Mot de passe changé : {Date(account.PasswordChangedAt)}{(account.MustChangePassword ? " (nouveau mot de passe exigé)" : "")}");
o.WriteLine($" Échecs (inconnus) : {account.FailedCount} en cours, {account.LockLevel} blocage(s), bloqué jusqu'au {Date(account.LockedUntil)}");
o.WriteLine($" Échecs (confiance) : {account.TrustedFailedCount} en cours, bloqué jusqu'au {Date(account.TrustedLockedUntil)}");
o.WriteLine($" Appareils connectés : {devices.Count}");
foreach (var device in devices)
o.WriteLine($" · {device.Label} (clé {device.KeyId}) — dernière utilisation {Date(device.LastUsedAt)}");
o.WriteLine(" Derniers événements :");
foreach (var e in store.Events(account.Id, 10))
o.WriteLine($" {Date(e.At)} {e.Type} {e.Result} {e.Details}");
}
private static string? Get(Dictionary<string, string> options, string name) =>
options.TryGetValue(name, out var value) ? value : null;
}
@@ -25,6 +25,18 @@
<ProjectReference Include="..\..\src\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
</ItemGroup>
<!--
Comptes joueurs (phase 8, docs\PLAYER-ACCOUNTS-ARCHITECTURE.md) :
• Microsoft.Data.Sqlite (Microsoft) : base players.db ; embarque la bibliothèque native SQLite (SQLitePCLRaw,
bibliothèque e_sqlite3 de linux-x64 incluse à la publication autonome) ;
• Konscious.Security.Cryptography.Argon2 (licence MIT, 100 % C#) : empreintes Argon2id des mots de passe (RFC 9106),
absentes de .NET 8.
-->
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.34" />
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="GameLauncher.Tests" />
</ItemGroup>
@@ -32,7 +32,9 @@ public static class ApiModules
new Modules.Publishing.PublishingModule(),
new Modules.Signing.SigningModule(),
new Modules.Files.FilesModule(),
new Modules.Feedback.FeedbackModule()
new Modules.Feedback.FeedbackModule(),
new Modules.Maintenance.MaintenanceModule(),
new Modules.Accounts.AccountsModule()
};
}
@@ -50,4 +52,8 @@ public static class RateLimitPolicies
/// <summary>Demandes des joueurs : défis et envois anonymes (en plus de la limite générale).</summary>
public const string Feedback = "feedback";
/// <summary>Comptes joueurs : défis, création, connexion, renouvellement, changement de mot de passe (en plus de la
/// limite générale).</summary>
public const string Account = "account";
}
@@ -53,6 +53,9 @@ public sealed class LauncherApiOptions
/// <summary>Journal d'audit : durée de conservation (purge automatique).</summary>
public AuditSettings Audit { get; set; } = new();
/// <summary>Comptes joueurs (phase 8) : désactivés par défaut. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.</summary>
public PlayersSettings Players { get; set; } = new();
/// <summary>
/// Jeux dont le manifest.json publié peut être modifié par l'API (notes de version, phase 4), par
/// identifiant de jeu (GameId de launcher.json). Absent : notes non modifiables en ligne.
@@ -177,6 +180,85 @@ public sealed class LauncherApiOptions
public int RetentionMonths { get; set; } = 12;
}
/// <summary>
/// Comptes joueurs : adresse e-mail + mot de passe, connexion mémorisée liée à une clé d'appareil. Données dans
/// « players/players.db » (SQLite, dossier des données privées), adresses e-mail chiffrées, mots de passe hachés
/// (Argon2id + clé secrète). La clé maîtresse est hors du dossier des données (credential systemd chiffré).
/// </summary>
public sealed class PlayersSettings
{
/// <summary>Service activé (faux par défaut : les routes « v1/account/… » répondent 503 « not_configured »).</summary>
public bool Enabled { get; set; }
/// <summary>Création de compte ouverte (la connexion reste possible si faux).</summary>
public bool RegistrationEnabled { get; set; } = true;
/// <summary>
/// Fichier de la clé maîtresse (32 octets en Base64). Vide : « players-master-key » dans le dossier des credentials
/// systemd ($CREDENTIALS_DIRECTORY, voir server/deploy/LISEZMOI.md). Jamais dans le dossier des données, jamais
/// lisible par le groupe ou les autres comptes.
/// </summary>
public string MasterKeyFile { get; set; } = "";
/// <summary>Version des conditions d'utilisation / politique de confidentialité à accepter à la création du compte.</summary>
public string TermsVersion { get; set; } = "1";
/// <summary>Argon2id : mémoire (Kio), passes, parallélisme. OWASP : 19 Mio et 2 passes au minimum (ou équivalent).</summary>
public int HashMemoryKib { get; set; } = 19456;
public int HashIterations { get; set; } = 2;
public int HashParallelism { get; set; } = 1;
/// <summary>Calculs de mot de passe simultanés (les suivants attendent) : protège le VPS d'un afflux de connexions.</summary>
public int MaxConcurrentHashes { get; set; } = 4;
/// <summary>Preuve de travail exigée à la création de compte et à la connexion (bits à zéro).</summary>
public int RegisterProofDifficulty { get; set; } = 20;
public int LoginProofDifficulty { get; set; } = 16;
/// <summary>Durée de validité d'un défi joueur.</summary>
public int ChallengeLifetimeSeconds { get; set; } = 300;
/// <summary>Anti-force brute (appareils inconnus) : blocage après N échecs consécutifs, durée doublée à chaque
/// blocage ; au blocage n° SuspendAfterLocks, le compte est SUSPENDU pour les nouveaux appareils (seul un
/// administrateur lève la suspension ; les appareils déjà connectés continuent de fonctionner).</summary>
public int FailuresPerLock { get; set; } = 5;
public int LockMinutes { get; set; } = 15;
public int SuspendAfterLocks { get; set; } = 4;
/// <summary>Appareils de confiance (déjà connectés) : blocage temporaire après N échecs, jamais de suspension.</summary>
public int TrustedFailuresPerLock { get; set; } = 10;
/// <summary>Échecs de connexion acceptés par adresse IP (IPv6 : /64) sur 15 minutes, tous comptes confondus.</summary>
public int FailedLoginsPerIpPer15Minutes { get; set; } = 30;
/// <summary>Créations de compte par adresse IP (IPv6 : /64) par heure et par jour, et au total par jour.</summary>
public int RegistrationsPerIpPerHour { get; set; } = 3;
public int RegistrationsPerIpPerDay { get; set; } = 5;
public int MaxRegistrationsPerDay { get; set; } = 500;
/// <summary>Requêtes par minute et par adresse IP sur les routes de défi, création, connexion et renouvellement.</summary>
public int AuthRequestsPerMinute { get; set; } = 20;
/// <summary>Jeton d'accès (minutes) ; session mémorisée : jours sans utilisation, puis durée maximale (jours).</summary>
public int AccessTokenMinutes { get; set; } = 15;
public int RefreshIdleDays { get; set; } = 90;
public int RefreshMaxDays { get; set; } = 365;
/// <summary>Appareils connectés par compte (au-delà, le moins récemment utilisé est déconnecté).</summary>
public int MaxDevicesPerAccount { get; set; } = 20;
/// <summary>Journal des comptes : conservation (mois), et effacement des adresses IP au-delà de N mois.</summary>
public int EventsRetentionMonths { get; set; } = 12;
public int EventsIpRetentionMonths { get; set; } = 6;
/// <summary>
/// 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
{
@@ -248,6 +330,15 @@ public sealed class LauncherApiOptions
/// <summary>Adresse publique des formulaires des demandes, calculée par <see cref="Validate"/>.</summary>
public string FeedbackFormsPublicUrl { get; private set; } = "";
/// <summary>
/// Fichier public du mode maintenance (maintenance.json, à côté du trousseau ; null : publication désactivée), calculé
/// par <see cref="Validate"/>. Les launchers le lisent dans le même dossier que le trousseau.
/// </summary>
public string? MaintenancePublicFileFullPath { get; private set; }
/// <summary>Adresse publique du mode maintenance, calculée par <see cref="Validate"/>.</summary>
public string MaintenancePublicUrl { get; private set; } = "";
/// <summary>Racine du site public pour la page « Fichiers du serveur » (null : page désactivée), calculée par <see cref="Validate"/>.</summary>
public string? FilesRootFullPath { get; private set; }
@@ -255,6 +346,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);
@@ -289,6 +383,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)");
@@ -303,6 +398,71 @@ 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 (!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;
@@ -403,11 +563,24 @@ public sealed class LauncherApiOptions
? Path.Combine(Path.GetDirectoryName(keyringFile)!, FeedbackFormsFileName)
: null;
FeedbackFormsPublicUrl = new Uri(new Uri(KeyringPublicUrl), FeedbackFormsFileName).AbsoluteUri;
// Mode maintenance : même règle (les launchers en déduisent l'adresse à partir de celle du trousseau).
MaintenancePublicFileFullPath = KeyringPublicFileFullPath is { } keyringPath
? Path.Combine(Path.GetDirectoryName(keyringPath)!, MaintenanceFileName)
: null;
MaintenancePublicUrl = new Uri(new Uri(KeyringPublicUrl), MaintenanceFileName).AbsoluteUri;
if (MaintenancePublicFileFullPath is { } maintenance &&
new[] { CatalogPublicFileFullPath, KeyringPublicFileFullPath }.Any(f => string.Equals(f, maintenance, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException(
$"{Section}:Keyring:PublicFile / Catalog:PublicFile : « {MaintenanceFileName} » est réservé au mode maintenance, choisissez un autre nom.");
}
/// <summary>Nom du fichier public des formulaires (à côté de keyring.json).</summary>
public const string FeedbackFormsFileName = "feedback-forms.json";
/// <summary>Nom du fichier public du mode maintenance (à côté de keyring.json).</summary>
public const string MaintenanceFileName = "maintenance.json";
private void ValidateGames()
{
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -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,34 @@ public sealed class RetentionService : BackgroundService
{
_logger.LogError(ex, "Purge des demandes des joueurs impossible");
}
if (!_options.Players.Enabled) return;
try
{
var players = _services.GetRequiredService<Storage.Players.PlayerStore>();
var (deleted, anonymized) = players.PurgeEvents(_time.GetUtcNow(), _options.Players.EventsRetentionMonths,
_options.Players.EventsIpRetentionMonths);
if (deleted > 0 || anonymized > 0)
_audit.Write("players.events_purge", AuditResults.Ok,
details: $"journal des comptes joueurs : {deleted} ligne(s) supprimée(s), {anonymized} adresse(s) IP effacée(s) ; " +
$"conservation {_options.Players.EventsRetentionMonths} mois, IP {_options.Players.EventsIpRetentionMonths} mois");
}
catch (Exception ex)
{
_logger.LogError(ex, "Purge du journal des comptes joueurs impossible");
}
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;
@@ -0,0 +1,715 @@
using System.Globalization;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using GameLauncher.Server.Storage.Players;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Accounts;
/// <summary>
/// Comptes joueurs (phase 8a) : création de compte, connexion par adresse e-mail et mot de passe, connexion mémorisée
/// (jeton de renouvellement lié à une clé d'appareil), déconnexion, changement de mot de passe, appareils connectés.
///
/// Sécurité (détail : docs\PLAYER-ACCOUNTS-ARCHITECTURE.md) :
/// • routes anonymes protégées par un défi sans état + preuve de travail + preuve de possession de la clé d'appareil
/// (<see cref="PlayerGuard"/>), limites par adresse IP ;
/// • anti-force brute : blocage temporaire (durée doublée), puis SUSPENSION que seul un administrateur lève. La
/// suspension bloque les connexions depuis un NOUVEL appareil ; les appareils déjà connectés (« de confiance »,
/// prouvés par leur clé) continuent de fonctionner : un tiers qui connaît l'adresse peut déclencher la suspension,
/// mais pas priver le joueur de son compte. Une adresse inconnue se comporte exactement comme un compte réel ;
/// • réponses identiques pour une adresse inconnue et un mauvais mot de passe (même durée de calcul) ; l'état du compte
/// (désactivé, suspendu) n'est révélé qu'après un mot de passe correct ;
/// • adresses e-mail chiffrées, mots de passe en Argon2id + poivre, clé maîtresse hors du dossier des données ;
/// • journal des comptes (actions des joueurs) dans la base, sans adresse e-mail ; actions du système et des
/// administrateurs dans le journal d'audit, par référence de compte.
/// Totalement séparé des administrateurs : aucune route d'administration n'accepte un jeton joueur.
/// </summary>
public sealed class AccountsModule : IApiModule
{
public string Name => "Accounts";
/// <summary>Longueur maximale acceptée d'un mot de passe SAISI (bien au-delà des règles : borne le travail du serveur).</summary>
private const int MaxSubmittedPasswordLength = 1024;
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
{
services.AddSingleton<PlayerGuard>();
services.AddSingleton<PlayerSessionStore>();
services.AddSingleton(sp =>
{
var api = sp.GetRequiredService<IOptions<LauncherApiOptions>>().Value;
return PlayerSecrets.Load(api.PlayersMasterKeyFullPath
?? throw new InvalidOperationException("Comptes joueurs désactivés : aucune clé maîtresse."));
});
services.AddSingleton(sp => new PlayerPasswordHasher(sp.GetRequiredService<PlayerSecrets>(),
sp.GetRequiredService<IOptions<LauncherApiOptions>>().Value.Players));
services.AddSingleton<PlayerStore>();
services.AddSingleton<PlayerPersonalData>();
services.AddPlayerAuthentication();
}
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapPost(ApiRoutes.AccountChallenge, Challenge).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
api.MapPost(ApiRoutes.AccountRegister, Register).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
api.MapPost(ApiRoutes.AccountLogin, Login).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
api.MapPost(ApiRoutes.AccountRefresh, Refresh).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
api.MapPost(ApiRoutes.AccountLogout, Logout).RequirePlayer();
api.MapGet(ApiRoutes.AccountMe, Me).RequirePlayer();
api.MapPost(ApiRoutes.AccountPassword, ChangePassword).RequirePlayerOrPasswordChange().RequireRateLimiting(RateLimitPolicies.Account);
api.MapDelete(ApiRoutes.AccountDevicePattern, DeleteDevice).RequirePlayer();
api.MapGet(ApiRoutes.AccountActivity, Activity).RequirePlayer();
api.MapPost(ApiRoutes.AccountExport, Export).RequirePlayer().RequireRateLimiting(RateLimitPolicies.Account);
api.MapPost(ApiRoutes.AccountDelete, DeleteAccount).RequirePlayer().RequireRateLimiting(RateLimitPolicies.Account);
}
private static IResult Disabled() => ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Les comptes joueurs ne sont pas activés sur ce serveur.");
private static IResult LoginFailed() => ApiResults.Error(401, ApiErrorCodes.LoginFailed, "Adresse e-mail ou mot de passe incorrect.");
private static IResult TooManyAttempts(HttpContext context, DateTimeOffset until, DateTimeOffset now)
{
var wait = until - now;
var seconds = Math.Max(1, (int)Math.Ceiling(wait.TotalSeconds));
context.Response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
var minutes = Math.Max(1, (int)Math.Ceiling(wait.TotalMinutes));
return ApiResults.Error(429, ApiErrorCodes.LoginLocked,
$"Trop d'essais : la connexion est bloquée pendant encore {minutes} minute{(minutes > 1 ? "s" : "")}.");
}
private static IResult RateLimited(HttpContext context, TimeSpan retryAfter, string message)
{
context.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
return ApiResults.Error(429, ApiErrorCodes.RateLimited, message);
}
// ------------------------------------------------------------------ Défi
private static IResult Challenge(PlayerChallengeRequest request, IOptions<LauncherApiOptions> options, PlayerGuard guard,
ServerIdentity server)
{
if (!options.Value.Players.Enabled) return Disabled();
if (!PlayerChallengePurposes.IsValid(request.Purpose)) return ApiResults.BadRequest("Usage du défi inconnu.");
return Results.Json(guard.CreateChallenge(request.Purpose, server.ServerId, server.Origin), ApiJson.Options);
}
// ------------------------------------------------------------------ Création de compte
private static async Task<IResult> Register(PlayerRegisterRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerGuard guard, PlayerSessionStore sessions, ServerIdentity server, TimeProvider time)
{
var settings = options.Value.Players;
if (!settings.Enabled) return Disabled();
if (!settings.RegistrationEnabled)
return ApiResults.Error(403, ApiErrorCodes.RegistrationClosed, "La création de compte est momentanément fermée.");
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
var hasher = services.GetRequiredService<PlayerPasswordHasher>();
var ip = context.ClientIp();
// 1. Défi et preuve de travail, puis possession de la clé d'appareil, puis consommation du défi.
var device = CheckDevice(guard, server, PlayerChallengePurposes.Register, request.ChallengeId, request.Solution, request.Device);
if (device.Error is { } deviceError) return deviceError;
var proof = device.Proof!;
// 2. Contenu : conditions acceptées, adresse, mot de passe (mêmes règles que le launcher).
if (!string.Equals(request.AcceptedTermsVersion, settings.TermsVersion, StringComparison.Ordinal))
return ApiResults.Error(400, ApiErrorCodes.TermsNotAccepted,
"Acceptez les conditions d'utilisation et la politique de confidentialité en vigueur pour créer un compte.");
if (PlayerEmail.Normalize(request.Email) is not { } email)
return ApiResults.Error(400, ApiErrorCodes.EmailInvalid, "Adresse e-mail invalide.");
if (request.Password is not { Length: > 0 and <= MaxSubmittedPasswordLength })
return ApiResults.Error(400, ApiErrorCodes.PasswordRejected, "Mot de passe invalide.");
var errors = PasswordRules.Check(request.Password, email);
if (errors.Count > 0) return ApiResults.Error(400, ApiErrorCodes.PasswordRejected, string.Join(" ", errors));
// 3. Limites par adresse IP (en mémoire) puis plafond quotidien global.
var now = time.GetUtcNow();
if (!guard.TryCountRegistration(context.RateLimitPartition(), out var retryAfter))
return RateLimited(context, retryAfter, "Plusieurs comptes ont été créés depuis votre connexion récemment : réessayez plus tard.");
if (store.CountCreatedSince(now - TimeSpan.FromDays(1)) >= settings.MaxRegistrationsPerDay)
return ApiResults.Error(503, ApiErrorCodes.RegistrationClosed,
"Trop de comptes ont été créés aujourd'hui : réessayez demain.");
// 4. Adresse libre ? (la création révèle forcément qu'une adresse est prise : limites ci-dessus + preuve de travail).
var emailIndex = secrets.EmailIndex(email);
if (store.FindByEmailIndex(emailIndex) is not null)
{
store.AddEvent(null, PlayerEvents.Register, AuditResults.Denied, ip, proof.KeyId, "adresse déjà utilisée");
return ApiResults.Error(409, ApiErrorCodes.EmailTaken, "Cette adresse e-mail est déjà utilisée par un compte.");
}
// 5. Empreinte (calcul coûteux, hors verrou), puis création sous transaction (unicité revérifiée).
var hash = await hasher.HashAsync(request.Password, context.RequestAborted);
var accountId = PlayerIds.NewId();
var account = new PlayerAccountRecord
{
Id = accountId,
Reference = PlayerIds.NewReference(),
EmailIndex = emailIndex,
EmailEncrypted = secrets.EncryptEmail(accountId, email),
PasswordHash = hash,
PasswordChangedAt = now,
CreatedAt = now,
LastLoginAt = now,
TermsVersion = settings.TermsVersion
};
var (deviceRecord, refreshToken) = NewDevice(accountId, proof, settings, now);
if (!store.TryCreate(account, deviceRecord, PlayerIds.NewReference, out var created))
return ApiResults.Error(409, ApiErrorCodes.EmailTaken, "Cette adresse e-mail est déjà utilisée par un compte.");
store.AddEvent(created.Id, PlayerEvents.Register, AuditResults.Ok, ip, proof.KeyId,
$"conditions version {settings.TermsVersion} ; appareil « {proof.Label} »");
return Results.Json(new PlayerLoginResponse
{
Status = PlayerLoginStatus.Ok,
Session = OpenSession(created, deviceRecord, refreshToken, sessions, secrets)
}, ApiJson.Options);
}
// ------------------------------------------------------------------ Connexion
private static async Task<IResult> Login(PlayerLoginRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerGuard guard, PlayerSessionStore sessions, ServerIdentity server, AuditLog audit, TimeProvider time)
{
var settings = options.Value.Players;
if (!settings.Enabled) return Disabled();
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
var hasher = services.GetRequiredService<PlayerPasswordHasher>();
var ip = context.ClientIp();
var partition = context.RateLimitPartition();
var device = CheckDevice(guard, server, PlayerChallengePurposes.Login, request.ChallengeId, request.Solution, request.Device);
if (device.Error is { } deviceError) return deviceError;
var proof = device.Proof!;
if (guard.IsIpThrottled(partition, out var ipRetry))
return RateLimited(context, ipRetry, "Trop d'échecs de connexion depuis votre connexion Internet : réessayez plus tard.");
var now = time.GetUtcNow();
var password = request.Password is { Length: > 0 and <= MaxSubmittedPasswordLength } p ? p : null;
var email = PlayerEmail.Normalize(request.Email);
if (email is null || password is null)
{
if (password is not null) await hasher.DummyAsync(password, context.RequestAborted);
guard.CountFailedLogin(partition);
return LoginFailed();
}
var emailIndex = secrets.EmailIndex(email);
var account = store.FindByEmailIndex(emailIndex);
if (account is null)
{
// Adresse sans compte : même calcul, même blocage, même réponse qu'un mauvais mot de passe.
if (guard.UnknownLockedUntil(emailIndex) is { } unknownUntil) return TooManyAttempts(context, unknownUntil, now);
await hasher.DummyAsync(password, context.RequestAborted);
guard.CountFailedLogin(partition);
store.AddEvent(null, PlayerEvents.LoginFailed, AuditResults.Denied, ip, proof.KeyId, "adresse sans compte");
return guard.CountUnknownFailure(emailIndex) is { } lockedNow ? TooManyAttempts(context, lockedNow, now) : LoginFailed();
}
var trusted = store.FindDeviceByKey(account.Id, proof.KeyId) is not null;
if ((trusted ? account.TrustedLockedUntil : account.LockedUntil) is { } until && until > now)
{
store.AddEvent(account.Id, PlayerEvents.LoginFailed, AuditResults.Denied, ip, proof.KeyId, "connexion bloquée (trop d'essais)");
return TooManyAttempts(context, until, now);
}
var (valid, rehash) = await hasher.VerifyAsync(password, account.PasswordHash, context.RequestAborted);
if (!valid)
{
var outcome = store.RecordFailure(account.Id, trusted, now, settings);
guard.CountFailedLogin(partition);
store.AddEvent(account.Id, PlayerEvents.LoginFailed, AuditResults.Denied, ip, proof.KeyId,
trusted ? "mot de passe incorrect (appareil de confiance)" : "mot de passe incorrect (appareil inconnu)");
if (outcome.Suspended)
{
var count = settings.FailuresPerLock * settings.SuspendAfterLocks;
store.AddEvent(account.Id, PlayerEvents.Suspended, AuditResults.Ok, null, null,
$"{count} échecs de connexion consécutifs depuis des appareils inconnus");
audit.Write("players.suspend", AuditResults.Ok, actorName: "système", ip: ip, target: account.Reference,
details: $"compte joueur suspendu automatiquement (nouveaux appareils) : {count} échecs de connexion consécutifs " +
"depuis des appareils inconnus");
}
if (outcome.LockedUntil is { } lockedUntil)
{
store.AddEvent(account.Id, PlayerEvents.Locked, AuditResults.Ok, ip, proof.KeyId,
$"blocage n° {outcome.LockLevel} jusqu'au {lockedUntil.UtcDateTime:yyyy-MM-dd HH:mm} UTC" +
(trusted ? " (appareils de confiance)" : " (appareils inconnus)"));
return TooManyAttempts(context, lockedUntil, now);
}
return LoginFailed();
}
// Mot de passe correct : l'état du compte peut maintenant être révélé.
if (StateRefusal(store, account, now, trusted) is { } refusal)
{
store.AddEvent(account.Id, PlayerEvents.Login, AuditResults.Denied, ip, proof.KeyId, refusal.Reason);
return refusal.Result;
}
store.RecordSuccess(account.Id, now, rehash ? await hasher.HashAsync(password, context.RequestAborted) : null);
account = store.FindById(account.Id) ?? account;
if (account.MustChangePassword)
{
var (changeToken, change) = sessions.CreatePasswordChange(account.Id, proof.PublicKey, proof.KeyId, proof.Label);
store.AddEvent(account.Id, PlayerEvents.PasswordChangeForced, AuditResults.Ok, ip, proof.KeyId,
"connexion acceptée : nouveau mot de passe exigé avant d'ouvrir la session");
return Results.Json(new PlayerLoginResponse
{
Status = PlayerLoginStatus.PasswordChangeRequired,
PasswordChangeToken = changeToken,
PasswordChangeExpiresAt = change.ExpiresAt
}, ApiJson.Options);
}
var (deviceRecord, refreshToken) = NewDevice(account.Id, proof, settings, now);
var removed = store.SaveDevice(deviceRecord, settings.MaxDevicesPerAccount);
sessions.RemoveDevices(removed);
store.AddEvent(account.Id, PlayerEvents.Login, AuditResults.Ok, ip, proof.KeyId,
$"appareil « {proof.Label} »{(trusted ? "" : " (nouvel appareil)")}" +
(account.Status == PlayerStatus.Suspended ? " ; compte suspendu pour les nouveaux appareils" : ""));
return Results.Json(new PlayerLoginResponse
{
Status = PlayerLoginStatus.Ok,
Session = OpenSession(account, deviceRecord, refreshToken, sessions, secrets)
}, ApiJson.Options);
}
/// <summary>
/// Refus lié à l'état du compte, révélé seulement à qui connaît le mot de passe ou possède une session mémorisée :
/// désactivé (tout est refusé), suspendu (seuls les appareils de confiance passent). Une désactivation arrivée à
/// échéance est levée au passage.
/// </summary>
private static (IResult Result, string Reason)? StateRefusal(PlayerStore store, PlayerAccountRecord account, DateTimeOffset now,
bool trustedDevice)
{
switch (account.Status)
{
case PlayerStatus.Active:
return null;
case PlayerStatus.Suspended when trustedDevice:
return null;
case PlayerStatus.Inactive:
// Compte en sommeil (longue inactivité) : la connexion avec le bon mot de passe le réveille.
store.SetStatus(account.Id, PlayerStatus.Active, null, null, "système (connexion après inactivité)", now);
store.AddEvent(account.Id, PlayerEvents.Reactivated, AuditResults.Ok, null, null, "connexion après une longue inactivité");
return null;
case PlayerStatus.Disabled when account.DisabledUntil is { } end && end <= now:
store.SetStatus(account.Id, PlayerStatus.Active, null, null, "système (fin de la désactivation)", now);
store.AddEvent(account.Id, PlayerEvents.Enabled, AuditResults.Ok, null, null, "fin de la désactivation temporaire");
return null;
case PlayerStatus.Disabled:
var until = account.DisabledUntil is { } date
? $" jusqu'au {date.UtcDateTime.ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture)} (UTC)"
: "";
return (ApiResults.Error(403, ApiErrorCodes.AccountDisabled,
$"Ce compte est désactivé{until}. Contactez le support en indiquant la référence {account.Reference}."),
"compte désactivé");
default:
return (ApiResults.Error(403, ApiErrorCodes.AccountSuspended,
"Par sécurité, les connexions depuis un nouvel appareil sont suspendues sur ce compte après de nombreux " +
"essais de mot de passe. Connectez-vous depuis un appareil déjà utilisé, ou contactez le support en indiquant " +
$"la référence {account.Reference} : un administrateur pourra lever la suspension."),
"compte suspendu (nouvel appareil)");
}
}
// ------------------------------------------------------------------ Renouvellement (connexion mémorisée)
private static IResult Refresh(PlayerRefreshRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerGuard guard, PlayerSessionStore sessions, ServerIdentity server, TimeProvider time)
{
var settings = options.Value.Players;
if (!settings.Enabled) return Disabled();
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
var ip = context.ClientIp();
var revoked = ApiResults.Error(401, ApiErrorCodes.SessionRevoked, "Session expirée ou révoquée : reconnectez-vous.");
if (!PlayerTokens.TryParseRefresh(request.RefreshToken, out var deviceId, out var secret)) return revoked;
if (guard.Validate(request.ChallengeId, PlayerChallengePurposes.Refresh, null) is not { } nonce)
return ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Défi absent, expiré ou invalide : réessayez.");
if (store.FindDevice(deviceId) is not { } device) return revoked;
// Possession de la clé de l'appareil : un jeton copié sur un autre PC ne sert à rien.
var signature = AdminKeys.DecodeSignature(request.Signature);
byte[] spki;
try
{
spki = Convert.FromBase64String(device.PublicKey);
}
catch (FormatException)
{
return revoked;
}
if (signature is null || !AdminKeys.Verify(spki,
PlayerMessages.Device(PlayerChallengePurposes.Refresh, server.Origin, server.ServerId, request.ChallengeId, nonce, device.KeyId),
signature))
{
store.AddEvent(device.AccountId, PlayerEvents.Refresh, AuditResults.Denied, ip, device.KeyId, "signature de l'appareil invalide");
return ApiResults.Error(401, ApiErrorCodes.DeviceProofInvalid, "Cet appareil n'a pas pu prouver son identité : reconnectez-vous.");
}
if (!guard.TryConsume(request.ChallengeId))
return ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Défi déjà utilisé : réessayez.");
var now = time.GetUtcNow();
var hash = HashSecret(secret);
if (device.PreviousRefreshHash is { } previous && FixedEquals(hash, previous))
{
// Jeton déjà remplacé et présenté de nouveau : copie volée (ou réponse perdue). L'appareil est déconnecté.
store.DeleteDevice(device.Id);
sessions.RemoveDevices(new[] { device.Id });
store.AddEvent(device.AccountId, PlayerEvents.RefreshReuse, AuditResults.Denied, ip, device.KeyId,
"jeton de renouvellement déjà utilisé : appareil déconnecté par précaution");
return revoked;
}
if (!FixedEquals(hash, device.RefreshHash)) return revoked;
if (now >= device.RefreshExpiresAt || now >= device.RefreshAbsoluteExpiresAt)
{
store.DeleteDevice(device.Id);
store.AddEvent(device.AccountId, PlayerEvents.Refresh, AuditResults.Denied, ip, device.KeyId, "session mémorisée expirée");
return revoked;
}
var account = store.FindById(device.AccountId);
if (account is null) return revoked;
if (StateRefusal(store, account, now, trustedDevice: true) is { } refusal)
{
store.AddEvent(account.Id, PlayerEvents.Refresh, AuditResults.Denied, ip, device.KeyId, refusal.Reason);
return refusal.Result;
}
if (account.MustChangePassword)
return ApiResults.Error(403, ApiErrorCodes.PasswordChangeRequired,
"Un nouveau mot de passe est exigé : reconnectez-vous pour le choisir.");
var newSecret = PlayerTokens.NewSecret();
var idle = Min(now + TimeSpan.FromDays(settings.RefreshIdleDays), device.RefreshAbsoluteExpiresAt);
if (!store.RotateRefresh(device.Id, device.RefreshHash, HashSecret(newSecret), idle, now)) return revoked;
// Journal : un renouvellement par appareil et par jour au plus (le launcher en fait un à chaque lancement).
if (device.LastUsedAt.UtcDateTime.Date != now.UtcDateTime.Date)
store.AddEvent(account.Id, PlayerEvents.Refresh, AuditResults.Ok, ip, device.KeyId, $"appareil « {device.Label} »");
var rotated = device with { RefreshExpiresAt = idle, LastUsedAt = now };
return Results.Json(OpenSession(account, rotated, PlayerTokens.FormatRefresh(device.Id, newSecret), sessions, secrets), ApiJson.Options);
}
// ------------------------------------------------------------------ Session ouverte
private static IResult Logout(PlayerLogoutRequest? request, HttpContext context, IServiceProvider services, PlayerSessionStore sessions)
{
var player = context.Player();
var store = services.GetRequiredService<PlayerStore>();
var ip = context.ClientIp();
if (request?.AllDevices == true)
{
var removed = store.DeleteDevices(player.Account.Id);
sessions.RemoveWhere(s => s.AccountId == player.Account.Id);
store.AddEvent(player.Account.Id, PlayerEvents.LogoutAll, AuditResults.Ok, ip, player.Device?.KeyId,
$"{removed.Count} appareil(s) déconnecté(s)");
}
else if (player.Device is { } device)
{
store.DeleteDevice(device.Id);
sessions.RemoveDevices(new[] { device.Id });
store.AddEvent(player.Account.Id, PlayerEvents.Logout, AuditResults.Ok, ip, device.KeyId, $"appareil « {device.Label} »");
}
return Results.NoContent();
}
private static IResult Me(HttpContext context, IServiceProvider services)
{
var player = context.Player();
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
return Results.Json(new PlayerMeResponse
{
Account = Info(player.Account, secrets),
Devices = store.Devices(player.Account.Id).Select(d => new PlayerDeviceInfo
{
Id = d.Id,
Label = d.Label,
CreatedAt = d.CreatedAt,
LastUsedAt = d.LastUsedAt,
Current = d.Id == player.Device?.Id
}).ToList()
}, ApiJson.Options);
}
private static async Task<IResult> ChangePassword(PlayerChangePasswordRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerSessionStore sessions, TimeProvider time)
{
var settings = options.Value.Players;
var player = context.Player();
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
var hasher = services.GetRequiredService<PlayerPasswordHasher>();
var ip = context.ClientIp();
var account = player.Account;
var deviceKey = player.Device?.KeyId ?? player.Session.DeviceKeyId;
var now = time.GetUtcNow();
if (account.TrustedLockedUntil is { } until && until > now) return TooManyAttempts(context, until, now);
if (request.CurrentPassword is not { Length: > 0 and <= MaxSubmittedPasswordLength } ||
request.NewPassword is not { Length: > 0 and <= MaxSubmittedPasswordLength })
return ApiResults.BadRequest("Mot de passe actuel et nouveau mot de passe obligatoires.");
if (await CheckCurrentPasswordAsync(request.CurrentPassword, account, PlayerEvents.PasswordChange, deviceKey, context, store, hasher,
sessions, settings, now) is { } refused)
return refused;
var email = secrets.DecryptEmail(account.Id, account.EmailEncrypted);
var errors = PasswordRules.Check(request.NewPassword, email).ToList();
if (errors.Count == 0 &&
string.Equals(request.NewPassword, request.CurrentPassword, StringComparison.Ordinal))
errors.Add("Le nouveau mot de passe doit être différent de l'actuel.");
if (errors.Count > 0) return ApiResults.Error(400, ApiErrorCodes.PasswordRejected, string.Join(" ", errors));
var hash = await hasher.HashAsync(request.NewPassword, context.RequestAborted);
store.SetPassword(account.Id, hash, now);
store.RecordSuccess(account.Id, now, null);
if (player.Session.Scope == PlayerScopes.PasswordChange)
{
// Changement EXIGÉ : tous les appareils sont déconnectés, puis la session s'ouvre sur cet appareil.
var removed = store.DeleteDevices(account.Id);
sessions.RemoveWhere(s => s.AccountId == account.Id);
var proof = new DeviceProofResult(player.Session.DevicePublicKey!, player.Session.DeviceKeyId!, player.Session.DeviceLabel!);
var (deviceRecord, refreshToken) = NewDevice(account.Id, proof, settings, now);
store.SaveDevice(deviceRecord, settings.MaxDevicesPerAccount);
store.AddEvent(account.Id, PlayerEvents.PasswordChange, AuditResults.Ok, ip, proof.KeyId,
$"nouveau mot de passe (exigé) ; {removed.Count} autre(s) appareil(s) déconnecté(s)");
var refreshed = store.FindById(account.Id) ?? account;
return Results.Json(new PlayerChangePasswordResponse
{
Session = OpenSession(refreshed, deviceRecord, refreshToken, sessions, secrets),
DevicesSignedOut = removed.Count
}, ApiJson.Options);
}
// Changement ordinaire : les AUTRES appareils sont déconnectés (celui-ci reste connecté).
var others = store.DeleteDevices(account.Id, exceptDeviceId: player.Device!.Id);
sessions.RemoveWhere(s => s.AccountId == account.Id && s.DeviceId != player.Device.Id);
store.AddEvent(account.Id, PlayerEvents.PasswordChange, AuditResults.Ok, ip, deviceKey,
$"nouveau mot de passe ; {others.Count} autre(s) appareil(s) déconnecté(s)");
return Results.Json(new PlayerChangePasswordResponse { DevicesSignedOut = others.Count }, ApiJson.Options);
}
private static IResult DeleteDevice(string deviceId, HttpContext context, IServiceProvider services, PlayerSessionStore sessions)
{
var player = context.Player();
var store = services.GetRequiredService<PlayerStore>();
if (!PlayerIds.IsValidId(deviceId) || store.FindDevice(deviceId) is not { } device || device.AccountId != player.Account.Id)
return ApiResults.NotFound("Appareil introuvable.");
store.DeleteDevice(device.Id);
sessions.RemoveDevices(new[] { device.Id });
store.AddEvent(player.Account.Id, PlayerEvents.DeviceRemoved, AuditResults.Ok, context.ClientIp(), player.Device?.KeyId,
$"appareil « {device.Label} » déconnecté{(device.Id == player.Device?.Id ? " (celui-ci)" : "")}");
return Results.NoContent();
}
// ------------------------------------------------------------------ Activité, export et suppression (RGPD)
/// <summary>Activité récente du compte (50 dernières lignes), sans détail interne, adresses IP masquées.</summary>
private static IResult Activity(HttpContext context, IServiceProvider services)
{
var player = context.Player();
var store = services.GetRequiredService<PlayerStore>();
var labels = store.Devices(player.Account.Id).ToDictionary(d => d.KeyId, d => d.Label, StringComparer.Ordinal);
var currentKey = player.Device?.KeyId;
return Results.Json(new PlayerActivityResponse
{
Entries = store.Events(player.Account.Id, 50).Select(e => new PlayerActivityEntry
{
At = e.At,
Type = e.Type,
Success = e.Result == AuditResults.Ok,
Device = e.DeviceKeyId is { } key && labels.TryGetValue(key, out var label) ? label : null,
ThisDevice = currentKey is not null && e.DeviceKeyId == currentKey,
Ip = MaskIp(e.Ip)
}).ToList()
}, ApiJson.Options);
}
/// <summary>
/// Export de toutes les données du compte (droits d'accès et de portabilité), mot de passe exigé : le document contient
/// l'adresse e-mail complète, que l'API ne renvoie jamais autrement.
/// </summary>
private static async Task<IResult> Export(PlayerPasswordConfirmation request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerSessionStore sessions, ServerIdentity server, TimeProvider time)
{
var player = context.Player();
var store = services.GetRequiredService<PlayerStore>();
var now = time.GetUtcNow();
if (await ConfirmAsync(request, player, PlayerEvents.Export, context, options.Value.Players, services, sessions, now) is { } refused)
return refused;
var account = store.FindById(player.Account.Id) ?? player.Account;
var document = services.GetRequiredService<PlayerPersonalData>().Export(account, server.Origin, now);
store.AddEvent(account.Id, PlayerEvents.Export, AuditResults.Ok, context.ClientIp(), player.Device?.KeyId, null);
return Results.Bytes(document, "application/json; charset=utf-8");
}
/// <summary>Suppression définitive du compte par son titulaire (droit à l'effacement), mot de passe exigé.</summary>
private static async Task<IResult> DeleteAccount(PlayerPasswordConfirmation request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerSessionStore sessions, TimeProvider time)
{
var player = context.Player();
var now = time.GetUtcNow();
if (await ConfirmAsync(request, player, PlayerEvents.Delete, context, options.Value.Players, services, sessions, now) is { } refused)
return refused;
services.GetRequiredService<PlayerPersonalData>().Erase(player.Account, byPlayer: true, context.ClientIp());
return Results.NoContent();
}
/// <summary>Mot de passe actuel d'une action sensible (export, suppression) : présence, blocage en cours, vérification.</summary>
private static async Task<IResult?> ConfirmAsync(PlayerPasswordConfirmation? request, AuthenticatedPlayer player, string eventType,
HttpContext context, LauncherApiOptions.PlayersSettings settings, IServiceProvider services, PlayerSessionStore sessions, DateTimeOffset now)
{
var account = player.Account;
if (account.TrustedLockedUntil is { } until && until > now) return TooManyAttempts(context, until, now);
if (request?.Password is not { Length: > 0 and <= MaxSubmittedPasswordLength } password)
return ApiResults.BadRequest("Mot de passe obligatoire.");
return await CheckCurrentPasswordAsync(password, account, eventType, player.Device?.KeyId, context,
services.GetRequiredService<PlayerStore>(), services.GetRequiredService<PlayerPasswordHasher>(), sessions, settings, now);
}
/// <summary>
/// Vérifie le mot de passe actuel d'un joueur DÉJÀ connecté. Un échec compte comme un échec de connexion depuis un appareil
/// de confiance : une session volée qui tente de deviner le mot de passe est bloquée, puis toutes les sessions du compte
/// sont fermées. Null si le mot de passe est correct.
/// </summary>
private static async Task<IResult?> CheckCurrentPasswordAsync(string password, PlayerAccountRecord account, string eventType, string? deviceKey,
HttpContext context, PlayerStore store, PlayerPasswordHasher hasher, PlayerSessionStore sessions,
LauncherApiOptions.PlayersSettings settings, DateTimeOffset now)
{
var (valid, _) = await hasher.VerifyAsync(password, account.PasswordHash, context.RequestAborted);
if (valid) return null;
var outcome = store.RecordFailure(account.Id, trusted: true, now, settings);
store.AddEvent(account.Id, eventType, AuditResults.Denied, context.ClientIp(), deviceKey, "mot de passe actuel incorrect");
if (outcome.LockedUntil is { } locked)
{
sessions.RemoveWhere(s => s.AccountId == account.Id);
return TooManyAttempts(context, locked, now);
}
return ApiResults.Error(400, ApiErrorCodes.LoginFailed, "Mot de passe actuel incorrect.");
}
/// <summary>
/// Adresse IP masquée pour l'affichage au joueur : IPv4 « 203.0.113.x », IPv6 réduite à son préfixe /48
/// (« 2001:db8:85a3::/48 »). Elle peut être celle d'un tiers (essai de mot de passe) : jamais affichée en entier.
/// </summary>
internal static string? MaskIp(string? ip)
{
if (string.IsNullOrWhiteSpace(ip) || !IPAddress.TryParse(ip, out var address)) return null;
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
var bytes = address.GetAddressBytes();
if (bytes.Length == 4) return $"{bytes[0]}.{bytes[1]}.{bytes[2]}.x";
var prefix = new byte[16];
Array.Copy(bytes, prefix, 6);
return new IPAddress(prefix) + "/48";
}
// ------------------------------------------------------------------ Outils
/// <summary>Clé d'appareil vérifiée : clé publique (Base64 canonique), identifiant, nom générique.</summary>
private sealed record DeviceProofResult(string PublicKey, string KeyId, string Label);
/// <summary>
/// Défi (sceau, usage, échéance, preuve de travail) puis signature de la clé d'appareil, puis consommation du défi
/// (un défi ne sert qu'une fois, et seulement avec une signature valide).
/// </summary>
private static (DeviceProofResult? Proof, IResult? Error) CheckDevice(PlayerGuard guard, ServerIdentity server, string purpose,
string? challengeId, string? solution, PlayerDeviceProof? device)
{
if (guard.Validate(challengeId, purpose, solution) is not { } nonce)
return (null, ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Vérification anti-robot échouée ou expirée : réessayez."));
var deviceError = ApiResults.Error(400, ApiErrorCodes.DeviceProofInvalid, "Clé de l'appareil invalide.");
if (device is null) return (null, deviceError);
byte[] spki;
try
{
spki = AdminKeys.ParsePublicKey(device.PublicKey);
}
catch (CryptographicException)
{
return (null, deviceError);
}
var keyId = AdminKeys.ComputeKeyId(spki);
var signature = AdminKeys.DecodeSignature(device.Signature);
if (signature is null ||
!AdminKeys.Verify(spki, PlayerMessages.Device(purpose, server.Origin, server.ServerId, challengeId!, nonce, keyId), signature))
return (null, deviceError);
if (!guard.TryConsume(challengeId!))
return (null, ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Vérification déjà utilisée : réessayez."));
var label = ApiText.CleanLine(device.Label, ApiText.MaxKeyLabelLength) ?? "Appareil";
return (new DeviceProofResult(Convert.ToBase64String(spki), keyId, label), null);
}
private static (PlayerDeviceRecord Device, string RefreshToken) NewDevice(string accountId, DeviceProofResult proof,
LauncherApiOptions.PlayersSettings settings, DateTimeOffset now)
{
var deviceId = PlayerIds.NewId();
var secret = PlayerTokens.NewSecret();
var absolute = now + TimeSpan.FromDays(settings.RefreshMaxDays);
var device = new PlayerDeviceRecord
{
Id = deviceId,
AccountId = accountId,
KeyId = proof.KeyId,
PublicKey = proof.PublicKey,
Label = proof.Label,
CreatedAt = now,
LastUsedAt = now,
RefreshHash = HashSecret(secret),
RefreshExpiresAt = Min(now + TimeSpan.FromDays(settings.RefreshIdleDays), absolute),
RefreshAbsoluteExpiresAt = absolute
};
return (device, PlayerTokens.FormatRefresh(deviceId, secret));
}
private static PlayerSession OpenSession(PlayerAccountRecord account, PlayerDeviceRecord device, string refreshToken,
PlayerSessionStore sessions, PlayerSecrets secrets)
{
var (accessToken, access) = sessions.CreateAccess(account.Id, device.Id);
return new PlayerSession
{
AccessToken = accessToken,
AccessExpiresAt = access.ExpiresAt,
RefreshToken = refreshToken,
RefreshExpiresAt = device.RefreshExpiresAt,
DeviceId = device.Id,
Account = Info(account, secrets)
};
}
private static PlayerAccountInfo Info(PlayerAccountRecord account, PlayerSecrets secrets) => new()
{
Reference = account.Reference,
EmailMasked = PlayerEmail.Mask(secrets.DecryptEmail(account.Id, account.EmailEncrypted)),
EmailVerified = account.EmailVerified,
CreatedAt = account.CreatedAt,
LastLoginAt = account.LastLoginAt,
PasswordChangedAt = account.PasswordChangedAt,
OtpEnabled = account.OtpEnabled,
NewDevicesSuspended = account.Status == PlayerStatus.Suspended
};
/// <summary>Empreinte d'un secret de jeton de renouvellement (seule valeur conservée).</summary>
private static string HashSecret(string secret) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("GameLauncher/Player/Refresh/v1\n" + secret))).ToLowerInvariant();
private static bool FixedEquals(string a, string b) =>
CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(a), Encoding.ASCII.GetBytes(b));
private static DateTimeOffset Min(DateTimeOffset a, DateTimeOffset b) => a <= b ? a : b;
}
@@ -0,0 +1,163 @@
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,
["twoFactorEnabled"] = account.OtpEnabled
},
["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.");
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)
{
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
_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>
/// 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);
}
}
@@ -197,7 +197,7 @@ public sealed class FeedbackModule : IApiModule
/// <summary>
/// Publication des formulaires : requête signée (permission feedback.forms) ET formulaires signés par la clé de la
/// session sur leur condensé (les launchers revérifient avec le trousseau : autorisation « forms »), règles complètes,
/// révision attendue (409 si quelqu'un a publié entre-temps), audit.
/// révision attendue (409 si quelqu'un a publié entre-temps), audit détaillé par type de demande (<see cref="FeedbackFormsAudit"/>).
/// </summary>
private static IResult PublishForms(FeedbackFormsPublishRequest request, HttpContext context, FeedbackFormsStore forms, AdminStore admins,
AuditLog audit, TimeProvider time, ILogger<FeedbackModule> logger)
@@ -222,8 +222,17 @@ public sealed class FeedbackModule : IApiModule
"Le serveur n'a pas pu écrire les formulaires publics : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
}
audit.Write("feedback.forms", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"révision {published.Revision}", $"{FeedbackFormsStore.Describe(previous, published)} ; signé (clé {signature.KeyId})");
// Une ligne par type de demande ajouté, modifié ou supprimé (son nom et tous ses changements, champs compris, sur
// plusieurs lignes numérotées si besoin), puis la ligne de la publication (révision, résumé, signature). Écrites
// de la dernière à la première : le journal, affiché du plus récent au plus ancien, les montre dans l'ordre.
var changes = FeedbackFormsAudit.Changes(previous, published);
foreach (var (action, name, details) in changes
.SelectMany(c => FeedbackFormsAudit.Lines(c.Details).Select(details => (c.Action, c.Name, details)))
.Reverse())
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"« {name} »", details);
audit.Write(FeedbackFormsAudit.Published, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"révision {published.Revision}", $"{FeedbackFormsAudit.Summary(previous, published, changes)} ; signé (clé {signature.KeyId})");
return Results.Json(FormsState(forms, admins), ApiJson.Options);
}
@@ -0,0 +1,104 @@
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Modules.Maintenance;
/// <summary>
/// Mode maintenance du launcher (commun à tous les jeux, <see cref="MaintenanceStore"/>) :
/// • GET v1/maintenance : état en vigueur, pour tout administrateur connecté (contenu public de toute façon) ;
/// • PUT v1/maintenance : activation, désactivation ou nouveau message — permission maintenance.manage, requête signée
/// ET document signé par la clé de la session (autorisation « maintenance » du trousseau, vérifiée par les launchers),
/// règles complètes, révision attendue (409 si quelqu'un a publié entre-temps), audit.
/// Le serveur ne bloque rien lui-même : ce sont les launchers des joueurs qui, en lisant le fichier public, bloquent tout
/// sauf la désinstallation des jeux et le menu d'administration. L'API d'administration reste utilisable pendant la
/// maintenance (c'est elle qui permet de la terminer).
/// </summary>
public sealed class MaintenanceModule : IApiModule
{
public string Name => "Maintenance";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<MaintenanceStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.Maintenance, Get).RequireAdmin();
api.MapPut(ApiRoutes.Maintenance, Publish).RequirePermission(AdminPermissions.MaintenanceManage).RequireSignedRequest();
}
private static IResult Get(MaintenanceStore store, AdminStore admins, ILogger<MaintenanceModule> logger)
{
try
{
return Results.Json(State(store, admins), ApiJson.Options);
}
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
{
logger.LogError(ex, "Lecture du mode maintenance");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/ " +
"dans les données privées du serveur.");
}
}
private static MaintenanceState State(MaintenanceStore store, AdminStore admins)
{
var current = store.Current;
return new MaintenanceState
{
Document = current,
PublishingEnabled = store.PublishingEnabled,
PublicUrl = store.PublicUrl,
UpdatedBy = ContentSignatures.SignerName(admins.Read(), current.Signature)
};
}
private static IResult Publish(MaintenancePublishRequest request, HttpContext context, MaintenanceStore store, AdminStore admins,
AuditLog audit, TimeProvider time, ILogger<MaintenanceModule> logger)
{
if (request.Document is null) return ApiResults.BadRequest("Document de maintenance absent.");
var document = request.Document with { UpdatedAt = null };
var actor = context.Admin();
if (document.Signature is not { } signature)
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired, "Le mode maintenance doit être signé.");
var now = time.GetUtcNow();
ContentSignatures.RequireFromActor(signature, ContentKinds.Maintenance, ContentKinds.MaintenanceId,
MaintenanceRules.Digest(document), actor, now);
MaintenanceDocument published, previous;
try
{
(published, previous) = store.Publish(document, request.BaseRevision, now);
}
catch (InvalidDataException ex)
{
logger.LogError(ex, "Publication du mode maintenance");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/.");
}
catch (IOException ex)
{
logger.LogError(ex, "Publication du mode maintenance");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu écrire le fichier public du mode maintenance : vérifiez le dossier et ses droits " +
"(server/deploy/LISEZMOI.md).");
}
var action = published.Enabled switch
{
true when !previous.Enabled => "maintenance.enable",
false when previous.Enabled => "maintenance.disable",
_ => "maintenance.update"
};
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"révision {published.Revision}", $"{MaintenanceRules.Describe(published)} ; signé (clé {signature.KeyId})");
logger.LogInformation("Mode maintenance {Action} par {Admin} (révision {Revision})", action, actor.Admin.DisplayName, published.Revision);
return Results.Json(State(store, admins), ApiJson.Options);
}
}
@@ -2,6 +2,8 @@ using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
@@ -13,7 +15,8 @@ namespace GameLauncher.Server.Modules.Signing;
/// <summary>
/// Signature du contenu servi aux joueurs (phase 7) :
/// • GET v1/signing (admins.manage) : trousseau publié, signatures des manifests et du catalogue ;
/// • GET v1/signing (admins.manage) : trousseau publié, signatures des manifests, du catalogue, des formulaires des demandes
/// et du mode maintenance ;
/// • PUT v1/keyring (admins.manage, requête signée) : publication d'un trousseau signé HORS LIGNE par la clé racine ;
/// • GET / POST v1/games/{gameId}/signature (releases.publish) : signature de la version déjà en ligne.
/// La signature des nouvelles versions et du catalogue se fait à leur publication (modules Publishing et Catalog).
@@ -33,8 +36,8 @@ public sealed class SigningModule : IApiModule
api.MapPost(ApiRoutes.ManifestSignaturePattern, SignManifest).RequirePermission(AdminPermissions.ReleasesPublish).RequireSignedRequest();
}
private static IResult State(KeyringStore keyrings, CatalogStore catalogs, AdminStore admins, IOptions<LauncherApiOptions> options,
ILogger<SigningModule> logger)
private static IResult State(KeyringStore keyrings, CatalogStore catalogs, FeedbackFormsStore forms, MaintenanceStore maintenance,
AdminStore admins, IOptions<LauncherApiOptions> options, ILogger<SigningModule> logger)
{
var settings = options.Value;
var db = admins.Read();
@@ -78,6 +81,55 @@ public sealed class SigningModule : IApiModule
};
}
// Formulaires des demandes en vigueur (référence privée, recopiée à l'identique dans le fichier public).
FeedbackFormsSignatureState? feedbackForms = null;
if (forms.PublishingEnabled)
{
try
{
var current = forms.Current;
feedbackForms = current.Revision == 0
? new FeedbackFormsSignatureState()
: new FeedbackFormsSignatureState
{
Revision = current.Revision,
Digest = FeedbackFormRules.Digest(current),
Signature = current.Signature,
SignerName = ContentSignatures.SignerName(db, current.Signature)
};
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
{
logger.LogWarning(ex, "Signature des formulaires des demandes");
feedbackForms = new FeedbackFormsSignatureState { Unreadable = true };
}
}
// Mode maintenance en vigueur (référence privée, recopiée à l'identique dans le fichier public).
MaintenanceSignatureState? maintenanceState = null;
if (maintenance.PublishingEnabled)
{
try
{
var current = maintenance.Current;
maintenanceState = current.Revision == 0
? new MaintenanceSignatureState()
: new MaintenanceSignatureState
{
Revision = current.Revision,
Enabled = current.Enabled,
Digest = MaintenanceRules.Digest(current),
Signature = current.Signature,
SignerName = ContentSignatures.SignerName(db, current.Signature)
};
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
{
logger.LogWarning(ex, "Signature du mode maintenance");
maintenanceState = new MaintenanceSignatureState { Unreadable = true };
}
}
KeyringDocument? keyring;
try
{
@@ -97,7 +149,9 @@ public sealed class SigningModule : IApiModule
KeyringPublicUrl = keyrings.PublishingEnabled ? settings.KeyringPublicUrl : null,
RequireSignatures = settings.ContentSigning.Require,
Manifests = manifests,
Catalog = catalog
Catalog = catalog,
FeedbackForms = feedbackForms,
Maintenance = maintenanceState
}, ApiJson.Options);
}
@@ -1,9 +1,11 @@
using System.Reflection;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.System;
@@ -21,13 +23,23 @@ public sealed class SystemModule : IApiModule
var plus = version.IndexOf('+');
if (plus > 0) version = version[..plus]; // sans l'empreinte du commit
api.MapGet(ApiRoutes.Info, (ServerIdentity server) => Results.Json(new ServerInfo
api.MapGet(ApiRoutes.Info, (ServerIdentity server, IOptions<LauncherApiOptions> options) => Results.Json(new ServerInfo
{
ServerId = server.ServerId,
Origin = server.Origin,
ApiVersion = ApiRoutes.Version,
ServerVersion = version,
KeyAlgorithms = new[] { AdminKeys.Algorithm }
KeyAlgorithms = new[] { AdminKeys.Algorithm },
// Comptes joueurs : annoncés seulement s'ils sont activés (le launcher n'affiche le bouton « Compte » qu'alors).
Accounts = options.Value.Players.Enabled
? new PlayerAccountsInfo
{
Enabled = true,
RegistrationOpen = options.Value.Players.RegistrationEnabled,
TermsVersion = options.Value.Players.TermsVersion,
PasswordRulesVersion = PasswordRules.Version
}
: null
}, ApiJson.Options));
api.MapGet(ApiRoutes.Health, () => Results.Json(new HealthStatus(), ApiJson.Options));
@@ -63,7 +63,13 @@ public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSc
if (!header.StartsWith("Bearer ", StringComparison.Ordinal))
return Task.FromResult(AuthenticateResult.Fail("Schéma d'authentification inattendu."));
var session = _sessions.Find(header["Bearer ".Length..].Trim());
var token = header["Bearer ".Length..].Trim();
// Jeton de JOUEUR (« glp… ») : jamais cherché parmi les sessions d'administrateurs. Un compte joueur ne peut ouvrir
// aucune route d'administration (voir PlayerSessionHandler).
if (token.StartsWith("glp", StringComparison.Ordinal))
return Task.FromResult(AuthenticateResult.NoResult());
var session = _sessions.Find(token);
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session absente ou expirée."));
var database = _store.Read();
@@ -115,12 +121,16 @@ public static class AdminAuthorization
{
services.AddAuthentication(AdminSessionHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, AdminSessionHandler>(AdminSessionHandler.SchemeName, null);
// Schéma désigné explicitement dans chaque règle : une route d'administration n'accepte QUE la session d'un
// administrateur, jamais celle d'un joueur (schéma « PlayerSession »), même si le schéma par défaut changeait.
services.AddAuthorization(options =>
{
options.AddPolicy(AdminSessionHandler.SchemeName, p => p.RequireAuthenticatedUser());
options.AddPolicy(AdminSessionHandler.SchemeName,
p => p.AddAuthenticationSchemes(AdminSessionHandler.SchemeName).RequireAuthenticatedUser());
foreach (var permission in AdminPermissions.All)
options.AddPolicy(PolicyName(permission),
p => p.RequireAuthenticatedUser().RequireClaim(AdminSessionHandler.PermissionClaim, permission));
p => p.AddAuthenticationSchemes(AdminSessionHandler.SchemeName).RequireAuthenticatedUser()
.RequireClaim(AdminSessionHandler.PermissionClaim, permission));
});
return services;
}
@@ -0,0 +1,128 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage.Players;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Security;
/// <summary>Joueur authentifié pour la requête en cours.</summary>
public sealed record AuthenticatedPlayer(PlayerAccessSession Session, PlayerAccountRecord Account, PlayerDeviceRecord? Device);
/// <summary>
/// Schéma « PlayerSession » : jeton joueur (« Authorization: Bearer glpa_… » ou « glpc_… »). TOTALEMENT distinct du
/// schéma des administrateurs (<see cref="AdminSessionHandler"/>) : autre stockage, autres préfixes, et les routes
/// d'administration n'utilisent jamais ce schéma (leurs règles désignent explicitement « AdminSession »). Un compte joueur
/// ne peut donc ouvrir aucune route d'administration, quels que soient ses droits ou son état.
///
/// À chaque requête : jeton valide ET compte actif (ou suspendu : la suspension ne vise que les nouveaux appareils) ET
/// (session complète) appareil toujours enregistré et aucun changement de mot de passe exigé. Une désactivation ou une
/// déconnexion prend effet immédiatement.
/// </summary>
public sealed class PlayerSessionHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "PlayerSession";
public const string ScopeClaim = "player_scope";
private const string ContextKey = "GameLauncher.Player";
private readonly PlayerSessionStore _sessions;
private readonly IServiceProvider _services;
private readonly LauncherApiOptions _options;
public PlayerSessionHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder,
PlayerSessionStore sessions, IServiceProvider services, IOptions<LauncherApiOptions> apiOptions)
: base(options, logger, encoder)
{
_sessions = sessions;
_services = services;
_options = apiOptions.Value;
}
public static AuthenticatedPlayer? Get(HttpContext context) =>
context.Items.TryGetValue(ContextKey, out var value) ? value as AuthenticatedPlayer : null;
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!_options.Players.Enabled) return Task.FromResult(AuthenticateResult.NoResult());
var header = Request.Headers.Authorization.ToString();
if (!header.StartsWith("Bearer ", StringComparison.Ordinal)) return Task.FromResult(AuthenticateResult.NoResult());
var token = header["Bearer ".Length..].Trim();
if (!token.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) &&
!token.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal))
return Task.FromResult(AuthenticateResult.NoResult()); // pas un jeton joueur
var session = _sessions.Find(token);
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session joueur absente ou expirée."));
var store = _services.GetRequiredService<PlayerStore>();
var account = store.FindById(session.AccountId);
// Suspendu : les appareils déjà connectés gardent l'accès (seuls les nouveaux appareils sont bloqués).
if (account is null || account.Status is not (PlayerStatus.Active or PlayerStatus.Suspended))
{
_sessions.RemoveWhere(s => s.AccountId == session.AccountId);
return Task.FromResult(AuthenticateResult.Fail("Compte supprimé, désactivé ou suspendu."));
}
PlayerDeviceRecord? device = null;
if (session.Scope == PlayerScopes.Full)
{
device = session.DeviceId is { } deviceId ? store.FindDevice(deviceId) : null;
if (device is null || device.AccountId != account.Id || account.MustChangePassword)
{
_sessions.Remove(session);
return Task.FromResult(AuthenticateResult.Fail("Appareil déconnecté ou nouveau mot de passe exigé."));
}
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, account.Id),
new(ScopeClaim, session.Scope)
};
Context.Items[ContextKey] = new AuthenticatedPlayer(session, account, device);
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName)));
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties) =>
ApiResults.WriteAsync(Context, 401, ApiErrorCodes.Unauthorized, "Connexion au compte requise (session absente ou expirée).");
protected override Task HandleForbiddenAsync(AuthenticationProperties properties) =>
ApiResults.WriteAsync(Context, 403, ApiErrorCodes.PasswordChangeRequired,
"Choisissez d'abord un nouveau mot de passe.");
}
public static class PlayerAuthorization
{
public const string PlayerPolicy = "player";
public const string PasswordChangePolicy = "player-password-change";
/// <summary>Route réservée à un joueur connecté (session complète).</summary>
public static TBuilder RequirePlayer<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
builder.RequireAuthorization(PlayerPolicy);
/// <summary>Route ouverte à une session complète OU au jeton limité « changement de mot de passe exigé ».</summary>
public static TBuilder RequirePlayerOrPasswordChange<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
builder.RequireAuthorization(PasswordChangePolicy);
public static IServiceCollection AddPlayerAuthentication(this IServiceCollection services)
{
services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, PlayerSessionHandler>(PlayerSessionHandler.SchemeName, null);
services.AddAuthorization(options =>
{
options.AddPolicy(PlayerPolicy, p => p.AddAuthenticationSchemes(PlayerSessionHandler.SchemeName).RequireAuthenticatedUser()
.RequireClaim(PlayerSessionHandler.ScopeClaim, PlayerScopes.Full));
options.AddPolicy(PasswordChangePolicy, p => p.AddAuthenticationSchemes(PlayerSessionHandler.SchemeName).RequireAuthenticatedUser()
.RequireClaim(PlayerSessionHandler.ScopeClaim, PlayerScopes.Full, PlayerScopes.PasswordChange));
});
return services;
}
/// <summary>Joueur authentifié de la requête (les routes protégées en ont toujours un).</summary>
public static AuthenticatedPlayer Player(this HttpContext context) =>
PlayerSessionHandler.Get(context) ?? throw new InvalidOperationException("Route non protégée par RequirePlayer.");
}
@@ -0,0 +1,242 @@
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Security;
/// <summary>
/// Protections des routes ANONYMES des comptes joueurs (création, connexion, renouvellement) :
/// • défis SANS ÉTAT (usage, échéance, difficulté et nonce scellés par HMAC, secret en mémoire, domaine propre aux
/// joueurs) : en demander ne coûte rien au serveur ; seuls les défis utilisés sont mémorisés (refus du rejeu) ;
/// • preuve de travail (création de compte, connexion) : coûteuse pour un robot, négligeable pour un joueur ;
/// • compteurs en mémoire par adresse IP (IPv6 : /64) : créations de compte, échecs de connexion ;
/// • blocage « miroir » des adresses inconnues : une adresse sans compte se bloque exactement comme un compte réel
/// (la réponse « trop d'essais » ne révèle donc pas qu'un compte existe).
/// </summary>
public sealed class PlayerGuard
{
private const int NonceLength = 16;
private const int MacLength = 16;
private const int PayloadLength = 1 + 8 + 1 + NonceLength; // usage + échéance + difficulté + nonce
private const int MaxTracked = 100_000;
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Player/Challenge/v1\n");
private readonly byte[] _secret = RandomNumberGenerator.GetBytes(32);
private readonly ConcurrentDictionary<string, DateTimeOffset> _used = new(StringComparer.Ordinal);
private readonly SlidingCounters _registrations = new();
private readonly SlidingCounters _failedLogins = new();
private readonly Dictionary<string, (int Failed, int Level, DateTimeOffset? Until)> _unknown = new(StringComparer.Ordinal);
private readonly LauncherApiOptions.PlayersSettings _settings;
private readonly TimeProvider _time;
public PlayerGuard(IOptions<LauncherApiOptions> options, TimeProvider time)
{
_settings = options.Value.Players;
_time = time;
}
// ------------------------------------------------------------------ Défis
public PlayerChallengeResponse CreateChallenge(string purpose, string serverId, string origin)
{
var code = PurposeCode(purpose);
var difficulty = purpose switch
{
PlayerChallengePurposes.Register => _settings.RegisterProofDifficulty,
PlayerChallengePurposes.Login => _settings.LoginProofDifficulty,
_ => 0
};
var expires = _time.GetUtcNow() + TimeSpan.FromSeconds(_settings.ChallengeLifetimeSeconds);
var payload = new byte[PayloadLength];
payload[0] = code;
BinaryPrimitives.WriteInt64BigEndian(payload.AsSpan(1), expires.ToUnixTimeSeconds());
payload[9] = (byte)difficulty;
RandomNumberGenerator.Fill(payload.AsSpan(10));
var id = new byte[PayloadLength + MacLength];
payload.CopyTo(id, 0);
Mac(payload).CopyTo(id, PayloadLength);
return new PlayerChallengeResponse
{
ChallengeId = Base64Url.Encode(id),
Nonce = Base64Url.Encode(payload.AsSpan(10)),
Purpose = purpose,
Difficulty = difficulty,
ServerId = serverId,
Origin = origin,
ExpiresAt = DateTimeOffset.FromUnixTimeSeconds(expires.ToUnixTimeSeconds())
};
}
/// <summary>
/// Vérifie le défi (sceau, usage, échéance) et la preuve de travail, SANS le consommer. Renvoie le nonce (à vérifier
/// dans la signature de l'appareil), ou null.
/// </summary>
public string? Validate(string? challengeId, string purpose, string? solution)
{
if (challengeId is not { Length: > 0 and <= 64 } || !Base64Url.TryDecode(challengeId, out var id) || id.Length != PayloadLength + MacLength)
return null;
var payload = id.AsSpan(0, PayloadLength);
if (!CryptographicOperations.FixedTimeEquals(Mac(payload), id.AsSpan(PayloadLength))) return null;
if (payload[0] != PurposeCode(purpose)) return null;
var now = _time.GetUtcNow();
var expires = DateTimeOffset.FromUnixTimeSeconds(BinaryPrimitives.ReadInt64BigEndian(payload[1..9]));
if (expires <= now) return null;
int difficulty = payload[9];
if (purpose != PlayerChallengePurposes.Refresh)
{
// Difficulté scellée à la création ; jamais en dessous du minimum pour la création de compte et la connexion.
if (difficulty < FeedbackProof.MinDifficulty || !FeedbackProof.Verify(payload[10..], difficulty, solution)) return null;
}
return Base64Url.Encode(payload[10..]);
}
/// <summary>Consomme le défi (après vérification de la signature de l'appareil). Faux s'il a déjà servi (rejeu).</summary>
public bool TryConsume(string challengeId)
{
var now = _time.GetUtcNow();
if (_used.Count >= MaxTracked)
{
foreach (var (key, until) in _used)
if (until <= now) _used.TryRemove(key, out _);
if (_used.Count >= MaxTracked) throw new StoreBusyException("Trop de défis récents.");
}
return _used.TryAdd(challengeId, now + TimeSpan.FromSeconds(_settings.ChallengeLifetimeSeconds + 5));
}
private static byte PurposeCode(string purpose) => purpose switch
{
PlayerChallengePurposes.Register => 1,
PlayerChallengePurposes.Login => 2,
PlayerChallengePurposes.Refresh => 3,
_ => throw new ArgumentException("Usage de défi inconnu.", nameof(purpose))
};
private byte[] Mac(ReadOnlySpan<byte> payload)
{
var data = new byte[Domain.Length + payload.Length];
Domain.CopyTo(data, 0);
payload.CopyTo(data.AsSpan(Domain.Length));
return HMACSHA256.HashData(_secret, data)[..MacLength];
}
// ------------------------------------------------------------------ Créations de compte par adresse IP
/// <summary>Compte une création de compte pour cette adresse. Faux (rien n'est compté) si la limite horaire ou
/// quotidienne est atteinte ; <paramref name="retryAfter"/> : délai avant la prochaine place.</summary>
public bool TryCountRegistration(string partition, out TimeSpan retryAfter) =>
_registrations.TryAdd(partition, _time.GetUtcNow(),
(TimeSpan.FromHours(1), _settings.RegistrationsPerIpPerHour), (TimeSpan.FromDays(1), _settings.RegistrationsPerIpPerDay),
out retryAfter);
// ------------------------------------------------------------------ Échecs de connexion par adresse IP
/// <summary>Vrai si cette adresse a trop échoué récemment (toutes adresses e-mail confondues).</summary>
public bool IsIpThrottled(string partition, out TimeSpan retryAfter) =>
_failedLogins.IsFull(partition, _time.GetUtcNow(), TimeSpan.FromMinutes(15), _settings.FailedLoginsPerIpPer15Minutes, out retryAfter);
public void CountFailedLogin(string partition) =>
_failedLogins.Add(partition, _time.GetUtcNow(), TimeSpan.FromMinutes(15));
// ------------------------------------------------------------------ Adresses inconnues (blocage miroir)
/// <summary>Fin du blocage d'une adresse SANS compte (même règle que pour un compte réel), ou null.</summary>
public DateTimeOffset? UnknownLockedUntil(string emailIndex)
{
lock (_unknown)
return _unknown.TryGetValue(emailIndex, out var state) && state.Until > _time.GetUtcNow() ? state.Until : null;
}
/// <summary>Échec sur une adresse sans compte : compté comme pour un compte (sans suspension, qui n'est jamais
/// révélée avant un mot de passe correct). Renvoie la fin du blocage déclenché, ou null.</summary>
public DateTimeOffset? CountUnknownFailure(string emailIndex)
{
var now = _time.GetUtcNow();
lock (_unknown)
{
if (_unknown.Count >= MaxTracked)
{
foreach (var key in _unknown.Where(p => p.Value.Until is null || p.Value.Until <= now).Select(p => p.Key).ToList())
_unknown.Remove(key);
if (_unknown.Count >= MaxTracked) _unknown.Clear();
}
_unknown.TryGetValue(emailIndex, out var state);
var failed = state.Failed + 1;
var level = state.Level;
DateTimeOffset? until = null;
if (failed >= _settings.FailuresPerLock)
{
failed = 0;
level++;
until = now + Storage.Players.PlayerStore.LockDuration(_settings, level);
}
_unknown[emailIndex] = (failed, level, until ?? state.Until);
return until;
}
}
/// <summary>Compteurs en mémoire sur des fenêtres glissantes (aucune adresse n'est écrite sur le disque).</summary>
private sealed class SlidingCounters
{
private readonly Dictionary<string, List<DateTimeOffset>> _events = new(StringComparer.Ordinal);
public bool TryAdd(string key, DateTimeOffset now, (TimeSpan Window, int Max) first, (TimeSpan Window, int Max) second,
out TimeSpan retryAfter)
{
lock (_events)
{
var times = Get(key, now, second.Window);
retryAfter = TimeSpan.Zero;
foreach (var (window, max) in new[] { first, second })
{
var recent = times.Where(t => now - t < window).OrderBy(t => t).ToList();
if (recent.Count >= max)
{
retryAfter = recent[recent.Count - max] + window - now;
return false;
}
}
times.Add(now);
return true;
}
}
public bool IsFull(string key, DateTimeOffset now, TimeSpan window, int max, out TimeSpan retryAfter)
{
lock (_events)
{
retryAfter = TimeSpan.Zero;
if (!_events.TryGetValue(key, out var times)) return false;
var recent = times.Where(t => now - t < window).OrderBy(t => t).ToList();
if (recent.Count < max) return false;
retryAfter = recent[recent.Count - max] + window - now;
return true;
}
}
public void Add(string key, DateTimeOffset now, TimeSpan window)
{
lock (_events) Get(key, now, window).Add(now);
}
private List<DateTimeOffset> Get(string key, DateTimeOffset now, TimeSpan keep)
{
if (_events.Count >= MaxTracked)
{
foreach (var stale in _events.Where(p => p.Value.All(t => now - t >= keep)).Select(p => p.Key).ToList())
_events.Remove(stale);
if (_events.Count >= MaxTracked) throw new StoreBusyException("Trop d'adresses suivies.");
}
if (!_events.TryGetValue(key, out var times)) _events[key] = times = new List<DateTimeOffset>();
times.RemoveAll(t => now - t >= keep);
return times;
}
}
}
@@ -0,0 +1,145 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Security;
/// <summary>Portée d'un jeton joueur.</summary>
public static class PlayerScopes
{
/// <summary>Session complète (jeton « glpa_ »).</summary>
public const string Full = "full";
/// <summary>Seulement le changement de mot de passe exigé (jeton « glpc_ »).</summary>
public const string PasswordChange = "password_change";
}
/// <summary>Jeton joueur valide (en mémoire uniquement : seule l'empreinte du jeton est gardée).</summary>
public sealed class PlayerAccessSession
{
public required string TokenHash { get; init; }
public required string Scope { get; init; }
public required string AccountId { get; init; }
/// <summary>Appareil de la session (portée complète).</summary>
public string? DeviceId { get; init; }
/// <summary>Clé publique et nom de l'appareil (portée « changement exigé » : l'appareil est enregistré une fois le
/// mot de passe changé).</summary>
public string? DevicePublicKey { get; init; }
public string? DeviceKeyId { get; init; }
public string? DeviceLabel { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>
/// Jetons d'accès des joueurs, en mémoire (un redémarrage du service les efface : le launcher renouvelle sa session avec
/// son jeton de renouvellement, sans rien demander au joueur). Totalement séparés des sessions des administrateurs
/// (<see cref="SessionStore"/>) : autre stockage, autre préfixe, autre schéma d'authentification.
/// </summary>
public sealed class PlayerSessionStore
{
private const int MaxSessions = 200_000;
private static readonly TimeSpan PasswordChangeLifetime = TimeSpan.FromMinutes(10);
private readonly ConcurrentDictionary<string, PlayerAccessSession> _byHash = new(StringComparer.Ordinal);
private readonly TimeProvider _time;
private readonly LauncherApiOptions.PlayersSettings _settings;
public PlayerSessionStore(IOptions<LauncherApiOptions> options, TimeProvider time)
{
_time = time;
_settings = options.Value.Players;
}
public int Count => _byHash.Count;
/// <summary>Jeton d'accès d'une session complète sur un appareil.</summary>
public (string Token, PlayerAccessSession Session) CreateAccess(string accountId, string deviceId)
{
var token = PlayerTokens.AccessPrefix + PlayerTokens.NewSecret();
var session = new PlayerAccessSession
{
TokenHash = Hash(token),
Scope = PlayerScopes.Full,
AccountId = accountId,
DeviceId = deviceId,
ExpiresAt = _time.GetUtcNow() + TimeSpan.FromMinutes(_settings.AccessTokenMinutes)
};
Add(session);
return (token, session);
}
/// <summary>Jeton limité au changement de mot de passe exigé (10 min), qui retient l'appareil à enregistrer ensuite.</summary>
public (string Token, PlayerAccessSession Session) CreatePasswordChange(string accountId, string devicePublicKey, string deviceKeyId,
string deviceLabel)
{
var token = PlayerTokens.PasswordChangePrefix + PlayerTokens.NewSecret();
var session = new PlayerAccessSession
{
TokenHash = Hash(token),
Scope = PlayerScopes.PasswordChange,
AccountId = accountId,
DevicePublicKey = devicePublicKey,
DeviceKeyId = deviceKeyId,
DeviceLabel = deviceLabel,
ExpiresAt = _time.GetUtcNow() + PasswordChangeLifetime
};
Add(session);
return (token, session);
}
/// <summary>Session du jeton, si elle est encore valide.</summary>
public PlayerAccessSession? Find(string? token)
{
if (string.IsNullOrEmpty(token) || token.Length > PlayerTokens.MaxLength) return null;
if (!token.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) &&
!token.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal))
return null;
var hash = Hash(token);
if (!_byHash.TryGetValue(hash, out var session)) return null;
if (session.ExpiresAt <= _time.GetUtcNow())
{
_byHash.TryRemove(hash, out _);
return null;
}
// Le préfixe doit correspondre à la portée (un jeton ne change jamais de portée).
var expectedPrefix = session.Scope == PlayerScopes.Full ? PlayerTokens.AccessPrefix : PlayerTokens.PasswordChangePrefix;
return token.StartsWith(expectedPrefix, StringComparison.Ordinal) ? session : null;
}
public void Remove(PlayerAccessSession session) => _byHash.TryRemove(session.TokenHash, out _);
/// <summary>Ferme toutes les sessions d'un compte (ou d'appareils donnés).</summary>
public int RemoveWhere(Func<PlayerAccessSession, bool> predicate)
{
var removed = 0;
foreach (var (hash, session) in _byHash)
if (predicate(session) && _byHash.TryRemove(hash, out _)) removed++;
return removed;
}
public int RemoveDevices(IEnumerable<string> deviceIds)
{
var set = deviceIds.ToHashSet(StringComparer.Ordinal);
return set.Count == 0 ? 0 : RemoveWhere(s => s.DeviceId is { } id && set.Contains(id));
}
private void Add(PlayerAccessSession session)
{
if (_byHash.Count >= MaxSessions)
{
var now = _time.GetUtcNow();
RemoveWhere(s => s.ExpiresAt <= now);
if (_byHash.Count >= MaxSessions) throw new StoreBusyException("Trop de sessions de joueurs ouvertes.");
}
_byHash[session.TokenHash] = session;
}
internal static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
}
@@ -0,0 +1,226 @@
using System.Text.Json;
using GameLauncher.Contracts.Feedback;
namespace GameLauncher.Server.Storage;
/// <summary>Changement d'un type de demande lors d'une publication des formulaires, pour le journal d'audit.</summary>
/// <param name="Action">Action d'audit (<see cref="FeedbackFormsAudit.Created"/>, <see cref="FeedbackFormsAudit.Updated"/>…).</param>
/// <param name="Name">Nom du type tel qu'affiché aux joueurs (après publication, ou avant suppression).</param>
/// <param name="Details">Détail de chaque changement, en clair.</param>
public sealed record FeedbackFormChange(string Action, string Name, IReadOnlyList<string> Details);
/// <summary>
/// Détail d'une publication des formulaires pour le journal d'audit : une ligne « feedback.forms » (révision, résumé,
/// signature), puis une ligne par type de demande ajouté, modifié ou supprimé, avec son nom et TOUS ses changements
/// (présentation, réglages, chaque champ ajouté, supprimé ou modifié, et ce qui a changé dans ce champ).
/// Un champ d'audit étant borné (<see cref="AuditLog"/>), un détail trop long est réparti sur plusieurs lignes
/// numérotées (1/2, 2/2…) plutôt que tronqué.
/// </summary>
public static class FeedbackFormsAudit
{
public const string Published = "feedback.forms";
public const string Created = "feedback.form_create";
public const string Updated = "feedback.form_update";
public const string Deleted = "feedback.form_delete";
/// <summary>Longueur au plus d'un détail d'audit (le journal tronque à 500 caractères).</summary>
internal const int MaxDetailsLength = 480;
/// <summary>Valeur montrée en entier dans un changement (« « avant » → « après » ») jusqu'à cette longueur.</summary>
private const int MaxShownValueLength = 80;
private const string Separator = " ; ";
/// <summary>Types ajoutés (dans l'ordre publié), modifiés, puis supprimés.</summary>
public static IReadOnlyList<FeedbackFormChange> Changes(FeedbackFormsDocument before, FeedbackFormsDocument after)
{
var changes = new List<FeedbackFormChange>();
foreach (var form in after.Forms)
{
var old = before.Find(form.Kind);
if (old is null) changes.Add(new FeedbackFormChange(Created, form.Label, CreatedDetails(form)));
else if (FormDetails(old, form) is { Count: > 0 } details) changes.Add(new FeedbackFormChange(Updated, form.Label, details));
}
foreach (var form in before.Forms.Where(f => after.Find(f.Kind) is null))
changes.Add(new FeedbackFormChange(Deleted, form.Label,
new[] { "plus proposé aux joueurs (les demandes déjà reçues de ce type restent consultables)" }));
return changes;
}
/// <summary>Résumé de la ligne « feedback.forms » : types ajoutés, modifiés, supprimés (par leur nom), ordre des types.</summary>
public static string Summary(FeedbackFormsDocument before, FeedbackFormsDocument after, IReadOnlyList<FeedbackFormChange> changes)
{
var parts = new List<string>();
AddGroup(parts, changes, Created, "type ajouté", "types ajoutés");
AddGroup(parts, changes, Updated, "type modifié", "types modifiés");
AddGroup(parts, changes, Deleted, "type supprimé", "types supprimés");
var kept = after.Forms.Select(f => f.Kind).Where(k => before.Find(k) is not null).ToList();
if (!before.Forms.Select(f => f.Kind).Where(kept.Contains).SequenceEqual(kept)) parts.Add("ordre des types modifié");
return parts.Count == 0 ? "aucun changement" : string.Join(Separator, parts);
}
private static void AddGroup(List<string> parts, IReadOnlyList<FeedbackFormChange> changes, string action, string one, string many)
{
var names = changes.Where(c => c.Action == action).Select(c => Quote(c.Name)).ToList();
if (names.Count > 0) parts.Add($"{names.Count} {(names.Count == 1 ? one : many)} : {string.Join(", ", names)}");
}
/// <summary>
/// Détails d'un changement répartis en lignes d'audit d'au plus <paramref name="maxLength"/> caractères, numérotées
/// s'il en faut plusieurs (aucun détail n'est perdu, un détail démesuré est coupé entre deux lignes).
/// </summary>
public static IReadOnlyList<string> Lines(IReadOnlyList<string> details, int maxLength = MaxDetailsLength)
{
// Place réservée au numéro « (12/12) ».
var room = Math.Max(20, maxLength - 8);
var lines = new List<string>();
var current = "";
foreach (var detail in details.SelectMany(d => Split(d, room)))
{
if (current.Length == 0) current = detail;
else if (current.Length + Separator.Length + detail.Length <= room) current += Separator + detail;
else
{
lines.Add(current);
current = detail;
}
}
if (current.Length > 0) lines.Add(current);
return lines.Count <= 1 ? lines : lines.Select((line, i) => $"({i + 1}/{lines.Count}) {line}").ToList();
}
private static IEnumerable<string> Split(string text, int room)
{
for (var i = 0; i < text.Length; i += room)
yield return text.Substring(i, Math.Min(room, text.Length - i));
}
// ------------------------------------------------------------------ Types de demande
private static List<string> CreatedDetails(FeedbackForm form)
{
var details = new List<string>();
if (form.Hidden) details.Add("masqué (pas encore proposé aux joueurs)");
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »" : "icône importée");
if (!form.AllowAttachments) details.Add("captures d'écran non proposées");
if (form.TechByDefault) details.Add("« Joindre les informations techniques » coché par défaut");
details.Add($"{form.Fields.Count} champ(s)");
details.AddRange(form.Fields.Select(f => $"champ « {f.Label} » ({FieldSummary(f)})"));
return details;
}
private static List<string> FormDetails(FeedbackForm old, FeedbackForm form)
{
var details = new List<string>();
if (old.Label != form.Label) details.Add($"nom affiché : {Quote(old.Label)} → {Quote(form.Label)}");
if (old.Hidden != form.Hidden) details.Add(form.Hidden ? "masqué (plus proposé aux joueurs)" : "de nouveau proposé aux joueurs");
if (old.Description != form.Description) details.Add(ValueChange("description", feminine: true, old.Description, form.Description));
if (old.TitleHint != form.TitleHint) details.Add(ValueChange("exemple de titre", feminine: false, old.TitleHint, form.TitleHint));
if (old.TechByDefault != form.TechByDefault)
details.Add($"« Joindre les informations techniques » {(form.TechByDefault ? "coché" : "décoché")} par défaut");
if (old.CustomIcon != form.CustomIcon)
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »"
: old.CustomIcon is null ? "icône importée" : "icône importée remplacée");
else if (form.CustomIcon is null && old.IconKey != form.IconKey)
details.Add($"icône : « {old.IconKey} » → « {form.IconKey} »");
if (old.AllowAttachments != form.AllowAttachments)
details.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
foreach (var field in old.Fields.Where(f => !newIds.Contains(f.Id)))
details.Add($"champ supprimé : « {field.Label} »");
foreach (var field in form.Fields)
{
if (old.Field(field.Id) is not { } before)
{
details.Add($"champ ajouté : « {field.Label} » ({FieldSummary(field)})");
continue;
}
var parts = FieldDetails(before, field);
if (parts.Count == 0) continue;
var prefix = $"champ « {field.Label} » modifié : ";
var combined = prefix + string.Join(", ", parts);
// Champ très modifié (choix nombreux) : un détail par changement, chacun avec le nom du champ.
if (combined.Length <= MaxDetailsLength / 2) details.Add(combined);
else details.AddRange(parts.Select(p => prefix + p));
}
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
details.Add("ordre des champs modifié");
return details;
}
// ------------------------------------------------------------------ Champs
private static List<string> FieldDetails(FeedbackField old, FeedbackField field)
{
var parts = new List<string>();
if (old.Label != field.Label) parts.Add($"libellé {Quote(old.Label)} → {Quote(field.Label)}");
if (old.Kind != field.Kind) parts.Add($"type {KindText(old.Kind)} → {KindText(field.Kind)}");
if (old.Required != field.Required) parts.Add(field.Required ? "rendu obligatoire" : "rendu facultatif");
if ((old.Hint ?? "") != (field.Hint ?? ""))
parts.Add(string.IsNullOrEmpty(old.Hint) ? "aide ajoutée" : string.IsNullOrEmpty(field.Hint) ? "aide retirée" : "aide modifiée");
if (field.IsText && old.MaxLength != field.MaxLength) parts.Add($"longueur maximale {old.MaxLength} → {field.MaxLength}");
if (field.Kind == FeedbackFieldKind.Rating && old.RatingMax != field.RatingMax)
parts.Add($"étoiles {old.RatingMax} → {field.RatingMax}");
if (old.ShowInList != field.ShowInList)
parts.Add(field.ShowInList ? "affiché en aperçu dans la liste des demandes" : "retiré de l'aperçu de la liste des demandes");
parts.AddRange(OptionDetails(old.Choices, field.Choices));
// Filet : tout autre écart (nouveau réglage d'une version future) reste signalé.
if (parts.Count == 0 && JsonSerializer.Serialize(old) != JsonSerializer.Serialize(field)) parts.Add("réglages modifiés");
return parts;
}
private static IEnumerable<string> OptionDetails(IReadOnlyList<FeedbackOption> old, IReadOnlyList<FeedbackOption> now)
{
var oldIds = old.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
var newIds = now.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
foreach (var option in now.Where(o => !oldIds.Contains(o.Id))) yield return $"choix ajouté « {option.Label} »";
foreach (var option in old.Where(o => !newIds.Contains(o.Id))) yield return $"choix retiré « {option.Label} »";
foreach (var option in now)
if (old.FirstOrDefault(o => o.Id == option.Id) is { } before && before.Label != option.Label)
yield return $"choix renommé « {before.Label} » → « {option.Label} »";
if (!old.Select(o => o.Id).Where(newIds.Contains).SequenceEqual(now.Select(o => o.Id).Where(oldIds.Contains)))
yield return "ordre des choix modifié";
}
private static string FieldSummary(FeedbackField field)
{
var parts = new List<string> { KindText(field.Kind), field.Required ? "obligatoire" : "facultatif" };
if (field.Kind == FeedbackFieldKind.Rating) parts.Add($"{field.RatingMax} étoiles");
if (field.Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes) parts.Add($"{field.Choices.Count} choix");
if (field.ShowInList) parts.Add("en aperçu dans la liste");
return string.Join(", ", parts);
}
private static string KindText(FeedbackFieldKind kind) => kind switch
{
FeedbackFieldKind.Multiline => "zone de texte",
FeedbackFieldKind.Text => "texte court",
FeedbackFieldKind.Choice => "liste de choix",
FeedbackFieldKind.Checkboxes => "cases à cocher",
FeedbackFieldKind.Rating => "note en étoiles",
FeedbackFieldKind.YesNo => "case oui/non",
_ => kind.ToString()
};
// ------------------------------------------------------------------ Texte
/// <summary>« nom : « avant » → « après » », ou « nom modifié » si l'une des valeurs est longue.</summary>
private static string ValueChange(string name, bool feminine, string? before, string? after)
{
var e = feminine ? "e" : "";
before = OneLine(before);
after = OneLine(after);
if (before.Length == 0) return $"{name} ajouté{e} : {Quote(after)}";
if (after.Length == 0) return $"{name} retiré{e}";
return before.Length <= MaxShownValueLength && after.Length <= MaxShownValueLength
? $"{name} : {Quote(before)} → {Quote(after)}"
: $"{name} modifié{e}";
}
private static string OneLine(string? value) =>
string.Join(' ', (value ?? "").Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
private static string Quote(string? value) => $"« {OneLine(value)} »";
}
@@ -1,5 +1,4 @@
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Server.Hosting;
@@ -143,47 +142,4 @@ public sealed class FeedbackFormsStore
// L'historique est une aide : son échec n'annule pas la publication.
}
}
/// <summary>
/// Résumé des changements pour l'audit (types ajoutés, supprimés, réordonnés, masqués ou réaffichés ; champs ajoutés,
/// retirés, modifiés ; captures d'écran, icône).
/// </summary>
public static string Describe(FeedbackFormsDocument before, FeedbackFormsDocument after)
{
var changes = new List<string>();
var added = after.Forms.Where(f => before.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
var removed = before.Forms.Where(f => after.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
if (added.Count > 0) changes.Add($"type(s) ajouté(s) : {string.Join(", ", added)}");
if (removed.Count > 0) changes.Add($"type(s) supprimé(s) : {string.Join(", ", removed)}");
var kept = after.Forms.Select(f => f.Kind).Where(k => before.Find(k) is not null).ToList();
if (!before.Forms.Select(f => f.Kind).Where(kept.Contains).SequenceEqual(kept)) changes.Add("ordre des types modifié");
foreach (var form in after.Forms)
{
var old = before.Find(form.Kind);
if (old is null) continue;
var parts = new List<string>();
if (old.Hidden != form.Hidden) parts.Add(form.Hidden ? "masqué" : "de nouveau proposé");
if (old.Label != form.Label || old.Description != form.Description || old.TitleHint != form.TitleHint ||
old.TechByDefault != form.TechByDefault)
parts.Add("présentation modifiée");
if (old.IconKey != form.IconKey || old.CustomIcon != form.CustomIcon)
parts.Add(form.CustomIcon is null ? "icône d'origine" : "icône importée");
if (old.AllowAttachments != form.AllowAttachments)
parts.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
var addedFields = newIds.Count(id => !oldIds.Contains(id));
var removedFields = oldIds.Count(id => !newIds.Contains(id));
var changed = form.Fields.Count(f => old.Field(f.Id) is { } o &&
JsonSerializer.Serialize(o) != JsonSerializer.Serialize(f));
if (addedFields > 0) parts.Add($"+{addedFields} champ(s)");
if (removedFields > 0) parts.Add($"-{removedFields} champ(s)");
if (changed > 0) parts.Add($"{changed} champ(s) modifié(s)");
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
parts.Add("ordre modifié");
var name = old.Label == form.Label ? old.Label : $"{old.Label} → « {form.Label} »";
if (parts.Count > 0) changes.Add($"{name} : {string.Join(", ", parts)}");
}
return changes.Count == 0 ? "aucun changement" : string.Join(" ; ", changes);
}
}
@@ -0,0 +1,133 @@
using System.Text;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Mode maintenance du launcher (commun à tous les jeux), activé ou désactivé depuis le menu d'administration.
/// • Référence : maintenance.json dans les données privées (+ historique des 20 dernières révisions) ;
/// • Publication : copie dans le fichier PUBLIC servi par nginx, à côté du trousseau (les launchers en déduisent
/// l'adresse), écrite atomiquement, lisible par tous ;
/// • Toujours signé (autorisation « maintenance » du trousseau) : le serveur vérifie que la signature est celle de la
/// session et que le document respecte les règles, le launcher vérifie la signature avec le trousseau.
/// Rien de publié : maintenance inactive (révision 0).
/// SQLite n'est pas utilisé : un seul petit document, modifié rarement, à publier de toute façon en fichier.
/// </summary>
public sealed class MaintenanceStore
{
public const string FileName = "maintenance.json";
private const string HistoryFolder = "maintenance-history";
private const int HistoryLength = 20;
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
private readonly string _path;
private readonly string _lockPath;
private readonly string _historyDirectory;
private readonly string? _publicFile;
private readonly object _lock = new();
private MaintenanceDocument? _current;
public MaintenanceStore(IOptions<LauncherApiOptions> options)
: this(options.Value.DataDirectoryFullPath, options.Value.MaintenancePublicFileFullPath, options.Value.MaintenancePublicUrl) { }
public MaintenanceStore(string dataDirectory, string? publicFile, string? publicUrl = null)
{
SecureFiles.EnsureDirectory(dataDirectory);
_path = Path.Combine(dataDirectory, FileName);
_lockPath = _path + ".lock";
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
_publicFile = publicFile;
PublicUrl = publicUrl;
}
public bool PublishingEnabled => _publicFile is not null;
public string? PublicUrl { get; }
/// <summary>État en vigueur : celui publié, sinon maintenance inactive (révision 0).</summary>
/// <exception cref="InvalidDataException">Fichier de référence illisible.</exception>
public MaintenanceDocument Current
{
get
{
lock (_lock) return _current ??= Read() ?? MaintenanceRules.Defaults();
}
}
private MaintenanceDocument? Read()
{
if (!File.Exists(_path)) return null;
try
{
return MaintenanceRules.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
}
catch (System.Text.Json.JsonException ex)
{
throw new InvalidDataException($"{FileName} illisible : {ex.Message}", ex);
}
}
/// <summary>
/// Publie un document signé (signature déjà contrôlée par l'appelant) : règles complètes, révision attendue (409 si
/// une autre publication a eu lieu), révision suivante obligatoire. Pendant une maintenance, le début annoncé ne
/// change pas (modification du message seulement).
/// </summary>
/// <returns>Document publié (avec la date) et celui d'avant.</returns>
public (MaintenanceDocument Published, MaintenanceDocument Previous) Publish(MaintenanceDocument document, long baseRevision, DateTimeOffset now)
{
if (_publicFile is null)
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
"Publication du mode maintenance non configurée : le fichier public du trousseau (LauncherApi:Keyring:PublicFile, ou " +
"LauncherApi:Catalog:PublicFile) est nécessaire, le mode maintenance est publié à côté.");
if (document.Signature is null || string.IsNullOrEmpty(document.Signature.Value))
throw new ApiRejection(400, ApiErrorCodes.SignatureRequired, "Mode maintenance non signé.");
var errors = new List<string>();
if (!MaintenanceRules.Validate(document, errors, now))
throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
lock (_lock)
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
{
var previous = Read() ?? MaintenanceRules.Defaults();
if (baseRevision != previous.Revision)
throw ApiRejection.Conflict(
$"Le mode maintenance a été modifié entre-temps (révision {previous.Revision}) : rechargez la page, puis recommencez.",
ApiErrorCodes.RevisionConflict);
if (document.Revision != previous.Revision + 1)
throw ApiRejection.BadRequest($"Révision invalide : {previous.Revision + 1} attendue.");
if (document.Enabled && previous.Enabled && document.Since != previous.Since)
throw ApiRejection.BadRequest("Maintenance déjà en cours : sa date de début ne change pas (seul le message peut être modifié).");
var published = document with { UpdatedAt = DateTimeOffset.FromUnixTimeSeconds(now.ToUnixTimeSeconds()) };
var json = MaintenanceRules.Serialize(published);
if (Encoding.UTF8.GetByteCount(json) > MaintenanceRules.MaxBytes)
throw ApiRejection.BadRequest($"Mode maintenance trop volumineux (au plus {MaintenanceRules.MaxBytes / 1024} Ko).");
// Public d'abord : en cas d'échec, la référence privée (qui porte la révision) reste l'ancienne.
PublicFileWriter.Write(_publicFile, json, "fichier public du mode maintenance");
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
WriteHistory(published.Revision, json);
_current = published;
return (published, previous);
}
}
private void WriteHistory(long revision, string json)
{
try
{
SecureFiles.EnsureDirectory(_historyDirectory);
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"maintenance-{revision:D6}.json"), json, keepBackup: false);
foreach (var old in Directory.GetFiles(_historyDirectory, "maintenance-*.json").OrderByDescending(f => f, StringComparer.Ordinal)
.Skip(HistoryLength))
File.Delete(old);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// L'historique est une aide : son échec n'annule pas la publication.
}
}
}
@@ -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,104 @@
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; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastLoginAt { get; init; }
public required string TermsVersion { get; init; }
/// <summary>Échecs consécutifs depuis des appareils inconnus (série en cours), et nombre de blocages successifs.</summary>
public int FailedCount { get; init; }
public int LockLevel { get; init; }
public DateTimeOffset? LockedUntil { get; init; }
/// <summary>Échecs consécutifs depuis des appareils de confiance, et leur blocage temporaire.</summary>
public int TrustedFailedCount { get; init; }
public DateTimeOffset? TrustedLockedUntil { get; init; }
}
/// <summary>Appareil connecté (clé publique de l'appareil et session mémorisée).</summary>
public sealed record PlayerDeviceRecord
{
public required string Id { get; init; }
public required string AccountId { get; init; }
public required string KeyId { get; init; }
/// <summary>Clé publique de l'appareil (SPKI P-256, Base64).</summary>
public required string PublicKey { get; init; }
public required string Label { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset LastUsedAt { get; init; }
/// <summary>SHA-256 du jeton de renouvellement en vigueur, et du précédent (détection d'une réutilisation).</summary>
public required string RefreshHash { get; init; }
public string? PreviousRefreshHash { get; init; }
public DateTimeOffset RefreshExpiresAt { get; init; }
public DateTimeOffset RefreshAbsoluteExpiresAt { get; init; }
}
/// <summary>Ligne du journal des comptes.</summary>
public sealed record PlayerEventRecord(long Id, string? AccountId, DateTimeOffset At, string Type, string Result, string? Ip, string? DeviceKeyId,
string? Details);
/// <summary>Résultat d'un échec de connexion pour un compte existant.</summary>
public sealed record PlayerFailureOutcome(DateTimeOffset? LockedUntil, bool Suspended, int LockLevel);
/// <summary>Types d'événements du journal des comptes (actions des joueurs et du système).</summary>
public static class PlayerEvents
{
public const string Register = "account.register";
public const string Login = "account.login";
public const string LoginFailed = "account.login_failed";
public const string Locked = "account.locked";
public const string Suspended = "account.suspended";
public const string Unlocked = "account.unlocked";
public const string Disabled = "account.disabled";
public const string Enabled = "account.enabled";
public const string Logout = "account.logout";
public const string LogoutAll = "account.logout_all";
public const string PasswordChange = "account.password_change";
public const string PasswordChangeForced = "account.password_change_required";
public const string Refresh = "session.refresh";
public const string RefreshReuse = "session.reuse";
public const string DeviceRemoved = "device.removed";
public const string Export = "account.export";
public const string Delete = "account.delete";
public const string Inactive = "account.inactive";
public const string Reactivated = "account.reactivated";
}
@@ -0,0 +1,134 @@
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
namespace GameLauncher.Server.Storage.Players;
/// <summary>
/// Clés des comptes joueurs, toutes dérivées (HKDF-SHA256) d'une clé maîtresse de 256 bits gardée HORS du dossier des
/// données (credential systemd chiffré, déchiffré par systemd au démarrage dans un dossier en mémoire visible du seul
/// service). Une copie de /var/lib/launcher-api ou de ses sauvegardes ne permet donc ni de lire les adresses e-mail, ni
/// de tester des mots de passe hors ligne (le « poivre » des empreintes en dérive aussi).
///
/// Une sous-clé par usage : chiffrement des adresses, index aveugle (recherche par adresse sans la stocker en clair),
/// poivre des mots de passe, et pour les étapes suivantes : secrets OTP, notes des administrateurs, messages.
/// </summary>
public sealed class PlayerSecrets
{
/// <summary>Version de la clé maîtresse, écrite devant chaque valeur chiffrée (rotation future).</summary>
public const int KeyVersion = 1;
private const int KeyLength = 32;
private const int NonceLength = 12;
private const int TagLength = 16;
private static readonly byte[] HkdfSalt = Encoding.ASCII.GetBytes("GameLauncher/Player/Keys/v1");
private readonly byte[] _emailKey;
private readonly byte[] _indexKey;
private readonly byte[] _pepper;
private readonly byte[] _fieldKey;
public PlayerSecrets(ReadOnlySpan<byte> masterKey)
{
if (masterKey.Length != KeyLength) throw new CryptographicException("La clé maîtresse des comptes joueurs doit faire 32 octets.");
if (masterKey.IndexOfAnyExcept((byte)0) < 0) throw new CryptographicException("Clé maîtresse vide (que des zéros).");
_emailKey = Derive(masterKey, "email-encryption/v1");
_indexKey = Derive(masterKey, "email-index/v1");
_pepper = Derive(masterKey, "password-pepper/v1");
_fieldKey = Derive(masterKey, "field-encryption/v1");
}
/// <summary>
/// Lit la clé maîtresse (Base64 de 32 octets). Sous Linux, le fichier ne doit être ni lisible ni modifiable par les
/// autres comptes, ni modifiable par le groupe (un credential systemd n'est lisible que par le service).
/// </summary>
public static PlayerSecrets Load(string path)
{
if (!File.Exists(path)) throw new InvalidOperationException($"Clé maîtresse des comptes joueurs introuvable : {path}");
if (!OperatingSystem.IsWindows())
{
var mode = File.GetUnixFileMode(path);
const UnixFileMode forbidden = UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute;
if ((mode & forbidden) != 0)
throw new InvalidOperationException(
$"Clé maîtresse des comptes joueurs accessible à d'autres comptes ({path}) : droits 0400 ou 0600 attendus.");
}
var text = File.ReadAllText(path).Trim();
byte[] key;
try
{
key = Convert.FromBase64String(text);
}
catch (FormatException)
{
throw new InvalidOperationException("Clé maîtresse des comptes joueurs illisible : 32 octets en Base64 attendus (commande players-key).");
}
try
{
return new PlayerSecrets(key);
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
}
/// <summary>Nouvelle clé maîtresse (commande « players-key »), en Base64.</summary>
public static string NewMasterKey() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(KeyLength));
/// <summary>Poivre des empreintes de mots de passe (« clé secrète » d'Argon2id).</summary>
public byte[] PasswordPepper => _pepper;
/// <summary>
/// Index aveugle d'une adresse NORMALISÉE : HMAC-SHA256 avec une clé secrète. Permet de retrouver un compte et
/// d'imposer l'unicité des adresses sans conserver l'adresse en clair ; sans la clé, impossible de tester une adresse.
/// </summary>
public string EmailIndex(string normalizedEmail) =>
Convert.ToHexString(HMACSHA256.HashData(_indexKey, Encoding.UTF8.GetBytes("GameLauncher/Player/Email/v1\n" + normalizedEmail)))
.ToLowerInvariant();
/// <summary>Chiffre l'adresse d'un compte (liée à ce compte : une valeur copiée sur un autre compte ne se déchiffre pas).</summary>
public string EncryptEmail(string accountId, string normalizedEmail) =>
Encrypt(_emailKey, Encoding.UTF8.GetBytes(normalizedEmail), Associated("email", accountId));
public string DecryptEmail(string accountId, string value) =>
Encoding.UTF8.GetString(Decrypt(_emailKey, value, Associated("email", accountId)));
/// <summary>Chiffre un champ libre d'un compte (motifs, notes…), lié au compte et au nom du champ.</summary>
public string EncryptField(string accountId, string field, string text) =>
Encrypt(_fieldKey, Encoding.UTF8.GetBytes(text), Associated(field, accountId));
public string DecryptField(string accountId, string field, string value) =>
Encoding.UTF8.GetString(Decrypt(_fieldKey, value, Associated(field, accountId)));
private static byte[] Associated(string field, string accountId) =>
Encoding.UTF8.GetBytes($"GameLauncher/Player/Field/v1\n{field}\n{accountId}");
private static byte[] Derive(ReadOnlySpan<byte> masterKey, string purpose) =>
HKDF.DeriveKey(HashAlgorithmName.SHA256, masterKey.ToArray(), KeyLength, HkdfSalt, Encoding.ASCII.GetBytes(purpose));
/// <summary>Format : « 1. » (version de la clé) + Base64Url(nonce ‖ chiffré ‖ étiquette), AES-256-GCM.</summary>
private static string Encrypt(byte[] key, byte[] plaintext, byte[] associated)
{
var buffer = new byte[NonceLength + plaintext.Length + TagLength];
var nonce = buffer.AsSpan(0, NonceLength);
RandomNumberGenerator.Fill(nonce);
using var aes = new AesGcm(key, TagLength);
aes.Encrypt(nonce, plaintext, buffer.AsSpan(NonceLength, plaintext.Length), buffer.AsSpan(NonceLength + plaintext.Length), associated);
return $"{KeyVersion}.{Base64Url.Encode(buffer)}";
}
private static byte[] Decrypt(byte[] key, string value, byte[] associated)
{
var dot = value.IndexOf('.');
if (dot <= 0 || value[..dot] != KeyVersion.ToString(System.Globalization.CultureInfo.InvariantCulture) ||
!Base64Url.TryDecode(value[(dot + 1)..], out var buffer) || buffer.Length < NonceLength + TagLength)
throw new CryptographicException("Valeur chiffrée illisible ou d'une autre version de clé.");
var length = buffer.Length - NonceLength - TagLength;
var plaintext = new byte[length];
using var aes = new AesGcm(key, TagLength);
aes.Decrypt(buffer.AsSpan(0, NonceLength), buffer.AsSpan(NonceLength, length), buffer.AsSpan(NonceLength + length), plaintext, associated);
return plaintext;
}
}
@@ -0,0 +1,561 @@
using System.Globalization;
using GameLauncher.Server.Hosting;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage.Players;
/// <summary>
/// Base des comptes joueurs : SQLite (players/players.db dans les données privées, dossier 0700, fichier 0600).
///
/// Pourquoi SQLite ici (et pas des fichiers JSON comme les autres modules) : relations (compte ⇄ appareils ⇄ journal, et
/// bientôt conversations, blocages), unicité stricte des adresses sous transaction, volumes non bornés, recherches par
/// index. Réglages : journal WAL, clés étrangères, effacement sécurisé (secure_delete : une ligne supprimée est écrasée
/// sur le disque), écriture synchrone complète. Requêtes PARAMÉTRÉES uniquement.
///
/// Une seule connexion, protégée par un verrou : SQLite n'accepte qu'un écrivain à la fois, les requêtes sont courtes,
/// et les calculs coûteux (empreintes de mots de passe) sont faits AVANT d'entrer ici. L'outil en ligne de commande peut
/// ouvrir la base en même temps (attente bornée).
///
/// Le contenu sensible est chiffré AVANT d'arriver ici (adresse e-mail : <see cref="PlayerSecrets"/>) : cette classe ne
/// connaît pas la clé maîtresse.
/// </summary>
public sealed class PlayerStore : IDisposable
{
public const string FolderName = "players";
public const string FileName = "players.db";
/// <summary>Version du schéma (PRAGMA user_version).</summary>
public const int SchemaVersion = 1;
private readonly object _lock = new();
private readonly SqliteConnection _db;
private readonly TimeProvider _time;
public PlayerStore(IOptions<LauncherApiOptions> options, TimeProvider time) : this(options.Value.DataDirectoryFullPath, time) { }
public PlayerStore(string dataDirectory, TimeProvider time)
{
_time = time;
SecureFiles.EnsureDirectory(dataDirectory);
var folder = Path.Combine(dataDirectory, FolderName);
SecureFiles.EnsureDirectory(folder);
DatabasePath = Path.Combine(folder, FileName);
// Fichier créé en 0600 AVANT que SQLite ne l'ouvre (les fichiers -wal et -shm reprennent ses droits).
if (!File.Exists(DatabasePath)) SecureFiles.TryCreateNew(DatabasePath, "");
_db = new SqliteConnection(new SqliteConnectionStringBuilder
{
DataSource = DatabasePath,
Mode = SqliteOpenMode.ReadWriteCreate,
Pooling = false
}.ToString());
_db.Open();
try
{
Execute(null, "PRAGMA busy_timeout = 5000;");
Scalar(null, "PRAGMA journal_mode = WAL;");
Execute(null, "PRAGMA foreign_keys = ON; PRAGMA secure_delete = ON; PRAGMA synchronous = FULL;");
Migrate();
}
catch
{
_db.Dispose();
throw;
}
}
public string DatabasePath { get; }
public void Dispose()
{
lock (_lock) _db.Dispose();
}
// ------------------------------------------------------------------ Schéma
private void Migrate()
{
var version = Convert.ToInt64(Scalar(null, "PRAGMA user_version;"), CultureInfo.InvariantCulture);
if (version > SchemaVersion)
throw new InvalidOperationException(
$"Base des comptes joueurs en version {version}, plus récente que ce serveur ({SchemaVersion}) : mettez le serveur à jour.");
if (version == SchemaVersion) return;
using var tx = _db.BeginTransaction();
if (version < 1)
{
Execute(tx, """
CREATE TABLE accounts (
id TEXT PRIMARY KEY,
reference TEXT NOT NULL UNIQUE,
email_index TEXT NOT NULL UNIQUE,
email_enc TEXT NOT NULL,
email_verified INTEGER NOT NULL DEFAULT 0,
password_hash TEXT NOT NULL,
password_changed_at INTEGER NOT NULL,
must_change_password INTEGER NOT NULL DEFAULT 0,
totp_secret_enc TEXT,
totp_last_step INTEGER,
status TEXT NOT NULL,
disabled_until INTEGER,
status_reason_enc TEXT,
status_changed_at INTEGER,
status_changed_by TEXT,
created_at INTEGER NOT NULL,
last_login_at INTEGER,
terms_version TEXT NOT NULL,
failed_count INTEGER NOT NULL DEFAULT 0,
lock_level INTEGER NOT NULL DEFAULT 0,
locked_until INTEGER,
trusted_failed_count INTEGER NOT NULL DEFAULT 0,
trusted_locked_until INTEGER
);
CREATE INDEX ix_accounts_created ON accounts(created_at);
CREATE INDEX ix_accounts_status ON accounts(status);
CREATE TABLE devices (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
key_id TEXT NOT NULL,
public_key TEXT NOT NULL,
label TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL,
refresh_hash TEXT NOT NULL,
previous_refresh_hash TEXT,
refresh_expires_at INTEGER NOT NULL,
refresh_absolute_expires_at INTEGER NOT NULL,
UNIQUE (account_id, key_id)
);
CREATE INDEX ix_devices_account ON devices(account_id);
CREATE TABLE account_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT REFERENCES accounts(id) ON DELETE CASCADE,
at INTEGER NOT NULL,
type TEXT NOT NULL,
result TEXT NOT NULL,
ip TEXT,
device_key TEXT,
details TEXT
);
CREATE INDEX ix_events_account ON account_events(account_id, at);
CREATE INDEX ix_events_at ON account_events(at);
""");
}
Execute(tx, $"PRAGMA user_version = {SchemaVersion};");
tx.Commit();
}
// ------------------------------------------------------------------ Comptes
private const string AccountColumns =
"id, reference, email_index, email_enc, email_verified, password_hash, password_changed_at, must_change_password, " +
"totp_secret_enc IS NOT NULL, status, disabled_until, status_changed_at, created_at, last_login_at, terms_version, " +
"failed_count, lock_level, locked_until, trusted_failed_count, trusted_locked_until";
public PlayerAccountRecord? FindByEmailIndex(string emailIndex) => Locked(() =>
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE email_index = $v;", ReadAccount, ("$v", emailIndex)));
public PlayerAccountRecord? FindById(string accountId) => Locked(() =>
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE id = $v;", ReadAccount, ("$v", accountId)));
public PlayerAccountRecord? FindByReference(string reference) => Locked(() =>
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE reference = $v;", ReadAccount, ("$v", reference)));
public int CountAccounts() => Locked(() => Convert.ToInt32(Scalar(null, "SELECT COUNT(*) FROM accounts;"), CultureInfo.InvariantCulture));
public int CountCreatedSince(DateTimeOffset since) => Locked(() =>
Convert.ToInt32(Scalar(null, "SELECT COUNT(*) FROM accounts WHERE created_at >= $t;", ("$t", since.ToUnixTimeSeconds())),
CultureInfo.InvariantCulture));
/// <summary>
/// Crée le compte et son premier appareil, sous transaction. Faux si l'adresse (index) est déjà utilisée. La référence
/// est retirée au sort de nouveau en cas de collision (40 bits : rare).
/// </summary>
public bool TryCreate(PlayerAccountRecord account, PlayerDeviceRecord device, Func<string> newReference, out PlayerAccountRecord created)
{
created = account;
lock (_lock)
{
using var tx = _db.BeginTransaction();
if (Scalar(tx, "SELECT 1 FROM accounts WHERE email_index = $v;", ("$v", account.EmailIndex)) is not null) return false;
var reference = account.Reference;
for (var attempt = 0; Scalar(tx, "SELECT 1 FROM accounts WHERE reference = $v;", ("$v", reference)) is not null; attempt++)
{
if (attempt > 20) throw new InvalidOperationException("Impossible de tirer une référence de compte libre.");
reference = newReference();
}
created = account with { Reference = reference };
Execute(tx, """
INSERT INTO accounts (id, reference, email_index, email_enc, email_verified, password_hash, password_changed_at,
must_change_password, status, created_at, last_login_at, terms_version)
VALUES ($id, $ref, $idx, $enc, 0, $hash, $pwd, 0, $status, $created, $login, $terms);
""",
("$id", created.Id), ("$ref", created.Reference), ("$idx", created.EmailIndex), ("$enc", created.EmailEncrypted),
("$hash", created.PasswordHash), ("$pwd", created.PasswordChangedAt.ToUnixTimeSeconds()), ("$status", PlayerStatus.Active),
("$created", created.CreatedAt.ToUnixTimeSeconds()), ("$login", Db(created.LastLoginAt)), ("$terms", created.TermsVersion));
InsertDevice(tx, device);
tx.Commit();
return true;
}
}
/// <summary>Connexion réussie : compteurs d'échecs remis à zéro, date de connexion, empreinte éventuellement remplacée.</summary>
public void RecordSuccess(string accountId, DateTimeOffset now, string? newPasswordHash) => Locked(() =>
{
Execute(null, """
UPDATE accounts SET failed_count = 0, lock_level = 0, locked_until = NULL, trusted_failed_count = 0,
trusted_locked_until = NULL, last_login_at = $now,
password_hash = COALESCE($hash, password_hash)
WHERE id = $id;
""", ("$now", now.ToUnixTimeSeconds()), ("$hash", (object?)newPasswordHash ?? DBNull.Value), ("$id", accountId));
return true;
});
/// <summary>
/// Échec de connexion (mot de passe faux) sur un compte existant, sous transaction. Appareil inconnu : blocage tous les
/// <paramref name="failuresPerLock"/> échecs (durée doublée à chaque fois), suspension au blocage n°
/// <paramref name="suspendAfterLocks"/>. Appareil de confiance : blocage temporaire seulement.
/// </summary>
public PlayerFailureOutcome RecordFailure(string accountId, bool trusted, DateTimeOffset now, LauncherApiOptions.PlayersSettings settings)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
var account = QuerySingle(tx, $"SELECT {AccountColumns} FROM accounts WHERE id = $v;", ReadAccount, ("$v", accountId))
?? throw new InvalidOperationException("Compte disparu.");
PlayerFailureOutcome outcome;
if (trusted)
{
var count = account.TrustedFailedCount + 1;
DateTimeOffset? until = null;
if (count >= settings.TrustedFailuresPerLock)
{
until = now + TimeSpan.FromMinutes(settings.LockMinutes);
count = 0;
}
Execute(tx, "UPDATE accounts SET trusted_failed_count = $c, trusted_locked_until = COALESCE($u, trusted_locked_until) WHERE id = $id;",
("$c", count), ("$u", Db(until)), ("$id", accountId));
outcome = new PlayerFailureOutcome(until, false, account.LockLevel);
}
else
{
var count = account.FailedCount + 1;
var level = account.LockLevel;
DateTimeOffset? until = null;
var suspended = false;
if (count >= settings.FailuresPerLock)
{
count = 0;
level++;
if (level >= settings.SuspendAfterLocks && account.Status != PlayerStatus.Suspended) suspended = true;
// Le blocage temporaire s'applique AUSSI au moment de la suspension et après : vu de l'extérieur, un compte
// suspendu se comporte comme une adresse inconnue (PlayerGuard.CountUnknownFailure), la suspension
// n'est révélée qu'après un mot de passe correct.
until = now + LockDuration(settings, level);
}
Execute(tx, """
UPDATE accounts SET failed_count = $c, lock_level = $l, locked_until = COALESCE($u, locked_until),
status = CASE WHEN $s = 1 THEN 'suspended' ELSE status END,
status_changed_at = CASE WHEN $s = 1 THEN $now ELSE status_changed_at END,
status_changed_by = CASE WHEN $s = 1 THEN 'système (échecs de connexion)' ELSE status_changed_by END,
disabled_until = CASE WHEN $s = 1 THEN NULL ELSE disabled_until END
WHERE id = $id;
""", ("$c", count), ("$l", level), ("$u", Db(until)), ("$s", suspended ? 1 : 0), ("$now", now.ToUnixTimeSeconds()),
("$id", accountId));
outcome = new PlayerFailureOutcome(until, suspended, level);
}
tx.Commit();
return outcome;
}
}
/// <summary>
/// Durée du blocage n° <paramref name="level"/> (1, 2, 3…) : <c>LockMinutes</c> doublée à chaque blocage, plafonnée
/// au niveau précédant la suspension et à 24 h. Même règle pour les adresses inconnues (blocage miroir).
/// </summary>
public static TimeSpan LockDuration(LauncherApiOptions.PlayersSettings settings, int level)
{
var capped = Math.Clamp(level, 1, Math.Max(1, settings.SuspendAfterLocks - 1));
return TimeSpan.FromMinutes(Math.Min(24 * 60, settings.LockMinutes * Math.Pow(2, capped - 1)));
}
/// <summary>Nouveau mot de passe : empreinte, date, « changement exigé » levé (ou posé).</summary>
public void SetPassword(string accountId, string passwordHash, DateTimeOffset now, bool mustChange = false) => Locked(() =>
{
Execute(null, "UPDATE accounts SET password_hash = $h, password_changed_at = $now, must_change_password = $m WHERE id = $id;",
("$h", passwordHash), ("$now", now.ToUnixTimeSeconds()), ("$m", mustChange ? 1 : 0), ("$id", accountId));
return true;
});
/// <summary>Exige (ou non) un nouveau mot de passe à la prochaine connexion.</summary>
public bool SetMustChangePassword(string accountId, bool value) => Locked(() =>
Execute(null, "UPDATE accounts SET must_change_password = $m WHERE id = $id;", ("$m", value ? 1 : 0), ("$id", accountId)) == 1);
/// <summary>Change l'état du compte (désactivation, réactivation, levée d'une suspension) ; remet à zéro les blocages
/// quand le compte redevient actif.</summary>
public bool SetStatus(string accountId, string status, DateTimeOffset? until, string? reasonEncrypted, string changedBy, DateTimeOffset now) =>
Locked(() => Execute(null, """
UPDATE accounts SET status = $s, disabled_until = $u, status_reason_enc = $r, status_changed_at = $now, status_changed_by = $by,
failed_count = CASE WHEN $s = 'active' THEN 0 ELSE failed_count END,
lock_level = CASE WHEN $s = 'active' THEN 0 ELSE lock_level END,
locked_until = CASE WHEN $s = 'active' THEN NULL ELSE locked_until END,
trusted_failed_count = CASE WHEN $s = 'active' THEN 0 ELSE trusted_failed_count END,
trusted_locked_until = CASE WHEN $s = 'active' THEN NULL ELSE trusted_locked_until END
WHERE id = $id;
""", ("$s", status), ("$u", Db(until)), ("$r", (object?)reasonEncrypted ?? DBNull.Value), ("$now", now.ToUnixTimeSeconds()),
("$by", changedBy), ("$id", accountId)) == 1);
/// <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;
}
}
/// <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))));
// ------------------------------------------------------------------ Appareils
private const string DeviceColumns =
"id, account_id, key_id, public_key, label, created_at, last_used_at, refresh_hash, previous_refresh_hash, " +
"refresh_expires_at, refresh_absolute_expires_at";
public PlayerDeviceRecord? FindDevice(string deviceId) => Locked(() =>
QuerySingle(null, $"SELECT {DeviceColumns} FROM devices WHERE id = $v;", ReadDevice, ("$v", deviceId)));
public PlayerDeviceRecord? FindDeviceByKey(string accountId, string keyId) => Locked(() =>
QuerySingle(null, $"SELECT {DeviceColumns} FROM devices WHERE account_id = $a AND key_id = $k;", ReadDevice,
("$a", accountId), ("$k", keyId)));
public IReadOnlyList<PlayerDeviceRecord> Devices(string accountId) => Locked(() =>
Query(null, $"SELECT {DeviceColumns} FROM devices WHERE account_id = $a ORDER BY last_used_at DESC;", ReadDevice, ("$a", accountId)));
/// <summary>
/// Ouvre une session mémorisée sur un appareil : appareil créé, ou remplacé s'il existait (même clé : nouvelle
/// connexion depuis le même PC). Au-delà de <paramref name="maxDevices"/>, les appareils les moins récemment utilisés
/// sont déconnectés. Renvoie les identifiants des appareils retirés (sessions à fermer).
/// </summary>
public IReadOnlyList<string> SaveDevice(PlayerDeviceRecord device, int maxDevices)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
var removed = Query(tx, "SELECT id FROM devices WHERE account_id = $a AND key_id = $k;", r => r.GetString(0),
("$a", device.AccountId), ("$k", device.KeyId)).ToList();
Execute(tx, "DELETE FROM devices WHERE account_id = $a AND key_id = $k;", ("$a", device.AccountId), ("$k", device.KeyId));
InsertDevice(tx, device);
var excess = Query(tx, "SELECT id FROM devices WHERE account_id = $a AND id <> $id ORDER BY last_used_at DESC, created_at DESC;",
r => r.GetString(0), ("$a", device.AccountId), ("$id", device.Id)).Skip(Math.Max(0, maxDevices - 1)).ToList();
foreach (var id in excess) Execute(tx, "DELETE FROM devices WHERE id = $id;", ("$id", id));
tx.Commit();
removed.AddRange(excess);
return removed;
}
}
/// <summary>
/// Remplace le jeton de renouvellement SI celui présenté est toujours celui en vigueur (deux renouvellements simultanés
/// avec le même jeton : un seul réussit).
/// </summary>
public bool RotateRefresh(string deviceId, string expectedHash, string newHash, DateTimeOffset idleExpiry, DateTimeOffset now) => Locked(() =>
Execute(null, """
UPDATE devices SET previous_refresh_hash = refresh_hash, refresh_hash = $new, refresh_expires_at = $exp, last_used_at = $now
WHERE id = $id AND refresh_hash = $old;
""", ("$new", newHash), ("$exp", idleExpiry.ToUnixTimeSeconds()), ("$now", now.ToUnixTimeSeconds()), ("$id", deviceId),
("$old", expectedHash)) == 1);
public bool DeleteDevice(string deviceId) => Locked(() => Execute(null, "DELETE FROM devices WHERE id = $id;", ("$id", deviceId)) == 1);
/// <summary>Déconnecte tous les appareils du compte (sauf <paramref name="exceptDeviceId"/>) ; renvoie leurs identifiants.</summary>
public IReadOnlyList<string> DeleteDevices(string accountId, string? exceptDeviceId = null)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
var ids = Query(tx, "SELECT id FROM devices WHERE account_id = $a AND id <> $e;", r => r.GetString(0),
("$a", accountId), ("$e", exceptDeviceId ?? "")).ToList();
Execute(tx, "DELETE FROM devices WHERE account_id = $a AND id <> $e;", ("$a", accountId), ("$e", exceptDeviceId ?? ""));
tx.Commit();
return ids;
}
}
private void InsertDevice(SqliteTransaction tx, PlayerDeviceRecord device) =>
Execute(tx, """
INSERT INTO devices (id, account_id, key_id, public_key, label, created_at, last_used_at, refresh_hash, previous_refresh_hash,
refresh_expires_at, refresh_absolute_expires_at)
VALUES ($id, $a, $k, $pk, $label, $created, $used, $rh, NULL, $exp, $abs);
""", ("$id", device.Id), ("$a", device.AccountId), ("$k", device.KeyId), ("$pk", device.PublicKey), ("$label", device.Label),
("$created", device.CreatedAt.ToUnixTimeSeconds()), ("$used", device.LastUsedAt.ToUnixTimeSeconds()), ("$rh", device.RefreshHash),
("$exp", device.RefreshExpiresAt.ToUnixTimeSeconds()), ("$abs", device.RefreshAbsoluteExpiresAt.ToUnixTimeSeconds()));
// ------------------------------------------------------------------ Journal des comptes
/// <summary>
/// Ajoute une ligne au journal des comptes. Jamais d'adresse e-mail ni de mot de passe : identifiant du compte, type,
/// résultat, adresse IP (effacée au bout de quelques mois), clé d'appareil, détail court.
/// </summary>
public void AddEvent(string? accountId, string type, string result, string? ip, string? deviceKeyId = null, string? details = null)
{
var now = _time.GetUtcNow();
Locked(() => Execute(null, """
INSERT INTO account_events (account_id, at, type, result, ip, device_key, details) VALUES ($a, $at, $t, $r, $ip, $k, $d);
""", ("$a", (object?)accountId ?? DBNull.Value), ("$at", now.ToUnixTimeSeconds()), ("$t", type), ("$r", result),
("$ip", (object?)Trim(ip, 64) ?? DBNull.Value), ("$k", (object?)Trim(deviceKeyId, 32) ?? DBNull.Value),
("$d", (object?)Trim(details, 300) ?? DBNull.Value)));
}
/// <summary>Journal d'un compte, le plus récent d'abord.</summary>
public IReadOnlyList<PlayerEventRecord> Events(string accountId, int limit) => Locked(() =>
Query(null, "SELECT id, account_id, at, type, result, ip, device_key, details FROM account_events WHERE account_id = $a ORDER BY id DESC LIMIT $l;",
ReadEvent, ("$a", accountId), ("$l", Math.Clamp(limit, 1, 1000))));
/// <summary>Lignes du journal postérieures à <paramref name="afterId"/> (tous comptes, tests et outils).</summary>
public IReadOnlyList<PlayerEventRecord> EventsAfter(long afterId, int limit) => Locked(() =>
Query(null, "SELECT id, account_id, at, type, result, ip, device_key, details FROM account_events WHERE id > $i ORDER BY id LIMIT $l;",
ReadEvent, ("$i", afterId), ("$l", Math.Clamp(limit, 1, 10_000))));
/// <summary>
/// Conservation : lignes plus anciennes que <paramref name="months"/> supprimées, adresses IP plus anciennes que
/// <paramref name="ipMonths"/> effacées. Renvoie (supprimées, anonymisées).
/// </summary>
public (int Deleted, int Anonymized) PurgeEvents(DateTimeOffset now, int months, int ipMonths) => Locked(() =>
{
var deleted = Execute(null, "DELETE FROM account_events WHERE at < $t;", ("$t", now.AddMonths(-months).ToUnixTimeSeconds()));
var anonymized = Execute(null, "UPDATE account_events SET ip = NULL WHERE ip IS NOT NULL AND at < $t;",
("$t", now.AddMonths(-ipMonths).ToUnixTimeSeconds()));
return (deleted, anonymized);
});
private static string? Trim(string? value, int max)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var clean = new string(value.Select(c => char.IsControl(c) ? ' ' : c).ToArray()).Trim();
return clean.Length <= max ? clean : clean[..max];
}
// ------------------------------------------------------------------ Accès SQLite
private T Locked<T>(Func<T> work)
{
lock (_lock) return work();
}
private int Execute(SqliteTransaction? tx, string sql, params (string Name, object? Value)[] parameters)
{
using var command = Command(tx, sql, parameters);
return command.ExecuteNonQuery();
}
private object? Scalar(SqliteTransaction? tx, string sql, params (string Name, object? Value)[] parameters)
{
using var command = Command(tx, sql, parameters);
return command.ExecuteScalar();
}
private List<T> Query<T>(SqliteTransaction? tx, string sql, Func<SqliteDataReader, T> read, params (string Name, object? Value)[] parameters)
{
using var command = Command(tx, sql, parameters);
using var reader = command.ExecuteReader();
var rows = new List<T>();
while (reader.Read()) rows.Add(read(reader));
return rows;
}
private T? QuerySingle<T>(SqliteTransaction? tx, string sql, Func<SqliteDataReader, T> read, params (string Name, object? Value)[] parameters)
where T : class =>
Query(tx, sql, read, parameters).FirstOrDefault();
private SqliteCommand Command(SqliteTransaction? tx, string sql, (string Name, object? Value)[] parameters)
{
var command = _db.CreateCommand();
command.CommandText = sql;
command.Transaction = tx;
foreach (var (name, value) in parameters) command.Parameters.AddWithValue(name, value ?? DBNull.Value);
return command;
}
private static object Db(DateTimeOffset? value) => value is { } date ? date.ToUnixTimeSeconds() : DBNull.Value;
private static DateTimeOffset Date(SqliteDataReader r, int i) => DateTimeOffset.FromUnixTimeSeconds(r.GetInt64(i));
private static DateTimeOffset? NullableDate(SqliteDataReader r, int i) => r.IsDBNull(i) ? null : Date(r, i);
private static PlayerAccountRecord ReadAccount(SqliteDataReader r) => new()
{
Id = r.GetString(0),
Reference = r.GetString(1),
EmailIndex = r.GetString(2),
EmailEncrypted = r.GetString(3),
EmailVerified = r.GetInt64(4) != 0,
PasswordHash = r.GetString(5),
PasswordChangedAt = Date(r, 6),
MustChangePassword = r.GetInt64(7) != 0,
OtpEnabled = r.GetInt64(8) != 0,
Status = r.GetString(9),
DisabledUntil = NullableDate(r, 10),
StatusChangedAt = NullableDate(r, 11),
CreatedAt = Date(r, 12),
LastLoginAt = NullableDate(r, 13),
TermsVersion = r.GetString(14),
FailedCount = (int)r.GetInt64(15),
LockLevel = (int)r.GetInt64(16),
LockedUntil = NullableDate(r, 17),
TrustedFailedCount = (int)r.GetInt64(18),
TrustedLockedUntil = NullableDate(r, 19)
};
private static PlayerDeviceRecord ReadDevice(SqliteDataReader r) => new()
{
Id = r.GetString(0),
AccountId = r.GetString(1),
KeyId = r.GetString(2),
PublicKey = r.GetString(3),
Label = r.GetString(4),
CreatedAt = Date(r, 5),
LastUsedAt = Date(r, 6),
RefreshHash = r.GetString(7),
PreviousRefreshHash = r.IsDBNull(8) ? null : r.GetString(8),
RefreshExpiresAt = Date(r, 9),
RefreshAbsoluteExpiresAt = Date(r, 10)
};
private static PlayerEventRecord ReadEvent(SqliteDataReader r) => new(
r.GetInt64(0), r.IsDBNull(1) ? null : r.GetString(1), Date(r, 2), r.GetString(3), r.GetString(4),
r.IsDBNull(5) ? null : r.GetString(5), r.IsDBNull(6) ? null : r.GetString(6), r.IsDBNull(7) ? null : r.GetString(7));
}
@@ -72,6 +72,26 @@
"Audit": {
"RetentionMonths": 12
},
// Comptes joueurs (phase 8) : désactivés par défaut. NE PAS ACTIVER EN PRODUCTION avant la fin de l'étape 8g
// (docs/PLAYER-ACCOUNTS-ARCHITECTURE.md). Activés, ils exigent la clé maîtresse : credential systemd chiffré
// « players-master-key » (server/deploy/LISEZMOI.md, étape 3 undecies), jamais dans le dossier des données.
"Players": {
"Enabled": false,
"RegistrationEnabled": true,
"TermsVersion": "1",
"HashMemoryKib": 19456,
"HashIterations": 2,
"FailuresPerLock": 5,
"LockMinutes": 15,
"SuspendAfterLocks": 4,
"RefreshIdleDays": 90,
"RefreshMaxDays": 365,
"EventsRetentionMonths": 12,
"EventsIpRetentionMonths": 6,
// 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.
+98 -2
View File
@@ -286,8 +286,11 @@ Limites :
textes) ;
* un launcher accepte tout trousseau signé par la clé racine dont le numéro est au moins `keyringMinSequence`
et au moins celui du dernier trousseau qu'il a vu ;
* un appareil ajouté au trousseau pendant qu'un launcher tourne est connu de ce launcher au plus tard 5 minutes
après (nouveau téléchargement du trousseau) ou à son prochain démarrage.
* un appareil ajouté au trousseau, ou une autorisation ajoutée à un appareil, pendant qu'un launcher tourne est
connu de ce launcher dès le contenu suivant signé par cet appareil (nouveau téléchargement du trousseau, au plus une
fois par minute) ;
* un launcher laissé ouvert retélécharge le trousseau toutes les 30 minutes au plus (à la vérification suivante) : un
appareil retiré, ou une autorisation enlevée, n'y est plus accepté sans attendre son redémarrage.
La clé racine (`launcher-signing.key`) ne doit jamais aller sur le VPS ni dans Git, ni servir de clé
d'administrateur (le menu d'administration la refuse, quel que soit le nom du fichier) : c'est elle qui protège
@@ -454,6 +457,92 @@ joueur remplit le sien, le serveur répond 409 : le launcher 3.2 relit les formu
inchangés. Chaque demande conserve le formulaire tel qu'à l'envoi : elle reste lisible si un champ est renommé ou
supprimé ensuite. Historique des 20 dernières publications : `feedback-forms-history/` dans les données privées.
## 3 decies. Mode maintenance du launcher (PC et VPS)
Le menu d'administration › page **« Maintenance »** active ou désactive le mode maintenance de **tous les launchers**
(tous les jeux) : fond et accents rouge / orange, « Maintenance en cours — merci de revenir plus tard » au centre avec,
au choix, une précision (6 lignes, 300 caractères, **aucun lien**). Pendant la maintenance, les joueurs ne peuvent plus
installer, mettre à jour, vérifier, réparer, localiser ni lancer un jeu, ni ouvrir les paramètres ou la fenêtre « Aide » :
seuls la **désinstallation** et le menu d'administration restent disponibles. Le document est **signé** : un serveur
compromis ne peut ni déclencher une fausse maintenance ni la prolonger chez les launchers en mode `require`.
Prérequis : étapes 3 bis (catalogue) et 3 quinquies (trousseau).
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : le fichier est écrit à
côté du trousseau (`/srv/exovival/www/catalog/maintenance.json`, 644), dans le dossier déjà autorisé en écriture
(`catalog.conf`, étape 3 bis).
2. **nginx** : ajoute le bloc `location = /catalog/maintenance.json { … }` de `server/deploy/nginx-catalog.conf`, à côté
de celui du trousseau (aucun cache : la fin de la maintenance doit être vue tout de suite), puis
`sudo nginx -t && sudo systemctl reload nginx`. Aucun bloc à ajouter dans `nginx-api.conf` (petite requête).
3. **Launcher 3.3** : publie-le (mise à jour signée habituelle) **avant** l'étape 4. Les launchers 3.2 acceptent le
nouveau trousseau (autorisation inconnue d'eux, sans effet) ; les launchers antérieurs à la 3.2 le refusent et
gardent le précédent, comme pour les formulaires. Les launchers antérieurs à la 3.3 ignorent la maintenance.
4. **Permission et trousseau** :
1. page **Groupes et permissions** : donne `maintenance.manage` (« Mode maintenance ») au groupe qui doit pouvoir
l'activer (le groupe Propriétaire l'a d'office ; Administrateur l'a sur une installation neuve) ;
2. signe un nouveau trousseau (étape 3 quinquies, point 3) : l'autorisation **« mode maintenance »** des appareils
concernés y apparaît, signalée « AUTORISATIONS ÉLARGIES » par `tools\sign-keyring.bat` : c'est attendu.
5. **Activer** depuis la page « Maintenance » (message facultatif, confirmation), **désactiver** de même. Les launchers
relisent le fichier au démarrage (avant toute mise à jour automatique), puis toutes les 2 minutes (toutes les minutes
pendant une maintenance) ; celui de l'administrateur qui publie bascule aussitôt. Un launcher resté ouvert pendant
la re-signature du trousseau (point 4) prend la nouvelle autorisation en compte à la relecture suivante
(launcher 3.3.1 et suivants ; en 3.3.0, il fallait le redémarrer).
Comportement des launchers :
- **Hors ligne** ou fichier injoignable au démarrage : pas de maintenance (le jeu installé reste jouable hors ligne).
Pendant que le launcher tourne, une coupure garde le dernier état vérifié.
- Fichier non signé, mal signé, hors limites ou plus ancien que le dernier vu (`contentSigning` « require ») : ignoré.
En « warn », il est appliqué et signalé dans le journal ; en « off », il est appliqué sans vérification.
- Une opération déjà commencée (téléchargement…) se termine ; un jeu déjà lancé n'est pas fermé. Les mises à jour du
**launcher lui-même** (signées par la clé racine) continuent : c'est le moyen de corriger un launcher défectueux.
- C'est un **confort d'affichage**, pas une barrière de sécurité : un launcher modifié peut l'ignorer. Si les serveurs
du jeu doivent refuser les connexions, c'est à eux de le faire.
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.
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 :
@@ -539,6 +628,9 @@ pas, le script remet la précédente en place.
| Un administrateur a perdu tous ses PC | Il crée une nouvelle clé et t'envoie son `.pub`, puis `sudo -u deploy /opt/launcher-api/GameLauncher.Server add-key --admin <identifiant> --public-key /tmp/paul.pub` (vérifie l'empreinte avec lui) |
| Toutes tes clés perdues | Nouvelle clé (étape 4), puis `add-owner` (étape 5) ; révoque ensuite les anciennes clés |
| Plus personne ne peut gérer les groupes | Tout propriétaire le peut toujours (groupe système, toutes les permissions) ; sinon `add-owner` (étape 5) |
| Compte joueur : voir son état (sans son adresse) | `sudo -u deploy /opt/launcher-api/GameLauncher.Server players-show --ref PL-XXXX-XXXX` |
| Compte joueur suspendu (trop d'essais) ou bloqué | `sudo -u deploy /opt/launcher-api/GameLauncher.Server players-unlock --ref PL-XXXX-XXXX` |
| Compte joueur à désactiver / réactiver | `… players-disable --ref PL-XXXX-XXXX [--until AAAA-MM-JJ]` / `… players-enable --ref PL-XXXX-XXXX` |
Toujours `sudo -u deploy …` : lancé en root, l'outil refuse (les fichiers recréés appartiendraient à
root et le service ne pourrait plus les lire). Il affiche le dossier de données utilisé ; il lit les
@@ -584,11 +676,15 @@ recréer les administrateurs.
│ et leurs captures <id>-<n>.png|jpg (sans métadonnées)
├─ feedback-forms.json (+ .bak) formulaires des demandes publiés (référence de la révision)
├─ 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)
├─ keyring.json trousseau publié (écrit par le service, 644)
├─ feedback-forms.json formulaires des demandes signés (écrit par le service, 644)
├─ maintenance.json mode maintenance signé (écrit par le service, 644)
└─ images/ images du catalogue (envoyées en SFTP)
/etc/systemd/system/launcher-api.service
/etc/systemd/system/launcher-api.service.d/catalog.conf écriture du dossier public (conservé)
+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
+11 -2
View File
@@ -1,6 +1,6 @@
# =============================================================================
# Catalogue des jeux, trousseau et formulaires des demandes (écrits par la Launcher API) — voir LISEZMOI.md,
# étapes 3 bis, 3 quinquies et 3 nonies :
# Catalogue des jeux, trousseau, formulaires des demandes et mode maintenance (écrits par la Launcher API) —
# voir LISEZMOI.md, étapes 3 bis, 3 quinquies, 3 nonies et 3 decies :
#
# Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival,
# AVANT « location / { … } » : (le bloc ci-dessous)
@@ -36,3 +36,12 @@ location = /catalog/feedback-forms.json {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Mode maintenance (launcher 3.3) : activé depuis le menu, signé (autorisation « maintenance » du trousseau), relu par les
# launchers au démarrage puis toutes les 1 à 2 minutes. Jamais mis en cache : la fin de la maintenance doit être vue tout de suite.
location = /catalog/maintenance.json {
limit_except GET { deny all; }
default_type application/json;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
}
@@ -7,6 +7,7 @@ using GameLauncher.Contracts.Files;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
@@ -315,6 +316,37 @@ public sealed class AdminSession : IAsyncDisposable
new FeedbackFormsPublishRequest { BaseRevision = baseRevision, Document = document with { Signature = signature } }, ct).ConfigureAwait(false);
}
/// <summary>Mode maintenance en vigueur (révision 0 : jamais publié, inactif).</summary>
public Task<MaintenanceState> GetMaintenanceAsync(CancellationToken ct = default) =>
GetAsync<MaintenanceState>(ApiRoutes.Maintenance, ct);
/// <summary>
/// Active, désactive ou modifie le mode maintenance : révision <paramref name="baseRevision"/> + 1, signé par la clé de
/// cette session (autorisation « maintenance » du trousseau requise chez les joueurs), puis envoyé en requête signée.
/// </summary>
/// <param name="message">Précision affichée aux joueurs, déjà nettoyée (<see cref="MaintenanceRules.CleanMessage"/>) ; ignorée à la désactivation.</param>
/// <param name="since">
/// Début de la maintenance : celui de la maintenance en cours pour un nouveau message, null pour en commencer une
/// (l'heure du serveur, à la seconde) ; ignoré à la désactivation.
/// </param>
public async Task<MaintenanceState> PublishMaintenanceAsync(bool enabled, string? message, DateTimeOffset? since, long baseRevision,
CancellationToken ct = default)
{
var document = new MaintenanceDocument
{
Revision = baseRevision + 1,
Enabled = enabled,
Message = enabled ? message : null,
Since = enabled ? since ?? ContentSigning.Truncate(DateTimeOffset.UtcNow + _clockOffset) : null
};
var errors = new List<string>();
if (!MaintenanceRules.Validate(document, errors)) throw new ArgumentException(string.Join(" ", errors.Take(5)), nameof(message));
var signature = await SignContentAsync(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), ct)
.ConfigureAwait(false);
return await SendSignedAsync<MaintenanceState>(HttpMethod.Put, ApiRoutes.Maintenance,
new MaintenancePublishRequest { BaseRevision = baseRevision, Document = document with { Signature = signature } }, ct).ConfigureAwait(false);
}
/// <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,202 @@
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;
}
/// <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 { Session: { } session }) Check(session);
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>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)))
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,247 @@
namespace GameLauncher.Contracts.Accounts;
// Comptes joueurs (phase 8). Routes sous « v1/account/… » : aucune n'est une route d'administration, et aucune route
// d'administration n'accepte un jeton joueur. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.
/// <summary>État du service des comptes, annoncé dans GET v1/info (null : serveur sans comptes joueurs).</summary>
public sealed record PlayerAccountsInfo
{
/// <summary>Connexion possible (le launcher affiche le bouton « Compte »).</summary>
public bool Enabled { get; init; }
/// <summary>Création de compte ouverte.</summary>
public bool RegistrationOpen { get; init; }
/// <summary>Version des conditions d'utilisation et de la politique de confidentialité à accepter à la création.</summary>
public string TermsVersion { get; init; } = "";
/// <summary>Version des règles des mots de passe (<see cref="PasswordRules.Version"/>).</summary>
public string PasswordRulesVersion { get; init; } = "";
}
/// <summary>Demande d'un défi (POST v1/account/challenge).</summary>
public sealed record PlayerChallengeRequest
{
/// <summary>Une valeur de <see cref="PlayerChallengePurposes"/>.</summary>
public string Purpose { get; init; } = "";
}
/// <summary>
/// Défi joueur, SANS ÉTAT sur le serveur, à usage unique : il porte l'usage, l'échéance et la difficulté de la preuve de
/// travail (0 : aucune, renouvellement de session). Le launcher résout la preuve de travail
/// (<see cref="Feedback.FeedbackProof"/>) puis signe <see cref="PlayerMessages.Device"/> avec sa clé d'appareil.
/// </summary>
public sealed record PlayerChallengeResponse
{
public string ChallengeId { get; init; } = "";
public string Nonce { get; init; } = "";
public string Purpose { get; init; } = "";
public int Difficulty { get; init; }
public string ServerId { get; init; } = "";
public string Origin { get; init; } = "";
public DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>Preuve de possession de la clé d'appareil (création de compte, connexion).</summary>
public sealed record PlayerDeviceProof
{
/// <summary>Clé publique de l'appareil (SPKI ECDSA P-256, Base64).</summary>
public string PublicKey { get; init; } = "";
/// <summary>Signature (Base64, 64 octets) de <see cref="PlayerMessages.Device"/>.</summary>
public string Signature { get; init; } = "";
/// <summary>Nom générique de l'appareil (« Windows 11 · GameLauncher 3.4.0 »), jamais le nom Windows du PC.</summary>
public string? Label { get; init; }
}
/// <summary>Création de compte (POST v1/account/register).</summary>
public sealed record PlayerRegisterRequest
{
public string Email { get; init; } = "";
public string Password { get; init; } = "";
public string ChallengeId { get; init; } = "";
/// <summary>Solution de la preuve de travail du défi.</summary>
public string Solution { get; init; } = "";
/// <summary>Version des conditions acceptées par le joueur (doit être celle annoncée par le serveur).</summary>
public string AcceptedTermsVersion { get; init; } = "";
public PlayerDeviceProof Device { get; init; } = new();
}
/// <summary>Connexion (POST v1/account/login).</summary>
public sealed record PlayerLoginRequest
{
public string Email { get; init; } = "";
public string Password { get; init; } = "";
public string ChallengeId { get; init; } = "";
public string Solution { get; init; } = "";
public PlayerDeviceProof Device { get; init; } = new();
}
/// <summary>Renouvellement de session au lancement du launcher (POST v1/account/refresh).</summary>
public sealed record PlayerRefreshRequest
{
public string RefreshToken { get; init; } = "";
public string ChallengeId { get; init; } = "";
/// <summary>Signature (Base64) de <see cref="PlayerMessages.Device"/> (usage « refresh ») par la clé de l'appareil.</summary>
public string Signature { get; init; } = "";
}
/// <summary>Résultats possibles d'une connexion réussie (mot de passe correct).</summary>
public static class PlayerLoginStatus
{
/// <summary>Connecté : <see cref="PlayerLoginResponse.Session"/>.</summary>
public const string Ok = "ok";
/// <summary>
/// Un administrateur exige un nouveau mot de passe : <see cref="PlayerLoginResponse.PasswordChangeToken"/> permet
/// seulement d'appeler POST v1/account/password ; la session est ouverte une fois le mot de passe changé.
/// </summary>
public const string PasswordChangeRequired = "password_change_required";
}
public sealed record PlayerLoginResponse
{
public string Status { get; init; } = PlayerLoginStatus.Ok;
public PlayerSession? Session { get; init; }
public string? PasswordChangeToken { get; init; }
public DateTimeOffset? PasswordChangeExpiresAt { get; init; }
}
/// <summary>Session ouverte (création de compte, connexion, renouvellement, changement de mot de passe exigé).</summary>
public sealed record PlayerSession
{
/// <summary>Jeton d'accès (« Authorization: Bearer … »), court, à garder en mémoire uniquement.</summary>
public string AccessToken { get; init; } = "";
public DateTimeOffset AccessExpiresAt { get; init; }
/// <summary>
/// Jeton de renouvellement (connexion mémorisée), remplacé à chaque renouvellement : à enregistrer chiffré sur le PC
/// (DPAPI) à la place du précédent. Inutilisable sans la clé de l'appareil.
/// </summary>
public string RefreshToken { get; init; } = "";
/// <summary>Échéance du jeton de renouvellement (sans utilisation ; repoussée à chaque renouvellement, dans la limite d'un an).</summary>
public DateTimeOffset RefreshExpiresAt { get; init; }
public string DeviceId { get; init; } = "";
public PlayerAccountInfo Account { get; init; } = new();
}
/// <summary>Compte du joueur connecté (adresse masquée : l'adresse complète n'est jamais renvoyée).</summary>
public sealed record PlayerAccountInfo
{
/// <summary>Référence du compte (« PL-7KQ2-M9XD »), à communiquer au support.</summary>
public string Reference { get; init; } = "";
public string EmailMasked { get; init; } = "";
public bool EmailVerified { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastLoginAt { get; init; }
public DateTimeOffset PasswordChangedAt { get; init; }
public bool OtpEnabled { get; init; }
/// <summary>
/// Suspension de sécurité après de nombreux essais de mot de passe : les connexions depuis un NOUVEL appareil sont
/// refusées jusqu'à ce qu'un administrateur la lève ; cet appareil-ci (déjà connecté) continue de fonctionner.
/// </summary>
public bool NewDevicesSuspended { get; init; }
}
/// <summary>Un appareil connecté au compte (« Mes appareils »).</summary>
public sealed record PlayerDeviceInfo
{
public string Id { get; init; } = "";
public string Label { get; init; } = "";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset LastUsedAt { get; init; }
/// <summary>Appareil de la session en cours.</summary>
public bool Current { get; init; }
}
/// <summary>GET v1/account/me.</summary>
public sealed record PlayerMeResponse
{
public PlayerAccountInfo Account { get; init; } = new();
public IReadOnlyList<PlayerDeviceInfo> Devices { get; init; } = Array.Empty<PlayerDeviceInfo>();
}
/// <summary>Changement de mot de passe (POST v1/account/password), avec une session ou un jeton « changement exigé ».</summary>
public sealed record PlayerChangePasswordRequest
{
public string CurrentPassword { get; init; } = "";
public string NewPassword { get; init; } = "";
}
public sealed record PlayerChangePasswordResponse
{
/// <summary>Session ouverte après un changement EXIGÉ (jeton limité) ; null pour un changement ordinaire.</summary>
public PlayerSession? Session { get; init; }
/// <summary>Nombre d'autres appareils déconnectés.</summary>
public int DevicesSignedOut { get; init; }
}
/// <summary>Déconnexion (POST v1/account/logout).</summary>
public sealed record PlayerLogoutRequest
{
/// <summary>Vrai : tous les appareils du compte (« Déconnecter tous mes appareils »).</summary>
public bool AllDevices { get; init; }
}
/// <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";
}
}
@@ -0,0 +1,35 @@
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,
/// et plus tard 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 levée",
"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é",
_ => 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,75 @@
namespace GameLauncher.Contracts.Accounts;
/// <summary>
/// Adresse e-mail d'un compte joueur : contrôle et forme normalisée (launcher et serveur). La forme normalisée sert
/// d'identifiant de connexion : une même adresse saisie en majuscules ou avec des espaces donne le même compte.
/// Règles volontairement simples et strictes : caractères latins sans accents (domaines internationaux sous leur forme
/// « xn-- »), un seul « @ », 254 caractères au plus.
/// </summary>
public static class PlayerEmail
{
public const int MaxLength = 254;
public const int MaxLocalLength = 64;
private const string LocalSymbols = "!#$%&'*+-/=?^_`{|}~.";
/// <summary>Adresse normalisée (minuscules, sans espaces autour), ou null si elle n'est pas valable.</summary>
public static string? Normalize(string? input)
{
if (string.IsNullOrWhiteSpace(input) || input.Length > MaxLength + 16) return null;
// Pas de normalisation Unicode (indisponible sur le serveur en globalisation invariante) : seuls les caractères
// ASCII sont acceptés, de la même façon par le launcher et par le serveur.
var text = input.Trim().ToLowerInvariant();
if (text.Length > MaxLength) return null;
var at = text.IndexOf('@');
if (at <= 0 || at != text.LastIndexOf('@') || at == text.Length - 1) return null;
var local = text[..at];
var domain = text[(at + 1)..];
if (domain.EndsWith('.')) domain = domain[..^1];
if (!IsValidLocal(local) || !IsValidDomain(domain)) return null;
var result = local + "@" + domain;
return result.Length <= MaxLength ? result : null;
}
public static bool IsValid(string? input) => Normalize(input) is not null;
/// <summary>
/// Adresse masquée pour l'affichage (« n***@e***.fr ») : première lettre de la partie locale et du domaine,
/// extension conservée. Chaîne vide si l'adresse n'est pas valable.
/// </summary>
public static string Mask(string? email)
{
if (Normalize(email) is not { } normalized) return "";
var at = normalized.IndexOf('@');
var local = normalized[..at];
var domain = normalized[(at + 1)..];
var lastDot = domain.LastIndexOf('.');
return $"{local[0]}***@{domain[0]}***{domain[lastDot..]}";
}
private static bool IsValidLocal(string local)
{
if (local.Length is 0 or > MaxLocalLength) return false;
if (local[0] == '.' || local[^1] == '.' || local.Contains("..", StringComparison.Ordinal)) return false;
foreach (var c in local)
if (!(c is >= 'a' and <= 'z' or >= '0' and <= '9' || LocalSymbols.Contains(c))) return false;
return true;
}
private static bool IsValidDomain(string domain)
{
if (domain.Length is 0 or > 253) return false;
var labels = domain.Split('.');
if (labels.Length < 2) return false;
foreach (var label in labels)
{
if (label.Length is 0 or > 63 || label[0] == '-' || label[^1] == '-') return false;
foreach (var c in label)
if (!(c is >= 'a' and <= 'z' or >= '0' and <= '9' or '-')) return false;
}
var tld = labels[^1];
return tld.StartsWith("xn--", StringComparison.Ordinal) || (tld.Length >= 2 && tld.All(c => c is >= 'a' and <= 'z'));
}
}
@@ -0,0 +1,111 @@
using System.Security.Cryptography;
using GameLauncher.Contracts.Security;
namespace GameLauncher.Contracts.Accounts;
/// <summary>
/// Messages signés par la CLÉ D'APPAREIL d'un joueur (ECDSA P-256 créée par le launcher, dans la puce TPM si elle existe).
/// En-tête propre aux joueurs (« GameLauncher/Player/… ») : une signature de joueur ne vaut jamais pour un message
/// d'administrateur (« GameLauncher/Admin/… »), et inversement.
///
/// La signature prouve que la requête vient de l'appareil qui possède la clé : un fichier de session copié sur un autre PC
/// ne permet pas de renouveler la session, et seul un appareil déjà utilisé avec succès est reconnu « de confiance » par
/// la protection contre la force brute.
/// </summary>
public static class PlayerMessages
{
public const string DeviceHeader = "GameLauncher/Player/Device/v1";
/// <summary>Message signé par la clé d'appareil pour une création de compte, une connexion ou un renouvellement.</summary>
/// <param name="purpose">Une valeur de <see cref="PlayerChallengePurposes"/>.</param>
/// <param name="deviceKeyId">Identifiant de la clé d'appareil (<see cref="AdminKeys.ComputeKeyId"/>).</param>
public static byte[] Device(string purpose, string origin, string serverId, string challengeId, string nonce, string deviceKeyId) =>
SignedMessages.Build(DeviceHeader,
("purpose", purpose), ("origin", origin), ("server", serverId), ("challenge", challengeId), ("nonce", nonce),
("device", deviceKeyId));
}
/// <summary>Usages d'un défi joueur (le défi est scellé pour un seul usage).</summary>
public static class PlayerChallengePurposes
{
public const string Register = "register";
public const string Login = "login";
public const string Refresh = "refresh";
public static bool IsValid(string? purpose) => purpose is Register or Login or Refresh;
}
/// <summary>
/// Formats des jetons joueurs. Préfixes distincts de tout jeton administrateur : le serveur rejette un jeton joueur
/// présenté à une route d'administration sans même le chercher, et inversement.
/// </summary>
public static class PlayerTokens
{
/// <summary>Jeton d'accès (15 min, en mémoire seulement côté launcher) : « glpa_ » + 256 bits.</summary>
public const string AccessPrefix = "glpa_";
/// <summary>Jeton de renouvellement (gardé chiffré sur le PC) : « glpr_ » + identifiant d'appareil + « . » + 256 bits.</summary>
public const string RefreshPrefix = "glpr_";
/// <summary>Jeton limité au changement de mot de passe exigé (10 min) : « glpc_ » + 256 bits.</summary>
public const string PasswordChangePrefix = "glpc_";
public const int MaxLength = 128;
public static string NewSecret() => Base64Url.Encode(RandomNumberGenerator.GetBytes(32));
/// <summary>Jeton de renouvellement : identifiant de l'appareil (32 hex) et secret, ou faux si mal formé.</summary>
public static bool TryParseRefresh(string? token, out string deviceId, out string secret)
{
deviceId = secret = "";
if (token is not { Length: > 40 and <= MaxLength } || !token.StartsWith(RefreshPrefix, StringComparison.Ordinal)) return false;
var body = token[RefreshPrefix.Length..];
var dot = body.IndexOf('.');
if (dot != 32) return false;
deviceId = body[..dot];
secret = body[(dot + 1)..];
return PlayerIds.IsValidId(deviceId) && Base64Url.TryDecode(secret, out var bytes) && bytes.Length == 32;
}
public static string FormatRefresh(string deviceId, string secret) => RefreshPrefix + deviceId + "." + secret;
}
/// <summary>Identifiants des comptes joueurs.</summary>
public static class PlayerIds
{
private const string Crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/// <summary>Identifiant interne (compte, appareil) : 128 bits en hexadécimal minuscule.</summary>
public static string NewId() => Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
public static bool IsValidId(string? id) => id is { Length: 32 } && id.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f');
/// <summary>
/// Référence affichée aux administrateurs et au joueur (« PL-7KQ2-M9XD », 40 bits, sans lettre ambiguë) : désigne un
/// compte dans le journal d'audit sans jamais y écrire l'adresse e-mail.
/// </summary>
public static string NewReference()
{
var bytes = RandomNumberGenerator.GetBytes(5);
ulong value = 0;
foreach (var b in bytes) value = (value << 8) | b;
var chars = new char[8];
for (var i = 7; i >= 0; i--)
{
chars[i] = Crockford[(int)(value & 31)];
value >>= 5;
}
return $"PL-{new string(chars, 0, 4)}-{new string(chars, 4, 4)}";
}
/// <summary>Référence normalisée (majuscules, tirets), ou null si elle n'est pas valable.</summary>
public static string? NormalizeReference(string? reference)
{
if (string.IsNullOrWhiteSpace(reference) || reference.Length > 20) return null;
var text = reference.Trim().ToUpperInvariant();
if (text.Length != 12 || !text.StartsWith("PL-", StringComparison.Ordinal) || text[7] != '-') return null;
foreach (var c in text[3..7] + text[8..])
if (!Crockford.Contains(c)) return null;
return text;
}
}
@@ -0,0 +1,19 @@
password-blocklist.txt.gz — dictionnaire des mots de passe les plus utilisés
=========================================================================
Contenu : les 99 839 mots de passe de la liste « 100k-most-used-passwords-NCSC.txt » (un par ligne, UTF-8, doublons
retirés, ordre d'origine conservé), compressés en gzip.
Origine :
• Liste publiée par le NCSC (National Cyber Security Centre, Royaume-Uni) : les 100 000 mots de passe les plus
fréquents dans les fuites recensées par le service « Have I Been Pwned » (Pwned Passwords).
• Copie utilisée : dépôt SecLists (https://github.com/danielmiessler/SecLists),
fichier Passwords/Common-Credentials/100k-most-used-passwords-NCSC.txt — licence MIT,
© 2018 Daniel Miessler.
Usage : GameLauncher.Contracts.Accounts.PasswordRules (même contrôle dans le launcher et sur le serveur). La liste est
normalisée au chargement (minuscules, substitutions courantes 0→o, 4→a, @→a…, chiffres et symboles de début et de fin
retirés) : les variantes simples (« P@ssw0rd2024! ») sont refusées comme l'original.
Mise à jour : remplacer le fichier .gz par une version plus récente de la même liste (même format), puis relancer les
tests (Contracts/PasswordRulesTests).
+42
View File
@@ -147,6 +147,48 @@ public static class ApiRoutes
/// <summary>Contenu d'une capture d'écran (feedback.read).</summary>
public static string GameFeedbackAttachment(string gameId, string id, int index) => $"v1/games/{gameId}/feedback/{id}/attachments/{index}";
// ------------------------------------------------------------------ Mode maintenance du launcher
/// <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";
// ------------------------------------------------------------------ Publication d'une version
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
public const string PublicationPattern = "v1/publications/{id}";
@@ -15,6 +15,9 @@ public sealed record ServerInfo
/// <summary>Algorithmes de clé acceptés (« ECDSA-P256-SHA256 »).</summary>
public IReadOnlyList<string> KeyAlgorithms { get; init; } = Array.Empty<string>();
/// <summary>Comptes joueurs (phase 8) : null si le serveur ne les propose pas (ou serveur plus ancien).</summary>
public Accounts.PlayerAccountsInfo? Accounts { get; init; }
}
public sealed record HealthStatus
@@ -77,4 +80,43 @@ public static class ApiErrorCodes
/// <summary>Demandes des joueurs : défi anti-spam absent, expiré, déjà utilisé ou mal résolu.</summary>
public const string ProofInvalid = "proof_invalid";
// ------------------------------------------------------------------ Comptes joueurs (phase 8)
/// <summary>Adresse e-mail mal formée.</summary>
public const string EmailInvalid = "email_invalid";
/// <summary>Adresse déjà utilisée par un autre compte (création de compte).</summary>
public const string EmailTaken = "email_taken";
/// <summary>Mot de passe refusé par <see cref="Accounts.PasswordRules"/> (message : les raisons).</summary>
public const string PasswordRejected = "password_rejected";
/// <summary>Adresse ou mot de passe incorrect (message volontairement identique dans les deux cas).</summary>
public const string LoginFailed = "login_failed";
/// <summary>Trop d'essais : connexion bloquée temporairement (en-tête Retry-After).</summary>
public const string LoginLocked = "login_locked";
/// <summary>Compte désactivé par un administrateur (révélé seulement après un mot de passe correct).</summary>
public const string AccountDisabled = "account_disabled";
/// <summary>Compte suspendu après trop d'essais (connexion depuis un NOUVEL appareil refusée) : seul un administrateur
/// peut lever la suspension ; les appareils déjà connectés continuent de fonctionner.</summary>
public const string AccountSuspended = "account_suspended";
/// <summary>Un nouveau mot de passe est exigé avant toute autre action.</summary>
public const string PasswordChangeRequired = "password_change_required";
/// <summary>Session mémorisée révoquée, expirée ou réutilisée (vol suspecté) : nouvelle connexion nécessaire.</summary>
public const string SessionRevoked = "session_revoked";
/// <summary>Création de compte fermée, ou bloquée pour cette adresse IP / cet appareil.</summary>
public const string RegistrationClosed = "registration_closed";
/// <summary>Conditions d'utilisation non acceptées ou dans une version dépassée.</summary>
public const string TermsNotAccepted = "terms_not_accepted";
/// <summary>Preuve de possession de la clé d'appareil absente ou invalide.</summary>
public const string DeviceProofInvalid = "device_proof_invalid";
}
@@ -13,4 +13,9 @@
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<!-- Comptes joueurs : dictionnaire des mots de passe les plus utilisés (PasswordRules), voir Accounts\Resources\password-blocklist.SOURCE.txt. -->
<ItemGroup>
<EmbeddedResource Include="Accounts\Resources\password-blocklist.txt.gz" LogicalName="GameLauncher.Contracts.Accounts.password-blocklist.txt.gz" />
</ItemGroup>
</Project>
@@ -0,0 +1,202 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Signing;
namespace GameLauncher.Contracts.Maintenance;
/// <summary>
/// Mode maintenance du launcher (maintenance.json, commun à tous les jeux), activé ou désactivé depuis le menu
/// d'administration. Fichier PUBLIC servi par nginx à côté du trousseau, signé par la clé d'un administrateur autorisée
/// « maintenance » dans le trousseau : les launchers des joueurs le vérifient comme le catalogue avant d'en tenir compte.
/// Maintenance active : le launcher passe au rouge / orange, affiche le message au centre et bloque tout, sauf la
/// désinstallation des jeux et le menu d'administration.
/// Révision 0 = rien n'a jamais été publié (maintenance inactive).
/// </summary>
public sealed record MaintenanceDocument
{
public const string FormatName = "gamelauncher-maintenance";
public const int CurrentVersion = 1;
public string Format { get; init; } = FormatName;
public int Version { get; init; } = CurrentVersion;
/// <summary>Révision : +1 à chaque publication (couverte par la signature).</summary>
public long Revision { get; init; }
/// <summary>Vrai : maintenance en cours.</summary>
public bool Enabled { get; init; }
/// <summary>
/// Précision facultative affichée aux joueurs sous le texte principal (« Mise à jour des serveurs, retour vers 18 h »).
/// Texte brut, sans lien, quelques lignes au plus (<see cref="MaintenanceRules"/>).
/// </summary>
public string? Message { get; init; }
/// <summary>Début de la maintenance (à la seconde, UTC) ; null quand elle est désactivée. Couvert par la signature.</summary>
public DateTimeOffset? Since { get; init; }
/// <summary>Date de publication (posée par le serveur, hors signature).</summary>
public DateTimeOffset? UpdatedAt { get; init; }
public ContentSignature? Signature { get; init; }
}
/// <summary>État du mode maintenance pour le menu d'administration (GET v1/maintenance).</summary>
public sealed record MaintenanceState
{
/// <summary>Document en vigueur (révision 0, maintenance inactive, si rien n'a été publié).</summary>
public MaintenanceDocument Document { get; init; } = new();
/// <summary>Publication possible (fichier public configuré sur le serveur).</summary>
public bool PublishingEnabled { get; init; }
/// <summary>Adresse publique du fichier (lue par les launchers).</summary>
public string? PublicUrl { get; init; }
/// <summary>Administrateur et appareil de la clé qui a signé la dernière publication, si le serveur la connaît.</summary>
public string? UpdatedBy { get; init; }
}
/// <summary>Publication du mode maintenance (PUT v1/maintenance, requête signée, maintenance.manage).</summary>
public sealed record MaintenancePublishRequest
{
/// <summary>Révision lue avant la modification (409 si une autre publication a eu lieu entre-temps).</summary>
public long BaseRevision { get; init; }
/// <summary>Document signé, révision <see cref="BaseRevision"/> + 1.</summary>
public MaintenanceDocument Document { get; init; } = new();
}
/// <summary>
/// Règles du mode maintenance, communes au menu (contrôle avant de signer), au serveur (refus avant publication) et au
/// launcher des joueurs (refus d'un fichier hors limites, même signé). Le message est du texte brut, déjà nettoyé
/// (<see cref="CleanMessage"/>) et SANS lien : rien ne doit pouvoir renvoyer le joueur vers un site depuis ce bandeau.
/// </summary>
public static class MaintenanceRules
{
/// <summary>Taille de maintenance.json (et de la requête de publication, sous la limite de 64 Ko de l'API).</summary>
public const int MaxBytes = 8 * 1024;
public const int MaxMessageLength = 300;
public const int MaxMessageLines = 6;
/// <summary>Texte principal affiché aux joueurs (non modifiable : toujours présent, même sans message).</summary>
public const string Title = "Maintenance en cours";
public const string Subtitle = "Merci de revenir plus tard.";
/// <summary>Écart toléré entre le début annoncé et l'heure du serveur (horloges des PC).</summary>
public static readonly TimeSpan MaxSinceSkew = TimeSpan.FromMinutes(10);
private static readonly JsonSerializerOptions FileOptions = new(ApiJson.Options) { WriteIndented = true };
private static readonly JsonSerializerOptions CanonicalOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>Rien de publié (révision 0) : maintenance inactive.</summary>
public static MaintenanceDocument Defaults() => new() { Revision = 0, Enabled = false };
/// <summary>Lit maintenance.json (null : vide).</summary>
/// <exception cref="JsonException">JSON illisible ou trop volumineux.</exception>
public static MaintenanceDocument? Parse(ReadOnlySpan<byte> json)
{
if (json.Length > MaxBytes) throw new JsonException($"fichier de maintenance trop volumineux (au plus {MaxBytes / 1024} Ko)");
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
return JsonSerializer.Deserialize<MaintenanceDocument>(json, ApiJson.Options);
}
public static string Serialize(MaintenanceDocument document) => JsonSerializer.Serialize(document, FileOptions);
/// <summary>
/// Condensé signé : format, version, révision, état, message et début (à la seconde). La date de publication et la
/// signature n'en font pas partie.
/// </summary>
public static string Digest(MaintenanceDocument document)
{
var canonical = new CanonicalMaintenance(document.Format ?? "", document.Version, document.Revision, document.Enabled,
string.IsNullOrEmpty(document.Message) ? null : document.Message,
document.Since is { } since ? since.ToUnixTimeSeconds() : null);
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, CanonicalOptions));
}
private sealed record CanonicalMaintenance(string Format, int Version, long Revision, bool Enabled, string? Message, long? Since);
/// <summary>
/// Nettoie le message saisi par l'administrateur (fins de ligne, espaces, lignes vides ; mêmes refus que les demandes
/// des joueurs : caractères invisibles, de mise en forme ou d'inversion du sens d'écriture), puis vérifie les bornes et
/// l'absence de lien. Null si vide ; <paramref name="problem"/> : raison du refus.
/// </summary>
public static string? CleanMessage(string? value, out string? problem)
{
var text = FeedbackRules.CleanMultiline(value, MaxMessageLength, out problem);
if (problem is not null) return null;
if (text is null) return null;
if (text.Count(c => c == '\n') + 1 > MaxMessageLines)
{
problem = $"{MaxMessageLines} lignes au plus.";
return null;
}
if (FeedbackLinks.Contains(text))
{
problem = "aucun lien ni adresse de site n'est accepté (les joueurs ne doivent pas être renvoyés vers un site depuis ce message).";
return null;
}
return text;
}
/// <summary>
/// Contrôle d'un document publié (révision 1 au moins). Les erreurs, en français, sont ajoutées à <paramref name="errors"/>.
/// </summary>
/// <param name="now">Heure de référence (serveur) : le début ne peut pas être dans le futur ; null : non contrôlé (launcher).</param>
public static bool Validate(MaintenanceDocument? document, ICollection<string> errors, DateTimeOffset? now = null)
{
var count = errors.Count;
if (document is null)
{
errors.Add("Document de maintenance absent.");
return false;
}
if (document.Format != MaintenanceDocument.FormatName || document.Version != MaintenanceDocument.CurrentVersion)
errors.Add($"Format du mode maintenance non pris en charge ({document.Format} v{document.Version}).");
if (document.Revision < 1) errors.Add("Révision invalide (1 au moins).");
if (document.Message is { } message)
{
var clean = CleanMessage(message, out var problem);
if (problem is not null) errors.Add($"Message : {problem}");
else if (!string.Equals(clean, message, StringComparison.Ordinal)) errors.Add("Message : texte non nettoyé (espaces, lignes vides).");
}
if (document.Enabled)
{
if (document.Since is not { } since || since == default) errors.Add("Début de la maintenance absent.");
else if (since.UtcTicks % TimeSpan.TicksPerSecond != 0)
errors.Add("Début de la maintenance : à la seconde près.");
else if (now is { } reference && since > reference + MaxSinceSkew)
errors.Add("Début de la maintenance dans le futur : vérifiez l'heure de Windows.");
}
else
{
if (document.Since is not null) errors.Add("Maintenance désactivée : pas de date de début.");
if (document.Message is not null) errors.Add("Maintenance désactivée : pas de message.");
}
return errors.Count == count;
}
/// <summary>Résumé pour l'audit et les journaux (« activée — message : … », « désactivée »).</summary>
public static string Describe(MaintenanceDocument document) =>
document.Enabled
? "activée" + (document.Message is { } message ? $" — message : « {OneLine(message)} »" : " (sans message)")
: "désactivée";
private static string OneLine(string text)
{
var line = text.Replace('\n', ' ');
return line.Length <= 120 ? line : line[..119] + "…";
}
}
@@ -37,9 +37,13 @@ public static class AdminPermissions
/// <summary>Formulaires des demandes (champs proposés aux joueurs) : modification et publication signée.</summary>
public const string FeedbackForms = "feedback.forms";
/// <summary>Mode maintenance du launcher : activation, désactivation et message affiché aux joueurs (publication signée).</summary>
public const string MaintenanceManage = "maintenance.manage";
public static readonly IReadOnlyList<string> All = new[]
{
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage, FeedbackForms
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage, FeedbackForms,
MaintenanceManage
};
/// <summary>Permission intégrée (vérifiée par le code : jamais supprimable).</summary>
@@ -57,6 +61,7 @@ public static class AdminPermissions
FeedbackRead => "Demandes des joueurs (lecture)",
FeedbackManage => "Demandes des joueurs (traitement)",
FeedbackForms => "Formulaires des demandes",
MaintenanceManage => "Mode maintenance",
_ => null
};
@@ -76,6 +81,9 @@ public static class AdminPermissions
"(à donner avec la lecture).",
FeedbackForms => "Modifier, masquer et publier les formulaires du bouton « Aide » (champs proposés aux joueurs). " +
"Publication signée : la clé doit avoir l'autorisation « formulaires » du trousseau.",
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.",
_ => null
};
}
@@ -27,7 +27,8 @@ public static class AdminRoles
{
AdminPermissions.AuditRead, AdminPermissions.GamesEdit,
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish, AdminPermissions.FilesManage,
AdminPermissions.FeedbackRead, AdminPermissions.FeedbackManage, AdminPermissions.FeedbackForms
AdminPermissions.FeedbackRead, AdminPermissions.FeedbackManage, AdminPermissions.FeedbackForms,
AdminPermissions.MaintenanceManage
},
[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)
@@ -29,6 +29,7 @@ public static class ContentKinds
public const string Manifest = "manifest";
public const string Catalog = "catalog";
public const string FeedbackForms = "feedback-forms";
public const string Maintenance = "maintenance";
/// <summary>Identifiant signé d'un catalogue (il n'y en a qu'un).</summary>
public const string CatalogId = "catalog";
@@ -36,11 +37,15 @@ public static class ContentKinds
/// <summary>Identifiant signé des formulaires des demandes (communs à tous les jeux).</summary>
public const string FeedbackFormsId = "feedback-forms";
/// <summary>Identifiant signé du mode maintenance (commun à tous les jeux).</summary>
public const string MaintenanceId = "maintenance";
/// <summary>Autorisation du trousseau requise pour signer ce type de contenu.</summary>
public static string ScopeOf(string kind) => kind switch
{
Manifest => SigningScopes.Releases,
FeedbackForms => SigningScopes.Forms,
Maintenance => SigningScopes.Maintenance,
_ => SigningScopes.Catalog
};
}
@@ -57,7 +62,10 @@ public static class SigningScopes
/// <summary>Signer les formulaires des demandes (champs affichés aux joueurs) : permission feedback.forms.</summary>
public const string Forms = "forms";
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog, Forms };
/// <summary>Signer le mode maintenance (activation, message affiché aux joueurs) : permission maintenance.manage.</summary>
public const string Maintenance = "maintenance";
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog, Forms, Maintenance };
/// <summary>
/// Autorisation bien formée : une autorisation inconnue de cette version (ajoutée par une version plus récente) est
@@ -74,6 +82,7 @@ public static class SigningScopes
if (set.Contains(AdminPermissions.ReleasesPublish)) scopes.Add(Releases);
if (set.Contains(AdminPermissions.GamesEdit)) scopes.Add(Catalog);
if (set.Contains(AdminPermissions.FeedbackForms)) scopes.Add(Forms);
if (set.Contains(AdminPermissions.MaintenanceManage)) scopes.Add(Maintenance);
return scopes;
}
@@ -82,6 +91,7 @@ public static class SigningScopes
Releases => "versions du jeu",
Catalog => "catalogue",
Forms => "formulaires des demandes",
Maintenance => "mode maintenance",
_ => scope
};
}
@@ -219,6 +229,7 @@ public static class ContentSigning
{
ContentKinds.Catalog => ContentKinds.CatalogId,
ContentKinds.FeedbackForms => ContentKinds.FeedbackFormsId,
ContentKinds.Maintenance => ContentKinds.MaintenanceId,
_ => id.ToLowerInvariant()
};
@@ -55,6 +55,12 @@ public sealed record SigningState
/// <summary>Catalogue publié (null : publication du catalogue non configurée).</summary>
public CatalogSignatureState? Catalog { get; init; }
/// <summary>Formulaires des demandes (null : publication des formulaires non configurée, ou serveur antérieur à la 3.2.1).</summary>
public FeedbackFormsSignatureState? FeedbackForms { get; init; }
/// <summary>Mode maintenance (null : publication non configurée, ou serveur antérieur à la 3.3.0).</summary>
public MaintenanceSignatureState? Maintenance { get; init; }
}
public sealed record CatalogSignatureState
@@ -64,3 +70,38 @@ public sealed record CatalogSignatureState
public ContentSignature? Signature { get; init; }
public string? SignerName { get; init; }
}
/// <summary>Signature des formulaires des demandes en vigueur (feedback-forms.json).</summary>
public sealed record FeedbackFormsSignatureState
{
/// <summary>Révision en vigueur (0 : formulaires d'origine, intégrés au launcher, jamais publiés).</summary>
public long Revision { get; init; }
/// <summary>Condensé du contenu signé (vide : formulaires d'origine, ou fichier illisible).</summary>
public string Digest { get; init; } = "";
public ContentSignature? Signature { get; init; }
public string? SignerName { get; init; }
/// <summary>Vrai : le fichier de référence des formulaires est illisible sur le serveur.</summary>
public bool Unreadable { get; init; }
}
/// <summary>Signature du mode maintenance en vigueur (maintenance.json).</summary>
public sealed record MaintenanceSignatureState
{
/// <summary>Révision en vigueur (0 : jamais publié, maintenance inactive).</summary>
public long Revision { get; init; }
/// <summary>Maintenance en cours.</summary>
public bool Enabled { get; init; }
/// <summary>Condensé du contenu signé (vide : jamais publié, ou fichier illisible).</summary>
public string Digest { get; init; } = "";
public ContentSignature? Signature { get; init; }
public string? SignerName { get; init; }
/// <summary>Vrai : le fichier de référence du mode maintenance est illisible sur le serveur.</summary>
public bool Unreadable { get; init; }
}
@@ -0,0 +1,206 @@
using System.Net;
using System.Text.Json;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Core.Trust;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Maintenance;
/// <summary>
/// Mode maintenance publié depuis le menu d'administration (maintenance.json, à côté du trousseau), lu par le launcher
/// des joueurs :
/// • au démarrage, AVANT la recherche des mises à jour des jeux (le launcher l'attend quelques secondes au plus) ;
/// • puis toutes les <see cref="NormalInterval"/> (<see cref="ActiveInterval"/> pendant une maintenance, pour en voir la
/// fin rapidement) et à la demande (publication depuis le menu d'administration de ce PC) ;
/// • fichier borné en taille, contrôlé avec les règles du mode maintenance (même signé, un fichier hors limites est
/// refusé) ; signature vérifiée avec le trousseau (autorisation « maintenance »), retour en arrière refusé (révision
/// et date de signature), selon le mode « contentSigning » du launcher, comme le catalogue.
/// Rien n'est conservé sur le disque (hormis la date de la dernière signature acceptée, dans l'état des signatures) :
/// au démarrage, un serveur injoignable ne bloque jamais le launcher (le jeu installé reste jouable hors ligne). Pendant
/// que le launcher tourne, une coupure ou un fichier refusé garde le dernier état vérifié ; un fichier absent (404)
/// termine la maintenance.
/// Ce n'est pas une barrière de sécurité (un launcher modifié peut l'ignorer) : c'est un affichage pour les joueurs.
/// </summary>
public sealed class MaintenanceService
{
public static readonly TimeSpan NormalInterval = TimeSpan.FromMinutes(2);
public static readonly TimeSpan ActiveInterval = TimeSpan.FromMinutes(1);
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(8);
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
private readonly ContentTrustService? _trust;
private readonly TimeProvider _time;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly object _stateLock = new();
private byte[]? _lastAccepted;
private bool _unreachableLogged;
private MaintenanceDocument? _current;
/// <param name="url">Adresse du fichier publié (null : jamais de maintenance).</param>
/// <param name="trust">Vérification des signatures (null : mode « off », document utilisé sans signature).</param>
/// <param name="time">Horloge (tests).</param>
public MaintenanceService(Func<Uri, long, CancellationToken, Task<byte[]>> download, Uri? url, ContentTrustService? trust,
TimeProvider? time = null)
{
_download = download;
Url = url;
_trust = trust;
_time = time ?? TimeProvider.System;
}
public Uri? Url { get; }
/// <summary>Dernier état accepté (null : rien de connu, ou rien de publié).</summary>
public MaintenanceDocument? Current
{
get
{
lock (_stateLock) return _current;
}
}
/// <summary>Maintenance en cours (dernier état vérifié).</summary>
public bool IsActive => Current is { Enabled: true };
/// <summary>
/// L'état affiché change (début, fin, nouveau message). Levé hors du fil de l'interface : l'appelant s'y ramène.
/// Null : plus de maintenance (fichier absent).
/// </summary>
public event Action<MaintenanceDocument?>? Changed;
/// <summary>Adresse du fichier publié, déduite de celle du trousseau (ou, à défaut, du catalogue) : même dossier.</summary>
public static Uri? PublishedUrl(string? keyringUrl, string? catalogUrl)
{
var baseUrl = keyringUrl ?? catalogUrl;
return baseUrl is not null && Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) ? new Uri(uri, "maintenance.json") : null;
}
/// <summary>Relit le fichier publié. Jamais d'exception (sauf annulation demandée par l'appelant).</summary>
/// <returns>Vrai : maintenance en cours (dernier état vérifié).</returns>
public async Task<bool> RefreshAsync(CancellationToken ct = default)
{
if (Url is null) return false;
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
byte[] bytes;
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(FetchTimeout);
bytes = await _download(Url, MaintenanceRules.MaxBytes, timeout.Token).ConfigureAwait(false);
_unreachableLogged = false;
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
// Rien de publié (ou fichier retiré à la main) : pas de maintenance.
_unreachableLogged = false;
_lastAccepted = null;
Apply(null);
return false;
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
// Réseau, délai dépassé, réponse trop grande : dernier état vérifié conservé, nouvel essai plus tard.
if (!_unreachableLogged)
Log.Warn($"Mode maintenance injoignable : {ex.Message}" + (_current is { Enabled: true } ? " (maintenance toujours affichée)." : "."));
_unreachableLogged = true;
return _current is { Enabled: true };
}
// Même fichier que la dernière fois : déjà vérifié.
if (_lastAccepted is not null && bytes.AsSpan().SequenceEqual(_lastAccepted)) return _current is { Enabled: true };
var document = await AcceptAsync(bytes, ct).ConfigureAwait(false);
if (document is null) return _current is { Enabled: true };
_lastAccepted = bytes;
Apply(document);
return document.Enabled;
}
finally
{
_gate.Release();
}
}
/// <summary>Relecture périodique jusqu'à la fermeture du launcher (rythme plus rapide pendant une maintenance).</summary>
public async Task RunAsync(CancellationToken lifetime)
{
if (Url is null) return;
try
{
while (!lifetime.IsCancellationRequested)
{
await Task.Delay(IsActive ? ActiveInterval : NormalInterval, _time, lifetime).ConfigureAwait(false);
await RefreshAsync(lifetime).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (lifetime.IsCancellationRequested)
{
// fermeture du launcher
}
catch (Exception ex)
{
Log.Error("Relecture du mode maintenance interrompue", ex);
}
}
/// <summary>Fichier lisible, dans les règles, pas plus ancien que l'état connu, à la signature acceptée ; sinon null.</summary>
private async Task<MaintenanceDocument?> AcceptAsync(byte[] bytes, CancellationToken ct)
{
MaintenanceDocument? document;
try
{
document = MaintenanceRules.Parse(bytes);
}
catch (JsonException ex)
{
Log.Error($"Mode maintenance illisible ({ex.Message}) : ignoré.");
return null;
}
var errors = new List<string>();
if (!MaintenanceRules.Validate(document, errors))
{
Log.Error($"Mode maintenance refusé : {errors[0]}");
return null;
}
if (_current is { } current && document!.Revision < current.Revision)
{
Log.Error($"Mode maintenance révision {document.Revision} refusé : plus ancien que la révision {current.Revision} déjà vue (retour en arrière).");
return null;
}
if (_trust is not null && !await _trust.VerifyMaintenanceAsync(document!, ct).ConfigureAwait(false)) return null;
return document;
}
private void Apply(MaintenanceDocument? document)
{
MaintenanceDocument? previous;
lock (_stateLock)
{
previous = _current;
_current = document;
}
if (SameDisplay(previous, document)) return;
Log.Info(document is { Enabled: true }
? $"Mode maintenance ACTIVÉ (révision {document.Revision}) : tout est bloqué sauf la désinstallation et l'administration."
: previous is { Enabled: true } ? "Mode maintenance terminé." : $"Mode maintenance : inactif (révision {document?.Revision ?? 0}).");
try
{
Changed?.Invoke(document);
}
catch (Exception ex)
{
Log.Error("Application du mode maintenance", ex);
}
}
/// <summary>Même affichage pour le joueur (état, message, début) : rien à signaler.</summary>
private static bool SameDisplay(MaintenanceDocument? a, MaintenanceDocument? b)
{
var activeA = a is { Enabled: true };
var activeB = b is { Enabled: true };
if (!activeA && !activeB) return true;
return activeA == activeB && a!.Message == b!.Message && a.Since == b.Since;
}
}
@@ -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)
{
@@ -15,7 +15,7 @@ public sealed class LauncherConfig
public string GameName { get; set; } = "Mon Jeu";
public string Publisher { get; set; } = "Xbhost";
public string Publisher { get; set; } = "LanisysGames";
public string Tagline { get; set; } = "";
@@ -50,9 +50,58 @@ public sealed partial class LauncherRelease
/// </summary>
public List<ChangelogEntry> Changelog { get; set; } = new();
/// <summary>
/// Version PRIORITAIRE la plus récente publiée jusqu'à celle-ci incluse (ex. "3.4.0"), ou null s'il n'y en a pas.
/// Un launcher plus ancien que cette version installe la mise à jour au démarrage MÊME SI le joueur a désactivé
/// la mise à jour automatique (correctif critique). Le réglage du joueur (self-update.json) n'est jamais modifié :
/// une fois le launcher à jour, il n'est plus concerné (effet ponctuel).
/// Reprise d'une publication à l'autre par LauncherPublisher (voir <see cref="ResolvePriorityVersion"/>) : une
/// version normale publiée après un correctif critique l'impose toujours aux launchers restés trop anciens.
/// Fait partie du contenu SIGNÉ : un serveur compromis ne peut ni l'ajouter, ni la retirer, ni la modifier.
/// Ignorée par les launchers antérieurs à 3.4.0 (propriété inconnue).
/// </summary>
public string? PriorityVersion { get; set; }
[JsonIgnore]
public Version ParsedVersion => VersionUtil.Parse(Version);
/// <summary>Cette version a elle-même été publiée comme prioritaire (option --priority).</summary>
[JsonIgnore]
public bool IsPriorityRelease => TryGetPriorityVersion(out var priority) && priority == ParsedVersion;
/// <summary>
/// Vrai si un launcher en version <paramref name="currentVersion"/> doit installer cette mise à jour même avec la
/// mise à jour automatique désactivée : il est plus ancien que la dernière version prioritaire publiée.
/// </summary>
public bool IsPriorityFor(Version currentVersion) =>
TryGetPriorityVersion(out var priority) && priority > currentVersion;
/// <summary>Version prioritaire lisible (null ou illisible : faux).</summary>
public bool TryGetPriorityVersion(out Version priority)
{
priority = new Version(0, 0, 0);
return !string.IsNullOrWhiteSpace(PriorityVersion) && VersionUtil.TryParse(PriorityVersion, out priority);
}
/// <summary>
/// Version prioritaire à inscrire dans une nouvelle publication (LauncherPublisher) :
/// <list type="bullet">
/// <item><paramref name="markPriority"/> : la version publiée elle-même ;</item>
/// <item><paramref name="clearPriority"/> : aucune (priorité publiée par erreur, par exemple) ;</item>
/// <item>sinon : celle de la publication précédente (<paramref name="previous"/>, dont la signature a été
/// VÉRIFIÉE par l'appelant), si elle n'est pas supérieure à la version publiée.</item>
/// </list>
/// </summary>
public static string? ResolvePriorityVersion(Version published, bool markPriority, bool clearPriority, LauncherRelease? previous)
{
if (markPriority && clearPriority)
throw new ArgumentException("Une publication ne peut pas à la fois être prioritaire et retirer la priorité.");
if (markPriority) return VersionUtil.Display(published);
if (clearPriority || previous is null || !previous.TryGetPriorityVersion(out var carried) || carried > published)
return null;
return VersionUtil.Display(carried);
}
public void Validate()
{
if (Format != 1)
@@ -67,6 +116,12 @@ public sealed partial class LauncherRelease
throw new UpdateSecurityException("Description de mise à jour invalide : taille incorrecte.");
if (!Sha256Regex().IsMatch(Sha256 ?? ""))
throw new UpdateSecurityException("Description de mise à jour invalide : empreinte SHA-256 incorrecte.");
if (string.IsNullOrWhiteSpace(PriorityVersion)) PriorityVersion = null;
else if (!VersionUtil.TryParse(PriorityVersion, out var priority))
throw new UpdateSecurityException($"Description de mise à jour invalide : version prioritaire « {PriorityVersion} » illisible.");
else if (priority > ParsedVersion)
throw new UpdateSecurityException(
$"Description de mise à jour invalide : version prioritaire {PriorityVersion} supérieure à la version publiée ({Version}).");
Notes ??= new List<string>();
// Textes affichés tels quels dans l'interface : mêmes bornes et nettoyage que les notes du jeu.
Changelog = ChangelogLimits.Normalize(Changelog);
@@ -10,6 +10,12 @@ public sealed record LauncherUpdateCheck(Version CurrentVersion, LauncherRelease
public bool IsAvailable => Release is not null && DownloadUri is not null;
public string CurrentDisplay => VersionUtil.Display(CurrentVersion);
public string AvailableDisplay => Release is null ? "" : VersionUtil.Display(Release.ParsedVersion);
/// <summary>
/// Mise à jour PRIORITAIRE pour ce launcher (plus ancien que la dernière version prioritaire publiée) :
/// installée au démarrage même si la mise à jour automatique est désactivée. Information signée.
/// </summary>
public bool IsPriority => IsAvailable && Release!.IsPriorityFor(CurrentVersion);
}
/// <summary>
@@ -2,6 +2,7 @@ using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
@@ -40,8 +41,23 @@ public sealed class ContentSecurityException : Exception
public sealed class ContentTrustService
{
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
private static readonly TimeSpan UnknownKeyRetryDelay = TimeSpan.FromMinutes(5);
/// <summary>Nouvel essai après un échec réseau (trousseau jamais obtenu pendant ce démarrage).</summary>
public static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
/// <summary>
/// Nouveau téléchargement, au plus à ce rythme, quand un contenu est signé par une clé absente du trousseau ou non
/// autorisée pour ce contenu : clé ajoutée ou autorisation donnée (ex. « maintenance ») pendant que le launcher tourne.
/// Plus court que la relecture du mode maintenance, pour qu'une activation juste après la republication du trousseau
/// soit vue à la relecture suivante.
/// </summary>
public static readonly TimeSpan KeyChangeRetryDelay = TimeSpan.FromMinutes(1);
/// <summary>
/// Âge maximal du trousseau en mémoire : au-delà, il est retéléchargé à la vérification suivante (une clé retirée ou
/// une autorisation enlevée est prise en compte sans redémarrer le launcher).
/// </summary>
public static readonly TimeSpan KeyringMaxAge = TimeSpan.FromMinutes(30);
private static readonly TimeSpan MaxFutureSignedAt = TimeSpan.FromDays(1);
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
@@ -81,12 +97,15 @@ public sealed class ContentTrustService
/// <summary>
/// Trousseau à utiliser : celui du serveur s'il est valide et pas plus ancien que le dernier vu, sinon le dernier
/// vu (cache), sinon null. Téléchargé une fois par démarrage (nouvel essai après un échec réseau, ou après quelques
/// minutes quand un contenu est signé par une clé encore inconnue : clé ajoutée pendant que le launcher tourne).
/// vu (cache), sinon null. Téléchargé au démarrage, puis de nouveau :
/// • après un échec réseau (<see cref="RetryDelay"/>) ;
/// • quand un contenu est signé par une clé absente ou non autorisée pour ce contenu (<see cref="KeyChangeRetryDelay"/>) ;
/// • quand il a plus de <see cref="KeyringMaxAge"/> (launcher ouvert longtemps).
/// Chaque nouveau trousseau passe par les mêmes contrôles (signature racine, numéro jamais en baisse).
/// </summary>
public Task<KeyringDocument?> GetKeyringAsync(CancellationToken ct = default) => GetKeyringAsync(false, ct);
private async Task<KeyringDocument?> GetKeyringAsync(bool unknownKey, CancellationToken ct)
private async Task<KeyringDocument?> GetKeyringAsync(bool keyChanged, CancellationToken ct)
{
Task<KeyringDocument?> task;
lock (_lock)
@@ -94,7 +113,8 @@ public sealed class ContentTrustService
var elapsed = _time.GetUtcNow() - _lastAttempt;
if (_keyringTask is null
|| (_keyringTask.IsCompleted && !_keyringFetched && elapsed > RetryDelay)
|| (_keyringTask.IsCompleted && unknownKey && elapsed > UnknownKeyRetryDelay))
|| (_keyringTask.IsCompleted && keyChanged && elapsed > KeyChangeRetryDelay)
|| (_keyringTask.IsCompleted && elapsed > KeyringMaxAge))
{
_lastAttempt = _time.GetUtcNow();
_keyringTask = FetchKeyringAsync();
@@ -150,7 +170,7 @@ public sealed class ContentTrustService
}
catch (Exception ex) when (ex is InvalidDataException or JsonException or NotSupportedException or FormatException)
{
lock (_lock) _keyringFetched = true; // contenu refusé : pas de nouvel essai pendant ce démarrage
lock (_lock) _keyringFetched = true; // contenu refusé : nouvel essai seulement aux conditions de GetKeyringAsync
Log.Error($"Trousseau du serveur refusé : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu reste utilisé)."));
return cached;
}
@@ -210,6 +230,26 @@ public sealed class ContentTrustService
}
}
/// <summary>
/// Vérifie le mode maintenance téléchargé (maintenance.json) : clé du trousseau autorisée « maintenance », signature
/// valide, pas plus ancien que le dernier accepté (un serveur compromis ne peut pas resservir une ancienne maintenance
/// légitime). Faux : à ignorer (état précédent conservé).
/// </summary>
public async Task<bool> VerifyMaintenanceAsync(MaintenanceDocument maintenance, CancellationToken ct = default)
{
if (Mode == ContentSigningMode.Off) return true;
try
{
await VerifyAsync(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(maintenance), maintenance.Signature,
$"Mode maintenance (révision {maintenance.Revision}, {MaintenanceRules.Describe(maintenance)})", ct).ConfigureAwait(false);
return true;
}
catch (ContentSecurityException)
{
return false;
}
}
/// <summary>Revérifie les formulaires en cache, sans réseau, avec le dernier trousseau vérifié (fichier modifié sur le disque : ignoré).</summary>
public bool VerifyCachedFeedbackForms(FeedbackFormsDocument forms)
{
@@ -253,8 +293,9 @@ public sealed class ContentTrustService
private async Task VerifyAsync(string kind, string id, string digest, ContentSignature? signature, string what, CancellationToken ct)
{
var keyring = await GetKeyringAsync(false, ct).ConfigureAwait(false);
// Clé inconnue (ajoutée depuis le démarrage) : nouveau téléchargement du trousseau, limité dans le temps.
if (keyring is not null && signature?.KeyId is { } keyId && !keyring.Keys.Any(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal)))
// Clé absente du trousseau, ou présente sans l'autorisation de ce contenu (clé ajoutée, ou autorisation donnée,
// depuis le dernier téléchargement) : nouveau téléchargement du trousseau, limité dans le temps.
if (keyring is not null && KeyMayBeNewer(keyring, kind, signature))
keyring = await GetKeyringAsync(true, ct).ConfigureAwait(false);
string problem;
@@ -301,6 +342,14 @@ public sealed class ContentTrustService
if (!Report(what, problem)) throw new ContentSecurityException($"{what} : {problem}.");
}
/// <summary>La signature vient d'une clé que ce trousseau ne connaît pas, ou n'autorise pas pour ce contenu.</summary>
private static bool KeyMayBeNewer(KeyringDocument keyring, string kind, ContentSignature? signature)
{
if (signature?.KeyId is not { } keyId) return false;
var key = keyring.Keys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal));
return key?.Scopes is not { } scopes || !scopes.Contains(ContentKinds.ScopeOf(kind), StringComparer.Ordinal);
}
// ------------------------------------------------------------------ État
private sealed class TrustState
@@ -0,0 +1,890 @@
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,
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 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();
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);
_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(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 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.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.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",
_ => "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();
}
}
/// <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. La réinitialisation par e-mail arrivera dans une prochaine version.";
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.PasswordChangeRequired)
{
_changeToken = result.PasswordChangeToken;
_changeCurrentPassword = password; // exigé par le serveur pour le changement ; oublié ensuite
_changeStarted = DateTime.UtcNow;
GoTo(AccountStep.PasswordChange);
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 =>
$"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.";
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;
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 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);
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();
}
}
/// <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,502 @@
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é, 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>
public sealed record PlayerSignInResult(bool PasswordChangeRequired, string? PasswordChangeToken = null);
/// <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>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);
if (response.Status == PlayerLoginStatus.PasswordChangeRequired && response.PasswordChangeToken is { } token)
return new PlayerSignInResult(true, token);
Adopt(response.Session ?? throw new ApiException(0, "invalid_response", "Réponse du serveur illisible."), key, remember);
Log.Info("Compte : connecté.");
return new PlayerSignInResult(false);
}
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");
}
@@ -58,6 +58,7 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
new("files.", "Fichiers du serveur"),
new("keyring.", "Signature (trousseau)"),
new("feedback.", "Demandes des joueurs"),
new("maintenance.", "Mode maintenance"),
new("audit.", "Journal d'audit (consultations, exports, purges)"),
new("security", "Refus de sécurité")
};
@@ -504,6 +505,12 @@ public static class AuditTexts
"feedback.delete" => "Demande de joueur supprimée",
"feedback.purge" => "Anciennes demandes supprimées (conservation)",
"feedback.forms" => "Formulaires des demandes publiés",
"feedback.form_create" => "Type de demande ajouté",
"feedback.form_update" => "Type de demande modifié",
"feedback.form_delete" => "Type de demande supprimé",
"maintenance.enable" => "Mode maintenance activé",
"maintenance.disable" => "Mode maintenance terminé",
"maintenance.update" => "Message de la maintenance modifié",
"files.delete" => "Fichier du serveur supprimé",
"files.prune" => "Dossier « objects » nettoyé",
"request.signature" => "Requête signée refusée",
@@ -0,0 +1,26 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Maintenance;
/// <summary>
/// Module « Maintenance » : page d'activation et de désactivation du mode maintenance du launcher (commun à tous les
/// jeux), avec un message facultatif pour les joueurs. Permission maintenance.manage ; publication signée (autorisation
/// « maintenance » du trousseau, vérifiée par les launchers). L'état est aussi rappelé au tableau de bord.
/// </summary>
public sealed class MaintenanceAdminModule : ILauncherModule
{
public string Id => "maintenance";
public string DisplayName => "Maintenance";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
MaintenancePanelViewModel.PanelId,
"Maintenance",
"Mode maintenance de tous les launchers : thème rouge / orange, message au centre, tout est bloqué chez les joueurs " +
"sauf la désinstallation des jeux et ce menu.",
"TrafficCone",
AdminPermissions.MaintenanceManage,
Order: 5,
context => new MaintenancePanelViewModel(context)));
}
@@ -0,0 +1,250 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Signing;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Maintenance;
/// <summary>
/// Page « Maintenance » (maintenance.manage) : état du mode maintenance de tous les launchers, activation (message
/// facultatif, confirmation), modification du message pendant la maintenance, désactivation (confirmation).
/// • Chaque publication est signée par la clé de ce PC (autorisation « maintenance » du trousseau) : avertissement si
/// les launchers des joueurs ne l'accepteront pas (<see cref="AdminPanelViewModel.ConfirmPlayerSigningAsync"/>).
/// • Message contrôlé ici avec les règles partagées (le serveur et les launchers les réappliquent) : texte brut, sans lien.
/// • Après une publication, le launcher de ce PC relit aussitôt le mode maintenance (<see cref="IAdminContext.NotifyMaintenanceChanged"/>).
/// </summary>
public sealed class MaintenancePanelViewModel : AdminPanelViewModel
{
public const string PanelId = "maintenance";
private MaintenanceState? _state;
private string _message = "";
private string? _messageError;
private string? _statusMessage;
public MaintenancePanelViewModel(IAdminContext context) : base(context)
{
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(ReloadAsync)), () => !IsBusy);
EnableCommand = new AsyncRelayCommand(ScrollingToTop(EnableAsync), () => !IsBusy && CanPublish && !IsActive);
UpdateMessageCommand = new AsyncRelayCommand(ScrollingToTop(UpdateMessageAsync), () => !IsBusy && CanPublish && IsActive && IsMessageChanged);
DisableCommand = new AsyncRelayCommand(ScrollingToTop(DisableAsync), () => !IsBusy && CanPublish && IsActive);
}
public AsyncRelayCommand RefreshCommand { get; }
public AsyncRelayCommand EnableCommand { get; }
public AsyncRelayCommand UpdateMessageCommand { get; }
public AsyncRelayCommand DisableCommand { get; }
// ------------------------------------------------------------------ État publié
public bool IsLoaded => _state is not null;
/// <summary>Maintenance en cours (état publié).</summary>
public bool IsActive => _state?.Document.Enabled == true;
/// <summary>Publication possible : état chargé et fichier public configuré sur le serveur.</summary>
public bool CanPublish => _state is { PublishingEnabled: true };
public bool ShowPublishingDisabled => _state is { PublishingEnabled: false };
public string? PublicUrl => _state?.PublicUrl;
public string StateTitle => _state is null ? "Chargement…" : IsActive ? "Maintenance en cours" : "Launchers accessibles";
public string StateText
{
get
{
if (_state is not { } state) return "";
var document = state.Document;
var by = state.UpdatedBy is { } name ? $" par {name}" : "";
if (document.Revision == 0) return "Le mode maintenance n'a jamais été activé.";
if (document.Enabled)
return (document.Since is { } since ? $"Depuis le {AccountTexts.Date(since)}" : "En cours") + by +
(document.UpdatedAt is { } updated && document.Since is { } start && updated - start > TimeSpan.FromMinutes(1)
? $" · message modifié le {AccountTexts.Date(updated)}"
: "") + ".";
return (document.UpdatedAt is { } at ? $"Dernière maintenance terminée le {AccountTexts.Date(at)}" : "Maintenance terminée") + by + ".";
}
}
/// <summary>Message publié (pendant la maintenance), sinon null.</summary>
public string? PublishedMessage => IsActive ? _state!.Document.Message : null;
// ------------------------------------------------------------------ Saisie
/// <summary>Précision affichée aux joueurs sous « Maintenance en cours — merci de revenir plus tard » (facultative).</summary>
public string Message
{
get => _message;
set
{
if (!SetProperty(ref _message, value ?? "")) return;
MaintenanceRules.CleanMessage(_message, out var problem);
MessageError = problem is null ? null : $"Message : {problem}";
OnPropertyChanged(nameof(MessageCounter));
OnPropertyChanged(nameof(PreviewMessage));
OnPropertyChanged(nameof(IsMessageChanged));
RefreshCommands();
}
}
public string? MessageError
{
get => _messageError;
private set
{
if (SetProperty(ref _messageError, value)) OnPropertyChanged(nameof(HasMessageError));
}
}
public bool HasMessageError => _messageError is not null;
public string MessageCounter => $"{_message.Trim().Length} / {MaintenanceRules.MaxMessageLength}";
public int MaxMessageLength => MaintenanceRules.MaxMessageLength;
/// <summary>Le message saisi diffère de celui publié (bouton « Modifier le message »).</summary>
public bool IsMessageChanged
{
get
{
var clean = MaintenanceRules.CleanMessage(_message, out var problem);
return problem is null && !string.Equals(clean, PublishedMessage, StringComparison.Ordinal);
}
}
// ------------------------------------------------------------------ Aperçu (textes affichés aux joueurs)
public string PreviewTitle => MaintenanceRules.Title;
public string PreviewSubtitle => MaintenanceRules.Subtitle;
/// <summary>Message tel que les joueurs le verront (nettoyé), sinon null.</summary>
public string? PreviewMessage => MaintenanceRules.CleanMessage(_message, out _);
// ------------------------------------------------------------------ Bandeau de résultat
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(_statusMessage);
// ------------------------------------------------------------------ Chargement
protected override void OnBusyChanged() => RefreshCommands();
private void RefreshCommands()
{
RefreshCommand.RaiseCanExecuteChanged();
EnableCommand.RaiseCanExecuteChanged();
UpdateMessageCommand.RaiseCanExecuteChanged();
DisableCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => RunAsync(ReloadAsync);
private async Task ReloadAsync() => Apply(await Context.Session.GetMaintenanceAsync(), resetMessage: true);
private void Apply(MaintenanceState state, bool resetMessage)
{
_state = state;
if (resetMessage) Message = state.Document.Enabled ? state.Document.Message ?? "" : _message;
foreach (var name in new[]
{
nameof(IsLoaded), nameof(IsActive), nameof(CanPublish), nameof(ShowPublishingDisabled), nameof(PublicUrl),
nameof(StateTitle), nameof(StateText), nameof(PublishedMessage), nameof(IsMessageChanged)
})
OnPropertyChanged(name);
RefreshCommands();
}
// ------------------------------------------------------------------ Actions
/// <summary>Message nettoyé, ou null avec l'erreur affichée (sous le champ).</summary>
private bool TryCleanMessage(out string? clean)
{
clean = MaintenanceRules.CleanMessage(_message, out var problem);
MessageError = problem is null ? null : $"Message : {problem}";
return problem is null;
}
private async Task EnableAsync()
{
if (_state is not { } state) return;
StatusMessage = null;
if (!TryCleanMessage(out var message)) return;
if (!Context.Dialogs.Confirm("Activer le mode maintenance ?",
"Tous les launchers des joueurs passeront en mode maintenance au plus tard dans 2 minutes (ceux qui démarrent : " +
"aussitôt), pour tous les jeux :\n\n" +
"• installation, mises à jour, vérification, réparation et lancement des jeux bloqués ;\n" +
"• paramètres du launcher et des jeux, bouton « Aide » et site officiel indisponibles ;\n" +
"• seule la désinstallation reste possible, et ce menu d'administration.\n\n" +
"Les téléchargements déjà commencés se terminent et les parties en cours ne sont pas fermées. Les mises à jour du " +
"launcher lui-même continuent.",
"Activer la maintenance", danger: true))
return;
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Activer quand même")) return;
await PublishAsync(enabled: true, message, since: null, state.Document.Revision,
"Mode maintenance activé : les launchers des joueurs l'affichent au plus tard dans 2 minutes.");
}
private async Task UpdateMessageAsync()
{
if (_state is not { Document: { Enabled: true } document }) return;
StatusMessage = null;
if (!TryCleanMessage(out var message)) return;
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Modifier quand même")) return;
await PublishAsync(enabled: true, message, document.Since, document.Revision,
"Message de la maintenance modifié : les launchers des joueurs l'affichent au plus tard dans une minute.");
}
private async Task DisableAsync()
{
if (_state is not { Document: { Enabled: true } document }) return;
StatusMessage = null;
if (!Context.Dialogs.Confirm("Terminer la maintenance ?",
"Les launchers des joueurs redeviendront utilisables au plus tard dans une minute (installation, mises à jour, " +
"lancement des jeux) ; ils rechercheront aussitôt les mises à jour des jeux.",
"Terminer la maintenance"))
return;
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Terminer quand même")) return;
await PublishAsync(enabled: false, message: null, since: null, document.Revision,
"Maintenance terminée : les launchers des joueurs redeviennent utilisables au plus tard dans une minute.");
}
private async Task PublishAsync(bool enabled, string? message, DateTimeOffset? since, long baseRevision, string success)
{
MaintenanceState? published = null;
var ok = await RunAsync(async () =>
published = await Context.Session.PublishMaintenanceAsync(enabled, message, since, baseRevision));
if (!ok)
{
// Publication par quelqu'un d'autre entre-temps (409) : état relu, la saisie est gardée.
if (ErrorMessage is not null) await ReloadKeepingMessageAsync();
return;
}
Apply(published!, resetMessage: true);
if (!enabled) Message = "";
StatusMessage = success;
Context.NotifyMaintenanceChanged(); // ce launcher bascule tout de suite
}
private async Task ReloadKeepingMessageAsync()
{
try
{
Apply(await Context.Session.GetMaintenanceAsync(), resetMessage: false);
}
catch (Exception ex) when (ex is ApiException or HttpRequestException or TaskCanceledException)
{
// L'erreur de la publication reste affichée ; « Recharger » permet de réessayer.
}
}
}
@@ -9,7 +9,8 @@ namespace GameLauncher.Modules.Admin.Signing;
/// <summary>
/// Page « Signature » (propriétaires) : trousseau publié (vérifié avec la clé racine intégrée à l'exe), trousseau
/// proposé d'après les appareils actifs autorisés à publier, état des signatures du contenu en ligne.
/// proposé d'après les appareils actifs autorisés à publier, état des signatures du contenu en ligne (versions en
/// ligne, catalogue, formulaires des demandes, mode maintenance).
/// La clé racine n'est JAMAIS ouverte par le launcher : la demande est exportée, signée hors ligne avec
/// tools\sign-keyring.bat, puis le trousseau signé est publié ici.
/// </summary>
@@ -193,6 +194,29 @@ public sealed class SigningPanelViewModel : AdminPanelViewModel
items.Add(new SignedContentItem($"Catalogue (révision {catalog.Revision})", ContentKinds.Catalog, ContentKinds.CatalogId,
catalog.Digest, catalog.Signature, catalog.SignerName, state.Keyring, proposed,
"Republiez-le depuis la page « Jeux » (il est signé à chaque publication)."));
if (state.FeedbackForms is { } forms)
items.Add(forms.Unreadable
? SignedContentItem.Failed("Formulaires des demandes",
"Fichier des formulaires illisible sur le serveur (voir ses journaux) : restaurez-le depuis feedback-forms-history/ " +
"dans les données privées du serveur.")
: new SignedContentItem(
forms.Revision > 0 ? $"Formulaires des demandes (révision {forms.Revision})" : "Formulaires des demandes",
ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, forms.Digest, forms.Signature, forms.SignerName, state.Keyring, proposed,
"Republiez-les depuis la page « Formulaires des demandes » (ils sont signés à chaque publication).",
"Formulaires d'origine, intégrés au launcher : rien à signer tant que rien n'est publié."));
if (state.Maintenance is { } maintenance)
items.Add(maintenance.Unreadable
? SignedContentItem.Failed("Mode maintenance",
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/ " +
"dans les données privées du serveur.")
: new SignedContentItem(
maintenance.Revision > 0
? $"Mode maintenance (révision {maintenance.Revision}, {(maintenance.Enabled ? "en cours" : "inactif")})"
: "Mode maintenance",
ContentKinds.Maintenance, ContentKinds.MaintenanceId, maintenance.Digest, maintenance.Signature, maintenance.SignerName,
state.Keyring, proposed,
"Republiez-le depuis la page « Maintenance » (il est signé à chaque activation, modification ou fin).",
"Jamais activé : rien à signer tant que rien n'est publié."));
return items;
}
@@ -319,12 +343,12 @@ public sealed class KeyringKeyItem
public sealed class SignedContentItem
{
internal SignedContentItem(string title, string kind, string id, string digest, ContentSignature? signature, string? signerName,
KeyringDocument? published, KeyringDocument proposed, string howToSign)
KeyringDocument? published, KeyringDocument proposed, string howToSign, string nothingPublished = "Rien n'est publié.")
{
Title = title;
if (string.IsNullOrEmpty(digest))
{
Text = "Rien n'est publié.";
Text = nothingPublished;
IsValid = true;
return;
}
@@ -342,6 +366,15 @@ public sealed class SignedContentItem
Text += $" Attention : sa clé ne sera plus dans le trousseau proposé. {howToSign}";
}
private SignedContentItem(string title, string text)
{
Title = title;
Text = text;
}
/// <summary>Contenu dont l'état ne peut pas être lu (refusé, à corriger sur le serveur).</summary>
internal static SignedContentItem Failed(string title, string text) => new(title, text);
public string Title { get; }
public string Text { get; }
@@ -47,6 +47,12 @@ public sealed class AdminEnvironment
/// </summary>
public PlayerSigningCheck? PlayerSigning { get; init; }
/// <summary>
/// Appelé après une publication du mode maintenance (page « Maintenance ») : le launcher de ce PC le relit aussitôt.
/// Null : rien à faire (tests).
/// </summary>
public Action? MaintenanceChanged { get; init; }
/// <summary>Puce de sécurité (TPM) de ce PC ; null : clés TPM non proposées.</summary>
public IAdminTpm? Tpm { get; init; }
@@ -127,6 +127,8 @@ public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, I
public string LauncherVersion => _shell.Environment.LauncherVersion;
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys => _shell.Environment.RootKeys;
public PlayerSigningCheck? PlayerSigning => _shell.Environment.PlayerSigning;
public void NotifyMaintenanceChanged() => _shell.Environment.MaintenanceChanged?.Invoke();
public IReadOnlyList<AdminPanelDescriptor> Panels { get; }
public IDisposable BeginLongOperation() => Guard.BeginLongOperation();
@@ -2,6 +2,7 @@ using GameLauncher.Contracts.Security;
using GameLauncher.ApiClient;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Feedback;
using GameLauncher.Modules.Admin.Maintenance;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
@@ -34,6 +35,45 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
OpenAdminsCommand = new RelayCommand(() => Context.Navigate(AdminsPanelViewModel.PanelId), () => CanOpenAdmins);
OpenDevicesCommand = new RelayCommand(() => Context.Navigate(MyDevicesPanelViewModel.PanelId), () => CanOpenDevices);
OpenFeedbackCommand = new RelayCommand(() => Context.Navigate(FeedbackPanelViewModel.PanelId), () => CanReadFeedback);
OpenMaintenanceCommand = new RelayCommand(() => Context.Navigate(MaintenancePanelViewModel.PanelId), () => CanOpenMaintenance);
}
// ------------------------------------------------------------------ Mode maintenance
/// <summary>Page « Maintenance » présente (permission maintenance.manage).</summary>
public bool CanOpenMaintenance => Context.Panels.Any(p => p.Id == MaintenancePanelViewModel.PanelId);
public RelayCommand OpenMaintenanceCommand { get; }
private string? _maintenanceText;
/// <summary>Bandeau « Maintenance en cours depuis … » (null : pas de maintenance, ou serveur antérieur à la 3.3.0).</summary>
public string? MaintenanceText
{
get => _maintenanceText;
private set
{
if (SetProperty(ref _maintenanceText, value)) OnPropertyChanged(nameof(IsMaintenanceActive));
}
}
public bool IsMaintenanceActive => _maintenanceText is not null;
private async Task RefreshMaintenanceAsync()
{
try
{
var state = await Context.Session.GetMaintenanceAsync();
MaintenanceText = state.Document is { Enabled: true } document
? "Maintenance en cours" + (document.Since is { } since ? $" depuis le {AccountTexts.Date(since)}" : "") +
(state.UpdatedBy is { } by ? $" (activée par {by})" : "") +
" : les joueurs ne peuvent ni installer, ni mettre à jour, ni lancer les jeux."
: null;
}
catch (ApiException ex) when (!ApiErrorText.IsSessionLost(ex))
{
MaintenanceText = null; // serveur antérieur (404) ou fichier illisible : rien au tableau de bord
}
}
// ------------------------------------------------------------------ Demandes des joueurs
@@ -182,6 +222,7 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
ActiveInvitations = invitations.Count;
}
if (CanReadFeedback) await RefreshFeedbackAsync();
await RefreshMaintenanceAsync();
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
});
}
@@ -36,6 +36,12 @@ public interface IAdminContext
/// </summary>
PlayerSigningCheck? PlayerSigning => null;
/// <summary>
/// Le mode maintenance vient d'être publié depuis le menu : le launcher de ce PC le relit aussitôt (sans attendre sa
/// relecture périodique). Sans effet par défaut (tests).
/// </summary>
void NotifyMaintenanceChanged() { }
/// <summary>Pages visibles pour cet administrateur.</summary>
IReadOnlyList<AdminPanelDescriptor> Panels { get; }
@@ -42,7 +42,8 @@ public sealed class PlayerSigningCheck
/// Texte de l'avertissement à afficher avant de publier un contenu de ce type signé par cette clé, ou null si les
/// launchers des joueurs l'accepteront (ou ne vérifient pas les signatures). Ne lève pas d'exception (sauf annulation).
/// </summary>
/// <param name="kind"><see cref="ContentKinds.Catalog"/>, <see cref="ContentKinds.Manifest"/> ou <see cref="ContentKinds.FeedbackForms"/>.</param>
/// <param name="kind"><see cref="ContentKinds.Catalog"/>, <see cref="ContentKinds.Manifest"/>, <see cref="ContentKinds.FeedbackForms"/>
/// ou <see cref="ContentKinds.Maintenance"/>.</param>
/// <param name="publicKey">Clé publique (SPKI) de ce PC : celle qui signera, lue localement.</param>
public async Task<string?> WarningAsync(string kind, byte[] publicKey, CancellationToken ct = default)
{
@@ -111,6 +112,9 @@ public sealed class PlayerSigningCheck
? kind == ContentKinds.FeedbackForms
? "Les launchers des joueurs (signatures obligatoires) ignoreront ces formulaires : la fenêtre « Aide » proposera " +
"les derniers formulaires acceptés, ou ceux d'origine."
: kind == ContentKinds.Maintenance
? "Les launchers des joueurs (signatures obligatoires) ignoreront ce changement : ils garderont l'état du mode " +
"maintenance qu'ils connaissent (une maintenance ne s'afficherait pas, ou ne se terminerait qu'à leur redémarrage)."
: catalog
? "Les launchers des joueurs (signatures obligatoires) ignoreront ce catalogue : ils garderont le précédent " +
"(celui intégré au launcher pour une installation neuve)."
+156 -5
View File
@@ -7,6 +7,7 @@ using System.Windows.Media;
using System.Windows.Threading;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Core.Catalog;
using GameLauncher.Core.Maintenance;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Services;
@@ -20,6 +21,7 @@ using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.Feedback;
using GameLauncher.Modules.Admin.Files;
using GameLauncher.Modules.Admin.Maintenance;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.Signing;
using GameLauncher.Platform;
@@ -57,6 +59,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;
@@ -64,6 +70,13 @@ public partial class App : Application, ILauncherRestartHost
private string? _keyringUrl;
private PlayerSigningCheck? _playerSigning;
private Task<CatalogDocument?>? _catalogFetch;
/// <summary>Mode maintenance publié depuis le menu d'administration (null : aucune adresse de trousseau ni de catalogue).</summary>
private MaintenanceService? _maintenance;
private Task<bool>? _maintenanceFetch;
/// <summary>Annulé à la fermeture du launcher (relecture périodique du mode maintenance).</summary>
private readonly CancellationTokenSource _lifetime = new();
private string _launcherPath = "";
private Version _version = new(1, 0, 0);
@@ -172,6 +185,11 @@ public partial class App : Application, ILauncherRestartHost
_trust = CreateContentTrust(catalog);
if (_trust is not null && args.CaptureDirectory is null && !args.Uninstall) _ = _trust.GetKeyringAsync();
// Mode maintenance (publié depuis le menu d'administration) : lu dès maintenant, en parallèle du catalogue et de la
// mise à jour du launcher, puis attendu quelques secondes au plus AVANT la recherche des mises à jour des jeux.
var maintenanceStarted = DateTime.UtcNow;
StartMaintenance(catalog, download: args.CaptureDirectory is null);
// Catalogue des jeux (informations modifiables publiées depuis le menu d'administration) :
// dernier catalogue en cache appliqué tout de suite ; version à jour téléchargée pendant la
// vérification de mise à jour du launcher (pas de téléchargement pour --capture et --uninstall).
@@ -222,7 +240,8 @@ public partial class App : Application, ILauncherRestartHost
else if (normalStart && _updates.IsEnabled && (!_updates.AutoUpdate || _updates.SkippedUpdate is not null))
_ = NotifyLauncherUpdateAsync();
// 5. Détection des installations + mises à jour des jeux
// 5. Détection des installations + mises à jour des jeux (après le mode maintenance, qui peut les bloquer)
if (args.CaptureDirectory is null) await ApplyStartupMaintenanceAsync(maintenanceStarted, wait: !args.Uninstall);
try
{
if (args.CaptureDirectory is not null)
@@ -359,12 +378,21 @@ public partial class App : Application, ILauncherRestartHost
launcher.SelectedGame = launcher.ResolveInitialGame(requestedGameId); // applique le thème du jeu
launcher.AttachTransition(window);
// Mode maintenance : chaque changement (relecture périodique, publication depuis le menu de ce PC), puis l'état déjà connu.
if (_maintenance is { } maintenance && !readOnly)
{
maintenance.Changed += state => Dispatcher.InvokeAsync(() => launcher.ApplyMaintenance(state));
launcher.ApplyMaintenance(maintenance.Current);
}
// Menu d'administration (accès discret : Ctrl+Maj+Alt+A ou 5 clics sur le logo). Pas en mode --capture.
if (!readOnly)
{
var (apiUrl, apiUrlError) = ResolveAdminApiUrl(catalog);
// Publication du mode maintenance depuis le menu : ce launcher le relit aussitôt (sans attendre la relecture périodique).
_admin = new AdminWindowService(window, CreateModules(), catalog, LoadDeclaredCatalog(catalog), apiUrl, apiUrlError, version,
ThemeManager.BuiltInThemes.ToList(), _playerSigning);
ThemeManager.BuiltInThemes.ToList(), _playerSigning,
maintenanceChanged: () => { if (_maintenance is { } m) _ = m.RefreshAsync(_lifetime.Token); });
window.AdminRequested += _admin.Open;
// Bouton « Aide » (demandes des joueurs) : même adresse d'API, lue au même endroit (launcher.json intégré en Release).
@@ -374,7 +402,15 @@ public partial class App : Application, ILauncherRestartHost
: new GameLauncher.Modules.Feedback.FeedbackFormsService(_http.GetBytesAsync, formsUrl, catalog.DataDirectory, _trust);
var feedback = new FeedbackWindowService(window, apiUrl, version, forms);
launcher.IsHelpAvailable = feedback.IsAvailable;
window.HelpRequested += game => feedback.Open(game.GameId, game.GameName, game.InstalledVersion);
window.HelpRequested += game =>
{
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;
@@ -383,6 +419,108 @@ 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>
private static readonly TimeSpan MaintenanceStartupBudget = TimeSpan.FromSeconds(4);
/// <summary>
/// Lance la lecture du mode maintenance (maintenance.json, à côté du trousseau ou du catalogue). Rien n'est gardé sur le
/// disque : hors ligne, le launcher démarre normalement (le jeu installé reste jouable).
/// </summary>
private void StartMaintenance(LauncherCatalog catalog, bool download)
{
if (!download || _http is null) return;
try
{
var url = MaintenanceService.PublishedUrl(_keyringUrl, ResolveCatalogUrl(catalog));
if (url is null) return;
_maintenance = new MaintenanceService(_http.GetBytesAsync, url, _trust);
_maintenanceFetch = _maintenance.RefreshAsync(_lifetime.Token);
}
catch (Exception ex)
{
Log.Error("Mode maintenance", ex);
}
}
/// <summary>
/// Avant la recherche des mises à jour des jeux : attend le mode maintenance (quelques secondes au plus), l'applique,
/// puis lance sa relecture périodique. <paramref name="wait"/> faux (désinstallation depuis Windows) : pas d'attente.
/// </summary>
private async Task ApplyStartupMaintenanceAsync(DateTime started, bool wait)
{
if (_maintenance is not { } maintenance || _maintenanceFetch is not { } fetch) return;
try
{
var remaining = MaintenanceStartupBudget - (DateTime.UtcNow - started);
if (wait && !fetch.IsCompleted && remaining > TimeSpan.Zero) await Task.WhenAny(fetch, Task.Delay(remaining));
if (wait && !fetch.IsCompleted)
Log.Info("Mode maintenance : serveur lent, démarrage sans attendre (l'état sera appliqué à son arrivée).");
_launcher?.ApplyMaintenance(maintenance.Current);
}
catch (Exception ex)
{
Log.Error("Mode maintenance", ex);
}
_ = maintenance.RunAsync(_lifetime.Token);
}
// ------------------------------------------------------------------ Catalogue des jeux
/// <summary>Attente maximale du catalogue à jour au démarrage (au-delà : cache, mise à jour au démarrage suivant).</summary>
@@ -591,6 +729,7 @@ public partial class App : Application, ILauncherRestartHost
new ChangelogAdminModule(),
new PublishAdminModule(),
new FilesAdminModule(),
new MaintenanceAdminModule(),
new AccountsAdminModule(),
new SigningAdminModule(),
new AuditAdminModule()
@@ -676,10 +815,15 @@ public partial class App : Application, ILauncherRestartHost
protected override void OnExit(ExitEventArgs e)
{
_lifetime.Cancel(); // relecture du mode maintenance
_instanceChannel?.Dispose();
_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();
@@ -822,7 +966,8 @@ public partial class App : Application, ILauncherRestartHost
/// <summary>
/// Nouvelle version non installée automatiquement (option désactivée, échec précédent,
/// dossier protégé) : simple information dans le bandeau.
/// dossier protégé, « Plus tard ») : simple information dans le bandeau, en avertissement pour une
/// version prioritaire.
/// </summary>
private async Task NotifyLauncherUpdateAsync()
{
@@ -830,7 +975,13 @@ public partial class App : Application, ILauncherRestartHost
{
await Task.Delay(TimeSpan.FromSeconds(4));
var check = _updates!.SkippedUpdate ?? await _updates.CheckAsync();
if (check.IsAvailable)
if (check.IsPriority)
// Correctif critique non installé au démarrage (« Plus tard », échec précédent, dossier protégé…)
_launcher?.SelectedGame?.ShowNotice("Mise à jour importante du launcher",
$"La version {check.AvailableDisplay} corrige un problème important et n'est pas encore installée : " +
"Paramètres du launcher (roue crantée) › Vérifier les mises à jour.",
DialogKind.Warning);
else if (check.IsAvailable)
_launcher?.SelectedGame?.ShowNotice("Nouvelle version du launcher",
$"La version {check.AvailableDisplay} est disponible : Paramètres du launcher (roue crantée) › Vérifier les mises à jour.",
DialogKind.Info);
+4 -1
View File
@@ -17,7 +17,7 @@
(1.0.0 → 1.0.1 → 1.1.0…). Les launchers des joueurs ne se mettent à jour que vers
une version STRICTEMENT supérieure à la leur.
-->
<Version>3.2.0</Version>
<Version>3.4.2</Version>
<Authors>Lanisys</Authors>
<Company>LanisysGames</Company>
<Copyright>© LanisysGames</Copyright>
@@ -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);
}
@@ -128,12 +128,15 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
/// <summary>
/// Au démarrage, avant l'affichage de la fenêtre principale : recherche rapide et, si une
/// nouvelle version existe et que la mise à jour automatique est activée, installation.
/// Mise à jour automatique désactivée : la recherche a lieu quand même, et seule une version
/// PRIORITAIRE (correctif critique, information signée) est installée. Le réglage du joueur
/// (self-update.json) n'est jamais modifié : il reste désactivé pour les versions suivantes.
/// Renvoie vrai si le launcher redémarre (l'appelant doit alors s'arrêter là).
/// </summary>
public async Task<bool> RunStartupCheckAsync()
{
ExecutableSwapper.CleanupLeftovers(TargetPath, CurrentVersionValue);
if (_client is null || !AutoUpdate || !IsSingleFileBuild) return false;
if (_client is null || !IsSingleFileBuild) return false;
LauncherUpdateCheck check;
try
@@ -148,6 +151,18 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
}
if (!check.IsAvailable) return false;
if (!AutoUpdate && !check.IsPriority)
{
// Option désactivée, version normale : simple information dans le bandeau (sans seconde recherche).
SkippedUpdate = check;
return false;
}
if (check.IsPriority)
Log.Warn($"Launcher {check.AvailableDisplay} : mise à jour PRIORITAIRE (correctif publié en {check.Release!.PriorityVersion})" +
(AutoUpdate ? "." : ", installée bien que la mise à jour automatique soit désactivée (réglage conservé)."));
// Échec précédent : même prioritaire, pas de nouvelle tentative automatique (une version qui ne démarre pas
// sur ce PC imposerait 60 s d'attente et un retour arrière à chaque lancement). Bandeau + bouton manuel.
if (string.Equals(_settings.FailedVersion, check.AvailableDisplay, StringComparison.OrdinalIgnoreCase))
{
Log.Warn($"Launcher {check.AvailableDisplay} : installation automatique ignorée (échec précédent). " +
@@ -167,7 +182,10 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
SkippedUpdate = check;
return false;
}
return await InstallAsync(check, automatic: true);
if (await InstallAsync(check, automatic: true)) return true;
// Version prioritaire non installée (« Plus tard », erreur) : rappel dans le bandeau une fois la fenêtre affichée.
if (check.IsPriority) SkippedUpdate = check;
return false;
}
// ------------------------------------------------------------------ Installation
@@ -265,10 +283,17 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
Log.Error("Mise à jour du launcher impossible", ex);
// Au démarrage, une erreur persistante (fichier absent du serveur, antivirus…) ne doit
// pas réapparaître à chaque lancement : cette version ne sera plus installée automatiquement.
if (automatic) RememberFailure(update);
vm.SetError(FriendlyMessage(ex) + (automatic
? "\n\nVous pourrez réessayer depuis Paramètres du launcher › Vérifier les mises à jour."
: ""));
// Exception : une version PRIORITAIRE (correctif critique) est retentée au prochain démarrage
// (erreur de téléchargement le plus souvent passagère). Seul un échec de démarrage de la nouvelle
// version (plus haut, après retour arrière) l'écarte des installations automatiques.
var retryAtNextStart = automatic && update.IsPriority;
if (automatic && !retryAtNextStart) RememberFailure(update);
vm.SetError(FriendlyMessage(ex) + (retryAtNextStart
? "\n\nCette mise à jour est importante : elle sera retentée au prochain démarrage du launcher " +
"(ou dès maintenant depuis Paramètres du launcher › Vérifier les mises à jour)."
: automatic
? "\n\nVous pourrez réessayer depuis Paramètres du launcher › Vérifier les mises à jour."
: ""));
}
// Erreur : la fenêtre reste ouverte jusqu'à ce que l'utilisateur la ferme.
@@ -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();
}
}
@@ -28,6 +28,7 @@ public sealed class AdminWindowService : IDisposable
private readonly Uri? _apiUrl;
private readonly string? _apiUrlError;
private readonly PlayerSigningCheck? _playerSigning;
private readonly Action? _maintenanceChanged;
private LauncherApiClient? _client;
private AdminWindow? _window;
@@ -39,9 +40,10 @@ public sealed class AdminWindowService : IDisposable
/// <param name="playerSigning">
/// Contrôle du trousseau des joueurs avant une publication signée (null : signatures non vérifiées, pas d'avertissement).
/// </param>
/// <param name="maintenanceChanged">Après une publication du mode maintenance : relecture immédiate par ce launcher.</param>
public AdminWindowService(Window owner, ModuleCatalog modules, LauncherCatalog catalog, LauncherCatalog declared, string? apiUrl,
string? apiUrlError, string launcherVersion, IReadOnlyList<string> themes,
PlayerSigningCheck? playerSigning = null)
PlayerSigningCheck? playerSigning = null, Action? maintenanceChanged = null)
{
_owner = owner;
_modules = modules;
@@ -52,6 +54,7 @@ public sealed class AdminWindowService : IDisposable
_apiUrl = apiUrl is null ? null : new Uri(apiUrl, UriKind.Absolute);
_apiUrlError = apiUrlError;
_playerSigning = playerSigning;
_maintenanceChanged = maintenanceChanged;
}
public bool IsAvailable => _apiUrl is not null;
@@ -93,7 +96,8 @@ public sealed class AdminWindowService : IDisposable
SettingsPath = _settingsPath,
LauncherVersion = _launcherVersion,
RootKeys = LauncherUpdateService.LoadRootKeys(),
PlayerSigning = _playerSigning
PlayerSigning = _playerSigning,
MaintenanceChanged = _maintenanceChanged
});
window = new AdminWindow(shell);
if (_owner.IsVisible) window.Owner = _owner;
+161 -2
View File
@@ -10,6 +10,7 @@
xmlns:asig="clr-namespace:GameLauncher.Modules.Admin.Signing;assembly=GameLauncher.Modules.Admin"
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:conv="clr-namespace:GameLauncher.Converters">
<!--
@@ -367,6 +368,19 @@
</DockPanel>
</Border>
<!-- Mode maintenance en cours (tous les administrateurs le voient ; la page « Maintenance » exige maintenance.manage) -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}" Background="#26FF8A1F"
Visibility="{Binding IsMaintenanceActive, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Gérer" Margin="12,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.TrafficCone}" Command="{Binding OpenMaintenanceCommand}"
Visibility="{Binding CanOpenMaintenance, Converter={StaticResource BoolToVisibility}}" />
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.TrafficCone}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding MaintenanceText}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" VerticalAlignment="Center" />
</DockPanel>
</Border>
<!-- Compte connecté -->
<Border Style="{StaticResource AdminCardStyle}">
<Grid>
@@ -2991,7 +3005,7 @@
<StackPanel>
<TextBlock Text="Clés autorisées à signer" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
Text="D'après les appareils actifs des administrateurs autorisés à publier une version (propriétaires, administrateurs) ou le catalogue (éditeurs compris). Le trousseau est public : chacun peut y lire le nom des appareils (« Nom — appareil »)." />
Text="D'après les appareils actifs des administrateurs autorisés à publier une version, le catalogue, les formulaires des demandes ou le mode maintenance (permissions de leur groupe). Le trousseau est public : chacun peut y lire le nom des appareils (« Nom — appareil »)." />
<TextBlock Text="{Binding ChangesText}" FontSize="12.5" FontWeight="SemiBold" Margin="0,0,0,10" />
<ItemsControl ItemsSource="{Binding Keys}" Focusable="False" />
<DockPanel Margin="0,10,0,0">
@@ -3014,13 +3028,130 @@
<StackPanel>
<TextBlock Text="Contenu en ligne" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
Text="Versions et catalogue tels que les launchers les reçoivent, vérifiés avec le trousseau publié et avec le trousseau proposé." />
Text="Versions, catalogue, formulaires des demandes et mode maintenance tels que les launchers les reçoivent, vérifiés avec le trousseau publié et avec le trousseau proposé." />
<ItemsControl ItemsSource="{Binding Contents}" Focusable="False" />
</StackPanel>
</Border>
</StackPanel>
</DataTemplate>
<!-- ============================================================ Page « Maintenance » -->
<DataTemplate DataType="{x:Type amnt:MaintenancePanelViewModel}">
<StackPanel>
<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>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding ShowPublishingDisabled, Converter={StaticResource BoolToVisibility}}">
<TextBlock TextWrapping="Wrap" FontSize="12.5"
Text="Publication du mode maintenance non configurée sur le serveur : renseignez LauncherApi:Catalog:PublicFile (le fichier est alors écrit à côté du catalogue) ou LauncherApi:Keyring:PublicFile (server/deploy/LISEZMOI.md, étape 3 decies)." />
</Border>
<!-- État publié -->
<Border x:Name="StateCard" Style="{StaticResource AdminCardStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border x:Name="StateBadge" Style="{StaticResource AdminIconBadgeStyle}" Width="46" Height="46" CornerRadius="15">
<Path x:Name="StateIcon" Style="{StaticResource AdminIconStyle}" Width="22" Height="22" Data="{StaticResource Icon.CheckCircle}"
Fill="{DynamicResource SuccessBrush}" />
</Border>
<StackPanel Grid.Column="1" Margin="14,0,12,0" VerticalAlignment="Center">
<TextBlock Text="{Binding StateTitle}" FontSize="17" FontWeight="SemiBold" TextWrapping="Wrap" />
<TextBlock Text="{Binding StateText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
<TextBlock FontSize="12" Margin="0,6,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}"
Visibility="{Binding PublicUrl, Converter={StaticResource StringToVisibility}}">
<Run Text="Fichier lu par les launchers :" />
<Run Text="{Binding PublicUrl, Mode=OneWay}" FontFamily="Cascadia Mono, Consolas, Courier New" />
</TextBlock>
</StackPanel>
</Grid>
</Border>
<!-- Message (actions : barre du bas, AdminFooter.MaintenancePanelViewModel) -->
<Border Style="{StaticResource AdminCardStyle}" IsEnabled="{Binding IsLoaded}">
<StackPanel>
<TextBlock Text="Message affiché aux joueurs" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
Text="Facultatif : une précision sous « Maintenance en cours — merci de revenir plus tard » (raison, heure de retour prévue…). Texte brut, 6 lignes au plus, aucun lien ni adresse de site." />
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="80" MaxLength="400"
Text="{Binding Message, UpdateSourceTrigger=PropertyChanged}" ToolTip="Précision affichée aux joueurs (facultative)" />
<DockPanel Margin="0,6,0,0">
<TextBlock DockPanel.Dock="Right" Text="{Binding MessageCounter}" FontSize="11.5" Margin="12,0,0,0"
Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock Text="{Binding MessageError}" FontSize="12" TextWrapping="Wrap" Foreground="{DynamicResource DangerBrush}"
Visibility="{Binding HasMessageError, Converter={StaticResource BoolToVisibility}}" />
</DockPanel>
<!-- Aperçu : mêmes textes que la carte du launcher -->
<Border Margin="0,16,0,0" CornerRadius="16" Padding="22,18" BorderThickness="1" Background="#E61A0D09" BorderBrush="#80FF6A2B">
<StackPanel>
<DockPanel HorizontalAlignment="Center">
<Border DockPanel.Dock="Left" Width="34" Height="34" CornerRadius="11" VerticalAlignment="Center">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#FF3B30" Offset="0" />
<GradientStop Color="#FF9500" Offset="1" />
</LinearGradientBrush>
</Border.Background>
<Path Data="{StaticResource Icon.TrafficCone}" Fill="White" Width="18" Height="18" Stretch="Uniform" />
</Border>
<StackPanel Margin="12,0,0,0" VerticalAlignment="Center">
<TextBlock Text="{Binding PreviewTitle}" FontSize="16" FontWeight="Bold" Foreground="#F4F6FB" />
<TextBlock Text="{Binding PreviewSubtitle}" FontSize="12.5" Foreground="#C8B4AC" />
</StackPanel>
</DockPanel>
<TextBlock Text="{Binding PreviewMessage}" Margin="0,12,0,0" FontSize="13" TextWrapping="Wrap" TextAlignment="Center"
Foreground="#F4F6FB" Visibility="{Binding PreviewMessage, Converter={StaticResource StringToVisibility}}" />
</StackPanel>
</Border>
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,8,0,0"
Text="Aperçu : dans le launcher, la carte s'affiche au centre de la fenêtre, sur un fond rouge / orange." />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,12,0,0"
Text="Publication signée par la clé de ce PC (autorisation « mode maintenance » du trousseau). Les launchers relisent l'état au démarrage, puis toutes les 1 à 2 minutes ; celui de ce PC bascule aussitôt." />
</StackPanel>
</Border>
<!-- Ce que voient les joueurs -->
<Border Style="{StaticResource AdminCardStyle}">
<StackPanel>
<TextBlock Text="Pendant la maintenance, chez les joueurs" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,6,0,0"
Text="• Bloqués : installation, mises à jour (automatiques ou non), vérification, réparation, localisation et lancement des jeux, raccourcis du Bureau, paramètres du launcher et des jeux, bouton « Aide », site officiel." />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
Text="• Toujours possibles : désinstallation des jeux (menu « … » ou Windows), menu d'administration, réduction et fermeture du launcher." />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
Text="• Non interrompus : téléchargements déjà commencés, parties en cours, mises à jour du launcher lui-même (signées par la clé racine)." />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
Text="• Hors ligne au démarrage : pas de maintenance (le jeu installé reste jouable). C'est un affichage pour les joueurs, pas une protection : si les serveurs du jeu doivent refuser les connexions, c'est à eux de le faire." />
</StackPanel>
</Border>
</StackPanel>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsActive}" Value="True">
<Setter TargetName="StateCard" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="StateBadge" Property="Background" Value="#33FF8A1F" />
<Setter TargetName="StateIcon" Property="Data" Value="{DynamicResource Icon.TrafficCone}" />
<Setter TargetName="StateIcon" Property="Fill" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<!-- ============================================================ Page « Fichiers du serveur » -->
<!-- Élément du fil d'Ariane (www › exovivalzombie › objects) -->
<DataTemplate DataType="{x:Type afil:FolderCrumb}">
@@ -3326,6 +3457,34 @@
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.MaintenancePanelViewModel">
<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 RefreshCommand}"
ToolTip="Afficher l'état actuellement publié" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Modifier le message" Margin="0,0,10,0"
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding UpdateMessageCommand}"
ToolTip="Publie le nouveau message (requête signée par votre clé) ; la maintenance continue"
Visibility="{Binding IsActive, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Terminer la maintenance"
ui:IconAssist.Icon="{StaticResource Icon.LockOpenVariant}" Command="{Binding DisableCommand}"
ToolTip="Les launchers des joueurs redeviennent utilisables (requête signée par votre clé)"
Visibility="{Binding IsActive, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource DangerButtonStyle}" Content="Activer la maintenance"
ui:IconAssist.Icon="{StaticResource Icon.TrafficCone}" Command="{Binding EnableCommand}"
ToolTip="Bloque tous les launchers des joueurs, sauf la désinstallation des jeux (requête signée par votre clé)"
Visibility="{Binding IsActive, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
</StackPanel>
<TextBlock Text="{Binding StateTitle}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.MyDevicesPanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
+2
View File
@@ -91,6 +91,8 @@
<!-- Publication d'une version (phase 5) -->
<Geometry x:Key="Icon.CloudUpload">M14,13V17H10V13H7L12,8L17,13M19.35,10.03C18.67,6.59 15.64,4 12,4C9.11,4 6.6,5.64 5.35,8.03C2.34,8.36 0,10.9 0,14A6,6 0 0,0 6,20H19A5,5 0 0,0 24,15C24,12.36 21.95,10.22 19.35,10.03Z</Geometry>
<!-- Cône de chantier (mode maintenance) : bandes du cône et socle -->
<Geometry x:Key="Icon.TrafficCone">M9.6,2H14.4L15.2,5H8.8L9.6,2M8.3,7H15.7L16.7,11H7.3L8.3,7M6.8,13H17.2L18.2,17H5.8L6.8,13M3,19H21V22H3V19Z</Geometry>
<Geometry x:Key="Icon.Pause">M14,19H18V5H14M6,19H10V5H6V19Z</Geometry>
<Geometry x:Key="Icon.PackageVariant">M2,10.96C1.5,10.68 1.35,10.07 1.63,9.59L3.13,7C3.24,6.8 3.41,6.66 3.6,6.58L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.66,6.72 20.82,6.88 20.91,7.08L22.36,9.6C22.64,10.08 22.47,10.69 22,10.96L21,11.54V16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V10.96C2.7,11.13 2.32,11.14 2,10.96M12,4.15V4.15L12,10.85V10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V12.69L14,15.59C13.67,15.77 13.3,15.76 13,15.6V19.29L19,15.91M13.04,13.36L20.03,9.27L19.5,8.35L12.51,12.45L13.04,13.36Z</Geometry>
<Geometry x:Key="Icon.Laptop">M4,6H20V16H4M20,18A2,2 0 0,0 22,16V6C22,4.89 21.1,4 20,4H4C2.89,4 2,4.89 2,6V16A2,2 0 0,0 4,18H0V20H24V18H20Z</Geometry>
+72
View File
@@ -0,0 +1,72 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
Mode maintenance (publié depuis le menu d'administration) : palette rouge / orange appliquée par-dessus le thème du
jeu, dans la fenêtre principale seulement (MainWindow l'ajoute à ses ressources pendant la maintenance, puis la
retire). Mêmes clés que Themes/Default.xaml : les vues, en {DynamicResource …}, changent de couleur à chaud.
Les clés « Maintenance* » ne servent qu'à la carte affichée au centre de la fenêtre.
-->
<!-- ============================================================ Palette -->
<Color x:Key="AccentColor">#FF3B30</Color>
<Color x:Key="Accent2Color">#FF9F0A</Color>
<Color x:Key="BackgroundColor">#140806</Color>
<SolidColorBrush x:Key="BackgroundBrush" Color="#140806" />
<SolidColorBrush x:Key="AccentBrush" Color="#FF453A" />
<SolidColorBrush x:Key="Accent2Brush" Color="#FF9F0A" />
<SolidColorBrush x:Key="OnAccentBrush" Color="White" />
<LinearGradientBrush x:Key="AccentGradientBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#FF3B30" Offset="0" />
<GradientStop Color="#FF9500" Offset="1" />
</LinearGradientBrush>
<SolidColorBrush x:Key="BorderBrush" Color="#2EFF7A45" />
<SolidColorBrush x:Key="BorderStrongBrush" Color="#4DFF7A45" />
<SolidColorBrush x:Key="ChipBrush" Color="#991C0C08" />
<SolidColorBrush x:Key="ChipIconBoxBrush" Color="#33FF6A2B" />
<SolidColorBrush x:Key="BadgeBrush" Color="#24FF6A2B" />
<SolidColorBrush x:Key="MenuBrush" Color="#F21C0E0A" />
<SolidColorBrush x:Key="ActionBarBrush" Color="#EB170B08" />
<SolidColorBrush x:Key="WindowBorderBrush" Color="#66FF6A2B" />
<SolidColorBrush x:Key="RailBrush" Color="#CC140806" />
<!-- ============================================================ Fond : illustration du jeu fortement teintée -->
<LinearGradientBrush x:Key="HeroOverlayBrush" StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#F2140806" Offset="0" />
<GradientStop Color="#D9140806" Offset="0.5" />
<GradientStop Color="#C7140806" Offset="1" />
</LinearGradientBrush>
<LinearGradientBrush x:Key="VignetteBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#B3140806" Offset="0" />
<GradientStop Color="#00140806" Offset="0.2" />
<GradientStop Color="#00140806" Offset="0.6" />
<GradientStop Color="#F2140806" Offset="1" />
</LinearGradientBrush>
<!-- Lueur rouge / orange derrière la carte de maintenance -->
<RadialGradientBrush x:Key="AmbientGlowBrush" Center="0.55,0.45" GradientOrigin="0.55,0.45" RadiusX="0.7" RadiusY="0.8">
<GradientStop Color="#66FF3B1F" Offset="0" />
<GradientStop Color="#33FF8A00" Offset="0.45" />
<GradientStop Color="#00FF8A00" Offset="1" />
</RadialGradientBrush>
<!-- ============================================================ Carte « Maintenance en cours » -->
<SolidColorBrush x:Key="MaintenanceCardBrush" Color="#E61A0D09" />
<SolidColorBrush x:Key="MaintenanceCardBorderBrush" Color="#80FF6A2B" />
<!-- Bandes de chantier (diagonales orange / brun), répétées -->
<DrawingBrush x:Key="MaintenanceStripesBrush" TileMode="Tile" Viewport="0,0,16,16" ViewportUnits="Absolute"
Viewbox="0,0,16,16" ViewboxUnits="Absolute" Stretch="None">
<DrawingBrush.Drawing>
<DrawingGroup>
<GeometryDrawing Brush="#FF2A130B" Geometry="M0,0H16V16H0Z" />
<GeometryDrawing Brush="#FFFF8A1F" Geometry="M0,0L8,0L0,8Z M16,0L16,8L8,16L0,16Z" />
</DrawingGroup>
</DrawingBrush.Drawing>
</DrawingBrush>
</ResourceDictionary>
+108 -11
View File
@@ -4,6 +4,7 @@ using System.IO;
using System.Net;
using System.Net.Http;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Core.Models;
using GameLauncher.Core.Services;
using GameLauncher.Core.Trust;
@@ -65,6 +66,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private string? _lastInstallDirectory;
private int _bannerVersion;
private bool _launchWhenReady;
private bool _isMaintenance;
public GameViewModel(LauncherConfig config, LauncherSettings settings, string settingsPath,
LauncherPreferences preferences, GameInstaller installer, ManifestClient manifestClient,
@@ -88,8 +90,10 @@ public sealed class GameViewModel : ObservableObject, IDisposable
LauncherVersion = launcherVersion;
ApplySettings();
PrimaryCommand = Command(ExecutePrimary, CanExecutePrimary);
CancelCommand = Command(Cancel, () => State == LauncherState.Busy && _operationCancellable);
// Mode maintenance : toutes les commandes sont désactivées, sauf la désinstallation, l'annulation d'une opération
// déjà commencée et la fermeture du bandeau (Command(…, allowedInMaintenance: true)).
PrimaryCommand = Command(ExecutePrimary, CanExecutePrimary, allowedInMaintenance: true);
CancelCommand = Command(Cancel, () => State == LauncherState.Busy && _operationCancellable, allowedInMaintenance: true);
CheckUpdatesCommand = Command(() => Fire(() => CheckForUpdatesAsync(userInitiated: true)),
() => State is LauncherState.NotInstalled or LauncherState.Ready or LauncherState.UpdateAvailable
or LauncherState.NeedsRepair or LauncherState.Broken);
@@ -101,12 +105,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
() => State is LauncherState.NotInstalled or LauncherState.Broken);
ForgetCommand = Command(Forget, () => State == LauncherState.Broken);
UninstallCommand = Command(() => Fire(async () => await UninstallAsync()),
() => State is LauncherState.Ready or LauncherState.UpdateAvailable or LauncherState.NeedsRepair or LauncherState.Broken);
() => State is LauncherState.Ready or LauncherState.UpdateAvailable or LauncherState.NeedsRepair or LauncherState.Broken,
allowedInMaintenance: true);
OpenFolderCommand = Command(() => { if (InstallPath is not null) _window.OpenFolder(InstallPath); },
() => InstallPath is not null && State != LauncherState.Broken && Directory.Exists(InstallPath));
OpenSettingsCommand = Command(OpenSettings);
OpenWebsiteCommand = Command(() => { if (HasWebsite) _window.OpenUrl(_config.WebsiteUrl!); }, () => HasWebsite);
DismissBannerCommand = Command(HideBanner);
DismissBannerCommand = Command(HideBanner, allowedInMaintenance: true);
}
// ------------------------------------------------------------------ Propriétés affichées
@@ -184,6 +189,8 @@ public sealed class GameViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(ShowInstallInfo));
OnPropertyChanged(nameof(ShowRailBadge));
OnPropertyChanged(nameof(RailToolTip));
OnPropertyChanged(nameof(DisplayStatusTitle));
OnPropertyChanged(nameof(DisplayStatusDetail));
RefreshCommands();
// Lancement demandé par un raccourci pendant une vérification : traité à la fin de celle-ci.
@@ -196,12 +203,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
}
public bool IsBusy => State == LauncherState.Busy;
public bool ShowLocateHint => State == LauncherState.NotInstalled;
public bool ShowLocateHint => State == LauncherState.NotInstalled && !_isMaintenance;
public bool ShowBrokenActions => State == LauncherState.Broken;
public bool ShowInstallInfo => InstallPath is not null && State != LauncherState.NotInstalled;
public string PrimaryText => State switch
{
not (LauncherState.Busy or LauncherState.Running) when _isMaintenance => "MAINTENANCE",
LauncherState.Checking => "CHARGEMENT",
LauncherState.NotInstalled => "INSTALLER",
LauncherState.Ready => "JOUER",
@@ -215,6 +223,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
/// <summary>Clé d'icône (ressource "Icon.&lt;clé&gt;" dans Themes/Icons.xaml).</summary>
public string PrimaryIcon => State switch
{
not (LauncherState.Busy or LauncherState.Running) when _isMaintenance => "TrafficCone",
LauncherState.Checking => "Refresh",
LauncherState.NotInstalled => "Download",
LauncherState.UpdateAvailable => "CloudDownload",
@@ -244,10 +253,71 @@ public sealed class GameViewModel : ObservableObject, IDisposable
};
private string _statusTitle = "Chargement…";
public string StatusTitle { get => _statusTitle; private set => SetProperty(ref _statusTitle, value); }
public string StatusTitle
{
get => _statusTitle;
private set { if (SetProperty(ref _statusTitle, value)) OnPropertyChanged(nameof(DisplayStatusTitle)); }
}
private string _statusDetail = "";
public string StatusDetail { get => _statusDetail; private set => SetProperty(ref _statusDetail, value); }
public string StatusDetail
{
get => _statusDetail;
private set { if (SetProperty(ref _statusDetail, value)) OnPropertyChanged(nameof(DisplayStatusDetail)); }
}
/// <summary>Titre de la barre d'action : pendant une maintenance, celle-ci (sauf pendant une opération déjà commencée).</summary>
public string DisplayStatusTitle => _isMaintenance && State != LauncherState.Busy ? MaintenanceRules.Title : StatusTitle;
public string DisplayStatusDetail => !_isMaintenance || State == LauncherState.Busy
? StatusDetail
: State == LauncherState.Running
? "Le jeu déjà lancé n'est pas fermé. Seule la désinstallation des jeux reste possible."
: "Seule la désinstallation des jeux reste possible (menu « … »).";
// ------------------------------------------------------------------ Mode maintenance
/// <summary>
/// Mode maintenance publié depuis le menu d'administration (commun à tous les jeux, posé par le launcher) : tout est
/// bloqué sauf la désinstallation. Une opération déjà commencée se termine (elle reste annulable) ; un jeu déjà lancé
/// n'est pas fermé. À la fin de la maintenance, les mises à jour sont recherchées de nouveau.
/// </summary>
public bool IsMaintenance
{
get => _isMaintenance;
set
{
if (!SetProperty(ref _isMaintenance, value)) return;
if (value) _launchWhenReady = false; // lancement demandé par un raccourci : abandonné
OnPropertyChanged(nameof(PrimaryText));
OnPropertyChanged(nameof(PrimaryIcon));
OnPropertyChanged(nameof(DisplayStatusTitle));
OnPropertyChanged(nameof(DisplayStatusDetail));
OnPropertyChanged(nameof(ShowLocateHint));
OnPropertyChanged(nameof(ShowWebsite));
RefreshCommands();
Log.Info($"[{GameId}] Mode maintenance {(value ? "activé" : "terminé")}.");
// Fin de la maintenance : état et mises à jour à jour (mise à jour automatique selon le réglage du jeu).
if (!value && State is LauncherState.NotInstalled or LauncherState.Ready or LauncherState.UpdateAvailable
or LauncherState.NeedsRepair or LauncherState.Broken)
Fire(() => CheckForUpdatesAsync(userInitiated: false));
}
}
/// <summary>Bouton « Site officiel » de la barre de titre (masqué pendant une maintenance).</summary>
public bool ShowWebsite => HasWebsite && !_isMaintenance;
/// <summary>Action refusée pendant une maintenance : bandeau d'explication. Vrai si l'action doit s'arrêter.</summary>
private bool BlockedByMaintenance()
{
if (!_isMaintenance) return false;
Log.Info($"[{GameId}] Action refusée : mode maintenance.");
ShowBanner(MaintenanceRules.Title,
"Cette action est indisponible pendant la maintenance. Merci de revenir plus tard : seule la désinstallation reste possible.",
DialogKind.Warning);
return true;
}
private string _stepText = "";
public string StepText { get => _stepText; private set => SetProperty(ref _stepText, value); }
@@ -334,9 +404,10 @@ public sealed class GameViewModel : ObservableObject, IDisposable
public RelayCommand OpenWebsiteCommand { get; }
public RelayCommand DismissBannerCommand { get; }
private RelayCommand Command(Action execute, Func<bool>? canExecute = null)
private RelayCommand Command(Action execute, Func<bool>? canExecute = null, bool allowedInMaintenance = false)
{
var command = new RelayCommand(execute, canExecute);
var command = new RelayCommand(execute,
allowedInMaintenance ? canExecute : () => !_isMaintenance && (canExecute?.Invoke() ?? true));
_commands.Add(command);
return command;
}
@@ -348,6 +419,8 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private bool CanExecutePrimary() => State switch
{
LauncherState.Busy when _isMaintenance => _operationCancellable, // seule l'annulation reste possible
_ when _isMaintenance => false,
LauncherState.Checking or LauncherState.Running => false,
LauncherState.Busy => _operationCancellable,
_ => true
@@ -355,6 +428,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private void ExecutePrimary()
{
if (State != LauncherState.Busy && BlockedByMaintenance()) return;
switch (State)
{
case LauncherState.NotInstalled: Fire(InstallAsync); break;
@@ -578,6 +652,12 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private async Task CheckForUpdatesAsync(bool userInitiated, bool background = false)
{
if (State is LauncherState.Busy or LauncherState.Running) return;
// Maintenance : ni recherche ni mise à jour automatique (reprises à la fin de la maintenance).
if (_isMaintenance)
{
if (userInitiated) BlockedByMaintenance();
return;
}
if (_launcherUpdates?.IsInstalling == true) return; // le launcher se met à jour puis redémarre
if (!background)
@@ -599,6 +679,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
if (State is LauncherState.Busy or LauncherState.Running) return; // une opération ou le jeu a démarré entre-temps
if (_launcherUpdates?.IsInstalling == true) return;
RefreshInstallState();
if (_isMaintenance) return; // maintenance commencée pendant la recherche : pas de mise à jour automatique
if (manifest is null)
{
@@ -649,11 +730,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
/// Exécute une opération longue : état "Busy", barre de progression, annulation,
/// gestion des erreurs, puis nouvelle détection de l'état.
/// </summary>
/// <param name="allowedInMaintenance">Vrai pour la désinstallation seulement : seule opération permise pendant une maintenance.</param>
private async Task<bool> RunOperationAsync(string title,
Func<IProgress<OperationProgress>, CancellationToken, Task> work,
bool cancellable = true)
bool cancellable = true, bool allowedInMaintenance = false)
{
if (State == LauncherState.Busy) return false;
if (!allowedInMaintenance && BlockedByMaintenance()) return false;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token);
_operationCts = cts;
@@ -725,6 +808,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private async Task InstallAsync()
{
if (BlockedByMaintenance()) return;
var manifest = await TryFetchManifestAsync();
if (manifest is null)
{
@@ -756,6 +840,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private async Task UpdateAsync()
{
if (BlockedByMaintenance()) return;
var dir = InstallPath;
if (dir is null) return;
@@ -783,6 +868,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
/// </param>
private async Task RepairAsync(bool cacheIsFresh = false)
{
if (BlockedByMaintenance()) return;
var dir = InstallPath;
if (dir is null) return;
@@ -914,6 +1000,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private async Task VerifyIntegrityAsync()
{
if (BlockedByMaintenance()) return;
var dir = InstallPath;
if (dir is null) return;
@@ -983,6 +1070,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private async Task LocateAsync()
{
if (BlockedByMaintenance()) return;
var initial = InstallPath is not null ? Path.GetDirectoryName(InstallPath) : null;
var folder = _dialogs.PickFolder($"Où se trouve {GameName} ?", initial);
if (folder is null) return;
@@ -1046,6 +1134,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private void Forget()
{
if (BlockedByMaintenance()) return;
if (!_dialogs.Confirm("Oublier l'installation",
$"Le launcher ne considérera plus {GameName} comme installé dans « {InstallPath} ».\nAucun fichier ne sera supprimé.",
"Oublier", "Annuler", DialogKind.Question))
@@ -1124,7 +1213,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
_registry.Delete();
_shortcuts.RemoveDesktopShortcut();
_needsRepair = false;
}, cancellable: false);
}, cancellable: false, allowedInMaintenance: true);
if (ok && result is not null)
{
@@ -1250,6 +1339,12 @@ public sealed class GameViewModel : ObservableObject, IDisposable
public void LaunchFromShortcut()
{
_launchWhenReady = false;
if (_isMaintenance && State != LauncherState.Running)
{
ShowBanner(MaintenanceRules.Title,
$"{GameName} ne peut pas être lancé pendant la maintenance. {MaintenanceRules.Subtitle}", DialogKind.Warning, autoHide: false);
return;
}
switch (State)
{
case LauncherState.Ready:
@@ -1283,6 +1378,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private void Play()
{
if (BlockedByMaintenance()) return;
var dir = InstallPath;
if (dir is null) return;
var exePath = GetExecutablePath(dir);
@@ -1416,6 +1512,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
/// <summary>Paramètres propres à ce jeu (menu « … »). Les réglages communs sont dans les paramètres du launcher.</summary>
private void OpenSettings()
{
if (BlockedByMaintenance()) return;
var info = new GameSettingsInfo(GameName, InstallPath, InstalledVersionText, LatestVersionText, InstallSizeText,
_config.GameLogsDirectory, _config.GameDataDirectory);
var shortcut = new GameShortcutActions(
@@ -116,9 +116,19 @@ public sealed class LauncherSettingsViewModel : SettingsViewModelBase
if (check.IsAvailable)
{
SetAvailable(check);
LauncherStatusKind = DialogKind.Info;
var title = string.IsNullOrWhiteSpace(check.Release?.Title) ? "" : $" — {check.Release!.Title}";
LauncherStatus = $"Nouvelle version {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}).";
if (check.IsPriority)
{
// Correctif critique non installé au démarrage (annulé, échec, dossier protégé…)
LauncherStatusKind = DialogKind.Warning;
LauncherStatus = $"Mise à jour importante {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}). " +
"Installez-la dès que possible.";
}
else
{
LauncherStatusKind = DialogKind.Info;
LauncherStatus = $"Nouvelle version {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}).";
}
}
else
{
@@ -21,13 +21,19 @@ public sealed class LauncherUpdateViewModel : ObservableObject
public LauncherUpdateViewModel(LauncherUpdateCheck update, bool automatic)
{
Automatic = automatic;
IsPriority = update.IsPriority;
FromVersion = update.CurrentDisplay;
ToVersion = update.AvailableDisplay;
// Notes de toutes les versions publiées depuis la version installée (format enrichi)
Changelog = update.Release?.GetChangelogSince(update.CurrentVersion) ?? Array.Empty<ChangelogEntry>();
Subtitle = automatic
? "Une nouvelle version est disponible. Elle s'installe automatiquement, le launcher redémarrera tout seul."
: "Installation de la nouvelle version. Le launcher redémarrera tout seul.";
Subtitle = (automatic, IsPriority) switch
{
(true, true) => "Mise à jour importante : elle s'installe automatiquement, même si la mise à jour automatique " +
"du launcher est désactivée (votre réglage ne change pas). Le launcher redémarrera tout seul.",
(true, false) => "Une nouvelle version est disponible. Elle s'installe automatiquement, le launcher redémarrera tout seul.",
(false, true) => "Installation d'une mise à jour importante. Le launcher redémarrera tout seul.",
_ => "Installation de la nouvelle version. Le launcher redémarrera tout seul."
};
Progress = new Progress<OperationProgress>(p =>
{
@@ -44,6 +50,9 @@ public sealed class LauncherUpdateViewModel : ObservableObject
/// <summary>Recherche au démarrage (vrai) ou demandée dans les Paramètres du launcher (faux).</summary>
public bool Automatic { get; }
/// <summary>Mise à jour prioritaire (correctif critique, information signée) : pastille « Prioritaire ».</summary>
public bool IsPriority { get; }
public string FromVersion { get; }
public string ToVersion { get; }
public string Subtitle { get; }
@@ -1,6 +1,8 @@
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Text.Json;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
@@ -9,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
{
@@ -32,6 +43,9 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
private readonly string _launcherVersion;
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,
@@ -52,7 +66,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
if (CloseToTray) HideToTray(); // option « La croix réduit le launcher dans la zone de notification »
else Quit();
});
OpenLauncherSettingsCommand = new RelayCommand(OpenLauncherSettings);
OpenLauncherSettingsCommand = new RelayCommand(OpenLauncherSettings, () => !IsMaintenance);
}
public ObservableCollection<GameViewModel> Games { get; } = new();
@@ -65,9 +79,89 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
public bool IsExitConfirmed { get; set; }
/// <summary>Bouton « Aide » affiché (adresse de l'API présente dans launcher.json).</summary>
/// <summary>Bouton « Aide » disponible (adresse de l'API présente dans launcher.json).</summary>
public bool IsHelpAvailable { get; set; }
/// <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>
/// Mode maintenance publié depuis le menu d'administration (maintenance.json, vérifié par MaintenanceService) : thème
/// rouge / orange, message au centre, tout bloqué sauf la désinstallation des jeux et le menu d'administration.
/// </summary>
public bool IsMaintenance => _maintenance is { Enabled: true };
public string MaintenanceTitle => MaintenanceRules.Title;
public string MaintenanceSubtitle => MaintenanceRules.Subtitle;
/// <summary>Précision publiée par l'administrateur (texte brut, sans lien), sinon null.</summary>
public string? MaintenanceMessage => IsMaintenance ? _maintenance!.Message : null;
/// <summary>« Depuis le mardi 10 novembre à 14:05 » (heure de ce PC).</summary>
public string? MaintenanceSinceText => IsMaintenance && _maintenance!.Since is { } since
? "Depuis le " + since.ToLocalTime().ToString("dddd d MMMM 'à' HH:mm", CultureInfo.GetCultureInfo("fr-FR"))
: null;
/// <summary>Nouvel état du mode maintenance (null : rien de publié). Appelé sur le fil de l'interface.</summary>
public void ApplyMaintenance(MaintenanceDocument? maintenance)
{
var wasActive = IsMaintenance;
var next = maintenance is { Enabled: true } ? maintenance : null;
void Change()
{
_maintenance = next;
var active = IsMaintenance;
foreach (var game in Games) game.IsMaintenance = active;
OnPropertyChanged(nameof(MaintenanceMessage));
OnPropertyChanged(nameof(MaintenanceSinceText));
if (wasActive == active) return;
OnPropertyChanged(nameof(IsMaintenance));
OnPropertyChanged(nameof(ShowHelpButton));
OnPropertyChanged(nameof(ShowAccountButton));
OpenLauncherSettingsCommand.RaiseCanExecuteChanged();
}
// Début ou fin de la maintenance : même fondu enchaîné qu'un changement de jeu (palette, carte au centre).
if (wasActive != (next is not null) && _transition is not null) _transition.Run(Change);
else Change();
}
public RelayCommand MinimizeCommand { get; }
public RelayCommand CloseCommand { get; }
@@ -124,6 +218,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
public void AddGame(GameViewModel game)
{
game.IsMaintenance = IsMaintenance;
Games.Add(game);
OnPropertyChanged(nameof(IsMultiGame));
}
@@ -253,6 +348,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
private void OpenLauncherSettings()
{
if (IsMaintenance) return; // bouton désactivé pendant une maintenance
var vm = new LauncherSettingsViewModel(_preferences, LauncherName, _launcherVersion, _window, _launcherUpdates);
if (!_dialogs.ShowSettings(vm)) return;
+509
View File
@@ -0,0 +1,509 @@
<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"
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, création de compte, nouveau mot de passe exigé, « Mon compte » (onglets Profil, 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>
<!-- 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é -->
<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 (étape suivante) -->
<Border Style="{StaticResource AccountCardStyle}">
<DockPanel>
<Border DockPanel.Dock="Right" CornerRadius="9" Padding="10,3" VerticalAlignment="Center"
Background="{DynamicResource ChipBrush}">
<TextBlock Text="Bientôt" FontSize="11.5" FontWeight="SemiBold" Foreground="{DynamicResource TextSecondaryBrush}" />
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Double authentification" FontSize="13.5" FontWeight="SemiBold" />
<TextBlock Text="Un code à usage unique (application d'authentification) en plus du mot de passe."
FontSize="12" Margin="0,2,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</DockPanel>
</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="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,152 @@
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);
/// <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
})
box.Clear();
}
// ------------------------------------------------------------------ Affichage
private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e)
{
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,
_ => 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();
}
}
@@ -76,6 +76,16 @@
<TextBlock Text="{Binding ToVersion}" FontSize="13" FontWeight="Bold" VerticalAlignment="Center" />
</StackPanel>
</Border>
<!-- Mise à jour prioritaire (correctif critique) : installée même si l'option automatique est désactivée -->
<Border Style="{StaticResource VersionChipStyle}" Margin="10,0,0,0" BorderBrush="{DynamicResource WarningBrush}"
ToolTip="Correctif important : installé même si la mise à jour automatique du launcher est désactivée. Votre réglage ne change pas."
Visibility="{Binding IsPriority, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Data="{StaticResource Icon.Alert}" Fill="{DynamicResource WarningBrush}"
Width="14" Height="14" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,7,0" />
<TextBlock Text="Prioritaire" FontSize="12.5" FontWeight="SemiBold" VerticalAlignment="Center" />
</StackPanel>
</Border>
</StackPanel>
<!-- Nouveautés : même présentation que les notes du jeu (Themes/Changelog.xaml) -->
+114 -8
View File
@@ -271,14 +271,32 @@
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<!-- Aide : avis, demande d'évolution, bug… pour le jeu sélectionné (absent sans « apiUrl » dans launcher.json) -->
<!-- Aide : avis, demande d'évolution, bug… pour le jeu sélectionné (absent sans « apiUrl » dans launcher.json,
masqué pendant une maintenance) -->
<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.IsHelpAvailable, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
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}"
Visibility="{Binding HasWebsite, Converter={StaticResource BoolToVisibility}}" />
Visibility="{Binding ShowWebsite, Converter={StaticResource BoolToVisibility}}" />
<!-- Paramètres du launcher : communs à tous les jeux -->
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Paramètres du launcher" Margin="4,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Cog}"
@@ -295,8 +313,9 @@
</StackPanel>
</Grid>
<!-- ===================================================== Contenu -->
<Grid Grid.Row="1" Margin="58,6,34,20">
<!-- ===================================================== Contenu (masqué pendant une maintenance) -->
<Grid Grid.Row="1" Margin="58,6,34,20"
Visibility="{Binding IsMaintenance, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<!-- Notes de version : 370 en mode fenêtré, plus larges en plein écran (jusqu'à 560) -->
@@ -489,6 +508,87 @@
</Border>
</Grid>
<!-- ===================================================== Mode maintenance (publié depuis le menu d'administration) -->
<!--
Carte au centre de la fenêtre, à la place de la présentation et des notes de version. La palette rouge / orange
(Themes/Maintenance.xaml) est ajoutée aux ressources de la fenêtre par MainWindow.xaml.cs pendant la maintenance.
Textes : ceux du launcher (titre, sous-titre) et, au choix, la précision publiée par l'administrateur (texte brut).
-->
<Grid Grid.Row="1" Margin="58,6,34,20"
Visibility="{Binding IsMaintenance, Converter={StaticResource BoolToVisibility}}">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Focusable="False">
<Grid HorizontalAlignment="Center" VerticalAlignment="Center" MaxWidth="660" Margin="0,8">
<!-- Halo de la carte (à part : l'ombre ne s'applique pas aux textes) -->
<Border CornerRadius="28" Background="{DynamicResource MaintenanceCardBrush}">
<Border.Effect>
<DropShadowEffect BlurRadius="44" ShadowDepth="0" Opacity="0.5" Color="#FF3B1F" />
</Border.Effect>
</Border>
<Border CornerRadius="28" Padding="44,26,44,34" BorderThickness="1"
BorderBrush="{DynamicResource MaintenanceCardBorderBrush}">
<StackPanel>
<!-- Bandes de chantier -->
<Border Width="132" Height="8" CornerRadius="4" Margin="0,0,0,26" HorizontalAlignment="Center"
Background="{DynamicResource MaintenanceStripesBrush}" />
<Grid Width="92" Height="92" HorizontalAlignment="Center">
<Border CornerRadius="30" Background="{DynamicResource AccentGradientBrush}" Opacity="0.7">
<Border.Effect>
<BlurEffect Radius="26" />
</Border.Effect>
</Border>
<Border CornerRadius="30" Background="{DynamicResource AccentGradientBrush}">
<Path Data="{StaticResource Icon.TrafficCone}" Fill="{DynamicResource OnAccentBrush}"
Width="46" Height="46" Stretch="Uniform" />
</Border>
</Grid>
<TextBlock Text="{Binding DataContext.MaintenanceTitle, RelativeSource={RelativeSource AncestorType=Window}}"
Margin="0,26,0,0" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
FontFamily="{DynamicResource HeroTitleFont}" FontSize="38" FontWeight="{DynamicResource HeroTitleWeight}"
FontStretch="{DynamicResource HeroTitleStretch}" />
<TextBlock Text="{Binding DataContext.MaintenanceSubtitle, RelativeSource={RelativeSource AncestorType=Window}}"
Margin="0,8,0,0" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
FontSize="18" Foreground="{DynamicResource TextSecondaryBrush}" />
<Border Width="72" Height="3" CornerRadius="1.5" Margin="0,22,0,0" HorizontalAlignment="Center"
Background="{DynamicResource AccentGradientBrush}" />
<!-- Précision publiée par l'administrateur : texte brut, sélectionnable, jamais interprété -->
<TextBox Text="{Binding DataContext.MaintenanceMessage, RelativeSource={RelativeSource AncestorType=Window}, Mode=OneWay}"
Margin="0,20,0,0" IsReadOnly="True" IsTabStop="False" BorderThickness="0" Background="Transparent"
Foreground="{DynamicResource TextPrimaryBrush}" CaretBrush="Transparent" Cursor="Arrow"
FontSize="15" TextAlignment="Center" TextWrapping="Wrap" HorizontalAlignment="Stretch"
Visibility="{Binding DataContext.MaintenanceMessage, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource StringToVisibility}}">
<TextBox.Template>
<ControlTemplate TargetType="TextBox">
<ScrollViewer x:Name="PART_ContentHost" Focusable="False" VerticalScrollBarVisibility="Disabled"
HorizontalScrollBarVisibility="Disabled" />
</ControlTemplate>
</TextBox.Template>
</TextBox>
<TextBlock Text="{Binding DataContext.MaintenanceSinceText, RelativeSource={RelativeSource AncestorType=Window}}"
Margin="0,18,0,0" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
FontSize="12.5" Foreground="{DynamicResource TextMutedBrush}"
Visibility="{Binding DataContext.MaintenanceSinceText, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource StringToVisibility}}" />
<Border HorizontalAlignment="Center" Margin="0,18,0,0" CornerRadius="14" Padding="14,8"
Background="{DynamicResource BadgeBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
<DockPanel>
<Path DockPanel.Dock="Left" Data="{StaticResource Icon.Lock}" Fill="{DynamicResource Accent2Brush}"
Width="13" Height="13" Stretch="Uniform" VerticalAlignment="Center" />
<TextBlock Margin="9,0,0,0" FontSize="12.5" TextWrapping="Wrap" VerticalAlignment="Center"
Foreground="{DynamicResource TextSecondaryBrush}"
Text="Installation, mises à jour et lancement suspendus. La désinstallation reste possible (menu « … »)." />
</DockPanel>
</Border>
</StackPanel>
</Border>
</Grid>
</ScrollViewer>
</Grid>
<!-- ===================================================== Barre d'action -->
<Border Grid.Row="2" Margin="24,0,24,24" Padding="28,20" CornerRadius="22"
Background="{DynamicResource ActionBarBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
@@ -500,7 +600,7 @@
<StackPanel VerticalAlignment="Center" Margin="0,0,32,0">
<Grid>
<TextBlock Text="{Binding StatusTitle}" FontSize="16" FontWeight="SemiBold"
<TextBlock Text="{Binding DisplayStatusTitle}" FontSize="16" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" Margin="0,0,90,0" />
<TextBlock Text="{Binding StepText}" HorizontalAlignment="Right" VerticalAlignment="Center"
FontSize="12" Foreground="{DynamicResource TextMutedBrush}" />
@@ -509,9 +609,9 @@
Value="{Binding ProgressValue, Mode=OneWay}"
IsIndeterminate="{Binding IsProgressIndeterminate, Mode=OneWay}"
Visibility="{Binding ShowProgress, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding StatusDetail}" Margin="0,8,0,0" FontSize="12.5"
<TextBlock Text="{Binding DisplayStatusDetail}" Margin="0,8,0,0" FontSize="12.5"
TextTrimming="CharacterEllipsis" Foreground="{DynamicResource TextSecondaryBrush}"
Visibility="{Binding StatusDetail, Converter={StaticResource StringToVisibility}}" />
Visibility="{Binding DisplayStatusDetail, Converter={StaticResource StringToVisibility}}" />
<StackPanel Orientation="Horizontal" Margin="0,10,0,0"
Visibility="{Binding ShowLocateHint, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Le jeu est déjà sur votre PC ?" Margin="0,0,10,0" FontSize="12.5"
@@ -533,6 +633,12 @@
<DataTrigger Binding="{Binding State}" Value="Running">
<Setter Property="Background" Value="{DynamicResource NeutralGradientBrush}" />
</DataTrigger>
<!-- Maintenance : bouton « MAINTENANCE » désactivé, aux couleurs de la maintenance -->
<DataTrigger Binding="{Binding IsMaintenance}" Value="True">
<Setter Property="Background" Value="{DynamicResource AccentGradientBrush}" />
<Setter Property="ToolTip" Value="Maintenance en cours : merci de revenir plus tard." />
<Setter Property="ToolTipService.ShowOnDisabled" Value="True" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
+47
View File
@@ -22,10 +22,52 @@ public partial class MainWindow : Window, IThemeTransition
private readonly Queue<DateTime> _logoClicks = new();
/// <summary>Palette rouge / orange du mode maintenance (Themes/Maintenance.xaml), ajoutée aux ressources de la fenêtre.</summary>
private static readonly Uri MaintenancePaletteUri = new("pack://application:,,,/GameLauncher;component/Themes/Maintenance.xaml");
private ResourceDictionary? _maintenancePalette;
private LauncherViewModel? _observed;
public MainWindow()
{
InitializeComponent();
PreviewKeyDown += OnPreviewKeyDown;
DataContextChanged += (_, _) => ObserveLauncher(DataContext as LauncherViewModel);
}
// ------------------------------------------------------------------ Mode maintenance
private void ObserveLauncher(LauncherViewModel? launcher)
{
if (_observed is not null) _observed.PropertyChanged -= OnLauncherPropertyChanged;
_observed = launcher;
if (launcher is null) return;
launcher.PropertyChanged += OnLauncherPropertyChanged;
ApplyMaintenancePalette(launcher.IsMaintenance);
}
private void OnLauncherPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(LauncherViewModel.IsMaintenance) && sender is LauncherViewModel launcher)
ApplyMaintenancePalette(launcher.IsMaintenance);
}
/// <summary>
/// Maintenance : la palette rouge / orange passe devant celle du thème du jeu, pour cette fenêtre seulement (les
/// ressources de Root sont trouvées avant celles de l'application). Le fondu enchaîné est fait par LauncherViewModel
/// (début et fin de la maintenance passent par <see cref="Run"/>, comme un changement de jeu).
/// </summary>
private void ApplyMaintenancePalette(bool active)
{
var merged = Root.Resources.MergedDictionaries;
if (active)
{
_maintenancePalette ??= new ResourceDictionary { Source = MaintenancePaletteUri };
if (!merged.Contains(_maintenancePalette)) merged.Add(_maintenancePalette);
}
else if (_maintenancePalette is not null)
{
merged.Remove(_maintenancePalette);
}
}
/// <summary>
@@ -42,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).");
}
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff. Show more