Compare commits

...
95 Commits
Author SHA1 Message Date
nicolas 2adbb3d4e6 Tests : avertissements de l’analyseur xUnit (2029, 2031) 2026-11-11 13:16:37 +01:00
nicolas d4f9a284d5 8e – Documentation 2026-11-11 13:16:11 +01:00
nicolas 0c9163db29 8e – UiSnapshot : scènes des blocages 2026-11-11 13:15:28 +01:00
nicolas 02cd677587 8e – Tests 2026-11-11 13:14:40 +01:00
nicolas b00d06b04c 8e – Menu d’administration : Bloquer… sur la fiche et page Blocages 2026-11-11 13:14:04 +01:00
nicolas b73aa0807e 8e – Client API et launcher : identifiants matériels envoyés à la connexion 2026-11-11 13:13:27 +01:00
nicolas 921bf7dff0 8e – Serveur : stockage (schéma 4), contrôle des blocages, routes d’administration, purge, export 2026-11-11 13:12:54 +01:00
nicolas 144e6b3dca 8e – Contrats : blocages, identifiants matériels, permission players.ban 2026-11-11 13:11:54 +01:00
nicolas 9ddc445de7 Docs : étape 8d 2026-11-11 11:49:35 +01:00
nicolas d6e96e63c1 UiSnapshot : scènes des comptes joueurs 2026-11-11 11:49:24 +01:00
nicolas 44b605b110 Menu d'administration : page « Comptes joueurs » (WPF) 2026-11-11 11:49:10 +01:00
nicolas d6e19fa7de Menu d'administration : page « Comptes joueurs » (ViewModels) et journal d'audit 2026-11-11 11:48:53 +01:00
nicolas f2e50295cb Fenêtre Compte : connexion avec un mot de passe temporaire 2026-11-11 11:48:18 +01:00
nicolas 5317956c56 Client API : méthodes des comptes joueurs 2026-11-11 11:47:54 +01:00
nicolas 1a005f5048 Tests serveur : comptes joueurs vus par les administrateurs 2026-11-11 11:47:44 +01:00
nicolas 26d80be5a3 Serveur : module PlayersAdmin (routes v1/players) 2026-11-11 11:47:30 +01:00
nicolas f2f80deac8 Serveur : connexion par mot de passe temporaire, motif de désactivation 2026-11-11 11:46:56 +01:00
nicolas 446eb703ff Serveur : schéma 3 (mot de passe temporaire), liste et compteurs des comptes 2026-11-11 11:46:34 +01:00
nicolas f98a35afe3 Contrats : permissions, routes et échanges du menu « Comptes joueurs » 2026-11-11 11:45:57 +01:00
nicolas dd3c1c780a Docs : étape 8c et README des comptes joueurs 2026-11-11 10:41:00 +01:00
nicolas f384618765 UiSnapshot : scènes de la double authentification 2026-11-11 10:40:51 +01:00
nicolas 234b45990b Fenêtre Compte : interface de la double authentification (WPF) 2026-11-11 10:40:37 +01:00
nicolas afc02147a8 Fenêtre Compte : écran du code et parcours de la double authentification (ViewModel) 2026-11-11 10:40:23 +01:00
nicolas e9f1f04898 Comptes : encodeur QR code maison 2026-11-11 10:40:04 +01:00
nicolas 8df16b8fbc Client : API et service de la double authentification 2026-11-11 10:39:46 +01:00
nicolas 87438a1593 Tests serveur : double authentification et séparation des jetons 2026-11-11 10:39:30 +01:00
nicolas be0a97b932 Serveur : commande de secours players-otp-reset 2026-11-11 10:39:00 +01:00
nicolas 52f6c0da25 Serveur : routes de la double authentification et connexion avec code 2026-11-11 10:38:42 +01:00
nicolas a8990ad8a8 Serveur : jeton de connexion en deux temps (glpo_) 2026-11-11 10:38:15 +01:00
nicolas 903d9c6159 Serveur : schéma 2 des comptes joueurs (secret OTP chiffré, codes de secours) 2026-11-11 10:38:03 +01:00
nicolas 44b086d5f0 Contrats : double authentification (TOTP, codes de secours, routes, DTO) 2026-11-11 10:37:37 +01:00
nicolas f125da4f60 Docs : comptes joueurs étape 8b 2026-11-11 09:51:26 +01:00
nicolas 0c6c686ceb UiSnapshot : captures de la fenêtre « Compte » et du bouton de la barre de titre 2026-11-11 09:51:14 +01:00
nicolas f07ffa156a Comptes joueurs 8b : tests du compte joueur côté launcher 2026-11-11 09:50:56 +01:00
nicolas 7885fe81e0 Comptes joueurs 8b : launcher – bouton et fenêtre « Compte », clé d'appareil, DPAPI 2026-11-11 09:50:35 +01:00
nicolas 2596736ddb Comptes joueurs 8b : module GameLauncher.Modules.Accounts (session, ViewModels) 2026-11-11 09:49:24 +01:00
nicolas c99e21c351 Comptes joueurs 8b : client de l'API joueurs 2026-11-11 09:48:38 +01:00
nicolas dd8bd00091 Comptes joueurs 8b : tests serveur (RGPD, étanchéité des nouvelles routes) 2026-11-11 09:48:16 +01:00
nicolas edcb902be5 Comptes joueurs 8b : serveur – activité, export, suppression, comptes inactifs 2026-11-11 09:47:49 +01:00
nicolas c00685eaec Comptes joueurs 8b : contrats (activité, export, suppression) 2026-11-11 09:46:48 +01:00
nicolas 02b7af8253 Comptes joueurs (8a) : déploiement et documentation
- launcher-api-players.conf : complément systemd (LoadCredentialEncrypted).
- LISEZMOI : étape 3 undecies (préparation, ne pas activer en production
  avant 8g), commandes de secours, fichiers sur le VPS.
- Nouveau docs/PLAYER-ACCOUNTS-ARCHITECTURE.md.
- ADMIN-ARCHITECTURE : dépendances NuGet, phase 8a au § 13, § 14 mis à jour.
2026-11-10 22:55:41 +01:00
nicolas 723df05cf3 Comptes joueurs (8a) : tests
- Règles des mots de passe, adresses, références, jetons.
- Création, connexion, blocages, suspension, session mémorisée (rotation,
  réutilisation, clé d'appareil), appareils, mot de passe, désactivation, purge.
- Étanchéité : toutes les routes admin déclarées essayées avec des jetons joueurs
  (401 partout), et l'inverse.
- Chiffrement, clé maîtresse, empreintes ; vecteur officiel Argon2id (RFC 9106).
2026-11-10 22:55:19 +01:00
nicolas 85e69e6a2c Comptes joueurs (8a) : commandes de secours en ligne de commande
players-key, players-show, players-unlock, players-disable [--until], players-enable.
Compte désigné par sa référence ; l'adresse e-mail n'est jamais affichée.
Actions inscrites au journal d'audit (cli.players_*).
2026-11-10 22:55:08 +01:00
nicolas 7a6ad2247b Comptes joueurs (8a) : module Accounts et authentification séparée des admins
- Routes : défi, création de compte, connexion, renouvellement, déconnexion,
  « Mon compte », changement de mot de passe (dont exigé), appareils.
- Schéma PlayerSession distinct ; les règles admin désignent explicitement
  AdminSession et le gestionnaire admin ignore les jetons glp… .
- PlayerGuard : défis sans état à usage unique, preuve de travail, limites par IP,
  blocage miroir des adresses inconnues.
- Anti-force brute : 5 échecs → 15/30/60 min ; 20 échecs consécutifs → suspension
  des nouveaux appareils (levée par un admin) ; appareils de confiance jamais suspendus.
- Session mémorisée liée à la clé d'appareil, jeton remplacé à chaque usage,
  réutilisation détectée.
- Limite de débit « account », ouverture de la base au démarrage, comptes annoncés
  dans v1/info, purge du journal des comptes (12 mois, IP effacées après 6 mois).
- appsettings.json : section Players (Enabled = false).
2026-11-10 22:54:57 +01:00
nicolas 19f207d430 Comptes joueurs (8a) : stockage SQLite chiffré et empreintes Argon2id
- Paquets Microsoft.Data.Sqlite 8.0.34 et Konscious.Security.Cryptography.Argon2 1.3.1.
- PlayerSecrets : clé maîtresse hors du dossier des données (credential systemd),
  sous-clés HKDF, adresses chiffrées AES-256-GCM liées au compte, index aveugle.
- PlayerPasswordHasher : Argon2id 19 Mio × 2, sel aléatoire 128 bits par compte,
  poivre = clé secrète d'Argon2, format PHC, file de 4 calculs, calcul fictif
  pour une adresse inconnue.
- PlayerStore : players/players.db (WAL, secure_delete, 0600, schéma versionné),
  comptes, appareils, journal des comptes, compteurs d'échecs et suspension.
- Réglages LauncherApi:Players (désactivé par défaut) et leur validation.
2026-11-10 22:54:05 +01:00
nicolas a35e419133 Comptes joueurs (8a) : protocole launcher ⇄ serveur
- Messages signés par la clé d'appareil (en-tête GameLauncher/Player/…,
  distinct des messages admin), types de défi (création, connexion, renouvellement).
- Jetons joueurs glpa_ (accès), glpr_ (renouvellement), glpc_ (changement exigé),
  identifiants et références de compte PL-XXXX-XXXX.
- Objets échangés, routes v1/account/…, 13 codes d'erreur, ServerInfo.Accounts.
- SignedMessages.Build passe en internal (réutilisé par PlayerMessages).
2026-11-10 22:53:17 +01:00
nicolas a9d07f6df8 Comptes joueurs (8a) : règles des mots de passe et des adresses e-mail
- PasswordRules : 12 caractères et 3 types, ou 16 sans contrainte ; 128 au plus.
  Refus : dictionnaire NCSC (99 839 mots de passe) et ses variantes simples,
  mots de passe français courants, suites (répétitions, claviers, suites de
  chiffres ou de lettres), mots tirés de l'adresse ou des noms du studio et des jeux.
  Aucune normalisation Unicode : même calcul dans le launcher et sur le serveur.
- PlayerEmail : contrôle et normalisation (ASCII), masquage pour l'affichage.
- Dictionnaire intégré compressé (430 Ko), avec son origine et sa licence (MIT).
2026-11-10 22:52:37 +01:00
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
243 changed files with 27547 additions and 326 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}
+145 -14
View File
@@ -32,17 +32,19 @@ 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) |
| `src/GameLauncher.ApiClient` | Client de la Launcher API : connexion par clé, requêtes signées, fichier de clé chiffré |
| `src/GameLauncher.ApiClient` | Client de la Launcher API : connexion par clé, requêtes signées, fichier de clé chiffré ; API des comptes joueurs (`PlayerAccountApi`) |
| `src/GameLauncher.Modules` | Socle des modules du launcher (sans WPF) : contrat d'un module, pages du menu d'administration, MVVM commun |
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux »), « Notes de version » (`Changelog/`) et « Publication » (`Publishing/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux »), « Notes de version » (`Changelog/`), « Publication » (`Publishing/`) et « Comptes joueurs » (`Players/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
| `src/GameLauncher.Modules.Feedback` | Module « Aide » côté joueur (sans WPF) : formulaire de demande (avis, demande d'évolution, bug, autre) et envoi anonyme ; fenêtre dans `src\GameLauncher\Views\FeedbackWindow.xaml` |
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
| `src/GameLauncher.Modules.Accounts` | Module « Compte » côté joueur (sans WPF) : connexion, création de compte, double authentification (TOTP, codes de secours, QR code), « Mon compte », session mémorisée ; fenêtre dans `src\GameLauncher\Views\AccountWindow.xaml` (voir §10) |
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration et des comptes joueurs, déployé sur le VPS (voir §8 et §10) |
| `website/GameLauncher.Website` | **Site vitrine** (ASP.NET Core Razor Pages) : présentation des jeux et du launcher, téléchargement, notes de version, actualités ; isolé de la Launcher API (voir §9) |
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
| `tools/UiSnapshot` | Captures de **toutes les fenêtres** (états de la fenêtre principale, dialogues, menu d'administration et ses pages) avec des données de démonstration, et rapport des problèmes de mise en page : `tools\ui-snapshot.bat` (voir `tools\UiSnapshot\LISEZMOI.md`) |
| `tools/UiSnapshot` | Captures de **toutes les fenêtres** (états de la fenêtre principale, dialogues, fenêtres « Aide » et « Compte », menu d'administration et ses pages) avec des données de démonstration, et rapport des problèmes de mise en page : `tools\ui-snapshot.bat` (voir `tools\UiSnapshot\LISEZMOI.md`) |
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
```
@@ -52,18 +54,20 @@ 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 »)
│ FeedbackWindow (bouton « Aide »), AccountWindow (bouton « Compte »)
│ └─ Admin/ AdminWindow, LoginView, EnrollView (menu d'administration ; ViewModels dans GameLauncher.Modules.Admin)
├─ ViewModels/ LauncherViewModel (jeux, thème actif, paramètres du launcher),
│ GameViewModel (machine à états d'un jeu),
│ LauncherSettingsViewModel / GameSettingsViewModel (les deux fenêtres de paramètres)
├─ Services/ ThemeManager, RegistryService, ShortcutService, InstanceChannel (raccourci → launcher
│ déjà ouvert), GameScopedServices, AdminWindowService (menu d'administration),
│ FeedbackWindowService (bouton « Aide »), interfaces
│ FeedbackWindowService (bouton « Aide »), AccountWindowService (bouton « Compte »), interfaces
└─ Platform/ DialogService, WindowService (implémentations WPF), TrayIcon (zone de notification),
ModuleDialogs, WpfUiDispatcher (services offerts aux modules)
ModuleDialogs, WpfUiDispatcher (services offerts aux modules),
PlayerDeviceKeyStore + DpapiProtector (clé d'appareil et session mémorisée des joueurs)
```
## 3. Publier une version du jeu (ManifestBuilder)
@@ -326,6 +330,7 @@ referme aussitôt. Il se ferme dès qu'il perd le premier plan (clic ailleurs) o
├─ self-update.json mise à jour du launcher (§7)
├─ launcher-state.json dernier jeu affiché (plusieurs jeux)
├─ window-modes.json plein écran ou fenêtré, par fenêtre (« main », « admin »)
├─ account\session.dat session mémorisée du compte joueur (chiffrée DPAPI, §10)
├─ logs\launcher.log journal du launcher (roue crantée › Ouvrir le dossier des journaux)
└─ Games\<GameId>\settings.json paramètres de chaque jeu (menu « … » › Paramètres du jeu)
```
@@ -366,7 +371,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 +433,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 +445,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 +468,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 +511,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 +537,9 @@ 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).
Phase 8 en cours : les **comptes joueurs** (voir §10), dont les pages **Comptes joueurs** (étape 8d) et **Blocages**
(étape 8e) du menu.
* **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 +547,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 +573,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 +667,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 +714,92 @@ 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`).
## 10. Comptes joueurs (phase 8, en cours)
Comptes **facultatifs** (adresse e-mail + mot de passe), jamais nécessaires pour jouer, distincts des comptes
d'administrateur. Architecture, sécurité et étapes : **`docs/PLAYER-ACCOUNTS-ARCHITECTURE.md`**. Étapes livrées :
8a (serveur), 8b (fenêtre « Compte » du launcher), 8c (double authentification), 8d (page « Comptes joueurs » du menu
d'administration), 8e (blocages). À venir : demandes connectées (8f), mise en production (8g), e-mails (8h).
> **Désactivé par défaut** et à n'activer qu'après l'étape 8g : serveur `LauncherApi:Players:Enabled = false`,
> launcher `"playerAccounts": false` dans `launcher.json` (en Release, seule la valeur intégrée à l'exe compte).
* **Bouton « Compte »** (barre de titre, à côté de « Aide ») : connexion, création de compte (règles du mot de passe
vérifiées en direct), changement de mot de passe exigé, puis **« Mon compte »** : profil, appareils connectés,
activité récente (IP masquées), changement de mot de passe, double authentification, export des données (JSON) et
suppression du compte (mot de passe exigé pour ces deux actions).
* **Session mémorisée** (« Rester connecté sur ce PC ») : liée à une **clé d'appareil** (puce TPM sans code PIN,
sinon clé CNG non exportable) ; jeton de renouvellement chiffré DPAPI dans
`%AppData%\<Publisher>\GameLauncher\account\session.dat`. 90 jours sans utilisation, 1 an au maximum.
* **Double authentification** (facultative, « Mon compte » › *Double authentification*) : codes à 6 chiffres
d'une application d'authentification (Microsoft Authenticator, Google Authenticator, Aegis, 2FAS, Bitwarden…).
Activation : mot de passe → QR code (ou clé à saisir) → code de confirmation → **10 codes de secours** affichés
une seule fois (à copier ou enregistrer). À la connexion, le code est demandé après le mot de passe ; un code de
secours le remplace (usage unique). Désactivation et nouveaux codes : mot de passe + code.
* **Sécurité côté serveur** : mots de passe Argon2id + poivre, adresses et secrets OTP **chiffrés** (SQLite
`players.db`, clé maîtresse hors du dossier des données), anti-force brute (blocages progressifs, suspension des
nouveaux appareils), chaque code OTP accepté une seule fois, journal des comptes (12 mois, IP effacées après
6 mois), comptes inactifs mis en sommeil puis supprimés. Règles RGPD / CNIL : `docs/PLAYER-ACCOUNTS-ARCHITECTURE.md` § 10.
* **Page « Comptes joueurs »** du menu d'administration (permissions `players.read`, `players.manage`, `players.delete`) :
compteurs, liste filtrée par état, recherche par référence (`PL-XXXX-XXXX`) ou adresse e-mail **exacte** (jamais affichée
en entier), fiche (état, double authentification, échecs, appareils, activité avec les adresses IP ; chaque consultation
est inscrite au journal d'audit). Actions signées et confirmées : désactiver (durée, motif montré au joueur) / réactiver,
lever une suspension ou un blocage, exiger un nouveau mot de passe, **mot de passe temporaire** (1 h, une seule fois, pour
choisir un nouveau mot de passe : réponse à « mot de passe oublié » en attendant les e-mails), réinitialiser la double
authentification, déconnecter des appareils, supprimer le compte. Le joueur voit ces actions « par l'équipe ».
* **Blocages** (permission `players.ban`) : « Bloquer… » sur la fiche d'un compte vise les **identifiants matériels** de
ses PC (installation de Windows et carte mère ; le launcher n'en envoie qu'une empreinte, re-hachée par le serveur) et
sa **dernière adresse IP** ; la page **« Blocages »** bloque une adresse ou une plage IP seule et lève les blocages.
Au choix : création de compte seule, ou création + connexion. Une adresse IP est bloquée un an au plus. Le joueur
bloqué voit seulement la référence du blocage (`BN-XXXX-XXXX`), à donner au support.
* Captures : `tools\ui-snapshot.bat --filter "account-*,main-account,admin-page-players-*"`.
| Pour… | Où |
|---|---|
| Préparer le VPS (clé maîtresse, réglages) | `server/deploy/LISEZMOI.md`, étape 3 undecies |
| Réglages (inscriptions, durées, émetteur OTP `OtpIssuer`…) | `server/GameLauncher.Server/appsettings.json` › `LauncherApi:Players` |
| Gérer un compte (désactiver, lever une suspension, mot de passe temporaire, double authentification, appareils, suppression) | Menu d'administration › **Comptes joueurs** |
| Bloquer un joueur (ses PC, sa dernière IP) ou une adresse / plage IP, lever un blocage | Fiche du compte › **Bloquer…** ; menu d'administration › **Blocages** |
| Secours sur le VPS : état, déblocage, désactivation / réactivation, double authentification | `GameLauncher.Server players-show` / `players-unlock` / `players-disable` / `players-enable` / `players-otp-reset` `--ref PL-XXXX-XXXX` |
+104 -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,12 @@ 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 |
| `players.read` | ✔ | ✔ | | Comptes joueurs : liste, recherche, fiche (adresse masquée, appareils, activité avec IP ; consultations inscrites au journal) — `PLAYER-ACCOUNTS-ARCHITECTURE.md` § 3.7 |
| `players.manage` | ✔ | ✔ | | Comptes joueurs : désactiver / réactiver, lever une suspension, nouveau mot de passe exigé, mot de passe temporaire, réinitialiser l'OTP, déconnecter des appareils (requêtes signées) |
| `players.delete` | ✔ | | | Comptes joueurs : suppression définitive (requête signée) |
| `players.ban` | ✔ | ✔ | | Blocages : identifiants matériels et adresse IP depuis la fiche d'un compte, adresse ou plage IP seule, levée (requêtes signées) ; page « Blocages » — `PLAYER-ACCOUNTS-ARCHITECTURE.md` § 3.8 |
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 +832,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 +873,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 +973,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 +1023,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 +1059,13 @@ 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é | ✅ |
| **8c** | Comptes joueurs — double authentification facultative : TOTP (6 chiffres, 30 s, toutes applications d'authentification), 10 codes de secours (empreintes HMAC), connexion en deux étapes (jeton `glpo_`, 5 essais, rien n'est révélé avant le code), activation par QR code (encodeur maison, aucune dépendance) depuis « Mon compte », désactivation et nouveaux codes (mot de passe + code), commande de secours `players-otp-reset` | ✅ |
| **8d** | Comptes joueurs — page « Comptes joueurs » du menu (module serveur `PlayersAdmin`, permissions `players.read` / `players.manage` / `players.delete`) : compteurs, liste filtrée, recherche par référence ou adresse **exacte** (corps de requête, jamais dans l'adresse ni l'audit), fiche (adresse masquée, appareils, activité avec IP, chaque consultation journalisée) ; désactivation (durée, motif montré au joueur, sans lien), réactivation, levée d'une suspension ou d'un blocage, nouveau mot de passe exigé, **mot de passe temporaire** (60 min, une seule fois, n'ouvre pas de session, ne remplace pas l'ancien ; empreinte HMAC, schéma v3), réinitialisation de l'OTP, déconnexion d'appareils, suppression ; catégorie « Comptes joueurs » du journal d'audit | ✅ |
| **8e** | Comptes joueurs — blocages (permission `players.ban`) : depuis la fiche, identifiants matériels du PC (empreintes envoyées par le launcher, re-hachées HMAC par le serveur) et dernière adresse IP ; page « Blocages » : adresse ou plage IP seule (IPv4 /16, IPv6 /32 au plus large, un an au plus) ; portée « création de compte » ou « création + connexion » (reconnexion automatique comprise) ; refus 403 `player_blocked` avec la référence `BN-XXXX-XXXX` ; levée ; note interne chiffrée ; purge des blocages échus ; schéma v4 ; audit `players.ban` / `players.unban` / `players.bans_purge` | ✅ |
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 +1074,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 |
+464
View File
@@ -0,0 +1,464 @@
# Comptes joueurs — Architecture (phase 8)
> Document de référence du module « comptes joueurs ». Plan validé le 2026-11-10 (copie du plan dans les documents du
> projet : `GameLauncher-Comptes-Joueurs-Plan.md`). Statut des étapes : § 12.
> À relire avec `ADMIN-ARCHITECTURE.md` (socle, principes de sécurité) et la fiche RGPD / CNIL du projet.
## 1. Besoin
* Compte joueur = **adresse e-mail + mot de passe**, **OTP (TOTP) facultatif** (étape 8c).
* **Jamais obligatoire** pour lancer le launcher, installer ou jouer (jeux solo : anonyme). Obligatoire seulement pour
la future partie sociale.
* Bouton « Aide » : **anonyme** (comme aujourd'hui, aucune réponse) **ou connecté** (suivi + échanges avec les
administrateurs) — étape 8f.
* Menu admin (étape 8d, § 3.7), avec permissions : supprimer, désactiver / réactiver (durée ou indéfini), forcer le changement
de mot de passe, mot de passe temporaire, réinitialiser l'OTP ; blocages par IP publique et par identifiants matériels
du PC (étape 8e, § 3.8).
* **Aucun lien** avec les comptes administrateurs : un compte joueur ne peut **jamais** ouvrir le menu d'administration.
* Dictionnaire public des pires mots de passe ; anti-force brute (blocage temporaire puis suspension levée par un
administrateur) ; **connexion mémorisée** (comme Steam) ; **audit** de chaque action joueur et administrateur.
## 2. Principes
1. **Le serveur est la seule barrière** : chaque règle est revérifiée côté serveur avec le même code partagé
(`GameLauncher.Contracts.Accounts`).
2. **Deux mondes étanches** : administrateurs = clé publique/privée et requêtes signées ; joueurs = mot de passe.
Schémas d'authentification ASP.NET distincts (`AdminSession` / `PlayerSession`), jetons de préfixes distincts
(`glpa_`, `glpr_`, `glpc_` pour les joueurs), stockages distincts (`admins.json` + sessions en mémoire / `players.db`),
routes distinctes (`v1/account/…`). Chaque règle d'autorisation d'administration **désigne explicitement** le schéma
`AdminSession` ; le gestionnaire administrateur ignore tout jeton commençant par `glp`. Un test essaie **toutes** les
routes d'administration déclarées avec des jetons joueurs (accès, changement exigé, renouvellement) : 401 attendu
partout (`PlayerSeparationTests`).
3. **Minimisation** : ni nom, ni date de naissance, ni adresse postale, ni nom Windows du PC. Le pseudo viendra avec le
module social.
4. **Une copie des données du serveur ne livre rien d'exploitable** : adresses chiffrées, mots de passe en Argon2id avec
un « poivre », clé maîtresse hors du dossier des données.
5. **Désactivé par défaut** (`LauncherApi:Players:Enabled = false`) ; création de compte désactivable séparément.
6. Le launcher ne contacte l'API que si le joueur l'a voulu (connexion, création) ou s'il a une session mémorisée.
## 3. Protocole
### 3.1 Clé d'appareil
Le launcher crée une clé ECDSA P-256 **par PC et par compte Windows** (`Platform\PlayerDeviceKeyStore.cs`) : dans la puce
TPM 2.0 (« Microsoft Platform Crypto Provider ») si elle existe, **sans code PIN**, jamais exportable ; sinon clé logicielle
du « Microsoft Software Key Storage Provider », marquée **non exportable** et protégée par Windows. Nom de clé propre aux
joueurs (`GameLauncher.PlayerDevice.<éditeur><dossier>.v1`), distinct des clés d'administrateur. La création de compte, la connexion et chaque renouvellement de
session sont **signés** par cette clé (`PlayerMessages.Device`) :
```
GameLauncher/Player/Device/v1
purpose:<register|login|refresh>
origin:https://launcher.lanisys.fr
server:<serverId>
challenge:<challengeId>
nonce:<nonce>
device:<keyId de la clé d'appareil>
```
En-tête propre aux joueurs (séparation de domaine avec `GameLauncher/Admin/…`). Bénéfices : un jeton de renouvellement
copié sur un autre PC est inutilisable ; seul un appareil déjà connecté avec succès est « de confiance » pour
l'anti-force brute.
### 3.2 Défi
`POST v1/account/challenge { purpose }` → `{ challengeId, nonce, purpose, difficulty, serverId, origin, expiresAt }`.
Défi **sans état** (usage, échéance, difficulté et nonce scellés par HMAC, secret en mémoire, domaine
`GameLauncher/Player/Challenge/v1`), valable 5 min, **à usage unique** (consommé seulement avec une signature d'appareil
valide). Preuve de travail (`FeedbackProof`) : 20 bits pour la création de compte, 16 pour la connexion, aucune pour le
renouvellement. Un défi d'un usage ne sert pas à un autre.
### 3.3 Routes
| Route | Accès | Rôle |
|---|---|---|
| `POST v1/account/challenge` | anonyme | Défi (§ 3.2) |
| `POST v1/account/register` | anonyme + défi + clé d'appareil | Création de compte → session ouverte |
| `POST v1/account/login` | anonyme + défi + clé d'appareil | Connexion → session, ou jeton « changement exigé » |
| `POST v1/account/refresh` | jeton de renouvellement + signature de l'appareil | Session renouvelée (jeton remplacé) |
| `POST v1/account/logout` | session | Déconnexion de cet appareil, ou de tous (`allDevices`) |
| `GET v1/account/me` | session | Compte (adresse **masquée**) et appareils connectés |
| `POST v1/account/password` | session ou jeton « changement exigé » | Nouveau mot de passe |
| `DELETE v1/account/devices/{deviceId}` | session | Déconnecter un appareil du compte |
| `GET v1/account/activity` | session | 50 derniers événements du compte : type, résultat, appareil s'il est toujours connecté, IP **masquée** (`203.0.113.x`, IPv6 en /48), aucun détail interne |
| `POST v1/account/export` | session + **mot de passe** | Export JSON de toutes ses données (`GameLauncher/PlayerData/v1`, § 10) |
| `POST v1/account/delete` | session + **mot de passe** | Suppression définitive du compte (§ 10) |
| `POST v1/account/login/otp` | jeton « code de vérification » (`glpo_`) | Connexion, 2ᵉ étape : code TOTP ou code de secours (§ 3.6) |
| `POST v1/account/otp/setup` | session + **mot de passe** | Double authentification : secret et adresse `otpauth://` (renvoyés une fois) |
| `POST v1/account/otp/enable` | session | Activation par un premier code ; renvoie 10 codes de secours |
| `POST v1/account/otp/disable` | session + **mot de passe + code** | Désactivation |
| `POST v1/account/otp/recovery-codes` | session + **mot de passe + code** | Nouveaux codes de secours (les anciens sont annulés) |
Limite de débit propre (`account`, `Players:AuthRequestsPerMinute`, 20/min par IP, IPv6 par /64) sur le défi, la
création, la connexion, le renouvellement, le changement de mot de passe, l'export et la suppression, en plus de la limite générale. `GET v1/info`
annonce `accounts` (activés, création ouverte, version des conditions, version des règles des mots de passe) ; absent
si les comptes sont désactivés.
### 3.4 Jetons et session mémorisée
| Jeton | Format | Durée | Où |
|---|---|---|---|
| Accès | `glpa_` + 256 bits | 15 min | mémoire du launcher ; serveur : empreinte en mémoire |
| Renouvellement | `glpr_` + identifiant d'appareil + `.` + 256 bits | 90 jours sans utilisation, 1 an au plus | PC (`account\session.dat`, chiffré DPAPI) si « Rester connecté » ; serveur : empreinte en base |
| Changement exigé | `glpc_` + 256 bits | 10 min, usage unique | mémoire |
| Code de vérification | `glpo_` + 256 bits | 5 min, 5 essais, usage unique | mémoire (2ᵉ étape de la connexion, § 3.6) |
Renouvellement : jeton **remplacé à chaque fois** (l'ancien devient « précédent ») ; un jeton précédent présenté de
nouveau = copie volée (ou réponse perdue) → l'appareil est déconnecté par précaution (`session.reuse`). Deux
renouvellements simultanés avec le même jeton : un seul réussit. Un redémarrage du service efface les jetons d'accès : le
launcher renouvelle sa session sans rien demander au joueur.
À chaque requête authentifiée : jeton valide **et** compte actif (ou suspendu, § 5) **et** appareil toujours enregistré
**et** aucun changement de mot de passe exigé. Une désactivation, une déconnexion ou un changement de mot de passe
prennent effet immédiatement.
### 3.5 Launcher (étape 8b)
* **Bouton « Compte »** dans la barre de titre (icône de compte, pastille verte = connecté, orange = reconnexion ou
serveur injoignable), présent seulement si `"playerAccounts": true` **et** `apiUrl` sont dans le `launcher.json`
**intégré** (Release) ; masqué pendant une maintenance. Le launcher ne contacte l'API des comptes que si une session est
mémorisée (reprise au démarrage, sans bloquer l'affichage) ou quand le joueur ouvre la fenêtre.
* **Fenêtre « Compte »** (`Views\AccountWindow.xaml`, ViewModel `AccountWindowViewModel` du projet
`GameLauncher.Modules.Accounts`, sans WPF, testé) : connexion (« Rester connecté sur ce PC », aide « mot de passe
oublié »), création de compte (règles du mot de passe vérifiées **en direct** avec `PasswordRules`, confirmation,
acceptation des conditions), nouveau mot de passe exigé, puis **Mon compte** : Profil (adresse masquée, référence à
copier, dates, changement de mot de passe), Appareils (déconnecter un appareil, tous les appareils), Activité (adresses
IP masquées, essais refusés signalés), Mes données (export JSON, suppression définitive : case à cocher + confirmation
+ mot de passe).
* **Mots de passe** : jamais liés au ViewModel ; lus dans les `PasswordBox` au moment de l'action, puis effacés. Seul le
mot de passe tapé à la connexion est gardé en mémoire quelques minutes quand un administrateur exige d'en changer (le
serveur le redemande).
* **Session** (`PlayerAccountService`) : jeton d'accès en mémoire, renouvelé une minute avant son échéance ou aussitôt
après un redémarrage du serveur (un seul renouvellement à la fois) ; jeton de renouvellement enregistré **chiffré DPAPI**
(`Platform\DpapiProtector.cs`, utilisateur Windows, entropie `GameLauncher/Player/Session/v1`, écriture atomique) et
remplacé sur le disque à chaque renouvellement. Session ignorée si elle vient d'un autre serveur ou d'une autre clé
d'appareil ; illisible (autre utilisateur, autre PC) = supprimée. Sans « Rester connecté », rien n'est écrit et la
session est fermée sur le serveur à la sortie du launcher (3 s au plus). Session refusée au renouvellement (expirée,
déconnectée ailleurs, compte désactivé) : effacée, raison affichée à l'ouverture de la fenêtre ; serveur injoignable :
conservée (« hors ligne »), nouvel essai à la prochaine action.
* **Nom de l'appareil** envoyé au serveur : générique (« Windows 11 · GameLauncher 3.5.0 »), jamais le nom Windows du PC.
* Captures : `tools\ui-snapshot.bat --filter "account-*,main-account"`.
### 3.6 Double authentification (étape 8c)
* **Facultative**, activée par le joueur depuis « Mon compte › Profil ». **TOTP** (RFC 6238 : HMAC-SHA1, 6 chiffres,
30 s), compatible avec toutes les applications d'authentification (Microsoft Authenticator, Google Authenticator,
Aegis, 2FAS, Bitwarden…). Secret aléatoire de **160 bits**.
* **Activation en deux temps** : `otp/setup` (mot de passe exigé) génère le secret, le garde **chiffré** comme « en cours »
(10 min) et le renvoie **une seule fois** avec l'adresse `otpauth://totp/LanisysGames:j***@e***.fr?…` (adresse
masquée, émetteur `Players:OtpIssuer`) ; le launcher en dessine le **QR code** avec son propre encodeur
(`QrCode`, aucune dépendance, vérifié contre un décodeur indépendant) et affiche la clé à saisir. `otp/enable` exige un
premier code (preuve que l'application est bien configurée), active la double authentification et renvoie **10 codes
de secours** (`7KQ2-M9XD`, 40 bits, alphabet sans lettres ambiguës), affichés une seule fois : le launcher propose de les
enregistrer dans un fichier ou de les copier, et n'autorise « Terminer » qu'une fois la case « mis en lieu sûr » cochée.
* **Connexion** : mot de passe correct → `otp_required` + jeton `glpo_` (5 min, 5 essais) ; **rien d'autre** n'est
révélé ni modifié avant le code (état du compte, réveil d'un compte en sommeil, remise à zéro des compteurs). Puis
`login/otp` avec le code de l'application ou un code de secours → session (ou nouveau mot de passe exigé). La session
mémorisée se renouvelle **sans code** (l'appareil prouve sa clé) ; le code n'est demandé qu'avec le mot de passe.
* **Codes à usage unique** : pas de temps courant ± 1 (téléphone décalé de 30 s), comparaison à durée constante, et un
pas de temps déjà accepté est refusé (`totp_last_step`, mis à jour atomiquement) : un code intercepté ne sert pas deux
fois. Code de secours : consommé (une seule utilisation), nombre restant renvoyé et affiché (alerte à 3 ou moins).
* **Force brute** : un code faux compte comme un **échec de connexion** (mêmes compteurs, blocages et suspension que les
mots de passe, appareils de confiance à part) ; 5 essais au plus par jeton, puis reprise complète (preuve de travail,
mot de passe, Argon2id). Désactivation et nouveaux codes : mot de passe **et** code exigés ; un code faux depuis une
session ouverte compte comme un échec « appareil de confiance » (blocage puis fermeture des sessions).
* **Stockage** : secret chiffré AES-256-GCM (lié au compte, champ `totp`), codes de secours sous forme d'**empreinte
HMAC-SHA256** à clé secrète dérivée de la clé maîtresse (40 bits : sans la clé, impossible de les retrouver à partir
d'une copie de la base). Ni le secret ni les codes ne sont exportés (§ 10).
* **Téléphone et codes perdus** : page « Comptes joueurs » du menu › « Réinitialiser la double authentification » (§ 3.7)
ou `players-otp-reset --ref …` (§ 8), après vérification de l'identité par l'équipe.
### 3.7 Menu d'administration « Comptes joueurs » (étape 8d)
Module serveur `PlayersAdmin` (routes `v1/players/…`, schéma `AdminSession` explicite : aucun jeton joueur accepté,
vérifié par `PlayerSeparationTests`), page **« Comptes joueurs »** du menu (module `PlayersAdminModule`, ViewModel
`PlayersPanelViewModel`, sans WPF, testé). Trois permissions intégrées : `players.read`, `players.manage`,
`players.delete` (Propriétaire d'office ; Administrateur : lecture et gestion sur une installation neuve ; groupes existants
inchangés, à compléter depuis « Groupes et permissions »).
| Route | Permission | Signée | Rôle |
|---|---|:-:|---|
| `GET v1/players/overview` | `players.read` | | Compteurs (total, actifs, désactivés, suspendus, bloqués, en sommeil, nouveau mot de passe exigé, OTP, créés en 30 jours) et durées de conservation ; `enabled: false` si le service est désactivé (le reste répond 503) |
| `GET v1/players?filter=&cursor=&limit=` | `players.read` | | Liste, les plus récents d'abord (curseur `création:identifiant`), filtres `active`, `disabled`, `suspended`, `locked`, `inactive`, `password`, `otp` |
| `POST v1/players/search` | `players.read` | | Recherche par référence ou par adresse **exacte** (index aveugle) : dans le **corps** de la requête, jamais dans l'adresse (journaux nginx) ; inscrite à l'audit **sans** l'adresse |
| `GET v1/players/{référence}` | `players.read` | | Fiche : état, motif, auteur du changement, mot de passe, mot de passe temporaire en attente, OTP et codes restants, échecs et blocages, appareils, 100 dernières lignes d'activité avec l'IP **complète** |
| `POST v1/players/{référence}/disable` | `players.manage` | ✔ | Désactivation jusqu'à une date (10 ans au plus) ou indéfiniment, **motif facultatif montré au joueur** (une ligne, 200 caractères, aucun lien : `PlayerAdminRules.CleanReason`), chiffré (champ `status-reason`) ; sessions fermées |
| `POST v1/players/{référence}/enable` | `players.manage` | ✔ | Réactivation (désactivé ou en sommeil) |
| `POST v1/players/{référence}/unlock` | `players.manage` | ✔ | Levée d'une suspension (compte réactivé) ou des blocages temporaires (compteurs remis à zéro) |
| `POST v1/players/{référence}/password-requirement` | `players.manage` | ✔ | Nouveau mot de passe exigé (sessions ouvertes fermées, reconnexion automatique refusée d'ici là) ou plus exigé |
| `POST v1/players/{référence}/temporary-password` | `players.manage` | ✔ | Mot de passe temporaire (ci-dessous), renvoyé **une seule fois** |
| `POST v1/players/{référence}/otp-reset` | `players.manage` | ✔ | Double authentification et codes de secours supprimés |
| `POST v1/players/{référence}/logout` | `players.manage` | ✔ | Déconnexion d'un appareil (`deviceId`) ou de tous |
| `DELETE v1/players/{référence}` | `players.delete` | ✔ | Suppression définitive (mêmes effets que la suppression par le joueur, § 10) ; audit `players.delete` |
* **Adresse jamais affichée en entier** dans le menu (forme masquée seulement) ; la recherche par adresse exacte suffit
pour retrouver le compte d'un joueur qui écrit au support.
* **Consultations journalisées** : `players.view` (liste par filtre, fiche ; une fois par compte et par clé toutes les
10 minutes), `players.search` (référence ou adresse, trouvé ou non). Actions : `players.disable`, `players.enable`,
`players.unlock`, `players.password_required`, `players.temporary_password` (jamais le mot de passe), `players.otp_reset`,
`players.logout`, `players.delete` — catégorie **« Comptes joueurs »** de la page « Journal d'audit » (commandes
`cli.players_*` comprises).
* **Vu du joueur** (D8) : chaque action est inscrite dans son activité « par l'équipe » (`account.disabled`,
`account.unlocked`, `account.password_reset_required`, `account.temporary_password`, `device.removed_by_team`…), jamais avec
le nom de l'administrateur (seul le journal d'audit le garde, comme l'auteur du dernier changement d'état). Désactivé, il
voit à la connexion la date de fin et le motif.
* **Mot de passe temporaire** (D6) : 80 bits aléatoires, affiché `K7Q2-M9XD-4TPA-W3HN` (alphabet sans lettre ambiguë ;
casse, espaces et tirets tolérés à la saisie), **60 minutes**, un seul à la fois (un nouveau annule le précédent). Il est
accepté à la connexion **à la place** du mot de passe, mais n'ouvre jamais de session : la connexion renvoie le jeton
« changement exigé » (`temporaryPassword: true` ; code de vérification d'abord si la double authentification est
activée), puis le changement de mot de passe le **redemande** (l'ancien mot de passe n'y est pas accepté) et l'efface.
L'ancien mot de passe reste valable tant que le nouveau n'est pas choisi ; tout changement de mot de passe efface un
mot de passe temporaire en attente. Stockage : empreinte **HMAC-SHA256** à clé dérivée de la clé maîtresse
(`temporary-password/v1`, liée au compte), échéance ; empreintes échues effacées chaque jour (`RetentionService`).
Sa création lève les blocages temporaires (identité vérifiée par l'équipe) mais pas une suspension. Refusé pour un
compte désactivé. Le launcher adapte l'écran « nouveau mot de passe » et l'aide « mot de passe oublié ».
* Toutes les actions demandent une **confirmation** dans le menu ; la page affiche le mot de passe temporaire une seule
fois (copier, puis « Transmis, masquer » ; oublié dès qu'un autre compte est affiché).
* Captures : `tools\ui-snapshot.bat --filter "admin-page-players-*"`.
### 3.8 Blocages par identifiants matériels et adresse IP (étape 8e, D10)
Permission **`players.ban`** (Propriétaire d'office ; Administrateur sur une installation neuve ; groupes existants
inchangés). Un blocage a une **portée** choisie à chaque fois : **création de compte seule** (`register` : les comptes
existants se connectent toujours) ou **création + connexion** (`register_login` : connexion, et renouvellement de session
— reconnexion automatique — refusés).
| Route | Permission | Signée | Rôle |
|---|---|:-:|---|
| `POST v1/players/{référence}/ban` | `players.ban` | ✔ | Blocage **depuis la fiche** : identifiants matériels connus du compte et/ou **dernière adresse IP connue** (IPv6 : son /64), portée, échéance, note interne |
| `GET v1/bans` | `players.ban` | | Blocages en vigueur, les plus récents d'abord (IP en clair : consultation inscrite à l'audit, `players.view` « blocages », une fois par clé toutes les 10 minutes) |
| `POST v1/bans` | `players.ban` | ✔ | Blocage d'une **adresse IP ou d'une plage seule**, sans compte (ex. vague de créations automatiques) |
| `DELETE v1/bans/{référence}` | `players.ban` | ✔ | Levée d'un blocage |
* **Identifiants matériels** (`PlayerHardwareIds`, launcher) : identifiant de l'installation de Windows (`MachineGuid`,
registre HKLM, lisible sans droits d'administrateur) et UUID du système (table SMBIOS de type 1, carte mère, lu par
`GetSystemFirmwareTable` ; valeurs « bidon » des fabricants écartées). Lus **seulement** à la connexion et à la création
de compte (une fois par lancement), jamais écrits sur le PC. Le launcher n'envoie qu'une **empreinte** :
`type:Base64Url(SHA-256("GameLauncher/Player/Hardware/v1\n{type}\n{valeur normalisée}"))` (4 au plus, dans la preuve
d'appareil) ; le serveur la **re-hache en HMAC-SHA256** à clé dérivée de la clé maîtresse (`hardware/v1`) avant tout
stockage : sans la clé maîtresse, la base ne permet ni de retrouver l'identifiant ni de le relier à une autre base. Le
menu n'affiche que les 12 premiers caractères de cette empreinte. Un launcher plus ancien, ou un PC où la lecture
échoue, n'envoie rien : seule l'adresse IP compte alors.
* **Adresses IP** (`PlayerBanRules`, mêmes règles dans le menu et sur le serveur) : adresse seule (IPv4 /32, IPv6 → son
**/64**, la plage d'un abonné) ou plage CIDR, **IPv4 /16 au plus large, IPv6 /32 au plus large**, plage locale refusée ;
**un an au plus, jamais indéfiniment** (une adresse change de titulaire : fournisseurs, CGNAT, réseaux partagés).
Identifiants matériels seuls : jusqu'à 10 ans ou indéfiniment.
* **Contrôle** (`PlayerBanGuard`, blocages gardés en mémoire 30 s, relus aussitôt après une création ou une levée) :
création de compte (toute portée, avant le mot de passe) ; connexion (portée `register_login`, **avant** le calcul du
mot de passe : rien n'est révélé sur le compte) ; renouvellement (identifiants matériels enregistrés avec l'appareil +
IP ; la session n'est pas supprimée, elle reprend à la levée). Réponse **403 `player_blocked`** : « impossible depuis cet
appareil ou cette connexion Internet », échéance et **référence `BN-XXXX-XXXX`** à donner au support — jamais la cible ni
la note. Chaque refus est compté (tentatives, dernière date) et inscrit au journal du compte visé quand l'adresse saisie
en a un (`account.blocked`, IP, référence du blocage).
* **Depuis la fiche** (D10) : par défaut, identifiants matériels **et** dernière IP ; la portée « création + connexion »
ferme aussi les sessions ouvertes du compte (les appareils mémorisés restent, leur reconnexion est refusée tant que le
blocage dure). Désactiver le compte reste l'action normale contre un joueur ; le blocage empêche d'en **recréer** un.
La fiche (`players.read`) montre les identifiants matériels (bloqués ou non), la dernière IP et les blocages qui
concernent le compte (créés depuis sa fiche, visant ses identifiants ou couvrant sa dernière IP) ; « Bloquer… » et
« Lever » demandent `players.ban`.
* **Note interne** facultative (une ligne, 300 caractères), chiffrée (champ `ban-note`), jamais montrée au joueur.
* **Supprimer un compte** ne lève pas ses blocages (sinon, supprimer puis recréer contournerait le blocage).
* Page **« Blocages »** du menu (`BansPanelViewModel`) : liste, levée, blocage d'une adresse ou d'une plage avec contrôle en
direct. Captures : `tools\ui-snapshot.bat --filter "admin-page-players-*"` et `--filter "account-signin-blocked"`.
## 4. Mots de passe et adresses
* **Règles** (`PasswordRules`, identiques launcher / serveur) : 12 caractères au moins avec 3 types parmi minuscules,
majuscules, chiffres, symboles ; ou 16 caractères sans contrainte (phrase de passe) ; 128 au plus ; pas de caractère
de contrôle. Refus : mots de passe du **dictionnaire** (« 100k-most-used-passwords-NCSC », 99 839 entrées, ressource
intégrée compressée, + une liste de mots de passe français courants) et leurs variantes simples (minuscules,
substitutions `0→o 1→i 3→e 4→a 5→s 7→t @→a $→s…`, chiffres et symboles retirés aux extrémités :
« P@ssw0rd2024! » ≡ « password ») ; suites triviales (répétitions, rangées de claviers AZERTY / QWERTY / QWERTZ, suites
de lettres ou de chiffres, motifs répétés) ; mot de passe tiré de l'adresse e-mail ou des noms du studio et des jeux
(moins de 8 caractères restants une fois ces mots retirés).
* **Aucune normalisation Unicode** : le serveur tourne en « globalisation invariante » (sans ICU), où elle n'est pas
disponible ; le mot de passe est contrôlé et haché tel qu'il est saisi, de la même façon partout.
* **Adresse** (`PlayerEmail`) : minuscules, caractères ASCII seulement (domaines internationaux sous forme `xn--`), un
seul `@`, 254 caractères au plus ; masquée pour l'affichage (`n***@e***.fr`). Non vérifiée tant que le module e-mail
n'existe pas (`emailVerified = false`).
* **Empreinte** (`PlayerPasswordHasher`) : Argon2id (RFC 9106 ; paquet `Konscious.Security.Cryptography.Argon2`, MIT),
19 Mio, 2 passes, parallélisme 1 (OWASP), sel 128 bits, **poivre = clé secrète d'Argon2** dérivée de la clé maîtresse.
Format PHC `$argon2id$v=19$m=19456,t=2,p=1,k=1$<sel>$<empreinte>` : un relèvement des réglages est appliqué à la
connexion suivante de chaque joueur. Calculs limités à 4 simultanés (file d'attente, 503 au-delà de 20 s) ; même calcul
pour une adresse inconnue (la durée de réponse ne révèle rien). Vecteur officiel de la RFC vérifié par un test
(`Argon2VectorTests`).
## 5. Anti-force brute
1. Limites de débit (nginx et ASP.NET) et preuve de travail.
2. **Par adresse IP** (en mémoire) : 30 échecs de connexion / 15 min tous comptes confondus → 429 ; 3 créations de
compte / heure et 5 / jour → 429 ; plafond global de 500 créations / jour → 503.
3. **Par compte, depuis un appareil inconnu** : blocage après **5 échecs consécutifs** (15 min, puis 30, puis 60 :
durée doublée à chaque blocage) ; au **4ᵉ blocage (20 échecs consécutifs)**, le compte est **suspendu**. Le blocage
s'applique avant tout calcul (429 + `Retry-After`).
4. **Appareils de confiance** (déjà connectés, prouvés par leur clé) : leurs échecs sont comptés à part (blocage de
15 min après 10 échecs), **jamais de suspension**.
5. **Suspension = connexions depuis un NOUVEL appareil refusées** jusqu'à ce qu'un administrateur la lève (outil en ligne
de commande, ou page « Comptes joueurs » du menu, § 3.7). Les appareils déjà connectés continuent de
fonctionner (`newDevicesSuspended` dans `GET v1/account/me`) : un tiers qui connaît l'adresse peut déclencher la
suspension, mais pas priver le joueur de son compte. Inscrite au journal d'audit (`players.suspend`, par référence).
6. **Rien ne révèle qu'une adresse a un compte** : message identique pour une adresse inconnue et un mauvais mot de
passe, même durée de calcul, et une adresse inconnue se **bloque exactement comme un compte réel** (blocage « miroir »
en mémoire). L'état du compte (désactivé, suspendu) n'est révélé qu'après un **mot de passe correct**. Seule exception
inévitable sans e-mail de confirmation : la création de compte indique qu'une adresse est déjà prise (atténué par les
points 1 et 2).
7. Une connexion réussie remet les compteurs à zéro.
## 6. Stockage
**SQLite retenu** pour ce module (analyse « module par module ») : relations (compte ⇄ appareils ⇄ journal, puis
conversations et blocages), unicité stricte des adresses sous transaction, volumes non bornés, recherches par index.
`Microsoft.Data.Sqlite` (Microsoft), sans Entity Framework, requêtes paramétrées uniquement.
* Fichier : `/var/lib/launcher-api/players/players.db` (dossier 0700, fichier 0600 créé avant l'ouverture ; les fichiers
`-wal` et `-shm` reprennent ses droits). Journal WAL, clés étrangères, `secure_delete` (lignes supprimées écrasées),
écriture synchrone complète. Schéma versionné (`PRAGMA user_version`, version 4 : double authentification en 2 (étape 8c), mot de passe temporaire en 3 (étape 8d), blocages en 4 (étape 8e)). Une seule connexion protégée par un
verrou ; l'outil en ligne de commande peut ouvrir la base en même temps.
* Tables : `accounts` (identifiant 128 bits, référence `PL-XXXX-XXXX`, index aveugle et adresse chiffrée, empreinte du
mot de passe, « changement exigé », secret OTP chiffré et dernier pas de temps accepté, secret en cours d'activation, état, échéance de désactivation, motif chiffré, auteur du changement d'état, empreinte et échéance du mot de passe temporaire,
dates, version des conditions acceptée, compteurs d'échecs et blocages) ; `devices` (clé publique, nom générique,
empreintes du jeton de renouvellement en vigueur et précédent, échéances) ; `recovery_codes` (empreintes HMAC des codes
de secours, date d'utilisation) ; `account_events` (journal, § 7) ; étape 8e : colonne `devices.hardware` (empreintes
serveur de l'appareil), `account_hardware` (empreintes serveur des identifiants matériels vus pour un compte, première et
dernière date), `bans` (référence, portée, note chiffrée, auteur, échéance, compte d'origine, tentatives) et
`ban_targets` (adresse du réseau et préfixe, ou empreinte serveur d'un identifiant matériel).
* **Chiffrement** (`PlayerSecrets`) : clé maîtresse de 256 bits → sous-clés HKDF-SHA256 par usage (chiffrement des
adresses, index aveugle, poivre, champs libres dont le secret OTP, le motif de désactivation et la note d'un blocage, empreintes des codes de secours, des mots de passe temporaires et des identifiants matériels). Adresse : AES-256-GCM, nonce aléatoire, **liée au compte** (données
associées : une valeur copiée sur un autre compte ne se déchiffre pas), préfixe de version de clé (`1.`). Recherche et
unicité par **index aveugle** HMAC-SHA256 de l'adresse normalisée.
* **Clé maîtresse hors du dossier des données** : credential systemd chiffré (`LoadCredentialEncrypted`,
`players-master-key`, lu dans `$CREDENTIALS_DIRECTORY`) ou `Players:MasterKeyFile` (chemin absolu). Le serveur refuse
de démarrer si la clé manque, est dans le dossier des données, est accessible à d'autres comptes, ou n'est pas une clé
de 32 octets. **À sauvegarder hors ligne** : perdue, les adresses sont illisibles et tous les mots de passe doivent
être réinitialisés. Commande : `GameLauncher.Server players-key`.
* Les demandes des joueurs restent en fichiers JSON (étape 8f : identifiant opaque du compte ajouté, messages dans
SQLite, chiffrés).
## 7. Audit
* **Actions des joueurs et du système sur un compte** → table `account_events` : création, connexion réussie ou
refusée, blocage, suspension, déconnexion (un appareil / tous), changement de mot de passe (dont exigé), appareil
déconnecté, renouvellement (une ligne par appareil et par jour au plus), réutilisation d'un jeton. Avec l'adresse IP et
l'identifiant de la clé d'appareil, **jamais l'adresse e-mail ni le mot de passe**. Les échecs sur une adresse sans
compte sont inscrits sans compte.
* **Actions des administrateurs et du système** → journal d'audit existant, par **référence** de compte : `players.suspend`
(suspension automatique), `cli.players_unlock`, `cli.players_enable`, `cli.players_disable`, `players.events_purge`,
`players.self_delete` (compte supprimé par son titulaire), `players.inactive` (bilan quotidien des comptes inactifs),
`players.inactive_delete` (compte inactif supprimé) ; menu d'administration (8d) : `players.view`, `players.search`,
`players.disable`, `players.enable`, `players.unlock`, `players.password_required`, `players.temporary_password`,
`players.otp_reset`, `players.logout`, `players.delete` (§ 3.7) ; blocages (8e) : `players.ban` (cibles, portée, échéance,
note), `players.unban`, `players.bans_purge` (blocages échus supprimés, identifiants matériels oubliés) (§ 3.8).
* Événements de la double authentification (8c) : `account.otp_challenge` (mot de passe correct, code demandé : visible du
joueur dans « Activité », il signale un mot de passe connu d'un tiers), `account.otp_failed`, `account.otp_enabled`,
`account.otp_disabled`, `account.recovery_codes`, `account.recovery_code_used`, `account.otp_reset` ; audit
`cli.players_otp_reset`.
* Nouveaux événements du journal des comptes (8b) : `account.export`, `account.delete` (essai refusé), `account.inactive`,
`account.reactivated`. Libellés français communs au launcher et au menu : `PlayerActivityText` (Contracts).
* Événements de l'étape 8d : `account.password_reset_required`, `account.password_reset_cancelled`,
`account.temporary_password`, `account.temporary_password_used`, `device.removed_by_team` (actions « par l'équipe »).
Libellés et catégorie « Comptes joueurs » de la page « Journal d'audit » : `AuditTexts`.
* Événement de l'étape 8e : `account.blocked` (connexion ou reconnexion automatique refusée par un blocage ; IP et
référence du blocage).
* **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`) ;
identifiants matériels d'un compte oubliés après `EventsRetentionMonths` sans être revus ; blocages échus supprimés. La
dernière IP de la fiche vient du journal : elle disparaît avec l'effacement des IP.
## 8. Outil en ligne de commande (secours, sur le VPS)
Un compte est désigné par sa **référence** ; l'adresse e-mail n'est jamais affichée (aucune clé maîtresse nécessaire).
| Commande | Rôle |
|---|---|
| `players-key` | Génère une clé maîtresse (sortie standard, à chiffrer avec `systemd-creds`) |
| `players-show --ref PL-XXXX-XXXX` | État, dates, échecs, appareils, derniers événements |
| `players-unlock --ref …` | Lève une suspension ou un blocage (compte réactivé) |
| `players-disable --ref … [--until AAAA-MM-JJ[THH:MM]]` | Désactive (UTC ; sans date : indéfiniment) |
| `players-enable --ref …` | Réactive |
| `players-otp-reset --ref …` | Désactive la double authentification (téléphone et codes de secours perdus, identité vérifiée au préalable) |
## 9. Réglages (`LauncherApi:Players`)
| Réglage | Défaut | Rôle |
|---|---|---|
| `Enabled` | `false` | Service des comptes |
| `RegistrationEnabled` | `true` | Création de compte ouverte |
| `MasterKeyFile` | vide | Clé maîtresse (vide : credential systemd `players-master-key`) |
| `TermsVersion` | `1` | Version des conditions à accepter à la création |
| `OtpIssuer` | `LanisysGames` | Nom affiché dans les applications d'authentification (sans « : ») |
| `HashMemoryKib`, `HashIterations`, `HashParallelism` | 19456, 2, 1 | Argon2id (coût minimal refusé en dessous de 19 Mio × 2) |
| `MaxConcurrentHashes` | 4 | Calculs simultanés |
| `RegisterProofDifficulty`, `LoginProofDifficulty` | 20, 16 | Preuves de travail |
| `FailuresPerLock`, `LockMinutes`, `SuspendAfterLocks` | 5, 15, 4 | Anti-force brute (appareils inconnus) |
| `TrustedFailuresPerLock` | 10 | Appareils de confiance |
| `FailedLoginsPerIpPer15Minutes` | 30 | Échecs par IP |
| `RegistrationsPerIpPerHour`, `…PerDay`, `MaxRegistrationsPerDay` | 3, 5, 500 | Créations de compte |
| `AuthRequestsPerMinute` | 20 | Limite de débit des routes `account` |
| `AccessTokenMinutes`, `RefreshIdleDays`, `RefreshMaxDays` | 15, 90, 365 | Sessions |
| `MaxDevicesPerAccount` | 20 | Au-delà, le moins récemment utilisé est déconnecté |
| `EventsRetentionMonths`, `EventsIpRetentionMonths` | 12, 6 | Conservation du journal des comptes |
| `InactiveDisableMonths`, `InactiveDeleteMonths` | 24, 12 | Comptes inactifs : mis en sommeil, puis supprimés (0 : désactivé) |
## 10. RGPD / CNIL
* Base légale : exécution du contrat (compte, échanges) ; intérêt légitime (journaux de sécurité, anti-force brute,
blocages). Aucun traitement par consentement.
* **Droits d'accès et de portabilité** (étape 8b) : « Mon compte › Mes données › Exporter » (`POST v1/account/export`,
mot de passe exigé car le document contient l'adresse complète) : compte (adresse en clair, dates, état et motif d'une
désactivation, nouveau mot de passe exigé, conditions acceptées), appareils (nom, identifiant de clé, dates), activité (1000 lignes au plus, avec IP et détails), sections des
autres modules, notes. Jamais l'empreinte du mot de passe, les jetons ni les clés publiques complètes. Export inscrit au
journal du compte.
* **Droit à l'effacement** (étape 8b) : « Supprimer mon compte » (`POST v1/account/delete`, mot de passe exigé) :
données des autres modules d'abord (`IPersonalDataProvider.Erase`), puis compte, appareils et journal du compte
(cascade SQLite, `secure_delete`), sessions fermées. Seule trace : une ligne `players.self_delete` du journal d'audit
(référence, IP, 12 mois). L'adresse redevient libre. Le jeu et ses sauvegardes sur le PC ne sont pas touchés.
* **`IPersonalDataProvider`** (serveur, `Modules\Accounts\PlayerPersonalData.cs`) : chaque futur module qui garde des
données liées à un compte (demandes connectées 8f, amis…) déclare son export et son effacement ; l'export et la
suppression du compte les couvrent sans modifier le module des comptes.
* **Comptes inactifs** (CNIL) : un compte **actif** sans aucune activité (connexion, renouvellement, création) depuis
`InactiveDisableMonths` (24 mois) est **mis en sommeil** (`inactive`, appareils déconnectés) ; une connexion avec le bon
mot de passe le **réveille**. Après `InactiveDeleteMonths` (12 mois) de sommeil, il est **supprimé** comme ci-dessus
(`players.inactive_delete`). Appliqué au démarrage puis chaque jour (`RetentionService`, ligne `players.inactive`). Les
comptes désactivés ou suspendus par un administrateur ne sont pas concernés. Le joueur ne peut pas encore être prévenu
avant l'échéance (module e-mail, étape 8h).
* **Blocages** (étape 8e) : base légale intérêt légitime (lutte contre la fraude et la triche, sécurité du service).
Identifiants matériels lus à la connexion et à la création de compte seulement, transmis sous forme d'empreinte,
re-hachés par le serveur ; oubliés 12 mois après leur dernière apparition ; inclus dans l'export du joueur (type,
12 premiers caractères de l'empreinte, dates) et effacés avec son compte (les blocages, eux, restent : ils ne contiennent
que des empreintes, sans lien avec l'adresse). IP bloquées un an au plus.
**Point à trancher en 8g (AIPD)** : la lecture d'identifiants du terminal relève de l'article 82 de la loi
Informatique et Libertés (« traceurs ») ; elle n'est permise sans consentement que si elle est strictement nécessaire
au service demandé ou à sa sécurité. L'argument « sécurité du service, lutte contre la fraude » doit être validé et
documenté (AIPD, politique de confidentialité, mention dans la fenêtre de connexion), sinon prévoir une information
dédiée ou désactiver l'envoi.
* Étape 8g : registre des traitements, politique de confidentialité et CGU, brouillon d'AIPD (mineurs, identifiants
matériels — voir ci-dessus —, messagerie).
## 11. Limites assumées
* Adresse non vérifiée tant que le module e-mail n'existe pas (étape 8h) : n'importe qui peut créer un compte avec une
adresse qui n'est pas la sienne ; l'administrateur peut le supprimer (page « Comptes joueurs »).
* La création de compte révèle qu'une adresse est déjà utilisée (§ 5, point 6).
* Rotation de la clé maîtresse : prévue par le format (version de clé), pas encore outillée (elle invaliderait aussi les
empreintes matérielles et donc les blocages matériels).
* Blocages (8e) : contournables par un joueur déterminé (identifiants modifiables avec des outils, réinstallation de
Windows pour `MachineGuid`, machine virtuelle, VPN ou autre connexion Internet) ; ils freinent, ils n'empêchent pas.
Une IP peut être partagée (box familiale, établissement, opérateur mobile / CGNAT) : un blocage IP touche aussi des
innocents, d'où la durée limitée et les plages bornées. Derrière nginx, l'IP vue est celle que nginx transmet
(`X-Forwarded-For`, accepté uniquement depuis 127.0.0.1, voir `ADMIN-ARCHITECTURE.md`) : à vérifier à la mise en
production (8g).
## 12. Étapes
| Étape | Contenu | Statut |
|---|---|---|
| **8a** | Socle serveur : contrats (`Contracts/Accounts`), module `Accounts`, schéma `PlayerSession`, base SQLite chiffrée, Argon2id + poivre, création / connexion / renouvellement / déconnexion / mot de passe / appareils, anti-force brute, journal des comptes, purge, outil en ligne de commande, tests (dont l'étanchéité sur toutes les routes d'administration) | ✅ |
| **8b** | Launcher : bouton Compte, fenêtre de connexion / création / nouveau mot de passe exigé, session mémorisée (clé d'appareil TPM ou CNG non exportable, jeton chiffré DPAPI), « Mon compte » (profil, appareils, activité, export, suppression) ; serveur : activité, export, suppression, `IPersonalDataProvider`, comptes inactifs ; captures UiSnapshot ; tests | ✅ |
| **8c** | Double authentification : TOTP (RFC 6238), codes de secours, connexion en deux étapes, activation / désactivation / nouveaux codes depuis « Mon compte », encodeur de QR codes maison, `players-otp-reset`, schéma v2, UiSnapshot, tests | ✅ |
| **8d** | Menu d'administration « Comptes joueurs » (§ 3.7) : compteurs, liste, recherche par référence ou adresse exacte, fiche (activité avec IP), désactivation avec motif, réactivation, levée d'une suspension, nouveau mot de passe exigé, mot de passe temporaire (schéma v3), réinitialisation de l'OTP, appareils, suppression ; permissions `players.*` ; journal d'audit ; UiSnapshot ; tests | ✅ |
| **8e** | Blocages (§ 3.8) : identifiants matériels du PC (empreintes, HMAC serveur) et adresse IP / plage, portée « création » ou « création + connexion », depuis la fiche (identifiants + dernière IP) ou page « Blocages » (IP seule), levée, échéance (IP : un an au plus), schéma v4, permission `players.ban`, export RGPD, purge ; UiSnapshot ; tests | ✅ |
| 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

+15 -2
View File
@@ -19,7 +19,8 @@ public static class AdminCli
private static readonly string[] Commands = { "add-owner", "add-key", "list", "revoke-admin", "revoke-key", "server-id", "help" };
public static bool IsCommand(string[] args) =>
args.Length > 0 && Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase);
args.Length > 0 && (Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase) ||
PlayersCli.Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase));
public static int Run(string[] args, TextWriter? output = null, string? dataDirectoryOverride = null)
{
@@ -29,6 +30,9 @@ public static class AdminCli
var command = args[0].ToLowerInvariant();
if (command == "help") return Help(o);
var options = ReadOptions(args);
// Clé maîtresse des comptes joueurs : n'utilise pas le dossier des données (peut être lancée par root, la sortie
// étant directement chiffrée par systemd-creds).
if (command == "players-key") return PlayersCli.Key(o, interactive: output is null);
// Lancé par erreur avec « sudo » (root) au lieu de « sudo -u deploy » : les fichiers recréés
// appartiendraient à root et le service ne pourrait plus les lire.
@@ -37,7 +41,7 @@ public static class AdminCli
"ne lancez pas cet outil en root : sudo -u deploy /opt/launcher-api/GameLauncher.Server " + command + " …");
var dataDirectory = dataDirectoryOverride ?? ResolveDataDirectory(options);
if (command != "add-owner" && command != "server-id" &&
if (command != "add-owner" && command != "server-id" && !command.StartsWith("players-", StringComparison.Ordinal) &&
!File.Exists(Path.Combine(dataDirectory, AdminStore.FileName)))
throw new InvalidOperationException(
$"aucun fichier {AdminStore.FileName} dans {dataDirectory} : mauvais dossier de données ? (option --data-dir)");
@@ -57,6 +61,12 @@ public static class AdminCli
case "server-id":
o.WriteLine(ServerIdentity.LoadOrCreate(dataDirectory));
return 0;
case "players-show":
case "players-unlock":
case "players-disable":
case "players-enable":
case "players-otp-reset":
return PlayersCli.Run(command, options, dataDirectory, audit, actor, o);
default: return Help(o);
}
}
@@ -219,6 +229,9 @@ public static class AdminCli
revoke-key --key <identifiant> Révoque une clé (poste perdu ou volé).
server-id Affiche l'identifiant de ce serveur.
Comptes joueurs (désignés par leur référence ; l'adresse e-mail n'est jamais affichée) :
""" + "\n" + PlayersCli.HelpText + "\n" + """
Option commune : --data-dir <dossier> (sinon LauncherApi:DataDirectory d'appsettings.json).
""");
return 0;
@@ -0,0 +1,130 @@
using System.Globalization;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Storage;
using GameLauncher.Server.Storage.Players;
namespace GameLauncher.Server.Cli;
/// <summary>
/// Commandes de secours des comptes joueurs (sur le VPS, sous le compte du service). Elles désignent un compte par sa
/// RÉFÉRENCE (« PL-7KQ2-M9XD ») et n'affichent jamais l'adresse e-mail : elles n'ont pas besoin de la clé maîtresse.
/// Le service n'a pas besoin d'être arrêté (SQLite gère l'accès simultané) ; l'état du compte est relu à chaque requête.
/// La gestion courante se fait depuis le menu d'administration (page « Comptes joueurs », étape 8d).
/// </summary>
internal static class PlayersCli
{
public static readonly string[] Commands =
{ "players-key", "players-show", "players-unlock", "players-disable", "players-enable", "players-otp-reset" };
public const string HelpText = """
players-key Génère une clé maîtresse des comptes joueurs (à chiffrer avec systemd-creds, voir LISEZMOI).
players-show --ref <PL-XXXX-XXXX> État d'un compte joueur (sans son adresse e-mail).
players-unlock --ref <PL-XXXX-XXXX> Lève une suspension ou un blocage (compte réactivé).
players-disable --ref <PL-XXXX-XXXX> [--until AAAA-MM-JJ[THH:MM]] Désactive un compte (UTC ; sans --until : indéfiniment).
players-enable --ref <PL-XXXX-XXXX> Réactive un compte désactivé.
players-otp-reset --ref <PL-XXXX-XXXX> Désactive la double authentification (téléphone et codes de secours perdus ;
identité vérifiée au préalable par l'équipe).
""";
/// <summary>Nouvelle clé maîtresse : sur la sortie standard (à rediriger vers systemd-creds), mode d'emploi sur stderr.</summary>
public static int Key(TextWriter o, bool interactive)
{
o.WriteLine(PlayerSecrets.NewMasterKey());
if (interactive)
Console.Error.WriteLine("Clé maîtresse générée (32 octets, Base64). À chiffrer immédiatement, sans la laisser sur le disque, ex. :\n" +
" GameLauncher.Server players-key | sudo systemd-creds encrypt --name=players-master-key - " +
"/etc/credstore.encrypted/players-master-key\n" +
"Gardez-en une copie HORS LIGNE (gestionnaire de mots de passe) : perdue, les adresses e-mail sont " +
"illisibles et tous les mots de passe doivent être réinitialisés.");
return 0;
}
public static int Run(string command, Dictionary<string, string> options, string dataDirectory, AuditLog audit, string actor, TextWriter o)
{
var path = Path.Combine(dataDirectory, PlayerStore.FolderName, PlayerStore.FileName);
if (!File.Exists(path)) throw new InvalidOperationException($"aucune base des comptes joueurs ({path}).");
using var store = new PlayerStore(dataDirectory, TimeProvider.System);
var reference = PlayerIds.NormalizeReference(Get(options, "ref"))
?? throw new ArgumentException("--ref <PL-XXXX-XXXX> obligatoire (référence du compte).");
var account = store.FindByReference(reference) ?? throw new InvalidOperationException($"aucun compte {reference}.");
var now = DateTimeOffset.UtcNow;
switch (command)
{
case "players-show":
Show(store, account, o);
return 0;
case "players-unlock":
case "players-enable":
store.SetStatus(account.Id, PlayerStatus.Active, null, null, actor, now);
store.AddEvent(account.Id, command == "players-unlock" ? PlayerEvents.Unlocked : PlayerEvents.Enabled, AuditResults.Ok, null,
details: $"par {actor} (outil en ligne de commande)");
audit.Write("cli." + command.Replace('-', '_'), AuditResults.Ok, actorName: actor, target: reference,
details: $"ancien état : {account.Status}");
o.WriteLine($"Compte {reference} réactivé (blocages remis à zéro).");
return 0;
case "players-disable":
DateTimeOffset? until = null;
if (Get(options, "until") is { Length: > 0 } text)
{
if (!DateTimeOffset.TryParseExact(text, new[] { "yyyy-MM-dd", "yyyy-MM-dd'T'HH:mm" }, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) || date <= now)
throw new ArgumentException("--until : date future attendue, AAAA-MM-JJ ou AAAA-MM-JJTHH:MM (UTC).");
until = date;
}
store.SetStatus(account.Id, PlayerStatus.Disabled, until, null, actor, now);
store.AddEvent(account.Id, PlayerEvents.Disabled, AuditResults.Ok, null,
details: $"par {actor} (outil en ligne de commande){(until is { } u ? $" jusqu'au {u:yyyy-MM-dd HH:mm} UTC" : " indéfiniment")}");
audit.Write("cli.players_disable", AuditResults.Ok, actorName: actor, target: reference,
details: until is { } end ? $"jusqu'au {end:yyyy-MM-dd HH:mm} UTC" : "indéfiniment");
o.WriteLine($"Compte {reference} désactivé{(until is { } d ? $" jusqu'au {d:yyyy-MM-dd HH:mm} UTC" : " indéfiniment")} " +
"(ses sessions sont refusées dès la requête suivante).");
return 0;
case "players-otp-reset":
if (!account.OtpEnabled)
{
o.WriteLine($"Compte {reference} : double authentification déjà désactivée.");
return 0;
}
store.DisableOtp(account.Id);
store.AddEvent(account.Id, PlayerEvents.OtpReset, AuditResults.Ok, null,
details: $"par {actor} (outil en ligne de commande)");
audit.Write("cli.players_otp_reset", AuditResults.Ok, actorName: actor, target: reference,
details: "double authentification désactivée, codes de secours supprimés");
o.WriteLine($"Compte {reference} : double authentification désactivée (codes de secours supprimés). Le joueur se connecte " +
"avec son mot de passe et peut la réactiver depuis « Mon compte ».");
return 0;
}
throw new ArgumentException($"commande inconnue : {command}");
}
private static void Show(PlayerStore store, PlayerAccountRecord account, TextWriter o)
{
static string Date(DateTimeOffset? d) => d is { } v ? v.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) : "—";
var devices = store.Devices(account.Id);
o.WriteLine($"Compte {account.Reference}");
o.WriteLine($" État : {account.Status}{(account.DisabledUntil is { } u ? $" jusqu'au {Date(u)}" : "")}" +
(account.StatusChangedBy is { } by ? $" (par {by}, le {Date(account.StatusChangedAt)})" : ""));
o.WriteLine($" Créé le : {Date(account.CreatedAt)}");
o.WriteLine($" Dernière connexion : {Date(account.LastLoginAt)}");
o.WriteLine($" Mot de passe changé : {Date(account.PasswordChangedAt)}{(account.MustChangePassword ? " (nouveau mot de passe exigé)" : "")}");
o.WriteLine($" Échecs (inconnus) : {account.FailedCount} en cours, {account.LockLevel} blocage(s), bloqué jusqu'au {Date(account.LockedUntil)}");
o.WriteLine($" Échecs (confiance) : {account.TrustedFailedCount} en cours, bloqué jusqu'au {Date(account.TrustedLockedUntil)}");
o.WriteLine($" Double auth. : {(account.OtpEnabled ? $"activée ({store.CountRecoveryCodes(account.Id)} code(s) de secours)" : "désactivée")}");
if (account.TemporaryPasswordExpiresAt is { } temporary && account.TemporaryPasswordHash is not null)
o.WriteLine($" Mot de passe temp. : en attente, valable jusqu'au {Date(temporary)}");
o.WriteLine($" Appareils connectés : {devices.Count}");
foreach (var device in devices)
o.WriteLine($" · {device.Label} (clé {device.KeyId}) — dernière utilisation {Date(device.LastUsedAt)}");
o.WriteLine(" Derniers événements :");
foreach (var e in store.Events(account.Id, 10))
o.WriteLine($" {Date(e.At)} {e.Type} {e.Result} {e.Details}");
}
private static string? Get(Dictionary<string, string> options, string name) =>
options.TryGetValue(name, out var value) ? value : null;
}
@@ -25,6 +25,18 @@
<ProjectReference Include="..\..\src\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
</ItemGroup>
<!--
Comptes joueurs (phase 8, docs\PLAYER-ACCOUNTS-ARCHITECTURE.md) :
• Microsoft.Data.Sqlite (Microsoft) : base players.db ; embarque la bibliothèque native SQLite (SQLitePCLRaw,
bibliothèque e_sqlite3 de linux-x64 incluse à la publication autonome) ;
• Konscious.Security.Cryptography.Argon2 (licence MIT, 100 % C#) : empreintes Argon2id des mots de passe (RFC 9106),
absentes de .NET 8.
-->
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.34" />
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="GameLauncher.Tests" />
</ItemGroup>
@@ -32,7 +32,10 @@ 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(),
new Modules.Accounts.PlayersAdminModule()
};
}
@@ -50,4 +53,8 @@ public static class RateLimitPolicies
/// <summary>Demandes des joueurs : défis et envois anonymes (en plus de la limite générale).</summary>
public const string Feedback = "feedback";
/// <summary>Comptes joueurs : défis, création, connexion, renouvellement, changement de mot de passe (en plus de la
/// limite générale).</summary>
public const string Account = "account";
}
@@ -53,6 +53,9 @@ public sealed class LauncherApiOptions
/// <summary>Journal d'audit : durée de conservation (purge automatique).</summary>
public AuditSettings Audit { get; set; } = new();
/// <summary>Comptes joueurs (phase 8) : désactivés par défaut. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.</summary>
public PlayersSettings Players { get; set; } = new();
/// <summary>
/// Jeux dont le manifest.json publié peut être modifié par l'API (notes de version, phase 4), par
/// identifiant de jeu (GameId de launcher.json). Absent : notes non modifiables en ligne.
@@ -177,6 +180,88 @@ public sealed class LauncherApiOptions
public int RetentionMonths { get; set; } = 12;
}
/// <summary>
/// Comptes joueurs : adresse e-mail + mot de passe, connexion mémorisée liée à une clé d'appareil. Données dans
/// « players/players.db » (SQLite, dossier des données privées), adresses e-mail chiffrées, mots de passe hachés
/// (Argon2id + clé secrète). La clé maîtresse est hors du dossier des données (credential systemd chiffré).
/// </summary>
public sealed class PlayersSettings
{
/// <summary>Service activé (faux par défaut : les routes « v1/account/… » répondent 503 « not_configured »).</summary>
public bool Enabled { get; set; }
/// <summary>Création de compte ouverte (la connexion reste possible si faux).</summary>
public bool RegistrationEnabled { get; set; } = true;
/// <summary>
/// Fichier de la clé maîtresse (32 octets en Base64). Vide : « players-master-key » dans le dossier des credentials
/// systemd ($CREDENTIALS_DIRECTORY, voir server/deploy/LISEZMOI.md). Jamais dans le dossier des données, jamais
/// lisible par le groupe ou les autres comptes.
/// </summary>
public string MasterKeyFile { get; set; } = "";
/// <summary>Version des conditions d'utilisation / politique de confidentialité à accepter à la création du compte.</summary>
public string TermsVersion { get; set; } = "1";
/// <summary>Nom affiché dans les applications d'authentification (double authentification), sans « : ».</summary>
public string OtpIssuer { get; set; } = "LanisysGames";
/// <summary>Argon2id : mémoire (Kio), passes, parallélisme. OWASP : 19 Mio et 2 passes au minimum (ou équivalent).</summary>
public int HashMemoryKib { get; set; } = 19456;
public int HashIterations { get; set; } = 2;
public int HashParallelism { get; set; } = 1;
/// <summary>Calculs de mot de passe simultanés (les suivants attendent) : protège le VPS d'un afflux de connexions.</summary>
public int MaxConcurrentHashes { get; set; } = 4;
/// <summary>Preuve de travail exigée à la création de compte et à la connexion (bits à zéro).</summary>
public int RegisterProofDifficulty { get; set; } = 20;
public int LoginProofDifficulty { get; set; } = 16;
/// <summary>Durée de validité d'un défi joueur.</summary>
public int ChallengeLifetimeSeconds { get; set; } = 300;
/// <summary>Anti-force brute (appareils inconnus) : blocage après N échecs consécutifs, durée doublée à chaque
/// blocage ; au blocage n° SuspendAfterLocks, le compte est SUSPENDU pour les nouveaux appareils (seul un
/// administrateur lève la suspension ; les appareils déjà connectés continuent de fonctionner).</summary>
public int FailuresPerLock { get; set; } = 5;
public int LockMinutes { get; set; } = 15;
public int SuspendAfterLocks { get; set; } = 4;
/// <summary>Appareils de confiance (déjà connectés) : blocage temporaire après N échecs, jamais de suspension.</summary>
public int TrustedFailuresPerLock { get; set; } = 10;
/// <summary>Échecs de connexion acceptés par adresse IP (IPv6 : /64) sur 15 minutes, tous comptes confondus.</summary>
public int FailedLoginsPerIpPer15Minutes { get; set; } = 30;
/// <summary>Créations de compte par adresse IP (IPv6 : /64) par heure et par jour, et au total par jour.</summary>
public int RegistrationsPerIpPerHour { get; set; } = 3;
public int RegistrationsPerIpPerDay { get; set; } = 5;
public int MaxRegistrationsPerDay { get; set; } = 500;
/// <summary>Requêtes par minute et par adresse IP sur les routes de défi, création, connexion et renouvellement.</summary>
public int AuthRequestsPerMinute { get; set; } = 20;
/// <summary>Jeton d'accès (minutes) ; session mémorisée : jours sans utilisation, puis durée maximale (jours).</summary>
public int AccessTokenMinutes { get; set; } = 15;
public int RefreshIdleDays { get; set; } = 90;
public int RefreshMaxDays { get; set; } = 365;
/// <summary>Appareils connectés par compte (au-delà, le moins récemment utilisé est déconnecté).</summary>
public int MaxDevicesPerAccount { get; set; } = 20;
/// <summary>Journal des comptes : conservation (mois), et effacement des adresses IP au-delà de N mois.</summary>
public int EventsRetentionMonths { get; set; } = 12;
public int EventsIpRetentionMonths { get; set; } = 6;
/// <summary>
/// Comptes inactifs (CNIL) : mis en sommeil après N mois sans aucune activité (appareils déconnectés ; une connexion
/// avec le bon mot de passe le réveille), puis supprimés définitivement après M mois de sommeil. 0 : désactivé.
/// </summary>
public int InactiveDisableMonths { get; set; } = 24;
public int InactiveDeleteMonths { get; set; } = 12;
}
/// <summary>Un jeu publié sur ce serveur.</summary>
public sealed class GameSettings
{
@@ -248,6 +333,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 +349,9 @@ public sealed class LauncherApiOptions
public IReadOnlyDictionary<string, string> GameManifestFiles { get; private set; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
/// <summary>Fichier de la clé maîtresse des comptes joueurs (null : comptes désactivés), calculé par <see cref="Validate"/>.</summary>
public string? PlayersMasterKeyFullPath { get; private set; }
/// <summary>Jeux acceptés par la réception des demandes des joueurs, calculés par <see cref="Validate"/>.</summary>
public IReadOnlySet<string> FeedbackGameIds { get; private set; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -289,6 +386,7 @@ public sealed class LauncherApiOptions
ValidateGames();
ValidateFiles();
ValidateFeedback();
ValidatePlayers();
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
@@ -303,6 +401,73 @@ public sealed class LauncherApiOptions
Require(Audit.RetentionMonths is >= 1 and <= 120, "Audit:RetentionMonths (1 à 120)");
}
private void ValidatePlayers()
{
var players = Players;
PlayersMasterKeyFullPath = null;
Require(players.HashMemoryKib is >= 7168 and <= 1_048_576, "Players:HashMemoryKib (7168 à 1048576)");
Require(players.HashIterations is >= 1 and <= 20, "Players:HashIterations (1 à 20)");
// OWASP : coût au moins équivalent à 19 Mio × 2 passes (ex. 12 Mio × 3, 9 Mio × 4, 7 Mio × 5).
Require((long)players.HashMemoryKib * players.HashIterations >= 19456L * 2, "Players:HashMemoryKib × HashIterations (au moins 38912)");
Require(players.HashParallelism is >= 1 and <= 16, "Players:HashParallelism (1 à 16)");
Require(players.MaxConcurrentHashes is >= 1 and <= 64, "Players:MaxConcurrentHashes (1 à 64)");
Require(players.RegisterProofDifficulty is >= Contracts.Feedback.FeedbackProof.MinDifficulty and <= Contracts.Feedback.FeedbackProof.MaxDifficulty &&
players.LoginProofDifficulty is >= Contracts.Feedback.FeedbackProof.MinDifficulty and <= Contracts.Feedback.FeedbackProof.MaxDifficulty,
$"Players:*ProofDifficulty ({Contracts.Feedback.FeedbackProof.MinDifficulty} à {Contracts.Feedback.FeedbackProof.MaxDifficulty})");
Require(players.ChallengeLifetimeSeconds is >= 30 and <= 3600, "Players:ChallengeLifetimeSeconds (30 à 3600)");
Require(players.FailuresPerLock is >= 3 and <= 20, "Players:FailuresPerLock (3 à 20)");
Require(players.LockMinutes is >= 1 and <= 1440, "Players:LockMinutes (1 à 1440)");
Require(players.SuspendAfterLocks is >= 2 and <= 20, "Players:SuspendAfterLocks (2 à 20)");
Require(players.TrustedFailuresPerLock is >= 3 and <= 50, "Players:TrustedFailuresPerLock (3 à 50)");
Require(players.FailedLoginsPerIpPer15Minutes is >= 5 and <= 10_000, "Players:FailedLoginsPerIpPer15Minutes (5 à 10000)");
Require(players.RegistrationsPerIpPerHour is >= 1 and <= 1000 && players.RegistrationsPerIpPerDay >= players.RegistrationsPerIpPerHour &&
players.RegistrationsPerIpPerDay <= 10_000, "Players:RegistrationsPerIp*");
Require(players.MaxRegistrationsPerDay is >= 1 and <= 1_000_000, "Players:MaxRegistrationsPerDay");
Require(players.AuthRequestsPerMinute is >= 1 and <= 1_000_000, "Players:AuthRequestsPerMinute");
Require(players.AccessTokenMinutes is >= 1 and <= 60, "Players:AccessTokenMinutes (1 à 60)");
Require(players.RefreshIdleDays is >= 1 and <= 365, "Players:RefreshIdleDays (1 à 365)");
Require(players.RefreshMaxDays >= players.RefreshIdleDays && players.RefreshMaxDays <= 730, "Players:RefreshMaxDays (RefreshIdleDays à 730)");
Require(players.MaxDevicesPerAccount is >= 1 and <= 100, "Players:MaxDevicesPerAccount (1 à 100)");
Require(players.EventsRetentionMonths is >= 1 and <= 120, "Players:EventsRetentionMonths (1 à 120)");
Require(players.EventsIpRetentionMonths is >= 1 && players.EventsIpRetentionMonths <= players.EventsRetentionMonths,
"Players:EventsIpRetentionMonths (1 à EventsRetentionMonths)");
// Au moins un an (au-delà de la durée maximale d'une session mémorisée) : un joueur régulier n'est jamais mis en sommeil.
Require(players.InactiveDisableMonths == 0 || players.InactiveDisableMonths is >= 12 and <= 120,
"Players:InactiveDisableMonths (0, ou 12 à 120)");
Require(players.InactiveDeleteMonths is >= 0 and <= 120, "Players:InactiveDeleteMonths (0 à 120)");
if (Contracts.ApiText.CleanLine(players.TermsVersion, 32) is null)
throw new InvalidOperationException($"{Section}:Players:TermsVersion : une version (ex. « 2026-11 ») est obligatoire.");
if (Contracts.ApiText.CleanLine(players.OtpIssuer, 64) is not { } issuer || issuer != players.OtpIssuer || issuer.Contains(':'))
throw new InvalidOperationException($"{Section}:Players:OtpIssuer : nom court sans « : » (ex. « LanisysGames »).");
if (!players.Enabled) return;
string file;
if (!string.IsNullOrWhiteSpace(players.MasterKeyFile))
{
file = players.MasterKeyFile.Trim();
if (!Path.IsPathRooted(file))
throw new InvalidOperationException($"{Section}:Players:MasterKeyFile doit être un chemin absolu.");
}
else if (Environment.GetEnvironmentVariable("CREDENTIALS_DIRECTORY") is { Length: > 0 } credentials)
{
file = Path.Combine(credentials, "players-master-key");
}
else
{
throw new InvalidOperationException(
$"{Section}:Players:Enabled = true, mais aucune clé maîtresse : credential systemd « players-master-key » " +
"(LoadCredentialEncrypted, voir server/deploy/LISEZMOI.md) ou Players:MasterKeyFile.");
}
var full = Path.GetFullPath(file);
var directory = Path.GetDirectoryName(full) ?? "";
var keyDirectory = RealPath(directory);
var dataDirectory = RealPath(DataDirectoryFullPath);
if (IsSameOrInside(keyDirectory, dataDirectory))
throw new InvalidOperationException(
$"{Section}:Players:MasterKeyFile : la clé maîtresse ne doit pas être dans le dossier des données (une copie des données ne doit pas la contenir).");
PlayersMasterKeyFullPath = full;
}
private void ValidateFeedback()
{
var feedback = Feedback;
@@ -403,11 +568,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,43 @@ public sealed class RetentionService : BackgroundService
{
_logger.LogError(ex, "Purge des demandes des joueurs impossible");
}
if (!_options.Players.Enabled) return;
try
{
var players = _services.GetRequiredService<Storage.Players.PlayerStore>();
var (deleted, anonymized) = players.PurgeEvents(_time.GetUtcNow(), _options.Players.EventsRetentionMonths,
_options.Players.EventsIpRetentionMonths);
players.ClearExpiredTemporaryPasswords(_time.GetUtcNow()); // empreintes inutiles une fois échues
// Identifiants matériels plus vus depuis la durée de conservation du journal ; blocages échus (adresses IP comprises).
var hardware = players.PurgeHardware(_time.GetUtcNow().AddMonths(-_options.Players.EventsRetentionMonths));
var bans = players.PurgeExpiredBans(_time.GetUtcNow());
if (bans > 0) _services.GetRequiredService<Security.PlayerBanGuard>().Invalidate();
if (hardware > 0 || bans > 0)
_audit.Write("players.bans_purge", AuditResults.Ok,
details: $"{bans} blocage(s) échu(s) supprimé(s) ; {hardware} identifiant(s) matériel(s) plus vu(s) depuis " +
$"{_options.Players.EventsRetentionMonths} mois effacé(s)");
if (deleted > 0 || anonymized > 0)
_audit.Write("players.events_purge", AuditResults.Ok,
details: $"journal des comptes joueurs : {deleted} ligne(s) supprimée(s), {anonymized} adresse(s) IP effacée(s) ; " +
$"conservation {_options.Players.EventsRetentionMonths} mois, IP {_options.Players.EventsIpRetentionMonths} mois");
}
catch (Exception ex)
{
_logger.LogError(ex, "Purge du journal des comptes joueurs impossible");
}
try
{
var (dormant, deleted) = _services.GetRequiredService<Modules.Accounts.PlayerPersonalData>().ApplyInactivity(_time.GetUtcNow());
if (dormant > 0 || deleted > 0)
_audit.Write("players.inactive", AuditResults.Ok,
details: $"comptes joueurs inactifs : {dormant} mis en sommeil (aucune activité depuis {_options.Players.InactiveDisableMonths} mois), " +
$"{deleted} supprimé(s) (en sommeil depuis {_options.Players.InactiveDeleteMonths} mois)");
}
catch (Exception ex)
{
_logger.LogError(ex, "Politique des comptes joueurs inactifs impossible");
}
}
}
@@ -86,6 +86,8 @@ public static class ServerApp
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.EnrollmentsPerHour, TimeSpan.FromHours(1))));
limiter.AddPolicy(RateLimitPolicies.Feedback, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Feedback.RequestsPerMinute, TimeSpan.FromMinutes(1))));
limiter.AddPolicy(RateLimitPolicies.Account, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Players.AuthRequestsPerMinute, TimeSpan.FromMinutes(1))));
});
// Protection des données d'ASP.NET Core (cookies, jetons anti-falsification) : inutilisée par l'API,
@@ -123,6 +125,13 @@ public static class ServerApp
var admins = app.Services.GetRequiredService<AdminStore>().Read();
app.Logger.LogInformation("Launcher API : origine {Origin}, serveur {ServerId}, {Count} administrateur(s), données {Data}",
identity.Origin, identity.ServerId, admins.Admins.Count, options.DataDirectoryFullPath);
// Comptes joueurs : clé maîtresse et base ouvertes dès le démarrage (clé absente ou base illisible : pas de démarrage).
if (options.Players.Enabled)
{
var players = app.Services.GetRequiredService<Storage.Players.PlayerStore>();
app.Services.GetRequiredService<Storage.Players.PlayerPasswordHasher>();
app.Logger.LogInformation("Comptes joueurs activés : {Count} compte(s), base {Database}", players.CountAccounts(), players.DatabasePath);
}
if (!admins.UsableOwners().Any())
app.Logger.LogWarning("Aucun propriétaire actif : créez-le avec « GameLauncher.Server add-owner » (voir server/deploy/LISEZMOI.md).");
return app;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,198 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using GameLauncher.Server.Storage.Players;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Accounts;
/// <summary>
/// Données d'un joueur détenues par un module (RGPD : droits d'accès, de portabilité et d'effacement). Chaque module qui
/// conserve des données liées à un compte joueur (demandes connectées et échanges à l'étape 8f, amis, sauvegardes…) déclare
/// une implémentation (<c>services.AddSingleton&lt;IPersonalDataProvider, …&gt;()</c>) : l'export et la suppression du compte
/// la couvrent alors automatiquement, sans modifier le module des comptes.
/// </summary>
public interface IPersonalDataProvider
{
/// <summary>Nom de la section dans l'export (« feedback »…), en minuscules.</summary>
string Name { get; }
/// <summary>Données du joueur, lisibles par machine (null : rien pour ce compte).</summary>
JsonNode? Export(PlayerAccountRecord account);
/// <summary>Efface (ou détache du compte) les données du joueur. Appelé AVANT la suppression du compte lui-même.</summary>
void Erase(PlayerAccountRecord account);
}
/// <summary>
/// Export et effacement des données d'un compte joueur, à la demande du joueur ou par la politique des comptes inactifs
/// (CNIL : mise en sommeil après <c>Players:InactiveDisableMonths</c> sans activité, suppression après
/// <c>Players:InactiveDeleteMonths</c> de sommeil). L'export contient TOUT ce que le serveur sait du compte, y compris
/// l'adresse e-mail complète ; jamais l'empreinte du mot de passe ni les secrets des sessions.
/// </summary>
public sealed class PlayerPersonalData
{
private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true };
private readonly PlayerStore _store;
private readonly PlayerSecrets _secrets;
private readonly PlayerSessionStore _sessions;
private readonly IReadOnlyList<IPersonalDataProvider> _providers;
private readonly AuditLog _audit;
private readonly LauncherApiOptions.PlayersSettings _settings;
public PlayerPersonalData(PlayerStore store, PlayerSecrets secrets, PlayerSessionStore sessions, IEnumerable<IPersonalDataProvider> providers,
AuditLog audit, IOptions<LauncherApiOptions> options)
{
_store = store;
_secrets = secrets;
_sessions = sessions;
_providers = providers.ToList();
_audit = audit;
_settings = options.Value.Players;
var duplicate = _providers.GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase).FirstOrDefault(g => g.Count() > 1);
if (duplicate is not null) throw new InvalidOperationException($"Données personnelles : section « {duplicate.Key} » déclarée deux fois.");
}
/// <summary>Document d'export (JSON indenté, UTF-8).</summary>
public byte[] Export(PlayerAccountRecord account, string origin, DateTimeOffset now)
{
var devices = _store.Devices(account.Id);
var document = new JsonObject
{
["format"] = PlayerDataExport.Format,
["exportedAt"] = now,
["service"] = origin,
["account"] = new JsonObject
{
["reference"] = account.Reference,
["email"] = _secrets.DecryptEmail(account.Id, account.EmailEncrypted),
["emailVerified"] = account.EmailVerified,
["createdAt"] = account.CreatedAt,
["lastLoginAt"] = account.LastLoginAt,
["passwordChangedAt"] = account.PasswordChangedAt,
["termsVersionAccepted"] = account.TermsVersion,
["status"] = account.Status,
["disabledUntil"] = account.DisabledUntil,
["statusReason"] = _secrets.TryDecryptStatusReason(account.Id, account.StatusReasonEncrypted),
["newPasswordRequired"] = account.MustChangePassword,
["twoFactorEnabled"] = account.OtpEnabled,
["recoveryCodesLeft"] = account.OtpEnabled ? _store.CountRecoveryCodes(account.Id) : 0
},
["devices"] = new JsonArray(devices.Select(d => (JsonNode)new JsonObject
{
["label"] = d.Label,
["deviceKeyId"] = d.KeyId,
["connectedAt"] = d.CreatedAt,
["lastUsedAt"] = d.LastUsedAt,
["rememberedUntil"] = d.RefreshExpiresAt
}).ToArray()),
// Identifiants matériels : seulement l'empreinte (abrégée) gardée par le serveur, jamais la valeur lue sur le PC.
["hardware"] = new JsonArray(_store.Hardware(account.Id).Select(h => (JsonNode)new JsonObject
{
["kind"] = h.Kind,
["description"] = PlayerHardware.Label(h.Kind),
["fingerprint"] = h.Hash.Length > 12 ? h.Hash[..12] : h.Hash,
["firstSeenAt"] = h.FirstSeenAt,
["lastSeenAt"] = h.LastSeenAt
}).ToArray()),
["activity"] = new JsonArray(_store.Events(account.Id, 1000).Select(e => (JsonNode)new JsonObject
{
["at"] = e.At,
["type"] = e.Type,
["description"] = PlayerActivityText.Describe(e.Type, e.Result == AuditResults.Ok),
["result"] = e.Result,
["ip"] = e.Ip,
["deviceKeyId"] = e.DeviceKeyId,
["details"] = e.Details
}).ToArray())
};
var modules = new JsonObject();
foreach (var provider in _providers)
{
if (provider.Export(account) is { } data) modules[provider.Name] = data;
}
document["modules"] = modules;
document["notes"] = new JsonArray(
"Le mot de passe n'est jamais conservé : seule une empreinte irréversible existe sur le serveur, elle n'est pas exportée.",
$"Activité : {_settings.EventsRetentionMonths} mois au plus ; adresses IP effacées au bout de {_settings.EventsIpRetentionMonths} mois.",
"Les clés d'appareil privées ne quittent jamais votre PC : seul leur identifiant public figure ici.",
"Le secret de la double authentification et les codes de secours ne sont jamais exportés (le serveur ne garde " +
"qu'une empreinte des codes de secours).",
"Identifiants matériels : le launcher n'envoie qu'une empreinte de l'identifiant de l'installation de Windows et de la carte " +
"mère ; le serveur la réempreinte avec une clé secrète et ne s'en sert que pour les blocages (sécurité du service). " +
$"Effacés {_settings.EventsRetentionMonths} mois après leur dernière utilisation.");
return JsonSerializer.SerializeToUtf8Bytes(document, Indented);
}
/// <summary>
/// Supprime DÉFINITIVEMENT le compte : données des modules, appareils, journal du compte, sessions ouvertes. Une ligne
/// du journal d'audit (référence seulement) garde la trace de la suppression.
/// </summary>
/// <param name="byPlayer">Vrai : à la demande du joueur ; faux : politique des comptes inactifs.</param>
public bool Erase(PlayerAccountRecord account, bool byPlayer, string? ip)
{
if (!EraseCore(account)) return false;
_audit.Write(byPlayer ? "players.self_delete" : "players.inactive_delete", AuditResults.Ok,
actorName: byPlayer ? "joueur" : "système", ip: ip, target: account.Reference,
details: byPlayer
? "compte joueur supprimé définitivement à la demande de son titulaire"
: $"compte joueur supprimé définitivement après {_settings.InactiveDeleteMonths} mois de sommeil (inactivité)");
return true;
}
/// <summary>
/// Suppression définitive décidée par un administrateur (menu d'administration, permission players.delete), inscrite au
/// journal d'audit avec son auteur. Mêmes effets que la suppression par le joueur.
/// </summary>
public bool EraseByAdmin(PlayerAccountRecord account, AuthenticatedAdmin actor, string? ip, string details)
{
if (!EraseCore(account)) return false;
_audit.Write("players.delete", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, ip, account.Reference, details);
return true;
}
/// <summary>Données des modules, sessions, puis compte (appareils, journal et codes en cascade). Faux si le compte n'existe plus.</summary>
private bool EraseCore(PlayerAccountRecord account)
{
foreach (var provider in _providers) provider.Erase(account);
_sessions.RemoveWhere(s => s.AccountId == account.Id);
if (!_store.DeleteAccount(account.Id)) return false;
_sessions.RemoveWhere(s => s.AccountId == account.Id); // session ouverte pendant la suppression
return true;
}
/// <summary>
/// Politique des comptes inactifs (au démarrage puis chaque jour) : mise en sommeil des comptes actifs sans activité
/// depuis <c>InactiveDisableMonths</c>, suppression de ceux en sommeil depuis <c>InactiveDeleteMonths</c>.
/// Renvoie (mis en sommeil, supprimés).
/// </summary>
public (int Dormant, int Deleted) ApplyInactivity(DateTimeOffset now)
{
if (_settings.InactiveDisableMonths <= 0) return (0, 0);
var dormant = 0;
foreach (var account in _store.FindInactive(now.AddMonths(-_settings.InactiveDisableMonths)))
{
if (!_store.MarkInactive(account.Id, now)) continue;
_sessions.RemoveWhere(s => s.AccountId == account.Id);
_store.AddEvent(account.Id, PlayerEvents.Inactive, AuditResults.Ok, null, null,
$"aucune activité depuis {_settings.InactiveDisableMonths} mois : appareils déconnectés");
dormant++;
}
var deleted = 0;
if (_settings.InactiveDeleteMonths > 0)
{
foreach (var account in _store.FindDormantSince(now.AddMonths(-_settings.InactiveDeleteMonths)))
{
if (Erase(account, byPlayer: false, ip: null)) deleted++;
}
}
return (dormant, deleted);
}
}
@@ -0,0 +1,638 @@
using System.Globalization;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Modules.Feedback;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using GameLauncher.Server.Storage.Players;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Accounts;
/// <summary>
/// Comptes joueurs vus par les ADMINISTRATEURS (étape 8d, page « Comptes joueurs » du menu d'administration).
/// • Lecture (players.read) : compteurs, liste filtrée, recherche par référence ou adresse e-mail EXACTE (POST : l'adresse
/// n'apparaît jamais dans une adresse de page ni dans les journaux du serveur web ; elle n'est pas inscrite à l'audit), fiche
/// (adresse masquée, appareils, activité avec adresses IP). Chaque consultation est inscrite au journal d'audit (une fois
/// par compte et par clé toutes les 10 minutes), sans l'adresse.
/// • Gestion (players.manage, requêtes signées) : désactiver (durée, motif montré au joueur) / réactiver, lever une suspension
/// ou un blocage, exiger un nouveau mot de passe, mot de passe temporaire (D6 : 60 min, une seule fois, ne remplace pas
/// l'ancien), réinitialiser la double authentification, déconnecter un appareil ou tous.
/// • Suppression définitive (players.delete, requête signée).
/// Chaque action est inscrite au journal d'audit (administrateur, référence) et au journal du compte, où le joueur la voit
/// comme venant de « l'équipe » (D8) : le nom de l'administrateur n'y figure jamais.
/// Aucune route n'accepte un jeton joueur (schéma AdminSession explicite, testé par PlayerSeparationTests).
/// </summary>
public sealed class PlayersAdminModule : IApiModule
{
public string Name => "PlayersAdmin";
private const int PageSize = 100;
/// <summary>Auteur inscrit dans le journal du compte (vu par le joueur, export compris).</summary>
private const string Team = "par l'équipe";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
{
// Services du module des comptes (AccountsModule) ; consultations : FeedbackConsultations (même règle des 10 minutes).
}
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.PlayersOverview, Overview).RequirePermission(AdminPermissions.PlayersRead);
api.MapGet(ApiRoutes.Players, List).RequirePermission(AdminPermissions.PlayersRead);
api.MapPost(ApiRoutes.PlayersSearch, Search).RequirePermission(AdminPermissions.PlayersRead);
api.MapGet(ApiRoutes.PlayerPattern, Get).RequirePermission(AdminPermissions.PlayersRead);
api.MapPost(ApiRoutes.PlayerDisablePattern, Disable).RequirePermission(AdminPermissions.PlayersManage).RequireSignedRequest();
api.MapPost(ApiRoutes.PlayerEnablePattern, Enable).RequirePermission(AdminPermissions.PlayersManage).RequireSignedRequest();
api.MapPost(ApiRoutes.PlayerUnlockPattern, Unlock).RequirePermission(AdminPermissions.PlayersManage).RequireSignedRequest();
api.MapPost(ApiRoutes.PlayerPasswordRequirementPattern, PasswordRequirement).RequirePermission(AdminPermissions.PlayersManage)
.RequireSignedRequest();
api.MapPost(ApiRoutes.PlayerTemporaryPasswordPattern, TemporaryPassword).RequirePermission(AdminPermissions.PlayersManage)
.RequireSignedRequest();
api.MapPost(ApiRoutes.PlayerOtpResetPattern, OtpReset).RequirePermission(AdminPermissions.PlayersManage).RequireSignedRequest();
api.MapPost(ApiRoutes.PlayerLogoutPattern, Logout).RequirePermission(AdminPermissions.PlayersManage).RequireSignedRequest();
api.MapDelete(ApiRoutes.PlayerPattern, Delete).RequirePermission(AdminPermissions.PlayersDelete).RequireSignedRequest();
// Blocages (étape 8e).
api.MapPost(ApiRoutes.PlayerBanPattern, BanPlayer).RequirePermission(AdminPermissions.PlayersBan).RequireSignedRequest();
api.MapGet(ApiRoutes.Bans, ListBans).RequirePermission(AdminPermissions.PlayersBan);
api.MapPost(ApiRoutes.Bans, BanIp).RequirePermission(AdminPermissions.PlayersBan).RequireSignedRequest();
api.MapDelete(ApiRoutes.BanPattern, Unban).RequirePermission(AdminPermissions.PlayersBan).RequireSignedRequest();
}
private static IResult NotConfigured() => ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Les comptes joueurs ne sont pas activés sur ce serveur.");
private static IResult NotFound() => ApiResults.NotFound("Compte joueur introuvable.");
// ------------------------------------------------------------------ Lecture (players.read)
private static IResult Overview(IOptions<LauncherApiOptions> options, IServiceProvider services, TimeProvider time)
{
var settings = options.Value.Players;
var overview = new PlayersOverview
{
Enabled = settings.Enabled,
RegistrationEnabled = settings.Enabled && settings.RegistrationEnabled,
EventsRetentionMonths = settings.EventsRetentionMonths,
EventsIpRetentionMonths = settings.EventsIpRetentionMonths,
InactiveDisableMonths = settings.InactiveDisableMonths,
InactiveDeleteMonths = settings.InactiveDeleteMonths
};
if (!settings.Enabled) return Results.Json(overview, ApiJson.Options);
var counts = services.GetRequiredService<PlayerStore>().Counts(time.GetUtcNow());
return Results.Json(overview with
{
Total = counts.Total,
Active = counts.Active,
Disabled = counts.Disabled,
Suspended = counts.Suspended,
Inactive = counts.Inactive,
Locked = counts.Locked,
PasswordChangeRequired = counts.PasswordChangeRequired,
OtpEnabled = counts.OtpEnabled,
CreatedLast30Days = counts.CreatedLast30Days
}, ApiJson.Options);
}
private static IResult List(string? filter, string? cursor, int? limit, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, AuditLog audit, FeedbackConsultations consultations, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
if (string.IsNullOrEmpty(filter)) filter = null;
if (!PlayerAdminRules.IsValidFilter(filter)) return ApiResults.BadRequest("Filtre inconnu.");
(long, string)? after = null;
if (!string.IsNullOrEmpty(cursor))
{
var parts = cursor.Split(':');
if (cursor.Length > 60 || parts.Length != 2 || !long.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var created) ||
!PlayerIds.IsValidId(parts[1]))
return ApiResults.BadRequest("Curseur invalide.");
after = (created, parts[1]);
}
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
var now = time.GetUtcNow();
var size = Math.Clamp(limit ?? PageSize, 1, 200);
var (rows, total) = store.List(filter, after, size, now);
var last = rows.Count == size ? rows[^1].Account : null;
var page = new PlayerAdminPage
{
Items = rows.Select(r => Summary(r, secrets, now)).ToList(),
NextCursor = last is null ? null : $"{last.CreatedAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)}:{last.Id}",
Total = total
};
var actor = context.Admin();
if (consultations.ShouldRecord(actor.Key.KeyId, "players:list:" + (filter ?? "all"), continuation: !string.IsNullOrEmpty(cursor)))
audit.Write("players.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
"liste des comptes", $"filtre « {FilterLabel(filter)} » : {page.Items.Count} compte(s) sur {total}");
return Results.Json(page, ApiJson.Options);
}
/// <summary>
/// Recherche par référence (« PL-7KQ2-M9XD ») ou par adresse e-mail EXACTE (index aveugle : l'adresse n'est ni enregistrée
/// ni inscrite à l'audit). Toujours inscrite au journal d'audit (comment le texte a été compris et le résultat).
/// </summary>
private static IResult Search(PlayerSearchRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var query = request.Query?.Trim() ?? "";
if (query.Length == 0 || query.Length > PlayerAdminRules.MaxSearchLength)
return ApiResults.BadRequest("Saisissez une référence (PL-XXXX-XXXX) ou une adresse e-mail complète.");
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
string kind;
PlayerAccountRecord? found;
if (PlayerIds.NormalizeReference(query) is { } reference)
{
kind = "reference";
found = store.FindByReference(reference);
}
else if (PlayerEmail.Normalize(query) is { } email)
{
kind = "email";
found = store.FindByEmailIndex(secrets.EmailIndex(email));
}
else
{
return ApiResults.BadRequest("Ni une référence (PL-XXXX-XXXX), ni une adresse e-mail complète.");
}
var actor = context.Admin();
audit.Write("players.search", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
found?.Reference ?? "aucun compte",
(kind == "email" ? "recherche par adresse e-mail" : "recherche par référence") + (found is null ? " : aucun compte trouvé" : " : compte trouvé"));
var now = time.GetUtcNow();
return Results.Json(new PlayerSearchResult
{
Kind = kind,
Items = found is not null && store.Row(found.Id) is { } row ? new List<PlayerAdminSummary> { Summary(row, secrets, now) } : new()
}, ApiJson.Options);
}
private static IResult Get(string reference, HttpContext context, IOptions<LauncherApiOptions> options, IServiceProvider services,
AuditLog audit, FeedbackConsultations consultations, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
if (Find(store, reference) is not { } account) return NotFound();
var actor = context.Admin();
// Fiche (adresses IP de l'activité : données personnelles) : inscrite au journal, une fois par compte et par clé toutes les
// 10 minutes.
if (consultations.ShouldRecord(actor.Key.KeyId, "players:item:" + account.Id, continuation: true))
audit.Write("players.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
account.Reference, "fiche consultée (appareils, activité)");
return Results.Json(Detail(account, store, services.GetRequiredService<PlayerSecrets>(), time.GetUtcNow()), ApiJson.Options);
}
// ------------------------------------------------------------------ Gestion (players.manage, requêtes signées)
private static IResult Disable(string reference, PlayerDisableRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerSessionStore sessions, AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
if (Find(store, reference) is not { } account) return NotFound();
var now = time.GetUtcNow();
if (request.Until is { } until && (until <= now || until > now.AddDays(PlayerAdminRules.MaxDisableDays)))
return ApiResults.BadRequest($"Date de fin : dans le futur, {PlayerAdminRules.MaxDisableDays / 365} ans au plus (sinon : indéfiniment).");
var reason = PlayerAdminRules.CleanReason(request.Reason, out var problem);
if (problem is not null) return ApiResults.BadRequest($"Motif : {problem}");
var actor = context.Admin();
var end = request.Until is { } u ? DateTimeOffset.FromUnixTimeSeconds(u.ToUnixTimeSeconds()) : (DateTimeOffset?)null;
store.SetStatus(account.Id, PlayerStatus.Disabled, end, reason is null ? null : secrets.EncryptStatusReason(account.Id, reason),
ChangedBy(actor), now);
sessions.RemoveWhere(s => s.AccountId == account.Id); // ses jetons sont de toute façon refusés dès la requête suivante
var period = end is { } e ? $"jusqu'au {Utc(e)}" : "indéfiniment";
store.AddEvent(account.Id, PlayerEvents.Disabled, AuditResults.Ok, null, null, $"{Team} ; {period}{(reason is null ? "" : " ; motif communiqué")}");
Audit(audit, context, actor, "players.disable", account,
$"{period} ; ancien état : {PlayerAccountStatuses.Label(account.Status)}{(reason is null ? " ; sans motif" : $" ; motif : {reason}")}");
return Updated(account.Id, store, secrets, now);
}
private static IResult Enable(string reference, HttpContext context, IOptions<LauncherApiOptions> options, IServiceProvider services,
AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
if (Find(store, reference) is not { } account) return NotFound();
if (account.Status is not (PlayerStatus.Disabled or PlayerStatus.Inactive))
return ApiResults.Error(409, ApiErrorCodes.Conflict,
account.Status == PlayerStatus.Suspended
? "Ce compte n'est pas désactivé mais suspendu : utilisez « Lever la suspension »."
: "Ce compte est déjà actif.");
var actor = context.Admin();
var now = time.GetUtcNow();
store.SetStatus(account.Id, PlayerStatus.Active, null, null, ChangedBy(actor), now);
store.AddEvent(account.Id, PlayerEvents.Enabled, AuditResults.Ok, null, null, Team);
Audit(audit, context, actor, "players.enable", account, $"ancien état : {PlayerAccountStatuses.Label(account.Status)}");
return Updated(account.Id, store, services.GetRequiredService<PlayerSecrets>(), now);
}
/// <summary>Lève une suspension (le compte redevient actif) et les blocages temporaires (trop d'essais).</summary>
private static IResult Unlock(string reference, HttpContext context, IOptions<LauncherApiOptions> options, IServiceProvider services,
AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
if (Find(store, reference) is not { } account) return NotFound();
var now = time.GetUtcNow();
var locked = account.LockedUntilAt(now) is not null || account.FailedCount > 0 || account.LockLevel > 0 || account.TrustedFailedCount > 0;
if (account.Status != PlayerStatus.Suspended && !locked)
return ApiResults.Error(409, ApiErrorCodes.Conflict, "Ce compte n'est ni suspendu, ni bloqué : rien à lever.");
var actor = context.Admin();
string details;
if (account.Status == PlayerStatus.Suspended)
{
store.SetStatus(account.Id, PlayerStatus.Active, null, null, ChangedBy(actor), now); // blocages remis à zéro aussi
details = "suspension levée (compte réactivé), blocages remis à zéro";
}
else
{
store.ClearLocks(account.Id);
details = "blocages et compteurs d'échecs remis à zéro";
}
store.AddEvent(account.Id, PlayerEvents.Unlocked, AuditResults.Ok, null, null, Team);
Audit(audit, context, actor, "players.unlock", account, details);
return Updated(account.Id, store, services.GetRequiredService<PlayerSecrets>(), now);
}
private static IResult PasswordRequirement(string reference, PlayerPasswordRequirementRequest request, HttpContext context,
IOptions<LauncherApiOptions> options, IServiceProvider services, PlayerSessionStore sessions, AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
if (Find(store, reference) is not { } account) return NotFound();
if (account.MustChangePassword == request.Required)
return ApiResults.Error(409, ApiErrorCodes.Conflict,
request.Required ? "Un nouveau mot de passe est déjà exigé." : "Aucun nouveau mot de passe n'est exigé.");
var actor = context.Admin();
store.SetMustChangePassword(account.Id, request.Required);
if (request.Required)
{
// Sessions ouvertes fermées : la reconnexion demande le mot de passe, puis le nouveau. Les reconnexions automatiques
// sont refusées tant que ce n'est pas fait.
sessions.RemoveWhere(s => s.AccountId == account.Id && s.Scope == PlayerScopes.Full);
store.AddEvent(account.Id, PlayerEvents.PasswordResetRequired, AuditResults.Ok, null, null, Team);
Audit(audit, context, actor, "players.password_required", account,
"nouveau mot de passe exigé à la prochaine connexion (sessions ouvertes fermées)");
}
else
{
store.AddEvent(account.Id, PlayerEvents.PasswordResetCancelled, AuditResults.Ok, null, null, Team);
Audit(audit, context, actor, "players.password_required", account, "nouveau mot de passe plus exigé");
}
return Updated(account.Id, store, services.GetRequiredService<PlayerSecrets>(), time.GetUtcNow());
}
/// <summary>
/// Mot de passe temporaire (D6) : renvoyé UNE fois (jamais enregistré en clair, ni inscrit à l'audit), valable une heure, il ne
/// sert qu'à choisir un nouveau mot de passe et remplace un éventuel précédent. Les blocages temporaires sont levés ; une
/// suspension reste à lever à part (nouvel appareil refusé sinon).
/// </summary>
private static IResult TemporaryPassword(string reference, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
if (Find(store, reference) is not { } account) return NotFound();
if (account.Status == PlayerStatus.Disabled)
return ApiResults.Error(409, ApiErrorCodes.Conflict, "Ce compte est désactivé : réactivez-le d'abord.");
var now = time.GetUtcNow();
var password = PlayerTemporaryPassword.New();
var expires = DateTimeOffset.FromUnixTimeSeconds((now + PlayerTemporaryPassword.Lifetime).ToUnixTimeSeconds());
store.SetTemporaryPassword(account.Id, secrets.TemporaryPasswordHash(account.Id, PlayerTemporaryPassword.Normalize(password)!), expires);
var actor = context.Admin();
store.AddEvent(account.Id, PlayerEvents.TemporaryPassword, AuditResults.Ok, null, null, $"{Team} ; valable jusqu'au {Utc(expires)}");
Audit(audit, context, actor, "players.temporary_password", account,
$"valable jusqu'au {Utc(expires)}{(account.TemporaryPasswordHash is not null ? " ; remplace le précédent" : "")} ; blocages levés" +
(account.OtpEnabled ? " ; double authentification toujours demandée" : ""));
return Results.Json(new PlayerTemporaryPasswordResponse
{
Password = password,
ExpiresAt = expires,
Account = Detail(store.FindById(account.Id)!, store, secrets, now)
}, ApiJson.Options);
}
private static IResult OtpReset(string reference, HttpContext context, IOptions<LauncherApiOptions> options, IServiceProvider services,
AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
if (Find(store, reference) is not { } account) return NotFound();
if (!account.OtpEnabled) return ApiResults.Error(409, ApiErrorCodes.Conflict, "La double authentification n'est pas activée sur ce compte.");
var actor = context.Admin();
store.DisableOtp(account.Id);
store.AddEvent(account.Id, PlayerEvents.OtpReset, AuditResults.Ok, null, null, Team);
Audit(audit, context, actor, "players.otp_reset", account, "double authentification désactivée, codes de secours supprimés");
return Updated(account.Id, store, services.GetRequiredService<PlayerSecrets>(), time.GetUtcNow());
}
private static IResult Logout(string reference, PlayerDevicesLogoutRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerSessionStore sessions, AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
if (Find(store, reference) is not { } account) return NotFound();
var actor = context.Admin();
if (request.DeviceId is { } deviceId)
{
if (!PlayerIds.IsValidId(deviceId) || store.FindDevice(deviceId) is not { } device || device.AccountId != account.Id)
return ApiResults.NotFound("Appareil introuvable (déjà déconnecté ?).");
store.DeleteDevice(device.Id);
sessions.RemoveDevices(new[] { device.Id });
store.AddEvent(account.Id, PlayerEvents.DevicesRemovedByTeam, AuditResults.Ok, null, device.KeyId, $"{Team} ; appareil « {device.Label} »");
Audit(audit, context, actor, "players.logout", account, $"appareil « {device.Label} » (clé {device.KeyId}) déconnecté");
}
else
{
var removed = store.DeleteDevices(account.Id);
sessions.RemoveWhere(s => s.AccountId == account.Id);
store.AddEvent(account.Id, PlayerEvents.DevicesRemovedByTeam, AuditResults.Ok, null, null, $"{Team} ; {removed.Count} appareil(s)");
Audit(audit, context, actor, "players.logout", account, $"tous les appareils déconnectés ({removed.Count})");
}
return Updated(account.Id, store, services.GetRequiredService<PlayerSecrets>(), time.GetUtcNow());
}
private static IResult Delete(string reference, HttpContext context, IOptions<LauncherApiOptions> options, IServiceProvider services)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
if (Find(store, reference) is not { } account) return NotFound();
var details = $"compte joueur supprimé définitivement par un administrateur (état : {PlayerAccountStatuses.Label(account.Status)} ; " +
$"créé le {Utc(account.CreatedAt)})";
if (!services.GetRequiredService<PlayerPersonalData>().EraseByAdmin(account, context.Admin(), context.ClientIp(), details))
return NotFound();
return Results.NoContent();
}
// ------------------------------------------------------------------ Blocages (players.ban, étape 8e)
/// <summary>
/// Blocage depuis la fiche d'un joueur (D10) : identifiants matériels connus de ses PC et/ou sa dernière adresse IP (IPv6 : son
/// /64). Portée « création de compte » ou « création et connexion » (ses sessions sont alors fermées ; la reconnexion
/// automatique est refusée). Une adresse IP est bloquée un an au plus.
/// </summary>
private static IResult BanPlayer(string reference, PlayerBanRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerSessionStore sessions, AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
if (Find(store, reference) is not { } account) return NotFound();
if (!PlayerBanScopes.IsValid(request.Scope)) return ApiResults.BadRequest("Portée du blocage inconnue.");
var now = time.GetUtcNow();
var targets = new List<PlayerBanTargetRecord>();
if (request.Hardware)
targets.AddRange(store.Hardware(account.Id).Select(h => new PlayerBanTargetRecord(h.Kind, h.Hash, null)));
string? ipText = null;
if (request.Ip && store.LastIp(account.Id) is { } last &&
PlayerBanRules.ParseIpTarget(last.Ip, out _) is { } network)
{
targets.Add(new PlayerBanTargetRecord(PlayerBanTargetKinds.Ip, network.Network, network.Prefix));
ipText = PlayerBanRules.Display(network.Network, network.Prefix);
}
if (targets.Count == 0)
return ApiResults.BadRequest(request.Hardware || request.Ip
? "Rien à bloquer : aucun identifiant matériel ni adresse IP connus pour ce compte (launcher ancien, ou adresses effacées après 6 mois)."
: "Choisissez les identifiants matériels, l'adresse IP, ou les deux.");
if (PlayerBanRules.CheckUntil(request.Until, ipText is not null, now) is { } untilProblem)
return ApiResults.BadRequest($"Durée : {untilProblem}");
var note = PlayerBanRules.CleanNote(request.Note, out var noteProblem);
if (noteProblem is not null) return ApiResults.BadRequest($"Note : {noteProblem}");
var actor = context.Admin();
var ban = NewBan(store, secrets, request.Scope, request.Until, note, actor, account.Reference, targets, now);
services.GetRequiredService<PlayerBanGuard>().Invalidate();
if (PlayerBanScopes.BlocksLogin(request.Scope)) sessions.RemoveWhere(s => s.AccountId == account.Id);
var hardware = targets.Count(t => t.Kind != PlayerBanTargetKinds.Ip);
audit.Write("players.ban", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), ban.Reference,
$"compte {account.Reference} ; {PlayerBanScopes.Label(request.Scope).ToLowerInvariant()} ; {hardware} identifiant(s) matériel(s)" +
(ipText is null ? "" : $" ; IP {ipText}") + $" ; {Period(ban.ExpiresAt)}{(note is null ? "" : $" ; note : {note}")}");
return Updated(account.Id, store, secrets, now);
}
/// <summary>Blocage d'une adresse IP ou d'une plage seule (un an au plus).</summary>
private static IResult BanIp(PlayerIpBanRequest request, HttpContext context, IOptions<LauncherApiOptions> options, IServiceProvider services,
AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
if (!PlayerBanScopes.IsValid(request.Scope)) return ApiResults.BadRequest("Portée du blocage inconnue.");
if (PlayerBanRules.ParseIpTarget(request.Ip, out var ipProblem) is not { } network) return ApiResults.BadRequest($"Adresse : {ipProblem}");
var now = time.GetUtcNow();
if (PlayerBanRules.CheckUntil(request.Until, targetsIp: true, now) is { } untilProblem) return ApiResults.BadRequest($"Durée : {untilProblem}");
var note = PlayerBanRules.CleanNote(request.Note, out var noteProblem);
if (noteProblem is not null) return ApiResults.BadRequest($"Note : {noteProblem}");
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
var actor = context.Admin();
var ban = NewBan(store, secrets, request.Scope, request.Until, note, actor, null,
new[] { new PlayerBanTargetRecord(PlayerBanTargetKinds.Ip, network.Network, network.Prefix) }, now);
services.GetRequiredService<PlayerBanGuard>().Invalidate();
var display = PlayerBanRules.Display(network.Network, network.Prefix);
audit.Write("players.ban", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), ban.Reference,
$"IP {display} ; {PlayerBanScopes.Label(request.Scope).ToLowerInvariant()} ; {Period(ban.ExpiresAt)}{(note is null ? "" : $" ; note : {note}")}");
return Results.Json(ToDto(store.FindBan(ban.Reference, now) ?? ban, secrets), ApiJson.Options);
}
private static IResult ListBans(HttpContext context, IOptions<LauncherApiOptions> options, IServiceProvider services, AuditLog audit,
FeedbackConsultations consultations, TimeProvider time)
{
if (!options.Value.Players.Enabled) return Results.Json(new PlayerBansPage { Enabled = false }, ApiJson.Options);
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
var bans = store.ActiveBans(time.GetUtcNow());
var actor = context.Admin();
// Adresses IP en clair : consultation inscrite au journal (une fois par clé toutes les 10 minutes).
if (consultations.ShouldRecord(actor.Key.KeyId, "players:bans", continuation: true))
audit.Write("players.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
"blocages", $"{bans.Count} blocage(s) en vigueur");
return Results.Json(new PlayerBansPage { Enabled = true, Items = bans.Select(b => ToDto(b, secrets)).ToList() }, ApiJson.Options);
}
private static IResult Unban(string reference, HttpContext context, IOptions<LauncherApiOptions> options, IServiceProvider services,
AuditLog audit, TimeProvider time)
{
if (!options.Value.Players.Enabled) return NotConfigured();
var store = services.GetRequiredService<PlayerStore>();
if (PlayerIds.NormalizeBanReference(reference) is not { } normalized || store.FindBan(normalized, time.GetUtcNow()) is not { } ban ||
!store.DeleteBan(ban.Id))
return ApiResults.NotFound("Blocage introuvable (déjà levé ou échu ?).");
services.GetRequiredService<PlayerBanGuard>().Invalidate();
var actor = context.Admin();
audit.Write("players.unban", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), ban.Reference,
$"{TargetsText(ban)} ; {PlayerBanScopes.Label(ban.Scope).ToLowerInvariant()} ; {ban.Hits} tentative(s) refusée(s)" +
(ban.AccountReference is { } acc ? $" ; compte {acc}" : ""));
return Results.NoContent();
}
private static PlayerBanRecord NewBan(PlayerStore store, PlayerSecrets secrets, string scope, DateTimeOffset? until, string? note,
AuthenticatedAdmin actor, string? accountReference, IReadOnlyList<PlayerBanTargetRecord> targets, DateTimeOffset now)
{
var id = PlayerIds.NewId();
var ban = new PlayerBanRecord
{
Id = id,
Reference = store.NewBanReference(PlayerIds.NewBanReference),
Scope = scope,
NoteEncrypted = note is null ? null : secrets.EncryptBanNote(id, note),
CreatedAt = DateTimeOffset.FromUnixTimeSeconds(now.ToUnixTimeSeconds()),
CreatedBy = actor.Admin.DisplayName,
ExpiresAt = until is { } u ? DateTimeOffset.FromUnixTimeSeconds(u.ToUnixTimeSeconds()) : null,
AccountReference = accountReference,
Targets = targets.Distinct().ToList()
};
store.AddBan(ban);
return ban;
}
private static PlayerBan ToDto(PlayerBanRecord ban, PlayerSecrets secrets) => new()
{
Reference = ban.Reference,
Scope = ban.Scope,
Targets = ban.Targets.Select(t => new PlayerBanTarget
{
Kind = t.Kind,
Display = t.Kind == PlayerBanTargetKinds.Ip && t.Prefix is { } prefix ? PlayerBanRules.Display(t.Value, prefix) : Fingerprint(t.Value)
}).ToList(),
CreatedAt = ban.CreatedAt,
CreatedBy = ban.CreatedBy,
ExpiresAt = ban.ExpiresAt,
AccountReference = ban.AccountReference,
Note = secrets.TryDecryptBanNote(ban.Id, ban.NoteEncrypted),
Hits = ban.Hits,
LastHitAt = ban.LastHitAt
};
/// <summary>Empreinte matérielle abrégée (12 caractères) : assez pour la reconnaître, rien à exploiter.</summary>
private static string Fingerprint(string hash) => hash.Length <= 12 ? hash : hash[..12];
private static string TargetsText(PlayerBanRecord ban)
{
var ips = ban.Targets.Where(t => t.Kind == PlayerBanTargetKinds.Ip && t.Prefix is not null)
.Select(t => PlayerBanRules.Display(t.Value, t.Prefix!.Value)).ToList();
var hardware = ban.Targets.Count(t => t.Kind != PlayerBanTargetKinds.Ip);
return string.Join(" ; ", new[] { ips.Count > 0 ? "IP " + string.Join(", ", ips) : null,
hardware > 0 ? $"{hardware} identifiant(s) matériel(s)" : null }.Where(t => t is not null));
}
private static string Period(DateTimeOffset? until) => until is { } u ? $"jusqu'au {Utc(u)}" : "indéfiniment";
// ------------------------------------------------------------------ Outils
private static PlayerAccountRecord? Find(PlayerStore store, string reference) =>
PlayerIds.NormalizeReference(reference) is { } normalized ? store.FindByReference(normalized) : null;
private static string ChangedBy(AuthenticatedAdmin actor) => $"{actor.Admin.DisplayName} (menu d'administration)";
private static string Utc(DateTimeOffset date) => date.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture);
private static string FilterLabel(string? filter) =>
PlayerAdminRules.Filters.FirstOrDefault(f => f.Value == filter).Label ?? filter ?? "";
private static void Audit(AuditLog audit, HttpContext context, AuthenticatedAdmin actor, string action, PlayerAccountRecord account,
string details) =>
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), account.Reference, details);
private static IResult Updated(string accountId, PlayerStore store, PlayerSecrets secrets, DateTimeOffset now) =>
store.FindById(accountId) is { } account ? Results.Json(Detail(account, store, secrets, now), ApiJson.Options) : NotFound();
private static PlayerAdminSummary Summary(PlayerListRow row, PlayerSecrets secrets, DateTimeOffset now)
{
var a = row.Account;
return new PlayerAdminSummary
{
Reference = a.Reference,
EmailMasked = PlayerEmail.Mask(secrets.DecryptEmail(a.Id, a.EmailEncrypted)),
Status = a.Status,
DisabledUntil = a.Status == PlayerStatus.Disabled ? a.DisabledUntil : null,
CreatedAt = a.CreatedAt,
LastLoginAt = a.LastLoginAt,
LastActivityAt = row.LastActivityAt,
OtpEnabled = a.OtpEnabled,
MustChangePassword = a.MustChangePassword,
LockedUntil = a.LockedUntilAt(now),
DeviceCount = row.DeviceCount
};
}
private static PlayerAdminDetail Detail(PlayerAccountRecord account, PlayerStore store, PlayerSecrets secrets, DateTimeOffset now)
{
var devices = store.Devices(account.Id);
var labels = devices.GroupBy(d => d.KeyId, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First().Label, StringComparer.Ordinal);
var row = new PlayerListRow(account, devices.Count, devices.Count == 0 ? null : devices.Max(d => d.LastUsedAt));
var hardware = store.Hardware(account.Id);
var bans = store.ActiveBans(now);
var lastIp = store.LastIp(account.Id);
var lastAddress = lastIp is { } known && global::System.Net.IPAddress.TryParse(known.Ip, out var parsed) ? parsed : null;
return new PlayerAdminDetail
{
Summary = Summary(row, secrets, now),
EmailVerified = account.EmailVerified,
TermsVersion = account.TermsVersion,
PasswordChangedAt = account.PasswordChangedAt,
StatusReason = secrets.TryDecryptStatusReason(account.Id, account.StatusReasonEncrypted),
StatusChangedAt = account.StatusChangedAt,
StatusChangedBy = account.StatusChangedBy,
FailedCount = account.FailedCount,
LockLevel = account.LockLevel,
UnknownDevicesLockedUntil = account.LockedUntil is { } u && u > now ? u : null,
TrustedFailedCount = account.TrustedFailedCount,
TrustedDevicesLockedUntil = account.TrustedLockedUntil is { } t && t > now ? t : null,
RecoveryCodesLeft = account.OtpEnabled ? store.CountRecoveryCodes(account.Id) : 0,
TemporaryPasswordExpiresAt = account.TemporaryPasswordExpiresAt is { } e && e > now && account.TemporaryPasswordHash is not null ? e : null,
Devices = devices.Select(d => new PlayerAdminDevice
{
Id = d.Id,
Label = d.Label,
KeyId = d.KeyId,
CreatedAt = d.CreatedAt,
LastUsedAt = d.LastUsedAt,
RememberedUntil = d.RefreshExpiresAt
}).ToList(),
Hardware = hardware.Select(h => new PlayerAdminHardware
{
Kind = h.Kind,
Fingerprint = Fingerprint(h.Hash),
FirstSeenAt = h.FirstSeenAt,
LastSeenAt = h.LastSeenAt,
BannedBy = bans.FirstOrDefault(b => b.Targets.Any(t => t.Kind == h.Kind && t.Value == h.Hash))?.Reference
}).ToList(),
LastIp = lastIp?.Ip,
LastIpAt = lastIp?.At,
Bans = bans.Where(b => b.AccountReference == account.Reference ||
b.Targets.Any(t => t.Kind != PlayerBanTargetKinds.Ip && hardware.Any(h => h.Kind == t.Kind && h.Hash == t.Value)) ||
(lastAddress is not null && b.Targets.Any(t => t.Kind == PlayerBanTargetKinds.Ip && t.Prefix is { } prefix &&
PlayerBanRules.Contains(t.Value, prefix, lastAddress))))
.Select(b => ToDto(b, secrets)).ToList(),
Events = store.Events(account.Id, PlayerAdminRules.DetailEvents).Select(e => new PlayerAdminEvent
{
At = e.At,
Type = e.Type,
Success = e.Result == AuditResults.Ok,
Ip = e.Ip,
DeviceKeyId = e.DeviceKeyId,
Device = e.DeviceKeyId is { } key && labels.TryGetValue(key, out var label) ? label : null,
Details = e.Details
}).ToList()
};
}
}
@@ -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,137 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage.Players;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Security;
/// <summary>Joueur authentifié pour la requête en cours.</summary>
public sealed record AuthenticatedPlayer(PlayerAccessSession Session, PlayerAccountRecord Account, PlayerDeviceRecord? Device);
/// <summary>
/// Schéma « PlayerSession » : jeton joueur (« Authorization: Bearer glpa_… », « glpc_… » ou « glpo_… »). TOTALEMENT distinct du
/// schéma des administrateurs (<see cref="AdminSessionHandler"/>) : autre stockage, autres préfixes, et les routes
/// d'administration n'utilisent jamais ce schéma (leurs règles désignent explicitement « AdminSession »). Un compte joueur
/// ne peut donc ouvrir aucune route d'administration, quels que soient ses droits ou son état.
///
/// À chaque requête : jeton valide ET compte actif (ou suspendu : la suspension ne vise que les nouveaux appareils) ET
/// (session complète) appareil toujours enregistré et aucun changement de mot de passe exigé. Une désactivation ou une
/// déconnexion prend effet immédiatement.
/// </summary>
public sealed class PlayerSessionHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "PlayerSession";
public const string ScopeClaim = "player_scope";
private const string ContextKey = "GameLauncher.Player";
private readonly PlayerSessionStore _sessions;
private readonly IServiceProvider _services;
private readonly LauncherApiOptions _options;
public PlayerSessionHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder,
PlayerSessionStore sessions, IServiceProvider services, IOptions<LauncherApiOptions> apiOptions)
: base(options, logger, encoder)
{
_sessions = sessions;
_services = services;
_options = apiOptions.Value;
}
public static AuthenticatedPlayer? Get(HttpContext context) =>
context.Items.TryGetValue(ContextKey, out var value) ? value as AuthenticatedPlayer : null;
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!_options.Players.Enabled) return Task.FromResult(AuthenticateResult.NoResult());
var header = Request.Headers.Authorization.ToString();
if (!header.StartsWith("Bearer ", StringComparison.Ordinal)) return Task.FromResult(AuthenticateResult.NoResult());
var token = header["Bearer ".Length..].Trim();
if (!PlayerSessionStore.IsPlayerToken(token))
return Task.FromResult(AuthenticateResult.NoResult()); // pas un jeton joueur
var session = _sessions.Find(token);
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session joueur absente ou expirée."));
var store = _services.GetRequiredService<PlayerStore>();
var account = store.FindById(session.AccountId);
// Suspendu : les appareils déjà connectés gardent l'accès (seuls les nouveaux appareils sont bloqués).
// Jeton de la 2ᵉ étape de la connexion : l'état du compte n'est révélé (ou un compte en sommeil réveillé) qu'APRÈS
// le code de vérification, par la route elle-même.
if (account is null || (session.Scope != PlayerScopes.Otp && account.Status is not (PlayerStatus.Active or PlayerStatus.Suspended)))
{
_sessions.RemoveWhere(s => s.AccountId == session.AccountId);
return Task.FromResult(AuthenticateResult.Fail("Compte supprimé, désactivé ou suspendu."));
}
PlayerDeviceRecord? device = null;
if (session.Scope == PlayerScopes.Full)
{
device = session.DeviceId is { } deviceId ? store.FindDevice(deviceId) : null;
if (device is null || device.AccountId != account.Id || account.MustChangePassword)
{
_sessions.Remove(session);
return Task.FromResult(AuthenticateResult.Fail("Appareil déconnecté ou nouveau mot de passe exigé."));
}
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, account.Id),
new(ScopeClaim, session.Scope)
};
Context.Items[ContextKey] = new AuthenticatedPlayer(session, account, device);
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName)));
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties) =>
ApiResults.WriteAsync(Context, 401, ApiErrorCodes.Unauthorized, "Connexion au compte requise (session absente ou expirée).");
protected override Task HandleForbiddenAsync(AuthenticationProperties properties) =>
Get(Context)?.Session.Scope == PlayerScopes.Otp
? ApiResults.WriteAsync(Context, 403, ApiErrorCodes.OtpRequired, "Saisissez d'abord le code de vérification.")
: ApiResults.WriteAsync(Context, 403, ApiErrorCodes.PasswordChangeRequired, "Choisissez d'abord un nouveau mot de passe.");
}
public static class PlayerAuthorization
{
public const string PlayerPolicy = "player";
public const string PasswordChangePolicy = "player-password-change";
public const string OtpPolicy = "player-otp";
/// <summary>Route réservée à un joueur connecté (session complète).</summary>
public static TBuilder RequirePlayer<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
builder.RequireAuthorization(PlayerPolicy);
/// <summary>Route ouverte à une session complète OU au jeton limité « changement de mot de passe exigé ».</summary>
public static TBuilder RequirePlayerOrPasswordChange<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
builder.RequireAuthorization(PasswordChangePolicy);
/// <summary>Route réservée au jeton de la 2ᵉ étape de la connexion (code de vérification).</summary>
public static TBuilder RequirePlayerOtp<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
builder.RequireAuthorization(OtpPolicy);
public static IServiceCollection AddPlayerAuthentication(this IServiceCollection services)
{
services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, PlayerSessionHandler>(PlayerSessionHandler.SchemeName, null);
services.AddAuthorization(options =>
{
options.AddPolicy(PlayerPolicy, p => p.AddAuthenticationSchemes(PlayerSessionHandler.SchemeName).RequireAuthenticatedUser()
.RequireClaim(PlayerSessionHandler.ScopeClaim, PlayerScopes.Full));
options.AddPolicy(PasswordChangePolicy, p => p.AddAuthenticationSchemes(PlayerSessionHandler.SchemeName).RequireAuthenticatedUser()
.RequireClaim(PlayerSessionHandler.ScopeClaim, PlayerScopes.Full, PlayerScopes.PasswordChange));
options.AddPolicy(OtpPolicy, p => p.AddAuthenticationSchemes(PlayerSessionHandler.SchemeName).RequireAuthenticatedUser()
.RequireClaim(PlayerSessionHandler.ScopeClaim, PlayerScopes.Otp));
});
return services;
}
/// <summary>Joueur authentifié de la requête (les routes protégées en ont toujours un).</summary>
public static AuthenticatedPlayer Player(this HttpContext context) =>
PlayerSessionHandler.Get(context) ?? throw new InvalidOperationException("Route non protégée par RequirePlayer.");
}
@@ -0,0 +1,88 @@
using System.Net;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Server.Storage.Players;
namespace GameLauncher.Server.Security;
/// <summary>
/// Contrôle des blocages (étape 8e) à la création de compte, à la connexion et au renouvellement : adresse IP de la requête
/// (comparée aux adresses et plages bloquées) et identifiants matériels (empreintes du serveur). Les blocages en vigueur sont
/// gardés en mémoire 30 secondes au plus (relus aussitôt après une création ou une levée faite sur ce serveur).
/// </summary>
public sealed class PlayerBanGuard
{
private static readonly TimeSpan CacheLifetime = TimeSpan.FromSeconds(30);
private readonly PlayerStore _store;
private readonly TimeProvider _time;
private readonly object _lock = new();
private IReadOnlyList<PlayerBanRecord> _bans = Array.Empty<PlayerBanRecord>();
private DateTimeOffset _loadedAt = DateTimeOffset.MinValue;
public PlayerBanGuard(PlayerStore store, TimeProvider time)
{
_store = store;
_time = time;
}
/// <summary>Un blocage vient d'être créé ou levé : relecture à la prochaine vérification.</summary>
public void Invalidate()
{
lock (_lock) _loadedAt = DateTimeOffset.MinValue;
}
/// <summary>
/// Blocage qui refuse cette action (création de compte : toute portée ; connexion et renouvellement : portée « création
/// et connexion »), ou null. Compte la tentative refusée.
/// </summary>
/// <param name="hardware">Empreintes du serveur des identifiants matériels de l'appareil.</param>
public PlayerBanRecord? Check(string? ip, IEnumerable<string> hardware, bool login)
{
var now = _time.GetUtcNow();
var hashes = hardware.ToHashSet(StringComparer.Ordinal);
IPAddress? address = null;
if (ip is not null && IPAddress.TryParse(ip, out var parsed)) address = parsed.IsIPv4MappedToIPv6 ? parsed.MapToIPv4() : parsed;
foreach (var ban in Current(now))
{
if (ban.ExpiresAt is { } end && end <= now) continue;
if (login && !PlayerBanScopes.BlocksLogin(ban.Scope)) continue;
var hit = ban.Targets.Any(t => t.Kind == PlayerBanTargetKinds.Ip
? address is not null && t.Prefix is { } prefix && PlayerBanRules.Contains(t.Value, prefix, address)
: hashes.Contains($"{t.Kind}:{t.Value}"));
if (!hit) continue;
_store.CountBanHit(ban.Id, now);
return ban;
}
return null;
}
private IReadOnlyList<PlayerBanRecord> Current(DateTimeOffset now)
{
lock (_lock)
{
if (now - _loadedAt < CacheLifetime && now >= _loadedAt) return _bans;
_bans = _store.ActiveBans(now);
_loadedAt = now;
return _bans;
}
}
/// <summary>Empreintes du serveur (« type:hex ») des identifiants matériels reçus ; les entrées invalides sont ignorées.</summary>
public static IReadOnlyList<(string Kind, string Hash)> ServerHashes(IEnumerable<string>? entries, PlayerSecrets secrets) =>
(entries ?? Array.Empty<string>()).Take(PlayerHardware.MaxEntries)
.Select(e => PlayerHardware.IsValid(e, out var kind, out var hash) ? (Kind: kind, Hash: secrets.HardwareHash(kind, hash)) : default)
.Where(e => e.Kind is not null)
.Distinct()
.ToList();
/// <summary>Forme enregistrée sur un appareil : « type:hex » séparés par des espaces (null si aucun).</summary>
public static string? Join(IEnumerable<(string Kind, string Hash)> hardware)
{
var text = string.Join(' ', hardware.Select(h => $"{h.Kind}:{h.Hash}"));
return text.Length == 0 ? null : text;
}
public static IEnumerable<string> Split(string? stored) =>
string.IsNullOrEmpty(stored) ? Array.Empty<string>() : stored.Split(' ', StringSplitOptions.RemoveEmptyEntries);
}
@@ -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,205 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Security;
/// <summary>Portée d'un jeton joueur.</summary>
public static class PlayerScopes
{
/// <summary>Session complète (jeton « glpa_ »).</summary>
public const string Full = "full";
/// <summary>Seulement le changement de mot de passe exigé (jeton « glpc_ »).</summary>
public const string PasswordChange = "password_change";
/// <summary>Seulement la 2ᵉ étape de la connexion : code de vérification (jeton « glpo_ »).</summary>
public const string Otp = "otp";
/// <summary>Préfixe des jetons d'une portée (un jeton ne change jamais de portée).</summary>
public static string PrefixOf(string scope) => scope switch
{
Full => PlayerTokens.AccessPrefix,
PasswordChange => PlayerTokens.PasswordChangePrefix,
Otp => PlayerTokens.OtpPrefix,
_ => throw new ArgumentOutOfRangeException(nameof(scope))
};
}
/// <summary>Jeton joueur valide (en mémoire uniquement : seule l'empreinte du jeton est gardée).</summary>
public sealed class PlayerAccessSession
{
public required string TokenHash { get; init; }
public required string Scope { get; init; }
public required string AccountId { get; init; }
/// <summary>Appareil de la session (portée complète).</summary>
public string? DeviceId { get; init; }
/// <summary>Clé publique et nom de l'appareil (portée « changement exigé » : l'appareil est enregistré une fois le
/// mot de passe changé).</summary>
public string? DevicePublicKey { get; init; }
public string? DeviceKeyId { get; init; }
public string? DeviceLabel { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Connexion faite avec un mot de passe temporaire (portées « code de vérification » et « changement exigé ») : le
/// changement de mot de passe vérifie alors ce mot de passe temporaire au lieu de l'ancien.
/// </summary>
public bool ViaTemporaryPassword { get; init; }
/// <summary>Identifiants matériels de l'appareil (empreintes du serveur), enregistrés avec lui une fois la connexion finie.</summary>
public string? DeviceHardware { get; init; }
private int _attempts;
/// <summary>Compte un essai (portée « code de vérification ») et renvoie le nombre d'essais faits.</summary>
public int CountAttempt() => Interlocked.Increment(ref _attempts);
}
/// <summary>
/// Jetons d'accès des joueurs, en mémoire (un redémarrage du service les efface : le launcher renouvelle sa session avec
/// son jeton de renouvellement, sans rien demander au joueur). Totalement séparés des sessions des administrateurs
/// (<see cref="SessionStore"/>) : autre stockage, autre préfixe, autre schéma d'authentification.
/// </summary>
public sealed class PlayerSessionStore
{
private const int MaxSessions = 200_000;
private static readonly TimeSpan PasswordChangeLifetime = TimeSpan.FromMinutes(10);
/// <summary>Durée et nombre d'essais du jeton de la 2ᵉ étape de la connexion (au-delà : reprendre la connexion).</summary>
public static readonly TimeSpan OtpLifetime = TimeSpan.FromMinutes(5);
public const int MaxOtpAttempts = 5;
private readonly ConcurrentDictionary<string, PlayerAccessSession> _byHash = new(StringComparer.Ordinal);
private readonly TimeProvider _time;
private readonly LauncherApiOptions.PlayersSettings _settings;
public PlayerSessionStore(IOptions<LauncherApiOptions> options, TimeProvider time)
{
_time = time;
_settings = options.Value.Players;
}
public int Count => _byHash.Count;
/// <summary>Jeton d'accès d'une session complète sur un appareil.</summary>
public (string Token, PlayerAccessSession Session) CreateAccess(string accountId, string deviceId)
{
var token = PlayerTokens.AccessPrefix + PlayerTokens.NewSecret();
var session = new PlayerAccessSession
{
TokenHash = Hash(token),
Scope = PlayerScopes.Full,
AccountId = accountId,
DeviceId = deviceId,
ExpiresAt = _time.GetUtcNow() + TimeSpan.FromMinutes(_settings.AccessTokenMinutes)
};
Add(session);
return (token, session);
}
/// <summary>Jeton limité au changement de mot de passe exigé (10 min), qui retient l'appareil à enregistrer ensuite.</summary>
public (string Token, PlayerAccessSession Session) CreatePasswordChange(string accountId, string devicePublicKey, string deviceKeyId,
string deviceLabel, bool viaTemporaryPassword = false,
string? deviceHardware = null)
{
var token = PlayerTokens.PasswordChangePrefix + PlayerTokens.NewSecret();
var session = new PlayerAccessSession
{
TokenHash = Hash(token),
Scope = PlayerScopes.PasswordChange,
AccountId = accountId,
DevicePublicKey = devicePublicKey,
DeviceKeyId = deviceKeyId,
DeviceLabel = deviceLabel,
ViaTemporaryPassword = viaTemporaryPassword,
DeviceHardware = deviceHardware,
ExpiresAt = _time.GetUtcNow() + PasswordChangeLifetime
};
Add(session);
return (token, session);
}
/// <summary>
/// Jeton de la 2ᵉ étape de la connexion (double authentification activée) : mot de passe vérifié, il ne permet que
/// d'envoyer le code ; il retient l'appareil à enregistrer ensuite.
/// </summary>
public (string Token, PlayerAccessSession Session) CreateOtp(string accountId, string devicePublicKey, string deviceKeyId, string deviceLabel,
bool viaTemporaryPassword = false, string? deviceHardware = null)
{
var token = PlayerTokens.OtpPrefix + PlayerTokens.NewSecret();
var session = new PlayerAccessSession
{
TokenHash = Hash(token),
Scope = PlayerScopes.Otp,
AccountId = accountId,
DevicePublicKey = devicePublicKey,
DeviceKeyId = deviceKeyId,
DeviceLabel = deviceLabel,
ViaTemporaryPassword = viaTemporaryPassword,
DeviceHardware = deviceHardware,
ExpiresAt = _time.GetUtcNow() + OtpLifetime
};
Add(session);
return (token, session);
}
/// <summary>Session du jeton, si elle est encore valide.</summary>
public PlayerAccessSession? Find(string? token)
{
if (string.IsNullOrEmpty(token) || token.Length > PlayerTokens.MaxLength) return null;
if (!IsPlayerToken(token)) return null;
var hash = Hash(token);
if (!_byHash.TryGetValue(hash, out var session)) return null;
if (session.ExpiresAt <= _time.GetUtcNow())
{
_byHash.TryRemove(hash, out _);
return null;
}
// Le préfixe doit correspondre à la portée (un jeton ne change jamais de portée).
return token.StartsWith(PlayerScopes.PrefixOf(session.Scope), StringComparison.Ordinal) ? session : null;
}
/// <summary>Jeton présenté dans « Authorization » : accès, changement exigé ou code de vérification.</summary>
public static bool IsPlayerToken(string token) =>
token.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) ||
token.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal) ||
token.StartsWith(PlayerTokens.OtpPrefix, StringComparison.Ordinal);
public void Remove(PlayerAccessSession session) => _byHash.TryRemove(session.TokenHash, out _);
/// <summary>Ferme toutes les sessions d'un compte (ou d'appareils donnés).</summary>
public int RemoveWhere(Func<PlayerAccessSession, bool> predicate)
{
var removed = 0;
foreach (var (hash, session) in _byHash)
if (predicate(session) && _byHash.TryRemove(hash, out _)) removed++;
return removed;
}
public int RemoveDevices(IEnumerable<string> deviceIds)
{
var set = deviceIds.ToHashSet(StringComparer.Ordinal);
return set.Count == 0 ? 0 : RemoveWhere(s => s.DeviceId is { } id && set.Contains(id));
}
private void Add(PlayerAccessSession session)
{
if (_byHash.Count >= MaxSessions)
{
var now = _time.GetUtcNow();
RemoveWhere(s => s.ExpiresAt <= now);
if (_byHash.Count >= MaxSessions) throw new StoreBusyException("Trop de sessions de joueurs ouvertes.");
}
_byHash[session.TokenHash] = session;
}
internal static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
}
@@ -0,0 +1,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,185 @@
namespace GameLauncher.Server.Storage.Players;
/// <summary>États d'un compte joueur.</summary>
public static class PlayerStatus
{
public const string Active = "active";
/// <summary>Désactivé par un administrateur (jusqu'à une date, ou indéfiniment).</summary>
public const string Disabled = "disabled";
/// <summary>
/// Suspendu après trop d'échecs de connexion depuis des appareils inconnus : connexions depuis un NOUVEL appareil
/// refusées jusqu'à ce qu'un administrateur lève la suspension ; les appareils déjà connectés continuent de fonctionner.
/// </summary>
public const string Suspended = "suspended";
/// <summary>
/// En sommeil après une longue inactivité (<c>Players:InactiveDisableMonths</c>) : appareils déconnectés ; une connexion
/// avec le bon mot de passe le réveille. Supprimé au bout de <c>Players:InactiveDeleteMonths</c> de plus.
/// </summary>
public const string Inactive = "inactive";
}
/// <summary>Compte joueur tel qu'enregistré (adresse chiffrée ; jamais renvoyé tel quel par l'API).</summary>
public sealed record PlayerAccountRecord
{
public required string Id { get; init; }
public required string Reference { get; init; }
public required string EmailIndex { get; init; }
public required string EmailEncrypted { get; init; }
public bool EmailVerified { get; init; }
public required string PasswordHash { get; init; }
public DateTimeOffset PasswordChangedAt { get; init; }
public bool MustChangePassword { get; init; }
public bool OtpEnabled { get; init; }
public string Status { get; init; } = PlayerStatus.Active;
public DateTimeOffset? DisabledUntil { get; init; }
public DateTimeOffset? StatusChangedAt { get; init; }
/// <summary>Motif de la désactivation (chiffré, lié au compte), montré au joueur à la connexion.</summary>
public string? StatusReasonEncrypted { get; init; }
/// <summary>Auteur du dernier changement d'état (administrateur, outil en ligne de commande, système).</summary>
public string? StatusChangedBy { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastLoginAt { get; init; }
public required string TermsVersion { get; init; }
/// <summary>Échecs consécutifs depuis des appareils inconnus (série en cours), et nombre de blocages successifs.</summary>
public int FailedCount { get; init; }
public int LockLevel { get; init; }
public DateTimeOffset? LockedUntil { get; init; }
/// <summary>Échecs consécutifs depuis des appareils de confiance, et leur blocage temporaire.</summary>
public int TrustedFailedCount { get; init; }
public DateTimeOffset? TrustedLockedUntil { get; init; }
/// <summary>
/// Mot de passe temporaire créé par un administrateur (étape 8d) : empreinte HMAC et échéance. Il ne sert qu'à choisir un
/// nouveau mot de passe ; tout changement de mot de passe l'efface.
/// </summary>
public string? TemporaryPasswordHash { get; init; }
public DateTimeOffset? TemporaryPasswordExpiresAt { get; init; }
/// <summary>Blocage temporaire en cours (appareils inconnus ou de confiance) : sa fin, sinon null.</summary>
public DateTimeOffset? LockedUntilAt(DateTimeOffset now)
{
DateTimeOffset? end = null;
foreach (var until in new[] { LockedUntil, TrustedLockedUntil })
if (until is { } u && u > now && (end is null || u > end)) end = u;
return end;
}
}
/// <summary>Compte dans une liste du menu d'administration : appareils connectés et dernière utilisation de l'un d'eux.</summary>
public sealed record PlayerListRow(PlayerAccountRecord Account, int DeviceCount, DateTimeOffset? LastActivityAt);
/// <summary>Compteurs des comptes (page « Comptes joueurs »).</summary>
public sealed record PlayerCounts(int Total, int Active, int Disabled, int Suspended, int Inactive, int Locked, int PasswordChangeRequired,
int OtpEnabled, int CreatedLast30Days);
/// <summary>Appareil connecté (clé publique de l'appareil et session mémorisée).</summary>
public sealed record PlayerDeviceRecord
{
public required string Id { get; init; }
public required string AccountId { get; init; }
public required string KeyId { get; init; }
/// <summary>Clé publique de l'appareil (SPKI P-256, Base64).</summary>
public required string PublicKey { get; init; }
public required string Label { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset LastUsedAt { get; init; }
/// <summary>SHA-256 du jeton de renouvellement en vigueur, et du précédent (détection d'une réutilisation).</summary>
public required string RefreshHash { get; init; }
public string? PreviousRefreshHash { get; init; }
public DateTimeOffset RefreshExpiresAt { get; init; }
public DateTimeOffset RefreshAbsoluteExpiresAt { get; init; }
/// <summary>Identifiants matériels du PC (empreintes du serveur « type:hex », séparées par des espaces), ou null.</summary>
public string? Hardware { get; init; }
}
/// <summary>Identifiant matériel vu sur un compte (empreinte du serveur), première et dernière fois.</summary>
public sealed record PlayerHardwareRecord(string Kind, string Hash, DateTimeOffset FirstSeenAt, DateTimeOffset LastSeenAt);
/// <summary>Cible d'un blocage : adresse IP ou plage (réseau, préfixe), ou identifiant matériel (empreinte du serveur).</summary>
public sealed record PlayerBanTargetRecord(string Kind, string Value, int? Prefix);
/// <summary>Blocage (étape 8e).</summary>
public sealed record PlayerBanRecord
{
public required string Id { get; init; }
public required string Reference { get; init; }
public required string Scope { get; init; }
public string? NoteEncrypted { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public required string CreatedBy { get; init; }
public DateTimeOffset? ExpiresAt { get; init; }
public string? AccountReference { get; init; }
public int Hits { get; init; }
public DateTimeOffset? LastHitAt { get; init; }
public IReadOnlyList<PlayerBanTargetRecord> Targets { get; init; } = Array.Empty<PlayerBanTargetRecord>();
}
/// <summary>
/// Double authentification d'un compte : secret en vigueur (chiffré) et dernier pas de temps accepté (un code ne sert qu'une
/// fois), secret en cours de configuration (chiffré) et date de début de cette configuration.
/// </summary>
public sealed record PlayerOtpState(string? SecretEncrypted, long? LastStep, string? PendingEncrypted, DateTimeOffset? PendingAt)
{
public bool Enabled => SecretEncrypted is not null;
}
/// <summary>Ligne du journal des comptes.</summary>
public sealed record PlayerEventRecord(long Id, string? AccountId, DateTimeOffset At, string Type, string Result, string? Ip, string? DeviceKeyId,
string? Details);
/// <summary>Résultat d'un échec de connexion pour un compte existant.</summary>
public sealed record PlayerFailureOutcome(DateTimeOffset? LockedUntil, bool Suspended, int LockLevel);
/// <summary>Types d'événements du journal des comptes (actions des joueurs et du système).</summary>
public static class PlayerEvents
{
public const string Register = "account.register";
public const string Login = "account.login";
public const string LoginFailed = "account.login_failed";
public const string Locked = "account.locked";
public const string Suspended = "account.suspended";
public const string Unlocked = "account.unlocked";
public const string Disabled = "account.disabled";
public const string Enabled = "account.enabled";
public const string Logout = "account.logout";
public const string LogoutAll = "account.logout_all";
public const string PasswordChange = "account.password_change";
public const string PasswordChangeForced = "account.password_change_required";
public const string Refresh = "session.refresh";
public const string RefreshReuse = "session.reuse";
public const string DeviceRemoved = "device.removed";
public const string Export = "account.export";
public const string Delete = "account.delete";
public const string Inactive = "account.inactive";
public const string Reactivated = "account.reactivated";
public const string OtpChallenge = "account.otp_challenge";
public const string OtpFailed = "account.otp_failed";
public const string OtpEnabled = "account.otp_enabled";
public const string OtpDisabled = "account.otp_disabled";
public const string OtpReset = "account.otp_reset";
public const string RecoveryCodes = "account.recovery_codes";
public const string RecoveryCodeUsed = "account.recovery_code_used";
// Étape 8d : actions de l'équipe (menu d'administration) et mot de passe temporaire.
public const string PasswordResetRequired = "account.password_reset_required";
public const string PasswordResetCancelled = "account.password_reset_cancelled";
public const string TemporaryPassword = "account.temporary_password";
public const string TemporaryPasswordUsed = "account.temporary_password_used";
public const string DevicesRemovedByTeam = "device.removed_by_team";
// Étape 8e : connexion ou reconnexion refusée par un blocage (appareil ou adresse IP).
public const string Blocked = "account.blocked";
}
@@ -0,0 +1,205 @@
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
namespace GameLauncher.Server.Storage.Players;
/// <summary>
/// Clés des comptes joueurs, toutes dérivées (HKDF-SHA256) d'une clé maîtresse de 256 bits gardée HORS du dossier des
/// données (credential systemd chiffré, déchiffré par systemd au démarrage dans un dossier en mémoire visible du seul
/// service). Une copie de /var/lib/launcher-api ou de ses sauvegardes ne permet donc ni de lire les adresses e-mail, ni
/// de tester des mots de passe hors ligne (le « poivre » des empreintes en dérive aussi).
///
/// Une sous-clé par usage : chiffrement des adresses, index aveugle (recherche par adresse sans la stocker en clair),
/// poivre des mots de passe, et pour les étapes suivantes : secrets OTP, notes des administrateurs, messages.
/// </summary>
public sealed class PlayerSecrets
{
/// <summary>Version de la clé maîtresse, écrite devant chaque valeur chiffrée (rotation future).</summary>
public const int KeyVersion = 1;
private const int KeyLength = 32;
private const int NonceLength = 12;
private const int TagLength = 16;
private static readonly byte[] HkdfSalt = Encoding.ASCII.GetBytes("GameLauncher/Player/Keys/v1");
private readonly byte[] _emailKey;
private readonly byte[] _indexKey;
private readonly byte[] _pepper;
private readonly byte[] _fieldKey;
private readonly byte[] _recoveryKey;
private readonly byte[] _temporaryKey;
private readonly byte[] _hardwareKey;
public PlayerSecrets(ReadOnlySpan<byte> masterKey)
{
if (masterKey.Length != KeyLength) throw new CryptographicException("La clé maîtresse des comptes joueurs doit faire 32 octets.");
if (masterKey.IndexOfAnyExcept((byte)0) < 0) throw new CryptographicException("Clé maîtresse vide (que des zéros).");
_emailKey = Derive(masterKey, "email-encryption/v1");
_indexKey = Derive(masterKey, "email-index/v1");
_pepper = Derive(masterKey, "password-pepper/v1");
_fieldKey = Derive(masterKey, "field-encryption/v1");
_recoveryKey = Derive(masterKey, "recovery-codes/v1");
_temporaryKey = Derive(masterKey, "temporary-password/v1");
_hardwareKey = Derive(masterKey, "hardware/v1");
}
/// <summary>
/// Lit la clé maîtresse (Base64 de 32 octets). Sous Linux, le fichier ne doit être ni lisible ni modifiable par les
/// autres comptes, ni modifiable par le groupe (un credential systemd n'est lisible que par le service).
/// </summary>
public static PlayerSecrets Load(string path)
{
if (!File.Exists(path)) throw new InvalidOperationException($"Clé maîtresse des comptes joueurs introuvable : {path}");
if (!OperatingSystem.IsWindows())
{
var mode = File.GetUnixFileMode(path);
const UnixFileMode forbidden = UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute;
if ((mode & forbidden) != 0)
throw new InvalidOperationException(
$"Clé maîtresse des comptes joueurs accessible à d'autres comptes ({path}) : droits 0400 ou 0600 attendus.");
}
var text = File.ReadAllText(path).Trim();
byte[] key;
try
{
key = Convert.FromBase64String(text);
}
catch (FormatException)
{
throw new InvalidOperationException("Clé maîtresse des comptes joueurs illisible : 32 octets en Base64 attendus (commande players-key).");
}
try
{
return new PlayerSecrets(key);
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
}
/// <summary>Nouvelle clé maîtresse (commande « players-key »), en Base64.</summary>
public static string NewMasterKey() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(KeyLength));
/// <summary>Poivre des empreintes de mots de passe (« clé secrète » d'Argon2id).</summary>
public byte[] PasswordPepper => _pepper;
/// <summary>
/// Index aveugle d'une adresse NORMALISÉE : HMAC-SHA256 avec une clé secrète. Permet de retrouver un compte et
/// d'imposer l'unicité des adresses sans conserver l'adresse en clair ; sans la clé, impossible de tester une adresse.
/// </summary>
public string EmailIndex(string normalizedEmail) =>
Convert.ToHexString(HMACSHA256.HashData(_indexKey, Encoding.UTF8.GetBytes("GameLauncher/Player/Email/v1\n" + normalizedEmail)))
.ToLowerInvariant();
/// <summary>Chiffre l'adresse d'un compte (liée à ce compte : une valeur copiée sur un autre compte ne se déchiffre pas).</summary>
public string EncryptEmail(string accountId, string normalizedEmail) =>
Encrypt(_emailKey, Encoding.UTF8.GetBytes(normalizedEmail), Associated("email", accountId));
public string DecryptEmail(string accountId, string value) =>
Encoding.UTF8.GetString(Decrypt(_emailKey, value, Associated("email", accountId)));
/// <summary>Chiffre un champ libre d'un compte (motifs, notes…), lié au compte et au nom du champ.</summary>
public string EncryptField(string accountId, string field, string text) =>
Encrypt(_fieldKey, Encoding.UTF8.GetBytes(text), Associated(field, accountId));
public string DecryptField(string accountId, string field, string value) =>
Encoding.UTF8.GetString(Decrypt(_fieldKey, value, Associated(field, accountId)));
/// <summary>
/// Empreinte d'un code de secours NORMALISÉ (<see cref="Contracts.Accounts.PlayerOtp.NormalizeRecoveryCode"/>) :
/// HMAC-SHA256 avec une clé secrète, liée au compte. Les codes ne font que 40 bits : sans la clé, impossible de les
/// retrouver à partir d'une copie de la base.
/// </summary>
public string RecoveryCodeHash(string accountId, string normalizedCode) =>
Convert.ToHexString(HMACSHA256.HashData(_recoveryKey,
Encoding.UTF8.GetBytes($"GameLauncher/Player/RecoveryCode/v1\n{accountId}\n{normalizedCode}"))).ToLowerInvariant();
/// <summary>
/// Empreinte d'un mot de passe temporaire NORMALISÉ (<see cref="Contracts.Accounts.PlayerTemporaryPassword.Normalize"/>) :
/// HMAC-SHA256 avec une clé secrète, liée au compte. 80 bits aléatoires valables une heure : une empreinte rapide suffit
/// (rien à deviner hors ligne sans la clé).
/// </summary>
public string TemporaryPasswordHash(string accountId, string normalized) =>
Convert.ToHexString(HMACSHA256.HashData(_temporaryKey,
Encoding.UTF8.GetBytes($"GameLauncher/Player/TemporaryPassword/v1\n{accountId}\n{normalized}"))).ToLowerInvariant();
/// <summary>
/// Empreinte d'un identifiant matériel (déjà empreinté par le launcher, <see cref="Contracts.Accounts.PlayerHardware.Format"/>) :
/// HMAC-SHA256 avec une clé secrète. Seule cette valeur est gardée et comparée aux blocages : une copie de la base ne permet
/// pas de relier un PC à d'autres services.
/// </summary>
public string HardwareHash(string kind, string clientHash) =>
Convert.ToHexString(HMACSHA256.HashData(_hardwareKey, Encoding.UTF8.GetBytes($"GameLauncher/Player/Hardware/v1\n{kind}\n{clientHash}")))
.ToLowerInvariant();
/// <summary>Note interne d'un blocage, chiffrée et liée au blocage.</summary>
public string EncryptBanNote(string banId, string note) => EncryptField("ban:" + banId, "ban-note", note);
public string? TryDecryptBanNote(string banId, string? value)
{
if (value is null) return null;
try
{
return DecryptField("ban:" + banId, "ban-note", value);
}
catch (CryptographicException)
{
return null;
}
}
/// <summary>Motif d'une désactivation (montré au joueur), chiffré et lié au compte.</summary>
public string EncryptStatusReason(string accountId, string reason) => EncryptField(accountId, "status-reason", reason);
/// <summary>Motif déchiffré, ou null s'il est absent ou illisible (autre clé maîtresse).</summary>
public string? TryDecryptStatusReason(string accountId, string? value)
{
if (value is null) return null;
try
{
return DecryptField(accountId, "status-reason", value);
}
catch (CryptographicException)
{
return null;
}
}
/// <summary>Secret de la double authentification (Base32), chiffré et lié au compte.</summary>
public string EncryptOtpSecret(string accountId, string base32) => EncryptField(accountId, "totp", base32);
public string DecryptOtpSecret(string accountId, string value) => DecryptField(accountId, "totp", value);
private static byte[] Associated(string field, string accountId) =>
Encoding.UTF8.GetBytes($"GameLauncher/Player/Field/v1\n{field}\n{accountId}");
private static byte[] Derive(ReadOnlySpan<byte> masterKey, string purpose) =>
HKDF.DeriveKey(HashAlgorithmName.SHA256, masterKey.ToArray(), KeyLength, HkdfSalt, Encoding.ASCII.GetBytes(purpose));
/// <summary>Format : « 1. » (version de la clé) + Base64Url(nonce ‖ chiffré ‖ étiquette), AES-256-GCM.</summary>
private static string Encrypt(byte[] key, byte[] plaintext, byte[] associated)
{
var buffer = new byte[NonceLength + plaintext.Length + TagLength];
var nonce = buffer.AsSpan(0, NonceLength);
RandomNumberGenerator.Fill(nonce);
using var aes = new AesGcm(key, TagLength);
aes.Encrypt(nonce, plaintext, buffer.AsSpan(NonceLength, plaintext.Length), buffer.AsSpan(NonceLength + plaintext.Length), associated);
return $"{KeyVersion}.{Base64Url.Encode(buffer)}";
}
private static byte[] Decrypt(byte[] key, string value, byte[] associated)
{
var dot = value.IndexOf('.');
if (dot <= 0 || value[..dot] != KeyVersion.ToString(System.Globalization.CultureInfo.InvariantCulture) ||
!Base64Url.TryDecode(value[(dot + 1)..], out var buffer) || buffer.Length < NonceLength + TagLength)
throw new CryptographicException("Valeur chiffrée illisible ou d'une autre version de clé.");
var length = buffer.Length - NonceLength - TagLength;
var plaintext = new byte[length];
using var aes = new AesGcm(key, TagLength);
aes.Decrypt(buffer.AsSpan(0, NonceLength), buffer.AsSpan(NonceLength, length), buffer.AsSpan(NonceLength + length), plaintext, associated);
return plaintext;
}
}
@@ -0,0 +1,923 @@
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 = 4;
private readonly object _lock = new();
private readonly SqliteConnection _db;
private readonly TimeProvider _time;
public PlayerStore(IOptions<LauncherApiOptions> options, TimeProvider time) : this(options.Value.DataDirectoryFullPath, time) { }
public PlayerStore(string dataDirectory, TimeProvider time)
{
_time = time;
SecureFiles.EnsureDirectory(dataDirectory);
var folder = Path.Combine(dataDirectory, FolderName);
SecureFiles.EnsureDirectory(folder);
DatabasePath = Path.Combine(folder, FileName);
// Fichier créé en 0600 AVANT que SQLite ne l'ouvre (les fichiers -wal et -shm reprennent ses droits).
if (!File.Exists(DatabasePath)) SecureFiles.TryCreateNew(DatabasePath, "");
_db = new SqliteConnection(new SqliteConnectionStringBuilder
{
DataSource = DatabasePath,
Mode = SqliteOpenMode.ReadWriteCreate,
Pooling = false
}.ToString());
_db.Open();
try
{
Execute(null, "PRAGMA busy_timeout = 5000;");
Scalar(null, "PRAGMA journal_mode = WAL;");
Execute(null, "PRAGMA foreign_keys = ON; PRAGMA secure_delete = ON; PRAGMA synchronous = FULL;");
Migrate();
}
catch
{
_db.Dispose();
throw;
}
}
public string DatabasePath { get; }
public void Dispose()
{
lock (_lock) _db.Dispose();
}
// ------------------------------------------------------------------ Schéma
private void Migrate()
{
var version = Convert.ToInt64(Scalar(null, "PRAGMA user_version;"), CultureInfo.InvariantCulture);
if (version > SchemaVersion)
throw new InvalidOperationException(
$"Base des comptes joueurs en version {version}, plus récente que ce serveur ({SchemaVersion}) : mettez le serveur à jour.");
if (version == SchemaVersion) return;
using var tx = _db.BeginTransaction();
if (version < 1)
{
Execute(tx, """
CREATE TABLE accounts (
id TEXT PRIMARY KEY,
reference TEXT NOT NULL UNIQUE,
email_index TEXT NOT NULL UNIQUE,
email_enc TEXT NOT NULL,
email_verified INTEGER NOT NULL DEFAULT 0,
password_hash TEXT NOT NULL,
password_changed_at INTEGER NOT NULL,
must_change_password INTEGER NOT NULL DEFAULT 0,
totp_secret_enc TEXT,
totp_last_step INTEGER,
status TEXT NOT NULL,
disabled_until INTEGER,
status_reason_enc TEXT,
status_changed_at INTEGER,
status_changed_by TEXT,
created_at INTEGER NOT NULL,
last_login_at INTEGER,
terms_version TEXT NOT NULL,
failed_count INTEGER NOT NULL DEFAULT 0,
lock_level INTEGER NOT NULL DEFAULT 0,
locked_until INTEGER,
trusted_failed_count INTEGER NOT NULL DEFAULT 0,
trusted_locked_until INTEGER
);
CREATE INDEX ix_accounts_created ON accounts(created_at);
CREATE INDEX ix_accounts_status ON accounts(status);
CREATE TABLE devices (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
key_id TEXT NOT NULL,
public_key TEXT NOT NULL,
label TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL,
refresh_hash TEXT NOT NULL,
previous_refresh_hash TEXT,
refresh_expires_at INTEGER NOT NULL,
refresh_absolute_expires_at INTEGER NOT NULL,
UNIQUE (account_id, key_id)
);
CREATE INDEX ix_devices_account ON devices(account_id);
CREATE TABLE account_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT REFERENCES accounts(id) ON DELETE CASCADE,
at INTEGER NOT NULL,
type TEXT NOT NULL,
result TEXT NOT NULL,
ip TEXT,
device_key TEXT,
details TEXT
);
CREATE INDEX ix_events_account ON account_events(account_id, at);
CREATE INDEX ix_events_at ON account_events(at);
""");
}
if (version < 2)
{
// Étape 8c : double authentification (secret en cours de configuration) et codes de secours (empreintes HMAC).
Execute(tx, """
ALTER TABLE accounts ADD COLUMN totp_pending_enc TEXT;
ALTER TABLE accounts ADD COLUMN totp_pending_at INTEGER;
CREATE TABLE recovery_codes (
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
used_at INTEGER,
PRIMARY KEY (account_id, code_hash)
);
""");
}
if (version < 3)
{
// Étape 8d : mot de passe temporaire créé par un administrateur (empreinte HMAC et échéance).
Execute(tx, """
ALTER TABLE accounts ADD COLUMN temp_password_hash TEXT;
ALTER TABLE accounts ADD COLUMN temp_password_expires INTEGER;
""");
}
if (version < 4)
{
// Étape 8e : identifiants matériels (empreintes du serveur) par appareil et par compte, blocages et leurs cibles.
Execute(tx, """
ALTER TABLE devices ADD COLUMN hardware TEXT;
CREATE TABLE account_hardware (
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
hash TEXT NOT NULL,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
PRIMARY KEY (account_id, hash)
);
CREATE INDEX ix_account_hardware_hash ON account_hardware(hash);
CREATE TABLE bans (
id TEXT PRIMARY KEY,
reference TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL,
note_enc TEXT,
created_at INTEGER NOT NULL,
created_by TEXT NOT NULL,
expires_at INTEGER,
account_reference TEXT,
hits INTEGER NOT NULL DEFAULT 0,
last_hit_at INTEGER
);
CREATE TABLE ban_targets (
ban_id TEXT NOT NULL REFERENCES bans(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
value TEXT NOT NULL,
prefix INTEGER,
PRIMARY KEY (ban_id, kind, value)
);
CREATE INDEX ix_ban_targets_value ON ban_targets(kind, value);
""");
}
Execute(tx, $"PRAGMA user_version = {SchemaVersion};");
tx.Commit();
}
// ------------------------------------------------------------------ Comptes
private const string AccountColumns =
"id, reference, email_index, email_enc, email_verified, password_hash, password_changed_at, must_change_password, " +
"totp_secret_enc IS NOT NULL, status, disabled_until, status_changed_at, created_at, last_login_at, terms_version, " +
"failed_count, lock_level, locked_until, trusted_failed_count, trusted_locked_until, status_reason_enc, status_changed_by, " +
"temp_password_hash, temp_password_expires";
public PlayerAccountRecord? FindByEmailIndex(string emailIndex) => Locked(() =>
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE email_index = $v;", ReadAccount, ("$v", emailIndex)));
public PlayerAccountRecord? FindById(string accountId) => Locked(() =>
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE id = $v;", ReadAccount, ("$v", accountId)));
public PlayerAccountRecord? FindByReference(string reference) => Locked(() =>
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE reference = $v;", ReadAccount, ("$v", reference)));
public int CountAccounts() => Locked(() => Convert.ToInt32(Scalar(null, "SELECT COUNT(*) FROM accounts;"), CultureInfo.InvariantCulture));
public int CountCreatedSince(DateTimeOffset since) => Locked(() =>
Convert.ToInt32(Scalar(null, "SELECT COUNT(*) FROM accounts WHERE created_at >= $t;", ("$t", since.ToUnixTimeSeconds())),
CultureInfo.InvariantCulture));
/// <summary>
/// Crée le compte et son premier appareil, sous transaction. Faux si l'adresse (index) est déjà utilisée. La référence
/// est retirée au sort de nouveau en cas de collision (40 bits : rare).
/// </summary>
public bool TryCreate(PlayerAccountRecord account, PlayerDeviceRecord device, Func<string> newReference, out PlayerAccountRecord created)
{
created = account;
lock (_lock)
{
using var tx = _db.BeginTransaction();
if (Scalar(tx, "SELECT 1 FROM accounts WHERE email_index = $v;", ("$v", account.EmailIndex)) is not null) return false;
var reference = account.Reference;
for (var attempt = 0; Scalar(tx, "SELECT 1 FROM accounts WHERE reference = $v;", ("$v", reference)) is not null; attempt++)
{
if (attempt > 20) throw new InvalidOperationException("Impossible de tirer une référence de compte libre.");
reference = newReference();
}
created = account with { Reference = reference };
Execute(tx, """
INSERT INTO accounts (id, reference, email_index, email_enc, email_verified, password_hash, password_changed_at,
must_change_password, status, created_at, last_login_at, terms_version)
VALUES ($id, $ref, $idx, $enc, 0, $hash, $pwd, 0, $status, $created, $login, $terms);
""",
("$id", created.Id), ("$ref", created.Reference), ("$idx", created.EmailIndex), ("$enc", created.EmailEncrypted),
("$hash", created.PasswordHash), ("$pwd", created.PasswordChangedAt.ToUnixTimeSeconds()), ("$status", PlayerStatus.Active),
("$created", created.CreatedAt.ToUnixTimeSeconds()), ("$login", Db(created.LastLoginAt)), ("$terms", created.TermsVersion));
InsertDevice(tx, device);
tx.Commit();
return true;
}
}
/// <summary>Connexion réussie : compteurs d'échecs remis à zéro, date de connexion, empreinte éventuellement remplacée.</summary>
public void RecordSuccess(string accountId, DateTimeOffset now, string? newPasswordHash) => Locked(() =>
{
Execute(null, """
UPDATE accounts SET failed_count = 0, lock_level = 0, locked_until = NULL, trusted_failed_count = 0,
trusted_locked_until = NULL, last_login_at = $now,
password_hash = COALESCE($hash, password_hash)
WHERE id = $id;
""", ("$now", now.ToUnixTimeSeconds()), ("$hash", (object?)newPasswordHash ?? DBNull.Value), ("$id", accountId));
return true;
});
/// <summary>
/// Échec de connexion (mot de passe faux) sur un compte existant, sous transaction. Appareil inconnu : blocage tous les
/// <paramref name="failuresPerLock"/> échecs (durée doublée à chaque fois), suspension au blocage n°
/// <paramref name="suspendAfterLocks"/>. Appareil de confiance : blocage temporaire seulement.
/// </summary>
public PlayerFailureOutcome RecordFailure(string accountId, bool trusted, DateTimeOffset now, LauncherApiOptions.PlayersSettings settings)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
var account = QuerySingle(tx, $"SELECT {AccountColumns} FROM accounts WHERE id = $v;", ReadAccount, ("$v", accountId))
?? throw new InvalidOperationException("Compte disparu.");
PlayerFailureOutcome outcome;
if (trusted)
{
var count = account.TrustedFailedCount + 1;
DateTimeOffset? until = null;
if (count >= settings.TrustedFailuresPerLock)
{
until = now + TimeSpan.FromMinutes(settings.LockMinutes);
count = 0;
}
Execute(tx, "UPDATE accounts SET trusted_failed_count = $c, trusted_locked_until = COALESCE($u, trusted_locked_until) WHERE id = $id;",
("$c", count), ("$u", Db(until)), ("$id", accountId));
outcome = new PlayerFailureOutcome(until, false, account.LockLevel);
}
else
{
var count = account.FailedCount + 1;
var level = account.LockLevel;
DateTimeOffset? until = null;
var suspended = false;
if (count >= settings.FailuresPerLock)
{
count = 0;
level++;
if (level >= settings.SuspendAfterLocks && account.Status != PlayerStatus.Suspended) suspended = true;
// Le blocage temporaire s'applique AUSSI au moment de la suspension et après : vu de l'extérieur, un compte
// suspendu se comporte comme une adresse inconnue (PlayerGuard.CountUnknownFailure), la suspension
// n'est révélée qu'après un mot de passe correct.
until = now + LockDuration(settings, level);
}
Execute(tx, """
UPDATE accounts SET failed_count = $c, lock_level = $l, locked_until = COALESCE($u, locked_until),
status = CASE WHEN $s = 1 THEN 'suspended' ELSE status END,
status_changed_at = CASE WHEN $s = 1 THEN $now ELSE status_changed_at END,
status_changed_by = CASE WHEN $s = 1 THEN 'système (échecs de connexion)' ELSE status_changed_by END,
disabled_until = CASE WHEN $s = 1 THEN NULL ELSE disabled_until END
WHERE id = $id;
""", ("$c", count), ("$l", level), ("$u", Db(until)), ("$s", suspended ? 1 : 0), ("$now", now.ToUnixTimeSeconds()),
("$id", accountId));
outcome = new PlayerFailureOutcome(until, suspended, level);
}
tx.Commit();
return outcome;
}
}
/// <summary>
/// Durée du blocage n° <paramref name="level"/> (1, 2, 3…) : <c>LockMinutes</c> doublée à chaque blocage, plafonnée
/// au niveau précédant la suspension et à 24 h. Même règle pour les adresses inconnues (blocage miroir).
/// </summary>
public static TimeSpan LockDuration(LauncherApiOptions.PlayersSettings settings, int level)
{
var capped = Math.Clamp(level, 1, Math.Max(1, settings.SuspendAfterLocks - 1));
return TimeSpan.FromMinutes(Math.Min(24 * 60, settings.LockMinutes * Math.Pow(2, capped - 1)));
}
/// <summary>
/// Nouveau mot de passe : empreinte, date, « changement exigé » levé (ou posé). Un mot de passe temporaire en attente est
/// effacé (il ne sert qu'une fois, à choisir ce nouveau mot de passe).
/// </summary>
public void SetPassword(string accountId, string passwordHash, DateTimeOffset now, bool mustChange = false) => Locked(() =>
{
Execute(null, """
UPDATE accounts SET password_hash = $h, password_changed_at = $now, must_change_password = $m,
temp_password_hash = NULL, temp_password_expires = NULL
WHERE id = $id;
""", ("$h", passwordHash), ("$now", now.ToUnixTimeSeconds()), ("$m", mustChange ? 1 : 0), ("$id", accountId));
return true;
});
/// <summary>
/// Mot de passe temporaire (empreinte HMAC) valable jusqu'à <paramref name="expiresAt"/>, à la place d'un éventuel précédent ;
/// les blocages temporaires (trop d'essais) sont levés : l'identité du joueur a été vérifiée par l'équipe. L'état du compte
/// (suspension comprise) ne change pas.
/// </summary>
public bool SetTemporaryPassword(string accountId, string hash, DateTimeOffset expiresAt) => Locked(() =>
Execute(null, """
UPDATE accounts SET temp_password_hash = $h, temp_password_expires = $e, failed_count = 0, locked_until = NULL,
trusted_failed_count = 0, trusted_locked_until = NULL
WHERE id = $id;
""", ("$h", hash), ("$e", expiresAt.ToUnixTimeSeconds()), ("$id", accountId)) == 1);
/// <summary>Efface les mots de passe temporaires échus (conservation : rien d'inutile en base). Renvoie le nombre effacé.</summary>
public int ClearExpiredTemporaryPasswords(DateTimeOffset now) => Locked(() =>
Execute(null, "UPDATE accounts SET temp_password_hash = NULL, temp_password_expires = NULL WHERE temp_password_expires < $t;",
("$t", now.ToUnixTimeSeconds())));
/// <summary>Lève les blocages temporaires (trop d'essais) sans changer l'état du compte.</summary>
public bool ClearLocks(string accountId) => Locked(() =>
Execute(null, """
UPDATE accounts SET failed_count = 0, lock_level = 0, locked_until = NULL, trusted_failed_count = 0, trusted_locked_until = NULL
WHERE id = $id;
""", ("$id", accountId)) == 1);
/// <summary>Exige (ou non) un nouveau mot de passe à la prochaine connexion.</summary>
public bool SetMustChangePassword(string accountId, bool value) => Locked(() =>
Execute(null, "UPDATE accounts SET must_change_password = $m WHERE id = $id;", ("$m", value ? 1 : 0), ("$id", accountId)) == 1);
/// <summary>Change l'état du compte (désactivation, réactivation, levée d'une suspension) ; remet à zéro les blocages
/// quand le compte redevient actif.</summary>
public bool SetStatus(string accountId, string status, DateTimeOffset? until, string? reasonEncrypted, string changedBy, DateTimeOffset now) =>
Locked(() => Execute(null, """
UPDATE accounts SET status = $s, disabled_until = $u, status_reason_enc = $r, status_changed_at = $now, status_changed_by = $by,
failed_count = CASE WHEN $s = 'active' THEN 0 ELSE failed_count END,
lock_level = CASE WHEN $s = 'active' THEN 0 ELSE lock_level END,
locked_until = CASE WHEN $s = 'active' THEN NULL ELSE locked_until END,
trusted_failed_count = CASE WHEN $s = 'active' THEN 0 ELSE trusted_failed_count END,
trusted_locked_until = CASE WHEN $s = 'active' THEN NULL ELSE trusted_locked_until END
WHERE id = $id;
""", ("$s", status), ("$u", Db(until)), ("$r", (object?)reasonEncrypted ?? DBNull.Value), ("$now", now.ToUnixTimeSeconds()),
("$by", changedBy), ("$id", accountId)) == 1);
/// <summary>
/// Supprime DÉFINITIVEMENT le compte : ses appareils et son journal partent avec lui (clés étrangères en cascade,
/// lignes écrasées sur le disque : secure_delete). Faux si le compte n'existe plus.
/// </summary>
public bool DeleteAccount(string accountId) => Locked(() => Execute(null, "DELETE FROM accounts WHERE id = $id;", ("$id", accountId)) == 1);
/// <summary>
/// Comptes ACTIFS sans aucune activité (création, connexion, utilisation d'un appareil) depuis <paramref name="before"/>.
/// Les comptes désactivés ou suspendus par un administrateur ne sont pas concernés (leur sort relève de l'administration).
/// </summary>
public IReadOnlyList<PlayerAccountRecord> FindInactive(DateTimeOffset before, int limit = 1000) => Locked(() =>
Query(null, $"""
SELECT {AccountColumns} FROM accounts a
WHERE status = 'active'
AND MAX(created_at, COALESCE(last_login_at, 0),
COALESCE((SELECT MAX(last_used_at) FROM devices d WHERE d.account_id = a.id), 0)) < $t
ORDER BY created_at LIMIT $l;
""", ReadAccount, ("$t", before.ToUnixTimeSeconds()), ("$l", Math.Clamp(limit, 1, 10_000))));
/// <summary>Met le compte en sommeil (inactivité) et déconnecte ses appareils, si son état n'a pas changé entre-temps.</summary>
public bool MarkInactive(string accountId, DateTimeOffset now)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
var changed = Execute(tx, """
UPDATE accounts SET status = 'inactive', disabled_until = NULL, status_changed_at = $now,
status_changed_by = 'système (inactivité)'
WHERE id = $id AND status = 'active';
""", ("$now", now.ToUnixTimeSeconds()), ("$id", accountId)) == 1;
if (changed) Execute(tx, "DELETE FROM devices WHERE account_id = $a;", ("$a", accountId));
tx.Commit();
return changed;
}
}
// ------------------------------------------------------------------ Menu d'administration (étape 8d)
/// <summary>Colonnes d'un compte préfixées par l'alias « a » (requêtes avec sous-requêtes sur les appareils).</summary>
private static readonly string AliasedAccountColumns = string.Join(", ", AccountColumns.Split(", ").Select(c => "a." + c.Trim()));
/// <summary>Condition SQL d'un filtre de la liste (<see cref="Contracts.Accounts.PlayerAdminRules.Filters"/>).</summary>
private static string FilterSql(string? filter) => filter switch
{
null => "1 = 1",
PlayerStatus.Active or PlayerStatus.Disabled or PlayerStatus.Suspended or PlayerStatus.Inactive => "a.status = $f",
"locked" => "(a.locked_until > $now OR a.trusted_locked_until > $now)",
"password" => "a.must_change_password = 1",
"otp" => "a.totp_secret_enc IS NOT NULL",
_ => throw new ArgumentException("Filtre inconnu.", nameof(filter))
};
/// <summary>
/// Page de la liste, les comptes les plus récents d'abord : curseur « date de création:identifiant » du dernier compte de la
/// page précédente. Renvoie aussi le nombre total de comptes du filtre.
/// </summary>
public (IReadOnlyList<PlayerListRow> Rows, int Total) List(string? filter, (long CreatedAt, string Id)? after, int limit, DateTimeOffset now) =>
Locked(() =>
{
var where = FilterSql(filter);
var parameters = new List<(string, object?)> { ("$f", filter ?? ""), ("$now", now.ToUnixTimeSeconds()) };
var total = Convert.ToInt32(Scalar(null, $"SELECT COUNT(*) FROM accounts a WHERE {where};", parameters.ToArray()),
CultureInfo.InvariantCulture);
var page = where;
if (after is { } cursor)
{
page += " AND (a.created_at < $c OR (a.created_at = $c AND a.id < $cid))";
parameters.Add(("$c", cursor.CreatedAt));
parameters.Add(("$cid", cursor.Id));
}
parameters.Add(("$l", Math.Clamp(limit, 1, 200)));
var rows = Query(null, $"""
SELECT {AliasedAccountColumns},
(SELECT COUNT(*) FROM devices d WHERE d.account_id = a.id),
(SELECT MAX(last_used_at) FROM devices d WHERE d.account_id = a.id)
FROM accounts a WHERE {page}
ORDER BY a.created_at DESC, a.id DESC LIMIT $l;
""", r => new PlayerListRow(ReadAccount(r), (int)r.GetInt64(AccountColumnCount), NullableDate(r, AccountColumnCount + 1)),
parameters.ToArray());
return ((IReadOnlyList<PlayerListRow>)rows, total);
});
/// <summary>Un compte avec ses compteurs d'appareils (résultat d'une recherche).</summary>
public PlayerListRow? Row(string accountId) => Locked(() =>
QuerySingle(null, $"""
SELECT {AliasedAccountColumns},
(SELECT COUNT(*) FROM devices d WHERE d.account_id = a.id),
(SELECT MAX(last_used_at) FROM devices d WHERE d.account_id = a.id)
FROM accounts a WHERE a.id = $id;
""", r => new PlayerListRow(ReadAccount(r), (int)r.GetInt64(AccountColumnCount), NullableDate(r, AccountColumnCount + 1)),
("$id", accountId)));
/// <summary>Compteurs des comptes par état (page « Comptes joueurs »).</summary>
public PlayerCounts Counts(DateTimeOffset now) => Locked(() =>
QuerySingle(null, """
SELECT COUNT(*),
COALESCE(SUM(status = 'active'), 0), COALESCE(SUM(status = 'disabled'), 0),
COALESCE(SUM(status = 'suspended'), 0), COALESCE(SUM(status = 'inactive'), 0),
COALESCE(SUM(locked_until > $now OR trusted_locked_until > $now), 0),
COALESCE(SUM(must_change_password = 1), 0), COALESCE(SUM(totp_secret_enc IS NOT NULL), 0),
COALESCE(SUM(created_at >= $month), 0)
FROM accounts;
""", r => new PlayerCounts((int)r.GetInt64(0), (int)r.GetInt64(1), (int)r.GetInt64(2), (int)r.GetInt64(3), (int)r.GetInt64(4),
(int)r.GetInt64(5), (int)r.GetInt64(6), (int)r.GetInt64(7), (int)r.GetInt64(8)),
("$now", now.ToUnixTimeSeconds()), ("$month", (now - TimeSpan.FromDays(30)).ToUnixTimeSeconds()))!);
/// <summary>Comptes en sommeil depuis avant <paramref name="before"/> (à supprimer).</summary>
public IReadOnlyList<PlayerAccountRecord> FindDormantSince(DateTimeOffset before, int limit = 1000) => Locked(() =>
Query(null, $"SELECT {AccountColumns} FROM accounts WHERE status = 'inactive' AND status_changed_at < $t ORDER BY status_changed_at LIMIT $l;",
ReadAccount, ("$t", before.ToUnixTimeSeconds()), ("$l", Math.Clamp(limit, 1, 10_000))));
// ------------------------------------------------------------------ Double authentification (étape 8c)
public PlayerOtpState ReadOtp(string accountId) => Locked(() =>
QuerySingle(null, "SELECT totp_secret_enc, totp_last_step, totp_pending_enc, totp_pending_at FROM accounts WHERE id = $id;",
r => new PlayerOtpState(r.IsDBNull(0) ? null : r.GetString(0), r.IsDBNull(1) ? null : r.GetInt64(1),
r.IsDBNull(2) ? null : r.GetString(2), NullableDate(r, 3)),
("$id", accountId)) ?? new PlayerOtpState(null, null, null, null));
/// <summary>Secret en cours de configuration (null : configuration abandonnée).</summary>
public void SetOtpPending(string accountId, string? pendingEncrypted, DateTimeOffset now) => Locked(() =>
Execute(null, "UPDATE accounts SET totp_pending_enc = $p, totp_pending_at = $t WHERE id = $id;",
("$p", (object?)pendingEncrypted ?? DBNull.Value), ("$t", pendingEncrypted is null ? DBNull.Value : now.ToUnixTimeSeconds()),
("$id", accountId)));
/// <summary>
/// Active la double authentification (sous transaction) : secret confirmé, pas de temps du code de confirmation
/// (déjà utilisé), configuration terminée, codes de secours remplacés.
/// </summary>
public void EnableOtp(string accountId, string secretEncrypted, long step, IEnumerable<string> recoveryHashes, DateTimeOffset now)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
Execute(tx, """
UPDATE accounts SET totp_secret_enc = $s, totp_last_step = $step, totp_pending_enc = NULL, totp_pending_at = NULL
WHERE id = $id;
""", ("$s", secretEncrypted), ("$step", step), ("$id", accountId));
InsertRecoveryCodes(tx, accountId, recoveryHashes, now);
tx.Commit();
}
}
/// <summary>Désactive la double authentification : secret, configuration en cours et codes de secours effacés.</summary>
public bool DisableOtp(string accountId)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
var changed = Execute(tx, """
UPDATE accounts SET totp_secret_enc = NULL, totp_last_step = NULL, totp_pending_enc = NULL, totp_pending_at = NULL
WHERE id = $id;
""", ("$id", accountId)) == 1;
Execute(tx, "DELETE FROM recovery_codes WHERE account_id = $a;", ("$a", accountId));
tx.Commit();
return changed;
}
}
/// <summary>
/// Retient le pas de temps d'un code accepté, SEULEMENT s'il est postérieur au dernier accepté : un code (même intercepté)
/// ne sert qu'une fois, y compris en cas de requêtes simultanées.
/// </summary>
public bool TryUseOtpStep(string accountId, long step) => Locked(() =>
Execute(null, """
UPDATE accounts SET totp_last_step = $s
WHERE id = $id AND totp_secret_enc IS NOT NULL AND (totp_last_step IS NULL OR totp_last_step < $s);
""", ("$s", step), ("$id", accountId)) == 1);
/// <summary>Remplace tous les codes de secours du compte (les anciens ne valent plus rien).</summary>
public void ReplaceRecoveryCodes(string accountId, IEnumerable<string> recoveryHashes, DateTimeOffset now)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
InsertRecoveryCodes(tx, accountId, recoveryHashes, now);
tx.Commit();
}
}
/// <summary>Utilise un code de secours (empreinte) : vrai s'il existait et n'avait jamais servi.</summary>
public bool TryUseRecoveryCode(string accountId, string codeHash, DateTimeOffset now) => Locked(() =>
Execute(null, "UPDATE recovery_codes SET used_at = $now WHERE account_id = $a AND code_hash = $h AND used_at IS NULL;",
("$now", now.ToUnixTimeSeconds()), ("$a", accountId), ("$h", codeHash)) == 1);
public int CountRecoveryCodes(string accountId) => Locked(() =>
Convert.ToInt32(Scalar(null, "SELECT COUNT(*) FROM recovery_codes WHERE account_id = $a AND used_at IS NULL;", ("$a", accountId)),
CultureInfo.InvariantCulture));
private void InsertRecoveryCodes(SqliteTransaction tx, string accountId, IEnumerable<string> hashes, DateTimeOffset now)
{
Execute(tx, "DELETE FROM recovery_codes WHERE account_id = $a;", ("$a", accountId));
foreach (var hash in hashes.Distinct(StringComparer.Ordinal))
Execute(tx, "INSERT INTO recovery_codes (account_id, code_hash, created_at, used_at) VALUES ($a, $h, $t, NULL);",
("$a", accountId), ("$h", hash), ("$t", now.ToUnixTimeSeconds()));
}
/// <summary>Nouvelle empreinte du mot de passe (paramètres de hachage relevés), sans changer la date du mot de passe.</summary>
public void RehashPassword(string accountId, string passwordHash) => Locked(() =>
Execute(null, "UPDATE accounts SET password_hash = $h WHERE id = $id;", ("$h", passwordHash), ("$id", accountId)));
// ------------------------------------------------------------------ Appareils
private const string DeviceColumns =
"id, account_id, key_id, public_key, label, created_at, last_used_at, refresh_hash, previous_refresh_hash, " +
"refresh_expires_at, refresh_absolute_expires_at, hardware";
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, hardware)
VALUES ($id, $a, $k, $pk, $label, $created, $used, $rh, NULL, $exp, $abs, $hw);
""", ("$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()),
("$hw", (object?)device.Hardware ?? DBNull.Value));
// ------------------------------------------------------------------ Identifiants matériels et blocages (étape 8e)
/// <summary>Identifiants matériels vus sur le compte (ajoutés, ou date de dernière vue mise à jour).</summary>
public void RecordHardware(string accountId, IEnumerable<(string Kind, string Hash)> hardware, DateTimeOffset now)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
foreach (var (kind, hash) in hardware.Distinct())
Execute(tx, """
INSERT INTO account_hardware (account_id, kind, hash, first_seen, last_seen) VALUES ($a, $k, $h, $t, $t)
ON CONFLICT (account_id, hash) DO UPDATE SET last_seen = $t;
""", ("$a", accountId), ("$k", kind), ("$h", hash), ("$t", now.ToUnixTimeSeconds()));
tx.Commit();
}
}
public IReadOnlyList<PlayerHardwareRecord> Hardware(string accountId) => Locked(() =>
Query(null, "SELECT kind, hash, first_seen, last_seen FROM account_hardware WHERE account_id = $a ORDER BY last_seen DESC;",
r => new PlayerHardwareRecord(r.GetString(0), r.GetString(1), Date(r, 2), Date(r, 3)), ("$a", accountId)));
/// <summary>Dernière adresse IP connue du compte (journal : les adresses de plus de 6 mois sont effacées), et sa date.</summary>
public (string Ip, DateTimeOffset At)? LastIp(string accountId) => Locked<(string, DateTimeOffset)?>(() =>
QuerySingle(null, """
SELECT ip, at FROM account_events WHERE account_id = $a AND ip IS NOT NULL AND result = 'ok' ORDER BY id DESC LIMIT 1;
""", r => new Tuple<string, DateTimeOffset>(r.GetString(0), Date(r, 1)), ("$a", accountId)) is { } row ? (row.Item1, row.Item2) : null);
/// <summary>Conservation : identifiants matériels plus vus depuis <paramref name="before"/> effacés. Renvoie le nombre effacé.</summary>
public int PurgeHardware(DateTimeOffset before) => Locked(() =>
Execute(null, "DELETE FROM account_hardware WHERE last_seen < $t;", ("$t", before.ToUnixTimeSeconds())));
public void AddBan(PlayerBanRecord ban)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
Execute(tx, """
INSERT INTO bans (id, reference, scope, note_enc, created_at, created_by, expires_at, account_reference, hits, last_hit_at)
VALUES ($id, $ref, $s, $n, $c, $by, $e, $acc, 0, NULL);
""", ("$id", ban.Id), ("$ref", ban.Reference), ("$s", ban.Scope), ("$n", (object?)ban.NoteEncrypted ?? DBNull.Value),
("$c", ban.CreatedAt.ToUnixTimeSeconds()), ("$by", ban.CreatedBy), ("$e", Db(ban.ExpiresAt)),
("$acc", (object?)ban.AccountReference ?? DBNull.Value));
foreach (var target in ban.Targets.Distinct())
Execute(tx, "INSERT INTO ban_targets (ban_id, kind, value, prefix) VALUES ($b, $k, $v, $p);",
("$b", ban.Id), ("$k", target.Kind), ("$v", target.Value), ("$p", (object?)target.Prefix ?? DBNull.Value));
tx.Commit();
}
}
/// <summary>Référence de blocage libre (tirée de nouveau en cas de collision, rare).</summary>
public string NewBanReference(Func<string> next) => Locked(() =>
{
for (var attempt = 0; attempt < 20; attempt++)
{
var reference = next();
if (Scalar(null, "SELECT 1 FROM bans WHERE reference = $r;", ("$r", reference)) is null) return reference;
}
throw new InvalidOperationException("Impossible de tirer une référence de blocage libre.");
});
/// <summary>Blocages en vigueur (non échus) avec leurs cibles, les plus récents d'abord.</summary>
public IReadOnlyList<PlayerBanRecord> ActiveBans(DateTimeOffset now) => Locked(() =>
{
var bans = Query(null, """
SELECT id, reference, scope, note_enc, created_at, created_by, expires_at, account_reference, hits, last_hit_at
FROM bans WHERE expires_at IS NULL OR expires_at > $now ORDER BY created_at DESC, id;
""", r => new PlayerBanRecord
{
Id = r.GetString(0),
Reference = r.GetString(1),
Scope = r.GetString(2),
NoteEncrypted = r.IsDBNull(3) ? null : r.GetString(3),
CreatedAt = Date(r, 4),
CreatedBy = r.GetString(5),
ExpiresAt = NullableDate(r, 6),
AccountReference = r.IsDBNull(7) ? null : r.GetString(7),
Hits = (int)r.GetInt64(8),
LastHitAt = NullableDate(r, 9)
}, ("$now", now.ToUnixTimeSeconds()));
var targets = Query(null, "SELECT ban_id, kind, value, prefix FROM ban_targets ORDER BY kind, value;",
r => (Ban: r.GetString(0), Target: new PlayerBanTargetRecord(r.GetString(1), r.GetString(2), r.IsDBNull(3) ? null : (int)r.GetInt64(3))))
.ToLookup(t => t.Ban, t => t.Target, StringComparer.Ordinal);
return (IReadOnlyList<PlayerBanRecord>)bans.Select(b => b with { Targets = targets[b.Id].ToList() }).ToList();
});
public PlayerBanRecord? FindBan(string reference, DateTimeOffset now) =>
ActiveBans(now).FirstOrDefault(b => string.Equals(b.Reference, reference, StringComparison.Ordinal));
/// <summary>Lève (supprime) un blocage et ses cibles. Faux s'il n'existe plus.</summary>
public bool DeleteBan(string banId) => Locked(() => Execute(null, "DELETE FROM bans WHERE id = $id;", ("$id", banId)) == 1);
/// <summary>Tentative refusée par un blocage : compteur et date.</summary>
public void CountBanHit(string banId, DateTimeOffset now) => Locked(() =>
Execute(null, "UPDATE bans SET hits = hits + 1, last_hit_at = $t WHERE id = $id;", ("$t", now.ToUnixTimeSeconds()), ("$id", banId)));
/// <summary>Conservation : blocages échus supprimés (cibles comprises). Renvoie le nombre supprimé.</summary>
public int PurgeExpiredBans(DateTimeOffset now) => Locked(() =>
Execute(null, "DELETE FROM bans WHERE expires_at IS NOT NULL AND expires_at <= $t;", ("$t", now.ToUnixTimeSeconds())));
// ------------------------------------------------------------------ Journal des comptes
/// <summary>
/// Ajoute une ligne au journal des comptes. Jamais d'adresse e-mail ni de mot de passe : identifiant du compte, type,
/// résultat, adresse IP (effacée au bout de quelques mois), clé d'appareil, détail court.
/// </summary>
public void AddEvent(string? accountId, string type, string result, string? ip, string? deviceKeyId = null, string? details = null)
{
var now = _time.GetUtcNow();
Locked(() => Execute(null, """
INSERT INTO account_events (account_id, at, type, result, ip, device_key, details) VALUES ($a, $at, $t, $r, $ip, $k, $d);
""", ("$a", (object?)accountId ?? DBNull.Value), ("$at", now.ToUnixTimeSeconds()), ("$t", type), ("$r", result),
("$ip", (object?)Trim(ip, 64) ?? DBNull.Value), ("$k", (object?)Trim(deviceKeyId, 32) ?? DBNull.Value),
("$d", (object?)Trim(details, 300) ?? DBNull.Value)));
}
/// <summary>Journal d'un compte, le plus récent d'abord.</summary>
public IReadOnlyList<PlayerEventRecord> Events(string accountId, int limit) => Locked(() =>
Query(null, "SELECT id, account_id, at, type, result, ip, device_key, details FROM account_events WHERE account_id = $a ORDER BY id DESC LIMIT $l;",
ReadEvent, ("$a", accountId), ("$l", Math.Clamp(limit, 1, 1000))));
/// <summary>Lignes du journal postérieures à <paramref name="afterId"/> (tous comptes, tests et outils).</summary>
public IReadOnlyList<PlayerEventRecord> EventsAfter(long afterId, int limit) => Locked(() =>
Query(null, "SELECT id, account_id, at, type, result, ip, device_key, details FROM account_events WHERE id > $i ORDER BY id LIMIT $l;",
ReadEvent, ("$i", afterId), ("$l", Math.Clamp(limit, 1, 10_000))));
/// <summary>
/// Conservation : lignes plus anciennes que <paramref name="months"/> supprimées, adresses IP plus anciennes que
/// <paramref name="ipMonths"/> effacées. Renvoie (supprimées, anonymisées).
/// </summary>
public (int Deleted, int Anonymized) PurgeEvents(DateTimeOffset now, int months, int ipMonths) => Locked(() =>
{
var deleted = Execute(null, "DELETE FROM account_events WHERE at < $t;", ("$t", now.AddMonths(-months).ToUnixTimeSeconds()));
var anonymized = Execute(null, "UPDATE account_events SET ip = NULL WHERE ip IS NOT NULL AND at < $t;",
("$t", now.AddMonths(-ipMonths).ToUnixTimeSeconds()));
return (deleted, anonymized);
});
private static string? Trim(string? value, int max)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var clean = new string(value.Select(c => char.IsControl(c) ? ' ' : c).ToArray()).Trim();
return clean.Length <= max ? clean : clean[..max];
}
// ------------------------------------------------------------------ Accès SQLite
private T Locked<T>(Func<T> work)
{
lock (_lock) return work();
}
private int Execute(SqliteTransaction? tx, string sql, params (string Name, object? Value)[] parameters)
{
using var command = Command(tx, sql, parameters);
return command.ExecuteNonQuery();
}
private object? Scalar(SqliteTransaction? tx, string sql, params (string Name, object? Value)[] parameters)
{
using var command = Command(tx, sql, parameters);
return command.ExecuteScalar();
}
private List<T> Query<T>(SqliteTransaction? tx, string sql, Func<SqliteDataReader, T> read, params (string Name, object? Value)[] parameters)
{
using var command = Command(tx, sql, parameters);
using var reader = command.ExecuteReader();
var rows = new List<T>();
while (reader.Read()) rows.Add(read(reader));
return rows;
}
private T? QuerySingle<T>(SqliteTransaction? tx, string sql, Func<SqliteDataReader, T> read, params (string Name, object? Value)[] parameters)
where T : class =>
Query(tx, sql, read, parameters).FirstOrDefault();
private SqliteCommand Command(SqliteTransaction? tx, string sql, (string Name, object? Value)[] parameters)
{
var command = _db.CreateCommand();
command.CommandText = sql;
command.Transaction = tx;
foreach (var (name, value) in parameters) command.Parameters.AddWithValue(name, value ?? DBNull.Value);
return command;
}
private static object Db(DateTimeOffset? value) => value is { } date ? date.ToUnixTimeSeconds() : DBNull.Value;
private static DateTimeOffset Date(SqliteDataReader r, int i) => DateTimeOffset.FromUnixTimeSeconds(r.GetInt64(i));
private static DateTimeOffset? NullableDate(SqliteDataReader r, int i) => r.IsDBNull(i) ? null : Date(r, i);
private static PlayerAccountRecord ReadAccount(SqliteDataReader r) => new()
{
Id = r.GetString(0),
Reference = r.GetString(1),
EmailIndex = r.GetString(2),
EmailEncrypted = r.GetString(3),
EmailVerified = r.GetInt64(4) != 0,
PasswordHash = r.GetString(5),
PasswordChangedAt = Date(r, 6),
MustChangePassword = r.GetInt64(7) != 0,
OtpEnabled = r.GetInt64(8) != 0,
Status = r.GetString(9),
DisabledUntil = NullableDate(r, 10),
StatusChangedAt = NullableDate(r, 11),
CreatedAt = Date(r, 12),
LastLoginAt = NullableDate(r, 13),
TermsVersion = r.GetString(14),
FailedCount = (int)r.GetInt64(15),
LockLevel = (int)r.GetInt64(16),
LockedUntil = NullableDate(r, 17),
TrustedFailedCount = (int)r.GetInt64(18),
TrustedLockedUntil = NullableDate(r, 19),
StatusReasonEncrypted = r.IsDBNull(20) ? null : r.GetString(20),
StatusChangedBy = r.IsDBNull(21) ? null : r.GetString(21),
TemporaryPasswordHash = r.IsDBNull(22) ? null : r.GetString(22),
TemporaryPasswordExpiresAt = NullableDate(r, 23)
};
/// <summary>Nombre de colonnes de <see cref="AccountColumns"/> (les colonnes suivantes d'une requête viennent après).</summary>
private const int AccountColumnCount = 24;
private static PlayerDeviceRecord ReadDevice(SqliteDataReader r) => new()
{
Id = r.GetString(0),
AccountId = r.GetString(1),
KeyId = r.GetString(2),
PublicKey = r.GetString(3),
Label = r.GetString(4),
CreatedAt = Date(r, 5),
LastUsedAt = Date(r, 6),
RefreshHash = r.GetString(7),
PreviousRefreshHash = r.IsDBNull(8) ? null : r.GetString(8),
RefreshExpiresAt = Date(r, 9),
RefreshAbsoluteExpiresAt = Date(r, 10),
Hardware = r.IsDBNull(11) ? null : r.GetString(11)
};
private static PlayerEventRecord ReadEvent(SqliteDataReader r) => new(
r.GetInt64(0), r.IsDBNull(1) ? null : r.GetString(1), Date(r, 2), r.GetString(3), r.GetString(4),
r.IsDBNull(5) ? null : r.GetString(5), r.IsDBNull(6) ? null : r.GetString(6), r.IsDBNull(7) ? null : r.GetString(7));
}
@@ -72,6 +72,28 @@
"Audit": {
"RetentionMonths": 12
},
// Comptes joueurs (phase 8) : désactivés par défaut. NE PAS ACTIVER EN PRODUCTION avant la fin de l'étape 8g
// (docs/PLAYER-ACCOUNTS-ARCHITECTURE.md). Activés, ils exigent la clé maîtresse : credential systemd chiffré
// « players-master-key » (server/deploy/LISEZMOI.md, étape 3 undecies), jamais dans le dossier des données.
"Players": {
"Enabled": false,
"RegistrationEnabled": true,
"TermsVersion": "1",
// Nom affiché dans les applications d'authentification (double authentification des joueurs).
"OtpIssuer": "LanisysGames",
"HashMemoryKib": 19456,
"HashIterations": 2,
"FailuresPerLock": 5,
"LockMinutes": 15,
"SuspendAfterLocks": 4,
"RefreshIdleDays": 90,
"RefreshMaxDays": 365,
"EventsRetentionMonths": 12,
"EventsIpRetentionMonths": 6,
// Comptes inactifs : mis en sommeil après 24 mois sans activité, supprimés 12 mois plus tard (0 : désactivé).
"InactiveDisableMonths": 24,
"InactiveDeleteMonths": 12
},
"Limits": {
"RequestsPerMinute": 300,
// Morceaux de 4 Mo envoyés pendant une publication (à la place de RequestsPerMinute) : 1500 ≈ 100 Mo/s.
+122 -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,115 @@ 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.
Double authentification (étape 8c) : facultative, activée par chaque joueur dans « Mon compte ». Nom affiché dans les
applications d'authentification : `Players:OtpIssuer` (« LanisysGames » par défaut). Un joueur qui a perdu son téléphone
**et** ses codes de secours : après avoir vérifié son identité (par exemple depuis l'adresse e-mail du compte),
`sudo -u deploy /opt/launcher-api/GameLauncher.Server players-otp-reset --ref PL-XXXX-XXXX` (inscrit au journal d'audit).
La mise à jour du serveur fait passer la base des comptes au schéma 2 au premier démarrage (ajout de colonnes et d'une
table, sans perte) : sauvegarde `players.db` avant, comme pour toute mise à jour.
Page « Comptes joueurs » du menu d'administration (étape 8d) : recherche (référence ou adresse e-mail exacte), fiche,
désactivation avec motif montré au joueur, levée d'une suspension, mot de passe temporaire (valable 1 h, à transmettre au
joueur par message privé), réinitialisation de la double authentification, suppression. Permissions `players.read`,
`players.manage` et `players.delete` : le groupe Propriétaire les a d'office ; sur un serveur déjà installé, les autres
groupes ne les reçoivent pas d'eux-mêmes (page « Groupes et permissions » : par exemple lecture + gestion pour
« Administrateur », jamais la suppression sans raison). La mise à jour fait passer la base au **schéma 3** (deux colonnes
ajoutées, sans perte) : sauvegarde `players.db` avant. Les commandes `players-*` ci-dessous restent le secours.
Blocages (étape 8e) : bouton « Bloquer… » de la fiche d'un compte (identifiants matériels de ses PC et sa dernière
adresse IP) et page « Blocages » (adresse ou plage IP seule, levée). Permission `players.ban` : le groupe Propriétaire l'a
d'office ; sur un serveur déjà installé, l'ajouter aux autres groupes depuis « Groupes et permissions » si besoin. La mise à
jour fait passer la base au **schéma 4** (une colonne et trois tables ajoutées, sans perte) : sauvegarde `players.db` avant.
Un blocage d'adresse IP dure un an au plus ; l'adresse vue par le serveur est celle transmise par nginx
(`X-Forwarded-For`) : vérifier, une fois en production, que la fiche d'un compte affiche bien l'adresse publique du joueur
et non `127.0.0.1`.
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 +651,10 @@ pas, le script remet la précédente en place.
| Un administrateur a perdu tous ses PC | Il crée une nouvelle clé et t'envoie son `.pub`, puis `sudo -u deploy /opt/launcher-api/GameLauncher.Server add-key --admin <identifiant> --public-key /tmp/paul.pub` (vérifie l'empreinte avec lui) |
| Toutes tes clés perdues | Nouvelle clé (étape 4), puis `add-owner` (étape 5) ; révoque ensuite les anciennes clés |
| Plus personne ne peut gérer les groupes | Tout propriétaire le peut toujours (groupe système, toutes les permissions) ; sinon `add-owner` (étape 5) |
| Compte joueur : voir son état (sans son adresse) | `sudo -u deploy /opt/launcher-api/GameLauncher.Server players-show --ref PL-XXXX-XXXX` |
| Compte joueur suspendu (trop d'essais) ou bloqué | `sudo -u deploy /opt/launcher-api/GameLauncher.Server players-unlock --ref PL-XXXX-XXXX` |
| Compte joueur à désactiver / réactiver | `… players-disable --ref PL-XXXX-XXXX [--until AAAA-MM-JJ]` / `… players-enable --ref PL-XXXX-XXXX` |
| Joueur sans téléphone ni codes de secours (identité vérifiée) | `… players-otp-reset --ref PL-XXXX-XXXX` |
Toujours `sudo -u deploy …` : lancé en root, l'outil refuse (les fichiers recréés appartiendraient à
root et le service ne pourrait plus les lire). Il affiche le dossier de données utilisé ; il lit les
@@ -584,11 +700,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;
}
+118
View File
@@ -1,12 +1,14 @@
using System.Security.Cryptography;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Files;
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 +317,122 @@ 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);
}
// ------------------------------------------------------------------ Comptes joueurs (étape 8d)
/// <summary>Compteurs et réglages du service des comptes (Enabled faux : service désactivé sur le serveur).</summary>
public Task<PlayersOverview> GetPlayersOverviewAsync(CancellationToken ct = default) =>
GetAsync<PlayersOverview>(ApiRoutes.PlayersOverview, ct);
/// <param name="filter">Une valeur de <see cref="PlayerAdminRules.Filters"/> (null : tous les comptes).</param>
public Task<PlayerAdminPage> GetPlayersPageAsync(string? filter = null, string? cursor = null, int limit = 100, CancellationToken ct = default) =>
GetAsync<PlayerAdminPage>(ApiRoutes.PlayersPage(PlayerAdminRules.IsValidFilter(filter) ? filter : throw new ArgumentException("Filtre inconnu.", nameof(filter)),
cursor, limit), ct);
/// <summary>
/// Recherche par référence ou adresse e-mail EXACTE : dans le corps d'une requête POST, jamais dans l'adresse (journaux du
/// serveur web). Inscrite au journal d'audit (sans l'adresse).
/// </summary>
public Task<PlayerSearchResult> SearchPlayersAsync(string query, CancellationToken ct = default) =>
SendCoreAsync<PlayerSearchResult>(HttpMethod.Post, ApiRoutes.PlayersSearch,
LauncherApiClient.Serialize(new PlayerSearchRequest { Query = query }), signed: false, ct);
public Task<PlayerAdminDetail> GetPlayerAsync(string reference, CancellationToken ct = default) =>
GetAsync<PlayerAdminDetail>(ApiRoutes.Player(CheckReference(reference)), ct);
/// <param name="until">Fin de la désactivation (null : indéfiniment).</param>
/// <param name="reason">Motif montré au joueur (null : aucun), contrôlé par <see cref="PlayerAdminRules.CleanReason"/>.</param>
public Task<PlayerAdminDetail> DisablePlayerAsync(string reference, DateTimeOffset? until, string? reason, CancellationToken ct = default) =>
PlayerActionAsync<PlayerAdminDetail>(reference, "disable", new PlayerDisableRequest { Until = until, Reason = reason }, ct);
public Task<PlayerAdminDetail> EnablePlayerAsync(string reference, CancellationToken ct = default) =>
PlayerActionAsync<PlayerAdminDetail>(reference, "enable", new { }, ct);
/// <summary>Lève une suspension et les blocages temporaires (trop d'essais).</summary>
public Task<PlayerAdminDetail> UnlockPlayerAsync(string reference, CancellationToken ct = default) =>
PlayerActionAsync<PlayerAdminDetail>(reference, "unlock", new { }, ct);
/// <summary>Exige (ou n'exige plus) un nouveau mot de passe à la prochaine connexion.</summary>
public Task<PlayerAdminDetail> SetPlayerPasswordRequirementAsync(string reference, bool required, CancellationToken ct = default) =>
PlayerActionAsync<PlayerAdminDetail>(reference, "password-requirement", new PlayerPasswordRequirementRequest { Required = required }, ct);
/// <summary>Mot de passe temporaire (valable une heure, une seule fois) : renvoyé UNE fois, à transmettre au joueur.</summary>
public Task<PlayerTemporaryPasswordResponse> CreatePlayerTemporaryPasswordAsync(string reference, CancellationToken ct = default) =>
PlayerActionAsync<PlayerTemporaryPasswordResponse>(reference, "temporary-password", new { }, ct);
public Task<PlayerAdminDetail> ResetPlayerOtpAsync(string reference, CancellationToken ct = default) =>
PlayerActionAsync<PlayerAdminDetail>(reference, "otp-reset", new { }, ct);
/// <param name="deviceId">Appareil à déconnecter (null : tous).</param>
public Task<PlayerAdminDetail> LogoutPlayerDevicesAsync(string reference, string? deviceId, CancellationToken ct = default) =>
PlayerActionAsync<PlayerAdminDetail>(reference, "logout",
new PlayerDevicesLogoutRequest { DeviceId = deviceId is null || PlayerIds.IsValidId(deviceId) ? deviceId : throw new ArgumentException("Appareil invalide.", nameof(deviceId)) },
ct);
/// <summary>Supprime DÉFINITIVEMENT le compte et ses données (players.delete).</summary>
public Task DeletePlayerAsync(string reference, CancellationToken ct = default) =>
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.Player(CheckReference(reference)), null, ct);
// ------------------------------------------------------------------ Blocages (étape 8e, players.ban)
/// <summary>
/// Bloque depuis la fiche d'un joueur ses identifiants matériels et/ou sa dernière adresse IP (un an au plus pour une IP) ;
/// renvoie la fiche à jour.
/// </summary>
public Task<PlayerAdminDetail> BanPlayerAsync(string reference, string scope, bool hardware, bool ip, DateTimeOffset? until, string? note,
CancellationToken ct = default) =>
PlayerActionAsync<PlayerAdminDetail>(reference, "ban",
new PlayerBanRequest { Scope = scope, Hardware = hardware, Ip = ip, Until = until, Note = note }, ct);
/// <summary>Blocages en vigueur (adresses IP visibles : consultation inscrite au journal).</summary>
public Task<PlayerBansPage> GetBansAsync(CancellationToken ct = default) => GetAsync<PlayerBansPage>(ApiRoutes.Bans, ct);
/// <summary>Bloque une adresse IP ou une plage seule (un an au plus).</summary>
public Task<PlayerBan> BanIpAsync(string ip, string scope, DateTimeOffset? until, string? note, CancellationToken ct = default) =>
SendSignedAsync<PlayerBan>(HttpMethod.Post, ApiRoutes.Bans, new PlayerIpBanRequest { Ip = ip, Scope = scope, Until = until, Note = note }, ct);
/// <summary>Lève un blocage.</summary>
public Task UnbanAsync(string reference, CancellationToken ct = default) =>
SendSignedAsync<NoContent>(HttpMethod.Delete,
ApiRoutes.Ban(PlayerIds.NormalizeBanReference(reference) ?? throw new ArgumentException("Référence de blocage invalide (BN-XXXX-XXXX).", nameof(reference))),
null, ct);
private Task<T> PlayerActionAsync<T>(string reference, string action, object body, CancellationToken ct) =>
SendSignedAsync<T>(HttpMethod.Post, ApiRoutes.PlayerAction(CheckReference(reference), action), body, ct);
private static string CheckReference(string reference) =>
PlayerIds.NormalizeReference(reference) ?? throw new ArgumentException("Référence de compte invalide (PL-XXXX-XXXX).", nameof(reference));
/// <summary>Contenu d'une capture d'écran (à revérifier avec FeedbackImages.Sanitize avant de l'afficher).</summary>
public Task<byte[]> GetFeedbackAttachmentAsync(string gameId, string id, int index, CancellationToken ct = default) =>
SendCoreAsync<byte[]>(HttpMethod.Get, ApiRoutes.GameFeedbackAttachment(CheckGameId(gameId), CheckFeedbackId(id), index), null,
@@ -304,7 +304,7 @@ public sealed class LauncherApiClient : IDisposable
}
/// <summary>Le serveur doit annoncer la même origine que celle configurée (sinon les signatures seraient refusées).</summary>
private void EnsureSameOrigin(string serverOrigin)
internal void EnsureSameOrigin(string serverOrigin)
{
if (!string.Equals(serverOrigin, Origin, StringComparison.OrdinalIgnoreCase))
throw new ApiException(0, "origin_mismatch",
@@ -0,0 +1,269 @@
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;
/// <param name="hardware">Empreintes des identifiants matériels du PC (<see cref="PlayerHardware.Format"/>) ; null : aucune.</param>
public Task<PlayerLoginResponse> RegisterAsync(string email, string password, string termsVersion, IPlayerDeviceKey key,
string deviceLabel, CancellationToken ct = default, IReadOnlyList<string>? hardware = null) =>
SendWithDeviceAsync<PlayerLoginResponse>(PlayerChallengePurposes.Register, ApiRoutes.AccountRegister, key, deviceLabel, hardware,
(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, IReadOnlyList<string>? hardware = null) =>
SendWithDeviceAsync<PlayerLoginResponse>(PlayerChallengePurposes.Login, ApiRoutes.AccountLogin, key, deviceLabel, hardware,
(challenge, solution, proof) => new PlayerLoginRequest
{
Email = email,
Password = password,
ChallengeId = challenge.ChallengeId,
Solution = solution,
Device = proof
}, ct);
/// <summary>Renouvellement de la session mémorisée : un nouveau jeton de renouvellement remplace celui présenté.</summary>
public async Task<PlayerSession> RefreshAsync(string refreshToken, IPlayerDeviceKey key, CancellationToken ct = default)
{
if (!PlayerTokens.TryParseRefresh(refreshToken, out _, out _))
throw new ArgumentException("Jeton de renouvellement mal formé.", nameof(refreshToken));
var (challenge, _) = await ChallengeAsync(PlayerChallengePurposes.Refresh, ct).ConfigureAwait(false);
var signature = await SignAsync(key, PlayerChallengePurposes.Refresh, challenge, ct).ConfigureAwait(false);
return Check(await _client.SendAsync<PlayerSession>(HttpMethod.Post, ApiRoutes.AccountRefresh,
LauncherApiClient.Serialize(new PlayerRefreshRequest
{
RefreshToken = refreshToken,
ChallengeId = challenge.ChallengeId,
Signature = signature
}), null, ct).ConfigureAwait(false));
}
public Task<PlayerMeResponse> MeAsync(string accessToken, CancellationToken ct = default) =>
_client.SendAsync<PlayerMeResponse>(HttpMethod.Get, ApiRoutes.AccountMe, null, Bearer(accessToken), ct);
public Task<PlayerActivityResponse> ActivityAsync(string accessToken, CancellationToken ct = default) =>
_client.SendAsync<PlayerActivityResponse>(HttpMethod.Get, ApiRoutes.AccountActivity, null, Bearer(accessToken), ct);
/// <param name="token">Jeton d'accès, ou jeton limité « changement de mot de passe exigé ».</param>
public async Task<PlayerChangePasswordResponse> ChangePasswordAsync(string token, string currentPassword, string newPassword,
CancellationToken ct = default)
{
var response = await _client.SendAsync<PlayerChangePasswordResponse>(HttpMethod.Post, ApiRoutes.AccountPassword,
LauncherApiClient.Serialize(new PlayerChangePasswordRequest { CurrentPassword = currentPassword, NewPassword = newPassword }),
Bearer(token), ct).ConfigureAwait(false);
if (response.Session is { } session) Check(session);
return response;
}
public async Task LogoutAsync(string accessToken, bool allDevices, CancellationToken ct = default) =>
await _client.SendAsync<NoContent>(HttpMethod.Post, ApiRoutes.AccountLogout,
LauncherApiClient.Serialize(new PlayerLogoutRequest { AllDevices = allDevices }), Bearer(accessToken), ct).ConfigureAwait(false);
public async Task RemoveDeviceAsync(string accessToken, string deviceId, CancellationToken ct = default)
{
if (!PlayerIds.IsValidId(deviceId)) throw new ArgumentException("Identifiant d'appareil invalide.", nameof(deviceId));
await _client.SendAsync<NoContent>(HttpMethod.Delete, ApiRoutes.AccountDevice(deviceId), null, Bearer(accessToken), ct)
.ConfigureAwait(false);
}
/// <summary>Export de toutes ses données (document JSON, vérifié avant d'être rendu), mot de passe exigé.</summary>
public async Task<byte[]> ExportAsync(string accessToken, string password, CancellationToken ct = default)
{
var bytes = await _client.SendAsync<byte[]>(HttpMethod.Post, ApiRoutes.AccountExport,
LauncherApiClient.Serialize(new PlayerPasswordConfirmation { Password = password }), Bearer(accessToken), ct,
timeout: TimeSpan.FromMinutes(1), maxResponseBytes: MaxExportBytes).ConfigureAwait(false);
try
{
using var document = JsonDocument.Parse(bytes);
if (document.RootElement.ValueKind != JsonValueKind.Object ||
!document.RootElement.TryGetProperty("format", out var format) || format.GetString() != PlayerDataExport.Format)
throw new JsonException("format inattendu");
}
catch (JsonException ex)
{
throw new ApiException(200, "invalid_response", "Le serveur a renvoyé un export illisible.", ex);
}
return bytes;
}
// ------------------------------------------------------------------ Double authentification (étape 8c)
/// <summary>Connexion, 2ᵉ étape : code de l'application d'authentification ou code de secours.</summary>
public async Task<PlayerLoginResponse> VerifyOtpAsync(string otpToken, string code, CancellationToken ct = default)
{
if (!otpToken.StartsWith(PlayerTokens.OtpPrefix, StringComparison.Ordinal))
throw new ArgumentException("Jeton de vérification invalide.", nameof(otpToken));
var response = await _client.SendAsync<PlayerLoginResponse>(HttpMethod.Post, ApiRoutes.AccountLoginOtp,
LauncherApiClient.Serialize(new PlayerOtpCodeRequest { Code = code }), Bearer(otpToken), ct).ConfigureAwait(false);
return Check(response);
}
/// <summary>Préparation (mot de passe exigé) : secret et adresse « otpauth:// », contrôlés avant d'être affichés.</summary>
public async Task<PlayerOtpSetupResponse> OtpSetupAsync(string accessToken, string password, CancellationToken ct = default)
{
var setup = await _client.SendAsync<PlayerOtpSetupResponse>(HttpMethod.Post, ApiRoutes.AccountOtpSetup,
LauncherApiClient.Serialize(new PlayerPasswordConfirmation { Password = password }), Bearer(accessToken), ct).ConfigureAwait(false);
if (!PlayerOtp.TryBase32Decode(setup.Secret, out var secret) || secret.Length < 16 || setup.Secret.Length > 64 ||
!setup.Uri.StartsWith("otpauth://totp/", StringComparison.Ordinal) || setup.Uri.Length > 512 ||
!setup.Uri.Contains("secret=" + setup.Secret, StringComparison.Ordinal) || setup.Uri.Any(char.IsControl))
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible (double authentification).");
return setup;
}
/// <summary>Activation (premier code) : codes de secours, à montrer une seule fois.</summary>
public async Task<IReadOnlyList<string>> OtpEnableAsync(string accessToken, string code, CancellationToken ct = default) =>
CheckCodes(await _client.SendAsync<PlayerRecoveryCodesResponse>(HttpMethod.Post, ApiRoutes.AccountOtpEnable,
LauncherApiClient.Serialize(new PlayerOtpCodeRequest { Code = code }), Bearer(accessToken), ct).ConfigureAwait(false));
public async Task OtpDisableAsync(string accessToken, string password, string code, CancellationToken ct = default) =>
await _client.SendAsync<NoContent>(HttpMethod.Post, ApiRoutes.AccountOtpDisable,
LauncherApiClient.Serialize(new PlayerOtpConfirmRequest { Password = password, Code = code }), Bearer(accessToken), ct)
.ConfigureAwait(false);
public async Task<IReadOnlyList<string>> RegenerateRecoveryCodesAsync(string accessToken, string password, string code,
CancellationToken ct = default) =>
CheckCodes(await _client.SendAsync<PlayerRecoveryCodesResponse>(HttpMethod.Post, ApiRoutes.AccountOtpRecoveryCodes,
LauncherApiClient.Serialize(new PlayerOtpConfirmRequest { Password = password, Code = code }), Bearer(accessToken), ct)
.ConfigureAwait(false));
private static IReadOnlyList<string> CheckCodes(PlayerRecoveryCodesResponse response)
{
if (response.RecoveryCodes is not { Count: > 0 and <= 50 } codes || codes.Any(c => PlayerOtp.NormalizeRecoveryCode(c) is null))
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible (codes de secours).");
return codes;
}
/// <summary>Suppression DÉFINITIVE du compte (mot de passe exigé).</summary>
public async Task DeleteAccountAsync(string accessToken, string password, CancellationToken ct = default) =>
await _client.SendAsync<NoContent>(HttpMethod.Post, ApiRoutes.AccountDelete,
LauncherApiClient.Serialize(new PlayerPasswordConfirmation { Password = password }), Bearer(accessToken), ct).ConfigureAwait(false);
// ------------------------------------------------------------------ Défi, preuve de travail, clé d'appareil
private async Task<T> SendWithDeviceAsync<T>(string purpose, string route, IPlayerDeviceKey key, string deviceLabel,
IReadOnlyList<string>? hardware, Func<PlayerChallengeResponse, string, PlayerDeviceProof, object> build, CancellationToken ct)
{
var (challenge, solution) = await ChallengeAsync(purpose, ct).ConfigureAwait(false);
// Empreintes seulement (jamais les valeurs lues sur le PC) : toute entrée mal formée est écartée.
var entries = hardware?.Where(h => PlayerHardware.IsValid(h, out _, out _)).Distinct(StringComparer.Ordinal)
.Take(PlayerHardware.MaxEntries).ToList();
var proof = new PlayerDeviceProof
{
PublicKey = Convert.ToBase64String(key.PublicKey),
Signature = await SignAsync(key, purpose, challenge, ct).ConfigureAwait(false),
Label = deviceLabel,
Hardware = entries is { Count: > 0 } ? entries : null
};
var request = build(challenge, solution, proof);
var response = await _client.SendAsync<T>(HttpMethod.Post, route, JsonSerializer.SerializeToUtf8Bytes(request, request.GetType(), ApiJson.Options),
null, ct, timeout: TimeSpan.FromMinutes(1)).ConfigureAwait(false);
if (response is PlayerLoginResponse login) Check(login);
return response;
}
/// <summary>
/// Défi du serveur, contrôlé (usage, origine, difficulté, valeurs bornées), puis preuve de travail résolue hors du fil
/// de l'interface (« » si la difficulté est nulle : renouvellement).
/// </summary>
private async Task<(PlayerChallengeResponse Challenge, string Solution)> ChallengeAsync(string purpose, CancellationToken ct)
{
var challenge = await _client.SendAsync<PlayerChallengeResponse>(HttpMethod.Post, ApiRoutes.AccountChallenge,
LauncherApiClient.Serialize(new PlayerChallengeRequest { Purpose = purpose }), null, ct).ConfigureAwait(false);
if (challenge.Purpose != purpose || challenge.Difficulty is < 0 or > FeedbackProof.MaxDifficulty ||
!IsPlainToken(challenge.ChallengeId, 512) || !IsPlainToken(challenge.ServerId, 128) ||
!Base64Url.TryDecode(challenge.Nonce, out var nonce) || nonce.Length is 0 or > 64)
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible.");
_client.EnsureSameOrigin(challenge.Origin);
var solution = challenge.Difficulty == 0
? ""
: await Task.Run(() => FeedbackProof.Solve(nonce, challenge.Difficulty, ct), ct).ConfigureAwait(false);
return (challenge, solution);
}
private async Task<string> SignAsync(IPlayerDeviceKey key, string purpose, PlayerChallengeResponse challenge, CancellationToken ct)
{
var message = PlayerMessages.Device(purpose, challenge.Origin, challenge.ServerId, challenge.ChallengeId, challenge.Nonce, key.KeyId);
return Convert.ToBase64String(await key.SignAsync(message, ct).ConfigureAwait(false));
}
/// <summary>Réponse de connexion : jetons de la session, du changement exigé ou de la 2ᵉ étape bien formés.</summary>
private static PlayerLoginResponse Check(PlayerLoginResponse response)
{
if (response.Session is { } session) Check(session);
if (response.Status == PlayerLoginStatus.OtpRequired &&
(response.OtpToken is not { } otp || !otp.StartsWith(PlayerTokens.OtpPrefix, StringComparison.Ordinal) || !IsPlainToken(otp, PlayerTokens.MaxLength)))
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible (code de vérification).");
if (response.Status == PlayerLoginStatus.PasswordChangeRequired &&
(response.PasswordChangeToken is not { } change || !change.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal) ||
!IsPlainToken(change, PlayerTokens.MaxLength)))
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible (changement de mot de passe).");
return response;
}
/// <summary>Session reçue : jetons bien formés (ils seront renvoyés tels quels dans un en-tête puis enregistrés).</summary>
private static PlayerSession Check(PlayerSession session)
{
if (!session.AccessToken.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) || !IsPlainToken(session.AccessToken, PlayerTokens.MaxLength) ||
!PlayerTokens.TryParseRefresh(session.RefreshToken, out var deviceId, out _) || deviceId != session.DeviceId)
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible (session).");
return session;
}
private static Action<HttpRequestMessage> Bearer(string token)
{
if (!IsPlainToken(token, PlayerTokens.MaxLength) ||
!(token.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) || token.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal) ||
token.StartsWith(PlayerTokens.OtpPrefix, StringComparison.Ordinal)))
throw new ArgumentException("Jeton de session invalide.", nameof(token));
return request => request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
/// <summary>Jeton ou identifiant « simple » : caractères ASCII visibles uniquement, longueur bornée.</summary>
private static bool IsPlainToken(string? value, int maxLength) =>
value is { Length: > 0 } && value.Length <= maxLength && value.All(c => c is > ' ' and < '\u007f');
}
@@ -0,0 +1,319 @@
using System.Globalization;
using System.IO.Compression;
using System.Text;
namespace GameLauncher.Contracts.Accounts;
/// <summary>
/// Règles des mots de passe des comptes joueurs, appliquées À L'IDENTIQUE par le launcher (retour immédiat pendant la
/// saisie) et par le serveur (seul juge). Inspirées de la recommandation CNIL n° 2022-100 et de l'OWASP :
/// • 12 caractères au moins avec 3 types parmi minuscules, majuscules, chiffres, symboles ; ou 16 caractères au moins
/// sans contrainte (phrase de passe) ; 128 au plus ;
/// • refus des mots de passe les plus utilisés (dictionnaire public de 100 000 mots de passe) et de leurs variantes
/// simples (« P@ssw0rd2024! ») ;
/// • refus des suites triviales (répétitions, suites de clavier ou de chiffres) ;
/// • refus d'un mot de passe tiré de l'adresse e-mail ou du nom du jeu / du studio.
/// Le mot de passe est contrôlé et haché TEL QU'IL EST SAISI (aucune normalisation Unicode) : le serveur tourne en mode
/// « globalisation invariante » (sans ICU), où la normalisation n'est pas disponible ; launcher et serveur appliquent
/// donc exactement le même calcul. Les claviers Windows produisent des caractères accentués déjà composés.
/// </summary>
public static class PasswordRules
{
/// <summary>Version des règles (annoncée par le serveur ; changer si les règles évoluent).</summary>
public const string Version = "1";
public const int MinLength = 12;
public const int MinLengthWithoutComposition = 16;
public const int MaxLength = 128;
public const int RequiredClasses = 3;
/// <summary>Taille maximale d'un mot de passe en UTF-8 (128 caractères de 4 octets).</summary>
public const int MaxUtf8Bytes = 512;
/// <summary>Longueur minimale d'un « cœur » comparé au dictionnaire (en dessous, la comparaison n'a pas de sens).</summary>
private const int MinCoreLength = 4;
/// <summary>Après retrait de l'adresse e-mail ou du nom du jeu, il doit rester au moins ce nombre de caractères.</summary>
private const int MinRemainingLength = 8;
/// <summary>Mots propres au studio et aux jeux : un mot de passe construit dessus est refusé.</summary>
private static readonly string[] ProductWords =
{
"lanisysgames", "lanisys", "gamelauncher", "launcher", "exovivalzombiereborn", "exovivalzombie", "exovival",
"zombiereborn", "zombie"
};
/// <summary>Mots de passe français très courants (complément du dictionnaire, majoritairement anglophone).</summary>
private static readonly string[] FrenchWords =
{
"azerty", "azertyuiop", "motdepasse", "monmotdepasse", "soleil", "doudou", "loulou", "chouchou", "bonjour",
"coucou", "jetaime", "marseille", "paris", "camille", "chocolat", "princesse", "nicolas", "julien", "doudoune",
"bisounours", "vacances", "football", "olympique", "toulouse", "bordeaux", "nantes", "liberte", "france",
"motdepasse123", "123soleil", "chaton", "cheval", "minou", "nounours", "tresor", "amour", "ordinateur"
};
/// <summary>Rangées de claviers (AZERTY, QWERTY, QWERTZ) et suites courantes.</summary>
private static readonly string[] Sequences =
{
"azertyuiop", "qsdfghjklm", "wxcvbn", "qwertyuiop", "asdfghjkl", "zxcvbnm", "qwertzuiop", "yxcvbnm",
"1234567890", "0123456789", "abcdefghijklmnopqrstuvwxyz", "&é\"'(-è_çà", "azertyuiopqsdfghjklmwxcvbn",
"qwertyuiopasdfghjklzxcvbnm", "1qaz2wsx3edc4rfv", "1a2z3e4r5t6y", "a1z2e3r4t5y6"
};
/// <summary>
/// Erreurs du mot de passe (messages en français, prêts à afficher). Liste vide : mot de passe accepté.
/// <paramref name="email"/> : adresse du compte (facultative), pour refuser un mot de passe qui en est tiré.
/// </summary>
public static IReadOnlyList<string> Check(string? password, string? email = null)
{
var errors = new List<string>();
if (string.IsNullOrEmpty(password))
{
errors.Add("Le mot de passe est obligatoire.");
return errors;
}
var normalized = password;
var length = RuneCount(normalized);
if (normalized.Any(char.IsControl) || HasInvalidUnicode(normalized))
errors.Add("Le mot de passe contient un caractère non autorisé (retour à la ligne, tabulation…).");
if (length > MaxLength || Encoding.UTF8.GetByteCount(normalized) > MaxUtf8Bytes)
{
errors.Add($"Le mot de passe ne doit pas dépasser {MaxLength} caractères.");
return errors;
}
if (length < MinLength)
{
errors.Add($"Le mot de passe doit contenir au moins {MinLength} caractères.");
return errors;
}
if (length < MinLengthWithoutComposition && CountClasses(normalized) < RequiredClasses)
errors.Add($"Entre {MinLength} et {MinLengthWithoutComposition - 1} caractères, le mot de passe doit mélanger au moins " +
$"{RequiredClasses} types de caractères parmi : minuscules, majuscules, chiffres, symboles. " +
$"Vous pouvez aussi choisir une phrase de passe d'au moins {MinLengthWithoutComposition} caractères.");
if (errors.Count > 0) return errors;
var lower = normalized.ToLowerInvariant();
if (IsTrivial(lower))
errors.Add("Le mot de passe est une suite trop simple (répétition, suite de clavier, de lettres ou de chiffres).");
else if (IsCommon(lower))
errors.Add("Ce mot de passe fait partie des mots de passe les plus utilisés (ou en est une simple variante) : " +
"choisissez-en un autre.");
else if (IsDerivedFrom(lower, email))
errors.Add("Le mot de passe ressemble trop à votre adresse e-mail ou au nom du jeu : choisissez-en un autre.");
return errors;
}
/// <summary>Vrai si le mot de passe est accepté.</summary>
public static bool IsAcceptable(string? password, string? email = null) => Check(password, email).Count == 0;
// ------------------------------------------------------------------ Composition
private static int RuneCount(string text)
{
var count = 0;
foreach (var _ in text.EnumerateRunes()) count++;
return count;
}
/// <summary>Demi-caractère (surrogate) isolé : texte mal formé.</summary>
private static bool HasInvalidUnicode(string text)
{
for (var i = 0; i < text.Length; i++)
{
if (char.IsHighSurrogate(text[i]))
{
if (i + 1 >= text.Length || !char.IsLowSurrogate(text[i + 1])) return true;
i++;
}
else if (char.IsLowSurrogate(text[i]))
{
return true;
}
}
return false;
}
private static int CountClasses(string text)
{
bool lower = false, upper = false, digit = false, other = false;
foreach (var rune in text.EnumerateRunes())
{
if (Rune.IsLower(rune)) lower = true;
else if (Rune.IsUpper(rune)) upper = true;
else if (Rune.IsDigit(rune)) digit = true;
else other = true; // symboles, espaces, lettres sans casse
}
return (lower ? 1 : 0) + (upper ? 1 : 0) + (digit ? 1 : 0) + (other ? 1 : 0);
}
// ------------------------------------------------------------------ Suites triviales
private static bool IsTrivial(string lower)
{
var compact = new string(lower.Where(c => !char.IsWhiteSpace(c)).ToArray());
if (compact.Distinct().Count() <= 3) return true; // « aaaaaaaaaaaa », « abababababab », « 121212121212 »
foreach (var candidate in new[] { compact, Core(compact), Leet(Core(compact)) })
{
if (candidate.Length < MinCoreLength) continue;
if (IsRepeatedPattern(candidate)) return true;
foreach (var sequence in Sequences)
{
if (IsInsideRepeated(candidate, sequence) || IsInsideRepeated(candidate, Reverse(sequence))) return true;
}
}
return false;
}
/// <summary>Texte formé d'un même motif répété (« azeazeazeaze », « 123412341234 »).</summary>
private static bool IsRepeatedPattern(string text)
{
for (var size = 1; size <= text.Length / 2; size++)
{
if (text.Length % size != 0) continue;
var pattern = text[..size];
var repeated = true;
for (var i = size; i < text.Length && repeated; i += size)
repeated = string.CompareOrdinal(text, i, pattern, 0, size) == 0;
if (repeated) return true;
}
return false;
}
/// <summary>Le texte est contenu dans la suite répétée (« 345678901234 » dans « 1234567890 » × 3).</summary>
private static bool IsInsideRepeated(string text, string sequence)
{
var builder = new StringBuilder();
while (builder.Length < text.Length + sequence.Length) builder.Append(sequence);
return builder.ToString().Contains(text, StringComparison.Ordinal);
}
private static string Reverse(string text)
{
var chars = text.ToCharArray();
Array.Reverse(chars);
return new string(chars);
}
// ------------------------------------------------------------------ Dictionnaire
private static bool IsCommon(string lower)
{
var words = Blocklist.Value;
foreach (var candidate in Candidates(lower))
if (candidate.Length >= MinCoreLength && words.Contains(candidate)) return true;
return false;
}
/// <summary>Formes comparées au dictionnaire : telle quelle, sans chiffres/symboles au début et à la fin, puis
/// avec les substitutions courantes (« p@ssw0rd » → « password »).</summary>
private static IEnumerable<string> Candidates(string lower)
{
yield return lower;
var compact = new string(lower.Where(c => !char.IsWhiteSpace(c)).ToArray());
yield return compact;
var core = Core(compact);
yield return core;
yield return Leet(core);
yield return Core(Leet(compact));
}
/// <summary>Retire chiffres, espaces et symboles au début et à la fin (« !!dragon2024 » → « dragon »).</summary>
internal static string Core(string text)
{
var start = 0;
var end = text.Length;
while (start < end && !char.IsLetter(text[start])) start++;
while (end > start && !char.IsLetter(text[end - 1])) end--;
return text[start..end];
}
/// <summary>Substitutions courantes chiffres/symboles → lettres.</summary>
internal static string Leet(string text)
{
var chars = text.ToCharArray();
for (var i = 0; i < chars.Length; i++)
{
chars[i] = chars[i] switch
{
'0' => 'o',
'1' or '!' or '|' => 'i',
'3' or '€' => 'e',
'4' or '@' => 'a',
'5' or '$' => 's',
'7' or '+' => 't',
'8' => 'b',
'9' => 'g',
_ => chars[i]
};
}
return new string(chars);
}
/// <summary>
/// Dictionnaire chargé à la première utilisation (ressource intégrée, ~100 000 entrées) : chaque entrée est gardée
/// en minuscules et sous sa forme normalisée (sans chiffres/symboles aux extrémités, substitutions retirées).
/// </summary>
private static readonly Lazy<HashSet<string>> Blocklist = new(LoadBlocklist, LazyThreadSafetyMode.ExecutionAndPublication);
/// <summary>Nombre de formes connues du dictionnaire (diagnostic et tests ; le charge s'il ne l'est pas encore).</summary>
public static int BlocklistSize => Blocklist.Value.Count;
private static HashSet<string> LoadBlocklist()
{
var set = new HashSet<string>(StringComparer.Ordinal);
void Add(string word)
{
var lower = word.ToLowerInvariant();
if (lower.Length >= MinCoreLength) set.Add(lower);
var core = Leet(Core(lower));
if (core.Length >= MinCoreLength) set.Add(core);
}
using (var stream = typeof(PasswordRules).Assembly.GetManifestResourceStream(BlocklistResource)
?? throw new InvalidOperationException("Dictionnaire des mots de passe absent de GameLauncher.Contracts."))
using (var gzip = new GZipStream(stream, CompressionMode.Decompress))
using (var reader = new StreamReader(gzip, Encoding.UTF8))
{
while (reader.ReadLine() is { } line)
if (line.Length > 0) Add(line);
}
foreach (var word in FrenchWords) Add(word);
return set; // les mots du studio et des jeux sont traités à part (IsDerivedFrom), avec un message plus précis
}
internal const string BlocklistResource = "GameLauncher.Contracts.Accounts.password-blocklist.txt.gz";
// ------------------------------------------------------------------ Adresse e-mail et nom du jeu
private static bool IsDerivedFrom(string lower, string? email)
{
var words = new List<string>(ProductWords);
if (PlayerEmail.Normalize(email) is { } normalized)
{
var at = normalized.IndexOf('@');
var local = normalized[..at];
words.Add(local);
words.AddRange(local.Split('.', '_', '-', '+').Where(p => p.Length >= 3));
var domainLabel = normalized[(at + 1)..].Split('.')[0];
if (domainLabel.Length >= 4) words.Add(domainLabel);
}
foreach (var form in new[] { lower, Leet(lower) })
{
var remaining = new string(form.Where(char.IsLetterOrDigit).ToArray());
var removedAny = false;
foreach (var word in words.Where(w => w.Length >= 3).OrderByDescending(w => w.Length))
{
var clean = new string(word.Where(char.IsLetterOrDigit).ToArray());
if (clean.Length < 3 || !remaining.Contains(clean, StringComparison.Ordinal)) continue;
remaining = remaining.Replace(clean, "", StringComparison.Ordinal);
removedAny = true;
}
if (removedAny && remaining.Length < MinRemainingLength) return true;
}
return false;
}
}
@@ -0,0 +1,314 @@
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>
/// Identifiants matériels du PC, sous forme d'empreintes seulement (<see cref="PlayerHardware.Format"/>, 4 au plus) :
/// servent aux blocages (étape 8e). Absents d'un launcher plus ancien.
/// </summary>
public List<string>? Hardware { get; init; }
}
/// <summary>Création de compte (POST v1/account/register).</summary>
public sealed record PlayerRegisterRequest
{
public string Email { get; init; } = "";
public string Password { get; init; } = "";
public string ChallengeId { get; init; } = "";
/// <summary>Solution de la preuve de travail du défi.</summary>
public string Solution { get; init; } = "";
/// <summary>Version des conditions acceptées par le joueur (doit être celle annoncée par le serveur).</summary>
public string AcceptedTermsVersion { get; init; } = "";
public PlayerDeviceProof Device { get; init; } = new();
}
/// <summary>Connexion (POST v1/account/login).</summary>
public sealed record PlayerLoginRequest
{
public string Email { get; init; } = "";
public string Password { get; init; } = "";
public string ChallengeId { get; init; } = "";
public string Solution { get; init; } = "";
public PlayerDeviceProof Device { get; init; } = new();
}
/// <summary>Renouvellement de session au lancement du launcher (POST v1/account/refresh).</summary>
public sealed record PlayerRefreshRequest
{
public string RefreshToken { get; init; } = "";
public string ChallengeId { get; init; } = "";
/// <summary>Signature (Base64) de <see cref="PlayerMessages.Device"/> (usage « refresh ») par la clé de l'appareil.</summary>
public string Signature { get; init; } = "";
}
/// <summary>Résultats possibles d'une connexion réussie (mot de passe correct).</summary>
public static class PlayerLoginStatus
{
/// <summary>Connecté : <see cref="PlayerLoginResponse.Session"/>.</summary>
public const string Ok = "ok";
/// <summary>
/// Un administrateur exige un nouveau mot de passe : <see cref="PlayerLoginResponse.PasswordChangeToken"/> permet
/// seulement d'appeler POST v1/account/password ; la session est ouverte une fois le mot de passe changé.
/// </summary>
public const string PasswordChangeRequired = "password_change_required";
/// <summary>
/// Double authentification activée : <see cref="PlayerLoginResponse.OtpToken"/> permet seulement d'envoyer le code de
/// l'application d'authentification (ou un code de secours) à POST v1/account/login/otp, qui termine la connexion.
/// </summary>
public const string OtpRequired = "otp_required";
}
public sealed record PlayerLoginResponse
{
public string Status { get; init; } = PlayerLoginStatus.Ok;
public PlayerSession? Session { get; init; }
public string? PasswordChangeToken { get; init; }
public DateTimeOffset? PasswordChangeExpiresAt { get; init; }
/// <summary>Jeton de la 2ᵉ étape (code de vérification), si <see cref="Status"/> vaut <see cref="PlayerLoginStatus.OtpRequired"/>.</summary>
public string? OtpToken { get; init; }
public DateTimeOffset? OtpExpiresAt { get; init; }
/// <summary>Connexion terminée avec un code de secours : codes encore utilisables (sinon null).</summary>
public int? RecoveryCodesLeft { get; init; }
/// <summary>
/// Connexion avec un mot de passe temporaire donné par l'équipe (étape 8d) : <see cref="Status"/> vaut alors
/// <see cref="PlayerLoginStatus.PasswordChangeRequired"/> (ou <see cref="PlayerLoginStatus.OtpRequired"/> d'abord).
/// </summary>
public bool TemporaryPassword { get; init; }
}
/// <summary>Session ouverte (création de compte, connexion, renouvellement, changement de mot de passe exigé).</summary>
public sealed record PlayerSession
{
/// <summary>Jeton d'accès (« Authorization: Bearer … »), court, à garder en mémoire uniquement.</summary>
public string AccessToken { get; init; } = "";
public DateTimeOffset AccessExpiresAt { get; init; }
/// <summary>
/// Jeton de renouvellement (connexion mémorisée), remplacé à chaque renouvellement : à enregistrer chiffré sur le PC
/// (DPAPI) à la place du précédent. Inutilisable sans la clé de l'appareil.
/// </summary>
public string RefreshToken { get; init; } = "";
/// <summary>Échéance du jeton de renouvellement (sans utilisation ; repoussée à chaque renouvellement, dans la limite d'un an).</summary>
public DateTimeOffset RefreshExpiresAt { get; init; }
public string DeviceId { get; init; } = "";
public PlayerAccountInfo Account { get; init; } = new();
}
/// <summary>Compte du joueur connecté (adresse masquée : l'adresse complète n'est jamais renvoyée).</summary>
public sealed record PlayerAccountInfo
{
/// <summary>Référence du compte (« PL-7KQ2-M9XD »), à communiquer au support.</summary>
public string Reference { get; init; } = "";
public string EmailMasked { get; init; } = "";
public bool EmailVerified { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastLoginAt { get; init; }
public DateTimeOffset PasswordChangedAt { get; init; }
public bool OtpEnabled { get; init; }
/// <summary>Codes de secours encore utilisables (double authentification activée), sinon 0.</summary>
public int RecoveryCodesLeft { get; init; }
/// <summary>
/// Suspension de sécurité après de nombreux essais de mot de passe : les connexions depuis un NOUVEL appareil sont
/// refusées jusqu'à ce qu'un administrateur la lève ; cet appareil-ci (déjà connecté) continue de fonctionner.
/// </summary>
public bool NewDevicesSuspended { get; init; }
}
/// <summary>Un appareil connecté au compte (« Mes appareils »).</summary>
public sealed record PlayerDeviceInfo
{
public string Id { get; init; } = "";
public string Label { get; init; } = "";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset LastUsedAt { get; init; }
/// <summary>Appareil de la session en cours.</summary>
public bool Current { get; init; }
}
/// <summary>GET v1/account/me.</summary>
public sealed record PlayerMeResponse
{
public PlayerAccountInfo Account { get; init; } = new();
public IReadOnlyList<PlayerDeviceInfo> Devices { get; init; } = Array.Empty<PlayerDeviceInfo>();
}
/// <summary>Changement de mot de passe (POST v1/account/password), avec une session ou un jeton « changement exigé ».</summary>
public sealed record PlayerChangePasswordRequest
{
public string CurrentPassword { get; init; } = "";
public string NewPassword { get; init; } = "";
}
public sealed record PlayerChangePasswordResponse
{
/// <summary>Session ouverte après un changement EXIGÉ (jeton limité) ; null pour un changement ordinaire.</summary>
public PlayerSession? Session { get; init; }
/// <summary>Nombre d'autres appareils déconnectés.</summary>
public int DevicesSignedOut { get; init; }
}
/// <summary>Déconnexion (POST v1/account/logout).</summary>
public sealed record PlayerLogoutRequest
{
/// <summary>Vrai : tous les appareils du compte (« Déconnecter tous mes appareils »).</summary>
public bool AllDevices { get; init; }
}
/// <summary>Mot de passe actuel, exigé pour les actions sensibles : export des données (POST v1/account/export), suppression du
/// compte (POST v1/account/delete).</summary>
public sealed record PlayerPasswordConfirmation
{
public string Password { get; init; } = "";
}
/// <summary>GET v1/account/activity : activité récente du compte, la plus récente d'abord.</summary>
public sealed record PlayerActivityResponse
{
public IReadOnlyList<PlayerActivityEntry> Entries { get; init; } = Array.Empty<PlayerActivityEntry>();
}
/// <summary>
/// Une ligne de l'activité du compte. Le libellé est calculé par le launcher (<see cref="PlayerActivityText"/>) ; aucun détail
/// interne n'est renvoyé, et l'adresse IP est MASQUÉE (« 203.0.113.x ») : elle peut être celle d'un tiers (essai de mot de passe).
/// </summary>
public sealed record PlayerActivityEntry
{
public DateTimeOffset At { get; init; }
/// <summary>Type d'événement (« account.login »…), voir <see cref="PlayerActivityText"/>.</summary>
public string Type { get; init; } = "";
/// <summary>Faux : tentative refusée (mot de passe incorrect, connexion bloquée…).</summary>
public bool Success { get; init; }
/// <summary>Nom générique de l'appareil s'il est toujours connecté au compte, sinon null.</summary>
public string? Device { get; init; }
/// <summary>Événement venu de l'appareil de la session en cours.</summary>
public bool ThisDevice { get; init; }
/// <summary>Adresse IP masquée, ou null (effacée après quelques mois, ou action du système).</summary>
public string? Ip { get; init; }
}
/// <summary>Document d'export des données d'un joueur (POST v1/account/export), lisible par machine (JSON).</summary>
public static class PlayerDataExport
{
/// <summary>Valeur de la propriété « format » du document.</summary>
public const string Format = "GameLauncher/PlayerData/v1";
/// <summary>Nom de fichier proposé à l'enregistrement (« donnees-compte-PL-7KQ2-M9XD-2026-11-11.json »).</summary>
public static string SuggestedFileName(string reference, DateTimeOffset date)
{
var safe = new string((reference ?? "").Where(c => char.IsAsciiLetterOrDigit(c) || c == '-').ToArray());
return $"donnees-compte-{(safe.Length > 0 ? safe : "joueur")}-{date:yyyy-MM-dd}.json";
}
}
/// <summary>Code de vérification : 6 chiffres de l'application d'authentification, ou code de secours (« 7KQ2-M9XD »).</summary>
public sealed record PlayerOtpCodeRequest
{
public string Code { get; init; } = "";
}
/// <summary>Mot de passe et code de vérification : désactivation, nouveaux codes de secours.</summary>
public sealed record PlayerOtpConfirmRequest
{
public string Password { get; init; } = "";
public string Code { get; init; } = "";
}
/// <summary>
/// Préparation de la double authentification (POST v1/account/otp/setup) : secret à enregistrer dans l'application
/// d'authentification (QR code de <see cref="Uri"/>, ou clé à saisir). Renvoyé UNE fois ; à confirmer par un premier code
/// avant <see cref="ExpiresAt"/>.
/// </summary>
public sealed record PlayerOtpSetupResponse
{
/// <summary>Secret en Base32 (RFC 4648).</summary>
public string Secret { get; init; } = "";
/// <summary>Adresse « otpauth://totp/… » (contenu du QR code).</summary>
public string Uri { get; init; } = "";
public string Issuer { get; init; } = "";
public string AccountName { get; init; } = "";
public int Digits { get; init; } = PlayerOtp.Digits;
public int Period { get; init; } = PlayerOtp.PeriodSeconds;
public DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>Codes de secours (activation, renouvellement) : affichés UNE fois, le serveur n'en garde qu'une empreinte.</summary>
public sealed record PlayerRecoveryCodesResponse
{
public IReadOnlyList<string> RecoveryCodes { get; init; } = Array.Empty<string>();
}
@@ -0,0 +1,48 @@
namespace GameLauncher.Contracts.Accounts;
/// <summary>
/// Libellés français des événements du journal des comptes joueurs (« Activité récente » du launcher, export des données,
/// fiche d'un compte dans le menu d'administration). Un type inconnu (serveur plus récent) est affiché tel quel.
/// </summary>
public static class PlayerActivityText
{
public static string Describe(string type, bool success) => type switch
{
"account.register" => "Création du compte",
"account.login" => success ? "Connexion" : "Connexion refusée (compte désactivé ou suspendu)",
"account.login_failed" => "Mot de passe incorrect",
"account.locked" => "Connexion bloquée temporairement (trop d'essais)",
"account.suspended" => "Nouveaux appareils suspendus (trop d'essais)",
"account.unlocked" => "Suspension ou blocage levé par l'équipe",
"account.disabled" => "Compte désactivé",
"account.enabled" => "Compte réactivé",
"account.inactive" => "Compte mis en sommeil (inactivité)",
"account.reactivated" => "Compte réveillé (connexion après une longue inactivité)",
"account.logout" => "Déconnexion",
"account.logout_all" => "Déconnexion de tous les appareils",
"account.password_change" => success ? "Mot de passe modifié" : "Changement de mot de passe refusé (mot de passe actuel incorrect)",
"account.password_change_required" => "Nouveau mot de passe exigé",
"account.export" => success ? "Export de vos données" : "Export refusé (mot de passe incorrect)",
"account.delete" => success ? "Suppression du compte" : "Suppression du compte refusée (mot de passe incorrect)",
"session.refresh" => success ? "Reconnexion automatique" : "Reconnexion automatique refusée",
"session.reuse" => "Jeton de connexion réutilisé : appareil déconnecté par précaution",
"device.removed" => "Appareil déconnecté",
"account.otp_challenge" => "Mot de passe correct, code de vérification demandé",
"account.otp_failed" => "Code de vérification incorrect",
"account.otp_enabled" => success ? "Double authentification activée" : "Activation de la double authentification refusée (mot de passe incorrect)",
"account.otp_disabled" => success ? "Double authentification désactivée" : "Désactivation de la double authentification refusée",
"account.otp_reset" => "Double authentification réinitialisée par l'équipe",
"account.recovery_codes" => success ? "Nouveaux codes de secours" : "Nouveaux codes de secours refusés",
"account.recovery_code_used" => "Connexion avec un code de secours",
"account.password_reset_required" => "Nouveau mot de passe exigé par l'équipe",
"account.password_reset_cancelled" => "Nouveau mot de passe plus exigé (décision de l'équipe)",
"account.temporary_password" => "Mot de passe temporaire créé par l'équipe",
"account.temporary_password_used" => "Connexion avec le mot de passe temporaire",
"device.removed_by_team" => "Appareil(s) déconnecté(s) par l'équipe",
"account.blocked" => "Connexion refusée : appareil ou connexion Internet bloqués par l'équipe",
_ => type
};
/// <summary>Vrai si le type a un libellé (sinon <see cref="Describe"/> renvoie le type tel quel).</summary>
public static bool IsKnown(string type) => Describe(type, true) != type;
}
@@ -0,0 +1,303 @@
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts.Feedback;
namespace GameLauncher.Contracts.Accounts;
// Comptes joueurs vus par les ADMINISTRATEURS (étape 8d, routes « v1/players/… », permissions players.*). L'adresse e-mail
// n'est jamais renvoyée en entier : seule sa forme masquée l'est ; la recherche par adresse exacte passe par le corps d'une
// requête POST. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md, § 3.7.
/// <summary>États d'un compte joueur (valeurs de l'API) et leurs libellés.</summary>
public static class PlayerAccountStatuses
{
public const string Active = "active";
public const string Disabled = "disabled";
public const string Suspended = "suspended";
public const string Inactive = "inactive";
public static string Label(string? status) => status switch
{
Active => "Actif",
Disabled => "Désactivé",
Suspended => "Suspendu (nouveaux appareils)",
Inactive => "En sommeil (inactivité)",
null or "" => "Inconnu",
_ => status
};
}
/// <summary>Règles des actions des administrateurs sur les comptes joueurs (mêmes contrôles dans le menu et sur le serveur).</summary>
public static class PlayerAdminRules
{
/// <summary>Motif d'une désactivation, communiqué au joueur à la connexion : une ligne, sans lien.</summary>
public const int MaxReasonLength = 200;
/// <summary>Désactivation temporaire : 10 ans au plus (au-delà : « indéfiniment »).</summary>
public const int MaxDisableDays = 3650;
/// <summary>Lignes d'activité renvoyées dans la fiche d'un compte.</summary>
public const int DetailEvents = 100;
/// <summary>Recherche : texte saisi (référence ou adresse e-mail).</summary>
public const int MaxSearchLength = 254;
/// <summary>Filtres de la liste (valeur, libellé) ; null : tous les comptes.</summary>
public static readonly IReadOnlyList<(string? Value, string Label)> Filters = new (string?, string)[]
{
(null, "Tous les comptes"),
(PlayerAccountStatuses.Active, "Actifs"),
(PlayerAccountStatuses.Disabled, "Désactivés"),
(PlayerAccountStatuses.Suspended, "Suspendus (nouveaux appareils)"),
("locked", "Bloqués temporairement (trop d'essais)"),
(PlayerAccountStatuses.Inactive, "En sommeil (inactivité)"),
("password", "Nouveau mot de passe exigé"),
("otp", "Double authentification activée")
};
public static bool IsValidFilter(string? filter) => filter is null || Filters.Any(f => f.Value == filter);
/// <summary>
/// Motif facultatif (vide : null). <paramref name="problem"/> non nul si le motif est refusé : plusieurs lignes, caractère
/// invisible, trop long, ou lien (le joueur ne doit pas être renvoyé vers un site depuis un message du support).
/// </summary>
public static string? CleanReason(string? reason, out string? problem)
{
problem = null;
if (string.IsNullOrWhiteSpace(reason)) return null;
if (reason.Contains('\n') || reason.Contains('\r'))
{
problem = "une seule ligne.";
return null;
}
var clean = ApiText.CleanLine(reason, MaxReasonLength);
if (clean is null)
{
problem = $"{MaxReasonLength} caractères au plus, sans caractère invisible.";
return null;
}
if (FeedbackLinks.Contains(clean))
{
problem = "aucun lien ni adresse de site (le motif est montré au joueur).";
return null;
}
return clean;
}
}
/// <summary>
/// Mot de passe temporaire créé par un administrateur (D6) : 80 bits aléatoires, affiché « K7Q2-M9XD-4TPA-W3HN » (alphabet
/// sans lettre ambiguë), valable <see cref="Lifetime"/>, il ne sert qu'à choisir un NOUVEAU mot de passe (il n'ouvre jamais
/// de session) et ne remplace pas l'ancien tant que ce n'est pas fait. Le serveur n'en garde qu'une empreinte HMAC.
/// </summary>
public static class PlayerTemporaryPassword
{
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(60);
private const string Crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
private const int Length = 16;
public static string New()
{
var bytes = RandomNumberGenerator.GetBytes(10);
var bits = new StringBuilder(Length + 3);
int buffer = 0, count = 0;
foreach (var b in bytes)
{
buffer = (buffer << 8) | b;
count += 8;
while (count >= 5)
{
if (bits.Length is 4 or 9 or 14) bits.Append('-');
bits.Append(Crockford[(buffer >> (count - 5)) & 31]);
count -= 5;
}
}
CryptographicOperations.ZeroMemory(bytes);
return bits.ToString();
}
/// <summary>
/// Forme normalisée (16 caractères, majuscules, sans tiret ni espace ; O → 0, I et L → 1), ou null si le texte saisi
/// n'est pas un mot de passe temporaire (c'est alors un mot de passe ordinaire, comparé tel quel).
/// </summary>
public static string? Normalize(string? input)
{
if (string.IsNullOrWhiteSpace(input) || input.Length > 40) return null;
var builder = new StringBuilder(Length);
foreach (var raw in input.Trim().ToUpperInvariant())
{
if (raw is ' ' or '-') continue;
var c = raw switch { 'O' => '0', 'I' or 'L' => '1', _ => raw };
if (!Crockford.Contains(c)) return null;
builder.Append(c);
}
return builder.Length == Length ? builder.ToString() : null;
}
}
// ------------------------------------------------------------------ Réponses
/// <summary>Compteurs et réglages du service (GET v1/players/overview).</summary>
public sealed record PlayersOverview
{
/// <summary>Service des comptes activé sur ce serveur (sinon tout le reste vaut 0).</summary>
public bool Enabled { get; init; }
public bool RegistrationEnabled { get; init; }
public int Total { get; init; }
public int Active { get; init; }
public int Disabled { get; init; }
public int Suspended { get; init; }
public int Inactive { get; init; }
public int Locked { get; init; }
public int PasswordChangeRequired { get; init; }
public int OtpEnabled { get; init; }
public int CreatedLast30Days { get; init; }
public int EventsRetentionMonths { get; init; }
public int EventsIpRetentionMonths { get; init; }
public int InactiveDisableMonths { get; init; }
public int InactiveDeleteMonths { get; init; }
}
/// <summary>Compte dans une liste (adresse masquée).</summary>
public sealed record PlayerAdminSummary
{
public string Reference { get; init; } = "";
public string EmailMasked { get; init; } = "";
public string Status { get; init; } = PlayerAccountStatuses.Active;
public DateTimeOffset? DisabledUntil { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastLoginAt { get; init; }
/// <summary>Dernière utilisation d'un appareil (reconnexion automatique comprise).</summary>
public DateTimeOffset? LastActivityAt { get; init; }
public bool OtpEnabled { get; init; }
public bool MustChangePassword { get; init; }
/// <summary>Fin du blocage temporaire en cours (trop d'essais), sinon null.</summary>
public DateTimeOffset? LockedUntil { get; init; }
public int DeviceCount { get; init; }
}
/// <summary>Page de la liste (GET v1/players).</summary>
public sealed record PlayerAdminPage
{
public List<PlayerAdminSummary> Items { get; init; } = new();
public string? NextCursor { get; init; }
/// <summary>Comptes correspondant au filtre.</summary>
public int Total { get; init; }
}
/// <summary>Recherche (POST v1/players/search) : référence « PL-XXXX-XXXX » ou adresse e-mail exacte.</summary>
public sealed record PlayerSearchRequest
{
public string Query { get; init; } = "";
}
public sealed record PlayerSearchResult
{
/// <summary>« reference » ou « email » (comment le texte a été compris).</summary>
public string Kind { get; init; } = "";
/// <summary>Le compte trouvé (0 ou 1).</summary>
public List<PlayerAdminSummary> Items { get; init; } = new();
}
/// <summary>Appareil connecté d'un compte.</summary>
public sealed record PlayerAdminDevice
{
public string Id { get; init; } = "";
public string Label { get; init; } = "";
public string KeyId { get; init; } = "";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset LastUsedAt { get; init; }
public DateTimeOffset RememberedUntil { get; init; }
}
/// <summary>Ligne du journal du compte (adresse IP complète : vue des administrateurs).</summary>
public sealed record PlayerAdminEvent
{
public DateTimeOffset At { get; init; }
public string Type { get; init; } = "";
public bool Success { get; init; }
public string? Ip { get; init; }
public string? DeviceKeyId { get; init; }
/// <summary>Nom de l'appareil si la clé est encore connectée.</summary>
public string? Device { get; init; }
public string? Details { get; init; }
}
/// <summary>Fiche d'un compte (GET v1/players/{reference}), renvoyée aussi après chaque action.</summary>
public sealed record PlayerAdminDetail
{
public PlayerAdminSummary Summary { get; init; } = new();
public bool EmailVerified { get; init; }
public string TermsVersion { get; init; } = "";
public DateTimeOffset PasswordChangedAt { get; init; }
/// <summary>Motif de la désactivation (communiqué au joueur), ou null.</summary>
public string? StatusReason { get; init; }
public DateTimeOffset? StatusChangedAt { get; init; }
public string? StatusChangedBy { get; init; }
public int FailedCount { get; init; }
public int LockLevel { get; init; }
public DateTimeOffset? UnknownDevicesLockedUntil { get; init; }
public int TrustedFailedCount { get; init; }
public DateTimeOffset? TrustedDevicesLockedUntil { get; init; }
public int RecoveryCodesLeft { get; init; }
/// <summary>Mot de passe temporaire en attente d'utilisation : son échéance (sinon null).</summary>
public DateTimeOffset? TemporaryPasswordExpiresAt { get; init; }
public List<PlayerAdminDevice> Devices { get; init; } = new();
public List<PlayerAdminEvent> Events { get; init; } = new();
/// <summary>Identifiants matériels connus de ses PC (étape 8e ; empreintes abrégées).</summary>
public List<PlayerAdminHardware> Hardware { get; init; } = new();
/// <summary>Dernière adresse IP connue (connexion, reconnexion, création) et sa date ; null si aucune (effacée après 6 mois).</summary>
public string? LastIp { get; init; }
public DateTimeOffset? LastIpAt { get; init; }
/// <summary>Blocages en vigueur qui visent ses identifiants matériels ou sa dernière adresse IP, ou décidés depuis sa fiche.</summary>
public List<PlayerBan> Bans { get; init; } = new();
}
// ------------------------------------------------------------------ Actions (requêtes signées, players.manage)
/// <summary>Désactivation (POST …/disable) : jusqu'à une date (null : indéfiniment), motif facultatif montré au joueur.</summary>
public sealed record PlayerDisableRequest
{
public DateTimeOffset? Until { get; init; }
public string? Reason { get; init; }
}
/// <summary>Exiger (ou ne plus exiger) un nouveau mot de passe à la prochaine connexion (POST …/password-requirement).</summary>
public sealed record PlayerPasswordRequirementRequest
{
public bool Required { get; init; }
}
/// <summary>Déconnexion d'un appareil (identifiant) ou de tous (null) (POST …/logout).</summary>
public sealed record PlayerDevicesLogoutRequest
{
public string? DeviceId { get; init; }
}
/// <summary>Mot de passe temporaire créé (POST …/temporary-password) : affiché UNE fois à l'administrateur.</summary>
public sealed record PlayerTemporaryPasswordResponse
{
public string Password { get; init; } = "";
public DateTimeOffset ExpiresAt { get; init; }
public PlayerAdminDetail Account { get; init; } = new();
}
@@ -0,0 +1,284 @@
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
namespace GameLauncher.Contracts.Accounts;
// Blocages des joueurs (étape 8e, décision D10) : par identifiants matériels du PC et/ou par adresse IP publique, pour la
// création de compte seule ou aussi pour la connexion. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md, § 3.8.
/// <summary>
/// Identifiants matériels du PC envoyés à la création de compte et à la connexion, JAMAIS en clair : le launcher n'envoie
/// qu'une empreinte SHA-256 propre à ce service (« machine:… »), que le serveur réempreinte avec une clé secrète avant de la
/// garder. Ils ne servent qu'aux blocages (sécurité du service) ; ils ne sont lus que si le joueur se connecte ou crée un compte.
/// </summary>
public static class PlayerHardware
{
/// <summary>Identifiant d'installation de Windows (HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid) : change à la réinstallation.</summary>
public const string Machine = "machine";
/// <summary>UUID du système (SMBIOS, carte mère) : survit à une réinstallation de Windows.</summary>
public const string Smbios = "smbios";
public const int MaxEntries = 4;
private const string Base64UrlChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
public static readonly IReadOnlyList<string> Kinds = new[] { Machine, Smbios };
public static string Label(string? kind) => kind switch
{
Machine => "Installation de Windows",
Smbios => "Carte mère (SMBIOS)",
_ => kind ?? ""
};
/// <summary>
/// Entrée envoyée au serveur : « type:empreinte » (SHA-256 en Base64Url, 43 caractères) de la valeur normalisée
/// (majuscules, sans espace ni accolade). La valeur brute ne quitte jamais le PC.
/// </summary>
public static string Format(string kind, string rawValue)
{
if (!Kinds.Contains(kind, StringComparer.Ordinal)) throw new ArgumentOutOfRangeException(nameof(kind));
var normalized = new string(rawValue.Where(c => !char.IsWhiteSpace(c) && c is not ('{' or '}')).ToArray()).ToUpperInvariant();
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"GameLauncher/Player/Hardware/v1\n{kind}\n{normalized}"));
return $"{kind}:{Base64Url.Encode(hash)}";
}
/// <summary>Entrée reçue bien formée (type connu, empreinte de 43 caractères Base64Url).</summary>
public static bool IsValid(string? entry, out string kind, out string hash)
{
kind = hash = "";
if (entry is not { Length: > 0 and <= 64 }) return false;
var colon = entry.IndexOf(':');
if (colon <= 0) return false;
kind = entry[..colon];
hash = entry[(colon + 1)..];
return Kinds.Contains(kind, StringComparer.Ordinal) && hash.Length == 43 && hash.All(c => Base64UrlChars.Contains(c));
}
}
/// <summary>Portée d'un blocage (D10).</summary>
public static class PlayerBanScopes
{
/// <summary>Création de compte refusée (les comptes existants continuent de se connecter).</summary>
public const string Register = "register";
/// <summary>Création de compte ET connexion refusées (reconnexion automatique comprise).</summary>
public const string RegisterAndLogin = "register_login";
public static bool IsValid(string? scope) => scope is Register or RegisterAndLogin;
public static bool BlocksLogin(string? scope) => scope == RegisterAndLogin;
public static string Label(string? scope) => scope switch
{
Register => "Création de compte",
RegisterAndLogin => "Création de compte et connexion",
_ => scope ?? ""
};
}
/// <summary>Types de cible d'un blocage.</summary>
public static class PlayerBanTargetKinds
{
public const string Ip = "ip";
public static string Label(string kind) => kind == Ip ? "Adresse IP" : PlayerHardware.Label(kind);
}
/// <summary>Règles des blocages (mêmes contrôles dans le menu et sur le serveur).</summary>
public static class PlayerBanRules
{
/// <summary>Une adresse IP change de titulaire (fournisseurs, CGNAT) : bloquée un an au plus, jamais indéfiniment.</summary>
public const int MaxIpDays = 365;
public const int MaxDays = 3650;
public const int MaxNoteLength = 300;
/// <summary>Plages les plus larges acceptées : /16 en IPv4, /32 en IPv6 (au-delà, trop de joueurs innocents).</summary>
public const int MinIpv4Prefix = 16;
public const int MinIpv6Prefix = 32;
/// <summary>Préfixe d'une adresse IPv6 seule (un abonné dispose généralement de tout un /64).</summary>
public const int DefaultIpv6Prefix = 64;
/// <summary>
/// Adresse (« 203.0.113.24 », « 2001:db8::1 ») ou plage (« 203.0.113.0/24 », « 2001:db8::/48 ») à bloquer, normalisée
/// (adresse du réseau, préfixe) : IPv4 seule = /32, IPv6 seule = /64. Null et <paramref name="problem"/> si refusée.
/// </summary>
public static (string Network, int Prefix)? ParseIpTarget(string? text, out string? problem)
{
problem = null;
var value = text?.Trim() ?? "";
if (value.Length is 0 or > 64)
{
problem = "saisissez une adresse IP (ex. 203.0.113.24) ou une plage (ex. 203.0.113.0/24).";
return null;
}
int? prefix = null;
var slash = value.IndexOf('/');
if (slash >= 0)
{
if (!int.TryParse(value[(slash + 1)..], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var p))
{
problem = "préfixe de la plage invalide.";
return null;
}
prefix = p;
value = value[..slash];
}
if (!IPAddress.TryParse(value, out var address) || value.Contains('%'))
{
problem = "adresse IP invalide.";
return null;
}
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
var v4 = address.AddressFamily == AddressFamily.InterNetwork;
var bits = v4 ? 32 : 128;
var length = prefix ?? (v4 ? 32 : DefaultIpv6Prefix);
var min = v4 ? MinIpv4Prefix : MinIpv6Prefix;
if (length < min || length > bits)
{
problem = $"plage trop large ou invalide : de /{min} à /{bits} en {(v4 ? "IPv4" : "IPv6")}.";
return null;
}
if (IPAddress.IsLoopback(address) && length < bits)
{
problem = "plage locale refusée.";
return null;
}
return (Network(address, length).ToString(), length);
}
/// <summary>Adresse du réseau (bits au-delà du préfixe à zéro).</summary>
public static IPAddress Network(IPAddress address, int prefix)
{
var bytes = address.GetAddressBytes();
for (var i = 0; i < bytes.Length; i++)
{
var keep = Math.Clamp(prefix - i * 8, 0, 8);
bytes[i] &= (byte)(0xFF << (8 - keep));
}
return new IPAddress(bytes);
}
/// <summary>L'adresse appartient-elle à la plage (réseau normalisé, préfixe) ?</summary>
public static bool Contains(string network, int prefix, IPAddress address)
{
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
if (!IPAddress.TryParse(network, out var net) || net.AddressFamily != address.AddressFamily) return false;
return Network(address, prefix).Equals(net);
}
/// <summary>Plage affichée : « 203.0.113.24 » (adresse seule) ou « 2001:db8:1:2::/64 ».</summary>
public static string Display(string network, int prefix) =>
(prefix == 32 && !network.Contains(':')) || prefix == 128 ? network : $"{network}/{prefix}";
/// <summary>Note interne facultative (jamais montrée au joueur) : une ligne, sans caractère invisible.</summary>
public static string? CleanNote(string? note, out string? problem)
{
problem = null;
if (string.IsNullOrWhiteSpace(note)) return null;
if (note.Contains('\n') || note.Contains('\r'))
{
problem = "une seule ligne.";
return null;
}
var clean = ApiText.CleanLine(note, MaxNoteLength);
if (clean is null) problem = $"{MaxNoteLength} caractères au plus, sans caractère invisible.";
return clean;
}
/// <summary>
/// Échéance d'un blocage (null : indéfiniment) : dans le futur, <see cref="MaxDays"/> jours au plus, et
/// <see cref="MaxIpDays"/> jours au plus s'il vise une adresse IP. Null si acceptée, sinon le problème.
/// </summary>
public static string? CheckUntil(DateTimeOffset? until, bool targetsIp, DateTimeOffset now)
{
if (until is null) return targetsIp ? $"une adresse IP ne se bloque pas indéfiniment : {MaxIpDays} jours au plus." : null;
if (until <= now) return "la date de fin doit être dans le futur.";
if (until > now.AddDays(targetsIp ? MaxIpDays + 1 : MaxDays + 1))
return targetsIp ? $"une adresse IP se bloque {MaxIpDays} jours au plus." : $"{MaxDays / 365} ans au plus (sinon : indéfiniment).";
return null;
}
}
// ------------------------------------------------------------------ Échanges (menu d'administration, players.ban)
/// <summary>Cible d'un blocage : adresse IP ou plage (en clair), ou identifiant matériel (empreinte du serveur, abrégée).</summary>
public sealed record PlayerBanTarget
{
/// <summary><see cref="PlayerBanTargetKinds.Ip"/>, <see cref="PlayerHardware.Machine"/> ou <see cref="PlayerHardware.Smbios"/>.</summary>
public string Kind { get; init; } = "";
/// <summary>« 203.0.113.24 », « 2001:db8:1:2::/64 », ou les 12 premiers caractères de l'empreinte matérielle.</summary>
public string Display { get; init; } = "";
}
/// <summary>Blocage en vigueur.</summary>
public sealed record PlayerBan
{
public string Reference { get; init; } = "";
public string Scope { get; init; } = PlayerBanScopes.Register;
public List<PlayerBanTarget> Targets { get; init; } = new();
public DateTimeOffset CreatedAt { get; init; }
public string CreatedBy { get; init; } = "";
/// <summary>Fin du blocage (null : indéfiniment).</summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>Compte depuis la fiche duquel le blocage a été décidé (référence), sinon null.</summary>
public string? AccountReference { get; init; }
/// <summary>Note interne (jamais montrée au joueur).</summary>
public string? Note { get; init; }
/// <summary>Tentatives refusées par ce blocage, et la dernière.</summary>
public int Hits { get; init; }
public DateTimeOffset? LastHitAt { get; init; }
}
/// <summary>Blocages en vigueur (GET v1/bans), les plus récents d'abord.</summary>
public sealed record PlayerBansPage
{
public List<PlayerBan> Items { get; init; } = new();
public bool Enabled { get; init; }
}
/// <summary>Blocage depuis la fiche d'un joueur (POST v1/players/{référence}/ban).</summary>
public sealed record PlayerBanRequest
{
public string Scope { get; init; } = PlayerBanScopes.Register;
/// <summary>Bloquer les identifiants matériels connus de ses PC.</summary>
public bool Hardware { get; init; } = true;
/// <summary>Bloquer sa dernière adresse IP connue (IPv6 : son /64).</summary>
public bool Ip { get; init; } = true;
public DateTimeOffset? Until { get; init; }
public string? Note { get; init; }
}
/// <summary>Blocage d'une adresse IP ou d'une plage seule (POST v1/bans).</summary>
public sealed record PlayerIpBanRequest
{
public string Ip { get; init; } = "";
public string Scope { get; init; } = PlayerBanScopes.Register;
public DateTimeOffset? Until { get; init; }
public string? Note { get; init; }
}
/// <summary>Identifiant matériel connu d'un compte (fiche) : type, empreinte abrégée, dates, bloqué ou non.</summary>
public sealed record PlayerAdminHardware
{
public string Kind { get; init; } = "";
public string Fingerprint { get; init; } = "";
public DateTimeOffset FirstSeenAt { get; init; }
public DateTimeOffset LastSeenAt { get; init; }
/// <summary>Référence du blocage en vigueur qui le vise, sinon null.</summary>
public string? BannedBy { get; init; }
}
@@ -0,0 +1,75 @@
namespace GameLauncher.Contracts.Accounts;
/// <summary>
/// Adresse e-mail d'un compte joueur : contrôle et forme normalisée (launcher et serveur). La forme normalisée sert
/// d'identifiant de connexion : une même adresse saisie en majuscules ou avec des espaces donne le même compte.
/// Règles volontairement simples et strictes : caractères latins sans accents (domaines internationaux sous leur forme
/// « xn-- »), un seul « @ », 254 caractères au plus.
/// </summary>
public static class PlayerEmail
{
public const int MaxLength = 254;
public const int MaxLocalLength = 64;
private const string LocalSymbols = "!#$%&'*+-/=?^_`{|}~.";
/// <summary>Adresse normalisée (minuscules, sans espaces autour), ou null si elle n'est pas valable.</summary>
public static string? Normalize(string? input)
{
if (string.IsNullOrWhiteSpace(input) || input.Length > MaxLength + 16) return null;
// Pas de normalisation Unicode (indisponible sur le serveur en globalisation invariante) : seuls les caractères
// ASCII sont acceptés, de la même façon par le launcher et par le serveur.
var text = input.Trim().ToLowerInvariant();
if (text.Length > MaxLength) return null;
var at = text.IndexOf('@');
if (at <= 0 || at != text.LastIndexOf('@') || at == text.Length - 1) return null;
var local = text[..at];
var domain = text[(at + 1)..];
if (domain.EndsWith('.')) domain = domain[..^1];
if (!IsValidLocal(local) || !IsValidDomain(domain)) return null;
var result = local + "@" + domain;
return result.Length <= MaxLength ? result : null;
}
public static bool IsValid(string? input) => Normalize(input) is not null;
/// <summary>
/// Adresse masquée pour l'affichage (« n***@e***.fr ») : première lettre de la partie locale et du domaine,
/// extension conservée. Chaîne vide si l'adresse n'est pas valable.
/// </summary>
public static string Mask(string? email)
{
if (Normalize(email) is not { } normalized) return "";
var at = normalized.IndexOf('@');
var local = normalized[..at];
var domain = normalized[(at + 1)..];
var lastDot = domain.LastIndexOf('.');
return $"{local[0]}***@{domain[0]}***{domain[lastDot..]}";
}
private static bool IsValidLocal(string local)
{
if (local.Length is 0 or > MaxLocalLength) return false;
if (local[0] == '.' || local[^1] == '.' || local.Contains("..", StringComparison.Ordinal)) return false;
foreach (var c in local)
if (!(c is >= 'a' and <= 'z' or >= '0' and <= '9' || LocalSymbols.Contains(c))) return false;
return true;
}
private static bool IsValidDomain(string domain)
{
if (domain.Length is 0 or > 253) return false;
var labels = domain.Split('.');
if (labels.Length < 2) return false;
foreach (var label in labels)
{
if (label.Length is 0 or > 63 || label[0] == '-' || label[^1] == '-') return false;
foreach (var c in label)
if (!(c is >= 'a' and <= 'z' or >= '0' and <= '9' or '-')) return false;
}
var tld = labels[^1];
return tld.StartsWith("xn--", StringComparison.Ordinal) || (tld.Length >= 2 && tld.All(c => c is >= 'a' and <= 'z'));
}
}
@@ -0,0 +1,121 @@
using System.Security.Cryptography;
using GameLauncher.Contracts.Security;
namespace GameLauncher.Contracts.Accounts;
/// <summary>
/// Messages signés par la CLÉ D'APPAREIL d'un joueur (ECDSA P-256 créée par le launcher, dans la puce TPM si elle existe).
/// En-tête propre aux joueurs (« GameLauncher/Player/… ») : une signature de joueur ne vaut jamais pour un message
/// d'administrateur (« GameLauncher/Admin/… »), et inversement.
///
/// La signature prouve que la requête vient de l'appareil qui possède la clé : un fichier de session copié sur un autre PC
/// ne permet pas de renouveler la session, et seul un appareil déjà utilisé avec succès est reconnu « de confiance » par
/// la protection contre la force brute.
/// </summary>
public static class PlayerMessages
{
public const string DeviceHeader = "GameLauncher/Player/Device/v1";
/// <summary>Message signé par la clé d'appareil pour une création de compte, une connexion ou un renouvellement.</summary>
/// <param name="purpose">Une valeur de <see cref="PlayerChallengePurposes"/>.</param>
/// <param name="deviceKeyId">Identifiant de la clé d'appareil (<see cref="AdminKeys.ComputeKeyId"/>).</param>
public static byte[] Device(string purpose, string origin, string serverId, string challengeId, string nonce, string deviceKeyId) =>
SignedMessages.Build(DeviceHeader,
("purpose", purpose), ("origin", origin), ("server", serverId), ("challenge", challengeId), ("nonce", nonce),
("device", deviceKeyId));
}
/// <summary>Usages d'un défi joueur (le défi est scellé pour un seul usage).</summary>
public static class PlayerChallengePurposes
{
public const string Register = "register";
public const string Login = "login";
public const string Refresh = "refresh";
public static bool IsValid(string? purpose) => purpose is Register or Login or Refresh;
}
/// <summary>
/// Formats des jetons joueurs. Préfixes distincts de tout jeton administrateur : le serveur rejette un jeton joueur
/// présenté à une route d'administration sans même le chercher, et inversement.
/// </summary>
public static class PlayerTokens
{
/// <summary>Jeton d'accès (15 min, en mémoire seulement côté launcher) : « glpa_ » + 256 bits.</summary>
public const string AccessPrefix = "glpa_";
/// <summary>Jeton de renouvellement (gardé chiffré sur le PC) : « glpr_ » + identifiant d'appareil + « . » + 256 bits.</summary>
public const string RefreshPrefix = "glpr_";
/// <summary>Jeton limité au changement de mot de passe exigé (10 min) : « glpc_ » + 256 bits.</summary>
public const string PasswordChangePrefix = "glpc_";
/// <summary>Jeton limité à la 2ᵉ étape de la connexion (code de vérification), 5 min, 5 essais : « glpo_ » + 256 bits.</summary>
public const string OtpPrefix = "glpo_";
public const int MaxLength = 128;
public static string NewSecret() => Base64Url.Encode(RandomNumberGenerator.GetBytes(32));
/// <summary>Jeton de renouvellement : identifiant de l'appareil (32 hex) et secret, ou faux si mal formé.</summary>
public static bool TryParseRefresh(string? token, out string deviceId, out string secret)
{
deviceId = secret = "";
if (token is not { Length: > 40 and <= MaxLength } || !token.StartsWith(RefreshPrefix, StringComparison.Ordinal)) return false;
var body = token[RefreshPrefix.Length..];
var dot = body.IndexOf('.');
if (dot != 32) return false;
deviceId = body[..dot];
secret = body[(dot + 1)..];
return PlayerIds.IsValidId(deviceId) && Base64Url.TryDecode(secret, out var bytes) && bytes.Length == 32;
}
public static string FormatRefresh(string deviceId, string secret) => RefreshPrefix + deviceId + "." + secret;
}
/// <summary>Identifiants des comptes joueurs.</summary>
public static class PlayerIds
{
private const string Crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/// <summary>Identifiant interne (compte, appareil) : 128 bits en hexadécimal minuscule.</summary>
public static string NewId() => Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
public static bool IsValidId(string? id) => id is { Length: 32 } && id.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f');
/// <summary>
/// Référence affichée aux administrateurs et au joueur (« PL-7KQ2-M9XD », 40 bits, sans lettre ambiguë) : désigne un
/// compte dans le journal d'audit sans jamais y écrire l'adresse e-mail.
/// </summary>
public static string NewReference()
{
var bytes = RandomNumberGenerator.GetBytes(5);
ulong value = 0;
foreach (var b in bytes) value = (value << 8) | b;
var chars = new char[8];
for (var i = 7; i >= 0; i--)
{
chars[i] = Crockford[(int)(value & 31)];
value >>= 5;
}
return $"PL-{new string(chars, 0, 4)}-{new string(chars, 4, 4)}";
}
/// <summary>Référence normalisée (majuscules, tirets), ou null si elle n'est pas valable.</summary>
public static string? NormalizeReference(string? reference) => Normalize(reference, "PL-");
/// <summary>Référence d'un blocage (« BN-7KQ2-M9XD »), donnée au joueur bloqué pour qu'il puisse la citer au support.</summary>
public static string NewBanReference() => "BN-" + NewReference()[3..];
public static string? NormalizeBanReference(string? reference) => Normalize(reference, "BN-");
private static string? Normalize(string? reference, string prefix)
{
if (string.IsNullOrWhiteSpace(reference) || reference.Length > 20) return null;
var text = reference.Trim().ToUpperInvariant();
if (text.Length != 12 || !text.StartsWith(prefix, StringComparison.Ordinal) || text[7] != '-') return null;
foreach (var c in text[3..7] + text[8..])
if (!Crockford.Contains(c)) return null;
return text;
}
}
@@ -0,0 +1,145 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace GameLauncher.Contracts.Accounts;
/// <summary>
/// Double authentification des comptes joueurs (étape 8c) : codes à usage unique TOTP (RFC 6238 : HMAC-SHA1, 6 chiffres,
/// 30 secondes, compatibles avec toutes les applications d'authentification : Microsoft Authenticator, Google Authenticator,
/// Aegis, 2FAS, Bitwarden…) et codes de secours (« 7KQ2-M9XD », 40 bits, usage unique).
/// Le secret ne quitte le serveur qu'une fois, à l'activation (QR code / clé à saisir) ; il est ensuite chiffré en base.
/// </summary>
public static class PlayerOtp
{
public const int Digits = 6;
public const int PeriodSeconds = 30;
/// <summary>Taille du secret (160 bits, RFC 4226 § 4).</summary>
public const int SecretBytes = 20;
/// <summary>Pas de temps acceptés de part et d'autre de l'heure du serveur (± 30 s : horloge du téléphone décalée).</summary>
public const int Window = 1;
public const int RecoveryCodeCount = 10;
/// <summary>Durée de la configuration en cours (QR code affiché, code pas encore confirmé).</summary>
public static readonly TimeSpan SetupLifetime = TimeSpan.FromMinutes(10);
private const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
private const string Crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/// <summary>Pas de temps TOTP à l'instant donné.</summary>
public static long Step(DateTimeOffset time) => time.ToUnixTimeSeconds() / PeriodSeconds;
/// <summary>Code HOTP (RFC 4226) du pas <paramref name="step"/>, sur <see cref="Digits"/> chiffres.</summary>
public static string Code(ReadOnlySpan<byte> secret, long step, int digits = Digits)
{
Span<byte> counter = stackalloc byte[8];
BinaryPrimitives.WriteInt64BigEndian(counter, step);
Span<byte> hash = stackalloc byte[20];
HMACSHA1.HashData(secret, counter, hash);
var offset = hash[19] & 0x0F;
var binary = ((hash[offset] & 0x7F) << 24) | (hash[offset + 1] << 16) | (hash[offset + 2] << 8) | hash[offset + 3];
var modulo = 1;
for (var i = 0; i < digits; i++) modulo *= 10;
return (binary % modulo).ToString(System.Globalization.CultureInfo.InvariantCulture).PadLeft(digits, '0');
}
/// <summary>Code à 6 chiffres saisi (espaces et tirets ignorés), ou null s'il n'en est pas un.</summary>
public static string? NormalizeCode(string? input)
{
if (string.IsNullOrWhiteSpace(input) || input.Length > 20) return null;
var digits = new string(input.Where(c => c is not (' ' or '-' or ' ')).ToArray());
return digits.Length == Digits && digits.All(char.IsAsciiDigit) ? digits : null;
}
/// <summary>
/// Code de secours saisi, normalisé (« 7KQ2M9XD ») : majuscules, tirets et espaces ignorés, lettres ambiguës corrigées
/// (O → 0, I et L → 1) ; null s'il n'en est pas un.
/// </summary>
public static string? NormalizeRecoveryCode(string? input)
{
if (string.IsNullOrWhiteSpace(input) || input.Length > 24) return null;
var builder = new StringBuilder(8);
foreach (var raw in input.ToUpperInvariant())
{
if (raw is ' ' or '-' or ' ') continue;
var c = raw switch { 'O' => '0', 'I' or 'L' => '1', _ => raw };
if (!Crockford.Contains(c)) return null;
builder.Append(c);
}
return builder.Length == 8 ? builder.ToString() : null;
}
/// <summary>Nouveau code de secours (40 bits aléatoires), affiché « 7KQ2-M9XD ».</summary>
public static string NewRecoveryCode()
{
var bytes = RandomNumberGenerator.GetBytes(5);
ulong value = 0;
foreach (var b in bytes) value = (value << 8) | b;
var chars = new char[8];
for (var i = 7; i >= 0; i--)
{
chars[i] = Crockford[(int)(value & 31)];
value >>= 5;
}
return $"{new string(chars, 0, 4)}-{new string(chars, 4, 4)}";
}
/// <summary>Base32 (RFC 4648, majuscules, sans « = ») : format des secrets dans les applications d'authentification.</summary>
public static string Base32Encode(ReadOnlySpan<byte> data)
{
var builder = new StringBuilder((data.Length * 8 + 4) / 5);
int buffer = 0, bits = 0;
foreach (var b in data)
{
buffer = (buffer << 8) | b;
bits += 8;
while (bits >= 5)
{
builder.Append(Base32Alphabet[(buffer >> (bits - 5)) & 31]);
bits -= 5;
}
}
if (bits > 0) builder.Append(Base32Alphabet[(buffer << (5 - bits)) & 31]);
return builder.ToString();
}
public static bool TryBase32Decode(string? text, out byte[] data)
{
data = Array.Empty<byte>();
if (string.IsNullOrEmpty(text) || text.Length > 256) return false;
var clean = text.Replace(" ", "", StringComparison.Ordinal).TrimEnd('=').ToUpperInvariant();
var output = new List<byte>(clean.Length * 5 / 8);
int buffer = 0, bits = 0;
foreach (var c in clean)
{
var value = Base32Alphabet.IndexOf(c);
if (value < 0) return false;
buffer = (buffer << 5) | value;
bits += 5;
if (bits >= 8)
{
output.Add((byte)((buffer >> (bits - 8)) & 0xFF));
bits -= 8;
}
}
data = output.ToArray();
return true;
}
/// <summary>Clé à saisir à la main, par groupes de 4 (« JBSW Y3DP EHPK 3PXP »).</summary>
public static string FormatSecret(string base32) =>
string.Join(' ', Enumerable.Range(0, (base32.Length + 3) / 4).Select(i => base32.Substring(i * 4, Math.Min(4, base32.Length - i * 4))));
/// <summary>
/// Adresse « otpauth://totp/… » lue par les applications d'authentification (format « Key Uri » de Google, repris par
/// toutes) : émetteur et nom du compte encodés, paramètres explicites.
/// </summary>
public static string AuthUri(string issuer, string accountName, string secretBase32)
{
var label = Uri.EscapeDataString(issuer) + ":" + Uri.EscapeDataString(accountName);
return $"otpauth://totp/{label}?secret={secretBase32}&issuer={Uri.EscapeDataString(issuer)}&algorithm=SHA1&digits={Digits}&period={PeriodSeconds}";
}
}
@@ -0,0 +1,19 @@
password-blocklist.txt.gz — dictionnaire des mots de passe les plus utilisés
=========================================================================
Contenu : les 99 839 mots de passe de la liste « 100k-most-used-passwords-NCSC.txt » (un par ligne, UTF-8, doublons
retirés, ordre d'origine conservé), compressés en gzip.
Origine :
• Liste publiée par le NCSC (National Cyber Security Centre, Royaume-Uni) : les 100 000 mots de passe les plus
fréquents dans les fuites recensées par le service « Have I Been Pwned » (Pwned Passwords).
• Copie utilisée : dépôt SecLists (https://github.com/danielmiessler/SecLists),
fichier Passwords/Common-Credentials/100k-most-used-passwords-NCSC.txt — licence MIT,
© 2018 Daniel Miessler.
Usage : GameLauncher.Contracts.Accounts.PasswordRules (même contrôle dans le launcher et sur le serveur). La liste est
normalisée au chargement (minuscules, substitutions courantes 0→o, 4→a, @→a…, chiffres et symboles de début et de fin
retirés) : les variantes simples (« P@ssw0rd2024! ») sont refusées comme l'original.
Mise à jour : remplacer le fichier .gz par une version plus récente de la même liste (même format), puis relancer les
tests (Contracts/PasswordRulesTests).
+108
View File
@@ -147,6 +147,114 @@ 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";
/// <summary>Connexion, 2ᵉ étape : code de l'application d'authentification ou code de secours (jeton « glpo_ »).</summary>
public const string AccountLoginOtp = "v1/account/login/otp";
/// <summary>Double authentification : préparation (mot de passe exigé ; secret et adresse du QR code renvoyés une fois).</summary>
public const string AccountOtpSetup = "v1/account/otp/setup";
/// <summary>Double authentification : activation (premier code de l'application) ; renvoie les codes de secours.</summary>
public const string AccountOtpEnable = "v1/account/otp/enable";
/// <summary>Double authentification : désactivation (mot de passe + code exigés).</summary>
public const string AccountOtpDisable = "v1/account/otp/disable";
/// <summary>Nouveaux codes de secours (mot de passe + code exigés) ; les anciens ne valent plus rien.</summary>
public const string AccountOtpRecoveryCodes = "v1/account/otp/recovery-codes";
// ------------------------------------------------------------------ Comptes joueurs : administration (étape 8d)
// Routes d'ADMINISTRATION (jetons et requêtes signées des administrateurs, permissions players.*) : aucun jeton joueur
// n'y est accepté. Un compte y est désigné par sa référence (« PL-7KQ2-M9XD » : rien à encoder).
/// <summary>Compteurs et réglages du service des comptes (players.read).</summary>
public const string PlayersOverview = "v1/players/overview";
/// <summary>Liste des comptes, les plus récents d'abord (players.read).</summary>
public const string Players = "v1/players";
/// <summary>
/// Recherche par référence ou par adresse e-mail EXACTE (POST : l'adresse ne doit jamais apparaître dans une adresse de
/// page, ni donc dans les journaux du serveur web) ; players.read.
/// </summary>
public const string PlayersSearch = "v1/players/search";
/// <summary>Fiche d'un compte (GET, players.read) ; suppression définitive (DELETE signé, players.delete).</summary>
public const string PlayerPattern = "v1/players/{reference}";
public const string PlayerDisablePattern = "v1/players/{reference}/disable";
public const string PlayerEnablePattern = "v1/players/{reference}/enable";
public const string PlayerUnlockPattern = "v1/players/{reference}/unlock";
public const string PlayerPasswordRequirementPattern = "v1/players/{reference}/password-requirement";
public const string PlayerTemporaryPasswordPattern = "v1/players/{reference}/temporary-password";
public const string PlayerOtpResetPattern = "v1/players/{reference}/otp-reset";
public const string PlayerLogoutPattern = "v1/players/{reference}/logout";
/// <summary>Blocage depuis la fiche d'un joueur : ses identifiants matériels et/ou sa dernière adresse IP (players.ban).</summary>
public const string PlayerBanPattern = "v1/players/{reference}/ban";
// Blocages (étape 8e, players.ban) : liste (GET), blocage d'une adresse IP seule (POST signé), levée (DELETE signé).
public const string Bans = "v1/bans";
public const string BanPattern = "v1/bans/{reference}";
/// <summary>Un blocage (référence « BN-XXXX-XXXX » : rien à encoder).</summary>
public static string Ban(string reference) => $"v1/bans/{reference}";
/// <summary>Liste filtrée (<see cref="Accounts.PlayerAdminRules.Filters"/>) ; curseur encodé.</summary>
public static string PlayersPage(string? filter = null, string? cursor = null, int limit = 100)
{
var route = $"{Players}?limit={limit}";
if (!string.IsNullOrEmpty(filter)) route += $"&filter={Uri.EscapeDataString(filter)}";
if (!string.IsNullOrEmpty(cursor)) route += $"&cursor={Uri.EscapeDataString(cursor)}";
return route;
}
public static string Player(string reference) => $"v1/players/{reference}";
/// <summary>Action sur un compte (requête signée, players.manage) : « disable », « enable », « unlock »…</summary>
public static string PlayerAction(string reference, string action) => $"v1/players/{reference}/{action}";
// ------------------------------------------------------------------ Publication d'une version
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
public const string PublicationPattern = "v1/publications/{id}";
@@ -15,6 +15,9 @@ public sealed record ServerInfo
/// <summary>Algorithmes de clé acceptés (« ECDSA-P256-SHA256 »).</summary>
public IReadOnlyList<string> KeyAlgorithms { get; init; } = Array.Empty<string>();
/// <summary>Comptes joueurs (phase 8) : null si le serveur ne les propose pas (ou serveur plus ancien).</summary>
public Accounts.PlayerAccountsInfo? Accounts { get; init; }
}
public sealed record HealthStatus
@@ -77,4 +80,55 @@ 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>
/// Création de compte ou connexion refusée : l'appareil (identifiants matériels) ou la connexion Internet (adresse IP)
/// fait l'objet d'un blocage décidé par l'équipe (étape 8e). Le message donne la référence du blocage.
/// </summary>
public const string PlayerBlocked = "player_blocked";
/// <summary>Session mémorisée révoquée, expirée ou réutilisée (vol suspecté) : nouvelle connexion nécessaire.</summary>
public const string SessionRevoked = "session_revoked";
/// <summary>Création de compte fermée, ou bloquée pour cette adresse IP / cet appareil.</summary>
public const string RegistrationClosed = "registration_closed";
/// <summary>Conditions d'utilisation non acceptées ou dans une version dépassée.</summary>
public const string TermsNotAccepted = "terms_not_accepted";
/// <summary>Preuve de possession de la clé d'appareil absente ou invalide.</summary>
public const string DeviceProofInvalid = "device_proof_invalid";
/// <summary>Code de vérification (application d'authentification ou code de secours) incorrect ou déjà utilisé.</summary>
public const string OtpInvalid = "otp_invalid";
/// <summary>Le code de vérification doit d'abord être saisi (jeton « glpo_ » présenté à une autre route).</summary>
public const string OtpRequired = "otp_required";
}
@@ -13,4 +13,9 @@
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<!-- Comptes joueurs : dictionnaire des mots de passe les plus utilisés (PasswordRules), voir Accounts\Resources\password-blocklist.SOURCE.txt. -->
<ItemGroup>
<EmbeddedResource Include="Accounts\Resources\password-blocklist.txt.gz" LogicalName="GameLauncher.Contracts.Accounts.password-blocklist.txt.gz" />
</ItemGroup>
</Project>
@@ -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] + "…";
}
}
@@ -84,7 +84,8 @@ public static partial class AdminGroups
public static string? DefaultDescription(string groupId) => groupId switch
{
AdminRoles.Owner => "Tous les droits, y compris ceux des permissions créées plus tard. Groupe système : non modifiable.",
AdminRoles.Admin => "Jeux, notes de version, publication des versions, fichiers du serveur, demandes des joueurs et journal d'audit.",
AdminRoles.Admin => "Jeux, notes de version, publication des versions, fichiers du serveur, demandes et comptes des joueurs (sans " +
"suppression), mode maintenance et journal d'audit.",
AdminRoles.Editor => "Informations des jeux et notes de version.",
_ => null
};
@@ -37,9 +37,31 @@ 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";
/// <summary>Comptes joueurs : liste, recherche et fiche (adresse masquée, appareils, activité avec adresses IP) — phase 8d.</summary>
public const string PlayersRead = "players.read";
/// <summary>
/// Comptes joueurs : désactiver / réactiver, lever une suspension ou un blocage, exiger un nouveau mot de passe, mot de
/// passe temporaire, réinitialiser la double authentification, déconnecter des appareils (requêtes signées).
/// </summary>
public const string PlayersManage = "players.manage";
/// <summary>Comptes joueurs : suppression définitive d'un compte (requête signée).</summary>
public const string PlayersDelete = "players.delete";
/// <summary>
/// Blocages des joueurs (étape 8e) : bloquer les identifiants matériels et l'adresse IP d'un joueur, ou une adresse IP
/// seule, pour la création de compte ou aussi la connexion ; consulter et lever les blocages (requêtes signées).
/// </summary>
public const string PlayersBan = "players.ban";
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, PlayersRead, PlayersManage, PlayersDelete, PlayersBan
};
/// <summary>Permission intégrée (vérifiée par le code : jamais supprimable).</summary>
@@ -57,6 +79,11 @@ public static class AdminPermissions
FeedbackRead => "Demandes des joueurs (lecture)",
FeedbackManage => "Demandes des joueurs (traitement)",
FeedbackForms => "Formulaires des demandes",
MaintenanceManage => "Mode maintenance",
PlayersRead => "Comptes joueurs (lecture)",
PlayersManage => "Comptes joueurs (gestion)",
PlayersDelete => "Comptes joueurs (suppression)",
PlayersBan => "Blocages des joueurs",
_ => null
};
@@ -76,6 +103,17 @@ 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.",
PlayersRead => "Rechercher et consulter les comptes des joueurs : adresse masquée, état, appareils et activité avec les " +
"adresses IP (données personnelles). Chaque consultation est inscrite au journal d'audit.",
PlayersManage => "Désactiver ou réactiver un compte joueur, lever une suspension ou un blocage, exiger un nouveau mot de " +
"passe, créer un mot de passe temporaire, réinitialiser la double authentification, déconnecter des " +
"appareils (à donner avec la lecture).",
PlayersDelete => "Supprimer définitivement un compte joueur et toutes ses données (irréversible ; à donner avec la lecture).",
PlayersBan => "Bloquer la création de compte (et, au choix, la connexion) depuis les PC d'un joueur (identifiants matériels) " +
"et son adresse IP, ou depuis une adresse IP seule ; consulter et lever les blocages (adresses IP visibles).",
_ => null
};
}
@@ -27,7 +27,9 @@ 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, AdminPermissions.PlayersRead, AdminPermissions.PlayersManage,
AdminPermissions.PlayersBan
},
[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,433 @@
using System.Collections.ObjectModel;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Core.Utils;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Accounts;
/// <summary>Étapes de la carte « Double authentification » de « Mon compte › Profil ».</summary>
public enum TwoFactorStage
{
Idle,
/// <summary>Activation : mot de passe demandé.</summary>
Password,
/// <summary>Activation : QR code (ou clé à saisir) puis premier code de l'application.</summary>
Scan,
/// <summary>Codes de secours affichés (une seule fois) : à noter avant de terminer.</summary>
Codes,
/// <summary>Désactivation : mot de passe et code.</summary>
Disable,
/// <summary>Nouveaux codes de secours : mot de passe et code.</summary>
Regenerate
}
/// <summary>
/// Double authentification dans la fenêtre « Compte » (étape 8c) : 2ᵉ étape de la connexion (code de l'application ou code
/// de secours), puis activation (QR code calculé sur ce PC par <see cref="QrCode"/>), codes de secours, désactivation.
/// Le secret n'est gardé qu'en mémoire pendant l'activation, puis oublié ; les codes de secours aussi, une fois notés.
/// </summary>
public sealed partial class AccountWindowViewModel
{
private string? _otpToken;
private string? _otpPassword;
private DateTime _otpStarted;
private string _otpCode = "";
private TwoFactorStage _twoFactorStage;
private string? _qrPathData;
private string _otpSecret = "";
private string _otpAccountText = "";
private string _setupCode = "";
private string _confirmCode = "";
private bool _codesSaved;
public ObservableCollection<string> RecoveryCodes { get; } = new();
public RelayCommand StartOtpSetupCommand { get; private set; } = null!;
public RelayCommand StartOtpDisableCommand { get; private set; } = null!;
public RelayCommand StartRegenerateCodesCommand { get; private set; } = null!;
public RelayCommand CancelTwoFactorCommand { get; private set; } = null!;
public RelayCommand CopySecretCommand { get; private set; } = null!;
public RelayCommand CopyRecoveryCodesCommand { get; private set; } = null!;
public RelayCommand SaveRecoveryCodesCommand { get; private set; } = null!;
public RelayCommand FinishRecoveryCodesCommand { get; private set; } = null!;
private void InitializeTwoFactor()
{
StartOtpSetupCommand = new RelayCommand(() => StartTwoFactor(TwoFactorStage.Password), () => !IsBusy && !OtpEnabled);
StartOtpDisableCommand = new RelayCommand(() => StartTwoFactor(TwoFactorStage.Disable), () => !IsBusy && OtpEnabled);
StartRegenerateCodesCommand = new RelayCommand(() => StartTwoFactor(TwoFactorStage.Regenerate), () => !IsBusy && OtpEnabled);
CancelTwoFactorCommand = new RelayCommand(() => TwoFactorStage = TwoFactorStage.Idle, () => !IsBusy);
CopySecretCommand = new RelayCommand(() =>
{
_dialogs.CopyToClipboard(_otpSecret);
ClearMessages();
SuccessMessage = "Clé copiée : collez-la dans votre application d'authentification.";
}, () => _otpSecret.Length > 0);
CopyRecoveryCodesCommand = new RelayCommand(() =>
{
_dialogs.CopyToClipboard(RecoveryCodesText);
ClearMessages();
SuccessMessage = "Codes de secours copiés. Rangez-les en lieu sûr (gestionnaire de mots de passe), puis videz le presse-papiers.";
}, () => RecoveryCodes.Count > 0);
SaveRecoveryCodesCommand = new RelayCommand(SaveRecoveryCodes, () => RecoveryCodes.Count > 0);
FinishRecoveryCodesCommand = new RelayCommand(() =>
{
TwoFactorStage = TwoFactorStage.Idle;
ClearMessages();
SuccessMessage = OtpEnabled ? "Double authentification active : un code sera demandé à chaque connexion avec le mot de passe." : null;
}, () => CodesSaved);
}
// ------------------------------------------------------------------ Connexion, 2ᵉ étape
/// <summary>Code saisi (6 chiffres de l'application, ou code de secours « 7KQ2-M9XD »).</summary>
public string OtpCode
{
get => _otpCode;
set => SetProperty(ref _otpCode, value ?? "");
}
public int OtpCodeMaxLength => 24;
public string OtpHelpText =>
"Ouvrez votre application d'authentification et saisissez le code à 6 chiffres affiché pour " +
$"{_publisher}. Téléphone indisponible ? Saisissez un de vos codes de secours (ex. 7KQ2-M9XD).";
/// <summary>Mot de passe correct, code demandé : jeton de la 2ᵉ étape (5 minutes) et mot de passe gardés en mémoire.</summary>
private void BeginOtpStep(string otpToken, string password)
{
GoTo(AccountStep.Otp);
_otpToken = otpToken;
_otpPassword = password; // redemandé par le serveur si un nouveau mot de passe est exigé ensuite ; oublié après
_otpStarted = DateTime.UtcNow;
OtpCode = "";
}
public async Task VerifyOtpAsync()
{
if (IsBusy) return;
ClearMessages();
if (_otpToken is null || DateTime.UtcNow - _otpStarted > TimeSpan.FromMinutes(4.5))
{
GoTo(AccountStep.SignIn);
ErrorMessage = "Délai dépassé : reconnectez-vous.";
return;
}
if (PlayerOtp.NormalizeCode(OtpCode) is null && PlayerOtp.NormalizeRecoveryCode(OtpCode) is null)
{
ErrorMessage = "Saisissez le code à 6 chiffres de votre application, ou un code de secours (ex. 7KQ2-M9XD).";
return;
}
await RunAsync("Vérification du code…", async () =>
{
PlayerSignInResult result;
try
{
result = await _service.VerifyOtpAsync(_otpToken, OtpCode, RememberMe, _lifetime.Token);
}
catch (ApiException ex) when (ex.Status is 401 or 429 || ex.Code is ApiErrorCodes.AccountDisabled or ApiErrorCodes.AccountSuspended)
{
// Trop d'essais, connexion bloquée, ou compte désactivé : tout est à reprendre depuis le mot de passe.
GoTo(AccountStep.SignIn);
ErrorMessage = PlayerAccountTexts.Describe(ex);
return;
}
OtpCode = "";
if (result.PasswordChangeRequired)
{
BeginRequiredChange(result.PasswordChangeToken, _otpPassword, result.TemporaryPassword);
return;
}
await EnterAccountAsync();
if (result.RecoveryCodesLeft is { } left)
SuccessMessage = left switch
{
0 => "Connecté avec votre DERNIER code de secours : créez-en de nouveaux dès maintenant (Profil › Double authentification).",
<= 3 => $"Connecté avec un code de secours : il vous en reste {left}. Pensez à en créer de nouveaux.",
_ => $"Connecté avec un code de secours : il vous en reste {left}."
};
});
}
private void ForgetOtp()
{
_otpToken = null;
_otpPassword = null;
_otpCode = "";
}
// ------------------------------------------------------------------ Mon compte : double authentification
public bool OtpEnabled => _account?.OtpEnabled == true;
public int RecoveryCodesLeft => _account?.RecoveryCodesLeft ?? 0;
public bool OtpDisabled => !OtpEnabled;
public string TwoFactorStatusText => OtpEnabled
? $"Activée · {RecoveryCodesLeft} code{(RecoveryCodesLeft > 1 ? "s" : "")} de secours restant{(RecoveryCodesLeft > 1 ? "s" : "")}"
: "Désactivée : seul le mot de passe protège votre compte.";
/// <summary>Peu de codes de secours restants : à renouveler.</summary>
public bool ShowRecoveryWarning => OtpEnabled && RecoveryCodesLeft <= 3;
public string RecoveryWarningText => RecoveryCodesLeft == 0
? "Vous n'avez plus de code de secours : sans votre téléphone, vous ne pourriez plus vous connecter. Créez-en de nouveaux."
: $"Plus que {RecoveryCodesLeft} code{(RecoveryCodesLeft > 1 ? "s" : "")} de secours : créez-en de nouveaux.";
public TwoFactorStage TwoFactorStage
{
get => _twoFactorStage;
private set
{
if (!SetProperty(ref _twoFactorStage, value)) return;
foreach (var name in new[]
{
nameof(IsTwoFactorIdle), nameof(IsTwoFactorPassword), nameof(IsTwoFactorScan), nameof(IsTwoFactorCodes),
nameof(IsTwoFactorConfirm), nameof(TwoFactorConfirmTitle), nameof(TwoFactorConfirmText), nameof(TwoFactorConfirmButton),
nameof(IsTwoFactorDisable)
})
OnPropertyChanged(name);
if (value != TwoFactorStage.Scan) ForgetSetup();
if (value != TwoFactorStage.Codes) ForgetRecoveryCodes();
if (value != TwoFactorStage.Idle) ConfirmCode = "";
ClearPasswordsRequested?.Invoke();
}
}
public bool IsTwoFactorIdle => TwoFactorStage == TwoFactorStage.Idle;
public bool IsTwoFactorPassword => TwoFactorStage == TwoFactorStage.Password;
public bool IsTwoFactorScan => TwoFactorStage == TwoFactorStage.Scan;
public bool IsTwoFactorCodes => TwoFactorStage == TwoFactorStage.Codes;
public bool IsTwoFactorConfirm => TwoFactorStage is TwoFactorStage.Disable or TwoFactorStage.Regenerate;
public bool IsTwoFactorDisable => TwoFactorStage == TwoFactorStage.Disable;
public string TwoFactorConfirmTitle => TwoFactorStage == TwoFactorStage.Disable
? "Désactiver la double authentification"
: "Créer de nouveaux codes de secours";
public string TwoFactorConfirmText => TwoFactorStage == TwoFactorStage.Disable
? "Votre compte ne sera plus protégé que par le mot de passe. Vos codes de secours seront supprimés."
: "Vos anciens codes de secours ne fonctionneront plus.";
public string TwoFactorConfirmButton => TwoFactorStage == TwoFactorStage.Disable ? "Désactiver" : "Créer les codes";
/// <summary>QR code de l'adresse « otpauth:// » (tracé vectoriel, voir <see cref="QrCode.ToPathData"/>), pendant l'activation.</summary>
public string? QrPathData
{
get => _qrPathData;
private set => SetProperty(ref _qrPathData, value);
}
/// <summary>Clé à saisir à la main, par groupes de 4.</summary>
public string OtpSecretText => PlayerOtp.FormatSecret(_otpSecret);
/// <summary>Nom affiché dans l'application (« LanisysGames · j***@e***.fr »).</summary>
public string OtpAccountText
{
get => _otpAccountText;
private set => SetProperty(ref _otpAccountText, value);
}
public string SetupHelpText =>
"1. Installez une application d'authentification sur votre téléphone (Microsoft Authenticator, Google Authenticator, " +
"Aegis, 2FAS…). 2. Scannez ce QR code, ou saisissez la clé. 3. Tapez le code à 6 chiffres affiché.";
public string SetupCode
{
get => _setupCode;
set => SetProperty(ref _setupCode, value ?? "");
}
/// <summary>Code demandé pour désactiver ou créer de nouveaux codes de secours (application ou code de secours).</summary>
public string ConfirmCode
{
get => _confirmCode;
set => SetProperty(ref _confirmCode, value ?? "");
}
public string RecoveryCodesHelpText =>
"Chaque code ne sert qu'une fois, si votre téléphone est perdu ou indisponible. Ils ne seront plus jamais affichés : " +
"enregistrez-les ou copiez-les dans un gestionnaire de mots de passe.";
public string RecoveryCodesText => string.Join(Environment.NewLine, RecoveryCodes);
public bool CodesSaved
{
get => _codesSaved;
set
{
if (SetProperty(ref _codesSaved, value)) FinishRecoveryCodesCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Activation, 1ʳᵉ étape : mot de passe vérifié par le serveur, qui renvoie le secret (QR code calculé ici).</summary>
public async Task BeginOtpSetupAsync(string password)
{
if (IsBusy) return;
ClearMessages();
if (string.IsNullOrEmpty(password))
{
ErrorMessage = "Saisissez votre mot de passe.";
return;
}
await RunAsync("Préparation de la double authentification…", async () =>
{
var setup = await _service.BeginOtpSetupAsync(password, _lifetime.Token);
_otpSecret = setup.Secret;
OtpAccountText = $"{setup.Issuer} · {setup.AccountName}";
QrPathData = QrCode.Encode(setup.Uri).ToPathData();
OnPropertyChanged(nameof(OtpSecretText));
CopySecretCommand.RaiseCanExecuteChanged();
SetupCode = "";
TwoFactorStage = TwoFactorStage.Scan;
});
}
/// <summary>Activation, 2ᵉ étape : premier code de l'application ; les codes de secours s'affichent.</summary>
public async Task ConfirmOtpSetupAsync()
{
if (IsBusy) return;
ClearMessages();
if (PlayerOtp.NormalizeCode(SetupCode) is null)
{
ErrorMessage = "Saisissez le code à 6 chiffres affiché par l'application.";
return;
}
await RunAsync("Activation…", async () =>
{
var codes = await _service.EnableOtpAsync(SetupCode, _lifetime.Token);
await LoadMeAsync();
ShowRecoveryCodes(codes);
SuccessMessage = "Double authentification activée. Notez maintenant vos codes de secours.";
});
}
/// <summary>Désactivation, ou nouveaux codes de secours : mot de passe (vue) et code saisi.</summary>
public async Task ConfirmTwoFactorAsync(string password)
{
if (IsBusy || !IsTwoFactorConfirm) return;
ClearMessages();
if (string.IsNullOrEmpty(password))
{
ErrorMessage = "Saisissez votre mot de passe.";
return;
}
if (PlayerOtp.NormalizeCode(ConfirmCode) is null && PlayerOtp.NormalizeRecoveryCode(ConfirmCode) is null)
{
ErrorMessage = "Saisissez le code de votre application (ou un code de secours).";
return;
}
var disable = TwoFactorStage == TwoFactorStage.Disable;
if (disable && !_dialogs.Confirm("Désactiver la double authentification ?",
"Votre compte ne sera plus protégé que par le mot de passe.", "Désactiver", "Annuler", danger: true))
return;
await RunAsync(disable ? "Désactivation…" : "Création des codes…", async () =>
{
if (disable)
{
await _service.DisableOtpAsync(password, ConfirmCode, _lifetime.Token);
TwoFactorStage = TwoFactorStage.Idle;
await LoadMeAsync();
SuccessMessage = "Double authentification désactivée.";
return;
}
var codes = await _service.RegenerateRecoveryCodesAsync(password, ConfirmCode, _lifetime.Token);
await LoadMeAsync();
ShowRecoveryCodes(codes);
SuccessMessage = "Nouveaux codes de secours créés : les anciens ne fonctionnent plus.";
});
}
private void StartTwoFactor(TwoFactorStage stage)
{
ClearMessages();
IsChangingPassword = false;
TwoFactorStage = stage;
}
private void ShowRecoveryCodes(IReadOnlyList<string> codes)
{
TwoFactorStage = TwoFactorStage.Codes;
foreach (var code in codes) RecoveryCodes.Add(code);
CodesSaved = false;
OnPropertyChanged(nameof(RecoveryCodesText));
CopyRecoveryCodesCommand.RaiseCanExecuteChanged();
SaveRecoveryCodesCommand.RaiseCanExecuteChanged();
}
private void SaveRecoveryCodes()
{
var path = _dialogs.PickSaveFile("Enregistrer les codes de secours", "Fichier texte (*.txt)|*.txt",
$"codes-de-secours-{_publisher}-{Reference}.txt", Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments));
if (path is null) return;
try
{
File.WriteAllText(path,
$"Codes de secours du compte {_publisher} {EmailMasked} ({Reference}){Environment.NewLine}" +
$"Créés le {DateTimeOffset.Now:dd/MM/yyyy à HH:mm}. Chaque code ne sert qu'une fois.{Environment.NewLine}{Environment.NewLine}" +
RecoveryCodesText + Environment.NewLine);
ClearMessages();
SuccessMessage = $"Codes enregistrés dans {Path.GetFileName(path)}. Gardez ce fichier en lieu sûr (pas sur un PC partagé).";
CodesSaved = true;
Log.Info("Compte : codes de secours enregistrés dans un fichier choisi par le joueur.");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
ErrorMessage = PlayerAccountTexts.Describe(ex);
}
}
private void ForgetSetup()
{
_otpSecret = "";
_setupCode = "";
QrPathData = null;
OtpAccountText = "";
OnPropertyChanged(nameof(OtpSecretText));
OnPropertyChanged(nameof(SetupCode));
CopySecretCommand?.RaiseCanExecuteChanged();
}
private void ForgetRecoveryCodes()
{
if (RecoveryCodes.Count == 0) return;
RecoveryCodes.Clear();
CodesSaved = false;
OnPropertyChanged(nameof(RecoveryCodesText));
}
private void ResetTwoFactor()
{
TwoFactorStage = TwoFactorStage.Idle;
ForgetSetup();
ForgetRecoveryCodes();
}
private void RaiseTwoFactorCommands()
{
StartOtpSetupCommand?.RaiseCanExecuteChanged();
StartOtpDisableCommand?.RaiseCanExecuteChanged();
StartRegenerateCodesCommand?.RaiseCanExecuteChanged();
CancelTwoFactorCommand?.RaiseCanExecuteChanged();
}
/// <summary>Compte relu : état de la double authentification.</summary>
private void OnTwoFactorAccountChanged()
{
foreach (var name in new[]
{
nameof(OtpEnabled), nameof(OtpDisabled), nameof(RecoveryCodesLeft), nameof(TwoFactorStatusText), nameof(ShowRecoveryWarning),
nameof(RecoveryWarningText)
})
OnPropertyChanged(name);
RaiseTwoFactorCommands();
}
}
@@ -0,0 +1,927 @@
using System.Collections.ObjectModel;
using System.Globalization;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Core.Utils;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Accounts;
/// <summary>Écrans de la fenêtre « Compte ».</summary>
public enum AccountStep
{
Loading,
/// <summary>Comptes non ouverts sur le serveur, ou serveur injoignable (bouton « Réessayer »).</summary>
Unavailable,
SignIn,
/// <summary>Connexion, 2ᵉ étape : code de l'application d'authentification ou code de secours (double authentification).</summary>
Otp,
Register,
/// <summary>Nouveau mot de passe exigé par un administrateur (après une connexion réussie).</summary>
PasswordChange,
/// <summary>« Mon compte » (onglets <see cref="AccountSection"/>).</summary>
Account
}
/// <summary>Onglets de « Mon compte ».</summary>
public enum AccountSection
{
Profile,
Devices,
Activity,
Data
}
/// <summary>
/// Fenêtre « Compte » (bouton de la barre de titre) : connexion, création de compte, nouveau mot de passe exigé, puis
/// « Mon compte » (profil et mot de passe, appareils connectés, activité récente, export et suppression des données).
/// Les mots de passe ne sont JAMAIS gardés par ce ViewModel : la vue (PasswordBox) les passe aux méthodes au moment de
/// l'action puis efface ses champs (<see cref="ClearPasswordsRequested"/>) ; seul le mot de passe tapé à la connexion est
/// conservé quelques minutes quand un administrateur exige d'en changer (il est demandé par le serveur).
/// </summary>
public sealed partial class AccountWindowViewModel : ObservableObject, IDisposable
{
private static readonly CultureInfo French = CultureInfo.GetCultureInfo("fr-FR");
/// <summary>Durée de validité du jeton « changement exigé » (serveur : 10 minutes).</summary>
private static readonly TimeSpan PasswordChangeWindow = TimeSpan.FromMinutes(9);
private readonly IPlayerAccountService _service;
private readonly IModuleDialogs _dialogs;
private readonly IUiDispatcher _ui;
private readonly string _publisher;
private readonly CancellationTokenSource _lifetime = new();
private AccountStep _step = AccountStep.Loading;
private AccountSection _section = AccountSection.Profile;
private bool _isBusy;
private string _busyText = "";
private string? _errorMessage;
private string? _successMessage;
private string? _unavailableText;
private string _email = "";
private bool _rememberMe = true;
private bool _showForgotHelp;
private bool _acceptTerms;
private bool _registrationOpen;
private string _termsVersion = "";
private bool _isChangingPassword;
private bool _deleteConfirmed;
private bool _isActivityLoading;
private bool _activityLoaded;
private string? _changeToken;
private string? _changeCurrentPassword;
private DateTime _changeStarted;
private PlayerAccountInfo? _account;
public AccountWindowViewModel(IPlayerAccountService service, IModuleDialogs dialogs, IUiDispatcher ui, string publisher)
{
_service = service;
_dialogs = dialogs;
_ui = ui;
_publisher = string.IsNullOrWhiteSpace(publisher) ? "LanisysGames" : publisher.Trim();
CloseCommand = new RelayCommand(() => CloseRequested?.Invoke(), () => !IsBusy);
RetryCommand = new AsyncRelayCommand(InitializeAsync, () => !IsBusy);
ShowRegisterCommand = new RelayCommand(() => GoTo(AccountStep.Register), () => !IsBusy && RegistrationOpen);
ShowSignInCommand = new RelayCommand(() => GoTo(AccountStep.SignIn), () => !IsBusy);
ToggleForgotCommand = new RelayCommand(() => ShowForgotHelp = !ShowForgotHelp);
ShowChangePasswordCommand = new RelayCommand(() =>
{
ClearMessages();
if (TwoFactorStage != TwoFactorStage.Codes) TwoFactorStage = TwoFactorStage.Idle;
IsChangingPassword = true;
}, () => !IsBusy);
CancelChangePasswordCommand = new RelayCommand(() => IsChangingPassword = false, () => !IsBusy);
CopyReferenceCommand = new RelayCommand(CopyReference, () => !string.IsNullOrEmpty(Reference));
SignOutCommand = new AsyncRelayCommand(() => SignOutAsync(allDevices: false), () => !IsBusy);
SignOutAllCommand = new AsyncRelayCommand(() => SignOutAsync(allDevices: true), () => !IsBusy);
RefreshCommand = new AsyncRelayCommand(RefreshAccountAsync, () => !IsBusy);
InitializeTwoFactor();
_service.Changed += OnServiceChanged;
}
/// <summary>La vue efface ses champs de mot de passe (changement d'écran, action terminée).</summary>
public event Action? ClearPasswordsRequested;
public event Action? CloseRequested;
public PasswordRulesViewModel NewPassword { get; } = new();
public ObservableCollection<PlayerDeviceItem> Devices { get; } = new();
public ObservableCollection<PlayerActivityItem> Activity { get; } = new();
public RelayCommand CloseCommand { get; }
public AsyncRelayCommand RetryCommand { get; }
public RelayCommand ShowRegisterCommand { get; }
public RelayCommand ShowSignInCommand { get; }
public RelayCommand ToggleForgotCommand { get; }
public RelayCommand ShowChangePasswordCommand { get; }
public RelayCommand CancelChangePasswordCommand { get; }
public RelayCommand CopyReferenceCommand { get; }
public AsyncRelayCommand SignOutCommand { get; }
public AsyncRelayCommand SignOutAllCommand { get; }
public AsyncRelayCommand RefreshCommand { get; }
// ------------------------------------------------------------------ Écran
public AccountStep Step
{
get => _step;
private set
{
if (!SetProperty(ref _step, value)) return;
OnPropertyChanged(nameof(IsLoading));
OnPropertyChanged(nameof(IsUnavailable));
OnPropertyChanged(nameof(IsSignIn));
OnPropertyChanged(nameof(IsOtp));
OnPropertyChanged(nameof(IsRegister));
OnPropertyChanged(nameof(IsPasswordChange));
OnPropertyChanged(nameof(IsAccount));
OnPropertyChanged(nameof(Title));
OnPropertyChanged(nameof(Subtitle));
OnPropertyChanged(nameof(HeaderIcon));
ShowRegisterCommand.RaiseCanExecuteChanged();
}
}
public bool IsLoading => Step == AccountStep.Loading;
public bool IsUnavailable => Step == AccountStep.Unavailable;
public bool IsSignIn => Step == AccountStep.SignIn;
public bool IsOtp => Step == AccountStep.Otp;
public bool IsRegister => Step == AccountStep.Register;
public bool IsPasswordChange => Step == AccountStep.PasswordChange;
public bool IsAccount => Step == AccountStep.Account;
public string Title => Step switch
{
AccountStep.SignIn => "Se connecter",
AccountStep.Register => "Créer un compte",
AccountStep.Otp => "Code de vérification",
AccountStep.PasswordChange => "Nouveau mot de passe exigé",
AccountStep.Account => "Mon compte",
_ => "Compte"
};
public string Subtitle => Step switch
{
AccountStep.SignIn => $"Avec votre compte {_publisher}",
AccountStep.Register => "Gratuit : une adresse e-mail et un mot de passe suffisent",
AccountStep.PasswordChange => $"Demandé par l'équipe {_publisher}",
AccountStep.Otp => "Double authentification",
AccountStep.Account => EmailMasked,
AccountStep.Unavailable => "Service indisponible",
_ => "Connexion au serveur…"
};
/// <summary>Clé de l'icône d'en-tête (Themes\Icons.xaml).</summary>
public string HeaderIcon => Step switch
{
AccountStep.Register => "AccountKey",
AccountStep.PasswordChange => "ShieldKey",
AccountStep.Otp => "ShieldAccount",
_ => "AccountCircle"
};
public bool IsBusy
{
get => _isBusy;
private set
{
if (!SetProperty(ref _isBusy, value)) return;
OnPropertyChanged(nameof(IsIdle));
CloseCommand.RaiseCanExecuteChanged();
RetryCommand.RaiseCanExecuteChanged();
ShowRegisterCommand.RaiseCanExecuteChanged();
ShowSignInCommand.RaiseCanExecuteChanged();
ShowChangePasswordCommand.RaiseCanExecuteChanged();
CancelChangePasswordCommand.RaiseCanExecuteChanged();
SignOutCommand.RaiseCanExecuteChanged();
SignOutAllCommand.RaiseCanExecuteChanged();
RefreshCommand.RaiseCanExecuteChanged();
RaiseTwoFactorCommands();
}
}
/// <summary>Saisie et boutons utilisables (aucune action en cours).</summary>
public bool IsIdle => !IsBusy;
public string BusyText
{
get => _busyText;
private set => SetProperty(ref _busyText, value);
}
public string? ErrorMessage
{
get => _errorMessage;
private set
{
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
}
}
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
public string? SuccessMessage
{
get => _successMessage;
private set
{
if (SetProperty(ref _successMessage, value)) OnPropertyChanged(nameof(HasSuccess));
}
}
public bool HasSuccess => !string.IsNullOrEmpty(SuccessMessage);
public string? UnavailableText
{
get => _unavailableText;
private set => SetProperty(ref _unavailableText, value);
}
// ------------------------------------------------------------------ Connexion et création
/// <summary>Adresse e-mail saisie (connexion et création), gardée d'un écran à l'autre.</summary>
public string Email
{
get => _email;
set => SetProperty(ref _email, value ?? "");
}
public int EmailMaxLength => PlayerEmail.MaxLength;
public int PasswordMaxLength => PasswordRules.MaxLength;
/// <summary>« Rester connecté sur ce PC » (session mémorisée, chiffrée par Windows).</summary>
public bool RememberMe
{
get => _rememberMe;
set => SetProperty(ref _rememberMe, value);
}
public bool ShowForgotHelp
{
get => _showForgotHelp;
private set => SetProperty(ref _showForgotHelp, value);
}
public string ForgotHelpText =>
$"Contactez l'équipe {_publisher} (Discord, e-mail…) en indiquant l'adresse de votre compte : un administrateur pourra " +
"vous donner un mot de passe temporaire, valable une heure, à saisir ici à la place de votre mot de passe pour en choisir " +
"un nouveau. La réinitialisation par e-mail arrivera dans une prochaine version. Téléphone perdu (double " +
"authentification) ? Utilisez un de vos codes de secours.";
public bool RegistrationOpen
{
get => _registrationOpen;
private set
{
if (!SetProperty(ref _registrationOpen, value)) return;
OnPropertyChanged(nameof(RegistrationClosed));
ShowRegisterCommand.RaiseCanExecuteChanged();
}
}
public bool RegistrationClosed => !RegistrationOpen;
public bool AcceptTerms
{
get => _acceptTerms;
set => SetProperty(ref _acceptTerms, value);
}
public string TermsText => string.IsNullOrEmpty(_termsVersion)
? "J'accepte les conditions d'utilisation et la politique de confidentialité."
: $"J'accepte les conditions d'utilisation et la politique de confidentialité (version {_termsVersion}).";
public string PrivacyText =>
"Conservés : votre adresse e-mail (chiffrée), une empreinte irréversible du mot de passe, les appareils connectés et " +
"l'activité de connexion (12 mois, adresses IP 6 mois). Export et suppression à tout moment depuis « Mon compte ».";
public string RememberText => "Rester connecté sur ce PC";
/// <summary>Saisie de la vue : contrôle en direct du nouveau mot de passe (création, changement).</summary>
public void UpdateNewPassword(string password, string confirmation) =>
NewPassword.Update(password, confirmation, Step == AccountStep.Register ? Email : null);
public async Task SignInAsync(string password)
{
if (IsBusy) return;
ClearMessages();
if (PlayerEmail.Normalize(Email) is null)
{
ErrorMessage = "Adresse e-mail invalide.";
return;
}
if (string.IsNullOrEmpty(password))
{
ErrorMessage = "Saisissez votre mot de passe.";
return;
}
await RunAsync("Vérification anti-robot puis connexion…", async () =>
{
var result = await _service.SignInAsync(Email, password, RememberMe, _lifetime.Token);
if (result.OtpRequired)
{
BeginOtpStep(result.OtpToken!, password);
return;
}
if (result.PasswordChangeRequired)
{
BeginRequiredChange(result.PasswordChangeToken, password, result.TemporaryPassword);
return;
}
await EnterAccountAsync();
});
}
public async Task RegisterAsync(string password, string confirmation)
{
if (IsBusy) return;
ClearMessages();
if (PlayerEmail.Normalize(Email) is null)
{
ErrorMessage = "Adresse e-mail invalide.";
return;
}
NewPassword.Update(password, confirmation, Email);
if (!NewPassword.IsAccepted)
{
ErrorMessage = NewPassword.Problems.FirstOrDefault() ?? "Mot de passe refusé.";
return;
}
if (!NewPassword.ConfirmationMatches)
{
ErrorMessage = "Les deux mots de passe ne sont pas identiques.";
return;
}
if (!AcceptTerms)
{
ErrorMessage = "Acceptez les conditions d'utilisation et la politique de confidentialité pour créer votre compte.";
return;
}
await RunAsync("Vérification anti-robot puis création du compte (quelques secondes)…", async () =>
{
await _service.RegisterAsync(Email, password, _termsVersion, RememberMe, _lifetime.Token);
await EnterAccountAsync();
SuccessMessage = "Bienvenue ! Votre compte est créé.";
});
}
// ------------------------------------------------------------------ Nouveau mot de passe exigé
public string PasswordChangeText => _changeAfterTemporary
? "Vous vous êtes connecté avec le mot de passe temporaire donné par l'équipe : choisissez maintenant votre nouveau mot de " +
"passe (le mot de passe temporaire ne servira plus). Vos autres appareils seront déconnectés."
: $"Pour la sécurité de votre compte, l'équipe {_publisher} vous demande de choisir un nouveau mot de passe. " +
"Vos autres appareils seront déconnectés.";
/// <summary>Changement demandé après une connexion avec un mot de passe temporaire (texte de l'écran).</summary>
private bool _changeAfterTemporary;
/// <summary>Écran « nouveau mot de passe » : jeton limité, mot de passe saisi (redemandé par le serveur), origine.</summary>
private void BeginRequiredChange(string? token, string? currentPassword, bool temporary)
{
_changeToken = token;
_changeCurrentPassword = currentPassword; // exigé par le serveur pour le changement ; oublié ensuite
_changeStarted = DateTime.UtcNow;
_changeAfterTemporary = temporary;
OnPropertyChanged(nameof(PasswordChangeText));
GoTo(AccountStep.PasswordChange);
}
public async Task CompleteRequiredPasswordChangeAsync(string password, string confirmation)
{
if (IsBusy) return;
ClearMessages();
if (_changeToken is null || _changeCurrentPassword is null || DateTime.UtcNow - _changeStarted > PasswordChangeWindow)
{
ForgetRequiredChange();
GoTo(AccountStep.SignIn);
ErrorMessage = "Délai dépassé : reconnectez-vous pour choisir votre nouveau mot de passe.";
return;
}
NewPassword.Update(password, confirmation, null);
if (!NewPassword.IsValid)
{
ErrorMessage = NewPassword.IsAccepted ? "Les deux mots de passe ne sont pas identiques." : NewPassword.Problems.FirstOrDefault();
return;
}
await RunAsync("Enregistrement du nouveau mot de passe…", async () =>
{
await _service.CompleteRequiredPasswordChangeAsync(_changeToken, _changeCurrentPassword, password, RememberMe, _lifetime.Token);
ForgetRequiredChange();
await EnterAccountAsync();
SuccessMessage = "Nouveau mot de passe enregistré : vous êtes connecté.";
});
}
private void ForgetRequiredChange()
{
_changeToken = null;
_changeCurrentPassword = null;
}
// ------------------------------------------------------------------ Mon compte
public AccountSection Section
{
get => _section;
private set
{
if (!SetProperty(ref _section, value)) return;
OnPropertyChanged(nameof(IsProfileSection));
OnPropertyChanged(nameof(IsDevicesSection));
OnPropertyChanged(nameof(IsActivitySection));
OnPropertyChanged(nameof(IsDataSection));
ClearMessages();
IsChangingPassword = false;
DeleteConfirmed = false;
if (TwoFactorStage != TwoFactorStage.Codes) TwoFactorStage = TwoFactorStage.Idle; // codes affichés : à terminer
ClearPasswordsRequested?.Invoke();
if (value == AccountSection.Activity && !_activityLoaded) _ = LoadActivityAsync();
}
}
// Onglets (RadioButton : IsChecked lié dans les deux sens).
public bool IsProfileSection
{
get => Section == AccountSection.Profile;
set { if (value) Section = AccountSection.Profile; }
}
public bool IsDevicesSection
{
get => Section == AccountSection.Devices;
set { if (value) Section = AccountSection.Devices; }
}
public bool IsActivitySection
{
get => Section == AccountSection.Activity;
set { if (value) Section = AccountSection.Activity; }
}
public bool IsDataSection
{
get => Section == AccountSection.Data;
set { if (value) Section = AccountSection.Data; }
}
public string EmailMasked => _account?.EmailMasked ?? "";
public string Reference => _account?.Reference ?? "";
public string CreatedText => FormatDate(_account?.CreatedAt);
public string LastLoginText => FormatDate(_account?.LastLoginAt);
public string PasswordChangedText => FormatDate(_account?.PasswordChangedAt);
public bool NewDevicesSuspended => _account?.NewDevicesSuspended == true;
public string AvatarText => EmailMasked.Length > 0 ? EmailMasked[..1].ToUpperInvariant() : "?";
public string SuspendedText =>
"Par sécurité, les connexions depuis un NOUVEL appareil sont suspendues sur ce compte après de nombreux essais de mot de " +
$"passe. Cet appareil continue de fonctionner. Contactez l'équipe {_publisher} avec la référence {Reference} pour lever la suspension.";
public bool IsChangingPassword
{
get => _isChangingPassword;
private set
{
if (!SetProperty(ref _isChangingPassword, value)) return;
NewPassword.Reset();
ClearPasswordsRequested?.Invoke();
}
}
public async Task ChangePasswordAsync(string currentPassword, string password, string confirmation)
{
if (IsBusy) return;
ClearMessages();
if (string.IsNullOrEmpty(currentPassword))
{
ErrorMessage = "Saisissez votre mot de passe actuel.";
return;
}
NewPassword.Update(password, confirmation, null);
if (!NewPassword.IsValid)
{
ErrorMessage = NewPassword.IsAccepted ? "Les deux mots de passe ne sont pas identiques." : NewPassword.Problems.FirstOrDefault();
return;
}
await RunAsync("Enregistrement du nouveau mot de passe…", async () =>
{
var others = await _service.ChangePasswordAsync(currentPassword, password, _lifetime.Token);
IsChangingPassword = false;
await LoadMeAsync();
SuccessMessage = others switch
{
0 => "Mot de passe modifié.",
1 => "Mot de passe modifié. Votre autre appareil a été déconnecté.",
_ => $"Mot de passe modifié. Vos {others} autres appareils ont été déconnectés."
};
});
}
public bool IsActivityLoading
{
get => _isActivityLoading;
private set
{
if (SetProperty(ref _isActivityLoading, value)) OnPropertyChanged(nameof(IsActivityEmpty));
}
}
public bool IsActivityEmpty => !IsActivityLoading && _activityLoaded && Activity.Count == 0;
public bool DeleteConfirmed
{
get => _deleteConfirmed;
set => SetProperty(ref _deleteConfirmed, value);
}
public string ExportText =>
"Téléchargez toutes les données de votre compte (adresse e-mail, appareils, activité…) dans un fichier JSON lisible " +
"par machine. Votre mot de passe est demandé, car le fichier contient votre adresse complète.";
public string DeleteText =>
"Votre compte, vos appareils connectés et l'activité de votre compte sont effacés DÉFINITIVEMENT. Le jeu et ses " +
"sauvegardes sur ce PC ne sont pas touchés. Une simple trace de la suppression (référence du compte, date) est gardée " +
"12 mois dans le journal de sécurité.";
/// <summary>Export des données : emplacement choisi d'abord (annulable sans rien envoyer), puis téléchargement.</summary>
public async Task ExportAsync(string password)
{
if (IsBusy) return;
ClearMessages();
if (string.IsNullOrEmpty(password))
{
ErrorMessage = "Saisissez votre mot de passe pour exporter vos données.";
return;
}
var path = _dialogs.PickSaveFile("Exporter mes données", "Fichier JSON (*.json)|*.json",
PlayerDataExport.SuggestedFileName(Reference, DateTimeOffset.Now), Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments));
if (path is null) return;
await RunAsync("Préparation de l'export…", async () =>
{
var bytes = await _service.ExportAsync(password, _lifetime.Token);
await File.WriteAllBytesAsync(path, bytes, _lifetime.Token);
ClearPasswordsRequested?.Invoke();
SuccessMessage = $"Vos données ont été enregistrées dans {Path.GetFileName(path)}.";
Log.Info("Compte : données exportées.");
});
}
public async Task DeleteAccountAsync(string password)
{
if (IsBusy) return;
ClearMessages();
if (!DeleteConfirmed)
{
ErrorMessage = "Cochez la case pour confirmer que vous comprenez que la suppression est définitive.";
return;
}
if (string.IsNullOrEmpty(password))
{
ErrorMessage = "Saisissez votre mot de passe pour supprimer votre compte.";
return;
}
if (!_dialogs.Confirm("Supprimer définitivement le compte ?",
$"Le compte {EmailMasked} ({Reference}) sera supprimé définitivement. Cette action est irréversible.",
"Supprimer mon compte", "Annuler", danger: true))
return;
await RunAsync("Suppression du compte…", async () =>
{
await _service.DeleteAccountAsync(password, _lifetime.Token);
Email = "";
GoTo(AccountStep.SignIn);
SuccessMessage = "Votre compte a été supprimé définitivement.";
});
}
// ------------------------------------------------------------------ Chargement
/// <summary>Ouverture de la fenêtre (et « Réessayer ») : compte connecté, sinon état du service puis connexion.</summary>
public async Task InitializeAsync()
{
ClearMessages();
_ = Task.Run(() => PasswordRules.Check("préchargement-du-dictionnaire")); // dictionnaire prêt avant la première frappe
if (_service.State is PlayerAccountState.SignedIn or PlayerAccountState.Restoring or PlayerAccountState.Offline)
{
await RunAsync("Chargement du compte…", EnterAccountAsync, Step == AccountStep.Account ? null : AccountStep.Loading);
return;
}
Step = AccountStep.Loading;
IsBusy = true;
BusyText = "Connexion au serveur…";
try
{
var info = await _service.GetServiceInfoAsync(_lifetime.Token);
if (info is not { Enabled: true })
{
UnavailableText = "Les comptes joueurs ne sont pas encore ouverts. Revenez bientôt !";
Step = AccountStep.Unavailable;
return;
}
RegistrationOpen = info.RegistrationOpen;
_termsVersion = info.TermsVersion;
OnPropertyChanged(nameof(TermsText));
Step = AccountStep.SignIn;
if (_service.Notice is { } notice) ErrorMessage = notice;
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
{
// fenêtre fermée
}
catch (Exception ex)
{
UnavailableText = PlayerAccountTexts.Describe(ex);
Step = AccountStep.Unavailable;
}
finally
{
IsBusy = false;
}
}
private async Task EnterAccountAsync()
{
Section = AccountSection.Profile;
_activityLoaded = false;
Activity.Clear();
await LoadMeAsync();
GoTo(AccountStep.Account, keepMessages: true);
}
private async Task RefreshAccountAsync()
{
ClearMessages();
await RunAsync("Actualisation…", async () =>
{
await LoadMeAsync();
if (Section == AccountSection.Activity) await LoadActivityCoreAsync();
});
}
private async Task LoadMeAsync()
{
var me = await _service.GetMeAsync(_lifetime.Token);
SetAccount(me.Account);
Devices.Clear();
foreach (var device in me.Devices.OrderByDescending(d => d.Current).ThenByDescending(d => d.LastUsedAt))
Devices.Add(new PlayerDeviceItem(device, FormatDate(device.CreatedAt), FormatDate(device.LastUsedAt), RemoveDeviceAsync));
}
private async Task LoadActivityAsync()
{
if (IsBusy) return;
try
{
await LoadActivityCoreAsync();
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
{
// fenêtre fermée
}
catch (Exception ex)
{
ShowError(ex);
}
}
private async Task LoadActivityCoreAsync()
{
IsActivityLoading = true;
try
{
var activity = await _service.GetActivityAsync(_lifetime.Token);
Activity.Clear();
foreach (var entry in activity.Entries) Activity.Add(new PlayerActivityItem(entry, FormatDateTime(entry.At)));
_activityLoaded = true;
}
finally
{
IsActivityLoading = false;
OnPropertyChanged(nameof(IsActivityEmpty));
}
}
private async Task RemoveDeviceAsync(PlayerDeviceItem device)
{
if (IsBusy) return;
ClearMessages();
if (!_dialogs.Confirm("Déconnecter cet appareil ?",
$"« {device.Label} » devra se reconnecter avec le mot de passe pour accéder au compte.", "Déconnecter", "Annuler"))
return;
await RunAsync("Déconnexion de l'appareil…", async () =>
{
await _service.RemoveDeviceAsync(device.Id, _lifetime.Token);
if (_service.State == PlayerAccountState.SignedOut) return; // c'était cet appareil
await LoadMeAsync();
SuccessMessage = $"« {device.Label} » a été déconnecté.";
});
}
private async Task SignOutAsync(bool allDevices)
{
if (IsBusy) return;
ClearMessages();
if (allDevices && !_dialogs.Confirm("Déconnecter tous vos appareils ?",
"Tous les appareils connectés à votre compte, y compris celui-ci, devront se reconnecter avec le mot de passe.",
"Tout déconnecter", "Annuler", danger: true))
return;
await RunAsync(allDevices ? "Déconnexion de tous les appareils…" : "Déconnexion…", async () =>
{
await _service.SignOutAsync(allDevices, _lifetime.Token);
GoTo(AccountStep.SignIn);
SuccessMessage = allDevices ? "Tous vos appareils ont été déconnectés." : "Vous êtes déconnecté.";
});
}
private void CopyReference()
{
if (string.IsNullOrEmpty(Reference)) return;
_dialogs.CopyToClipboard(Reference);
ClearMessages();
SuccessMessage = $"Référence {Reference} copiée.";
}
// ------------------------------------------------------------------ Outils
/// <summary>Action longue : saisie bloquée, erreurs affichées en clair ; session perdue → écran de connexion.</summary>
private async Task RunAsync(string busyText, Func<Task> work, AccountStep? stepWhileBusy = null)
{
IsBusy = true;
BusyText = busyText;
if (stepWhileBusy is { } step) Step = step;
try
{
await work();
}
catch (PlayerSessionLostException ex)
{
GoTo(AccountStep.SignIn);
await EnsureServiceInfoAsync();
ErrorMessage = ex.Message;
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
{
// fenêtre fermée pendant l'action
}
catch (Exception ex)
{
if (Step == AccountStep.Loading) Step = _service.State == PlayerAccountState.SignedOut ? AccountStep.SignIn : AccountStep.Account;
ShowError(ex);
}
finally
{
IsBusy = false;
ClearPasswordsRequested?.Invoke();
}
}
/// <summary>Informations du service (création ouverte, conditions) si l'écran de connexion est atteint sans elles.</summary>
private async Task EnsureServiceInfoAsync()
{
if (!string.IsNullOrEmpty(_termsVersion)) return;
try
{
if (await _service.GetServiceInfoAsync(_lifetime.Token) is { } info)
{
RegistrationOpen = info.Enabled && info.RegistrationOpen;
_termsVersion = info.TermsVersion;
OnPropertyChanged(nameof(TermsText));
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Warn($"Compte : état du service illisible ({ex.Message}).");
}
}
private void ShowError(Exception ex)
{
ErrorMessage = PlayerAccountTexts.Describe(ex);
Log.Warn($"Compte : {ex.GetType().Name} : {ex.Message}");
}
private void GoTo(AccountStep step, bool keepMessages = false)
{
if (!keepMessages) ClearMessages();
if (step != AccountStep.PasswordChange) ForgetRequiredChange();
if (step != AccountStep.Otp) ForgetOtp();
if (step != AccountStep.Account) ResetTwoFactor();
if (step is AccountStep.SignIn or AccountStep.Register)
{
AcceptTerms = false;
ShowForgotHelp = false;
if (step == AccountStep.Register && !RegistrationOpen) step = AccountStep.SignIn;
}
IsChangingPassword = false;
DeleteConfirmed = false;
NewPassword.Reset();
Step = step;
ClearPasswordsRequested?.Invoke();
}
private void ClearMessages()
{
ErrorMessage = null;
SuccessMessage = null;
}
private void SetAccount(PlayerAccountInfo? account)
{
_account = account;
foreach (var name in new[]
{
nameof(EmailMasked), nameof(Reference), nameof(CreatedText), nameof(LastLoginText), nameof(PasswordChangedText),
nameof(NewDevicesSuspended), nameof(AvatarText), nameof(SuspendedText), nameof(Subtitle)
})
OnPropertyChanged(name);
OnTwoFactorAccountChanged();
CopyReferenceCommand.RaiseCanExecuteChanged();
}
/// <summary>Changement venu du service (session perdue pendant que la fenêtre est ouverte…).</summary>
private void OnServiceChanged() => _ui.Post(() =>
{
if (_service.State == PlayerAccountState.SignedOut && Step is AccountStep.Account && !IsBusy)
{
GoTo(AccountStep.SignIn);
ErrorMessage = _service.Notice ?? "Vous avez été déconnecté.";
}
else if (_service.Account is { } account && Step == AccountStep.Account)
{
SetAccount(account);
}
});
internal static string FormatDate(DateTimeOffset? date) =>
date is { } value && value != default ? value.ToLocalTime().ToString("d MMMM yyyy", French) : "—";
internal static string FormatDateTime(DateTimeOffset date) =>
date.ToLocalTime().ToString("d MMM yyyy 'à' HH:mm", French);
public void Dispose()
{
_service.Changed -= OnServiceChanged;
_lifetime.Cancel(); // actions en cours abandonnées (la source n'est pas libérée : une action peut encore la lire)
ForgetRequiredChange();
ForgetOtp();
ResetTwoFactor();
}
}
/// <summary>Un appareil connecté au compte (onglet « Appareils »).</summary>
public sealed class PlayerDeviceItem
{
public PlayerDeviceItem(PlayerDeviceInfo device, string createdText, string lastUsedText, Func<PlayerDeviceItem, Task> remove)
{
Id = device.Id;
Label = device.Label;
IsCurrent = device.Current;
Detail = $"Connecté le {createdText} · utilisé le {lastUsedText}";
RemoveCommand = new AsyncRelayCommand(() => remove(this));
}
public string Id { get; }
public string Label { get; }
public string Detail { get; }
/// <summary>Appareil de la session en cours (« Cet appareil » ; se déconnecte avec « Se déconnecter »).</summary>
public bool IsCurrent { get; }
public bool CanRemove => !IsCurrent;
public AsyncRelayCommand RemoveCommand { get; }
}
/// <summary>Une ligne de l'activité récente du compte.</summary>
public sealed class PlayerActivityItem
{
private static readonly HashSet<string> Alerts = new(StringComparer.Ordinal)
{
"account.locked", "account.suspended", "session.reuse", "account.disabled"
};
public PlayerActivityItem(PlayerActivityEntry entry, string dateText)
{
Title = PlayerActivityText.Describe(entry.Type, entry.Success);
IsWarning = !entry.Success || Alerts.Contains(entry.Type);
var where = entry.ThisDevice ? "cet appareil" : entry.Device ?? (entry.Ip is { } ip ? $"adresse {ip}" : null);
Detail = where is null ? dateText : $"{dateText} · {where}";
}
public string Title { get; }
public string Detail { get; }
/// <summary>Échec ou alerte de sécurité (icône orange).</summary>
public bool IsWarning { get; }
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Module « Compte » côté joueur (sans WPF) : connexion, création de compte, session mémorisée (jeton de renouvellement
chiffré sur le PC, lié à la clé de l'appareil), « Mon compte » (appareils, activité, mot de passe, export des données,
suppression du compte). Testable sans interface, contre la Launcher API démarrée en mémoire.
Ce qui dépend de Windows est fourni par le launcher : clé d'appareil (puce TPM ou clé logicielle CNG, Platform\PlayerDeviceKeyStore.cs)
et chiffrement de la session (DPAPI, Platform\DpapiProtector.cs) ; la fenêtre est Views\AccountWindow.xaml.
Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>GameLauncher.Modules.Accounts</RootNamespace>
<AssemblyName>GameLauncher.Modules.Accounts</AssemblyName>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="GameLauncher.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,103 @@
using GameLauncher.Contracts.Accounts;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Accounts;
/// <summary>
/// Contrôle EN DIRECT d'un nouveau mot de passe (création de compte, changement) avec les mêmes règles que le serveur
/// (<see cref="PasswordRules"/>) : problèmes restants, confirmation identique, jauge de solidité indicative. Le mot de passe
/// n'est jamais gardé ici : seule la vue (PasswordBox) le détient ; elle appelle <see cref="Update"/> à chaque frappe.
/// </summary>
public sealed class PasswordRulesViewModel : ObservableObject
{
private IReadOnlyList<string> _problems = Array.Empty<string>();
private bool _hasInput;
private bool _hasConfirmation;
private bool _confirmationMatches;
private int _strength;
/// <summary>Rappel des règles, affiché tant que rien n'est saisi.</summary>
public string RulesText =>
$"Au moins {PasswordRules.MinLength} caractères mêlant au moins {PasswordRules.RequiredClasses} sortes parmi minuscules, majuscules, " +
$"chiffres et symboles, ou une phrase de passe d'au moins {PasswordRules.MinLengthWithoutComposition} caractères. " +
"Les mots de passe trop courants sont refusés.";
/// <summary>Problèmes restants (messages du contrôle commun au launcher et au serveur).</summary>
public IReadOnlyList<string> Problems
{
get => _problems;
private set => SetProperty(ref _problems, value);
}
public bool HasInput
{
get => _hasInput;
private set => SetProperty(ref _hasInput, value);
}
public bool ShowRules => !HasInput;
public bool HasProblems => HasInput && Problems.Count > 0;
/// <summary>Mot de passe conforme aux règles (indépendamment de la confirmation).</summary>
public bool IsAccepted => HasInput && Problems.Count == 0;
public bool HasConfirmation
{
get => _hasConfirmation;
private set => SetProperty(ref _hasConfirmation, value);
}
public bool ConfirmationMatches
{
get => _confirmationMatches;
private set => SetProperty(ref _confirmationMatches, value);
}
public bool ShowConfirmationMismatch => HasConfirmation && !ConfirmationMatches;
/// <summary>Mot de passe accepté ET confirmation identique : le bouton d'envoi peut être utilisé.</summary>
public bool IsValid => IsAccepted && ConfirmationMatches;
/// <summary>Jauge 0 à 4 (indicative : longueur et variété au-delà du minimum).</summary>
public int Strength
{
get => _strength;
private set => SetProperty(ref _strength, value);
}
public string StrengthText => !HasInput ? "" : !IsAccepted ? "Refusé" : Strength switch
{
>= 4 => "Excellent",
3 => "Très bon",
_ => "Correct"
};
/// <param name="email">Adresse du compte (création) : un mot de passe qui en est tiré est refusé.</param>
public void Update(string password, string confirmation, string? email)
{
HasInput = !string.IsNullOrEmpty(password);
Problems = HasInput ? PasswordRules.Check(password, email) : Array.Empty<string>();
HasConfirmation = !string.IsNullOrEmpty(confirmation);
ConfirmationMatches = HasInput && string.Equals(password, confirmation, StringComparison.Ordinal);
Strength = !HasInput || Problems.Count > 0 ? 0 : Score(password);
OnPropertyChanged(nameof(ShowRules));
OnPropertyChanged(nameof(HasProblems));
OnPropertyChanged(nameof(IsAccepted));
OnPropertyChanged(nameof(ShowConfirmationMismatch));
OnPropertyChanged(nameof(IsValid));
OnPropertyChanged(nameof(StrengthText));
}
public void Reset() => Update("", "", null);
private static int Score(string password)
{
var classes = (password.Any(char.IsLower) ? 1 : 0) + (password.Any(char.IsUpper) ? 1 : 0) +
(password.Any(char.IsDigit) ? 1 : 0) + (password.Any(c => !char.IsLetterOrDigit(c)) ? 1 : 0);
var length = password.Length;
var score = 2;
if (length >= 16) score++;
if (length >= 20 || (length >= 14 && classes == 4)) score++;
return Math.Min(4, score);
}
}
@@ -0,0 +1,581 @@
using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Core.Utils;
namespace GameLauncher.Modules.Accounts;
/// <summary>État du compte joueur dans le launcher.</summary>
public enum PlayerAccountState
{
/// <summary>Aucun compte connecté.</summary>
SignedOut,
/// <summary>Session mémorisée en cours de renouvellement (démarrage du launcher).</summary>
Restoring,
SignedIn,
/// <summary>Session mémorisée, serveur injoignable : nouvel essai à la prochaine action.</summary>
Offline
}
/// <summary>
/// Résultat d'une connexion : connecté, code de vérification demandé (double authentification), ou nouveau mot de passe
/// exigé par un administrateur.
/// </summary>
/// <param name="PasswordChangeToken">Jeton limité (10 min) qui permet seulement de choisir le nouveau mot de passe.</param>
/// <param name="OtpToken">Jeton limité (5 min, 5 essais) qui permet seulement d'envoyer le code de vérification.</param>
/// <param name="RecoveryCodesLeft">Connexion faite avec un code de secours : codes restants.</param>
/// <param name="TemporaryPassword">Connexion avec un mot de passe temporaire donné par l'équipe : il ne sert qu'à choisir le nouveau.</param>
public sealed record PlayerSignInResult(bool PasswordChangeRequired, string? PasswordChangeToken = null, bool OtpRequired = false,
string? OtpToken = null, int? RecoveryCodesLeft = null, bool TemporaryPassword = false);
/// <summary>Compte joueur du launcher (implémenté par <see cref="PlayerAccountService"/>, simulé par les tests et UiSnapshot).</summary>
public interface IPlayerAccountService
{
PlayerAccountState State { get; }
/// <summary>Compte connecté (ou mémorisé hors ligne : référence et adresse masquée seulement), sinon null.</summary>
PlayerAccountInfo? Account { get; }
/// <summary>Raison de la dernière déconnexion imposée (session expirée, compte désactivé…), à afficher une fois.</summary>
string? Notice { get; }
/// <summary>Changement d'état ou de compte (peut être levé depuis un autre fil).</summary>
event Action? Changed;
/// <summary>État du service des comptes annoncé par le serveur (null : comptes non activés sur ce serveur).</summary>
Task<PlayerAccountsInfo?> GetServiceInfoAsync(CancellationToken ct = default);
/// <summary>Reprend la session mémorisée (démarrage du launcher), sans jamais lever d'erreur.</summary>
Task RestoreAsync(CancellationToken ct = default);
Task<PlayerSignInResult> SignInAsync(string email, string password, bool remember, CancellationToken ct = default);
Task RegisterAsync(string email, string password, string termsVersion, bool remember, CancellationToken ct = default);
/// <summary>Code de vérification (après <see cref="SignInAsync"/>, double authentification activée).</summary>
Task<PlayerSignInResult> VerifyOtpAsync(string otpToken, string code, bool remember, CancellationToken ct = default);
/// <summary>Double authentification : préparation (mot de passe exigé), activation (premier code), désactivation,
/// nouveaux codes de secours (mot de passe et code exigés).</summary>
Task<PlayerOtpSetupResponse> BeginOtpSetupAsync(string password, CancellationToken ct = default);
Task<IReadOnlyList<string>> EnableOtpAsync(string code, CancellationToken ct = default);
Task DisableOtpAsync(string password, string code, CancellationToken ct = default);
Task<IReadOnlyList<string>> RegenerateRecoveryCodesAsync(string password, string code, CancellationToken ct = default);
/// <summary>Nouveau mot de passe EXIGÉ (après <see cref="SignInAsync"/>) : la session s'ouvre ensuite.</summary>
Task CompleteRequiredPasswordChangeAsync(string passwordChangeToken, string currentPassword, string newPassword, bool remember,
CancellationToken ct = default);
/// <summary>Changement ordinaire : renvoie le nombre d'autres appareils déconnectés.</summary>
Task<int> ChangePasswordAsync(string currentPassword, string newPassword, CancellationToken ct = default);
Task<PlayerMeResponse> GetMeAsync(CancellationToken ct = default);
Task<PlayerActivityResponse> GetActivityAsync(CancellationToken ct = default);
Task RemoveDeviceAsync(string deviceId, CancellationToken ct = default);
/// <summary>Déconnexion de ce PC (ou de tous les appareils) ; la session locale est effacée même si le serveur est injoignable.</summary>
Task SignOutAsync(bool allDevices, CancellationToken ct = default);
Task<byte[]> ExportAsync(string password, CancellationToken ct = default);
/// <summary>Suppression DÉFINITIVE du compte ; la session locale est effacée.</summary>
Task DeleteAccountAsync(string password, CancellationToken ct = default);
}
/// <summary>Session perdue (expirée, révoquée, compte désactivé…) : le joueur doit se reconnecter.</summary>
public sealed class PlayerSessionLostException : Exception
{
public PlayerSessionLostException(string message, Exception? inner = null) : base(message, inner) { }
}
/// <summary>
/// Compte joueur du launcher. Le jeton d'accès (15 min) reste en mémoire ; le jeton de renouvellement est enregistré
/// chiffré (<see cref="PlayerSessionFile"/>) si le joueur a choisi « Rester connecté », sinon gardé en mémoire et la session
/// est fermée à la sortie du launcher (<see cref="Shutdown"/>). Chaque renouvellement est signé par la clé de l'appareil ;
/// le nouveau jeton remplace aussitôt l'ancien sur le disque (l'ancien, présenté de nouveau, ferait déconnecter l'appareil
/// par le serveur). Un renouvellement à la fois (verrou) ; jeton d'accès renouvelé une minute avant son échéance, ou
/// aussitôt si le serveur l'a oublié (redémarrage).
/// Le launcher ne contacte l'API des comptes que sur une action du joueur, ou pour reprendre une session mémorisée.
/// </summary>
public sealed class PlayerAccountService : IPlayerAccountService, IDisposable
{
private static readonly TimeSpan AccessMargin = TimeSpan.FromMinutes(1);
private readonly PlayerAccountApi _api;
private readonly IPlayerDeviceKeyStore _keys;
private readonly PlayerSessionFile _file;
private readonly string _deviceLabel;
private readonly TimeProvider _time;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly object _lock = new();
private IPlayerDeviceKey? _key;
private string? _access;
private DateTimeOffset _accessExpiresAt;
private string? _refresh;
private string? _deviceId;
private DateTimeOffset _refreshExpiresAt;
private bool _remember;
private PlayerAccountInfo? _account;
private PlayerAccountState _state = PlayerAccountState.SignedOut;
private string? _notice;
/// <param name="hardware">Identifiants matériels du PC (empreintes) envoyés à la connexion et à la création de compte (étape 8e).</param>
public PlayerAccountService(PlayerAccountApi api, IPlayerDeviceKeyStore keys, PlayerSessionFile file, string deviceLabel,
TimeProvider? time = null, IPlayerHardwareIds? hardware = null)
{
_api = api;
_keys = keys;
_file = file;
_deviceLabel = deviceLabel;
_time = time ?? TimeProvider.System;
_hardware = hardware;
}
private readonly IPlayerHardwareIds? _hardware;
/// <summary>Empreintes matérielles (lues hors du fil de l'interface ; une erreur de lecture n'empêche pas la connexion).</summary>
private async Task<IReadOnlyList<string>?> ReadHardwareAsync(CancellationToken ct)
{
if (_hardware is null) return null;
try
{
return await Task.Run(_hardware.Read, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Warn($"Compte : identifiants matériels illisibles ({ex.Message}).");
return null;
}
}
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, await ReadHardwareAsync(ct).ConfigureAwait(false))
.ConfigureAwait(false);
return Conclude(response, key, remember);
}
public async Task<PlayerSignInResult> VerifyOtpAsync(string otpToken, string code, bool remember, CancellationToken ct = default)
{
var key = await OpenKeyAsync(ct).ConfigureAwait(false);
var response = await _api.VerifyOtpAsync(otpToken, code, ct).ConfigureAwait(false);
return Conclude(response, key, remember);
}
/// <summary>Suite d'une connexion : code de vérification demandé, nouveau mot de passe exigé, ou session ouverte.</summary>
private PlayerSignInResult Conclude(PlayerLoginResponse response, IPlayerDeviceKey key, bool remember)
{
if (response.Status == PlayerLoginStatus.OtpRequired && response.OtpToken is { } otp)
return new PlayerSignInResult(false, OtpRequired: true, OtpToken: otp, TemporaryPassword: response.TemporaryPassword);
if (response.Status == PlayerLoginStatus.PasswordChangeRequired && response.PasswordChangeToken is { } token)
return new PlayerSignInResult(true, token, RecoveryCodesLeft: response.RecoveryCodesLeft, TemporaryPassword: response.TemporaryPassword);
Adopt(response.Session ?? throw new ApiException(0, "invalid_response", "Réponse du serveur illisible."), key, remember);
Log.Info(response.RecoveryCodesLeft is null ? "Compte : connecté." : "Compte : connecté avec un code de secours.");
return new PlayerSignInResult(false, RecoveryCodesLeft: response.RecoveryCodesLeft);
}
public Task<PlayerOtpSetupResponse> BeginOtpSetupAsync(string password, CancellationToken ct = default) =>
CallAsync(token => _api.OtpSetupAsync(token, password, ct), ct);
public async Task<IReadOnlyList<string>> EnableOtpAsync(string code, CancellationToken ct = default)
{
var codes = await CallAsync(token => _api.OtpEnableAsync(token, code, ct), ct).ConfigureAwait(false);
Log.Info("Compte : double authentification activée.");
return codes;
}
public async Task DisableOtpAsync(string password, string code, CancellationToken ct = default)
{
await CallAsync(async token =>
{
await _api.OtpDisableAsync(token, password, code, ct).ConfigureAwait(false);
return true;
}, ct).ConfigureAwait(false);
Log.Info("Compte : double authentification désactivée.");
}
public Task<IReadOnlyList<string>> RegenerateRecoveryCodesAsync(string password, string code, CancellationToken ct = default) =>
CallAsync(token => _api.RegenerateRecoveryCodesAsync(token, password, code, ct), ct);
public async Task RegisterAsync(string email, string password, string termsVersion, bool remember, CancellationToken ct = default)
{
var key = await OpenKeyAsync(ct).ConfigureAwait(false);
var response = await _api.RegisterAsync(email, password, termsVersion, key, _deviceLabel, ct,
await ReadHardwareAsync(ct).ConfigureAwait(false)).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,57 @@
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>
/// Identifiants matériels du PC (étape 8e), lus seulement quand le joueur se connecte ou crée un compte : uniquement leurs
/// empreintes (<see cref="Contracts.Accounts.PlayerHardware.Format"/>), jamais les valeurs. Implémentation Windows dans le
/// launcher (Platform\PlayerHardwareIds.cs) ; vide si rien n'est lisible.
/// </summary>
public interface IPlayerHardwareIds
{
IReadOnlyList<string> Read();
}
/// <summary>Nom générique de l'appareil envoyé au serveur (« Windows 11 · GameLauncher 3.4.2 ») : jamais le nom Windows du PC.</summary>
public static class PlayerDeviceLabel
{
public static string Create(string launcherVersion)
{
var os = OperatingSystem.IsWindows()
? Environment.OSVersion.Version.Build >= 22000 ? "Windows 11" : "Windows 10"
: "PC";
var version = new string((launcherVersion ?? "").Where(c => char.IsAsciiDigit(c) || c == '.').Take(20).ToArray());
return version.Length > 0 ? $"{os} · GameLauncher {version}" : $"{os} · GameLauncher";
}
}
@@ -0,0 +1,116 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Core.Utils;
namespace GameLauncher.Modules.Accounts;
/// <summary>Session mémorisée (« Rester connecté »), telle qu'enregistrée chiffrée sur le PC.</summary>
/// <param name="Origin">Serveur qui a ouvert la session (« https://launcher.lanisys.fr ») : ignorée si l'adresse de l'API change.</param>
/// <param name="KeyId">Clé d'appareil avec laquelle la session a été ouverte (sans elle, le jeton ne sert à rien).</param>
/// <param name="Reference">Référence et adresse masquée du compte : affichées hors ligne.</param>
public sealed record StoredPlayerSession(string Origin, string RefreshToken, string DeviceId, string KeyId, string Reference,
string EmailMasked, DateTimeOffset RefreshExpiresAt);
/// <summary>
/// Fichier de la session mémorisée : %AppData%\…\GameLauncher\account\session.dat, chiffré par <see cref="ISecretProtector"/>
/// (DPAPI de l'utilisateur Windows). Le jeton de renouvellement qu'il contient est inutilisable sans la clé de l'appareil
/// (non exportable) et change à chaque lancement. Écriture atomique (fichier temporaire puis remplacement) : une coupure
/// pendant l'écriture ne laisse jamais un fichier à moitié écrit.
/// </summary>
public sealed class PlayerSessionFile
{
public const string FolderName = "account";
public const string FileName = "session.dat";
/// <summary>En-tête du fichier (« GLS » + version du format).</summary>
private static readonly byte[] Magic = "GLS1"u8.ToArray();
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private readonly ISecretProtector _protector;
public PlayerSessionFile(string dataDirectory, ISecretProtector protector)
{
_protector = protector;
Path = System.IO.Path.Combine(dataDirectory, FolderName, FileName);
}
public string Path { get; }
/// <summary>Session enregistrée, ou null (absente, illisible, d'un autre utilisateur ou PC : le fichier est alors supprimé).</summary>
public StoredPlayerSession? Load()
{
byte[] content;
try
{
if (!File.Exists(Path)) return null;
content = File.ReadAllBytes(Path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"Compte : session mémorisée illisible ({ex.Message}).");
return null;
}
try
{
if (content.Length <= Magic.Length || content.Length > 64 * 1024 || !content.AsSpan(0, Magic.Length).SequenceEqual(Magic))
throw new InvalidDataException("en-tête inconnu");
var json = _protector.Unprotect(content[Magic.Length..]);
try
{
var session = JsonSerializer.Deserialize<StoredPlayerSession>(json, Json) ?? throw new InvalidDataException("vide");
if (!PlayerTokens.TryParseRefresh(session.RefreshToken, out var deviceId, out _) || deviceId != session.DeviceId ||
string.IsNullOrEmpty(session.Origin) || string.IsNullOrEmpty(session.KeyId))
throw new InvalidDataException("contenu incohérent");
return session;
}
finally
{
CryptographicOperations.ZeroMemory(json);
}
}
catch (Exception ex) when (ex is CryptographicException or InvalidDataException or JsonException)
{
Log.Warn($"Compte : session mémorisée inutilisable ({ex.Message}) : elle est supprimée.");
Delete();
return null;
}
}
public void Save(StoredPlayerSession session)
{
var json = JsonSerializer.SerializeToUtf8Bytes(session, Json);
byte[] protectedBytes;
try
{
protectedBytes = _protector.Protect(json);
}
finally
{
CryptographicOperations.ZeroMemory(json);
}
var directory = System.IO.Path.GetDirectoryName(Path)!;
Directory.CreateDirectory(directory);
var temp = Path + ".tmp";
using (var stream = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None))
{
stream.Write(Magic);
stream.Write(protectedBytes);
stream.Flush(flushToDisk: true);
}
File.Move(temp, Path, overwrite: true);
}
public void Delete()
{
FileUtil.TryDeleteFile(Path);
FileUtil.TryDeleteFile(Path + ".tmp");
}
/// <summary>Texte d'en-tête utilisé pour lier le chiffrement à cet usage (entropie DPAPI).</summary>
public static byte[] Purpose => Encoding.ASCII.GetBytes("GameLauncher/Player/Session/v1");
}
+475
View File
@@ -0,0 +1,475 @@
using System.Globalization;
using System.Text;
namespace GameLauncher.Modules.Accounts;
/// <summary>Niveau de correction d'erreurs d'un QR code (part du symbole qui peut être abîmée sans gêner la lecture).</summary>
public enum QrErrorCorrection
{
/// <summary>≈ 7 %.</summary>
Low,
/// <summary>≈ 15 % (défaut : bon compromis pour un écran).</summary>
Medium,
/// <summary>≈ 25 %.</summary>
Quartile,
/// <summary>≈ 30 %.</summary>
High
}
/// <summary>
/// Encodeur de QR codes (ISO/IEC 18004), écrit pour le launcher (décision D3 : aucune dépendance) : mode « octets »
/// (UTF-8), versions 1 à 40, quatre niveaux de correction, choix automatique du masque. Sert à afficher l'adresse
/// « otpauth:// » de la double authentification ; le symbole est calculé sur ce PC, le secret ne part vers aucun service.
/// </summary>
public sealed class QrCode
{
public const int MinVersion = 1;
public const int MaxVersion = 40;
private readonly bool[,] _modules; // [y, x]
private readonly bool[,] _isFunction;
private QrCode(int version, QrErrorCorrection level)
{
Version = version;
ErrorCorrection = level;
Size = version * 4 + 17;
_modules = new bool[Size, Size];
_isFunction = new bool[Size, Size];
}
public int Version { get; }
public int Size { get; }
public QrErrorCorrection ErrorCorrection { get; }
/// <summary>Masque retenu (0 à 7).</summary>
public int Mask { get; private set; }
/// <summary>Module sombre en (x, y) ; hors du symbole : clair (marge).</summary>
public bool this[int x, int y] => x >= 0 && y >= 0 && x < Size && y < Size && _modules[y, x];
public static QrCode Encode(string text, QrErrorCorrection level = QrErrorCorrection.Medium) =>
EncodeBytes(Encoding.UTF8.GetBytes(text ?? throw new ArgumentNullException(nameof(text))), level);
/// <param name="mask">Masque imposé (tests) ; null : celui qui rend le symbole le plus lisible.</param>
public static QrCode EncodeBytes(byte[] data, QrErrorCorrection level = QrErrorCorrection.Medium, int? mask = null)
{
if (mask is < 0 or > 7) throw new ArgumentOutOfRangeException(nameof(mask));
var version = MinVersion;
for (; ; version++)
{
if (version > MaxVersion) throw new ArgumentException("Texte trop long pour un QR code.", nameof(data));
var needed = 4 + CharCountBits(version) + data.Length * 8;
if (data.Length < (1 << CharCountBits(version)) && needed <= DataCodewords(version, level) * 8) break;
}
// Segment « octets », terminaison, remplissage.
var bits = new BitWriter();
bits.Append(0b0100, 4);
bits.Append(data.Length, CharCountBits(version));
foreach (var b in data) bits.Append(b, 8);
var capacity = DataCodewords(version, level) * 8;
bits.Append(0, Math.Min(4, capacity - bits.Length));
bits.Append(0, (8 - bits.Length % 8) % 8);
for (var pad = 0xEC; bits.Length < capacity; pad ^= 0xEC ^ 0x11) bits.Append(pad, 8);
var qr = new QrCode(version, level);
qr.DrawFunctionPatterns();
qr.DrawCodewords(qr.AddErrorCorrection(bits.ToBytes()));
if (mask is { } forced)
{
qr.ApplyMask(forced);
qr.DrawFormatBits(forced);
qr.Mask = forced;
return qr;
}
var best = 0;
var bestPenalty = int.MaxValue;
for (var m = 0; m < 8; m++)
{
qr.ApplyMask(m);
qr.DrawFormatBits(m);
var penalty = qr.Penalty();
if (penalty < bestPenalty)
{
best = m;
bestPenalty = penalty;
}
qr.ApplyMask(m); // XOR : annule le masque
}
qr.ApplyMask(best);
qr.DrawFormatBits(best);
qr.Mask = best;
return qr;
}
/// <summary>
/// Tracé vectoriel (syntaxe des chemins SVG / WPF) : un rectangle par suite horizontale de modules sombres, une unité
/// par module, marge claire de <paramref name="border"/> modules comprise dans les coordonnées.
/// </summary>
public string ToPathData(int border = 4)
{
var builder = new StringBuilder();
for (var y = 0; y < Size; y++)
{
for (var x = 0; x < Size; x++)
{
if (!_modules[y, x]) continue;
var start = x;
while (x + 1 < Size && _modules[y, x + 1]) x++;
var length = x - start + 1;
builder.Append(CultureInfo.InvariantCulture, $"M{start + border},{y + border}h{length}v1h-{length}z");
}
}
return builder.ToString();
}
// ------------------------------------------------------------------ Motifs fixes
private void DrawFunctionPatterns()
{
for (var i = 0; i < Size; i++)
{
SetFunction(6, i, i % 2 == 0);
SetFunction(i, 6, i % 2 == 0);
}
DrawFinder(3, 3);
DrawFinder(Size - 4, 3);
DrawFinder(3, Size - 4);
var positions = AlignmentPositions();
var count = positions.Length;
for (var i = 0; i < count; i++)
{
for (var j = 0; j < count; j++)
{
if ((i == 0 && j == 0) || (i == 0 && j == count - 1) || (i == count - 1 && j == 0)) continue; // coins des repères
DrawAlignment(positions[i], positions[j]);
}
}
DrawFormatBits(0); // réservés, remplacés après le choix du masque
DrawVersion();
}
private void DrawFinder(int cx, int cy)
{
for (var dy = -4; dy <= 4; dy++)
{
for (var dx = -4; dx <= 4; dx++)
{
var distance = Math.Max(Math.Abs(dx), Math.Abs(dy));
var x = cx + dx;
var y = cy + dy;
if (x >= 0 && x < Size && y >= 0 && y < Size) SetFunction(x, y, distance != 2 && distance != 4);
}
}
}
private void DrawAlignment(int cx, int cy)
{
for (var dy = -2; dy <= 2; dy++)
for (var dx = -2; dx <= 2; dx++)
SetFunction(cx + dx, cy + dy, Math.Max(Math.Abs(dx), Math.Abs(dy)) != 1);
}
private int[] AlignmentPositions()
{
if (Version == 1) return Array.Empty<int>();
var count = Version / 7 + 2;
var step = (Version * 8 + count * 3 + 5) / (count * 4 - 4) * 2;
var result = new int[count];
result[0] = 6;
for (int i = count - 1, position = Size - 7; i >= 1; i--, position -= step) result[i] = position;
return result;
}
private void DrawFormatBits(int mask)
{
var data = FormatLevelBits(ErrorCorrection) << 3 | mask;
var remainder = data;
for (var i = 0; i < 10; i++) remainder = (remainder << 1) ^ ((remainder >> 9) * 0x537);
var bits = ((data << 10) | remainder) ^ 0x5412;
for (var i = 0; i <= 5; i++) SetFunction(8, i, Bit(bits, i));
SetFunction(8, 7, Bit(bits, 6));
SetFunction(8, 8, Bit(bits, 7));
SetFunction(7, 8, Bit(bits, 8));
for (var i = 9; i < 15; i++) SetFunction(14 - i, 8, Bit(bits, i));
for (var i = 0; i < 8; i++) SetFunction(Size - 1 - i, 8, Bit(bits, i));
for (var i = 8; i < 15; i++) SetFunction(8, Size - 15 + i, Bit(bits, i));
SetFunction(8, Size - 8, true); // module toujours sombre
}
private void DrawVersion()
{
if (Version < 7) return;
var remainder = Version;
for (var i = 0; i < 12; i++) remainder = (remainder << 1) ^ ((remainder >> 11) * 0x1F25);
var bits = (Version << 12) | remainder;
for (var i = 0; i < 18; i++)
{
var dark = Bit(bits, i);
var a = Size - 11 + i % 3;
var b = i / 3;
SetFunction(a, b, dark);
SetFunction(b, a, dark);
}
}
private void SetFunction(int x, int y, bool dark)
{
_modules[y, x] = dark;
_isFunction[y, x] = true;
}
// ------------------------------------------------------------------ Données et correction d'erreurs
/// <summary>Blocs de données suivis de leur code de Reed-Solomon, entrelacés.</summary>
private byte[] AddErrorCorrection(byte[] data)
{
var level = (int)ErrorCorrection;
var blocks = ErrorCorrectionBlocks[level, Version];
var eccLength = EccCodewordsPerBlock[level, Version];
var raw = RawDataModules(Version) / 8;
var shortBlocks = blocks - raw % blocks;
var shortLength = raw / blocks;
var divisor = ReedSolomonDivisor(eccLength);
var allBlocks = new List<byte[]>(blocks);
for (int i = 0, k = 0; i < blocks; i++)
{
var length = shortLength - eccLength + (i < shortBlocks ? 0 : 1);
var block = new byte[shortLength + 1];
Array.Copy(data, k, block, 0, length);
k += length;
var ecc = ReedSolomonRemainder(block.AsSpan(0, length), divisor);
// Bloc court : une case vide, ignorée à l'entrelacement, garde les colonnes alignées.
Array.Copy(ecc, 0, block, shortLength + 1 - eccLength, eccLength);
allBlocks.Add(block);
}
var result = new byte[raw];
var index = 0;
for (var i = 0; i < shortLength + 1; i++)
{
for (var j = 0; j < allBlocks.Count; j++)
{
if (i != shortLength - eccLength || j >= shortBlocks) result[index++] = allBlocks[j][i];
}
}
return result;
}
private void DrawCodewords(byte[] data)
{
var i = 0;
for (var right = Size - 1; right >= 1; right -= 2)
{
if (right == 6) right = 5; // colonne du motif de synchronisation
for (var vertical = 0; vertical < Size; vertical++)
{
for (var j = 0; j < 2; j++)
{
var x = right - j;
var upward = ((right + 1) & 2) == 0;
var y = upward ? Size - 1 - vertical : vertical;
if (_isFunction[y, x] || i >= data.Length * 8) continue;
_modules[y, x] = Bit(data[i >> 3], 7 - (i & 7));
i++;
}
}
}
}
private void ApplyMask(int mask)
{
for (var y = 0; y < Size; y++)
{
for (var x = 0; x < Size; x++)
{
var invert = mask switch
{
0 => (x + y) % 2 == 0,
1 => y % 2 == 0,
2 => x % 3 == 0,
3 => (x + y) % 3 == 0,
4 => (x / 3 + y / 2) % 2 == 0,
5 => x * y % 2 + x * y % 3 == 0,
6 => (x * y % 2 + x * y % 3) % 2 == 0,
_ => ((x + y) % 2 + x * y % 3) % 2 == 0
};
if (invert && !_isFunction[y, x]) _modules[y, x] = !_modules[y, x];
}
}
}
/// <summary>Pénalités de la norme (suites, carrés, motifs trompeurs, équilibre clair / sombre) : le masque le moins pénalisé est retenu.</summary>
private int Penalty()
{
var result = 0;
for (var pass = 0; pass < 2; pass++)
{
for (var a = 0; a < Size; a++)
{
var run = 1;
var line = new bool[Size];
for (var b = 0; b < Size; b++) line[b] = pass == 0 ? _modules[a, b] : _modules[b, a];
for (var b = 1; b < Size; b++)
{
if (line[b] == line[b - 1])
{
run++;
if (run == 5) result += 3;
else if (run > 5) result++;
}
else
{
run = 1;
}
}
for (var b = 0; b + 11 <= Size; b++)
{
if (Matches(line, b, FinderLike) || Matches(line, b, FinderLikeReversed)) result += 40;
}
}
}
for (var y = 0; y < Size - 1; y++)
{
for (var x = 0; x < Size - 1; x++)
{
var color = _modules[y, x];
if (color == _modules[y, x + 1] && color == _modules[y + 1, x] && color == _modules[y + 1, x + 1]) result += 3;
}
}
var dark = 0;
foreach (var module in _modules) if (module) dark++;
var total = Size * Size;
var k = (Math.Abs(dark * 20 - total * 10) + total - 1) / total - 1;
return result + k * 10;
}
private static readonly bool[] FinderLike = { true, false, true, true, true, false, true, false, false, false, false };
private static readonly bool[] FinderLikeReversed = FinderLike.Reverse().ToArray();
private static bool Matches(bool[] line, int start, bool[] pattern)
{
for (var i = 0; i < pattern.Length; i++) if (line[start + i] != pattern[i]) return false;
return true;
}
// ------------------------------------------------------------------ Reed-Solomon sur GF(256), polynôme 0x11D
private static byte[] ReedSolomonDivisor(int degree)
{
var result = new byte[degree];
result[degree - 1] = 1;
var root = 1;
for (var i = 0; i < degree; i++)
{
for (var j = 0; j < degree; j++)
{
result[j] = Multiply(result[j], root);
if (j + 1 < degree) result[j] ^= result[j + 1];
}
root = Multiply(root, 0x02);
}
return result;
}
private static byte[] ReedSolomonRemainder(ReadOnlySpan<byte> data, byte[] divisor)
{
var result = new byte[divisor.Length];
foreach (var b in data)
{
var factor = b ^ result[0];
Array.Copy(result, 1, result, 0, result.Length - 1);
result[^1] = 0;
for (var i = 0; i < result.Length; i++) result[i] ^= Multiply(divisor[i], factor);
}
return result;
}
private static byte Multiply(int x, int y)
{
var z = 0;
for (var i = 7; i >= 0; i--)
{
z = (z << 1) ^ ((z >> 7) * 0x11D);
z ^= ((y >> i) & 1) * x;
}
return (byte)z;
}
// ------------------------------------------------------------------ Tables de la norme
private static int CharCountBits(int version) => version <= 9 ? 8 : 16;
private static int FormatLevelBits(QrErrorCorrection level) => level switch
{
QrErrorCorrection.Low => 1,
QrErrorCorrection.Medium => 0,
QrErrorCorrection.Quartile => 3,
_ => 2
};
/// <summary>Modules disponibles pour les données et la correction (hors motifs fixes et informations de format / version).</summary>
internal static int RawDataModules(int version)
{
var result = (16 * version + 128) * version + 64;
if (version >= 2)
{
var alignments = version / 7 + 2;
result -= (25 * alignments - 10) * alignments - 55;
if (version >= 7) result -= 36;
}
return result;
}
internal static int DataCodewords(int version, QrErrorCorrection level) =>
RawDataModules(version) / 8 - EccCodewordsPerBlock[(int)level, version] * ErrorCorrectionBlocks[(int)level, version];
private static readonly int[,] EccCodewordsPerBlock =
{
{ -1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30 },
{ -1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28 },
{ -1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30 },
{ -1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30 }
};
private static readonly int[,] ErrorCorrectionBlocks =
{
{ -1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25 },
{ -1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49 },
{ -1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68 },
{ -1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81 }
};
private static bool Bit(int value, int index) => ((value >> index) & 1) != 0;
/// <summary>Suite de bits, du plus significatif au moins significatif.</summary>
private sealed class BitWriter
{
private readonly List<bool> _bits = new();
public int Length => _bits.Count;
public void Append(int value, int count)
{
for (var i = count - 1; i >= 0; i--) _bits.Add(((value >> i) & 1) != 0);
}
public byte[] ToBytes()
{
var result = new byte[_bits.Count / 8];
for (var i = 0; i < _bits.Count; i++)
if (_bits[i]) result[i >> 3] |= (byte)(0x80 >> (i & 7));
return result;
}
}
}
@@ -58,6 +58,8 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
new("files.", "Fichiers du serveur"),
new("keyring.", "Signature (trousseau)"),
new("feedback.", "Demandes des joueurs"),
new("players.", "Comptes joueurs"),
new("maintenance.", "Mode maintenance"),
new("audit.", "Journal d'audit (consultations, exports, purges)"),
new("security", "Refus de sécurité")
};
@@ -504,6 +506,34 @@ 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é",
"players.view" => "Comptes joueurs consultés",
"players.search" => "Compte joueur recherché",
"players.disable" => "Compte joueur désactivé",
"players.enable" => "Compte joueur réactivé",
"players.unlock" => "Suspension ou blocage d'un compte joueur levé",
"players.password_required" => "Nouveau mot de passe d'un joueur exigé (ou plus exigé)",
"players.temporary_password" => "Mot de passe temporaire créé pour un joueur",
"players.otp_reset" => "Double authentification d'un joueur réinitialisée",
"players.logout" => "Appareils d'un joueur déconnectés",
"players.delete" => "Compte joueur supprimé",
"players.ban" => "Blocage d'un joueur ou d'une adresse IP",
"players.unban" => "Blocage levé",
"players.bans_purge" => "Blocages échus et identifiants matériels anciens supprimés (conservation)",
"players.suspend" => "Compte joueur suspendu automatiquement (trop d'essais)",
"players.self_delete" => "Compte joueur supprimé par son titulaire",
"players.inactive" => "Comptes joueurs inactifs (mise en sommeil, suppression)",
"players.inactive_delete" => "Compte joueur inactif supprimé",
"players.events_purge" => "Ancienne activité des comptes joueurs supprimée (conservation)",
"cli.players_unlock" => "Compte joueur débloqué sur le VPS",
"cli.players_enable" => "Compte joueur réactivé sur le VPS",
"cli.players_disable" => "Compte joueur désactivé sur le VPS",
"cli.players_otp_reset" => "Double authentification d'un joueur réinitialisée sur le VPS",
"files.delete" => "Fichier du serveur supprimé",
"files.prune" => "Dossier « objects » nettoyé",
"request.signature" => "Requête signée refusée",
@@ -524,13 +554,15 @@ public static class AuditTexts
/// <summary>
/// Catégorie : préfixe de l'action (« auth. »…) ; « security » : refus de signature et tentatives regroupées ;
/// « audit. » : consultations et exports du journal (pas les tentatives regroupées, classées en sécurité).
/// « audit. » : consultations et exports du journal (pas les tentatives regroupées, classées en sécurité) ;
/// « players. » : comptes joueurs, commandes du VPS sur ces comptes comprises (aussi dans « Outil du VPS »).
/// </summary>
public static bool InCategory(string action, string? category) => category switch
{
null => true,
"security" => action is "request.signature" or "audit.suppressed",
"audit." => action is "audit.view" or "audit.export" or "audit.purge",
"players." => action.StartsWith("players.", StringComparison.Ordinal) || action.StartsWith("cli.players_", StringComparison.Ordinal),
_ => action.StartsWith(category, StringComparison.Ordinal)
};
@@ -0,0 +1,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.
}
}
}
@@ -0,0 +1,242 @@
using System.Collections.ObjectModel;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Players;
/// <summary>
/// Page « Blocages » (étape 8e, permission players.ban) :
/// • liste des blocages en cours (identifiants matériels et/ou adresses IP, portée, échéance, tentatives refusées, note
/// interne), levée d'un blocage (confirmée, requête signée) ;
/// • blocage d'une adresse IP ou d'une plage SEULE (sans compte joueur) : IPv4 /16 au moins, IPv6 /32 au moins (une adresse
/// IPv6 seule vise son /64), jamais une adresse locale, durée d'un an au plus ; contrôle en direct avec les mêmes règles
/// que le serveur. Les blocages d'un compte précis (identifiants matériels + IP) se posent depuis sa fiche
/// (page « Comptes joueurs »).
/// </summary>
public sealed class BansPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "players.bans";
private bool _enabled = true;
private bool _loaded;
private string? _statusMessage;
private string _ipText = "";
private Choice _scope;
private Choice _duration;
private string _note = "";
public BansPanelViewModel(IAdminContext context) : base(context)
{
Scopes = new[]
{
new Choice(PlayerBanScopes.Register, "Création de compte seulement"),
new Choice(PlayerBanScopes.RegisterAndLogin, "Création de compte et connexion")
};
_scope = Scopes[0];
// Une adresse IP n'est jamais bloquée indéfiniment : un an au plus.
Durations = PlayerBanDurations.All.Where(d => d.Value is not null).ToList();
_duration = Durations[2];
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(LoadAsync), () => !IsBusy);
BanIpCommand = new AsyncRelayCommand(BanIpAsync, () => !IsBusy && _enabled && IpProblem is null && NoteProblem is null && _ipText.Trim().Length > 0);
}
public ObservableCollection<PlayerBanItem> Items { get; } = new();
public bool HasItems => Items.Count > 0;
public string CountText => Items.Count == 0 ? "Aucun blocage en cours" : PlayerTexts.Plural(Items.Count, "blocage en cours", "blocages en cours");
public bool ShowEmpty => _loaded && _enabled && Items.Count == 0 && !IsBusy;
/// <summary>Comptes joueurs désactivés sur le serveur (PlayerAccounts:Enabled = false) : rien à bloquer.</summary>
public bool ShowDisabledService => _loaded && !_enabled;
public bool IsServiceEnabled => _enabled;
public bool IsEditable => !IsBusy;
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(_statusMessage);
// ------------------------------------------------------------------ Blocage d'une adresse IP seule
/// <summary>Adresse IP ou plage en notation CIDR (203.0.113.7, 198.51.100.0/24, 2001:db8::/48…).</summary>
public string IpText
{
get => _ipText;
set
{
if (SetProperty(ref _ipText, value ?? "")) RaiseForm();
}
}
public IReadOnlyList<Choice> Scopes { get; }
public Choice Scope
{
get => _scope;
set
{
if (value is not null && SetProperty(ref _scope, value)) RaiseForm();
}
}
public IReadOnlyList<Choice> Durations { get; }
public Choice Duration
{
get => _duration;
set
{
if (value is not null && SetProperty(ref _duration, value)) RaiseForm();
}
}
public string Note
{
get => _note;
set
{
if (SetProperty(ref _note, value ?? "")) RaiseForm();
}
}
public int NoteMaxLength => PlayerBanRules.MaxNoteLength;
/// <summary>Problème de l'adresse saisie (null si vide ou valide).</summary>
public string? IpProblem
{
get
{
if (_ipText.Trim().Length == 0) return null;
PlayerBanRules.ParseIpTarget(_ipText, out var problem);
return problem;
}
}
/// <summary>Ce qui sera bloqué (plage normalisée), ou null.</summary>
public string? IpPreview
{
get
{
if (_ipText.Trim().Length == 0) return null;
return PlayerBanRules.ParseIpTarget(_ipText, out _) is { } target
? $"Sera bloqué : {PlayerBanRules.Display(target.Network, target.Prefix)}"
: null;
}
}
public string? NoteProblem
{
get
{
PlayerBanRules.CleanNote(_note, out var problem);
return problem;
}
}
public string? FormProblem => IpProblem ?? NoteProblem;
public bool HasFormProblem => FormProblem is not null;
public bool HasIpPreview => IpPreview is not null && FormProblem is null;
private void RaiseForm()
{
foreach (var name in new[] { nameof(IpProblem), nameof(IpPreview), nameof(NoteProblem), nameof(FormProblem), nameof(HasFormProblem), nameof(HasIpPreview) })
OnPropertyChanged(name);
BanIpCommand.RaiseCanExecuteChanged();
}
private async Task BanIpAsync()
{
if (PlayerBanRules.ParseIpTarget(_ipText, out var problem) is not { } target)
{
ErrorMessage = problem;
return;
}
var display = PlayerBanRules.Display(target.Network, target.Prefix);
var until = PlayerBanDurations.Until(_duration, DateTimeOffset.UtcNow);
var login = PlayerBanScopes.BlocksLogin(_scope.Value);
if (!Context.Dialogs.Confirm("Bloquer cette adresse IP ?",
$"{display} sera bloquée, {PlayerTexts.Until(until)}, pour {(login ? "la création de compte ET la connexion (y compris la reconnexion automatique)" : "la création de compte")}. " +
"Une adresse IP peut être partagée (box familiale, réseau d'un établissement, fournisseur mobile) : tous les joueurs qui l'utilisent seront bloqués. " +
"Cette action est inscrite au journal d'audit.",
"Bloquer", danger: true))
return;
var note = PlayerBanRules.CleanNote(_note, out _);
var ok = await RunAsync(async () =>
{
StatusMessage = null;
var ban = await Context.Session.BanIpAsync(_ipText.Trim(), _scope.Value!, until, note);
await RefreshAsync();
StatusMessage = $"Blocage {ban.Reference} en place : {display}.";
});
if (ok)
{
_ipText = "";
_note = "";
OnPropertyChanged(nameof(IpText));
OnPropertyChanged(nameof(Note));
RaiseForm();
}
}
// ------------------------------------------------------------------ Liste
public AsyncRelayCommand ReloadCommand { get; }
public AsyncRelayCommand BanIpCommand { get; }
protected override void OnBusyChanged()
{
ReloadCommand.RaiseCanExecuteChanged();
BanIpCommand.RaiseCanExecuteChanged();
OnPropertyChanged(nameof(ShowEmpty));
OnPropertyChanged(nameof(IsEditable));
}
protected override Task OnActivatedAsync(bool firstTime) => LoadAsync();
public Task LoadAsync() => RunAsync(async () =>
{
StatusMessage = null;
await RefreshAsync();
});
private async Task RefreshAsync()
{
var page = await Context.Session.GetBansAsync();
_enabled = page.Enabled;
_loaded = true;
var canLift = Context.Session.Has(AdminPermissions.PlayersBan);
Items.Clear();
foreach (var ban in page.Items) Items.Add(new PlayerBanItem(ban, canLift, LiftAsync));
foreach (var name in new[] { nameof(HasItems), nameof(CountText), nameof(ShowEmpty), nameof(ShowDisabledService), nameof(IsServiceEnabled) })
OnPropertyChanged(name);
BanIpCommand.RaiseCanExecuteChanged();
}
private async Task LiftAsync(PlayerBanItem ban)
{
if (!Context.Dialogs.Confirm("Lever le blocage ?",
$"Le blocage {ban.Reference} ({ban.TargetsText}) sera levé : création de compte{(ban.BlocksLogin ? " et connexion" : "")} de nouveau possibles.",
"Lever le blocage"))
return;
await RunAsync(async () =>
{
StatusMessage = null;
await Context.Session.UnbanAsync(ban.Reference);
await RefreshAsync();
StatusMessage = $"Blocage {ban.Reference} levé.";
});
}
}
@@ -0,0 +1,313 @@
using System.Collections.ObjectModel;
using System.Globalization;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Players;
/// <summary>Textes de dates des comptes (heure du PC).</summary>
internal static class PlayerTexts
{
public static string Date(DateTimeOffset date) => date.ToLocalTime().ToString("dd/MM/yyyy", CultureInfo.InvariantCulture);
public static string DateTime(DateTimeOffset date) => date.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
public static string Time(DateTimeOffset date) => date.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture);
public static string Plural(int count, string one, string many) => $"{count} {(count > 1 ? many : one)}";
/// <summary>« jusqu'au 18/11/2026 à 14:02 » ou « indéfiniment ».</summary>
public static string Until(DateTimeOffset? until) => until is { } end ? $"jusqu'au {DateTime(end)}" : "indéfiniment";
/// <summary>« IP 203.0.113.24 · Carte mère (SMBIOS) 3f9a0c… ».</summary>
public static string Targets(PlayerBan ban) => string.Join(" · ", ban.Targets.Select(t =>
t.Kind == PlayerBanTargetKinds.Ip ? $"IP {t.Display}" : $"{PlayerHardware.Label(t.Kind)} {t.Display}…"));
/// <summary>« Actif », « Désactivé jusqu'au 18/11/2026 »…</summary>
public static string Status(PlayerAdminSummary summary) =>
summary.Status == PlayerAccountStatuses.Disabled
? summary.DisabledUntil is { } until ? $"Désactivé jusqu'au {Date(until)}" : "Désactivé"
: summary.Status == PlayerAccountStatuses.Suspended ? "Suspendu"
: PlayerAccountStatuses.Label(summary.Status);
}
/// <summary>Compte dans la liste (adresse masquée).</summary>
public sealed class PlayerListItem : ObservableObject
{
private PlayerAdminSummary _summary;
public PlayerListItem(PlayerAdminSummary summary) => _summary = summary;
public PlayerAdminSummary Summary
{
get => _summary;
set
{
_summary = value;
OnPropertyChanged(string.Empty); // toutes les propriétés
}
}
public string Reference => _summary.Reference;
public string EmailMasked => _summary.EmailMasked;
public string StatusLabel => PlayerTexts.Status(_summary);
public bool IsActive => _summary.Status == PlayerAccountStatuses.Active;
public bool IsDisabled => _summary.Status == PlayerAccountStatuses.Disabled;
public bool IsSuspended => _summary.Status == PlayerAccountStatuses.Suspended;
public bool IsInactive => _summary.Status == PlayerAccountStatuses.Inactive;
public bool IsLocked => _summary.LockedUntil is not null;
public bool OtpEnabled => _summary.OtpEnabled;
public bool MustChangePassword => _summary.MustChangePassword;
/// <summary>« Créé le 12/10/2026 · 2 appareils · connexion le 08/11/2026 ».</summary>
public string DetailText =>
$"Créé le {PlayerTexts.Date(_summary.CreatedAt)} · {PlayerTexts.Plural(_summary.DeviceCount, "appareil", "appareils")}" +
(_summary.LastLoginAt is { } login ? $" · connexion le {PlayerTexts.Date(login)}" : "");
/// <summary>Infobulle des pastilles (blocage, double authentification, nouveau mot de passe).</summary>
public string FlagsText => string.Join(" · ", new[]
{
IsLocked ? $"bloqué jusqu'à {PlayerTexts.Time(_summary.LockedUntil!.Value)} (trop d'essais)" : null,
OtpEnabled ? "double authentification activée" : null,
MustChangePassword ? "nouveau mot de passe exigé" : null
}.Where(t => t is not null));
}
/// <summary>Ligne « libellé : valeur » de la fiche (en orange si elle demande de l'attention).</summary>
public sealed record PlayerFact(string Label, string Value, bool Warning = false);
/// <summary>Appareil connecté d'un compte (déconnexion possible avec players.manage).</summary>
public sealed class PlayerDeviceItem
{
public PlayerDeviceItem(PlayerAdminDevice device, bool canManage, Func<PlayerDeviceItem, Task> logout)
{
Device = device;
CanManage = canManage;
LogoutCommand = new AsyncRelayCommand(() => logout(this), () => CanManage);
}
public PlayerAdminDevice Device { get; }
public string Id => Device.Id;
public string Label => Device.Label;
public bool CanManage { get; }
public string DetailText =>
$"Clé {Device.KeyId} · connecté le {PlayerTexts.Date(Device.CreatedAt)} · utilisé le {PlayerTexts.DateTime(Device.LastUsedAt)}" +
$" · mémorisé jusqu'au {PlayerTexts.Date(Device.RememberedUntil)}";
public AsyncRelayCommand LogoutCommand { get; }
}
/// <summary>Ligne de l'activité du compte (adresse IP complète).</summary>
public sealed class PlayerEventItem
{
public PlayerEventItem(PlayerAdminEvent e)
{
Event = e;
TimeText = PlayerTexts.DateTime(e.At);
Label = PlayerActivityText.Describe(e.Type, e.Success);
OriginText = string.Join(" · ", new[]
{
e.Ip is { } ip ? $"IP {ip}" : null,
e.Device is { } device ? $"appareil « {device} »" : e.DeviceKeyId is { } key ? $"clé {key}" : null
}.Where(t => t is not null));
}
public PlayerAdminEvent Event { get; }
public string TimeText { get; }
public string Label { get; }
/// <summary>Code technique (« account.login »), en infobulle.</summary>
public string Type => Event.Type;
public bool IsFailure => !Event.Success;
public string OriginText { get; }
public bool HasOrigin => OriginText.Length > 0;
public string Details => Event.Details ?? "";
public bool HasDetails => !string.IsNullOrEmpty(Event.Details);
}
/// <summary>Blocage en vigueur (fiche d'un compte, page « Blocages »).</summary>
public sealed class PlayerBanItem
{
public PlayerBanItem(PlayerBan ban, bool canLift, Func<PlayerBanItem, Task>? lift = null)
{
Ban = ban;
CanLift = canLift && lift is not null;
LiftCommand = new AsyncRelayCommand(() => lift?.Invoke(this) ?? Task.CompletedTask, () => CanLift);
}
public PlayerBan Ban { get; }
public string Reference => Ban.Reference;
public string ScopeLabel => PlayerBanScopes.Label(Ban.Scope);
public bool BlocksLogin => PlayerBanScopes.BlocksLogin(Ban.Scope);
public string TargetsText => PlayerTexts.Targets(Ban);
public string PeriodText => $"Depuis le {PlayerTexts.DateTime(Ban.CreatedAt)} par {Ban.CreatedBy}, {PlayerTexts.Until(Ban.ExpiresAt)}";
public string HitsText => Ban.Hits == 0 ? "Aucune tentative refusée" :
$"{PlayerTexts.Plural(Ban.Hits, "tentative refusée", "tentatives refusées")}" +
(Ban.LastHitAt is { } last ? $", la dernière le {PlayerTexts.DateTime(last)}" : "");
public string? AccountText => Ban.AccountReference is { } account ? $"Depuis la fiche du compte {account}" : null;
/// <summary>« · depuis la fiche du compte PL-… » à la suite des tentatives (vide sinon).</summary>
public string AccountSuffix => Ban.AccountReference is { } account ? $" · depuis la fiche du compte {account}" : "";
public bool HasAccount => Ban.AccountReference is not null;
public string? Note => Ban.Note;
public bool HasNote => !string.IsNullOrEmpty(Ban.Note);
public bool CanLift { get; }
public AsyncRelayCommand LiftCommand { get; }
}
/// <summary>Identifiant matériel connu d'un compte (empreinte abrégée).</summary>
public sealed record PlayerHardwareItem(PlayerAdminHardware Hardware)
{
public string Label => PlayerHardware.Label(Hardware.Kind);
public string DetailText => $"Empreinte {Hardware.Fingerprint}… · vu du {PlayerTexts.Date(Hardware.FirstSeenAt)} au {PlayerTexts.Date(Hardware.LastSeenAt)}";
public bool IsBanned => Hardware.BannedBy is not null;
public string? BannedText => Hardware.BannedBy is { } reference ? $"Bloqué ({reference})" : null;
}
/// <summary>Fiche d'un compte (lecture) et état des actions possibles.</summary>
public sealed class PlayerDetailView
{
public PlayerDetailView(PlayerAdminDetail detail, bool canManage, bool canDelete, Func<PlayerDeviceItem, Task> logoutDevice,
Action<string> copy, bool canBan = false, Func<PlayerBanItem, Task>? liftBan = null)
{
Detail = detail;
CanManage = canManage;
CanDelete = canDelete;
CanBan = canBan;
Hardware = detail.Hardware.Select(h => new PlayerHardwareItem(h)).ToList();
Bans = detail.Bans.Select(b => new PlayerBanItem(b, canBan, liftBan)).ToList();
var s = detail.Summary;
Devices = new ObservableCollection<PlayerDeviceItem>(detail.Devices.Select(d => new PlayerDeviceItem(d, canManage, logoutDevice)));
Events = detail.Events.Select(e => new PlayerEventItem(e)).ToList();
CopyReferenceCommand = new RelayCommand(() => copy(s.Reference));
var facts = new List<PlayerFact>
{
new("Créé le", $"{PlayerTexts.DateTime(s.CreatedAt)} (conditions version {detail.TermsVersion})"),
new("Dernière connexion", s.LastLoginAt is { } login ? PlayerTexts.DateTime(login) : "jamais"),
new("Dernière activité", s.LastActivityAt is { } used ? $"{PlayerTexts.DateTime(used)} (reconnexion automatique comprise)" : "aucun appareil connecté"),
new("Adresse e-mail", detail.EmailVerified ? "vérifiée" : "non vérifiée (module e-mail à venir)"),
new("Mot de passe", $"changé le {PlayerTexts.DateTime(detail.PasswordChangedAt)}" +
(s.MustChangePassword ? " · nouveau mot de passe exigé à la prochaine connexion" : ""), s.MustChangePassword)
};
if (detail.TemporaryPasswordExpiresAt is { } temporary)
facts.Add(new("Mot de passe temporaire", $"en attente, valable jusqu'à {PlayerTexts.Time(temporary)} (le {PlayerTexts.Date(temporary)})", true));
facts.Add(new("Double authentification", s.OtpEnabled
? $"activée · {PlayerTexts.Plural(detail.RecoveryCodesLeft, "code de secours restant", "codes de secours restants")}"
: "désactivée", s.OtpEnabled && detail.RecoveryCodesLeft <= 3));
facts.Add(new("Échecs de connexion", FailuresText(detail), s.LockedUntil is not null || detail.FailedCount > 0));
Facts = facts;
}
public PlayerAdminDetail Detail { get; }
public PlayerAdminSummary Summary => Detail.Summary;
public string Reference => Summary.Reference;
public string EmailMasked => Summary.EmailMasked;
public bool CanManage { get; }
public bool CanDelete { get; }
// ------------------------------------------------------------------ Identifiants et blocages (étape 8e)
public bool CanBan { get; }
public IReadOnlyList<PlayerHardwareItem> Hardware { get; }
public bool HasHardware => Hardware.Count > 0;
public IReadOnlyList<PlayerBanItem> Bans { get; }
public bool HasBans => Bans.Count > 0;
/// <summary>« 203.0.113.24 (le 08/11/2026 à 21:14) » ou un texte d'absence.</summary>
public string LastIpText => Detail.LastIp is { } ip
? $"Dernière adresse IP : {ip}{(Detail.LastIpAt is { } at ? $" (le {PlayerTexts.DateTime(at)})" : "")}"
: "Dernière adresse IP : aucune connue";
public bool HasLastIp => Detail.LastIp is not null;
/// <summary>Ni identifiant matériel ni adresse IP connus (compte sans connexion récente, ou données purgées).</summary>
public bool HasNoIdentifiers => !HasHardware && !HasLastIp;
public string HardwareTitle => Hardware.Count == 0
? "Identifiants matériels : aucun connu (launcher plus ancien, ou pas de connexion depuis)"
: $"Identifiants matériels ({Hardware.Count})";
public string StatusLabel => PlayerTexts.Status(Summary);
public bool IsActive => Summary.Status == PlayerAccountStatuses.Active;
public bool IsDisabled => Summary.Status == PlayerAccountStatuses.Disabled;
public bool IsSuspended => Summary.Status == PlayerAccountStatuses.Suspended;
public bool IsInactive => Summary.Status == PlayerAccountStatuses.Inactive;
/// <summary>Explication de l'état (« Désactivé le 03/11/2026 à 14:02 par Nicolas (menu d'administration) »).</summary>
public string StatusText
{
get
{
var text = Summary.Status switch
{
PlayerAccountStatuses.Disabled => "Connexions refusées" + (Summary.DisabledUntil is { } until
? $" jusqu'au {PlayerTexts.DateTime(until)} (réactivation automatique)."
: " jusqu'à sa réactivation."),
PlayerAccountStatuses.Suspended => "Trop d'essais de mot de passe : les connexions depuis un NOUVEL appareil sont refusées ; " +
"les appareils déjà connectés fonctionnent.",
PlayerAccountStatuses.Inactive => "Mis en sommeil après une longue inactivité (appareils déconnectés) : une connexion avec le bon " +
"mot de passe le réveille.",
_ => "Compte utilisable."
};
if (Detail.StatusChangedAt is { } at && !IsActive)
text += $" Depuis le {PlayerTexts.DateTime(at)}{(Detail.StatusChangedBy is { } by ? $", par {by}" : "")}.";
return text;
}
}
public string? StatusReason => Detail.StatusReason;
public bool HasReason => !string.IsNullOrEmpty(Detail.StatusReason);
public IReadOnlyList<PlayerFact> Facts { get; }
public ObservableCollection<PlayerDeviceItem> Devices { get; }
public bool HasDevices => Devices.Count > 0;
public string DevicesTitle => $"Appareils connectés ({Devices.Count})";
public IReadOnlyList<PlayerEventItem> Events { get; }
public bool HasEvents => Events.Count > 0;
public string EventsTitle => $"Activité ({PlayerTexts.Plural(Events.Count, "ligne", "dernières lignes")})";
// ------------------------------------------------------------------ Actions possibles (affichage des boutons)
public bool CanEnable => CanManage && Summary.Status is PlayerAccountStatuses.Disabled or PlayerAccountStatuses.Inactive;
public bool CanUnlock => CanManage && (IsSuspended || Summary.LockedUntil is not null || Detail.FailedCount > 0 || Detail.LockLevel > 0 ||
Detail.TrustedFailedCount > 0);
public bool CanRequirePassword => CanManage && !Summary.MustChangePassword;
public bool CanCancelPasswordRequirement => CanManage && Summary.MustChangePassword;
public bool CanCreateTemporaryPassword => CanManage && !IsDisabled;
public bool CanResetOtp => CanManage && Summary.OtpEnabled;
public bool CanLogoutAll => CanManage && HasDevices;
public string DisableText => IsDisabled ? "Modifier la désactivation…" : "Désactiver…";
public string UnlockText => IsSuspended ? "Lever la suspension" : "Lever le blocage";
public RelayCommand CopyReferenceCommand { get; }
private static string FailuresText(PlayerAdminDetail d)
{
var parts = new List<string>();
if (d.FailedCount > 0 || d.LockLevel > 0)
parts.Add($"{d.FailedCount} en cours depuis des appareils inconnus, {PlayerTexts.Plural(d.LockLevel, "blocage", "blocages")}");
if (d.UnknownDevicesLockedUntil is { } unknown) parts.Add($"bloqué jusqu'à {PlayerTexts.Time(unknown)} (appareils inconnus)");
if (d.TrustedFailedCount > 0) parts.Add($"{d.TrustedFailedCount} depuis les appareils connectés");
if (d.TrustedDevicesLockedUntil is { } trusted) parts.Add($"bloqué jusqu'à {PlayerTexts.Time(trusted)} (appareils connectés)");
return parts.Count == 0 ? "aucun" : string.Join(" · ", parts);
}
}
/// <summary>Durées proposées pour un blocage (valeur : nombre de jours ; null : indéfiniment).</summary>
public static class PlayerBanDurations
{
public static readonly IReadOnlyList<Catalog.Choice> All = new[]
{
new Catalog.Choice("1", "24 heures"), new Catalog.Choice("7", "7 jours"), new Catalog.Choice("30", "30 jours"),
new Catalog.Choice("90", "90 jours"), new Catalog.Choice("365", "1 an"),
new Catalog.Choice(null, "Indéfiniment (identifiants matériels seulement)")
};
public static DateTimeOffset? Until(Catalog.Choice duration, DateTimeOffset now) =>
duration.Value is { } days ? now.AddDays(int.Parse(days, CultureInfo.InvariantCulture)) : null;
}
@@ -0,0 +1,35 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Players;
/// <summary>
/// Module « Comptes joueurs » (étape 8d) : page de recherche, de consultation et de gestion des comptes des joueurs (lecture :
/// players.read ; actions : players.manage ; suppression : players.delete), et page « Blocages » (étape 8e, players.ban) :
/// blocages par identifiants matériels et adresses IP. La fenêtre « Compte » des joueurs est dans GameLauncher.Modules.Accounts.
/// </summary>
public sealed class PlayersAdminModule : ILauncherModule
{
public string Id => "players";
public string DisplayName => "Comptes joueurs";
public void Register(IModuleRegistry registry)
{
registry.AddAdminPanel(new AdminPanelDescriptor(
PlayersPanelViewModel.PanelId,
"Comptes joueurs",
"Rechercher un compte (référence ou adresse e-mail exacte), le consulter, le désactiver, lever une suspension, donner un mot de passe temporaire…",
"AccountMultiple",
AdminPermissions.PlayersRead,
Order: 12,
context => new PlayersPanelViewModel(context)));
registry.AddAdminPanel(new AdminPanelDescriptor(
BansPanelViewModel.PanelId,
"Blocages",
"Bloquer la création de compte (et la connexion, au choix) par identifiants matériels ou adresse IP, lever un blocage.",
"AccountCancel",
AdminPermissions.PlayersBan,
Order: 13,
context => new BansPanelViewModel(context)));
}
}
@@ -0,0 +1,785 @@
using System.Collections.ObjectModel;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Players;
/// <summary>
/// Page « Comptes joueurs » (étape 8d) :
/// • compteurs, liste filtrée par état (les plus récents d'abord, pages de 100), recherche par référence ou adresse e-mail
/// EXACTE (envoyée dans le corps d'une requête, jamais dans une adresse) ; les adresses ne sont jamais affichées en entier ;
/// • fiche : état, motif, mot de passe, double authentification, échecs, appareils et activité (adresses IP complètes) ;
/// chaque consultation est inscrite au journal d'audit par le serveur ;
/// • actions (players.manage, requêtes signées, confirmées) : désactiver (durée, motif montré au joueur) / réactiver, lever
/// une suspension ou un blocage, exiger un nouveau mot de passe, mot de passe temporaire (affiché une seule fois),
/// réinitialiser la double authentification, déconnecter un appareil ou tous ; suppression définitive (players.delete) ;
/// • étape 8e (players.ban) : identifiants matériels (empreintes) et dernière adresse IP du compte, blocages qui le
/// concernent (levée possible), et « Bloquer… » : ses identifiants matériels et/ou sa dernière adresse IP, pour la
/// création de compte seule ou création + connexion, durée bornée à un an dès qu'une adresse IP est visée.
/// </summary>
public sealed class PlayersPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "players.accounts";
private const int PageSize = 100;
private readonly List<PlayerListItem> _loaded = new();
private PlayersOverview? _overview;
private Choice _filter;
private string _searchText = "";
private string? _searchResultText;
private string? _nextCursor;
private int _total;
private PlayerListItem? _selectedItem;
private PlayerDetailView? _detail;
private string? _statusMessage;
private int _loadVersion;
private bool _isDisableFormOpen;
private Choice _disableDuration;
private string _disableReason = "";
private string? _temporaryPassword;
private string? _temporaryPasswordText;
private bool _isBanFormOpen;
private Choice _banScope;
private Choice _banDuration;
private bool _banHardware = true;
private bool _banIp = true;
private string _banNote = "";
public PlayersPanelViewModel(IAdminContext context) : base(context)
{
Filters = PlayerAdminRules.Filters.Select(f => new Choice(f.Value, f.Label)).ToList();
_filter = Filters[0];
DisableDurations = new[]
{
new Choice("1", "24 heures"), new Choice("7", "7 jours"), new Choice("30", "30 jours"), new Choice("90", "90 jours"),
new Choice("365", "1 an"), new Choice(null, "Indéfiniment (jusqu'à la réactivation)")
};
_disableDuration = DisableDurations[1];
BanScopes = new[]
{
new Choice(PlayerBanScopes.Register, "Création de compte seulement (ses comptes existants se connectent toujours)"),
new Choice(PlayerBanScopes.RegisterAndLogin, "Création de compte et connexion (reconnexion automatique comprise)")
};
_banScope = BanScopes[0];
BanDurations = PlayerBanDurations.All;
_banDuration = BanDurations[2];
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy);
LoadMoreCommand = new AsyncRelayCommand(LoadMoreAsync, () => !IsBusy && _nextCursor is not null && !IsSearchResult);
SearchCommand = new AsyncRelayCommand(SearchAsync, () => !IsBusy && _searchText.Trim().Length > 0 && IsServiceEnabled);
ClearSearchCommand = new AsyncRelayCommand(ClearSearchAsync, () => !IsBusy && IsSearchResult);
OpenDisableCommand = new RelayCommand(() => IsDisableFormOpen = true, () => !IsBusy && _detail is { CanManage: true });
CancelDisableCommand = new RelayCommand(() => IsDisableFormOpen = false);
DisableCommand = new AsyncRelayCommand(DisableAsync, () => !IsBusy && _detail is { CanManage: true } && ReasonProblem is null);
EnableCommand = new AsyncRelayCommand(EnableAsync, () => !IsBusy && _detail is { CanEnable: true });
UnlockCommand = new AsyncRelayCommand(UnlockAsync, () => !IsBusy && _detail is { CanUnlock: true });
RequirePasswordCommand = new AsyncRelayCommand(() => SetPasswordRequirementAsync(true), () => !IsBusy && _detail is { CanRequirePassword: true });
CancelPasswordRequirementCommand = new AsyncRelayCommand(() => SetPasswordRequirementAsync(false),
() => !IsBusy && _detail is { CanCancelPasswordRequirement: true });
TemporaryPasswordCommand = new AsyncRelayCommand(TemporaryPasswordAsync, () => !IsBusy && _detail is { CanCreateTemporaryPassword: true });
ResetOtpCommand = new AsyncRelayCommand(ResetOtpAsync, () => !IsBusy && _detail is { CanResetOtp: true });
LogoutAllCommand = new AsyncRelayCommand(() => LogoutAsync(null), () => !IsBusy && _detail is { CanLogoutAll: true });
DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => !IsBusy && _detail is { CanDelete: true });
CopyTemporaryPasswordCommand = new RelayCommand(() =>
{
if (_temporaryPassword is { } password) Context.Dialogs.CopyToClipboard(password);
}, () => _temporaryPassword is not null);
DismissTemporaryPasswordCommand = new RelayCommand(ForgetTemporaryPassword);
OpenBanCommand = new RelayCommand(OpenBanForm, () => !IsBusy && _detail is { CanBan: true });
CancelBanCommand = new RelayCommand(() => IsBanFormOpen = false);
BanCommand = new AsyncRelayCommand(BanAsync, () => !IsBusy && _detail is { CanBan: true } && BanProblem is null);
}
public bool CanManage => Context.Session.Has(AdminPermissions.PlayersManage);
public bool CanDelete => Context.Session.Has(AdminPermissions.PlayersDelete);
public bool CanBan => Context.Session.Has(AdminPermissions.PlayersBan);
// ------------------------------------------------------------------ Service et compteurs
/// <summary>Faux tant que le serveur n'a pas répondu, ou si les comptes joueurs n'y sont pas activés.</summary>
public bool IsServiceEnabled => _overview is { Enabled: true };
public bool ShowDisabledService => _overview is { Enabled: false };
public bool ShowRegistrationClosed => _overview is { Enabled: true, RegistrationEnabled: false };
/// <summary>Compteurs (« 1 248 comptes », « 12 désactivés »…), seulement ceux qui ne valent pas 0 (hors total).</summary>
public IReadOnlyList<PlayerFact> Counters { get; private set; } = Array.Empty<PlayerFact>();
public string RetentionText => _overview is not { Enabled: true } ? "Chaque consultation d'un compte est inscrite au journal d'audit." :
$"Activité conservée {_overview.EventsRetentionMonths} mois (adresses IP {_overview.EventsIpRetentionMonths} mois) · comptes " +
$"inactifs en sommeil après {_overview.InactiveDisableMonths} mois, supprimés {_overview.InactiveDeleteMonths} mois plus tard · " +
"chaque consultation est inscrite au journal d'audit.";
// ------------------------------------------------------------------ Filtre, recherche et liste
public IReadOnlyList<Choice> Filters { get; }
public Choice Filter
{
get => _filter;
set
{
if (value is null || Equals(value, _filter)) return;
if (IsBusy)
{
OnPropertyChanged(); // la liste de l'écran revient au filtre affiché
return;
}
_filter = value;
OnPropertyChanged();
ListLoad = ReloadListAsync();
}
}
/// <summary>Chargement lancé par le dernier changement de filtre (tests).</summary>
internal Task ListLoad { get; private set; } = Task.CompletedTask;
/// <summary>Référence (« PL-7KQ2-M9XD ») ou adresse e-mail complète.</summary>
public string SearchText
{
get => _searchText;
set
{
if (SetProperty(ref _searchText, value ?? "")) SearchCommand.RaiseCanExecuteChanged();
}
}
public int SearchMaxLength => PlayerAdminRules.MaxSearchLength;
/// <summary>La liste affiche le résultat d'une recherche (pas la liste filtrée).</summary>
public bool IsSearchResult => _searchResultText is not null;
public string? SearchResultText => _searchResultText;
public ObservableCollection<PlayerListItem> Items { get; } = new();
public PlayerListItem? SelectedItem
{
get => _selectedItem;
set
{
if (value is null || ReferenceEquals(value, _selectedItem)) return;
if (IsBusy)
{
OnPropertyChanged();
return;
}
_selectedItem = value;
OnPropertyChanged();
DetailLoad = LoadDetailAsync(value);
}
}
/// <summary>Chargement de la fiche lancé par la dernière sélection (tests).</summary>
internal Task DetailLoad { get; private set; } = Task.CompletedTask;
public PlayerDetailView? Detail
{
get => _detail;
private set
{
if (!SetProperty(ref _detail, value)) return;
OnPropertyChanged(nameof(HasDetail));
OnPropertyChanged(nameof(ShowNoSelection));
IsDisableFormOpen = false;
IsBanFormOpen = false;
RaiseCommands();
}
}
public bool HasDetail => _detail is not null;
public bool ShowNoSelection => _detail is null && Items.Count > 0;
public bool HasItems => Items.Count > 0;
public override bool HasSplitLayout => HasItems;
public string? EmptyText => !IsServiceEnabled || IsBusy || Items.Count > 0 ? null
: IsSearchResult ? "Aucun compte ne correspond à cette référence ou à cette adresse."
: "Aucun compte pour ce filtre.";
/// <summary>« 1 248 comptes · 100 chargés » ou le résultat de la recherche.</summary>
public string CountText => IsSearchResult ? _searchResultText! :
$"{PlayerTexts.Plural(_total, "compte", "comptes")}" + (_loaded.Count < _total ? $" · {_loaded.Count} chargés" : "");
public bool CanLoadMore => _nextCursor is not null && !IsSearchResult;
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(_statusMessage);
public bool IsEditable => !IsBusy;
// ------------------------------------------------------------------ Désactivation (formulaire dans la fiche)
public bool IsDisableFormOpen
{
get => _isDisableFormOpen;
private set
{
if (!SetProperty(ref _isDisableFormOpen, value)) return;
if (value)
{
// Désactivation en cours : le formulaire reprend son motif.
DisableReason = _detail?.StatusReason ?? "";
}
OpenDisableCommand.RaiseCanExecuteChanged();
}
}
public IReadOnlyList<Choice> DisableDurations { get; }
public Choice DisableDuration
{
get => _disableDuration;
set
{
if (value is not null) SetProperty(ref _disableDuration, value);
}
}
/// <summary>Motif facultatif, montré au joueur à la connexion (une ligne, sans lien).</summary>
public string DisableReason
{
get => _disableReason;
set
{
if (!SetProperty(ref _disableReason, value ?? "")) return;
OnPropertyChanged(nameof(ReasonProblem));
OnPropertyChanged(nameof(HasReasonProblem));
DisableCommand.RaiseCanExecuteChanged();
}
}
public int ReasonMaxLength => PlayerAdminRules.MaxReasonLength;
/// <summary>Problème du motif saisi (contrôle en direct, mêmes règles que le serveur), sinon null.</summary>
public string? ReasonProblem
{
get
{
PlayerAdminRules.CleanReason(_disableReason, out var problem);
return problem is null ? null : $"Motif : {problem}";
}
}
public bool HasReasonProblem => ReasonProblem is not null;
// ------------------------------------------------------------------ Blocage depuis la fiche (étape 8e, players.ban)
public bool IsBanFormOpen
{
get => _isBanFormOpen;
private set
{
if (SetProperty(ref _isBanFormOpen, value)) OpenBanCommand.RaiseCanExecuteChanged();
}
}
public IReadOnlyList<Choice> BanScopes { get; }
public Choice BanScope
{
get => _banScope;
set
{
if (value is not null && SetProperty(ref _banScope, value)) RaiseBanForm();
}
}
/// <summary>Durées proposées (« indéfiniment » refusé dès qu'une adresse IP est visée : un an au plus).</summary>
public IReadOnlyList<Choice> BanDurations { get; }
public Choice BanDuration
{
get => _banDuration;
set
{
if (value is not null && SetProperty(ref _banDuration, value)) RaiseBanForm();
}
}
/// <summary>Bloquer les identifiants matériels connus de ses PC.</summary>
public bool BanHardware
{
get => _banHardware;
set
{
if (SetProperty(ref _banHardware, value)) RaiseBanForm();
}
}
/// <summary>Bloquer sa dernière adresse IP connue (IPv6 : son /64).</summary>
public bool BanIp
{
get => _banIp;
set
{
if (SetProperty(ref _banIp, value)) RaiseBanForm();
}
}
/// <summary>Note interne (jamais montrée au joueur).</summary>
public string BanNote
{
get => _banNote;
set
{
if (SetProperty(ref _banNote, value ?? "")) RaiseBanForm();
}
}
public int BanNoteMaxLength => PlayerBanRules.MaxNoteLength;
/// <summary>Ce qui empêche le blocage (contrôle en direct, mêmes règles que le serveur), sinon null.</summary>
public string? BanProblem
{
get
{
if (_detail is not { } detail) return null;
var hardware = _banHardware && detail.HasHardware;
var ip = _banIp && detail.HasLastIp;
if (!hardware && !ip)
return detail.HasHardware || detail.HasLastIp
? "Choisissez les identifiants matériels, l'adresse IP, ou les deux."
: "Rien à bloquer : aucun identifiant matériel ni adresse IP connus pour ce compte.";
if (PlayerBanRules.CheckUntil(PlayerBanDurations.Until(_banDuration, DateTimeOffset.UtcNow), ip, DateTimeOffset.UtcNow) is { } until)
return $"Durée : {until}";
PlayerBanRules.CleanNote(_banNote, out var noteProblem);
return noteProblem is null ? null : $"Note : {noteProblem}";
}
}
public bool HasBanProblem => BanProblem is not null;
private void OpenBanForm()
{
_banHardware = _detail?.HasHardware == true;
_banIp = _detail?.HasLastIp == true;
_banNote = "";
_banScope = BanScopes[0];
_banDuration = BanDurations[2];
IsBanFormOpen = true;
foreach (var name in new[] { nameof(BanHardware), nameof(BanIp), nameof(BanNote), nameof(BanScope), nameof(BanDuration) }) OnPropertyChanged(name);
RaiseBanForm();
}
private void RaiseBanForm()
{
OnPropertyChanged(nameof(BanProblem));
OnPropertyChanged(nameof(HasBanProblem));
BanCommand.RaiseCanExecuteChanged();
}
private async Task BanAsync()
{
if (_detail is not { } detail) return;
if (BanProblem is { } problem)
{
ErrorMessage = problem;
return;
}
var hardware = _banHardware && detail.HasHardware;
var ip = _banIp && detail.HasLastIp;
var until = PlayerBanDurations.Until(_banDuration, DateTimeOffset.UtcNow);
var note = PlayerBanRules.CleanNote(_banNote, out _);
var login = PlayerBanScopes.BlocksLogin(_banScope.Value);
var what = string.Join(" et ", new[]
{
hardware ? PlayerTexts.Plural(detail.Hardware.Count, "identifiant matériel", "identifiants matériels") + " de ses PC" : null,
ip ? $"son adresse IP {detail.Detail.LastIp}" : null
}.Where(t => t is not null));
if (!Context.Dialogs.Confirm("Bloquer ce joueur ?",
$"Seront bloqués, {PlayerTexts.Until(until)} : {what}, pour {(login ? "la création de compte ET la connexion (ses sessions sont fermées)" : "la création de compte")}." +
(ip ? " Une adresse IP peut être partagée (box familiale, réseau d'un établissement, fournisseur mobile) : d'autres joueurs seront aussi bloqués." : "") +
" Le joueur bloqué ne voit que la référence du blocage. Cette action est inscrite au journal d'audit.",
"Bloquer", danger: true))
return;
await ActAsync(r => Context.Session.BanPlayerAsync(r, _banScope.Value!, hardware, ip, until, note),
d => $"Blocage {d.Bans.FirstOrDefault()?.Reference} en place pour le compte {d.Summary.Reference}.");
}
private async Task LiftBanAsync(PlayerBanItem ban)
{
if (_detail is not { } detail) return;
if (!Context.Dialogs.Confirm("Lever le blocage ?",
$"Le blocage {ban.Reference} ({ban.TargetsText}) sera levé : création de compte{(ban.BlocksLogin ? " et connexion" : "")} de nouveau possibles.",
"Lever le blocage"))
return;
await RunAsync(async () =>
{
StatusMessage = null;
await Context.Session.UnbanAsync(ban.Reference);
Show(await Context.Session.GetPlayerAsync(detail.Reference));
StatusMessage = $"Blocage {ban.Reference} levé.";
});
}
// ------------------------------------------------------------------ Mot de passe temporaire (affiché une seule fois)
public bool HasTemporaryPassword => _temporaryPassword is not null;
public string? TemporaryPassword => _temporaryPassword;
public string? TemporaryPasswordText => _temporaryPasswordText;
// ------------------------------------------------------------------ Commandes
public AsyncRelayCommand ReloadCommand { get; }
public AsyncRelayCommand LoadMoreCommand { get; }
public AsyncRelayCommand SearchCommand { get; }
public AsyncRelayCommand ClearSearchCommand { get; }
public RelayCommand OpenDisableCommand { get; }
public RelayCommand CancelDisableCommand { get; }
public AsyncRelayCommand DisableCommand { get; }
public AsyncRelayCommand EnableCommand { get; }
public AsyncRelayCommand UnlockCommand { get; }
public AsyncRelayCommand RequirePasswordCommand { get; }
public AsyncRelayCommand CancelPasswordRequirementCommand { get; }
public AsyncRelayCommand TemporaryPasswordCommand { get; }
public AsyncRelayCommand ResetOtpCommand { get; }
public AsyncRelayCommand LogoutAllCommand { get; }
public AsyncRelayCommand DeleteCommand { get; }
public RelayCommand CopyTemporaryPasswordCommand { get; }
public RelayCommand DismissTemporaryPasswordCommand { get; }
public RelayCommand OpenBanCommand { get; }
public RelayCommand CancelBanCommand { get; }
public AsyncRelayCommand BanCommand { get; }
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(IsEditable));
OnPropertyChanged(nameof(EmptyText));
RaiseCommands();
}
private void RaiseCommands()
{
foreach (var command in new[]
{
ReloadCommand, LoadMoreCommand, SearchCommand, ClearSearchCommand, DisableCommand, EnableCommand, UnlockCommand,
RequirePasswordCommand, CancelPasswordRequirementCommand, TemporaryPasswordCommand, ResetOtpCommand,
LogoutAllCommand, DeleteCommand, BanCommand
})
command.RaiseCanExecuteChanged();
OpenDisableCommand.RaiseCanExecuteChanged();
OpenBanCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
// ------------------------------------------------------------------ Chargement
/// <summary>Compteurs, puis la première page de la liste filtrée (si le service est activé).</summary>
public Task LoadAsync() => RunAsync(async () =>
{
var version = ++_loadVersion;
ApplyOverview(await Context.Session.GetPlayersOverviewAsync());
if (!IsServiceEnabled) return;
var page = await Context.Session.GetPlayersPageAsync(_filter.Value, null, PageSize);
if (version != _loadVersion) return; // filtre changé entre-temps
ClearList();
AddPage(page);
});
private async Task ReloadAsync()
{
var selected = _selectedItem?.Reference;
StatusMessage = null;
if (IsSearchResult && _searchText.Trim().Length > 0) await SearchAsync();
else await LoadAsync();
if (selected is not null && Items.FirstOrDefault(i => i.Reference == selected) is { } again)
{
_selectedItem = again;
OnPropertyChanged(nameof(SelectedItem));
await LoadDetailAsync(again);
}
}
private Task ReloadListAsync()
{
_searchResultText = null;
OnPropertyChanged(nameof(IsSearchResult));
OnPropertyChanged(nameof(SearchResultText));
StatusMessage = null;
return LoadAsync();
}
private Task LoadMoreAsync() => RunAsync(async () =>
{
if (_nextCursor is null || IsSearchResult) return;
var version = _loadVersion;
var page = await Context.Session.GetPlayersPageAsync(_filter.Value, _nextCursor, PageSize);
if (version != _loadVersion) return;
AddPage(page);
});
/// <summary>Recherche par référence ou adresse exacte : la liste n'affiche plus que le résultat.</summary>
private Task SearchAsync() => RunAsync(async () =>
{
var query = _searchText.Trim();
if (query.Length == 0) return;
StatusMessage = null;
var version = ++_loadVersion;
var result = await Context.Session.SearchPlayersAsync(query);
if (version != _loadVersion) return;
ClearList();
_searchResultText = result.Items.Count == 0
? "Aucun compte trouvé"
: result.Kind == "email" ? "Compte trouvé par son adresse e-mail" : "Compte trouvé par sa référence";
OnPropertyChanged(nameof(IsSearchResult));
OnPropertyChanged(nameof(SearchResultText));
_loaded.AddRange(result.Items.Select(s => new PlayerListItem(s)));
_total = result.Items.Count;
ApplyList();
if (Items.Count == 1)
{
_selectedItem = Items[0];
OnPropertyChanged(nameof(SelectedItem));
DetailLoad = LoadDetailCoreAsync(Items[0]);
await DetailLoad;
}
});
private async Task ClearSearchAsync()
{
SearchText = "";
await ReloadListAsync();
}
private Task LoadDetailAsync(PlayerListItem item) => RunAsync(() => LoadDetailCoreAsync(item));
private async Task LoadDetailCoreAsync(PlayerListItem item)
{
var detail = await Context.Session.GetPlayerAsync(item.Reference);
if (!ReferenceEquals(item, _selectedItem)) return; // autre compte choisi entre-temps
ForgetTemporaryPassword();
Show(detail);
}
private void ApplyOverview(PlayersOverview overview)
{
_overview = overview;
var counters = new List<PlayerFact> { new("comptes", overview.Total.ToString("N0")) };
void Add(int count, string label, bool warning = false)
{
if (count > 0) counters.Add(new PlayerFact(label, count.ToString("N0"), warning));
}
Add(overview.Active, overview.Active > 1 ? "actifs" : "actif");
Add(overview.Disabled, overview.Disabled > 1 ? "désactivés" : "désactivé");
Add(overview.Suspended, overview.Suspended > 1 ? "suspendus" : "suspendu", warning: true);
Add(overview.Locked, overview.Locked > 1 ? "bloqués (essais)" : "bloqué (essais)", warning: true);
Add(overview.Inactive, "en sommeil");
Add(overview.PasswordChangeRequired, "nouveau mot de passe exigé");
Add(overview.OtpEnabled, "double authentification");
Add(overview.CreatedLast30Days, overview.CreatedLast30Days > 1 ? "créés en 30 jours" : "créé en 30 jours");
Counters = counters;
foreach (var name in new[] { nameof(Counters), nameof(IsServiceEnabled), nameof(ShowDisabledService), nameof(ShowRegistrationClosed),
nameof(RetentionText), nameof(EmptyText) })
OnPropertyChanged(name);
SearchCommand.RaiseCanExecuteChanged();
}
private void ClearList()
{
_loaded.Clear();
_nextCursor = null;
_total = 0;
_selectedItem = null;
OnPropertyChanged(nameof(SelectedItem));
Detail = null;
ForgetTemporaryPassword();
ApplyList();
}
private void AddPage(PlayerAdminPage page)
{
_loaded.AddRange(page.Items.Select(s => new PlayerListItem(s)));
_nextCursor = page.NextCursor;
_total = page.Total;
ApplyList();
}
private void ApplyList()
{
Items.Clear();
foreach (var item in _loaded) Items.Add(item);
foreach (var name in new[] { nameof(HasItems), nameof(HasSplitLayout), nameof(EmptyText), nameof(CountText), nameof(CanLoadMore),
nameof(ShowNoSelection) })
OnPropertyChanged(name);
LoadMoreCommand.RaiseCanExecuteChanged();
ClearSearchCommand.RaiseCanExecuteChanged();
}
private void Show(PlayerAdminDetail detail)
{
Detail = new PlayerDetailView(detail, CanManage, CanDelete, LogoutDeviceAsync, text => Context.Dialogs.CopyToClipboard(text), CanBan,
LiftBanAsync);
if (_loaded.FirstOrDefault(i => i.Reference == detail.Summary.Reference) is { } item) item.Summary = detail.Summary;
}
// ------------------------------------------------------------------ Actions (requêtes signées)
/// <summary>Action sur le compte affiché, puis fiche à jour, message et compteurs.</summary>
private Task ActAsync(Func<string, Task<PlayerAdminDetail>> action, Func<PlayerAdminDetail, string> message) => RunAsync(async () =>
{
if (_detail is not { } current) return;
StatusMessage = null;
var updated = await action(current.Reference);
Show(updated);
StatusMessage = message(updated);
await RefreshCountsAsync();
});
private async Task DisableAsync()
{
if (_detail is not { } detail) return;
if (PlayerAdminRules.CleanReason(_disableReason, out var problem) is var reason && problem is not null)
{
ErrorMessage = $"Motif : {problem}";
return;
}
DateTimeOffset? until = _disableDuration.Value is { } days ? DateTimeOffset.UtcNow.AddDays(int.Parse(days, System.Globalization.CultureInfo.InvariantCulture)) : null;
var period = until is { } end ? $"jusqu'au {PlayerTexts.DateTime(end)}" : "indéfiniment";
if (!Context.Dialogs.Confirm("Désactiver le compte ?",
$"Le compte {detail.Reference} sera désactivé {period} : ses sessions sont fermées et toute connexion est refusée" +
(reason is null ? "." : $" avec le motif « {reason} ».") + " Cette action est inscrite au journal d'audit.",
"Désactiver", danger: true))
return;
await ActAsync(r => Context.Session.DisablePlayerAsync(r, until, reason), d => $"Compte {d.Summary.Reference} désactivé {period}.");
}
private Task EnableAsync() =>
ActAsync(r => Context.Session.EnablePlayerAsync(r), d => $"Compte {d.Summary.Reference} réactivé.");
private Task UnlockAsync()
{
var suspended = _detail?.IsSuspended == true;
return ActAsync(r => Context.Session.UnlockPlayerAsync(r),
d => suspended ? $"Suspension du compte {d.Summary.Reference} levée." : $"Blocage du compte {d.Summary.Reference} levé.");
}
private async Task SetPasswordRequirementAsync(bool required)
{
if (_detail is not { } detail) return;
if (required && !Context.Dialogs.Confirm("Exiger un nouveau mot de passe ?",
$"À sa prochaine connexion, le titulaire du compte {detail.Reference} devra choisir un nouveau mot de passe (il lui faut " +
"l'actuel, ou un mot de passe temporaire). Ses sessions ouvertes sont fermées et la reconnexion automatique est refusée " +
"d'ici là ; ses autres appareils seront déconnectés au changement.", "Exiger"))
return;
await ActAsync(r => Context.Session.SetPlayerPasswordRequirementAsync(r, required),
d => required ? $"Nouveau mot de passe exigé pour le compte {d.Summary.Reference}." : $"Plus aucun nouveau mot de passe exigé pour {d.Summary.Reference}.");
}
private async Task TemporaryPasswordAsync()
{
if (_detail is not { } detail) return;
if (!Context.Dialogs.Confirm("Créer un mot de passe temporaire ?",
$"Vérifiez d'abord que vous parlez bien au titulaire du compte {detail.Reference} (adresse {detail.EmailMasked}). Le mot de " +
"passe temporaire sera affiché UNE seule fois, valable une heure, et ne servira qu'à choisir un nouveau mot de passe ; " +
"l'ancien reste valable d'ici là. Un mot de passe temporaire précédent est annulé." +
(detail.IsSuspended ? " Le compte est suspendu : levez aussi la suspension s'il se connecte depuis un nouvel appareil." : "") +
(detail.Summary.OtpEnabled ? " La double authentification restera demandée." : ""),
"Créer"))
return;
await RunAsync(async () =>
{
StatusMessage = null;
var created = await Context.Session.CreatePlayerTemporaryPasswordAsync(detail.Reference);
Show(created.Account);
_temporaryPassword = created.Password;
_temporaryPasswordText =
$"Valable jusqu'à {PlayerTexts.Time(created.ExpiresAt)} (une heure), une seule fois, pour choisir un nouveau mot de passe. " +
"Transmettez-le au joueur par un moyen sûr (message privé), jamais dans un salon public : il ne sera plus affiché.";
RaiseTemporaryPassword();
await RefreshCountsAsync();
});
}
private async Task ResetOtpAsync()
{
if (_detail is not { } detail) return;
if (!Context.Dialogs.Confirm("Réinitialiser la double authentification ?",
$"Seulement si le titulaire du compte {detail.Reference} a perdu son téléphone ET ses codes de secours, après avoir vérifié " +
"son identité : la double authentification et les codes de secours sont supprimés ; il pourra la réactiver depuis « Mon compte ».",
"Réinitialiser", danger: true))
return;
await ActAsync(r => Context.Session.ResetPlayerOtpAsync(r), d => $"Double authentification du compte {d.Summary.Reference} réinitialisée.");
}
private async Task LogoutAsync(PlayerDeviceItem? device)
{
if (_detail is not { } detail) return;
var text = device is null
? $"Tous les appareils du compte {detail.Reference} seront déconnectés (sessions mémorisées comprises)."
: $"L'appareil « {device.Label} » du compte {detail.Reference} sera déconnecté.";
if (!Context.Dialogs.Confirm(device is null ? "Déconnecter tous les appareils ?" : "Déconnecter cet appareil ?",
text + " Le joueur devra se reconnecter avec son mot de passe.", "Déconnecter"))
return;
await ActAsync(r => Context.Session.LogoutPlayerDevicesAsync(r, device?.Id),
d => device is null ? $"Tous les appareils du compte {d.Summary.Reference} sont déconnectés." : $"Appareil « {device.Label} » déconnecté.");
}
private Task LogoutDeviceAsync(PlayerDeviceItem device) => LogoutAsync(device);
private async Task DeleteAsync()
{
if (_detail is not { } detail) return;
if (!Context.Dialogs.Confirm("Supprimer définitivement le compte ?",
$"Le compte {detail.Reference} ({detail.EmailMasked}), ses appareils, son activité et ses données dans les autres modules " +
"seront supprimés DÉFINITIVEMENT : c'est irréversible. Seule une ligne du journal d'audit (référence) en gardera la trace. " +
"Le jeu et ses sauvegardes sur le PC du joueur ne sont pas touchés.", "Supprimer définitivement", danger: true))
return;
await RunAsync(async () =>
{
StatusMessage = null;
await Context.Session.DeletePlayerAsync(detail.Reference);
_loaded.RemoveAll(i => i.Reference == detail.Reference);
_total = Math.Max(0, _total - 1);
_selectedItem = null;
OnPropertyChanged(nameof(SelectedItem));
Detail = null;
ForgetTemporaryPassword();
ApplyList();
StatusMessage = $"Compte {detail.Reference} supprimé définitivement.";
await RefreshCountsAsync();
});
}
/// <summary>Compteurs après un changement (sans effet sur la page en cas d'échec).</summary>
private async Task RefreshCountsAsync()
{
try
{
ApplyOverview(await Context.Session.GetPlayersOverviewAsync());
}
catch (ApiException)
{
// compteurs indicatifs : le prochain rechargement les mettra à jour
}
}
private void ForgetTemporaryPassword()
{
if (_temporaryPassword is null) return;
_temporaryPassword = null;
_temporaryPasswordText = null;
RaiseTemporaryPassword();
}
private void RaiseTemporaryPassword()
{
OnPropertyChanged(nameof(HasTemporaryPassword));
OnPropertyChanged(nameof(TemporaryPassword));
OnPropertyChanged(nameof(TemporaryPasswordText));
CopyTemporaryPasswordCommand.RaiseCanExecuteChanged();
}
}
@@ -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)."
+159 -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,8 +21,10 @@ 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.Modules.Admin.Players;
using GameLauncher.Platform;
using GameLauncher.Services;
using GameLauncher.ViewModels;
@@ -57,6 +60,10 @@ public partial class App : Application, ILauncherRestartHost
private HttpDownloader? _http;
private LauncherUpdateService? _updates;
private AdminWindowService? _admin;
/// <summary>Bouton « Compte » (comptes joueurs) et son client de l'API ; null si les comptes ne sont pas activés.</summary>
private AccountWindowService? _accounts;
private GameLauncher.ApiClient.LauncherApiClient? _accountsClient;
private CatalogService? _catalogService;
private ContentTrustService? _trust;
@@ -64,6 +71,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 +186,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 +241,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 +379,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 +403,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 +420,109 @@ 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),
hardware: new PlayerHardwareIds());
_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>
@@ -588,9 +728,11 @@ public partial class App : Application, ILauncherRestartHost
new AdminModule(),
new CatalogAdminModule(),
new FeedbackAdminModule(),
new PlayersAdminModule(),
new ChangelogAdminModule(),
new PublishAdminModule(),
new FilesAdminModule(),
new MaintenanceAdminModule(),
new AccountsAdminModule(),
new SigningAdminModule(),
new AuditAdminModule()
@@ -676,10 +818,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 +969,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 +978,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);
+25
View File
@@ -30,6 +30,31 @@ public sealed class StringToVisibilityConverter : IValueConverter
throw new NotSupportedException();
}
/// <summary>
/// Tracé vectoriel (« M4,4h7v1h-7z… ») → Geometry figée, ex. QR code de la double authentification
/// (<see cref="GameLauncher.Modules.Accounts.QrCode.ToPathData"/>). Texte vide ou invalide : rien.
/// </summary>
public sealed class PathDataConverter : IValueConverter
{
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not string { Length: > 0 } data) return null;
try
{
var geometry = Geometry.Parse(data);
geometry.Freeze();
return geometry;
}
catch (FormatException)
{
return null;
}
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
/// <summary>Clé d'icône ("Play") → Geometry (ressource "Icon.Play").</summary>
public sealed class IconKeyConverter : IValueConverter
{
+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();
}
}
@@ -0,0 +1,110 @@
using System.IO;
using System.Runtime.InteropServices;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Accounts;
using Microsoft.Win32;
namespace GameLauncher.Platform;
/// <summary>
/// Identifiants matériels du PC pour les blocages des comptes joueurs (étape 8e) — lus seulement quand le joueur se connecte
/// ou crée un compte, et envoyés UNIQUEMENT sous forme d'empreinte (<see cref="PlayerHardware.Format"/>) :
/// • identifiant de l'installation de Windows (HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid, lisible sans droits
/// d'administrateur ; change si Windows est réinstallé) ;
/// • UUID du système (table SMBIOS du micrologiciel, structure de type 1 : carte mère ; survit à une réinstallation), lu par
/// GetSystemFirmwareTable (kernel32, aucune dépendance) ; les valeurs « bidon » des fabricants (que des 0, que des F, suite
/// 03000200-0400-0500-0006-000700080009) sont écartées.
/// Rien n'est écrit sur le PC. Lu une fois par lancement du launcher.
/// </summary>
public sealed class PlayerHardwareIds : IPlayerHardwareIds
{
private const uint Rsmb = 0x52534D42; // « RSMB »
private static readonly byte[] PlaceholderUuid = { 0x03, 0x00, 0x02, 0x00, 0x04, 0x00, 0x05, 0x00, 0x00, 0x06, 0x00, 0x07, 0x00, 0x08, 0x00, 0x09 };
private readonly Lazy<IReadOnlyList<string>> _entries = new(ReadAll, LazyThreadSafetyMode.ExecutionAndPublication);
public IReadOnlyList<string> Read() => _entries.Value;
private static IReadOnlyList<string> ReadAll()
{
var entries = new List<string>();
try
{
if (MachineGuid() is { } guid) entries.Add(PlayerHardware.Format(PlayerHardware.Machine, guid));
}
catch (Exception ex) when (ex is System.Security.SecurityException or UnauthorizedAccessException or IOException)
{
Log.Warn($"Compte : identifiant de l'installation de Windows illisible ({ex.Message}).");
}
try
{
if (SmbiosUuid() is { } uuid) entries.Add(PlayerHardware.Format(PlayerHardware.Smbios, uuid));
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
Log.Warn($"Compte : identifiant SMBIOS illisible ({ex.Message}).");
}
return entries;
}
private static string? MachineGuid()
{
using var root = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
using var key = root.OpenSubKey(@"SOFTWARE\Microsoft\Cryptography");
return key?.GetValue("MachineGuid") is string value && Guid.TryParse(value, out var guid) && guid != Guid.Empty ? value : null;
}
/// <summary>UUID de la structure SMBIOS de type 1 (System Information), en hexadécimal, ou null.</summary>
private static string? SmbiosUuid()
{
var size = GetSystemFirmwareTable(Rsmb, 0, IntPtr.Zero, 0);
if (size is 0 or > 1024 * 1024) return null;
var buffer = Marshal.AllocHGlobal((int)size);
try
{
if (GetSystemFirmwareTable(Rsmb, 0, buffer, size) != size) return null;
var raw = new byte[size];
Marshal.Copy(buffer, raw, 0, (int)size);
return ParseSmbiosUuid(raw);
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
/// <summary>
/// RawSMBIOSData : 8 octets d'en-tête (méthode, version, révision, longueur de la table), puis les structures : en-tête de
/// 4 octets (type, longueur de la partie formatée, numéro), partie formatée, puis chaînes terminées par deux octets nuls.
/// </summary>
internal static string? ParseSmbiosUuid(byte[] raw)
{
if (raw.Length < 8) return null;
var length = BitConverter.ToInt32(raw, 4);
var end = Math.Min(raw.Length, 8 + Math.Max(0, length));
var offset = 8;
while (offset + 4 <= end)
{
var type = raw[offset];
var formatted = raw[offset + 1];
if (formatted < 4 || offset + formatted > end) return null;
if (type == 1 && formatted >= 0x18)
{
var uuid = raw.AsSpan(offset + 8, 16);
if (uuid.IndexOfAnyExcept((byte)0x00) < 0 || uuid.IndexOfAnyExcept((byte)0xFF) < 0 || uuid.SequenceEqual(PlaceholderUuid))
return null;
return Convert.ToHexString(uuid);
}
if (type == 127) return null; // fin de la table
// Chaînes de la structure : jusqu'à deux octets nuls consécutifs.
var next = offset + formatted;
while (next + 1 < end && !(raw[next] == 0 && raw[next + 1] == 0)) next++;
offset = next + 2;
}
return null;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint GetSystemFirmwareTable(uint firmwareTableProviderSignature, uint firmwareTableId, IntPtr buffer, uint bufferSize);
}
+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;
+863 -2
View File
@@ -10,6 +10,8 @@
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:apl="clr-namespace:GameLauncher.Modules.Admin.Players;assembly=GameLauncher.Modules.Admin"
xmlns:conv="clr-namespace:GameLauncher.Converters">
<!--
@@ -367,6 +369,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>
@@ -2441,6 +2456,672 @@
</DockPanel>
</DataTemplate>
<!-- ============================================================ Page « Comptes joueurs » (players.read, étape 8d) -->
<!-- Compte dans la liste (adresse masquée) -->
<Style x:Key="AdminPlayerItemStyle" TargetType="ListBoxItem">
<Setter Property="Margin" Value="0,0,0,6" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd" CornerRadius="12" Padding="12,9" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border Width="30" Height="30" CornerRadius="15" VerticalAlignment="Top" Background="{DynamicResource BadgeBrush}">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.AccountCircle}" />
</Border>
<StackPanel Grid.Column="1" Margin="10,0,8,0">
<TextBlock x:Name="ItemTitle" Text="{Binding EmailMasked}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextPrimaryBrush}" />
<TextBlock Text="{Binding Reference}" FontSize="11.5" Margin="0,1,0,0" FontFamily="Cascadia Mono, Consolas, Courier New"
Foreground="{DynamicResource TextSecondaryBrush}" />
<TextBlock Text="{Binding DetailText}" FontSize="11.5" Margin="0,2,0,0" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextMutedBrush}" ToolTip="{Binding DetailText}" />
</StackPanel>
<StackPanel Grid.Column="2" VerticalAlignment="Top" HorizontalAlignment="Right">
<TextBlock x:Name="StatusText" Text="{Binding StatusLabel}" FontSize="11" FontWeight="SemiBold" HorizontalAlignment="Right"
Foreground="{DynamicResource SuccessBrush}" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,4,0,0" ToolTip="{Binding FlagsText}">
<Path Width="12" Height="12" Stretch="Uniform" Margin="6,0,0,0" Fill="{DynamicResource WarningBrush}"
Data="{StaticResource Icon.Lock}" Visibility="{Binding IsLocked, Converter={StaticResource BoolToVisibility}}" />
<Path Width="12" Height="12" Stretch="Uniform" Margin="6,0,0,0" Fill="{DynamicResource WarningBrush}"
Data="{StaticResource Icon.LockReset}" Visibility="{Binding MustChangePassword, Converter={StaticResource BoolToVisibility}}" />
<Path Width="12" Height="12" Stretch="Uniform" Margin="6,0,0,0" Fill="{DynamicResource Accent2Brush}"
Data="{StaticResource Icon.ShieldAccount}" Visibility="{Binding OtpEnabled, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</StackPanel>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource BorderStrongBrush}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceAltBrush}" />
</Trigger>
<DataTrigger Binding="{Binding IsDisabled}" Value="True">
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource DangerBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsSuspended}" Value="True">
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsInactive}" Value="True">
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
<Setter TargetName="ItemTitle" Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Compteur (« 1 248 comptes », « 3 suspendus ») -->
<DataTemplate x:Key="AdminPlayerCounterTemplate">
<Border x:Name="Chip" Style="{StaticResource AdminChipStyle}">
<TextBlock FontSize="12">
<Run Text="{Binding Value, Mode=OneWay}" FontWeight="SemiBold" Foreground="{DynamicResource TextPrimaryBrush}" />
<Run Text="{Binding Label, Mode=OneWay}" Foreground="{DynamicResource TextSecondaryBrush}" />
</TextBlock>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding Warning}" Value="True">
<Setter TargetName="Chip" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<!-- Blocage (fiche d'un compte et page « Blocages », étape 8e) -->
<DataTemplate x:Key="AdminPlayerBanTemplate">
<Border CornerRadius="12" Padding="12,9" Margin="0,0,0,6" BorderThickness="1"
Background="{DynamicResource SurfaceAltBrush}" BorderBrush="{DynamicResource BorderBrush}">
<DockPanel>
<Border DockPanel.Dock="Left" Width="30" Height="30" CornerRadius="15" VerticalAlignment="Top" Margin="0,0,12,0"
Background="{DynamicResource BadgeBrush}">
<Path x:Name="ScopeIcon" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Cancel}" />
</Border>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Lever" Margin="12,0,0,0" VerticalAlignment="Top"
ui:IconAssist.Icon="{StaticResource Icon.LockOpenVariant}" Command="{Binding LiftCommand}"
Visibility="{Binding CanLift, Converter={StaticResource BoolToVisibility}}" />
<StackPanel>
<TextBlock FontSize="12.5" TextWrapping="Wrap">
<Run Text="{Binding Reference, Mode=OneWay}" FontWeight="SemiBold" FontFamily="Cascadia Mono, Consolas, Courier New" />
<Run Text=" · " Foreground="{DynamicResource TextMutedBrush}" />
<Run x:Name="ScopeText" Text="{Binding ScopeLabel, Mode=OneWay}" FontWeight="SemiBold" Foreground="{DynamicResource WarningBrush}" />
</TextBlock>
<TextBlock Text="{Binding TargetsText}" FontSize="12" Margin="0,3,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextPrimaryBrush}" />
<TextBlock Text="{Binding PeriodText}" FontSize="11.5" Margin="0,2,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}" />
<TextBlock FontSize="11.5" Margin="0,2,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextMutedBrush}">
<Run Text="{Binding HitsText, Mode=OneWay}" /><Run Text="{Binding AccountSuffix, Mode=OneWay}" />
</TextBlock>
<TextBlock FontSize="11.5" Margin="0,4,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}"
Visibility="{Binding HasNote, Converter={StaticResource BoolToVisibility}}">
<Run Text="Note interne :" /> <Run Text="{Binding Note, Mode=OneWay}" FontStyle="Italic" />
</TextBlock>
</StackPanel>
</DockPanel>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding BlocksLogin}" Value="True">
<Setter TargetName="ScopeIcon" Property="Fill" Value="{DynamicResource DangerBrush}" />
<Setter TargetName="ScopeText" Property="Foreground" Value="{DynamicResource DangerBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<DataTemplate DataType="{x:Type apl:PlayersPanelViewModel}">
<!-- Deux colonnes qui défilent séparément (HasSplitLayout) : liste à gauche, fiche à droite. -->
<DockPanel x:Name="PlayersRoot">
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,0">
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Service désactivé / création de compte fermée -->
<Border Style="{StaticResource AdminInfoBannerStyle}" Visibility="{Binding ShowDisabledService, Converter={StaticResource BoolToVisibility}}">
<TextBlock TextWrapping="Wrap" FontSize="12.5"
Text="Les comptes joueurs ne sont pas activés sur ce serveur (LauncherApi:Players:Enabled). Voir server/deploy/LISEZMOI.md, étape 3 undecies." />
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" Visibility="{Binding ShowRegistrationClosed, Converter={StaticResource BoolToVisibility}}">
<TextBlock TextWrapping="Wrap" FontSize="12.5"
Text="La création de compte est fermée (LauncherApi:Players:RegistrationEnabled) : les joueurs existants peuvent toujours se connecter." />
</Border>
<!-- Compteurs, recherche et filtre -->
<Border Style="{StaticResource AdminCardStyle}" Padding="16,14" Visibility="{Binding IsServiceEnabled, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<ItemsControl ItemsSource="{Binding Counters}" Focusable="False" ItemTemplate="{StaticResource AdminPlayerCounterTemplate}" Margin="0,0,0,4">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<Grid IsEnabled="{Binding IsEditable}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="260" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Rechercher (référence PL-XXXX-XXXX ou adresse e-mail complète)" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding SearchMaxLength}"
ui:TextAssist.Placeholder="PL-7KQ2-M9XD ou joueur@exemple.fr"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}">
<TextBox.InputBindings>
<KeyBinding Key="Enter" Command="{Binding SearchCommand}" />
</TextBox.InputBindings>
</TextBox>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Bottom" Margin="8,0,0,0">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Rechercher" ui:IconAssist.Icon="{StaticResource Icon.Locate}"
Command="{Binding SearchCommand}"
ToolTip="L'adresse est envoyée dans le corps de la requête et n'est jamais affichée en entier ; la recherche est inscrite au journal d'audit" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Toute la liste" Margin="8,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Close}" Command="{Binding ClearSearchCommand}"
Visibility="{Binding IsSearchResult, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
<StackPanel Grid.Column="2" Margin="10,0,0,0">
<TextBlock Text="Afficher" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding Filters}"
SelectedItem="{Binding Filter, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
<TextBlock Text="{Binding EmptyText}" Style="{StaticResource AdminHintStyle}" FontSize="12.5" Margin="4,4,0,16"
Visibility="{Binding EmptyText, Converter={StaticResource StringToVisibility}}" />
</StackPanel>
<Grid Visibility="{Binding HasItems, Converter={StaticResource BoolToVisibility}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="330" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{Binding CountText}" Style="{StaticResource SectionTitleStyle}" Margin="2,0,0,8" />
<Button DockPanel.Dock="Bottom" Style="{StaticResource SmallButtonStyle}" Content="Charger plus" Margin="0,4,8,0"
HorizontalAlignment="Left" ui:IconAssist.Icon="{StaticResource Icon.History}" Command="{Binding LoadMoreCommand}"
Visibility="{Binding CanLoadMore, Converter={StaticResource BoolToVisibility}}" />
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,8,0">
<ListBox ItemsSource="{Binding Items}" SelectedItem="{Binding SelectedItem, Mode=TwoWay}"
Style="{StaticResource AdminListBoxStyle}" IsEnabled="{Binding IsEditable}"
ItemContainerStyle="{StaticResource AdminPlayerItemStyle}" />
</ScrollViewer>
</DockPanel>
<!-- Fiche du compte sélectionné : remonte en haut à chaque changement -->
<ScrollViewer Grid.Column="1" Margin="10,0,0,0" VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,14,0"
ui:ScrollAssist.ResetOn="{Binding Detail}">
<Grid>
<TextBlock Text="Choisissez un compte dans la liste." Style="{StaticResource AdminHintStyle}" FontSize="12.5" Margin="4,8,0,0"
Visibility="{Binding ShowNoSelection, Converter={StaticResource BoolToVisibility}}" />
<StackPanel Visibility="{Binding HasDetail, Converter={StaticResource BoolToVisibility}}">
<!-- Mot de passe temporaire : affiché UNE fois -->
<Border Style="{StaticResource AdminCardStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding HasTemporaryPassword, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<DockPanel>
<Border DockPanel.Dock="Left" Style="{StaticResource AdminIconBadgeStyle}" Margin="0,0,14,0">
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.FormTextboxPassword}" />
</Border>
<StackPanel>
<TextBlock Text="Mot de passe temporaire" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="{Binding TemporaryPasswordText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
</StackPanel>
</DockPanel>
<DockPanel Margin="52,12,0,0">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
<Button Style="{StaticResource SmallButtonStyle}" Content="Copier" Margin="12,0,8,0"
ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}" Command="{Binding CopyTemporaryPasswordCommand}" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Transmis, masquer"
ui:IconAssist.Icon="{StaticResource Icon.EyeOffOutline}" Command="{Binding DismissTemporaryPasswordCommand}" />
</StackPanel>
<TextBox Text="{Binding TemporaryPassword, Mode=OneWay}" IsReadOnly="True" BorderThickness="0" Background="Transparent"
Padding="0" FontSize="22" FontWeight="SemiBold" FontFamily="Cascadia Mono, Consolas, Courier New"
Foreground="{DynamicResource TextPrimaryBrush}" CaretBrush="{DynamicResource TextPrimaryBrush}"
SelectionBrush="{DynamicResource AccentBrush}" IsUndoEnabled="False" VerticalAlignment="Center" />
</DockPanel>
</StackPanel>
</Border>
<!-- Identité, état et sécurité -->
<Border Style="{StaticResource AdminCardStyle}" DataContext="{Binding Detail}">
<StackPanel>
<DockPanel>
<Border DockPanel.Dock="Left" Style="{StaticResource AdminIconBadgeStyle}" Margin="0,0,14,0">
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.AccountCircle}" />
</Border>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButtonStyle}" Width="32" Height="32" Padding="8" VerticalAlignment="Top"
ToolTip="Copier la référence" ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}"
Command="{Binding CopyReferenceCommand}" />
<StackPanel>
<TextBlock Text="{Binding EmailMasked}" FontSize="15" FontWeight="SemiBold" TextWrapping="Wrap"
ToolTip="Adresse masquée : elle n'est jamais affichée en entier dans le menu" />
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<TextBlock Text="{Binding Reference}" FontSize="12" FontFamily="Cascadia Mono, Consolas, Courier New"
Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock Text=" · " FontSize="12" Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock Text="{Binding StatusLabel}" FontSize="12" FontWeight="SemiBold">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource SuccessBrush}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsDisabled}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource DangerBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsSuspended}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsInactive}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</StackPanel>
</DockPanel>
<TextBlock Text="{Binding StatusText}" FontSize="12.5" TextWrapping="Wrap" Margin="52,12,0,0"
Foreground="{DynamicResource TextSecondaryBrush}" />
<Border Style="{StaticResource AdminInfoBannerStyle}" Margin="52,10,0,0" Padding="12,8"
Visibility="{Binding HasReason, Converter={StaticResource BoolToVisibility}}">
<TextBlock TextWrapping="Wrap" FontSize="12.5">
<Run Text="Motif communiqué au joueur :" Foreground="{DynamicResource TextSecondaryBrush}" />
<Run Text="{Binding StatusReason, Mode=OneWay}" FontWeight="SemiBold" />
</TextBlock>
</Border>
<ItemsControl ItemsSource="{Binding Facts}" Focusable="False" Margin="52,14,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,0,0,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="170" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Label}" Style="{StaticResource AdminLabelStyle}" Margin="0" />
<TextBlock x:Name="FactValue" Grid.Column="1" Text="{Binding Value}" Style="{StaticResource AdminValueStyle}" Margin="0" />
</Grid>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding Warning}" Value="True">
<Setter TargetName="FactValue" Property="Foreground" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Actions (players.manage) : requêtes signées, confirmées -->
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding CanManage, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<TextBlock Text="Actions" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
Text="Signées par votre clé et inscrites au journal d'audit. Le joueur les voit dans son activité comme venant de « l'équipe »." />
<WrapPanel IsEnabled="{Binding IsEditable}">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="{Binding Detail.DisableText}" Margin="0,0,8,8"
ui:IconAssist.Icon="{StaticResource Icon.AccountCancel}" Command="{Binding OpenDisableCommand}"
ToolTip="Refuse toute connexion jusqu'à une date ou indéfiniment ; motif facultatif montré au joueur" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Réactiver" Margin="0,0,8,8"
ui:IconAssist.Icon="{StaticResource Icon.AccountCheck}" Command="{Binding EnableCommand}"
Visibility="{Binding Detail.CanEnable, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="{Binding Detail.UnlockText}" Margin="0,0,8,8"
ui:IconAssist.Icon="{StaticResource Icon.AccountLockOpen}" Command="{Binding UnlockCommand}"
ToolTip="Le compte redevient utilisable depuis tout appareil ; compteurs d'échecs remis à zéro"
Visibility="{Binding Detail.CanUnlock, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Mot de passe temporaire" Margin="0,0,8,8"
ui:IconAssist.Icon="{StaticResource Icon.FormTextboxPassword}" Command="{Binding TemporaryPasswordCommand}"
ToolTip="Valable une heure, une seule fois, pour choisir un nouveau mot de passe (mot de passe oublié)"
Visibility="{Binding Detail.CanCreateTemporaryPassword, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Exiger un nouveau mot de passe" Margin="0,0,8,8"
ui:IconAssist.Icon="{StaticResource Icon.LockReset}" Command="{Binding RequirePasswordCommand}"
ToolTip="Mot de passe peut-être connu d'un tiers : à la prochaine connexion, le joueur devra en choisir un autre"
Visibility="{Binding Detail.CanRequirePassword, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Ne plus exiger de nouveau mot de passe" Margin="0,0,8,8"
ui:IconAssist.Icon="{StaticResource Icon.LockOpenVariant}" Command="{Binding CancelPasswordRequirementCommand}"
Visibility="{Binding Detail.CanCancelPasswordRequirement, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Réinitialiser la double authentification" Margin="0,0,8,8"
ui:IconAssist.Icon="{StaticResource Icon.ShieldOffOutline}" Command="{Binding ResetOtpCommand}"
ToolTip="Téléphone ET codes de secours perdus, identité vérifiée"
Visibility="{Binding Detail.CanResetOtp, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Déconnecter tous les appareils" Margin="0,0,8,8"
ui:IconAssist.Icon="{StaticResource Icon.Logout}" Command="{Binding LogoutAllCommand}"
Visibility="{Binding Detail.CanLogoutAll, Converter={StaticResource BoolToVisibility}}" />
</WrapPanel>
<!-- Désactivation : durée et motif -->
<Border CornerRadius="12" Padding="14,12" Margin="0,6,0,0" BorderThickness="1"
Background="{DynamicResource SurfaceAltBrush}" BorderBrush="{DynamicResource BorderStrongBrush}"
Visibility="{Binding IsDisableFormOpen, Converter={StaticResource BoolToVisibility}}">
<StackPanel IsEnabled="{Binding IsEditable}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="230" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Durée" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding DisableDurations}"
SelectedItem="{Binding DisableDuration, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
<StackPanel Grid.Column="1" Margin="12,0,0,0">
<TextBlock Text="Motif montré au joueur (facultatif, une ligne, sans lien)" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding ReasonMaxLength}"
ui:TextAssist.Placeholder="Ex. : triche en partie en ligne (avertissement du 03/11)"
Text="{Binding DisableReason, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</Grid>
<TextBlock Text="{Binding ReasonProblem}" FontSize="12" Margin="0,6,0,0" TextWrapping="Wrap" Foreground="{DynamicResource DangerBrush}"
Visibility="{Binding HasReasonProblem, Converter={StaticResource BoolToVisibility}}" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Annuler" Margin="0,0,8,0" Command="{Binding CancelDisableCommand}" />
<Button Style="{StaticResource DangerButtonStyle}" Content="Désactiver le compte"
ui:IconAssist.Icon="{StaticResource Icon.AccountCancel}" Command="{Binding DisableCommand}" />
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- Identifiants matériels, dernière adresse IP et blocages (étape 8e ; « Bloquer… » : players.ban) -->
<Border Style="{StaticResource AdminCardStyle}">
<StackPanel>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SecondaryButtonStyle}" Content="Bloquer…" VerticalAlignment="Top"
ui:IconAssist.Icon="{StaticResource Icon.Cancel}" Command="{Binding OpenBanCommand}" IsEnabled="{Binding IsEditable}"
ToolTip="Bloquer la création de compte (et la connexion, au choix) depuis ses PC et/ou sa dernière adresse IP"
Visibility="{Binding Detail.CanBan, Converter={StaticResource BoolToVisibility}}" />
<StackPanel>
<TextBlock Text="Identifiants et blocages" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,12,0" TextWrapping="Wrap"
Text="Identifiants matériels : empreintes calculées par le serveur (jamais les identifiants eux-mêmes) de l'installation de Windows et de la carte mère des PC du joueur. Ils servent uniquement aux blocages." />
</StackPanel>
</DockPanel>
<!-- Dernière adresse IP connue -->
<DockPanel Margin="0,12,0,0" Visibility="{Binding Detail.HasLastIp, Converter={StaticResource BoolToVisibility}}">
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Margin="2,0,12,0" VerticalAlignment="Center"
Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.IpNetwork}" />
<TextBlock Text="{Binding Detail.LastIpText}" FontSize="12.5" TextWrapping="Wrap" VerticalAlignment="Center" />
</DockPanel>
<!-- Identifiants matériels (empreintes abrégées) -->
<TextBlock Text="{Binding Detail.HardwareTitle}" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,12,0,0"
Visibility="{Binding Detail.HasNoIdentifiers, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<ItemsControl ItemsSource="{Binding Detail.Hardware}" Focusable="False" Margin="0,6,0,0"
Visibility="{Binding Detail.HasHardware, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border x:Name="Bd" CornerRadius="12" Padding="12,8" Margin="0,0,0,6" BorderThickness="1"
Background="{DynamicResource SurfaceAltBrush}" BorderBrush="{DynamicResource BorderBrush}">
<DockPanel>
<Path x:Name="Icon" DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Margin="0,0,12,0"
VerticalAlignment="Center" Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.Chip}" />
<TextBlock DockPanel.Dock="Right" Text="{Binding BannedText}" FontSize="11.5" FontWeight="SemiBold" Margin="12,0,0,0"
VerticalAlignment="Center" Foreground="{DynamicResource DangerBrush}"
Visibility="{Binding IsBanned, Converter={StaticResource BoolToVisibility}}" />
<StackPanel>
<TextBlock Text="{Binding Label}" FontSize="12.5" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding DetailText}" FontSize="11.5" Margin="0,2,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</DockPanel>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsBanned}" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource DangerBrush}" />
<Setter TargetName="Icon" Property="Fill" Value="{DynamicResource DangerBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Aucun identifiant matériel ni adresse IP connus (compte sans connexion récente, ou données purgées après la durée de conservation)."
Style="{StaticResource AdminHintStyle}" Margin="0,10,0,0" TextWrapping="Wrap"
Visibility="{Binding Detail.HasNoIdentifiers, Converter={StaticResource BoolToVisibility}}" />
<!-- Blocages en vigueur qui concernent ce compte -->
<TextBlock Text="Blocages en vigueur" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,12,0,6"
Visibility="{Binding Detail.HasBans, Converter={StaticResource BoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding Detail.Bans}" Focusable="False" ItemTemplate="{StaticResource AdminPlayerBanTemplate}"
IsEnabled="{Binding IsEditable}"
Visibility="{Binding Detail.HasBans, Converter={StaticResource BoolToVisibility}}" />
<!-- Formulaire « Bloquer » -->
<Border CornerRadius="12" Padding="14,12" Margin="0,8,0,0" BorderThickness="1"
Background="{DynamicResource SurfaceAltBrush}" BorderBrush="{DynamicResource BorderStrongBrush}"
Visibility="{Binding IsBanFormOpen, Converter={StaticResource BoolToVisibility}}">
<StackPanel IsEnabled="{Binding IsEditable}">
<TextBlock Text="Que bloquer ?" Style="{StaticResource AdminFieldLabelStyle}" />
<CheckBox Style="{StaticResource ToggleSwitchStyle}" IsChecked="{Binding BanHardware, Mode=TwoWay}" Margin="0,4,0,8"
IsEnabled="{Binding Detail.HasHardware}" FontSize="13"
Content="Les identifiants matériels de ses PC (installation de Windows, carte mère)" />
<CheckBox Style="{StaticResource ToggleSwitchStyle}" IsChecked="{Binding BanIp, Mode=TwoWay}" Margin="0,0,0,12"
IsEnabled="{Binding Detail.HasLastIp}" FontSize="13"
Content="Sa dernière adresse IP connue (IPv6 : son réseau /64 ; un an au plus)" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="230" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Bloquer" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding BanScopes}"
SelectedItem="{Binding BanScope, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
<StackPanel Grid.Column="1" Margin="12,0,0,0">
<TextBlock Text="Durée" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding BanDurations}"
SelectedItem="{Binding BanDuration, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
</Grid>
<TextBlock Text="Note interne (facultative, jamais montrée au joueur)" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,10,0,0" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding BanNoteMaxLength}"
ui:TextAssist.Placeholder="Ex. : recrée des comptes pour contourner la désactivation"
Text="{Binding BanNote, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="{Binding BanProblem}" FontSize="12" Margin="0,6,0,0" TextWrapping="Wrap" Foreground="{DynamicResource DangerBrush}"
Visibility="{Binding HasBanProblem, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,8,0,0" TextWrapping="Wrap"
Text="Le joueur bloqué voit seulement « impossible depuis cet appareil ou cette connexion » et la référence du blocage, à donner au support. Avec la connexion, ses sessions sont fermées." />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Annuler" Margin="0,0,8,0" Command="{Binding CancelBanCommand}" />
<Button Style="{StaticResource DangerButtonStyle}" Content="Bloquer"
ui:IconAssist.Icon="{StaticResource Icon.Cancel}" Command="{Binding BanCommand}" />
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- Appareils connectés -->
<Border Style="{StaticResource AdminCardStyle}" DataContext="{Binding Detail}">
<StackPanel>
<TextBlock Text="{Binding DevicesTitle}" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="Aucun appareil connecté (le joueur devra se connecter avec son mot de passe)." Style="{StaticResource AdminHintStyle}"
Margin="0,6,0,0" Visibility="{Binding HasDevices, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<ItemsControl ItemsSource="{Binding Devices}" Focusable="False" Margin="0,10,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border CornerRadius="12" Padding="12,9" Margin="0,0,0,6" BorderThickness="1"
Background="{DynamicResource SurfaceAltBrush}" BorderBrush="{DynamicResource BorderBrush}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="18" Height="18" Stretch="Uniform" Margin="0,0,12,0" VerticalAlignment="Center"
Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.Laptop}" />
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Déconnecter" Margin="12,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Logout}" Command="{Binding LogoutCommand}"
Visibility="{Binding CanManage, Converter={StaticResource BoolToVisibility}}" />
<StackPanel>
<TextBlock Text="{Binding Label}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding DetailText}" FontSize="11.5" Margin="0,2,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Activité (adresses IP complètes : données personnelles) -->
<Border Style="{StaticResource AdminCardStyle}" DataContext="{Binding Detail}">
<StackPanel>
<TextBlock Text="{Binding EventsTitle}" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,10"
Text="Adresses IP complètes (le joueur ne voit que leur début) : à n'utiliser que pour la sécurité du compte." />
<ItemsControl ItemsSource="{Binding Events}" Focusable="False">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,0,0,8" ToolTip="{Binding Type}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="135" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Ellipse x:Name="Dot" Width="8" Height="8" Margin="0,5,10,0" VerticalAlignment="Top" Fill="{DynamicResource SuccessBrush}" />
<TextBlock Grid.Column="1" Text="{Binding TimeText}" FontSize="12" Foreground="{DynamicResource TextMutedBrush}" />
<StackPanel Grid.Column="2">
<TextBlock Text="{Binding Label}" FontSize="12.5" TextWrapping="Wrap" />
<TextBlock Text="{Binding OriginText}" FontSize="11.5" Margin="0,1,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}"
Visibility="{Binding HasOrigin, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding Details}" FontSize="11.5" Margin="0,1,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}"
Visibility="{Binding HasDetails, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</Grid>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsFailure}" Value="True">
<Setter TargetName="Dot" Property="Fill" Value="{DynamicResource DangerBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</ScrollViewer>
</Grid>
</DockPanel>
</DataTemplate>
<!-- ============================================================ Page « Blocages » (players.ban, étape 8e) -->
<DataTemplate DataType="{x:Type apl:BansPanelViewModel}">
<StackPanel Margin="0,0,14,0">
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" Visibility="{Binding ShowDisabledService, Converter={StaticResource BoolToVisibility}}">
<TextBlock TextWrapping="Wrap" FontSize="12.5"
Text="Les comptes joueurs ne sont pas activés sur ce serveur (LauncherApi:Players:Enabled) : il n'y a rien à bloquer. Voir server/deploy/LISEZMOI.md, étape 3 undecies." />
</Border>
<!-- Bloquer une adresse IP ou une plage seule -->
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding IsServiceEnabled, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<DockPanel>
<Border DockPanel.Dock="Left" Style="{StaticResource AdminIconBadgeStyle}" Margin="0,0,14,0">
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.IpNetwork}" />
</Border>
<StackPanel>
<TextBlock Text="Bloquer une adresse IP ou une plage" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" TextWrapping="Wrap"
Text="Sans compte précis (ex. vague de créations de comptes automatiques). Pour bloquer un joueur, ses PC et sa dernière adresse, utilisez « Bloquer… » sur sa fiche (page « Comptes joueurs »). Un an au plus : une adresse change de titulaire." />
</StackPanel>
</DockPanel>
<Grid Margin="52,14,0,0" IsEnabled="{Binding IsEditable}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="230" />
<ColumnDefinition Width="150" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Adresse IP ou plage (notation CIDR)" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="64" FontFamily="Cascadia Mono, Consolas, Courier New"
ui:TextAssist.Placeholder="203.0.113.7, 198.51.100.0/24 ou 2001:db8::/48"
Text="{Binding IpText, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Grid.Column="1" Margin="10,0,0,0">
<TextBlock Text="Bloquer" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding Scopes}"
SelectedItem="{Binding Scope, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
<StackPanel Grid.Column="2" Margin="10,0,0,0">
<TextBlock Text="Durée" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding Durations}"
SelectedItem="{Binding Duration, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
</Grid>
<StackPanel Margin="52,10,0,0" IsEnabled="{Binding IsEditable}">
<TextBlock Text="Note interne (facultative, jamais montrée au joueur)" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding NoteMaxLength}"
ui:TextAssist.Placeholder="Ex. : vague de créations de comptes automatiques"
Text="{Binding Note, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<DockPanel Margin="52,10,0,0">
<Button DockPanel.Dock="Right" Style="{StaticResource DangerButtonStyle}" Content="Bloquer" VerticalAlignment="Top" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Cancel}" Command="{Binding BanIpCommand}"
ToolTip="Requête signée par votre clé, inscrite au journal d'audit" />
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding FormProblem}" FontSize="12" TextWrapping="Wrap" Foreground="{DynamicResource DangerBrush}"
Visibility="{Binding HasFormProblem, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding IpPreview}" FontSize="12" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}"
Visibility="{Binding HasIpPreview, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DockPanel>
</StackPanel>
</Border>
<!-- Blocages en vigueur -->
<TextBlock Text="{Binding CountText}" Style="{StaticResource SectionTitleStyle}" Margin="2,6,0,8"
Visibility="{Binding IsServiceEnabled, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Style="{StaticResource AdminHintStyle}" FontSize="12.5" Margin="4,0,0,16" TextWrapping="Wrap"
Text="Aucun blocage en cours : la création de compte et la connexion sont possibles depuis tout appareil et toute connexion Internet."
Visibility="{Binding ShowEmpty, Converter={StaticResource BoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding Items}" Focusable="False" ItemTemplate="{StaticResource AdminPlayerBanTemplate}"
IsEnabled="{Binding IsEditable}" />
</StackPanel>
</DataTemplate>
<!-- ============================================================ Page « Formulaires des demandes » (feedback.forms) -->
<!-- Type de demande (liste de gauche) -->
@@ -2991,7 +3672,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 +3695,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}">
@@ -3295,6 +4093,41 @@
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.PlayersPanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
<DockPanel Margin="0,0,0,6">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Recharger" Margin="0,0,10,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding ReloadCommand}"
ToolTip="Relire les comptes et la fiche affichée sur le serveur" />
<Button Style="{StaticResource DangerButtonStyle}" Content="Supprimer le compte"
ui:IconAssist.Icon="{StaticResource Icon.AccountRemove}" Command="{Binding DeleteCommand}"
ToolTip="Supprime DÉFINITIVEMENT le compte affiché et ses données (requête signée par votre clé)"
Visibility="{Binding CanDelete, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
<TextBlock Text="{Binding RetentionText}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" TextWrapping="Wrap" Margin="0,0,16,0" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="{Binding IsBusy}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.BansPanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource SecondaryButtonStyle}" Content="Recharger"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding ReloadCommand}"
ToolTip="Relire les blocages en cours sur le serveur" />
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" TextWrapping="Wrap" Margin="0,0,16,0"
Text="Un blocage expiré est supprimé automatiquement. Chaque blocage et chaque levée sont inscrits au journal d'audit." />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="{Binding IsBusy}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.PublishPanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
@@ -3326,6 +4159,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}" />
+13
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>
@@ -103,4 +105,15 @@
<Geometry x:Key="Icon.Bug">M14,12H10V10H14M14,16H10V14H14M20,8H17.19C16.74,7.22 16.12,6.55 15.37,6.04L17,4.41L15.59,3L13.42,5.17C12.96,5.06 12.5,5 12,5C11.5,5 11.04,5.06 10.59,5.17L8.41,3L7,4.41L8.62,6.04C7.88,6.55 7.26,7.22 6.81,8H4V10H6.09C6.04,10.33 6,10.66 6,11V12H4V14H6V15C6,15.34 6.04,15.67 6.09,16H4V18H6.81C7.85,19.79 9.78,21 12,21C14.22,21 16.15,19.79 17.19,18H20V16H17.91C17.96,15.67 18,15.34 18,15V14H20V12H18V11C18,10.66 17.96,10.33 17.91,10H20V8Z</Geometry>
<Geometry x:Key="Icon.MessageQuestion">M4,2H20A2,2 0 0,1 22,4V16A2,2 0 0,1 20,18H16L12,22L8,18H4A2,2 0 0,1 2,16V4A2,2 0 0,1 4,2M12.19,5.5C11.3,5.5 10.59,5.68 10.05,6.04C9.5,6.4 9.22,7 9.27,7.69H11.24C11.24,7.41 11.34,7.2 11.5,7.06C11.7,6.92 11.92,6.85 12.19,6.85C12.5,6.85 12.77,6.93 12.95,7.11C13.13,7.28 13.22,7.5 13.22,7.8C13.22,8.08 13.14,8.33 13,8.54C12.83,8.76 12.62,8.94 12.36,9.08C11.84,9.4 11.5,9.68 11.29,9.92C11.1,10.16 11,10.5 11,11H13C13,10.72 13.05,10.5 13.14,10.32C13.23,10.15 13.4,10 13.66,9.85C14.12,9.64 14.5,9.36 14.79,9C15.08,8.63 15.23,8.24 15.23,7.8C15.23,7.1 14.96,6.54 14.42,6.12C13.88,5.71 13.13,5.5 12.19,5.5M11,12V14H13V12H11Z</Geometry>
<!-- Comptes joueurs (menu d'administration, étape 8d) -->
<Geometry x:Key="Icon.AccountMultiple">M16 17V19H2V17S2 13 9 13 16 17 16 17M12.5 7.5A3.5 3.5 0 1 0 9 11A3.5 3.5 0 0 0 12.5 7.5M15.94 13A5.32 5.32 0 0 1 18 17V19H22V17S22 13.37 15.94 13M15 4A3.39 3.39 0 0 0 13.07 4.59A5 5 0 0 1 13.07 10.41A3.39 3.39 0 0 0 15 11A3.5 3.5 0 0 0 15 4Z</Geometry>
<Geometry x:Key="Icon.AccountCancel">M10 4A4 4 0 0 0 6 8A4 4 0 0 0 10 12A4 4 0 0 0 14 8A4 4 0 0 0 10 4M17.5 13C15 13 13 15 13 17.5C13 20 15 22 17.5 22C20 22 22 20 22 17.5C22 15 20 13 17.5 13M10 14C5.58 14 2 15.79 2 18V20H11.5A6.5 6.5 0 0 1 11 17.5A6.5 6.5 0 0 1 11.95 14.14C11.32 14.06 10.68 14 10 14M17.5 14.5C19.16 14.5 20.5 15.84 20.5 17.5C20.5 18.06 20.35 18.58 20.08 19L16 14.92C16.42 14.65 16.94 14.5 17.5 14.5M14.92 16L19 20.08C18.58 20.35 18.06 20.5 17.5 20.5C15.84 20.5 14.5 19.16 14.5 17.5C14.5 16.94 14.65 16.42 14.92 16Z</Geometry>
<Geometry x:Key="Icon.AccountCheck">M21.1,12.5L22.5,13.91L15.97,20.5L12.5,17L13.9,15.59L15.97,17.67L21.1,12.5M10,17L13,20H3V18C3,15.79 6.58,14 11,14L12.89,14.11L10,17M11,4A4,4 0 0,1 15,8A4,4 0 0,1 11,12A4,4 0 0,1 7,8A4,4 0 0,1 11,4Z</Geometry>
<Geometry x:Key="Icon.LockReset">M12.63,2C18.16,2 22.64,6.5 22.64,12C22.64,17.5 18.16,22 12.63,22C9.12,22 6.05,20.18 4.26,17.43L5.84,16.18C7.25,18.47 9.76,20 12.64,20A8,8 0 0,0 20.64,12A8,8 0 0,0 12.64,4C8.56,4 5.2,7.06 4.71,11H7.47L3.73,14.73L0,11H2.69C3.19,5.95 7.45,2 12.63,2M15.59,10.24C16.09,10.25 16.5,10.65 16.5,11.16V15.77C16.5,16.27 16.09,16.69 15.58,16.69H10.05C9.54,16.69 9.13,16.27 9.13,15.77V11.16C9.13,10.65 9.54,10.25 10.04,10.24V9.23C10.04,7.7 11.29,6.46 12.81,6.46C14.34,6.46 15.59,7.7 15.59,9.23V10.24M12.81,7.86C12.06,7.86 11.44,8.47 11.44,9.23V10.24H14.19V9.23C14.19,8.47 13.57,7.86 12.81,7.86Z</Geometry>
<Geometry x:Key="Icon.FormTextboxPassword">M17,7H22V17H17V19A1,1 0 0,0 18,20H20V22H17.5C16.95,22 16,21.55 16,21C16,21.55 15.05,22 14.5,22H12V20H14A1,1 0 0,0 15,19V5A1,1 0 0,0 14,4H12V2H14.5C15.05,2 16,2.45 16,3C16,2.45 16.95,2 17.5,2H20V4H18A1,1 0 0,0 17,5V7M2,7H13V9H4V15H13V17H2V7M20,15V9H17V15H20M8.5,12A1.5,1.5 0 0,0 7,10.5A1.5,1.5 0 0,0 5.5,12A1.5,1.5 0 0,0 7,13.5A1.5,1.5 0 0,0 8.5,12M13,10.89C12.39,10.33 11.44,10.38 10.88,11C10.32,11.6 10.37,12.55 11,13.11C11.55,13.63 12.43,13.63 13,13.11V10.89Z</Geometry>
<Geometry x:Key="Icon.ShieldOffOutline">M1,4.27L3,6.27V11C3,16.55 6.84,21.74 12,23C13.87,22.54 15.57,21.56 16.97,20.24L19.23,22.5L20.5,21.22L2.28,3L1,4.27M12,21C8.25,20 5,15.54 5,11.22V8.27L15.59,18.86C14.53,19.89 13.3,20.65 12,21M21,5V11C21,13.28 20.35,15.5 19.23,17.4L17.77,15.95C18.54,14.5 19,12.84 19,11.22V6.3L12,3.18L7.16,5.34L5.65,3.82L12,1L21,5Z</Geometry>
<Geometry x:Key="Icon.AccountLockOpen">M6 8C6 5.79 7.79 4 10 4S14 5.79 14 8 12.21 12 10 12 6 10.21 6 8M12 18.2C12 17.24 12.5 16.34 13.2 15.74V14.5C13.2 14.45 13.22 14.39 13.22 14.34C12.23 14.12 11.15 14 10 14C5.58 14 2 15.79 2 18V20H12V18.2M22 18.3V21.8C22 22.4 21.4 23 20.7 23H15.2C14.6 23 14 22.4 14 21.7V18.2C14 17.6 14.6 17 15.2 17V14.5C15.2 13.1 16.6 12 18 12S20.8 13.1 20.8 14.5V15H19.5V14.5C19.5 13.7 18.8 13.2 18 13.2S16.5 13.7 16.5 14.5V17H20.8C21.4 17 22 17.6 22 18.3Z</Geometry>
<!-- Étape 8e : blocages (adresse IP, blocage) -->
<Geometry x:Key="Icon.IpNetwork">M15,9H13V7H15V9M22,20V22H15A1,1 0 0,1 14,23H10A1,1 0 0,1 9,22H2V20H9A1,1 0 0,1 10,19H11V17H7A2,2 0 0,1 5,15V5A2,2 0 0,1 7,3H17A2,2 0 0,1 19,5V15A2,2 0 0,1 17,17H13V19H14A1,1 0 0,1 15,20H22M9,5H7V15H9V5M11,15H13V11H15A2,2 0 0,0 17,9V7A2,2 0 0,0 15,5H11V15Z</Geometry>
<Geometry x:Key="Icon.Cancel">M12 2C17.5 2 22 6.5 22 12S17.5 22 12 22 2 17.5 2 12 6.5 2 12 2M12 4C10.1 4 8.4 4.6 7.1 5.7L18.3 16.9C19.3 15.5 20 13.8 20 12C20 7.6 16.4 4 12 4M16.9 18.3L5.7 7.1C4.6 8.4 4 10.1 4 12C4 16.4 7.6 20 12 20C13.9 20 15.6 19.4 16.9 18.3Z</Geometry>
</ResourceDictionary>
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff. Show more