Compare commits

...
27 Commits
Author SHA1 Message Date
nicolas 02b7af8253 Comptes joueurs (8a) : déploiement et documentation
- launcher-api-players.conf : complément systemd (LoadCredentialEncrypted).
- LISEZMOI : étape 3 undecies (préparation, ne pas activer en production
  avant 8g), commandes de secours, fichiers sur le VPS.
- Nouveau docs/PLAYER-ACCOUNTS-ARCHITECTURE.md.
- ADMIN-ARCHITECTURE : dépendances NuGet, phase 8a au § 13, § 14 mis à jour.
2026-11-10 22:55:41 +01:00
nicolas 723df05cf3 Comptes joueurs (8a) : tests
- Règles des mots de passe, adresses, références, jetons.
- Création, connexion, blocages, suspension, session mémorisée (rotation,
  réutilisation, clé d'appareil), appareils, mot de passe, désactivation, purge.
- Étanchéité : toutes les routes admin déclarées essayées avec des jetons joueurs
  (401 partout), et l'inverse.
- Chiffrement, clé maîtresse, empreintes ; vecteur officiel Argon2id (RFC 9106).
2026-11-10 22:55:19 +01:00
nicolas 85e69e6a2c Comptes joueurs (8a) : commandes de secours en ligne de commande
players-key, players-show, players-unlock, players-disable [--until], players-enable.
Compte désigné par sa référence ; l'adresse e-mail n'est jamais affichée.
Actions inscrites au journal d'audit (cli.players_*).
2026-11-10 22:55:08 +01:00
nicolas 7a6ad2247b Comptes joueurs (8a) : module Accounts et authentification séparée des admins
- Routes : défi, création de compte, connexion, renouvellement, déconnexion,
  « Mon compte », changement de mot de passe (dont exigé), appareils.
- Schéma PlayerSession distinct ; les règles admin désignent explicitement
  AdminSession et le gestionnaire admin ignore les jetons glp… .
- PlayerGuard : défis sans état à usage unique, preuve de travail, limites par IP,
  blocage miroir des adresses inconnues.
- Anti-force brute : 5 échecs → 15/30/60 min ; 20 échecs consécutifs → suspension
  des nouveaux appareils (levée par un admin) ; appareils de confiance jamais suspendus.
- Session mémorisée liée à la clé d'appareil, jeton remplacé à chaque usage,
  réutilisation détectée.
- Limite de débit « account », ouverture de la base au démarrage, comptes annoncés
  dans v1/info, purge du journal des comptes (12 mois, IP effacées après 6 mois).
- appsettings.json : section Players (Enabled = false).
2026-11-10 22:54:57 +01:00
nicolas 19f207d430 Comptes joueurs (8a) : stockage SQLite chiffré et empreintes Argon2id
- Paquets Microsoft.Data.Sqlite 8.0.34 et Konscious.Security.Cryptography.Argon2 1.3.1.
- PlayerSecrets : clé maîtresse hors du dossier des données (credential systemd),
  sous-clés HKDF, adresses chiffrées AES-256-GCM liées au compte, index aveugle.
- PlayerPasswordHasher : Argon2id 19 Mio × 2, sel aléatoire 128 bits par compte,
  poivre = clé secrète d'Argon2, format PHC, file de 4 calculs, calcul fictif
  pour une adresse inconnue.
- PlayerStore : players/players.db (WAL, secure_delete, 0600, schéma versionné),
  comptes, appareils, journal des comptes, compteurs d'échecs et suspension.
- Réglages LauncherApi:Players (désactivé par défaut) et leur validation.
2026-11-10 22:54:05 +01:00
nicolas a35e419133 Comptes joueurs (8a) : protocole launcher ⇄ serveur
- Messages signés par la clé d'appareil (en-tête GameLauncher/Player/…,
  distinct des messages admin), types de défi (création, connexion, renouvellement).
- Jetons joueurs glpa_ (accès), glpr_ (renouvellement), glpc_ (changement exigé),
  identifiants et références de compte PL-XXXX-XXXX.
- Objets échangés, routes v1/account/…, 13 codes d'erreur, ServerInfo.Accounts.
- SignedMessages.Build passe en internal (réutilisé par PlayerMessages).
2026-11-10 22:53:17 +01:00
nicolas a9d07f6df8 Comptes joueurs (8a) : règles des mots de passe et des adresses e-mail
- PasswordRules : 12 caractères et 3 types, ou 16 sans contrainte ; 128 au plus.
  Refus : dictionnaire NCSC (99 839 mots de passe) et ses variantes simples,
  mots de passe français courants, suites (répétitions, claviers, suites de
  chiffres ou de lettres), mots tirés de l'adresse ou des noms du studio et des jeux.
  Aucune normalisation Unicode : même calcul dans le launcher et sur le serveur.
- PlayerEmail : contrôle et normalisation (ASCII), masquage pour l'affichage.
- Dictionnaire intégré compressé (430 Ko), avec son origine et sa licence (MIT).
2026-11-10 22:52:37 +01:00
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
147 changed files with 10589 additions and 138 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>
+24
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,19 @@ 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("{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
@@ -182,6 +196,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
@@ -201,6 +223,8 @@ Global
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36} = {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}
+45 -2
View File
@@ -41,6 +41,7 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux »), « Notes de version » (`Changelog/`) et « Publication » (`Publishing/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
| `src/GameLauncher.Modules.Feedback` | Module « Aide » côté joueur (sans WPF) : formulaire de demande (avis, demande d'évolution, bug, autre) et envoi anonyme ; fenêtre dans `src\GameLauncher\Views\FeedbackWindow.xaml` |
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
| `website/GameLauncher.Website` | **Site vitrine** (ASP.NET Core Razor Pages) : présentation des jeux et du launcher, téléchargement, notes de version, actualités ; isolé de la Launcher API (voir §9) |
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
| `tools/UiSnapshot` | Captures de **toutes les fenêtres** (états de la fenêtre principale, dialogues, menu d'administration et ses pages) avec des données de démonstration, et rapport des problèmes de mise en page : `tools\ui-snapshot.bat` (voir `tools\UiSnapshot\LISEZMOI.md`) |
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
@@ -367,7 +368,7 @@ fait au démarrage suivant. Les entrées *Applications installées* (`Uninstall\
| Vignette d'un jeu (liste des jeux) | `launcher.json` → `"icon"` (image carrée) ; sinon fond du thème + initiales |
| Icône | Remplacez `Assets/launcher.ico` |
| Icônes de l'interface | `Themes/Icons.xaml` (tracés 24×24, compatibles [Material Design Icons](https://pictogrammers.com/library/mdi/)) |
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran (ou WebP avec `--format webp`) dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` ; sans recompiler : menu d'administration › **Jeux** (§8) |
| Fréquence des recherches de mises à jour | `launcher.json` → `updateCheckIntervalMinutes` |
| Dossier des données du jeu Unity (« Société\Produit ») | `launcher.json` → `gameDataFolder` |
@@ -567,7 +568,8 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs**, puis le **mode
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
@@ -707,3 +709,44 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs**, puis le **mode
| 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`).
+6 -4
View File
@@ -80,7 +80,8 @@ Dépendances : `Contracts` ne dépend de rien ; `ApiClient` → `Contracts` ; `S
(uniquement : le serveur n'utilise pas `Core`) ; `Core` → `Contracts` (règles du catalogue, phase 3) ;
`Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ; `Modules.Feedback` → `Modules` ;
`GameLauncher` → `Core`, `Modules`, `Modules.Admin`, `Modules.Feedback`.
Aucun paquet NuGet n'est nécessaire en dehors du projet de tests (xUnit).
Paquets NuGet : xUnit (projet de tests) ; depuis la phase 8, le serveur utilise aussi `Microsoft.Data.Sqlite` (Microsoft)
et `Konscious.Security.Cryptography.Argon2` (licence MIT) pour les comptes joueurs (`docs/PLAYER-ACCOUNTS-ARCHITECTURE.md`).
## 5. Modules
@@ -827,7 +828,7 @@ avant refusent encore un trousseau contenant `forms` et gardent le précédent (
| Route | Accès | Signée | Rôle |
|---|---|:-:|---|
| `GET v1/feedback/forms` | administrateur connecté | | Formulaires en vigueur, publication possible, adresse publique, signataire |
| `PUT v1/feedback/forms` | `feedback.forms` | ✔ | Publication : `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 sinon), audit `feedback.forms` (changements par type) |
| `PUT v1/feedback/forms` | `feedback.forms` | ✔ | Publication : `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 sinon), audit `feedback.forms` (révision, types ajoutés, modifiés et supprimés par leur nom, signature), puis une ligne par type de demande : `feedback.form_create`, `feedback.form_update` ou `feedback.form_delete`, cible « nom du type », détail de chaque changement (nom, description, réglages, champ ajouté, supprimé ou modifié avec ce qui a changé : libellé, type, obligatoire, aide, longueur, étoiles, choix, aperçu), sur plusieurs lignes numérotées (1/2, 2/2…) au-delà de 480 caractères |
**Serveur** (`Storage/FeedbackFormsStore`) : référence privée `feedback-forms.json` (+ `.bak`, historique de 20
révisions), copie **publique** à côté du trousseau (`FeedbackFormsPublicFileFullPath`, 644, atomique ; public d'abord).
@@ -1056,6 +1057,7 @@ trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; re
| **7d** | Demandes des joueurs : bouton « Aide » (avis, évolution, bug, autre ; anonyme, preuve de travail, limites), module serveur `Feedback`, page « Demandes des joueurs », purge automatique (demandes et journal d'audit) | ✅ |
| **7e** | Formulaires des demandes modifiables depuis le menu (types, champs, icônes importées), signés (autorisation `forms`) | ✅ |
| **7f** | Mode maintenance du launcher : page « Maintenance », module serveur `Maintenance`, document signé (autorisation `maintenance`), blocage côté joueur sauf la désinstallation, thème rouge / orange | ✅ |
| **8a** | Comptes joueurs — socle serveur (module `Accounts`, schéma d'authentification `PlayerSession` totalement séparé des administrateurs, base SQLite chiffrée, Argon2id + poivre, session mémorisée liée à une clé d'appareil, anti-force brute, journal des comptes, outil en ligne de commande) ; désactivé par défaut. Suite (8b à 8h) : `docs/PLAYER-ACCOUNTS-ARCHITECTURE.md`, § 12 | ✅ |
Chaque phase compile, est testée et laisse le launcher utilisable. Pour les joueurs, rien ne
change : le menu d'administration est discret et ne contacte le serveur qu'une fois ouvert.
@@ -1064,8 +1066,8 @@ change : le menu d'administration est discret et ne contacte le serveur qu'une f
| Option | Pourquoi elle n'a pas été retenue |
|---|---|
| Identifiant + mot de passe + OTP | Mots de passe hameçonnables ; secrets OTP stockés sur le serveur |
| Identifiant + mot de passe + OTP (administrateurs) | Mots de passe hameçonnables ; secrets OTP stockés sur le serveur. Les **comptes joueurs** (phase 8), totalement séparés, utilisent adresse + mot de passe + OTP facultatif : ils n'ouvrent jamais le menu d'administration |
| Envoi SFTP direct depuis le launcher | Pas de droits par rôle, pas d'audit, pas de validation serveur, clé SFTP = accès total aux fichiers |
| Secret partagé dans l'exe | Extractible par n'importe quel joueur |
| Base de données (SQL) | Inutile à cette échelle : fichiers JSON écrits atomiquement, verrou inter-processus, sauvegarde. SQLite n'est pas exclu : sa légitimité est évaluée **module par module** (voir § 11 quinquies pour les demandes des joueurs) |
| Base de données (SQL) | Inutile à cette échelle : fichiers JSON écrits atomiquement, verrou inter-processus, sauvegarde. SQLite n'est pas exclu : sa légitimité est évaluée **module par module** (voir § 11 quinquies pour les demandes des joueurs) ; **retenu pour les comptes joueurs** (relations, unicité, volumes : `PLAYER-ACCOUNTS-ARCHITECTURE.md` § 6) |
| Thème XAML distant | Un XAML téléchargé peut référencer du code : seuls les thèmes intégrés sont sélectionnables |
+247
View File
@@ -0,0 +1,247 @@
# Comptes joueurs — Architecture (phase 8)
> Document de référence du module « comptes joueurs ». Plan validé le 2026-11-10 (copie du plan dans les documents du
> projet : `GameLauncher-Comptes-Joueurs-Plan.md`). Statut des étapes : § 12.
> À relire avec `ADMIN-ARCHITECTURE.md` (socle, principes de sécurité) et la fiche RGPD / CNIL du projet.
## 1. Besoin
* Compte joueur = **adresse e-mail + mot de passe**, **OTP (TOTP) facultatif** (étape 8c).
* **Jamais obligatoire** pour lancer le launcher, installer ou jouer (jeux solo : anonyme). Obligatoire seulement pour
la future partie sociale.
* Bouton « Aide » : **anonyme** (comme aujourd'hui, aucune réponse) **ou connecté** (suivi + échanges avec les
administrateurs) — étape 8f.
* Menu admin (étape 8d), avec permissions : supprimer, désactiver / réactiver (durée ou indéfini), forcer le changement
de mot de passe, mot de passe temporaire, réinitialiser l'OTP ; blocages par IP publique et par identifiants matériels
du PC (étape 8e).
* **Aucun lien** avec les comptes administrateurs : un compte joueur ne peut **jamais** ouvrir le menu d'administration.
* Dictionnaire public des pires mots de passe ; anti-force brute (blocage temporaire puis suspension levée par un
administrateur) ; **connexion mémorisée** (comme Steam) ; **audit** de chaque action joueur et administrateur.
## 2. Principes
1. **Le serveur est la seule barrière** : chaque règle est revérifiée côté serveur avec le même code partagé
(`GameLauncher.Contracts.Accounts`).
2. **Deux mondes étanches** : administrateurs = clé publique/privée et requêtes signées ; joueurs = mot de passe.
Schémas d'authentification ASP.NET distincts (`AdminSession` / `PlayerSession`), jetons de préfixes distincts
(`glpa_`, `glpr_`, `glpc_` pour les joueurs), stockages distincts (`admins.json` + sessions en mémoire / `players.db`),
routes distinctes (`v1/account/…`). Chaque règle d'autorisation d'administration **désigne explicitement** le schéma
`AdminSession` ; le gestionnaire administrateur ignore tout jeton commençant par `glp`. Un test essaie **toutes** les
routes d'administration déclarées avec des jetons joueurs (accès, changement exigé, renouvellement) : 401 attendu
partout (`PlayerSeparationTests`).
3. **Minimisation** : ni nom, ni date de naissance, ni adresse postale, ni nom Windows du PC. Le pseudo viendra avec le
module social.
4. **Une copie des données du serveur ne livre rien d'exploitable** : adresses chiffrées, mots de passe en Argon2id avec
un « poivre », clé maîtresse hors du dossier des données.
5. **Désactivé par défaut** (`LauncherApi:Players:Enabled = false`) ; création de compte désactivable séparément.
6. Le launcher ne contacte l'API que si le joueur l'a voulu (connexion, création) ou s'il a une session mémorisée.
## 3. Protocole
### 3.1 Clé d'appareil
Le launcher crée une clé ECDSA P-256 **par PC** (étape 8b : dans la puce TPM si elle existe, sans code PIN, non
exportable ; sinon clé logicielle protégée par DPAPI). La création de compte, la connexion et chaque renouvellement de
session sont **signés** par cette clé (`PlayerMessages.Device`) :
```
GameLauncher/Player/Device/v1
purpose:<register|login|refresh>
origin:https://launcher.lanisys.fr
server:<serverId>
challenge:<challengeId>
nonce:<nonce>
device:<keyId de la clé d'appareil>
```
En-tête propre aux joueurs (séparation de domaine avec `GameLauncher/Admin/…`). Bénéfices : un jeton de renouvellement
copié sur un autre PC est inutilisable ; seul un appareil déjà connecté avec succès est « de confiance » pour
l'anti-force brute.
### 3.2 Défi
`POST v1/account/challenge { purpose }` → `{ challengeId, nonce, purpose, difficulty, serverId, origin, expiresAt }`.
Défi **sans état** (usage, échéance, difficulté et nonce scellés par HMAC, secret en mémoire, domaine
`GameLauncher/Player/Challenge/v1`), valable 5 min, **à usage unique** (consommé seulement avec une signature d'appareil
valide). Preuve de travail (`FeedbackProof`) : 20 bits pour la création de compte, 16 pour la connexion, aucune pour le
renouvellement. Un défi d'un usage ne sert pas à un autre.
### 3.3 Routes
| Route | Accès | Rôle |
|---|---|---|
| `POST v1/account/challenge` | anonyme | Défi (§ 3.2) |
| `POST v1/account/register` | anonyme + défi + clé d'appareil | Création de compte → session ouverte |
| `POST v1/account/login` | anonyme + défi + clé d'appareil | Connexion → session, ou jeton « changement exigé » |
| `POST v1/account/refresh` | jeton de renouvellement + signature de l'appareil | Session renouvelée (jeton remplacé) |
| `POST v1/account/logout` | session | Déconnexion de cet appareil, ou de tous (`allDevices`) |
| `GET v1/account/me` | session | Compte (adresse **masquée**) et appareils connectés |
| `POST v1/account/password` | session ou jeton « changement exigé » | Nouveau mot de passe |
| `DELETE v1/account/devices/{deviceId}` | session | Déconnecter un appareil du compte |
Limite de débit propre (`account`, `Players:AuthRequestsPerMinute`, 20/min par IP, IPv6 par /64) sur le défi, la
création, la connexion, le renouvellement et le changement de mot de passe, en plus de la limite générale. `GET v1/info`
annonce `accounts` (activés, création ouverte, version des conditions, version des règles des mots de passe) ; absent
si les comptes sont désactivés.
### 3.4 Jetons et session mémorisée
| Jeton | Format | Durée | Où |
|---|---|---|---|
| Accès | `glpa_` + 256 bits | 15 min | mémoire du launcher ; serveur : empreinte en mémoire |
| Renouvellement | `glpr_` + identifiant d'appareil + `.` + 256 bits | 90 jours sans utilisation, 1 an au plus | PC (chiffré DPAPI, étape 8b) ; serveur : empreinte en base |
| Changement exigé | `glpc_` + 256 bits | 10 min, usage unique | mémoire |
Renouvellement : jeton **remplacé à chaque fois** (l'ancien devient « précédent ») ; un jeton précédent présenté de
nouveau = copie volée (ou réponse perdue) → l'appareil est déconnecté par précaution (`session.reuse`). Deux
renouvellements simultanés avec le même jeton : un seul réussit. Un redémarrage du service efface les jetons d'accès : le
launcher renouvelle sa session sans rien demander au joueur.
À chaque requête authentifiée : jeton valide **et** compte actif (ou suspendu, § 5) **et** appareil toujours enregistré
**et** aucun changement de mot de passe exigé. Une désactivation, une déconnexion ou un changement de mot de passe
prennent effet immédiatement.
## 4. Mots de passe et adresses
* **Règles** (`PasswordRules`, identiques launcher / serveur) : 12 caractères au moins avec 3 types parmi minuscules,
majuscules, chiffres, symboles ; ou 16 caractères sans contrainte (phrase de passe) ; 128 au plus ; pas de caractère
de contrôle. Refus : mots de passe du **dictionnaire** (« 100k-most-used-passwords-NCSC », 99 839 entrées, ressource
intégrée compressée, + une liste de mots de passe français courants) et leurs variantes simples (minuscules,
substitutions `0→o 1→i 3→e 4→a 5→s 7→t @→a $→s…`, chiffres et symboles retirés aux extrémités :
« P@ssw0rd2024! » ≡ « password ») ; suites triviales (répétitions, rangées de claviers AZERTY / QWERTY / QWERTZ, suites
de lettres ou de chiffres, motifs répétés) ; mot de passe tiré de l'adresse e-mail ou des noms du studio et des jeux
(moins de 8 caractères restants une fois ces mots retirés).
* **Aucune normalisation Unicode** : le serveur tourne en « globalisation invariante » (sans ICU), où elle n'est pas
disponible ; le mot de passe est contrôlé et haché tel qu'il est saisi, de la même façon partout.
* **Adresse** (`PlayerEmail`) : minuscules, caractères ASCII seulement (domaines internationaux sous forme `xn--`), un
seul `@`, 254 caractères au plus ; masquée pour l'affichage (`n***@e***.fr`). Non vérifiée tant que le module e-mail
n'existe pas (`emailVerified = false`).
* **Empreinte** (`PlayerPasswordHasher`) : Argon2id (RFC 9106 ; paquet `Konscious.Security.Cryptography.Argon2`, MIT),
19 Mio, 2 passes, parallélisme 1 (OWASP), sel 128 bits, **poivre = clé secrète d'Argon2** dérivée de la clé maîtresse.
Format PHC `$argon2id$v=19$m=19456,t=2,p=1,k=1$<sel>$<empreinte>` : un relèvement des réglages est appliqué à la
connexion suivante de chaque joueur. Calculs limités à 4 simultanés (file d'attente, 503 au-delà de 20 s) ; même calcul
pour une adresse inconnue (la durée de réponse ne révèle rien). Vecteur officiel de la RFC vérifié par un test
(`Argon2VectorTests`).
## 5. Anti-force brute
1. Limites de débit (nginx et ASP.NET) et preuve de travail.
2. **Par adresse IP** (en mémoire) : 30 échecs de connexion / 15 min tous comptes confondus → 429 ; 3 créations de
compte / heure et 5 / jour → 429 ; plafond global de 500 créations / jour → 503.
3. **Par compte, depuis un appareil inconnu** : blocage après **5 échecs consécutifs** (15 min, puis 30, puis 60 :
durée doublée à chaque blocage) ; au **4ᵉ blocage (20 échecs consécutifs)**, le compte est **suspendu**. Le blocage
s'applique avant tout calcul (429 + `Retry-After`).
4. **Appareils de confiance** (déjà connectés, prouvés par leur clé) : leurs échecs sont comptés à part (blocage de
15 min après 10 échecs), **jamais de suspension**.
5. **Suspension = connexions depuis un NOUVEL appareil refusées** jusqu'à ce qu'un administrateur la lève (outil en ligne
de commande dès maintenant, menu d'administration à l'étape 8d). Les appareils déjà connectés continuent de
fonctionner (`newDevicesSuspended` dans `GET v1/account/me`) : un tiers qui connaît l'adresse peut déclencher la
suspension, mais pas priver le joueur de son compte. Inscrite au journal d'audit (`players.suspend`, par référence).
6. **Rien ne révèle qu'une adresse a un compte** : message identique pour une adresse inconnue et un mauvais mot de
passe, même durée de calcul, et une adresse inconnue se **bloque exactement comme un compte réel** (blocage « miroir »
en mémoire). L'état du compte (désactivé, suspendu) n'est révélé qu'après un **mot de passe correct**. Seule exception
inévitable sans e-mail de confirmation : la création de compte indique qu'une adresse est déjà prise (atténué par les
points 1 et 2).
7. Une connexion réussie remet les compteurs à zéro.
## 6. Stockage
**SQLite retenu** pour ce module (analyse « module par module ») : relations (compte ⇄ appareils ⇄ journal, puis
conversations et blocages), unicité stricte des adresses sous transaction, volumes non bornés, recherches par index.
`Microsoft.Data.Sqlite` (Microsoft), sans Entity Framework, requêtes paramétrées uniquement.
* Fichier : `/var/lib/launcher-api/players/players.db` (dossier 0700, fichier 0600 créé avant l'ouverture ; les fichiers
`-wal` et `-shm` reprennent ses droits). Journal WAL, clés étrangères, `secure_delete` (lignes supprimées écrasées),
écriture synchrone complète. Schéma versionné (`PRAGMA user_version`, version 1). Une seule connexion protégée par un
verrou ; l'outil en ligne de commande peut ouvrir la base en même temps.
* Tables : `accounts` (identifiant 128 bits, référence `PL-XXXX-XXXX`, index aveugle et adresse chiffrée, empreinte du
mot de passe, « changement exigé », champs OTP réservés à l'étape 8c, état, échéance de désactivation, motif chiffré,
dates, version des conditions acceptée, compteurs d'échecs et blocages) ; `devices` (clé publique, nom générique,
empreintes du jeton de renouvellement en vigueur et précédent, échéances) ; `account_events` (journal, § 7).
* **Chiffrement** (`PlayerSecrets`) : clé maîtresse de 256 bits → sous-clés HKDF-SHA256 par usage (chiffrement des
adresses, index aveugle, poivre, champs libres). Adresse : AES-256-GCM, nonce aléatoire, **liée au compte** (données
associées : une valeur copiée sur un autre compte ne se déchiffre pas), préfixe de version de clé (`1.`). Recherche et
unicité par **index aveugle** HMAC-SHA256 de l'adresse normalisée.
* **Clé maîtresse hors du dossier des données** : credential systemd chiffré (`LoadCredentialEncrypted`,
`players-master-key`, lu dans `$CREDENTIALS_DIRECTORY`) ou `Players:MasterKeyFile` (chemin absolu). Le serveur refuse
de démarrer si la clé manque, est dans le dossier des données, est accessible à d'autres comptes, ou n'est pas une clé
de 32 octets. **À sauvegarder hors ligne** : perdue, les adresses sont illisibles et tous les mots de passe doivent
être réinitialisés. Commande : `GameLauncher.Server players-key`.
* Les demandes des joueurs restent en fichiers JSON (étape 8f : identifiant opaque du compte ajouté, messages dans
SQLite, chiffrés).
## 7. Audit
* **Actions des joueurs et du système sur un compte** → table `account_events` : création, connexion réussie ou
refusée, blocage, suspension, déconnexion (un appareil / tous), changement de mot de passe (dont exigé), appareil
déconnecté, renouvellement (une ligne par appareil et par jour au plus), réutilisation d'un jeton. Avec l'adresse IP et
l'identifiant de la clé d'appareil, **jamais l'adresse e-mail ni le mot de passe**. Les échecs sur une adresse sans
compte sont inscrits sans compte.
* **Actions des administrateurs et du système** → journal d'audit existant, par **référence** de compte : `players.suspend`
(suspension automatique), `cli.players_unlock`, `cli.players_enable`, `cli.players_disable`, `players.events_purge`.
Les libellés lisibles et la catégorie « Comptes joueurs » du journal du menu arrivent avec l'étape 8d.
* **Conservation** (`RetentionService`, au démarrage puis chaque jour) : journal des comptes 12 mois
(`Players:EventsRetentionMonths`), adresses IP effacées au-delà de 6 mois (`Players:EventsIpRetentionMonths`).
## 8. Outil en ligne de commande (secours, sur le VPS)
Un compte est désigné par sa **référence** ; l'adresse e-mail n'est jamais affichée (aucune clé maîtresse nécessaire).
| Commande | Rôle |
|---|---|
| `players-key` | Génère une clé maîtresse (sortie standard, à chiffrer avec `systemd-creds`) |
| `players-show --ref PL-XXXX-XXXX` | État, dates, échecs, appareils, derniers événements |
| `players-unlock --ref …` | Lève une suspension ou un blocage (compte réactivé) |
| `players-disable --ref … [--until AAAA-MM-JJ[THH:MM]]` | Désactive (UTC ; sans date : indéfiniment) |
| `players-enable --ref …` | Réactive |
## 9. Réglages (`LauncherApi:Players`)
| Réglage | Défaut | Rôle |
|---|---|---|
| `Enabled` | `false` | Service des comptes |
| `RegistrationEnabled` | `true` | Création de compte ouverte |
| `MasterKeyFile` | vide | Clé maîtresse (vide : credential systemd `players-master-key`) |
| `TermsVersion` | `1` | Version des conditions à accepter à la création |
| `HashMemoryKib`, `HashIterations`, `HashParallelism` | 19456, 2, 1 | Argon2id (coût minimal refusé en dessous de 19 Mio × 2) |
| `MaxConcurrentHashes` | 4 | Calculs simultanés |
| `RegisterProofDifficulty`, `LoginProofDifficulty` | 20, 16 | Preuves de travail |
| `FailuresPerLock`, `LockMinutes`, `SuspendAfterLocks` | 5, 15, 4 | Anti-force brute (appareils inconnus) |
| `TrustedFailuresPerLock` | 10 | Appareils de confiance |
| `FailedLoginsPerIpPer15Minutes` | 30 | Échecs par IP |
| `RegistrationsPerIpPerHour`, `…PerDay`, `MaxRegistrationsPerDay` | 3, 5, 500 | Créations de compte |
| `AuthRequestsPerMinute` | 20 | Limite de débit des routes `account` |
| `AccessTokenMinutes`, `RefreshIdleDays`, `RefreshMaxDays` | 15, 90, 365 | Sessions |
| `MaxDevicesPerAccount` | 20 | Au-delà, le moins récemment utilisé est déconnecté |
| `EventsRetentionMonths`, `EventsIpRetentionMonths` | 12, 6 | Conservation du journal des comptes |
## 10. RGPD / CNIL
* Base légale : exécution du contrat (compte, échanges) ; intérêt légitime (journaux de sécurité, anti-force brute,
blocages). Aucun traitement par consentement.
* Étape 8b : export JSON de ses données et suppression du compte depuis le launcher ; interface commune
`IPersonalDataProvider` ; comptes inactifs (désactivés après 2 ans, supprimés 1 an plus tard).
* Étape 8g : registre des traitements, politique de confidentialité et CGU, brouillon d'AIPD (mineurs, identifiants
matériels, messagerie).
## 11. Limites assumées
* Adresse non vérifiée tant que le module e-mail n'existe pas (étape 8h) : n'importe qui peut créer un compte avec une
adresse qui n'est pas la sienne ; l'administrateur peut le supprimer (étape 8d).
* La création de compte révèle qu'une adresse est déjà utilisée (§ 5, point 6).
* Rotation de la clé maîtresse : prévue par le format (version de clé), pas encore outillée.
## 12. Étapes
| Étape | Contenu | Statut |
|---|---|---|
| **8a** | Socle serveur : contrats (`Contracts/Accounts`), module `Accounts`, schéma `PlayerSession`, base SQLite chiffrée, Argon2id + poivre, création / connexion / renouvellement / déconnexion / mot de passe / appareils, anti-force brute, journal des comptes, purge, outil en ligne de commande, tests (dont l'étanchéité sur toutes les routes d'administration) | ✅ |
| 8b | Launcher : bouton Compte, connexion, création, session mémorisée (clé TPM / DPAPI), « Mon compte », export et suppression | à venir |
| 8c | OTP (TOTP, codes de secours, QR maison) | à venir |
| 8d | Menu d'administration « Comptes joueurs » | à venir |
| 8e | Blocages par IP et par identifiants matériels (un blocage depuis une fiche vise les deux) | à venir |
| 8f | Demandes connectées et échanges avec les administrateurs | à venir |
| 8g | Mise en production (nginx, credential, sauvegardes, textes RGPD, AIPD, revue de sécurité indépendante) | à venir |
| 8h | E-mails (vérification, réinitialisation autonome) | futur |
Activation en production : après 8a, 8b, 8c, 8d et 8g au minimum.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 416 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

After

Width:  |  Height:  |  Size: 395 KiB

+14 -2
View File
@@ -19,7 +19,8 @@ public static class AdminCli
private static readonly string[] Commands = { "add-owner", "add-key", "list", "revoke-admin", "revoke-key", "server-id", "help" };
public static bool IsCommand(string[] args) =>
args.Length > 0 && Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase);
args.Length > 0 && (Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase) ||
PlayersCli.Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase));
public static int Run(string[] args, TextWriter? output = null, string? dataDirectoryOverride = null)
{
@@ -29,6 +30,9 @@ public static class AdminCli
var command = args[0].ToLowerInvariant();
if (command == "help") return Help(o);
var options = ReadOptions(args);
// Clé maîtresse des comptes joueurs : n'utilise pas le dossier des données (peut être lancée par root, la sortie
// étant directement chiffrée par systemd-creds).
if (command == "players-key") return PlayersCli.Key(o, interactive: output is null);
// Lancé par erreur avec « sudo » (root) au lieu de « sudo -u deploy » : les fichiers recréés
// appartiendraient à root et le service ne pourrait plus les lire.
@@ -37,7 +41,7 @@ public static class AdminCli
"ne lancez pas cet outil en root : sudo -u deploy /opt/launcher-api/GameLauncher.Server " + command + " …");
var dataDirectory = dataDirectoryOverride ?? ResolveDataDirectory(options);
if (command != "add-owner" && command != "server-id" &&
if (command != "add-owner" && command != "server-id" && !command.StartsWith("players-", StringComparison.Ordinal) &&
!File.Exists(Path.Combine(dataDirectory, AdminStore.FileName)))
throw new InvalidOperationException(
$"aucun fichier {AdminStore.FileName} dans {dataDirectory} : mauvais dossier de données ? (option --data-dir)");
@@ -57,6 +61,11 @@ public static class AdminCli
case "server-id":
o.WriteLine(ServerIdentity.LoadOrCreate(dataDirectory));
return 0;
case "players-show":
case "players-unlock":
case "players-disable":
case "players-enable":
return PlayersCli.Run(command, options, dataDirectory, audit, actor, o);
default: return Help(o);
}
}
@@ -219,6 +228,9 @@ public static class AdminCli
revoke-key --key <identifiant> Révoque une clé (poste perdu ou volé).
server-id Affiche l'identifiant de ce serveur.
Comptes joueurs (désignés par leur référence ; l'adresse e-mail n'est jamais affichée) :
""" + "\n" + PlayersCli.HelpText + "\n" + """
Option commune : --data-dir <dossier> (sinon LauncherApi:DataDirectory d'appsettings.json).
""");
return 0;
@@ -0,0 +1,108 @@
using System.Globalization;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Storage;
using GameLauncher.Server.Storage.Players;
namespace GameLauncher.Server.Cli;
/// <summary>
/// Commandes de secours des comptes joueurs (sur le VPS, sous le compte du service). Elles désignent un compte par sa
/// RÉFÉRENCE (« PL-7KQ2-M9XD ») et n'affichent jamais l'adresse e-mail : elles n'ont pas besoin de la clé maîtresse.
/// Le service n'a pas besoin d'être arrêté (SQLite gère l'accès simultané) ; l'état du compte est relu à chaque requête.
/// La gestion courante se fait depuis le menu d'administration (étape 8d).
/// </summary>
internal static class PlayersCli
{
public static readonly string[] Commands = { "players-key", "players-show", "players-unlock", "players-disable", "players-enable" };
public const string HelpText = """
players-key Génère une clé maîtresse des comptes joueurs (à chiffrer avec systemd-creds, voir LISEZMOI).
players-show --ref <PL-XXXX-XXXX> État d'un compte joueur (sans son adresse e-mail).
players-unlock --ref <PL-XXXX-XXXX> Lève une suspension ou un blocage (compte réactivé).
players-disable --ref <PL-XXXX-XXXX> [--until AAAA-MM-JJ[THH:MM]] Désactive un compte (UTC ; sans --until : indéfiniment).
players-enable --ref <PL-XXXX-XXXX> Réactive un compte désactivé.
""";
/// <summary>Nouvelle clé maîtresse : sur la sortie standard (à rediriger vers systemd-creds), mode d'emploi sur stderr.</summary>
public static int Key(TextWriter o, bool interactive)
{
o.WriteLine(PlayerSecrets.NewMasterKey());
if (interactive)
Console.Error.WriteLine("Clé maîtresse générée (32 octets, Base64). À chiffrer immédiatement, sans la laisser sur le disque, ex. :\n" +
" GameLauncher.Server players-key | sudo systemd-creds encrypt --name=players-master-key - " +
"/etc/credstore.encrypted/players-master-key\n" +
"Gardez-en une copie HORS LIGNE (gestionnaire de mots de passe) : perdue, les adresses e-mail sont " +
"illisibles et tous les mots de passe doivent être réinitialisés.");
return 0;
}
public static int Run(string command, Dictionary<string, string> options, string dataDirectory, AuditLog audit, string actor, TextWriter o)
{
var path = Path.Combine(dataDirectory, PlayerStore.FolderName, PlayerStore.FileName);
if (!File.Exists(path)) throw new InvalidOperationException($"aucune base des comptes joueurs ({path}).");
using var store = new PlayerStore(dataDirectory, TimeProvider.System);
var reference = PlayerIds.NormalizeReference(Get(options, "ref"))
?? throw new ArgumentException("--ref <PL-XXXX-XXXX> obligatoire (référence du compte).");
var account = store.FindByReference(reference) ?? throw new InvalidOperationException($"aucun compte {reference}.");
var now = DateTimeOffset.UtcNow;
switch (command)
{
case "players-show":
Show(store, account, o);
return 0;
case "players-unlock":
case "players-enable":
store.SetStatus(account.Id, PlayerStatus.Active, null, null, actor, now);
store.AddEvent(account.Id, command == "players-unlock" ? PlayerEvents.Unlocked : PlayerEvents.Enabled, AuditResults.Ok, null,
details: $"par {actor} (outil en ligne de commande)");
audit.Write("cli." + command.Replace('-', '_'), AuditResults.Ok, actorName: actor, target: reference,
details: $"ancien état : {account.Status}");
o.WriteLine($"Compte {reference} réactivé (blocages remis à zéro).");
return 0;
case "players-disable":
DateTimeOffset? until = null;
if (Get(options, "until") is { Length: > 0 } text)
{
if (!DateTimeOffset.TryParseExact(text, new[] { "yyyy-MM-dd", "yyyy-MM-dd'T'HH:mm" }, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) || date <= now)
throw new ArgumentException("--until : date future attendue, AAAA-MM-JJ ou AAAA-MM-JJTHH:MM (UTC).");
until = date;
}
store.SetStatus(account.Id, PlayerStatus.Disabled, until, null, actor, now);
store.AddEvent(account.Id, PlayerEvents.Disabled, AuditResults.Ok, null,
details: $"par {actor} (outil en ligne de commande){(until is { } u ? $" jusqu'au {u:yyyy-MM-dd HH:mm} UTC" : " indéfiniment")}");
audit.Write("cli.players_disable", AuditResults.Ok, actorName: actor, target: reference,
details: until is { } end ? $"jusqu'au {end:yyyy-MM-dd HH:mm} UTC" : "indéfiniment");
o.WriteLine($"Compte {reference} désactivé{(until is { } d ? $" jusqu'au {d:yyyy-MM-dd HH:mm} UTC" : " indéfiniment")} " +
"(ses sessions sont refusées dès la requête suivante).");
return 0;
}
throw new ArgumentException($"commande inconnue : {command}");
}
private static void Show(PlayerStore store, PlayerAccountRecord account, TextWriter o)
{
static string Date(DateTimeOffset? d) => d is { } v ? v.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) : "—";
var devices = store.Devices(account.Id);
o.WriteLine($"Compte {account.Reference}");
o.WriteLine($" État : {account.Status}{(account.DisabledUntil is { } u ? $" jusqu'au {Date(u)}" : "")}");
o.WriteLine($" Créé le : {Date(account.CreatedAt)}");
o.WriteLine($" Dernière connexion : {Date(account.LastLoginAt)}");
o.WriteLine($" Mot de passe changé : {Date(account.PasswordChangedAt)}{(account.MustChangePassword ? " (nouveau mot de passe exigé)" : "")}");
o.WriteLine($" Échecs (inconnus) : {account.FailedCount} en cours, {account.LockLevel} blocage(s), bloqué jusqu'au {Date(account.LockedUntil)}");
o.WriteLine($" Échecs (confiance) : {account.TrustedFailedCount} en cours, bloqué jusqu'au {Date(account.TrustedLockedUntil)}");
o.WriteLine($" Appareils connectés : {devices.Count}");
foreach (var device in devices)
o.WriteLine($" · {device.Label} (clé {device.KeyId}) — dernière utilisation {Date(device.LastUsedAt)}");
o.WriteLine(" Derniers événements :");
foreach (var e in store.Events(account.Id, 10))
o.WriteLine($" {Date(e.At)} {e.Type} {e.Result} {e.Details}");
}
private static string? Get(Dictionary<string, string> options, string name) =>
options.TryGetValue(name, out var value) ? value : null;
}
@@ -25,6 +25,18 @@
<ProjectReference Include="..\..\src\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
</ItemGroup>
<!--
Comptes joueurs (phase 8, docs\PLAYER-ACCOUNTS-ARCHITECTURE.md) :
• Microsoft.Data.Sqlite (Microsoft) : base players.db ; embarque la bibliothèque native SQLite (SQLitePCLRaw,
bibliothèque e_sqlite3 de linux-x64 incluse à la publication autonome) ;
• Konscious.Security.Cryptography.Argon2 (licence MIT, 100 % C#) : empreintes Argon2id des mots de passe (RFC 9106),
absentes de .NET 8.
-->
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.34" />
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="GameLauncher.Tests" />
</ItemGroup>
@@ -33,7 +33,8 @@ public static class ApiModules
new Modules.Signing.SigningModule(),
new Modules.Files.FilesModule(),
new Modules.Feedback.FeedbackModule(),
new Modules.Maintenance.MaintenanceModule()
new Modules.Maintenance.MaintenanceModule(),
new Modules.Accounts.AccountsModule()
};
}
@@ -51,4 +52,8 @@ public static class RateLimitPolicies
/// <summary>Demandes des joueurs : défis et envois anonymes (en plus de la limite générale).</summary>
public const string Feedback = "feedback";
/// <summary>Comptes joueurs : défis, création, connexion, renouvellement, changement de mot de passe (en plus de la
/// limite générale).</summary>
public const string Account = "account";
}
@@ -53,6 +53,9 @@ public sealed class LauncherApiOptions
/// <summary>Journal d'audit : durée de conservation (purge automatique).</summary>
public AuditSettings Audit { get; set; } = new();
/// <summary>Comptes joueurs (phase 8) : désactivés par défaut. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.</summary>
public PlayersSettings Players { get; set; } = new();
/// <summary>
/// Jeux dont le manifest.json publié peut être modifié par l'API (notes de version, phase 4), par
/// identifiant de jeu (GameId de launcher.json). Absent : notes non modifiables en ligne.
@@ -177,6 +180,78 @@ public sealed class LauncherApiOptions
public int RetentionMonths { get; set; } = 12;
}
/// <summary>
/// Comptes joueurs : adresse e-mail + mot de passe, connexion mémorisée liée à une clé d'appareil. Données dans
/// « players/players.db » (SQLite, dossier des données privées), adresses e-mail chiffrées, mots de passe hachés
/// (Argon2id + clé secrète). La clé maîtresse est hors du dossier des données (credential systemd chiffré).
/// </summary>
public sealed class PlayersSettings
{
/// <summary>Service activé (faux par défaut : les routes « v1/account/… » répondent 503 « not_configured »).</summary>
public bool Enabled { get; set; }
/// <summary>Création de compte ouverte (la connexion reste possible si faux).</summary>
public bool RegistrationEnabled { get; set; } = true;
/// <summary>
/// Fichier de la clé maîtresse (32 octets en Base64). Vide : « players-master-key » dans le dossier des credentials
/// systemd ($CREDENTIALS_DIRECTORY, voir server/deploy/LISEZMOI.md). Jamais dans le dossier des données, jamais
/// lisible par le groupe ou les autres comptes.
/// </summary>
public string MasterKeyFile { get; set; } = "";
/// <summary>Version des conditions d'utilisation / politique de confidentialité à accepter à la création du compte.</summary>
public string TermsVersion { get; set; } = "1";
/// <summary>Argon2id : mémoire (Kio), passes, parallélisme. OWASP : 19 Mio et 2 passes au minimum (ou équivalent).</summary>
public int HashMemoryKib { get; set; } = 19456;
public int HashIterations { get; set; } = 2;
public int HashParallelism { get; set; } = 1;
/// <summary>Calculs de mot de passe simultanés (les suivants attendent) : protège le VPS d'un afflux de connexions.</summary>
public int MaxConcurrentHashes { get; set; } = 4;
/// <summary>Preuve de travail exigée à la création de compte et à la connexion (bits à zéro).</summary>
public int RegisterProofDifficulty { get; set; } = 20;
public int LoginProofDifficulty { get; set; } = 16;
/// <summary>Durée de validité d'un défi joueur.</summary>
public int ChallengeLifetimeSeconds { get; set; } = 300;
/// <summary>Anti-force brute (appareils inconnus) : blocage après N échecs consécutifs, durée doublée à chaque
/// blocage ; au blocage n° SuspendAfterLocks, le compte est SUSPENDU pour les nouveaux appareils (seul un
/// administrateur lève la suspension ; les appareils déjà connectés continuent de fonctionner).</summary>
public int FailuresPerLock { get; set; } = 5;
public int LockMinutes { get; set; } = 15;
public int SuspendAfterLocks { get; set; } = 4;
/// <summary>Appareils de confiance (déjà connectés) : blocage temporaire après N échecs, jamais de suspension.</summary>
public int TrustedFailuresPerLock { get; set; } = 10;
/// <summary>Échecs de connexion acceptés par adresse IP (IPv6 : /64) sur 15 minutes, tous comptes confondus.</summary>
public int FailedLoginsPerIpPer15Minutes { get; set; } = 30;
/// <summary>Créations de compte par adresse IP (IPv6 : /64) par heure et par jour, et au total par jour.</summary>
public int RegistrationsPerIpPerHour { get; set; } = 3;
public int RegistrationsPerIpPerDay { get; set; } = 5;
public int MaxRegistrationsPerDay { get; set; } = 500;
/// <summary>Requêtes par minute et par adresse IP sur les routes de défi, création, connexion et renouvellement.</summary>
public int AuthRequestsPerMinute { get; set; } = 20;
/// <summary>Jeton d'accès (minutes) ; session mémorisée : jours sans utilisation, puis durée maximale (jours).</summary>
public int AccessTokenMinutes { get; set; } = 15;
public int RefreshIdleDays { get; set; } = 90;
public int RefreshMaxDays { get; set; } = 365;
/// <summary>Appareils connectés par compte (au-delà, le moins récemment utilisé est déconnecté).</summary>
public int MaxDevicesPerAccount { get; set; } = 20;
/// <summary>Journal des comptes : conservation (mois), et effacement des adresses IP au-delà de N mois.</summary>
public int EventsRetentionMonths { get; set; } = 12;
public int EventsIpRetentionMonths { get; set; } = 6;
}
/// <summary>Un jeu publié sur ce serveur.</summary>
public sealed class GameSettings
{
@@ -264,6 +339,9 @@ public sealed class LauncherApiOptions
public IReadOnlyDictionary<string, string> GameManifestFiles { get; private set; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
/// <summary>Fichier de la clé maîtresse des comptes joueurs (null : comptes désactivés), calculé par <see cref="Validate"/>.</summary>
public string? PlayersMasterKeyFullPath { get; private set; }
/// <summary>Jeux acceptés par la réception des demandes des joueurs, calculés par <see cref="Validate"/>.</summary>
public IReadOnlySet<string> FeedbackGameIds { get; private set; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -298,6 +376,7 @@ public sealed class LauncherApiOptions
ValidateGames();
ValidateFiles();
ValidateFeedback();
ValidatePlayers();
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
@@ -312,6 +391,67 @@ public sealed class LauncherApiOptions
Require(Audit.RetentionMonths is >= 1 and <= 120, "Audit:RetentionMonths (1 à 120)");
}
private void ValidatePlayers()
{
var players = Players;
PlayersMasterKeyFullPath = null;
Require(players.HashMemoryKib is >= 7168 and <= 1_048_576, "Players:HashMemoryKib (7168 à 1048576)");
Require(players.HashIterations is >= 1 and <= 20, "Players:HashIterations (1 à 20)");
// OWASP : coût au moins équivalent à 19 Mio × 2 passes (ex. 12 Mio × 3, 9 Mio × 4, 7 Mio × 5).
Require((long)players.HashMemoryKib * players.HashIterations >= 19456L * 2, "Players:HashMemoryKib × HashIterations (au moins 38912)");
Require(players.HashParallelism is >= 1 and <= 16, "Players:HashParallelism (1 à 16)");
Require(players.MaxConcurrentHashes is >= 1 and <= 64, "Players:MaxConcurrentHashes (1 à 64)");
Require(players.RegisterProofDifficulty is >= Contracts.Feedback.FeedbackProof.MinDifficulty and <= Contracts.Feedback.FeedbackProof.MaxDifficulty &&
players.LoginProofDifficulty is >= Contracts.Feedback.FeedbackProof.MinDifficulty and <= Contracts.Feedback.FeedbackProof.MaxDifficulty,
$"Players:*ProofDifficulty ({Contracts.Feedback.FeedbackProof.MinDifficulty} à {Contracts.Feedback.FeedbackProof.MaxDifficulty})");
Require(players.ChallengeLifetimeSeconds is >= 30 and <= 3600, "Players:ChallengeLifetimeSeconds (30 à 3600)");
Require(players.FailuresPerLock is >= 3 and <= 20, "Players:FailuresPerLock (3 à 20)");
Require(players.LockMinutes is >= 1 and <= 1440, "Players:LockMinutes (1 à 1440)");
Require(players.SuspendAfterLocks is >= 2 and <= 20, "Players:SuspendAfterLocks (2 à 20)");
Require(players.TrustedFailuresPerLock is >= 3 and <= 50, "Players:TrustedFailuresPerLock (3 à 50)");
Require(players.FailedLoginsPerIpPer15Minutes is >= 5 and <= 10_000, "Players:FailedLoginsPerIpPer15Minutes (5 à 10000)");
Require(players.RegistrationsPerIpPerHour is >= 1 and <= 1000 && players.RegistrationsPerIpPerDay >= players.RegistrationsPerIpPerHour &&
players.RegistrationsPerIpPerDay <= 10_000, "Players:RegistrationsPerIp*");
Require(players.MaxRegistrationsPerDay is >= 1 and <= 1_000_000, "Players:MaxRegistrationsPerDay");
Require(players.AuthRequestsPerMinute is >= 1 and <= 1_000_000, "Players:AuthRequestsPerMinute");
Require(players.AccessTokenMinutes is >= 1 and <= 60, "Players:AccessTokenMinutes (1 à 60)");
Require(players.RefreshIdleDays is >= 1 and <= 365, "Players:RefreshIdleDays (1 à 365)");
Require(players.RefreshMaxDays >= players.RefreshIdleDays && players.RefreshMaxDays <= 730, "Players:RefreshMaxDays (RefreshIdleDays à 730)");
Require(players.MaxDevicesPerAccount is >= 1 and <= 100, "Players:MaxDevicesPerAccount (1 à 100)");
Require(players.EventsRetentionMonths is >= 1 and <= 120, "Players:EventsRetentionMonths (1 à 120)");
Require(players.EventsIpRetentionMonths is >= 1 && players.EventsIpRetentionMonths <= players.EventsRetentionMonths,
"Players:EventsIpRetentionMonths (1 à EventsRetentionMonths)");
if (Contracts.ApiText.CleanLine(players.TermsVersion, 32) is null)
throw new InvalidOperationException($"{Section}:Players:TermsVersion : une version (ex. « 2026-11 ») est obligatoire.");
if (!players.Enabled) return;
string file;
if (!string.IsNullOrWhiteSpace(players.MasterKeyFile))
{
file = players.MasterKeyFile.Trim();
if (!Path.IsPathRooted(file))
throw new InvalidOperationException($"{Section}:Players:MasterKeyFile doit être un chemin absolu.");
}
else if (Environment.GetEnvironmentVariable("CREDENTIALS_DIRECTORY") is { Length: > 0 } credentials)
{
file = Path.Combine(credentials, "players-master-key");
}
else
{
throw new InvalidOperationException(
$"{Section}:Players:Enabled = true, mais aucune clé maîtresse : credential systemd « players-master-key » " +
"(LoadCredentialEncrypted, voir server/deploy/LISEZMOI.md) ou Players:MasterKeyFile.");
}
var full = Path.GetFullPath(file);
var directory = Path.GetDirectoryName(full) ?? "";
var keyDirectory = RealPath(directory);
var dataDirectory = RealPath(DataDirectoryFullPath);
if (IsSameOrInside(keyDirectory, dataDirectory))
throw new InvalidOperationException(
$"{Section}:Players:MasterKeyFile : la clé maîtresse ne doit pas être dans le dossier des données (une copie des données ne doit pas la contenir).");
PlayersMasterKeyFullPath = full;
}
private void ValidateFeedback()
{
var feedback = Feedback;
@@ -9,7 +9,9 @@ namespace GameLauncher.Server.Hosting;
/// puis toutes les 24 heures :
/// • journal d'audit : fichiers mensuels plus anciens que <c>LauncherApi:Audit:RetentionMonths</c> (12 mois) ;
/// • demandes des joueurs : closes depuis <c>Feedback:RetentionClosedMonths</c> (12), et toutes au-delà de
/// <c>Feedback:RetentionMaxMonths</c> (24).
/// <c>Feedback:RetentionMaxMonths</c> (24) ;
/// • journal des comptes joueurs (si activés) : lignes au-delà de <c>Players:EventsRetentionMonths</c> (12), adresses IP
/// effacées au-delà de <c>Players:EventsIpRetentionMonths</c> (6).
/// Chaque purge qui supprime quelque chose est inscrite au journal (« audit.purge », « feedback.purge »). Une erreur est
/// écrite dans journalctl sans arrêter le service ; la purge suivante réessaie.
/// </summary>
@@ -22,12 +24,14 @@ public sealed class RetentionService : BackgroundService
private readonly LauncherApiOptions _options;
private readonly TimeProvider _time;
private readonly ILogger<RetentionService> _logger;
private readonly IServiceProvider _services;
public RetentionService(AuditLog audit, FeedbackStore feedback, IOptions<LauncherApiOptions> options, TimeProvider time,
ILogger<RetentionService> logger)
ILogger<RetentionService> logger, IServiceProvider services)
{
_audit = audit;
_feedback = feedback;
_services = services;
_options = options.Value;
_time = time;
_logger = logger;
@@ -76,5 +80,21 @@ public sealed class RetentionService : BackgroundService
{
_logger.LogError(ex, "Purge des demandes des joueurs impossible");
}
if (!_options.Players.Enabled) return;
try
{
var players = _services.GetRequiredService<Storage.Players.PlayerStore>();
var (deleted, anonymized) = players.PurgeEvents(_time.GetUtcNow(), _options.Players.EventsRetentionMonths,
_options.Players.EventsIpRetentionMonths);
if (deleted > 0 || anonymized > 0)
_audit.Write("players.events_purge", AuditResults.Ok,
details: $"journal des comptes joueurs : {deleted} ligne(s) supprimée(s), {anonymized} adresse(s) IP effacée(s) ; " +
$"conservation {_options.Players.EventsRetentionMonths} mois, IP {_options.Players.EventsIpRetentionMonths} mois");
}
catch (Exception ex)
{
_logger.LogError(ex, "Purge du journal des comptes joueurs impossible");
}
}
}
@@ -86,6 +86,8 @@ public static class ServerApp
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.EnrollmentsPerHour, TimeSpan.FromHours(1))));
limiter.AddPolicy(RateLimitPolicies.Feedback, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Feedback.RequestsPerMinute, TimeSpan.FromMinutes(1))));
limiter.AddPolicy(RateLimitPolicies.Account, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Players.AuthRequestsPerMinute, TimeSpan.FromMinutes(1))));
});
// Protection des données d'ASP.NET Core (cookies, jetons anti-falsification) : inutilisée par l'API,
@@ -123,6 +125,13 @@ public static class ServerApp
var admins = app.Services.GetRequiredService<AdminStore>().Read();
app.Logger.LogInformation("Launcher API : origine {Origin}, serveur {ServerId}, {Count} administrateur(s), données {Data}",
identity.Origin, identity.ServerId, admins.Admins.Count, options.DataDirectoryFullPath);
// Comptes joueurs : clé maîtresse et base ouvertes dès le démarrage (clé absente ou base illisible : pas de démarrage).
if (options.Players.Enabled)
{
var players = app.Services.GetRequiredService<Storage.Players.PlayerStore>();
app.Services.GetRequiredService<Storage.Players.PlayerPasswordHasher>();
app.Logger.LogInformation("Comptes joueurs activés : {Count} compte(s), base {Database}", players.CountAccounts(), players.DatabasePath);
}
if (!admins.UsableOwners().Any())
app.Logger.LogWarning("Aucun propriétaire actif : créez-le avec « GameLauncher.Server add-owner » (voir server/deploy/LISEZMOI.md).");
return app;
@@ -0,0 +1,614 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using GameLauncher.Server.Storage.Players;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Accounts;
/// <summary>
/// Comptes joueurs (phase 8a) : création de compte, connexion par adresse e-mail et mot de passe, connexion mémorisée
/// (jeton de renouvellement lié à une clé d'appareil), déconnexion, changement de mot de passe, appareils connectés.
///
/// Sécurité (détail : docs\PLAYER-ACCOUNTS-ARCHITECTURE.md) :
/// • routes anonymes protégées par un défi sans état + preuve de travail + preuve de possession de la clé d'appareil
/// (<see cref="PlayerGuard"/>), limites par adresse IP ;
/// • anti-force brute : blocage temporaire (durée doublée), puis SUSPENSION que seul un administrateur lève. La
/// suspension bloque les connexions depuis un NOUVEL appareil ; les appareils déjà connectés (« de confiance »,
/// prouvés par leur clé) continuent de fonctionner : un tiers qui connaît l'adresse peut déclencher la suspension,
/// mais pas priver le joueur de son compte. Une adresse inconnue se comporte exactement comme un compte réel ;
/// • réponses identiques pour une adresse inconnue et un mauvais mot de passe (même durée de calcul) ; l'état du compte
/// (désactivé, suspendu) n'est révélé qu'après un mot de passe correct ;
/// • adresses e-mail chiffrées, mots de passe en Argon2id + poivre, clé maîtresse hors du dossier des données ;
/// • journal des comptes (actions des joueurs) dans la base, sans adresse e-mail ; actions du système et des
/// administrateurs dans le journal d'audit, par référence de compte.
/// Totalement séparé des administrateurs : aucune route d'administration n'accepte un jeton joueur.
/// </summary>
public sealed class AccountsModule : IApiModule
{
public string Name => "Accounts";
/// <summary>Longueur maximale acceptée d'un mot de passe SAISI (bien au-delà des règles : borne le travail du serveur).</summary>
private const int MaxSubmittedPasswordLength = 1024;
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
{
services.AddSingleton<PlayerGuard>();
services.AddSingleton<PlayerSessionStore>();
services.AddSingleton(sp =>
{
var api = sp.GetRequiredService<IOptions<LauncherApiOptions>>().Value;
return PlayerSecrets.Load(api.PlayersMasterKeyFullPath
?? throw new InvalidOperationException("Comptes joueurs désactivés : aucune clé maîtresse."));
});
services.AddSingleton(sp => new PlayerPasswordHasher(sp.GetRequiredService<PlayerSecrets>(),
sp.GetRequiredService<IOptions<LauncherApiOptions>>().Value.Players));
services.AddSingleton<PlayerStore>();
services.AddPlayerAuthentication();
}
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapPost(ApiRoutes.AccountChallenge, Challenge).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
api.MapPost(ApiRoutes.AccountRegister, Register).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
api.MapPost(ApiRoutes.AccountLogin, Login).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
api.MapPost(ApiRoutes.AccountRefresh, Refresh).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
api.MapPost(ApiRoutes.AccountLogout, Logout).RequirePlayer();
api.MapGet(ApiRoutes.AccountMe, Me).RequirePlayer();
api.MapPost(ApiRoutes.AccountPassword, ChangePassword).RequirePlayerOrPasswordChange().RequireRateLimiting(RateLimitPolicies.Account);
api.MapDelete(ApiRoutes.AccountDevicePattern, DeleteDevice).RequirePlayer();
}
private static IResult Disabled() => ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Les comptes joueurs ne sont pas activés sur ce serveur.");
private static IResult LoginFailed() => ApiResults.Error(401, ApiErrorCodes.LoginFailed, "Adresse e-mail ou mot de passe incorrect.");
private static IResult TooManyAttempts(HttpContext context, DateTimeOffset until, DateTimeOffset now)
{
var wait = until - now;
var seconds = Math.Max(1, (int)Math.Ceiling(wait.TotalSeconds));
context.Response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
var minutes = Math.Max(1, (int)Math.Ceiling(wait.TotalMinutes));
return ApiResults.Error(429, ApiErrorCodes.LoginLocked,
$"Trop d'essais : la connexion est bloquée pendant encore {minutes} minute{(minutes > 1 ? "s" : "")}.");
}
private static IResult RateLimited(HttpContext context, TimeSpan retryAfter, string message)
{
context.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
return ApiResults.Error(429, ApiErrorCodes.RateLimited, message);
}
// ------------------------------------------------------------------ Défi
private static IResult Challenge(PlayerChallengeRequest request, IOptions<LauncherApiOptions> options, PlayerGuard guard,
ServerIdentity server)
{
if (!options.Value.Players.Enabled) return Disabled();
if (!PlayerChallengePurposes.IsValid(request.Purpose)) return ApiResults.BadRequest("Usage du défi inconnu.");
return Results.Json(guard.CreateChallenge(request.Purpose, server.ServerId, server.Origin), ApiJson.Options);
}
// ------------------------------------------------------------------ Création de compte
private static async Task<IResult> Register(PlayerRegisterRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerGuard guard, PlayerSessionStore sessions, ServerIdentity server, TimeProvider time)
{
var settings = options.Value.Players;
if (!settings.Enabled) return Disabled();
if (!settings.RegistrationEnabled)
return ApiResults.Error(403, ApiErrorCodes.RegistrationClosed, "La création de compte est momentanément fermée.");
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
var hasher = services.GetRequiredService<PlayerPasswordHasher>();
var ip = context.ClientIp();
// 1. Défi et preuve de travail, puis possession de la clé d'appareil, puis consommation du défi.
var device = CheckDevice(guard, server, PlayerChallengePurposes.Register, request.ChallengeId, request.Solution, request.Device);
if (device.Error is { } deviceError) return deviceError;
var proof = device.Proof!;
// 2. Contenu : conditions acceptées, adresse, mot de passe (mêmes règles que le launcher).
if (!string.Equals(request.AcceptedTermsVersion, settings.TermsVersion, StringComparison.Ordinal))
return ApiResults.Error(400, ApiErrorCodes.TermsNotAccepted,
"Acceptez les conditions d'utilisation et la politique de confidentialité en vigueur pour créer un compte.");
if (PlayerEmail.Normalize(request.Email) is not { } email)
return ApiResults.Error(400, ApiErrorCodes.EmailInvalid, "Adresse e-mail invalide.");
if (request.Password is not { Length: > 0 and <= MaxSubmittedPasswordLength })
return ApiResults.Error(400, ApiErrorCodes.PasswordRejected, "Mot de passe invalide.");
var errors = PasswordRules.Check(request.Password, email);
if (errors.Count > 0) return ApiResults.Error(400, ApiErrorCodes.PasswordRejected, string.Join(" ", errors));
// 3. Limites par adresse IP (en mémoire) puis plafond quotidien global.
var now = time.GetUtcNow();
if (!guard.TryCountRegistration(context.RateLimitPartition(), out var retryAfter))
return RateLimited(context, retryAfter, "Plusieurs comptes ont été créés depuis votre connexion récemment : réessayez plus tard.");
if (store.CountCreatedSince(now - TimeSpan.FromDays(1)) >= settings.MaxRegistrationsPerDay)
return ApiResults.Error(503, ApiErrorCodes.RegistrationClosed,
"Trop de comptes ont été créés aujourd'hui : réessayez demain.");
// 4. Adresse libre ? (la création révèle forcément qu'une adresse est prise : limites ci-dessus + preuve de travail).
var emailIndex = secrets.EmailIndex(email);
if (store.FindByEmailIndex(emailIndex) is not null)
{
store.AddEvent(null, PlayerEvents.Register, AuditResults.Denied, ip, proof.KeyId, "adresse déjà utilisée");
return ApiResults.Error(409, ApiErrorCodes.EmailTaken, "Cette adresse e-mail est déjà utilisée par un compte.");
}
// 5. Empreinte (calcul coûteux, hors verrou), puis création sous transaction (unicité revérifiée).
var hash = await hasher.HashAsync(request.Password, context.RequestAborted);
var accountId = PlayerIds.NewId();
var account = new PlayerAccountRecord
{
Id = accountId,
Reference = PlayerIds.NewReference(),
EmailIndex = emailIndex,
EmailEncrypted = secrets.EncryptEmail(accountId, email),
PasswordHash = hash,
PasswordChangedAt = now,
CreatedAt = now,
LastLoginAt = now,
TermsVersion = settings.TermsVersion
};
var (deviceRecord, refreshToken) = NewDevice(accountId, proof, settings, now);
if (!store.TryCreate(account, deviceRecord, PlayerIds.NewReference, out var created))
return ApiResults.Error(409, ApiErrorCodes.EmailTaken, "Cette adresse e-mail est déjà utilisée par un compte.");
store.AddEvent(created.Id, PlayerEvents.Register, AuditResults.Ok, ip, proof.KeyId,
$"conditions version {settings.TermsVersion} ; appareil « {proof.Label} »");
return Results.Json(new PlayerLoginResponse
{
Status = PlayerLoginStatus.Ok,
Session = OpenSession(created, deviceRecord, refreshToken, sessions, secrets)
}, ApiJson.Options);
}
// ------------------------------------------------------------------ Connexion
private static async Task<IResult> Login(PlayerLoginRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerGuard guard, PlayerSessionStore sessions, ServerIdentity server, AuditLog audit, TimeProvider time)
{
var settings = options.Value.Players;
if (!settings.Enabled) return Disabled();
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
var hasher = services.GetRequiredService<PlayerPasswordHasher>();
var ip = context.ClientIp();
var partition = context.RateLimitPartition();
var device = CheckDevice(guard, server, PlayerChallengePurposes.Login, request.ChallengeId, request.Solution, request.Device);
if (device.Error is { } deviceError) return deviceError;
var proof = device.Proof!;
if (guard.IsIpThrottled(partition, out var ipRetry))
return RateLimited(context, ipRetry, "Trop d'échecs de connexion depuis votre connexion Internet : réessayez plus tard.");
var now = time.GetUtcNow();
var password = request.Password is { Length: > 0 and <= MaxSubmittedPasswordLength } p ? p : null;
var email = PlayerEmail.Normalize(request.Email);
if (email is null || password is null)
{
if (password is not null) await hasher.DummyAsync(password, context.RequestAborted);
guard.CountFailedLogin(partition);
return LoginFailed();
}
var emailIndex = secrets.EmailIndex(email);
var account = store.FindByEmailIndex(emailIndex);
if (account is null)
{
// Adresse sans compte : même calcul, même blocage, même réponse qu'un mauvais mot de passe.
if (guard.UnknownLockedUntil(emailIndex) is { } unknownUntil) return TooManyAttempts(context, unknownUntil, now);
await hasher.DummyAsync(password, context.RequestAborted);
guard.CountFailedLogin(partition);
store.AddEvent(null, PlayerEvents.LoginFailed, AuditResults.Denied, ip, proof.KeyId, "adresse sans compte");
return guard.CountUnknownFailure(emailIndex) is { } lockedNow ? TooManyAttempts(context, lockedNow, now) : LoginFailed();
}
var trusted = store.FindDeviceByKey(account.Id, proof.KeyId) is not null;
if ((trusted ? account.TrustedLockedUntil : account.LockedUntil) is { } until && until > now)
{
store.AddEvent(account.Id, PlayerEvents.LoginFailed, AuditResults.Denied, ip, proof.KeyId, "connexion bloquée (trop d'essais)");
return TooManyAttempts(context, until, now);
}
var (valid, rehash) = await hasher.VerifyAsync(password, account.PasswordHash, context.RequestAborted);
if (!valid)
{
var outcome = store.RecordFailure(account.Id, trusted, now, settings);
guard.CountFailedLogin(partition);
store.AddEvent(account.Id, PlayerEvents.LoginFailed, AuditResults.Denied, ip, proof.KeyId,
trusted ? "mot de passe incorrect (appareil de confiance)" : "mot de passe incorrect (appareil inconnu)");
if (outcome.Suspended)
{
var count = settings.FailuresPerLock * settings.SuspendAfterLocks;
store.AddEvent(account.Id, PlayerEvents.Suspended, AuditResults.Ok, null, null,
$"{count} échecs de connexion consécutifs depuis des appareils inconnus");
audit.Write("players.suspend", AuditResults.Ok, actorName: "système", ip: ip, target: account.Reference,
details: $"compte joueur suspendu automatiquement (nouveaux appareils) : {count} échecs de connexion consécutifs " +
"depuis des appareils inconnus");
}
if (outcome.LockedUntil is { } lockedUntil)
{
store.AddEvent(account.Id, PlayerEvents.Locked, AuditResults.Ok, ip, proof.KeyId,
$"blocage n° {outcome.LockLevel} jusqu'au {lockedUntil.UtcDateTime:yyyy-MM-dd HH:mm} UTC" +
(trusted ? " (appareils de confiance)" : " (appareils inconnus)"));
return TooManyAttempts(context, lockedUntil, now);
}
return LoginFailed();
}
// Mot de passe correct : l'état du compte peut maintenant être révélé.
if (StateRefusal(store, account, now, trusted) is { } refusal)
{
store.AddEvent(account.Id, PlayerEvents.Login, AuditResults.Denied, ip, proof.KeyId, refusal.Reason);
return refusal.Result;
}
store.RecordSuccess(account.Id, now, rehash ? await hasher.HashAsync(password, context.RequestAborted) : null);
account = store.FindById(account.Id) ?? account;
if (account.MustChangePassword)
{
var (changeToken, change) = sessions.CreatePasswordChange(account.Id, proof.PublicKey, proof.KeyId, proof.Label);
store.AddEvent(account.Id, PlayerEvents.PasswordChangeForced, AuditResults.Ok, ip, proof.KeyId,
"connexion acceptée : nouveau mot de passe exigé avant d'ouvrir la session");
return Results.Json(new PlayerLoginResponse
{
Status = PlayerLoginStatus.PasswordChangeRequired,
PasswordChangeToken = changeToken,
PasswordChangeExpiresAt = change.ExpiresAt
}, ApiJson.Options);
}
var (deviceRecord, refreshToken) = NewDevice(account.Id, proof, settings, now);
var removed = store.SaveDevice(deviceRecord, settings.MaxDevicesPerAccount);
sessions.RemoveDevices(removed);
store.AddEvent(account.Id, PlayerEvents.Login, AuditResults.Ok, ip, proof.KeyId,
$"appareil « {proof.Label} »{(trusted ? "" : " (nouvel appareil)")}" +
(account.Status == PlayerStatus.Suspended ? " ; compte suspendu pour les nouveaux appareils" : ""));
return Results.Json(new PlayerLoginResponse
{
Status = PlayerLoginStatus.Ok,
Session = OpenSession(account, deviceRecord, refreshToken, sessions, secrets)
}, ApiJson.Options);
}
/// <summary>
/// Refus lié à l'état du compte, révélé seulement à qui connaît le mot de passe ou possède une session mémorisée :
/// désactivé (tout est refusé), suspendu (seuls les appareils de confiance passent). Une désactivation arrivée à
/// échéance est levée au passage.
/// </summary>
private static (IResult Result, string Reason)? StateRefusal(PlayerStore store, PlayerAccountRecord account, DateTimeOffset now,
bool trustedDevice)
{
switch (account.Status)
{
case PlayerStatus.Active:
return null;
case PlayerStatus.Suspended when trustedDevice:
return null;
case PlayerStatus.Disabled when account.DisabledUntil is { } end && end <= now:
store.SetStatus(account.Id, PlayerStatus.Active, null, null, "système (fin de la désactivation)", now);
store.AddEvent(account.Id, PlayerEvents.Enabled, AuditResults.Ok, null, null, "fin de la désactivation temporaire");
return null;
case PlayerStatus.Disabled:
var until = account.DisabledUntil is { } date
? $" jusqu'au {date.UtcDateTime.ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture)} (UTC)"
: "";
return (ApiResults.Error(403, ApiErrorCodes.AccountDisabled,
$"Ce compte est désactivé{until}. Contactez le support en indiquant la référence {account.Reference}."),
"compte désactivé");
default:
return (ApiResults.Error(403, ApiErrorCodes.AccountSuspended,
"Par sécurité, les connexions depuis un nouvel appareil sont suspendues sur ce compte après de nombreux " +
"essais de mot de passe. Connectez-vous depuis un appareil déjà utilisé, ou contactez le support en indiquant " +
$"la référence {account.Reference} : un administrateur pourra lever la suspension."),
"compte suspendu (nouvel appareil)");
}
}
// ------------------------------------------------------------------ Renouvellement (connexion mémorisée)
private static IResult Refresh(PlayerRefreshRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerGuard guard, PlayerSessionStore sessions, ServerIdentity server, TimeProvider time)
{
var settings = options.Value.Players;
if (!settings.Enabled) return Disabled();
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
var ip = context.ClientIp();
var revoked = ApiResults.Error(401, ApiErrorCodes.SessionRevoked, "Session expirée ou révoquée : reconnectez-vous.");
if (!PlayerTokens.TryParseRefresh(request.RefreshToken, out var deviceId, out var secret)) return revoked;
if (guard.Validate(request.ChallengeId, PlayerChallengePurposes.Refresh, null) is not { } nonce)
return ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Défi absent, expiré ou invalide : réessayez.");
if (store.FindDevice(deviceId) is not { } device) return revoked;
// Possession de la clé de l'appareil : un jeton copié sur un autre PC ne sert à rien.
var signature = AdminKeys.DecodeSignature(request.Signature);
byte[] spki;
try
{
spki = Convert.FromBase64String(device.PublicKey);
}
catch (FormatException)
{
return revoked;
}
if (signature is null || !AdminKeys.Verify(spki,
PlayerMessages.Device(PlayerChallengePurposes.Refresh, server.Origin, server.ServerId, request.ChallengeId, nonce, device.KeyId),
signature))
{
store.AddEvent(device.AccountId, PlayerEvents.Refresh, AuditResults.Denied, ip, device.KeyId, "signature de l'appareil invalide");
return ApiResults.Error(401, ApiErrorCodes.DeviceProofInvalid, "Cet appareil n'a pas pu prouver son identité : reconnectez-vous.");
}
if (!guard.TryConsume(request.ChallengeId))
return ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Défi déjà utilisé : réessayez.");
var now = time.GetUtcNow();
var hash = HashSecret(secret);
if (device.PreviousRefreshHash is { } previous && FixedEquals(hash, previous))
{
// Jeton déjà remplacé et présenté de nouveau : copie volée (ou réponse perdue). L'appareil est déconnecté.
store.DeleteDevice(device.Id);
sessions.RemoveDevices(new[] { device.Id });
store.AddEvent(device.AccountId, PlayerEvents.RefreshReuse, AuditResults.Denied, ip, device.KeyId,
"jeton de renouvellement déjà utilisé : appareil déconnecté par précaution");
return revoked;
}
if (!FixedEquals(hash, device.RefreshHash)) return revoked;
if (now >= device.RefreshExpiresAt || now >= device.RefreshAbsoluteExpiresAt)
{
store.DeleteDevice(device.Id);
store.AddEvent(device.AccountId, PlayerEvents.Refresh, AuditResults.Denied, ip, device.KeyId, "session mémorisée expirée");
return revoked;
}
var account = store.FindById(device.AccountId);
if (account is null) return revoked;
if (StateRefusal(store, account, now, trustedDevice: true) is { } refusal)
{
store.AddEvent(account.Id, PlayerEvents.Refresh, AuditResults.Denied, ip, device.KeyId, refusal.Reason);
return refusal.Result;
}
if (account.MustChangePassword)
return ApiResults.Error(403, ApiErrorCodes.PasswordChangeRequired,
"Un nouveau mot de passe est exigé : reconnectez-vous pour le choisir.");
var newSecret = PlayerTokens.NewSecret();
var idle = Min(now + TimeSpan.FromDays(settings.RefreshIdleDays), device.RefreshAbsoluteExpiresAt);
if (!store.RotateRefresh(device.Id, device.RefreshHash, HashSecret(newSecret), idle, now)) return revoked;
// Journal : un renouvellement par appareil et par jour au plus (le launcher en fait un à chaque lancement).
if (device.LastUsedAt.UtcDateTime.Date != now.UtcDateTime.Date)
store.AddEvent(account.Id, PlayerEvents.Refresh, AuditResults.Ok, ip, device.KeyId, $"appareil « {device.Label} »");
var rotated = device with { RefreshExpiresAt = idle, LastUsedAt = now };
return Results.Json(OpenSession(account, rotated, PlayerTokens.FormatRefresh(device.Id, newSecret), sessions, secrets), ApiJson.Options);
}
// ------------------------------------------------------------------ Session ouverte
private static IResult Logout(PlayerLogoutRequest? request, HttpContext context, IServiceProvider services, PlayerSessionStore sessions)
{
var player = context.Player();
var store = services.GetRequiredService<PlayerStore>();
var ip = context.ClientIp();
if (request?.AllDevices == true)
{
var removed = store.DeleteDevices(player.Account.Id);
sessions.RemoveWhere(s => s.AccountId == player.Account.Id);
store.AddEvent(player.Account.Id, PlayerEvents.LogoutAll, AuditResults.Ok, ip, player.Device?.KeyId,
$"{removed.Count} appareil(s) déconnecté(s)");
}
else if (player.Device is { } device)
{
store.DeleteDevice(device.Id);
sessions.RemoveDevices(new[] { device.Id });
store.AddEvent(player.Account.Id, PlayerEvents.Logout, AuditResults.Ok, ip, device.KeyId, $"appareil « {device.Label} »");
}
return Results.NoContent();
}
private static IResult Me(HttpContext context, IServiceProvider services)
{
var player = context.Player();
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
return Results.Json(new PlayerMeResponse
{
Account = Info(player.Account, secrets),
Devices = store.Devices(player.Account.Id).Select(d => new PlayerDeviceInfo
{
Id = d.Id,
Label = d.Label,
CreatedAt = d.CreatedAt,
LastUsedAt = d.LastUsedAt,
Current = d.Id == player.Device?.Id
}).ToList()
}, ApiJson.Options);
}
private static async Task<IResult> ChangePassword(PlayerChangePasswordRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerSessionStore sessions, TimeProvider time)
{
var settings = options.Value.Players;
var player = context.Player();
var store = services.GetRequiredService<PlayerStore>();
var secrets = services.GetRequiredService<PlayerSecrets>();
var hasher = services.GetRequiredService<PlayerPasswordHasher>();
var ip = context.ClientIp();
var account = player.Account;
var deviceKey = player.Device?.KeyId ?? player.Session.DeviceKeyId;
var now = time.GetUtcNow();
if (account.TrustedLockedUntil is { } until && until > now) return TooManyAttempts(context, until, now);
if (request.CurrentPassword is not { Length: > 0 and <= MaxSubmittedPasswordLength } ||
request.NewPassword is not { Length: > 0 and <= MaxSubmittedPasswordLength })
return ApiResults.BadRequest("Mot de passe actuel et nouveau mot de passe obligatoires.");
var (valid, _) = await hasher.VerifyAsync(request.CurrentPassword, account.PasswordHash, context.RequestAborted);
if (!valid)
{
// Session volée qui tente de deviner le mot de passe : blocage puis fermeture des sessions du compte.
var outcome = store.RecordFailure(account.Id, trusted: true, now, settings);
store.AddEvent(account.Id, PlayerEvents.PasswordChange, AuditResults.Denied, ip, deviceKey, "mot de passe actuel incorrect");
if (outcome.LockedUntil is { } locked)
{
sessions.RemoveWhere(s => s.AccountId == account.Id);
return TooManyAttempts(context, locked, now);
}
return ApiResults.Error(400, ApiErrorCodes.LoginFailed, "Mot de passe actuel incorrect.");
}
var email = secrets.DecryptEmail(account.Id, account.EmailEncrypted);
var errors = PasswordRules.Check(request.NewPassword, email).ToList();
if (errors.Count == 0 &&
string.Equals(request.NewPassword, request.CurrentPassword, StringComparison.Ordinal))
errors.Add("Le nouveau mot de passe doit être différent de l'actuel.");
if (errors.Count > 0) return ApiResults.Error(400, ApiErrorCodes.PasswordRejected, string.Join(" ", errors));
var hash = await hasher.HashAsync(request.NewPassword, context.RequestAborted);
store.SetPassword(account.Id, hash, now);
store.RecordSuccess(account.Id, now, null);
if (player.Session.Scope == PlayerScopes.PasswordChange)
{
// Changement EXIGÉ : tous les appareils sont déconnectés, puis la session s'ouvre sur cet appareil.
var removed = store.DeleteDevices(account.Id);
sessions.RemoveWhere(s => s.AccountId == account.Id);
var proof = new DeviceProofResult(player.Session.DevicePublicKey!, player.Session.DeviceKeyId!, player.Session.DeviceLabel!);
var (deviceRecord, refreshToken) = NewDevice(account.Id, proof, settings, now);
store.SaveDevice(deviceRecord, settings.MaxDevicesPerAccount);
store.AddEvent(account.Id, PlayerEvents.PasswordChange, AuditResults.Ok, ip, proof.KeyId,
$"nouveau mot de passe (exigé) ; {removed.Count} autre(s) appareil(s) déconnecté(s)");
var refreshed = store.FindById(account.Id) ?? account;
return Results.Json(new PlayerChangePasswordResponse
{
Session = OpenSession(refreshed, deviceRecord, refreshToken, sessions, secrets),
DevicesSignedOut = removed.Count
}, ApiJson.Options);
}
// Changement ordinaire : les AUTRES appareils sont déconnectés (celui-ci reste connecté).
var others = store.DeleteDevices(account.Id, exceptDeviceId: player.Device!.Id);
sessions.RemoveWhere(s => s.AccountId == account.Id && s.DeviceId != player.Device.Id);
store.AddEvent(account.Id, PlayerEvents.PasswordChange, AuditResults.Ok, ip, deviceKey,
$"nouveau mot de passe ; {others.Count} autre(s) appareil(s) déconnecté(s)");
return Results.Json(new PlayerChangePasswordResponse { DevicesSignedOut = others.Count }, ApiJson.Options);
}
private static IResult DeleteDevice(string deviceId, HttpContext context, IServiceProvider services, PlayerSessionStore sessions)
{
var player = context.Player();
var store = services.GetRequiredService<PlayerStore>();
if (!PlayerIds.IsValidId(deviceId) || store.FindDevice(deviceId) is not { } device || device.AccountId != player.Account.Id)
return ApiResults.NotFound("Appareil introuvable.");
store.DeleteDevice(device.Id);
sessions.RemoveDevices(new[] { device.Id });
store.AddEvent(player.Account.Id, PlayerEvents.DeviceRemoved, AuditResults.Ok, context.ClientIp(), player.Device?.KeyId,
$"appareil « {device.Label} » déconnecté{(device.Id == player.Device?.Id ? " (celui-ci)" : "")}");
return Results.NoContent();
}
// ------------------------------------------------------------------ Outils
/// <summary>Clé d'appareil vérifiée : clé publique (Base64 canonique), identifiant, nom générique.</summary>
private sealed record DeviceProofResult(string PublicKey, string KeyId, string Label);
/// <summary>
/// Défi (sceau, usage, échéance, preuve de travail) puis signature de la clé d'appareil, puis consommation du défi
/// (un défi ne sert qu'une fois, et seulement avec une signature valide).
/// </summary>
private static (DeviceProofResult? Proof, IResult? Error) CheckDevice(PlayerGuard guard, ServerIdentity server, string purpose,
string? challengeId, string? solution, PlayerDeviceProof? device)
{
if (guard.Validate(challengeId, purpose, solution) is not { } nonce)
return (null, ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Vérification anti-robot échouée ou expirée : réessayez."));
var deviceError = ApiResults.Error(400, ApiErrorCodes.DeviceProofInvalid, "Clé de l'appareil invalide.");
if (device is null) return (null, deviceError);
byte[] spki;
try
{
spki = AdminKeys.ParsePublicKey(device.PublicKey);
}
catch (CryptographicException)
{
return (null, deviceError);
}
var keyId = AdminKeys.ComputeKeyId(spki);
var signature = AdminKeys.DecodeSignature(device.Signature);
if (signature is null ||
!AdminKeys.Verify(spki, PlayerMessages.Device(purpose, server.Origin, server.ServerId, challengeId!, nonce, keyId), signature))
return (null, deviceError);
if (!guard.TryConsume(challengeId!))
return (null, ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Vérification déjà utilisée : réessayez."));
var label = ApiText.CleanLine(device.Label, ApiText.MaxKeyLabelLength) ?? "Appareil";
return (new DeviceProofResult(Convert.ToBase64String(spki), keyId, label), null);
}
private static (PlayerDeviceRecord Device, string RefreshToken) NewDevice(string accountId, DeviceProofResult proof,
LauncherApiOptions.PlayersSettings settings, DateTimeOffset now)
{
var deviceId = PlayerIds.NewId();
var secret = PlayerTokens.NewSecret();
var absolute = now + TimeSpan.FromDays(settings.RefreshMaxDays);
var device = new PlayerDeviceRecord
{
Id = deviceId,
AccountId = accountId,
KeyId = proof.KeyId,
PublicKey = proof.PublicKey,
Label = proof.Label,
CreatedAt = now,
LastUsedAt = now,
RefreshHash = HashSecret(secret),
RefreshExpiresAt = Min(now + TimeSpan.FromDays(settings.RefreshIdleDays), absolute),
RefreshAbsoluteExpiresAt = absolute
};
return (device, PlayerTokens.FormatRefresh(deviceId, secret));
}
private static PlayerSession OpenSession(PlayerAccountRecord account, PlayerDeviceRecord device, string refreshToken,
PlayerSessionStore sessions, PlayerSecrets secrets)
{
var (accessToken, access) = sessions.CreateAccess(account.Id, device.Id);
return new PlayerSession
{
AccessToken = accessToken,
AccessExpiresAt = access.ExpiresAt,
RefreshToken = refreshToken,
RefreshExpiresAt = device.RefreshExpiresAt,
DeviceId = device.Id,
Account = Info(account, secrets)
};
}
private static PlayerAccountInfo Info(PlayerAccountRecord account, PlayerSecrets secrets) => new()
{
Reference = account.Reference,
EmailMasked = PlayerEmail.Mask(secrets.DecryptEmail(account.Id, account.EmailEncrypted)),
EmailVerified = account.EmailVerified,
CreatedAt = account.CreatedAt,
LastLoginAt = account.LastLoginAt,
PasswordChangedAt = account.PasswordChangedAt,
OtpEnabled = account.OtpEnabled,
NewDevicesSuspended = account.Status == PlayerStatus.Suspended
};
/// <summary>Empreinte d'un secret de jeton de renouvellement (seule valeur conservée).</summary>
private static string HashSecret(string secret) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("GameLauncher/Player/Refresh/v1\n" + secret))).ToLowerInvariant();
private static bool FixedEquals(string a, string b) =>
CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(a), Encoding.ASCII.GetBytes(b));
private static DateTimeOffset Min(DateTimeOffset a, DateTimeOffset b) => a <= b ? a : b;
}
@@ -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);
}
@@ -1,9 +1,11 @@
using System.Reflection;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.System;
@@ -21,13 +23,23 @@ public sealed class SystemModule : IApiModule
var plus = version.IndexOf('+');
if (plus > 0) version = version[..plus]; // sans l'empreinte du commit
api.MapGet(ApiRoutes.Info, (ServerIdentity server) => Results.Json(new ServerInfo
api.MapGet(ApiRoutes.Info, (ServerIdentity server, IOptions<LauncherApiOptions> options) => Results.Json(new ServerInfo
{
ServerId = server.ServerId,
Origin = server.Origin,
ApiVersion = ApiRoutes.Version,
ServerVersion = version,
KeyAlgorithms = new[] { AdminKeys.Algorithm }
KeyAlgorithms = new[] { AdminKeys.Algorithm },
// Comptes joueurs : annoncés seulement s'ils sont activés (le launcher n'affiche le bouton « Compte » qu'alors).
Accounts = options.Value.Players.Enabled
? new PlayerAccountsInfo
{
Enabled = true,
RegistrationOpen = options.Value.Players.RegistrationEnabled,
TermsVersion = options.Value.Players.TermsVersion,
PasswordRulesVersion = PasswordRules.Version
}
: null
}, ApiJson.Options));
api.MapGet(ApiRoutes.Health, () => Results.Json(new HealthStatus(), ApiJson.Options));
@@ -63,7 +63,13 @@ public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSc
if (!header.StartsWith("Bearer ", StringComparison.Ordinal))
return Task.FromResult(AuthenticateResult.Fail("Schéma d'authentification inattendu."));
var session = _sessions.Find(header["Bearer ".Length..].Trim());
var token = header["Bearer ".Length..].Trim();
// Jeton de JOUEUR (« glp… ») : jamais cherché parmi les sessions d'administrateurs. Un compte joueur ne peut ouvrir
// aucune route d'administration (voir PlayerSessionHandler).
if (token.StartsWith("glp", StringComparison.Ordinal))
return Task.FromResult(AuthenticateResult.NoResult());
var session = _sessions.Find(token);
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session absente ou expirée."));
var database = _store.Read();
@@ -115,12 +121,16 @@ public static class AdminAuthorization
{
services.AddAuthentication(AdminSessionHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, AdminSessionHandler>(AdminSessionHandler.SchemeName, null);
// Schéma désigné explicitement dans chaque règle : une route d'administration n'accepte QUE la session d'un
// administrateur, jamais celle d'un joueur (schéma « PlayerSession »), même si le schéma par défaut changeait.
services.AddAuthorization(options =>
{
options.AddPolicy(AdminSessionHandler.SchemeName, p => p.RequireAuthenticatedUser());
options.AddPolicy(AdminSessionHandler.SchemeName,
p => p.AddAuthenticationSchemes(AdminSessionHandler.SchemeName).RequireAuthenticatedUser());
foreach (var permission in AdminPermissions.All)
options.AddPolicy(PolicyName(permission),
p => p.RequireAuthenticatedUser().RequireClaim(AdminSessionHandler.PermissionClaim, permission));
p => p.AddAuthenticationSchemes(AdminSessionHandler.SchemeName).RequireAuthenticatedUser()
.RequireClaim(AdminSessionHandler.PermissionClaim, permission));
});
return services;
}
@@ -0,0 +1,128 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage.Players;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Security;
/// <summary>Joueur authentifié pour la requête en cours.</summary>
public sealed record AuthenticatedPlayer(PlayerAccessSession Session, PlayerAccountRecord Account, PlayerDeviceRecord? Device);
/// <summary>
/// Schéma « PlayerSession » : jeton joueur (« Authorization: Bearer glpa_… » ou « glpc_… »). TOTALEMENT distinct du
/// schéma des administrateurs (<see cref="AdminSessionHandler"/>) : autre stockage, autres préfixes, et les routes
/// d'administration n'utilisent jamais ce schéma (leurs règles désignent explicitement « AdminSession »). Un compte joueur
/// ne peut donc ouvrir aucune route d'administration, quels que soient ses droits ou son état.
///
/// À chaque requête : jeton valide ET compte actif (ou suspendu : la suspension ne vise que les nouveaux appareils) ET
/// (session complète) appareil toujours enregistré et aucun changement de mot de passe exigé. Une désactivation ou une
/// déconnexion prend effet immédiatement.
/// </summary>
public sealed class PlayerSessionHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "PlayerSession";
public const string ScopeClaim = "player_scope";
private const string ContextKey = "GameLauncher.Player";
private readonly PlayerSessionStore _sessions;
private readonly IServiceProvider _services;
private readonly LauncherApiOptions _options;
public PlayerSessionHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder,
PlayerSessionStore sessions, IServiceProvider services, IOptions<LauncherApiOptions> apiOptions)
: base(options, logger, encoder)
{
_sessions = sessions;
_services = services;
_options = apiOptions.Value;
}
public static AuthenticatedPlayer? Get(HttpContext context) =>
context.Items.TryGetValue(ContextKey, out var value) ? value as AuthenticatedPlayer : null;
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!_options.Players.Enabled) return Task.FromResult(AuthenticateResult.NoResult());
var header = Request.Headers.Authorization.ToString();
if (!header.StartsWith("Bearer ", StringComparison.Ordinal)) return Task.FromResult(AuthenticateResult.NoResult());
var token = header["Bearer ".Length..].Trim();
if (!token.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) &&
!token.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal))
return Task.FromResult(AuthenticateResult.NoResult()); // pas un jeton joueur
var session = _sessions.Find(token);
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session joueur absente ou expirée."));
var store = _services.GetRequiredService<PlayerStore>();
var account = store.FindById(session.AccountId);
// Suspendu : les appareils déjà connectés gardent l'accès (seuls les nouveaux appareils sont bloqués).
if (account is null || account.Status is not (PlayerStatus.Active or PlayerStatus.Suspended))
{
_sessions.RemoveWhere(s => s.AccountId == session.AccountId);
return Task.FromResult(AuthenticateResult.Fail("Compte supprimé, désactivé ou suspendu."));
}
PlayerDeviceRecord? device = null;
if (session.Scope == PlayerScopes.Full)
{
device = session.DeviceId is { } deviceId ? store.FindDevice(deviceId) : null;
if (device is null || device.AccountId != account.Id || account.MustChangePassword)
{
_sessions.Remove(session);
return Task.FromResult(AuthenticateResult.Fail("Appareil déconnecté ou nouveau mot de passe exigé."));
}
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, account.Id),
new(ScopeClaim, session.Scope)
};
Context.Items[ContextKey] = new AuthenticatedPlayer(session, account, device);
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName)));
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties) =>
ApiResults.WriteAsync(Context, 401, ApiErrorCodes.Unauthorized, "Connexion au compte requise (session absente ou expirée).");
protected override Task HandleForbiddenAsync(AuthenticationProperties properties) =>
ApiResults.WriteAsync(Context, 403, ApiErrorCodes.PasswordChangeRequired,
"Choisissez d'abord un nouveau mot de passe.");
}
public static class PlayerAuthorization
{
public const string PlayerPolicy = "player";
public const string PasswordChangePolicy = "player-password-change";
/// <summary>Route réservée à un joueur connecté (session complète).</summary>
public static TBuilder RequirePlayer<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
builder.RequireAuthorization(PlayerPolicy);
/// <summary>Route ouverte à une session complète OU au jeton limité « changement de mot de passe exigé ».</summary>
public static TBuilder RequirePlayerOrPasswordChange<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
builder.RequireAuthorization(PasswordChangePolicy);
public static IServiceCollection AddPlayerAuthentication(this IServiceCollection services)
{
services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, PlayerSessionHandler>(PlayerSessionHandler.SchemeName, null);
services.AddAuthorization(options =>
{
options.AddPolicy(PlayerPolicy, p => p.AddAuthenticationSchemes(PlayerSessionHandler.SchemeName).RequireAuthenticatedUser()
.RequireClaim(PlayerSessionHandler.ScopeClaim, PlayerScopes.Full));
options.AddPolicy(PasswordChangePolicy, p => p.AddAuthenticationSchemes(PlayerSessionHandler.SchemeName).RequireAuthenticatedUser()
.RequireClaim(PlayerSessionHandler.ScopeClaim, PlayerScopes.Full, PlayerScopes.PasswordChange));
});
return services;
}
/// <summary>Joueur authentifié de la requête (les routes protégées en ont toujours un).</summary>
public static AuthenticatedPlayer Player(this HttpContext context) =>
PlayerSessionHandler.Get(context) ?? throw new InvalidOperationException("Route non protégée par RequirePlayer.");
}
@@ -0,0 +1,242 @@
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Security;
/// <summary>
/// Protections des routes ANONYMES des comptes joueurs (création, connexion, renouvellement) :
/// • défis SANS ÉTAT (usage, échéance, difficulté et nonce scellés par HMAC, secret en mémoire, domaine propre aux
/// joueurs) : en demander ne coûte rien au serveur ; seuls les défis utilisés sont mémorisés (refus du rejeu) ;
/// • preuve de travail (création de compte, connexion) : coûteuse pour un robot, négligeable pour un joueur ;
/// • compteurs en mémoire par adresse IP (IPv6 : /64) : créations de compte, échecs de connexion ;
/// • blocage « miroir » des adresses inconnues : une adresse sans compte se bloque exactement comme un compte réel
/// (la réponse « trop d'essais » ne révèle donc pas qu'un compte existe).
/// </summary>
public sealed class PlayerGuard
{
private const int NonceLength = 16;
private const int MacLength = 16;
private const int PayloadLength = 1 + 8 + 1 + NonceLength; // usage + échéance + difficulté + nonce
private const int MaxTracked = 100_000;
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Player/Challenge/v1\n");
private readonly byte[] _secret = RandomNumberGenerator.GetBytes(32);
private readonly ConcurrentDictionary<string, DateTimeOffset> _used = new(StringComparer.Ordinal);
private readonly SlidingCounters _registrations = new();
private readonly SlidingCounters _failedLogins = new();
private readonly Dictionary<string, (int Failed, int Level, DateTimeOffset? Until)> _unknown = new(StringComparer.Ordinal);
private readonly LauncherApiOptions.PlayersSettings _settings;
private readonly TimeProvider _time;
public PlayerGuard(IOptions<LauncherApiOptions> options, TimeProvider time)
{
_settings = options.Value.Players;
_time = time;
}
// ------------------------------------------------------------------ Défis
public PlayerChallengeResponse CreateChallenge(string purpose, string serverId, string origin)
{
var code = PurposeCode(purpose);
var difficulty = purpose switch
{
PlayerChallengePurposes.Register => _settings.RegisterProofDifficulty,
PlayerChallengePurposes.Login => _settings.LoginProofDifficulty,
_ => 0
};
var expires = _time.GetUtcNow() + TimeSpan.FromSeconds(_settings.ChallengeLifetimeSeconds);
var payload = new byte[PayloadLength];
payload[0] = code;
BinaryPrimitives.WriteInt64BigEndian(payload.AsSpan(1), expires.ToUnixTimeSeconds());
payload[9] = (byte)difficulty;
RandomNumberGenerator.Fill(payload.AsSpan(10));
var id = new byte[PayloadLength + MacLength];
payload.CopyTo(id, 0);
Mac(payload).CopyTo(id, PayloadLength);
return new PlayerChallengeResponse
{
ChallengeId = Base64Url.Encode(id),
Nonce = Base64Url.Encode(payload.AsSpan(10)),
Purpose = purpose,
Difficulty = difficulty,
ServerId = serverId,
Origin = origin,
ExpiresAt = DateTimeOffset.FromUnixTimeSeconds(expires.ToUnixTimeSeconds())
};
}
/// <summary>
/// Vérifie le défi (sceau, usage, échéance) et la preuve de travail, SANS le consommer. Renvoie le nonce (à vérifier
/// dans la signature de l'appareil), ou null.
/// </summary>
public string? Validate(string? challengeId, string purpose, string? solution)
{
if (challengeId is not { Length: > 0 and <= 64 } || !Base64Url.TryDecode(challengeId, out var id) || id.Length != PayloadLength + MacLength)
return null;
var payload = id.AsSpan(0, PayloadLength);
if (!CryptographicOperations.FixedTimeEquals(Mac(payload), id.AsSpan(PayloadLength))) return null;
if (payload[0] != PurposeCode(purpose)) return null;
var now = _time.GetUtcNow();
var expires = DateTimeOffset.FromUnixTimeSeconds(BinaryPrimitives.ReadInt64BigEndian(payload[1..9]));
if (expires <= now) return null;
int difficulty = payload[9];
if (purpose != PlayerChallengePurposes.Refresh)
{
// Difficulté scellée à la création ; jamais en dessous du minimum pour la création de compte et la connexion.
if (difficulty < FeedbackProof.MinDifficulty || !FeedbackProof.Verify(payload[10..], difficulty, solution)) return null;
}
return Base64Url.Encode(payload[10..]);
}
/// <summary>Consomme le défi (après vérification de la signature de l'appareil). Faux s'il a déjà servi (rejeu).</summary>
public bool TryConsume(string challengeId)
{
var now = _time.GetUtcNow();
if (_used.Count >= MaxTracked)
{
foreach (var (key, until) in _used)
if (until <= now) _used.TryRemove(key, out _);
if (_used.Count >= MaxTracked) throw new StoreBusyException("Trop de défis récents.");
}
return _used.TryAdd(challengeId, now + TimeSpan.FromSeconds(_settings.ChallengeLifetimeSeconds + 5));
}
private static byte PurposeCode(string purpose) => purpose switch
{
PlayerChallengePurposes.Register => 1,
PlayerChallengePurposes.Login => 2,
PlayerChallengePurposes.Refresh => 3,
_ => throw new ArgumentException("Usage de défi inconnu.", nameof(purpose))
};
private byte[] Mac(ReadOnlySpan<byte> payload)
{
var data = new byte[Domain.Length + payload.Length];
Domain.CopyTo(data, 0);
payload.CopyTo(data.AsSpan(Domain.Length));
return HMACSHA256.HashData(_secret, data)[..MacLength];
}
// ------------------------------------------------------------------ Créations de compte par adresse IP
/// <summary>Compte une création de compte pour cette adresse. Faux (rien n'est compté) si la limite horaire ou
/// quotidienne est atteinte ; <paramref name="retryAfter"/> : délai avant la prochaine place.</summary>
public bool TryCountRegistration(string partition, out TimeSpan retryAfter) =>
_registrations.TryAdd(partition, _time.GetUtcNow(),
(TimeSpan.FromHours(1), _settings.RegistrationsPerIpPerHour), (TimeSpan.FromDays(1), _settings.RegistrationsPerIpPerDay),
out retryAfter);
// ------------------------------------------------------------------ Échecs de connexion par adresse IP
/// <summary>Vrai si cette adresse a trop échoué récemment (toutes adresses e-mail confondues).</summary>
public bool IsIpThrottled(string partition, out TimeSpan retryAfter) =>
_failedLogins.IsFull(partition, _time.GetUtcNow(), TimeSpan.FromMinutes(15), _settings.FailedLoginsPerIpPer15Minutes, out retryAfter);
public void CountFailedLogin(string partition) =>
_failedLogins.Add(partition, _time.GetUtcNow(), TimeSpan.FromMinutes(15));
// ------------------------------------------------------------------ Adresses inconnues (blocage miroir)
/// <summary>Fin du blocage d'une adresse SANS compte (même règle que pour un compte réel), ou null.</summary>
public DateTimeOffset? UnknownLockedUntil(string emailIndex)
{
lock (_unknown)
return _unknown.TryGetValue(emailIndex, out var state) && state.Until > _time.GetUtcNow() ? state.Until : null;
}
/// <summary>Échec sur une adresse sans compte : compté comme pour un compte (sans suspension, qui n'est jamais
/// révélée avant un mot de passe correct). Renvoie la fin du blocage déclenché, ou null.</summary>
public DateTimeOffset? CountUnknownFailure(string emailIndex)
{
var now = _time.GetUtcNow();
lock (_unknown)
{
if (_unknown.Count >= MaxTracked)
{
foreach (var key in _unknown.Where(p => p.Value.Until is null || p.Value.Until <= now).Select(p => p.Key).ToList())
_unknown.Remove(key);
if (_unknown.Count >= MaxTracked) _unknown.Clear();
}
_unknown.TryGetValue(emailIndex, out var state);
var failed = state.Failed + 1;
var level = state.Level;
DateTimeOffset? until = null;
if (failed >= _settings.FailuresPerLock)
{
failed = 0;
level++;
until = now + Storage.Players.PlayerStore.LockDuration(_settings, level);
}
_unknown[emailIndex] = (failed, level, until ?? state.Until);
return until;
}
}
/// <summary>Compteurs en mémoire sur des fenêtres glissantes (aucune adresse n'est écrite sur le disque).</summary>
private sealed class SlidingCounters
{
private readonly Dictionary<string, List<DateTimeOffset>> _events = new(StringComparer.Ordinal);
public bool TryAdd(string key, DateTimeOffset now, (TimeSpan Window, int Max) first, (TimeSpan Window, int Max) second,
out TimeSpan retryAfter)
{
lock (_events)
{
var times = Get(key, now, second.Window);
retryAfter = TimeSpan.Zero;
foreach (var (window, max) in new[] { first, second })
{
var recent = times.Where(t => now - t < window).OrderBy(t => t).ToList();
if (recent.Count >= max)
{
retryAfter = recent[recent.Count - max] + window - now;
return false;
}
}
times.Add(now);
return true;
}
}
public bool IsFull(string key, DateTimeOffset now, TimeSpan window, int max, out TimeSpan retryAfter)
{
lock (_events)
{
retryAfter = TimeSpan.Zero;
if (!_events.TryGetValue(key, out var times)) return false;
var recent = times.Where(t => now - t < window).OrderBy(t => t).ToList();
if (recent.Count < max) return false;
retryAfter = recent[recent.Count - max] + window - now;
return true;
}
}
public void Add(string key, DateTimeOffset now, TimeSpan window)
{
lock (_events) Get(key, now, window).Add(now);
}
private List<DateTimeOffset> Get(string key, DateTimeOffset now, TimeSpan keep)
{
if (_events.Count >= MaxTracked)
{
foreach (var stale in _events.Where(p => p.Value.All(t => now - t >= keep)).Select(p => p.Key).ToList())
_events.Remove(stale);
if (_events.Count >= MaxTracked) throw new StoreBusyException("Trop d'adresses suivies.");
}
if (!_events.TryGetValue(key, out var times)) _events[key] = times = new List<DateTimeOffset>();
times.RemoveAll(t => now - t >= keep);
return times;
}
}
}
@@ -0,0 +1,145 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Security;
/// <summary>Portée d'un jeton joueur.</summary>
public static class PlayerScopes
{
/// <summary>Session complète (jeton « glpa_ »).</summary>
public const string Full = "full";
/// <summary>Seulement le changement de mot de passe exigé (jeton « glpc_ »).</summary>
public const string PasswordChange = "password_change";
}
/// <summary>Jeton joueur valide (en mémoire uniquement : seule l'empreinte du jeton est gardée).</summary>
public sealed class PlayerAccessSession
{
public required string TokenHash { get; init; }
public required string Scope { get; init; }
public required string AccountId { get; init; }
/// <summary>Appareil de la session (portée complète).</summary>
public string? DeviceId { get; init; }
/// <summary>Clé publique et nom de l'appareil (portée « changement exigé » : l'appareil est enregistré une fois le
/// mot de passe changé).</summary>
public string? DevicePublicKey { get; init; }
public string? DeviceKeyId { get; init; }
public string? DeviceLabel { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>
/// Jetons d'accès des joueurs, en mémoire (un redémarrage du service les efface : le launcher renouvelle sa session avec
/// son jeton de renouvellement, sans rien demander au joueur). Totalement séparés des sessions des administrateurs
/// (<see cref="SessionStore"/>) : autre stockage, autre préfixe, autre schéma d'authentification.
/// </summary>
public sealed class PlayerSessionStore
{
private const int MaxSessions = 200_000;
private static readonly TimeSpan PasswordChangeLifetime = TimeSpan.FromMinutes(10);
private readonly ConcurrentDictionary<string, PlayerAccessSession> _byHash = new(StringComparer.Ordinal);
private readonly TimeProvider _time;
private readonly LauncherApiOptions.PlayersSettings _settings;
public PlayerSessionStore(IOptions<LauncherApiOptions> options, TimeProvider time)
{
_time = time;
_settings = options.Value.Players;
}
public int Count => _byHash.Count;
/// <summary>Jeton d'accès d'une session complète sur un appareil.</summary>
public (string Token, PlayerAccessSession Session) CreateAccess(string accountId, string deviceId)
{
var token = PlayerTokens.AccessPrefix + PlayerTokens.NewSecret();
var session = new PlayerAccessSession
{
TokenHash = Hash(token),
Scope = PlayerScopes.Full,
AccountId = accountId,
DeviceId = deviceId,
ExpiresAt = _time.GetUtcNow() + TimeSpan.FromMinutes(_settings.AccessTokenMinutes)
};
Add(session);
return (token, session);
}
/// <summary>Jeton limité au changement de mot de passe exigé (10 min), qui retient l'appareil à enregistrer ensuite.</summary>
public (string Token, PlayerAccessSession Session) CreatePasswordChange(string accountId, string devicePublicKey, string deviceKeyId,
string deviceLabel)
{
var token = PlayerTokens.PasswordChangePrefix + PlayerTokens.NewSecret();
var session = new PlayerAccessSession
{
TokenHash = Hash(token),
Scope = PlayerScopes.PasswordChange,
AccountId = accountId,
DevicePublicKey = devicePublicKey,
DeviceKeyId = deviceKeyId,
DeviceLabel = deviceLabel,
ExpiresAt = _time.GetUtcNow() + PasswordChangeLifetime
};
Add(session);
return (token, session);
}
/// <summary>Session du jeton, si elle est encore valide.</summary>
public PlayerAccessSession? Find(string? token)
{
if (string.IsNullOrEmpty(token) || token.Length > PlayerTokens.MaxLength) return null;
if (!token.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) &&
!token.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal))
return null;
var hash = Hash(token);
if (!_byHash.TryGetValue(hash, out var session)) return null;
if (session.ExpiresAt <= _time.GetUtcNow())
{
_byHash.TryRemove(hash, out _);
return null;
}
// Le préfixe doit correspondre à la portée (un jeton ne change jamais de portée).
var expectedPrefix = session.Scope == PlayerScopes.Full ? PlayerTokens.AccessPrefix : PlayerTokens.PasswordChangePrefix;
return token.StartsWith(expectedPrefix, StringComparison.Ordinal) ? session : null;
}
public void Remove(PlayerAccessSession session) => _byHash.TryRemove(session.TokenHash, out _);
/// <summary>Ferme toutes les sessions d'un compte (ou d'appareils donnés).</summary>
public int RemoveWhere(Func<PlayerAccessSession, bool> predicate)
{
var removed = 0;
foreach (var (hash, session) in _byHash)
if (predicate(session) && _byHash.TryRemove(hash, out _)) removed++;
return removed;
}
public int RemoveDevices(IEnumerable<string> deviceIds)
{
var set = deviceIds.ToHashSet(StringComparer.Ordinal);
return set.Count == 0 ? 0 : RemoveWhere(s => s.DeviceId is { } id && set.Contains(id));
}
private void Add(PlayerAccessSession session)
{
if (_byHash.Count >= MaxSessions)
{
var now = _time.GetUtcNow();
RemoveWhere(s => s.ExpiresAt <= now);
if (_byHash.Count >= MaxSessions) throw new StoreBusyException("Trop de sessions de joueurs ouvertes.");
}
_byHash[session.TokenHash] = session;
}
internal static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
}
@@ -0,0 +1,226 @@
using System.Text.Json;
using GameLauncher.Contracts.Feedback;
namespace GameLauncher.Server.Storage;
/// <summary>Changement d'un type de demande lors d'une publication des formulaires, pour le journal d'audit.</summary>
/// <param name="Action">Action d'audit (<see cref="FeedbackFormsAudit.Created"/>, <see cref="FeedbackFormsAudit.Updated"/>…).</param>
/// <param name="Name">Nom du type tel qu'affiché aux joueurs (après publication, ou avant suppression).</param>
/// <param name="Details">Détail de chaque changement, en clair.</param>
public sealed record FeedbackFormChange(string Action, string Name, IReadOnlyList<string> Details);
/// <summary>
/// Détail d'une publication des formulaires pour le journal d'audit : une ligne « feedback.forms » (révision, résumé,
/// signature), puis une ligne par type de demande ajouté, modifié ou supprimé, avec son nom et TOUS ses changements
/// (présentation, réglages, chaque champ ajouté, supprimé ou modifié, et ce qui a changé dans ce champ).
/// Un champ d'audit étant borné (<see cref="AuditLog"/>), un détail trop long est réparti sur plusieurs lignes
/// numérotées (1/2, 2/2…) plutôt que tronqué.
/// </summary>
public static class FeedbackFormsAudit
{
public const string Published = "feedback.forms";
public const string Created = "feedback.form_create";
public const string Updated = "feedback.form_update";
public const string Deleted = "feedback.form_delete";
/// <summary>Longueur au plus d'un détail d'audit (le journal tronque à 500 caractères).</summary>
internal const int MaxDetailsLength = 480;
/// <summary>Valeur montrée en entier dans un changement (« « avant » → « après » ») jusqu'à cette longueur.</summary>
private const int MaxShownValueLength = 80;
private const string Separator = " ; ";
/// <summary>Types ajoutés (dans l'ordre publié), modifiés, puis supprimés.</summary>
public static IReadOnlyList<FeedbackFormChange> Changes(FeedbackFormsDocument before, FeedbackFormsDocument after)
{
var changes = new List<FeedbackFormChange>();
foreach (var form in after.Forms)
{
var old = before.Find(form.Kind);
if (old is null) changes.Add(new FeedbackFormChange(Created, form.Label, CreatedDetails(form)));
else if (FormDetails(old, form) is { Count: > 0 } details) changes.Add(new FeedbackFormChange(Updated, form.Label, details));
}
foreach (var form in before.Forms.Where(f => after.Find(f.Kind) is null))
changes.Add(new FeedbackFormChange(Deleted, form.Label,
new[] { "plus proposé aux joueurs (les demandes déjà reçues de ce type restent consultables)" }));
return changes;
}
/// <summary>Résumé de la ligne « feedback.forms » : types ajoutés, modifiés, supprimés (par leur nom), ordre des types.</summary>
public static string Summary(FeedbackFormsDocument before, FeedbackFormsDocument after, IReadOnlyList<FeedbackFormChange> changes)
{
var parts = new List<string>();
AddGroup(parts, changes, Created, "type ajouté", "types ajoutés");
AddGroup(parts, changes, Updated, "type modifié", "types modifiés");
AddGroup(parts, changes, Deleted, "type supprimé", "types supprimés");
var kept = after.Forms.Select(f => f.Kind).Where(k => before.Find(k) is not null).ToList();
if (!before.Forms.Select(f => f.Kind).Where(kept.Contains).SequenceEqual(kept)) parts.Add("ordre des types modifié");
return parts.Count == 0 ? "aucun changement" : string.Join(Separator, parts);
}
private static void AddGroup(List<string> parts, IReadOnlyList<FeedbackFormChange> changes, string action, string one, string many)
{
var names = changes.Where(c => c.Action == action).Select(c => Quote(c.Name)).ToList();
if (names.Count > 0) parts.Add($"{names.Count} {(names.Count == 1 ? one : many)} : {string.Join(", ", names)}");
}
/// <summary>
/// Détails d'un changement répartis en lignes d'audit d'au plus <paramref name="maxLength"/> caractères, numérotées
/// s'il en faut plusieurs (aucun détail n'est perdu, un détail démesuré est coupé entre deux lignes).
/// </summary>
public static IReadOnlyList<string> Lines(IReadOnlyList<string> details, int maxLength = MaxDetailsLength)
{
// Place réservée au numéro « (12/12) ».
var room = Math.Max(20, maxLength - 8);
var lines = new List<string>();
var current = "";
foreach (var detail in details.SelectMany(d => Split(d, room)))
{
if (current.Length == 0) current = detail;
else if (current.Length + Separator.Length + detail.Length <= room) current += Separator + detail;
else
{
lines.Add(current);
current = detail;
}
}
if (current.Length > 0) lines.Add(current);
return lines.Count <= 1 ? lines : lines.Select((line, i) => $"({i + 1}/{lines.Count}) {line}").ToList();
}
private static IEnumerable<string> Split(string text, int room)
{
for (var i = 0; i < text.Length; i += room)
yield return text.Substring(i, Math.Min(room, text.Length - i));
}
// ------------------------------------------------------------------ Types de demande
private static List<string> CreatedDetails(FeedbackForm form)
{
var details = new List<string>();
if (form.Hidden) details.Add("masqué (pas encore proposé aux joueurs)");
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »" : "icône importée");
if (!form.AllowAttachments) details.Add("captures d'écran non proposées");
if (form.TechByDefault) details.Add("« Joindre les informations techniques » coché par défaut");
details.Add($"{form.Fields.Count} champ(s)");
details.AddRange(form.Fields.Select(f => $"champ « {f.Label} » ({FieldSummary(f)})"));
return details;
}
private static List<string> FormDetails(FeedbackForm old, FeedbackForm form)
{
var details = new List<string>();
if (old.Label != form.Label) details.Add($"nom affiché : {Quote(old.Label)} → {Quote(form.Label)}");
if (old.Hidden != form.Hidden) details.Add(form.Hidden ? "masqué (plus proposé aux joueurs)" : "de nouveau proposé aux joueurs");
if (old.Description != form.Description) details.Add(ValueChange("description", feminine: true, old.Description, form.Description));
if (old.TitleHint != form.TitleHint) details.Add(ValueChange("exemple de titre", feminine: false, old.TitleHint, form.TitleHint));
if (old.TechByDefault != form.TechByDefault)
details.Add($"« Joindre les informations techniques » {(form.TechByDefault ? "coché" : "décoché")} par défaut");
if (old.CustomIcon != form.CustomIcon)
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »"
: old.CustomIcon is null ? "icône importée" : "icône importée remplacée");
else if (form.CustomIcon is null && old.IconKey != form.IconKey)
details.Add($"icône : « {old.IconKey} » → « {form.IconKey} »");
if (old.AllowAttachments != form.AllowAttachments)
details.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
foreach (var field in old.Fields.Where(f => !newIds.Contains(f.Id)))
details.Add($"champ supprimé : « {field.Label} »");
foreach (var field in form.Fields)
{
if (old.Field(field.Id) is not { } before)
{
details.Add($"champ ajouté : « {field.Label} » ({FieldSummary(field)})");
continue;
}
var parts = FieldDetails(before, field);
if (parts.Count == 0) continue;
var prefix = $"champ « {field.Label} » modifié : ";
var combined = prefix + string.Join(", ", parts);
// Champ très modifié (choix nombreux) : un détail par changement, chacun avec le nom du champ.
if (combined.Length <= MaxDetailsLength / 2) details.Add(combined);
else details.AddRange(parts.Select(p => prefix + p));
}
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
details.Add("ordre des champs modifié");
return details;
}
// ------------------------------------------------------------------ Champs
private static List<string> FieldDetails(FeedbackField old, FeedbackField field)
{
var parts = new List<string>();
if (old.Label != field.Label) parts.Add($"libellé {Quote(old.Label)} → {Quote(field.Label)}");
if (old.Kind != field.Kind) parts.Add($"type {KindText(old.Kind)} → {KindText(field.Kind)}");
if (old.Required != field.Required) parts.Add(field.Required ? "rendu obligatoire" : "rendu facultatif");
if ((old.Hint ?? "") != (field.Hint ?? ""))
parts.Add(string.IsNullOrEmpty(old.Hint) ? "aide ajoutée" : string.IsNullOrEmpty(field.Hint) ? "aide retirée" : "aide modifiée");
if (field.IsText && old.MaxLength != field.MaxLength) parts.Add($"longueur maximale {old.MaxLength} → {field.MaxLength}");
if (field.Kind == FeedbackFieldKind.Rating && old.RatingMax != field.RatingMax)
parts.Add($"étoiles {old.RatingMax} → {field.RatingMax}");
if (old.ShowInList != field.ShowInList)
parts.Add(field.ShowInList ? "affiché en aperçu dans la liste des demandes" : "retiré de l'aperçu de la liste des demandes");
parts.AddRange(OptionDetails(old.Choices, field.Choices));
// Filet : tout autre écart (nouveau réglage d'une version future) reste signalé.
if (parts.Count == 0 && JsonSerializer.Serialize(old) != JsonSerializer.Serialize(field)) parts.Add("réglages modifiés");
return parts;
}
private static IEnumerable<string> OptionDetails(IReadOnlyList<FeedbackOption> old, IReadOnlyList<FeedbackOption> now)
{
var oldIds = old.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
var newIds = now.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
foreach (var option in now.Where(o => !oldIds.Contains(o.Id))) yield return $"choix ajouté « {option.Label} »";
foreach (var option in old.Where(o => !newIds.Contains(o.Id))) yield return $"choix retiré « {option.Label} »";
foreach (var option in now)
if (old.FirstOrDefault(o => o.Id == option.Id) is { } before && before.Label != option.Label)
yield return $"choix renommé « {before.Label} » → « {option.Label} »";
if (!old.Select(o => o.Id).Where(newIds.Contains).SequenceEqual(now.Select(o => o.Id).Where(oldIds.Contains)))
yield return "ordre des choix modifié";
}
private static string FieldSummary(FeedbackField field)
{
var parts = new List<string> { KindText(field.Kind), field.Required ? "obligatoire" : "facultatif" };
if (field.Kind == FeedbackFieldKind.Rating) parts.Add($"{field.RatingMax} étoiles");
if (field.Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes) parts.Add($"{field.Choices.Count} choix");
if (field.ShowInList) parts.Add("en aperçu dans la liste");
return string.Join(", ", parts);
}
private static string KindText(FeedbackFieldKind kind) => kind switch
{
FeedbackFieldKind.Multiline => "zone de texte",
FeedbackFieldKind.Text => "texte court",
FeedbackFieldKind.Choice => "liste de choix",
FeedbackFieldKind.Checkboxes => "cases à cocher",
FeedbackFieldKind.Rating => "note en étoiles",
FeedbackFieldKind.YesNo => "case oui/non",
_ => kind.ToString()
};
// ------------------------------------------------------------------ Texte
/// <summary>« nom : « avant » → « après » », ou « nom modifié » si l'une des valeurs est longue.</summary>
private static string ValueChange(string name, bool feminine, string? before, string? after)
{
var e = feminine ? "e" : "";
before = OneLine(before);
after = OneLine(after);
if (before.Length == 0) return $"{name} ajouté{e} : {Quote(after)}";
if (after.Length == 0) return $"{name} retiré{e}";
return before.Length <= MaxShownValueLength && after.Length <= MaxShownValueLength
? $"{name} : {Quote(before)} → {Quote(after)}"
: $"{name} modifié{e}";
}
private static string OneLine(string? value) =>
string.Join(' ', (value ?? "").Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
private static string Quote(string? value) => $"« {OneLine(value)} »";
}
@@ -1,5 +1,4 @@
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Server.Hosting;
@@ -143,47 +142,4 @@ public sealed class FeedbackFormsStore
// L'historique est une aide : son échec n'annule pas la publication.
}
}
/// <summary>
/// Résumé des changements pour l'audit (types ajoutés, supprimés, réordonnés, masqués ou réaffichés ; champs ajoutés,
/// retirés, modifiés ; captures d'écran, icône).
/// </summary>
public static string Describe(FeedbackFormsDocument before, FeedbackFormsDocument after)
{
var changes = new List<string>();
var added = after.Forms.Where(f => before.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
var removed = before.Forms.Where(f => after.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
if (added.Count > 0) changes.Add($"type(s) ajouté(s) : {string.Join(", ", added)}");
if (removed.Count > 0) changes.Add($"type(s) supprimé(s) : {string.Join(", ", removed)}");
var kept = after.Forms.Select(f => f.Kind).Where(k => before.Find(k) is not null).ToList();
if (!before.Forms.Select(f => f.Kind).Where(kept.Contains).SequenceEqual(kept)) changes.Add("ordre des types modifié");
foreach (var form in after.Forms)
{
var old = before.Find(form.Kind);
if (old is null) continue;
var parts = new List<string>();
if (old.Hidden != form.Hidden) parts.Add(form.Hidden ? "masqué" : "de nouveau proposé");
if (old.Label != form.Label || old.Description != form.Description || old.TitleHint != form.TitleHint ||
old.TechByDefault != form.TechByDefault)
parts.Add("présentation modifiée");
if (old.IconKey != form.IconKey || old.CustomIcon != form.CustomIcon)
parts.Add(form.CustomIcon is null ? "icône d'origine" : "icône importée");
if (old.AllowAttachments != form.AllowAttachments)
parts.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
var addedFields = newIds.Count(id => !oldIds.Contains(id));
var removedFields = oldIds.Count(id => !newIds.Contains(id));
var changed = form.Fields.Count(f => old.Field(f.Id) is { } o &&
JsonSerializer.Serialize(o) != JsonSerializer.Serialize(f));
if (addedFields > 0) parts.Add($"+{addedFields} champ(s)");
if (removedFields > 0) parts.Add($"-{removedFields} champ(s)");
if (changed > 0) parts.Add($"{changed} champ(s) modifié(s)");
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
parts.Add("ordre modifié");
var name = old.Label == form.Label ? old.Label : $"{old.Label} → « {form.Label} »";
if (parts.Count > 0) changes.Add($"{name} : {string.Join(", ", parts)}");
}
return changes.Count == 0 ? "aucun changement" : string.Join(" ; ", changes);
}
}
@@ -0,0 +1,124 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Server.Hosting;
using Konscious.Security.Cryptography;
namespace GameLauncher.Server.Storage.Players;
/// <summary>
/// Empreintes des mots de passe des joueurs : Argon2id (RFC 9106, cité par la CNIL et l'OWASP), sel aléatoire de 128 bits
/// par compte, et « poivre » = clé secrète d'Argon2 dérivée de la clé maîtresse (hors du dossier des données). Format PHC :
/// <c>$argon2id$v=19$m=19456,t=2,p=1,k=1$&lt;sel&gt;$&lt;empreinte&gt;</c> (k : version du poivre). Les paramètres sont
/// gardés avec l'empreinte : un relèvement des réglages s'applique à la connexion suivante de chaque joueur.
///
/// Les calculs (≈ 19 Mio de mémoire chacun) passent par une file bornée : un afflux de connexions ne peut pas épuiser
/// la mémoire ou le processeur du VPS (les requêtes en trop attendent, puis reçoivent 503).
/// </summary>
public sealed class PlayerPasswordHasher
{
private const int SaltLength = 16;
private const int HashLength = 32;
private const string Prefix = "$argon2id$v=19$";
private static readonly TimeSpan QueueTimeout = TimeSpan.FromSeconds(20);
private readonly PlayerSecrets _secrets;
private readonly LauncherApiOptions.PlayersSettings _settings;
private readonly SemaphoreSlim _slots;
public PlayerPasswordHasher(PlayerSecrets secrets, LauncherApiOptions.PlayersSettings settings)
{
_secrets = secrets;
_settings = settings;
_slots = new SemaphoreSlim(settings.MaxConcurrentHashes, settings.MaxConcurrentHashes);
}
/// <summary>Empreinte d'un nouveau mot de passe (UTF-8 du mot de passe tel que saisi).</summary>
public async Task<string> HashAsync(string password, CancellationToken ct = default)
{
var salt = RandomNumberGenerator.GetBytes(SaltLength);
var m = _settings.HashMemoryKib;
var t = _settings.HashIterations;
var p = _settings.HashParallelism;
var hash = await ComputeAsync(password, salt, m, t, p, ct);
return string.Create(CultureInfo.InvariantCulture,
$"{Prefix}m={m},t={t},p={p},k={PlayerSecrets.KeyVersion}${Base64Url.Encode(salt)}${Base64Url.Encode(hash)}");
}
/// <summary>
/// Vérifie un mot de passe. <c>NeedsRehash</c> : empreinte calculée avec d'anciens réglages (à remplacer après une
/// connexion réussie). Une empreinte illisible ne correspond à rien.
/// </summary>
public async Task<(bool Valid, bool NeedsRehash)> VerifyAsync(string password, string stored, CancellationToken ct = default)
{
if (!TryParse(stored, out var m, out var t, out var p, out var salt, out var expected))
{
await DummyAsync(password, ct);
return (false, false);
}
var actual = await ComputeAsync(password, salt, m, t, p, ct);
var valid = CryptographicOperations.FixedTimeEquals(actual, expected);
var rehash = valid && (m != _settings.HashMemoryKib || t != _settings.HashIterations || p != _settings.HashParallelism);
return (valid, rehash);
}
/// <summary>Même calcul pour une adresse inconnue : la durée de réponse ne révèle pas si le compte existe.</summary>
public Task DummyAsync(string password, CancellationToken ct = default) =>
ComputeAsync(password, RandomNumberGenerator.GetBytes(SaltLength), _settings.HashMemoryKib, _settings.HashIterations,
_settings.HashParallelism, ct);
private async Task<byte[]> ComputeAsync(string password, byte[] salt, int memoryKib, int iterations, int parallelism, CancellationToken ct)
{
var bytes = Encoding.UTF8.GetBytes(password);
if (!await _slots.WaitAsync(QueueTimeout, ct))
{
CryptographicOperations.ZeroMemory(bytes);
throw new StoreBusyException("Trop de calculs de mots de passe en attente.");
}
try
{
using var argon2 = new Argon2id(bytes)
{
Salt = salt,
KnownSecret = _secrets.PasswordPepper,
MemorySize = memoryKib,
Iterations = iterations,
DegreeOfParallelism = parallelism
};
return argon2.GetBytes(HashLength);
}
finally
{
_slots.Release();
CryptographicOperations.ZeroMemory(bytes);
}
}
private static bool TryParse(string stored, out int m, out int t, out int p, out byte[] salt, out byte[] hash)
{
m = t = p = 0;
salt = hash = Array.Empty<byte>();
if (string.IsNullOrEmpty(stored) || !stored.StartsWith(Prefix, StringComparison.Ordinal)) return false;
var parts = stored[Prefix.Length..].Split('$');
if (parts.Length != 3) return false;
int k = 0;
foreach (var pair in parts[0].Split(','))
{
var eq = pair.IndexOf('=');
if (eq <= 0 || !int.TryParse(pair[(eq + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out var value)) return false;
switch (pair[..eq])
{
case "m": m = value; break;
case "t": t = value; break;
case "p": p = value; break;
case "k": k = value; break;
default: return false;
}
}
if (m is < 8 or > 4_194_304 || t is < 1 or > 100 || p is < 1 or > 64 || k != PlayerSecrets.KeyVersion) return false;
return Base64Url.TryDecode(parts[1], out salt) && salt.Length == SaltLength &&
Base64Url.TryDecode(parts[2], out hash) && hash.Length == HashLength;
}
}
@@ -0,0 +1,94 @@
namespace GameLauncher.Server.Storage.Players;
/// <summary>États d'un compte joueur.</summary>
public static class PlayerStatus
{
public const string Active = "active";
/// <summary>Désactivé par un administrateur (jusqu'à une date, ou indéfiniment).</summary>
public const string Disabled = "disabled";
/// <summary>
/// Suspendu après trop d'échecs de connexion depuis des appareils inconnus : connexions depuis un NOUVEL appareil
/// refusées jusqu'à ce qu'un administrateur lève la suspension ; les appareils déjà connectés continuent de fonctionner.
/// </summary>
public const string Suspended = "suspended";
}
/// <summary>Compte joueur tel qu'enregistré (adresse chiffrée ; jamais renvoyé tel quel par l'API).</summary>
public sealed record PlayerAccountRecord
{
public required string Id { get; init; }
public required string Reference { get; init; }
public required string EmailIndex { get; init; }
public required string EmailEncrypted { get; init; }
public bool EmailVerified { get; init; }
public required string PasswordHash { get; init; }
public DateTimeOffset PasswordChangedAt { get; init; }
public bool MustChangePassword { get; init; }
public bool OtpEnabled { get; init; }
public string Status { get; init; } = PlayerStatus.Active;
public DateTimeOffset? DisabledUntil { get; init; }
public DateTimeOffset? StatusChangedAt { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastLoginAt { get; init; }
public required string TermsVersion { get; init; }
/// <summary>Échecs consécutifs depuis des appareils inconnus (série en cours), et nombre de blocages successifs.</summary>
public int FailedCount { get; init; }
public int LockLevel { get; init; }
public DateTimeOffset? LockedUntil { get; init; }
/// <summary>Échecs consécutifs depuis des appareils de confiance, et leur blocage temporaire.</summary>
public int TrustedFailedCount { get; init; }
public DateTimeOffset? TrustedLockedUntil { get; init; }
}
/// <summary>Appareil connecté (clé publique de l'appareil et session mémorisée).</summary>
public sealed record PlayerDeviceRecord
{
public required string Id { get; init; }
public required string AccountId { get; init; }
public required string KeyId { get; init; }
/// <summary>Clé publique de l'appareil (SPKI P-256, Base64).</summary>
public required string PublicKey { get; init; }
public required string Label { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset LastUsedAt { get; init; }
/// <summary>SHA-256 du jeton de renouvellement en vigueur, et du précédent (détection d'une réutilisation).</summary>
public required string RefreshHash { get; init; }
public string? PreviousRefreshHash { get; init; }
public DateTimeOffset RefreshExpiresAt { get; init; }
public DateTimeOffset RefreshAbsoluteExpiresAt { get; init; }
}
/// <summary>Ligne du journal des comptes.</summary>
public sealed record PlayerEventRecord(long Id, string? AccountId, DateTimeOffset At, string Type, string Result, string? Ip, string? DeviceKeyId,
string? Details);
/// <summary>Résultat d'un échec de connexion pour un compte existant.</summary>
public sealed record PlayerFailureOutcome(DateTimeOffset? LockedUntil, bool Suspended, int LockLevel);
/// <summary>Types d'événements du journal des comptes (actions des joueurs et du système).</summary>
public static class PlayerEvents
{
public const string Register = "account.register";
public const string Login = "account.login";
public const string LoginFailed = "account.login_failed";
public const string Locked = "account.locked";
public const string Suspended = "account.suspended";
public const string Unlocked = "account.unlocked";
public const string Disabled = "account.disabled";
public const string Enabled = "account.enabled";
public const string Logout = "account.logout";
public const string LogoutAll = "account.logout_all";
public const string PasswordChange = "account.password_change";
public const string PasswordChangeForced = "account.password_change_required";
public const string Refresh = "session.refresh";
public const string RefreshReuse = "session.reuse";
public const string DeviceRemoved = "device.removed";
}
@@ -0,0 +1,134 @@
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
namespace GameLauncher.Server.Storage.Players;
/// <summary>
/// Clés des comptes joueurs, toutes dérivées (HKDF-SHA256) d'une clé maîtresse de 256 bits gardée HORS du dossier des
/// données (credential systemd chiffré, déchiffré par systemd au démarrage dans un dossier en mémoire visible du seul
/// service). Une copie de /var/lib/launcher-api ou de ses sauvegardes ne permet donc ni de lire les adresses e-mail, ni
/// de tester des mots de passe hors ligne (le « poivre » des empreintes en dérive aussi).
///
/// Une sous-clé par usage : chiffrement des adresses, index aveugle (recherche par adresse sans la stocker en clair),
/// poivre des mots de passe, et pour les étapes suivantes : secrets OTP, notes des administrateurs, messages.
/// </summary>
public sealed class PlayerSecrets
{
/// <summary>Version de la clé maîtresse, écrite devant chaque valeur chiffrée (rotation future).</summary>
public const int KeyVersion = 1;
private const int KeyLength = 32;
private const int NonceLength = 12;
private const int TagLength = 16;
private static readonly byte[] HkdfSalt = Encoding.ASCII.GetBytes("GameLauncher/Player/Keys/v1");
private readonly byte[] _emailKey;
private readonly byte[] _indexKey;
private readonly byte[] _pepper;
private readonly byte[] _fieldKey;
public PlayerSecrets(ReadOnlySpan<byte> masterKey)
{
if (masterKey.Length != KeyLength) throw new CryptographicException("La clé maîtresse des comptes joueurs doit faire 32 octets.");
if (masterKey.IndexOfAnyExcept((byte)0) < 0) throw new CryptographicException("Clé maîtresse vide (que des zéros).");
_emailKey = Derive(masterKey, "email-encryption/v1");
_indexKey = Derive(masterKey, "email-index/v1");
_pepper = Derive(masterKey, "password-pepper/v1");
_fieldKey = Derive(masterKey, "field-encryption/v1");
}
/// <summary>
/// Lit la clé maîtresse (Base64 de 32 octets). Sous Linux, le fichier ne doit être ni lisible ni modifiable par les
/// autres comptes, ni modifiable par le groupe (un credential systemd n'est lisible que par le service).
/// </summary>
public static PlayerSecrets Load(string path)
{
if (!File.Exists(path)) throw new InvalidOperationException($"Clé maîtresse des comptes joueurs introuvable : {path}");
if (!OperatingSystem.IsWindows())
{
var mode = File.GetUnixFileMode(path);
const UnixFileMode forbidden = UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute;
if ((mode & forbidden) != 0)
throw new InvalidOperationException(
$"Clé maîtresse des comptes joueurs accessible à d'autres comptes ({path}) : droits 0400 ou 0600 attendus.");
}
var text = File.ReadAllText(path).Trim();
byte[] key;
try
{
key = Convert.FromBase64String(text);
}
catch (FormatException)
{
throw new InvalidOperationException("Clé maîtresse des comptes joueurs illisible : 32 octets en Base64 attendus (commande players-key).");
}
try
{
return new PlayerSecrets(key);
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
}
/// <summary>Nouvelle clé maîtresse (commande « players-key »), en Base64.</summary>
public static string NewMasterKey() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(KeyLength));
/// <summary>Poivre des empreintes de mots de passe (« clé secrète » d'Argon2id).</summary>
public byte[] PasswordPepper => _pepper;
/// <summary>
/// Index aveugle d'une adresse NORMALISÉE : HMAC-SHA256 avec une clé secrète. Permet de retrouver un compte et
/// d'imposer l'unicité des adresses sans conserver l'adresse en clair ; sans la clé, impossible de tester une adresse.
/// </summary>
public string EmailIndex(string normalizedEmail) =>
Convert.ToHexString(HMACSHA256.HashData(_indexKey, Encoding.UTF8.GetBytes("GameLauncher/Player/Email/v1\n" + normalizedEmail)))
.ToLowerInvariant();
/// <summary>Chiffre l'adresse d'un compte (liée à ce compte : une valeur copiée sur un autre compte ne se déchiffre pas).</summary>
public string EncryptEmail(string accountId, string normalizedEmail) =>
Encrypt(_emailKey, Encoding.UTF8.GetBytes(normalizedEmail), Associated("email", accountId));
public string DecryptEmail(string accountId, string value) =>
Encoding.UTF8.GetString(Decrypt(_emailKey, value, Associated("email", accountId)));
/// <summary>Chiffre un champ libre d'un compte (motifs, notes…), lié au compte et au nom du champ.</summary>
public string EncryptField(string accountId, string field, string text) =>
Encrypt(_fieldKey, Encoding.UTF8.GetBytes(text), Associated(field, accountId));
public string DecryptField(string accountId, string field, string value) =>
Encoding.UTF8.GetString(Decrypt(_fieldKey, value, Associated(field, accountId)));
private static byte[] Associated(string field, string accountId) =>
Encoding.UTF8.GetBytes($"GameLauncher/Player/Field/v1\n{field}\n{accountId}");
private static byte[] Derive(ReadOnlySpan<byte> masterKey, string purpose) =>
HKDF.DeriveKey(HashAlgorithmName.SHA256, masterKey.ToArray(), KeyLength, HkdfSalt, Encoding.ASCII.GetBytes(purpose));
/// <summary>Format : « 1. » (version de la clé) + Base64Url(nonce ‖ chiffré ‖ étiquette), AES-256-GCM.</summary>
private static string Encrypt(byte[] key, byte[] plaintext, byte[] associated)
{
var buffer = new byte[NonceLength + plaintext.Length + TagLength];
var nonce = buffer.AsSpan(0, NonceLength);
RandomNumberGenerator.Fill(nonce);
using var aes = new AesGcm(key, TagLength);
aes.Encrypt(nonce, plaintext, buffer.AsSpan(NonceLength, plaintext.Length), buffer.AsSpan(NonceLength + plaintext.Length), associated);
return $"{KeyVersion}.{Base64Url.Encode(buffer)}";
}
private static byte[] Decrypt(byte[] key, string value, byte[] associated)
{
var dot = value.IndexOf('.');
if (dot <= 0 || value[..dot] != KeyVersion.ToString(System.Globalization.CultureInfo.InvariantCulture) ||
!Base64Url.TryDecode(value[(dot + 1)..], out var buffer) || buffer.Length < NonceLength + TagLength)
throw new CryptographicException("Valeur chiffrée illisible ou d'une autre version de clé.");
var length = buffer.Length - NonceLength - TagLength;
var plaintext = new byte[length];
using var aes = new AesGcm(key, TagLength);
aes.Decrypt(buffer.AsSpan(0, NonceLength), buffer.AsSpan(NonceLength, length), buffer.AsSpan(NonceLength + length), plaintext, associated);
return plaintext;
}
}
@@ -0,0 +1,520 @@
using System.Globalization;
using GameLauncher.Server.Hosting;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage.Players;
/// <summary>
/// Base des comptes joueurs : SQLite (players/players.db dans les données privées, dossier 0700, fichier 0600).
///
/// Pourquoi SQLite ici (et pas des fichiers JSON comme les autres modules) : relations (compte ⇄ appareils ⇄ journal, et
/// bientôt conversations, blocages), unicité stricte des adresses sous transaction, volumes non bornés, recherches par
/// index. Réglages : journal WAL, clés étrangères, effacement sécurisé (secure_delete : une ligne supprimée est écrasée
/// sur le disque), écriture synchrone complète. Requêtes PARAMÉTRÉES uniquement.
///
/// Une seule connexion, protégée par un verrou : SQLite n'accepte qu'un écrivain à la fois, les requêtes sont courtes,
/// et les calculs coûteux (empreintes de mots de passe) sont faits AVANT d'entrer ici. L'outil en ligne de commande peut
/// ouvrir la base en même temps (attente bornée).
///
/// Le contenu sensible est chiffré AVANT d'arriver ici (adresse e-mail : <see cref="PlayerSecrets"/>) : cette classe ne
/// connaît pas la clé maîtresse.
/// </summary>
public sealed class PlayerStore : IDisposable
{
public const string FolderName = "players";
public const string FileName = "players.db";
/// <summary>Version du schéma (PRAGMA user_version).</summary>
public const int SchemaVersion = 1;
private readonly object _lock = new();
private readonly SqliteConnection _db;
private readonly TimeProvider _time;
public PlayerStore(IOptions<LauncherApiOptions> options, TimeProvider time) : this(options.Value.DataDirectoryFullPath, time) { }
public PlayerStore(string dataDirectory, TimeProvider time)
{
_time = time;
SecureFiles.EnsureDirectory(dataDirectory);
var folder = Path.Combine(dataDirectory, FolderName);
SecureFiles.EnsureDirectory(folder);
DatabasePath = Path.Combine(folder, FileName);
// Fichier créé en 0600 AVANT que SQLite ne l'ouvre (les fichiers -wal et -shm reprennent ses droits).
if (!File.Exists(DatabasePath)) SecureFiles.TryCreateNew(DatabasePath, "");
_db = new SqliteConnection(new SqliteConnectionStringBuilder
{
DataSource = DatabasePath,
Mode = SqliteOpenMode.ReadWriteCreate,
Pooling = false
}.ToString());
_db.Open();
try
{
Execute(null, "PRAGMA busy_timeout = 5000;");
Scalar(null, "PRAGMA journal_mode = WAL;");
Execute(null, "PRAGMA foreign_keys = ON; PRAGMA secure_delete = ON; PRAGMA synchronous = FULL;");
Migrate();
}
catch
{
_db.Dispose();
throw;
}
}
public string DatabasePath { get; }
public void Dispose()
{
lock (_lock) _db.Dispose();
}
// ------------------------------------------------------------------ Schéma
private void Migrate()
{
var version = Convert.ToInt64(Scalar(null, "PRAGMA user_version;"), CultureInfo.InvariantCulture);
if (version > SchemaVersion)
throw new InvalidOperationException(
$"Base des comptes joueurs en version {version}, plus récente que ce serveur ({SchemaVersion}) : mettez le serveur à jour.");
if (version == SchemaVersion) return;
using var tx = _db.BeginTransaction();
if (version < 1)
{
Execute(tx, """
CREATE TABLE accounts (
id TEXT PRIMARY KEY,
reference TEXT NOT NULL UNIQUE,
email_index TEXT NOT NULL UNIQUE,
email_enc TEXT NOT NULL,
email_verified INTEGER NOT NULL DEFAULT 0,
password_hash TEXT NOT NULL,
password_changed_at INTEGER NOT NULL,
must_change_password INTEGER NOT NULL DEFAULT 0,
totp_secret_enc TEXT,
totp_last_step INTEGER,
status TEXT NOT NULL,
disabled_until INTEGER,
status_reason_enc TEXT,
status_changed_at INTEGER,
status_changed_by TEXT,
created_at INTEGER NOT NULL,
last_login_at INTEGER,
terms_version TEXT NOT NULL,
failed_count INTEGER NOT NULL DEFAULT 0,
lock_level INTEGER NOT NULL DEFAULT 0,
locked_until INTEGER,
trusted_failed_count INTEGER NOT NULL DEFAULT 0,
trusted_locked_until INTEGER
);
CREATE INDEX ix_accounts_created ON accounts(created_at);
CREATE INDEX ix_accounts_status ON accounts(status);
CREATE TABLE devices (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
key_id TEXT NOT NULL,
public_key TEXT NOT NULL,
label TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL,
refresh_hash TEXT NOT NULL,
previous_refresh_hash TEXT,
refresh_expires_at INTEGER NOT NULL,
refresh_absolute_expires_at INTEGER NOT NULL,
UNIQUE (account_id, key_id)
);
CREATE INDEX ix_devices_account ON devices(account_id);
CREATE TABLE account_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT REFERENCES accounts(id) ON DELETE CASCADE,
at INTEGER NOT NULL,
type TEXT NOT NULL,
result TEXT NOT NULL,
ip TEXT,
device_key TEXT,
details TEXT
);
CREATE INDEX ix_events_account ON account_events(account_id, at);
CREATE INDEX ix_events_at ON account_events(at);
""");
}
Execute(tx, $"PRAGMA user_version = {SchemaVersion};");
tx.Commit();
}
// ------------------------------------------------------------------ Comptes
private const string AccountColumns =
"id, reference, email_index, email_enc, email_verified, password_hash, password_changed_at, must_change_password, " +
"totp_secret_enc IS NOT NULL, status, disabled_until, status_changed_at, created_at, last_login_at, terms_version, " +
"failed_count, lock_level, locked_until, trusted_failed_count, trusted_locked_until";
public PlayerAccountRecord? FindByEmailIndex(string emailIndex) => Locked(() =>
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE email_index = $v;", ReadAccount, ("$v", emailIndex)));
public PlayerAccountRecord? FindById(string accountId) => Locked(() =>
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE id = $v;", ReadAccount, ("$v", accountId)));
public PlayerAccountRecord? FindByReference(string reference) => Locked(() =>
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE reference = $v;", ReadAccount, ("$v", reference)));
public int CountAccounts() => Locked(() => Convert.ToInt32(Scalar(null, "SELECT COUNT(*) FROM accounts;"), CultureInfo.InvariantCulture));
public int CountCreatedSince(DateTimeOffset since) => Locked(() =>
Convert.ToInt32(Scalar(null, "SELECT COUNT(*) FROM accounts WHERE created_at >= $t;", ("$t", since.ToUnixTimeSeconds())),
CultureInfo.InvariantCulture));
/// <summary>
/// Crée le compte et son premier appareil, sous transaction. Faux si l'adresse (index) est déjà utilisée. La référence
/// est retirée au sort de nouveau en cas de collision (40 bits : rare).
/// </summary>
public bool TryCreate(PlayerAccountRecord account, PlayerDeviceRecord device, Func<string> newReference, out PlayerAccountRecord created)
{
created = account;
lock (_lock)
{
using var tx = _db.BeginTransaction();
if (Scalar(tx, "SELECT 1 FROM accounts WHERE email_index = $v;", ("$v", account.EmailIndex)) is not null) return false;
var reference = account.Reference;
for (var attempt = 0; Scalar(tx, "SELECT 1 FROM accounts WHERE reference = $v;", ("$v", reference)) is not null; attempt++)
{
if (attempt > 20) throw new InvalidOperationException("Impossible de tirer une référence de compte libre.");
reference = newReference();
}
created = account with { Reference = reference };
Execute(tx, """
INSERT INTO accounts (id, reference, email_index, email_enc, email_verified, password_hash, password_changed_at,
must_change_password, status, created_at, last_login_at, terms_version)
VALUES ($id, $ref, $idx, $enc, 0, $hash, $pwd, 0, $status, $created, $login, $terms);
""",
("$id", created.Id), ("$ref", created.Reference), ("$idx", created.EmailIndex), ("$enc", created.EmailEncrypted),
("$hash", created.PasswordHash), ("$pwd", created.PasswordChangedAt.ToUnixTimeSeconds()), ("$status", PlayerStatus.Active),
("$created", created.CreatedAt.ToUnixTimeSeconds()), ("$login", Db(created.LastLoginAt)), ("$terms", created.TermsVersion));
InsertDevice(tx, device);
tx.Commit();
return true;
}
}
/// <summary>Connexion réussie : compteurs d'échecs remis à zéro, date de connexion, empreinte éventuellement remplacée.</summary>
public void RecordSuccess(string accountId, DateTimeOffset now, string? newPasswordHash) => Locked(() =>
{
Execute(null, """
UPDATE accounts SET failed_count = 0, lock_level = 0, locked_until = NULL, trusted_failed_count = 0,
trusted_locked_until = NULL, last_login_at = $now,
password_hash = COALESCE($hash, password_hash)
WHERE id = $id;
""", ("$now", now.ToUnixTimeSeconds()), ("$hash", (object?)newPasswordHash ?? DBNull.Value), ("$id", accountId));
return true;
});
/// <summary>
/// Échec de connexion (mot de passe faux) sur un compte existant, sous transaction. Appareil inconnu : blocage tous les
/// <paramref name="failuresPerLock"/> échecs (durée doublée à chaque fois), suspension au blocage n°
/// <paramref name="suspendAfterLocks"/>. Appareil de confiance : blocage temporaire seulement.
/// </summary>
public PlayerFailureOutcome RecordFailure(string accountId, bool trusted, DateTimeOffset now, LauncherApiOptions.PlayersSettings settings)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
var account = QuerySingle(tx, $"SELECT {AccountColumns} FROM accounts WHERE id = $v;", ReadAccount, ("$v", accountId))
?? throw new InvalidOperationException("Compte disparu.");
PlayerFailureOutcome outcome;
if (trusted)
{
var count = account.TrustedFailedCount + 1;
DateTimeOffset? until = null;
if (count >= settings.TrustedFailuresPerLock)
{
until = now + TimeSpan.FromMinutes(settings.LockMinutes);
count = 0;
}
Execute(tx, "UPDATE accounts SET trusted_failed_count = $c, trusted_locked_until = COALESCE($u, trusted_locked_until) WHERE id = $id;",
("$c", count), ("$u", Db(until)), ("$id", accountId));
outcome = new PlayerFailureOutcome(until, false, account.LockLevel);
}
else
{
var count = account.FailedCount + 1;
var level = account.LockLevel;
DateTimeOffset? until = null;
var suspended = false;
if (count >= settings.FailuresPerLock)
{
count = 0;
level++;
if (level >= settings.SuspendAfterLocks && account.Status != PlayerStatus.Suspended) suspended = true;
// Le blocage temporaire s'applique AUSSI au moment de la suspension et après : vu de l'extérieur, un compte
// suspendu se comporte comme une adresse inconnue (PlayerGuard.CountUnknownFailure), la suspension
// n'est révélée qu'après un mot de passe correct.
until = now + LockDuration(settings, level);
}
Execute(tx, """
UPDATE accounts SET failed_count = $c, lock_level = $l, locked_until = COALESCE($u, locked_until),
status = CASE WHEN $s = 1 THEN 'suspended' ELSE status END,
status_changed_at = CASE WHEN $s = 1 THEN $now ELSE status_changed_at END,
status_changed_by = CASE WHEN $s = 1 THEN 'système (échecs de connexion)' ELSE status_changed_by END,
disabled_until = CASE WHEN $s = 1 THEN NULL ELSE disabled_until END
WHERE id = $id;
""", ("$c", count), ("$l", level), ("$u", Db(until)), ("$s", suspended ? 1 : 0), ("$now", now.ToUnixTimeSeconds()),
("$id", accountId));
outcome = new PlayerFailureOutcome(until, suspended, level);
}
tx.Commit();
return outcome;
}
}
/// <summary>
/// Durée du blocage n° <paramref name="level"/> (1, 2, 3…) : <c>LockMinutes</c> doublée à chaque blocage, plafonnée
/// au niveau précédant la suspension et à 24 h. Même règle pour les adresses inconnues (blocage miroir).
/// </summary>
public static TimeSpan LockDuration(LauncherApiOptions.PlayersSettings settings, int level)
{
var capped = Math.Clamp(level, 1, Math.Max(1, settings.SuspendAfterLocks - 1));
return TimeSpan.FromMinutes(Math.Min(24 * 60, settings.LockMinutes * Math.Pow(2, capped - 1)));
}
/// <summary>Nouveau mot de passe : empreinte, date, « changement exigé » levé (ou posé).</summary>
public void SetPassword(string accountId, string passwordHash, DateTimeOffset now, bool mustChange = false) => Locked(() =>
{
Execute(null, "UPDATE accounts SET password_hash = $h, password_changed_at = $now, must_change_password = $m WHERE id = $id;",
("$h", passwordHash), ("$now", now.ToUnixTimeSeconds()), ("$m", mustChange ? 1 : 0), ("$id", accountId));
return true;
});
/// <summary>Exige (ou non) un nouveau mot de passe à la prochaine connexion.</summary>
public bool SetMustChangePassword(string accountId, bool value) => Locked(() =>
Execute(null, "UPDATE accounts SET must_change_password = $m WHERE id = $id;", ("$m", value ? 1 : 0), ("$id", accountId)) == 1);
/// <summary>Change l'état du compte (désactivation, réactivation, levée d'une suspension) ; remet à zéro les blocages
/// quand le compte redevient actif.</summary>
public bool SetStatus(string accountId, string status, DateTimeOffset? until, string? reasonEncrypted, string changedBy, DateTimeOffset now) =>
Locked(() => Execute(null, """
UPDATE accounts SET status = $s, disabled_until = $u, status_reason_enc = $r, status_changed_at = $now, status_changed_by = $by,
failed_count = CASE WHEN $s = 'active' THEN 0 ELSE failed_count END,
lock_level = CASE WHEN $s = 'active' THEN 0 ELSE lock_level END,
locked_until = CASE WHEN $s = 'active' THEN NULL ELSE locked_until END,
trusted_failed_count = CASE WHEN $s = 'active' THEN 0 ELSE trusted_failed_count END,
trusted_locked_until = CASE WHEN $s = 'active' THEN NULL ELSE trusted_locked_until END
WHERE id = $id;
""", ("$s", status), ("$u", Db(until)), ("$r", (object?)reasonEncrypted ?? DBNull.Value), ("$now", now.ToUnixTimeSeconds()),
("$by", changedBy), ("$id", accountId)) == 1);
// ------------------------------------------------------------------ Appareils
private const string DeviceColumns =
"id, account_id, key_id, public_key, label, created_at, last_used_at, refresh_hash, previous_refresh_hash, " +
"refresh_expires_at, refresh_absolute_expires_at";
public PlayerDeviceRecord? FindDevice(string deviceId) => Locked(() =>
QuerySingle(null, $"SELECT {DeviceColumns} FROM devices WHERE id = $v;", ReadDevice, ("$v", deviceId)));
public PlayerDeviceRecord? FindDeviceByKey(string accountId, string keyId) => Locked(() =>
QuerySingle(null, $"SELECT {DeviceColumns} FROM devices WHERE account_id = $a AND key_id = $k;", ReadDevice,
("$a", accountId), ("$k", keyId)));
public IReadOnlyList<PlayerDeviceRecord> Devices(string accountId) => Locked(() =>
Query(null, $"SELECT {DeviceColumns} FROM devices WHERE account_id = $a ORDER BY last_used_at DESC;", ReadDevice, ("$a", accountId)));
/// <summary>
/// Ouvre une session mémorisée sur un appareil : appareil créé, ou remplacé s'il existait (même clé : nouvelle
/// connexion depuis le même PC). Au-delà de <paramref name="maxDevices"/>, les appareils les moins récemment utilisés
/// sont déconnectés. Renvoie les identifiants des appareils retirés (sessions à fermer).
/// </summary>
public IReadOnlyList<string> SaveDevice(PlayerDeviceRecord device, int maxDevices)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
var removed = Query(tx, "SELECT id FROM devices WHERE account_id = $a AND key_id = $k;", r => r.GetString(0),
("$a", device.AccountId), ("$k", device.KeyId)).ToList();
Execute(tx, "DELETE FROM devices WHERE account_id = $a AND key_id = $k;", ("$a", device.AccountId), ("$k", device.KeyId));
InsertDevice(tx, device);
var excess = Query(tx, "SELECT id FROM devices WHERE account_id = $a AND id <> $id ORDER BY last_used_at DESC, created_at DESC;",
r => r.GetString(0), ("$a", device.AccountId), ("$id", device.Id)).Skip(Math.Max(0, maxDevices - 1)).ToList();
foreach (var id in excess) Execute(tx, "DELETE FROM devices WHERE id = $id;", ("$id", id));
tx.Commit();
removed.AddRange(excess);
return removed;
}
}
/// <summary>
/// Remplace le jeton de renouvellement SI celui présenté est toujours celui en vigueur (deux renouvellements simultanés
/// avec le même jeton : un seul réussit).
/// </summary>
public bool RotateRefresh(string deviceId, string expectedHash, string newHash, DateTimeOffset idleExpiry, DateTimeOffset now) => Locked(() =>
Execute(null, """
UPDATE devices SET previous_refresh_hash = refresh_hash, refresh_hash = $new, refresh_expires_at = $exp, last_used_at = $now
WHERE id = $id AND refresh_hash = $old;
""", ("$new", newHash), ("$exp", idleExpiry.ToUnixTimeSeconds()), ("$now", now.ToUnixTimeSeconds()), ("$id", deviceId),
("$old", expectedHash)) == 1);
public bool DeleteDevice(string deviceId) => Locked(() => Execute(null, "DELETE FROM devices WHERE id = $id;", ("$id", deviceId)) == 1);
/// <summary>Déconnecte tous les appareils du compte (sauf <paramref name="exceptDeviceId"/>) ; renvoie leurs identifiants.</summary>
public IReadOnlyList<string> DeleteDevices(string accountId, string? exceptDeviceId = null)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
var ids = Query(tx, "SELECT id FROM devices WHERE account_id = $a AND id <> $e;", r => r.GetString(0),
("$a", accountId), ("$e", exceptDeviceId ?? "")).ToList();
Execute(tx, "DELETE FROM devices WHERE account_id = $a AND id <> $e;", ("$a", accountId), ("$e", exceptDeviceId ?? ""));
tx.Commit();
return ids;
}
}
private void InsertDevice(SqliteTransaction tx, PlayerDeviceRecord device) =>
Execute(tx, """
INSERT INTO devices (id, account_id, key_id, public_key, label, created_at, last_used_at, refresh_hash, previous_refresh_hash,
refresh_expires_at, refresh_absolute_expires_at)
VALUES ($id, $a, $k, $pk, $label, $created, $used, $rh, NULL, $exp, $abs);
""", ("$id", device.Id), ("$a", device.AccountId), ("$k", device.KeyId), ("$pk", device.PublicKey), ("$label", device.Label),
("$created", device.CreatedAt.ToUnixTimeSeconds()), ("$used", device.LastUsedAt.ToUnixTimeSeconds()), ("$rh", device.RefreshHash),
("$exp", device.RefreshExpiresAt.ToUnixTimeSeconds()), ("$abs", device.RefreshAbsoluteExpiresAt.ToUnixTimeSeconds()));
// ------------------------------------------------------------------ Journal des comptes
/// <summary>
/// Ajoute une ligne au journal des comptes. Jamais d'adresse e-mail ni de mot de passe : identifiant du compte, type,
/// résultat, adresse IP (effacée au bout de quelques mois), clé d'appareil, détail court.
/// </summary>
public void AddEvent(string? accountId, string type, string result, string? ip, string? deviceKeyId = null, string? details = null)
{
var now = _time.GetUtcNow();
Locked(() => Execute(null, """
INSERT INTO account_events (account_id, at, type, result, ip, device_key, details) VALUES ($a, $at, $t, $r, $ip, $k, $d);
""", ("$a", (object?)accountId ?? DBNull.Value), ("$at", now.ToUnixTimeSeconds()), ("$t", type), ("$r", result),
("$ip", (object?)Trim(ip, 64) ?? DBNull.Value), ("$k", (object?)Trim(deviceKeyId, 32) ?? DBNull.Value),
("$d", (object?)Trim(details, 300) ?? DBNull.Value)));
}
/// <summary>Journal d'un compte, le plus récent d'abord.</summary>
public IReadOnlyList<PlayerEventRecord> Events(string accountId, int limit) => Locked(() =>
Query(null, "SELECT id, account_id, at, type, result, ip, device_key, details FROM account_events WHERE account_id = $a ORDER BY id DESC LIMIT $l;",
ReadEvent, ("$a", accountId), ("$l", Math.Clamp(limit, 1, 1000))));
/// <summary>Lignes du journal postérieures à <paramref name="afterId"/> (tous comptes, tests et outils).</summary>
public IReadOnlyList<PlayerEventRecord> EventsAfter(long afterId, int limit) => Locked(() =>
Query(null, "SELECT id, account_id, at, type, result, ip, device_key, details FROM account_events WHERE id > $i ORDER BY id LIMIT $l;",
ReadEvent, ("$i", afterId), ("$l", Math.Clamp(limit, 1, 10_000))));
/// <summary>
/// Conservation : lignes plus anciennes que <paramref name="months"/> supprimées, adresses IP plus anciennes que
/// <paramref name="ipMonths"/> effacées. Renvoie (supprimées, anonymisées).
/// </summary>
public (int Deleted, int Anonymized) PurgeEvents(DateTimeOffset now, int months, int ipMonths) => Locked(() =>
{
var deleted = Execute(null, "DELETE FROM account_events WHERE at < $t;", ("$t", now.AddMonths(-months).ToUnixTimeSeconds()));
var anonymized = Execute(null, "UPDATE account_events SET ip = NULL WHERE ip IS NOT NULL AND at < $t;",
("$t", now.AddMonths(-ipMonths).ToUnixTimeSeconds()));
return (deleted, anonymized);
});
private static string? Trim(string? value, int max)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var clean = new string(value.Select(c => char.IsControl(c) ? ' ' : c).ToArray()).Trim();
return clean.Length <= max ? clean : clean[..max];
}
// ------------------------------------------------------------------ Accès SQLite
private T Locked<T>(Func<T> work)
{
lock (_lock) return work();
}
private int Execute(SqliteTransaction? tx, string sql, params (string Name, object? Value)[] parameters)
{
using var command = Command(tx, sql, parameters);
return command.ExecuteNonQuery();
}
private object? Scalar(SqliteTransaction? tx, string sql, params (string Name, object? Value)[] parameters)
{
using var command = Command(tx, sql, parameters);
return command.ExecuteScalar();
}
private List<T> Query<T>(SqliteTransaction? tx, string sql, Func<SqliteDataReader, T> read, params (string Name, object? Value)[] parameters)
{
using var command = Command(tx, sql, parameters);
using var reader = command.ExecuteReader();
var rows = new List<T>();
while (reader.Read()) rows.Add(read(reader));
return rows;
}
private T? QuerySingle<T>(SqliteTransaction? tx, string sql, Func<SqliteDataReader, T> read, params (string Name, object? Value)[] parameters)
where T : class =>
Query(tx, sql, read, parameters).FirstOrDefault();
private SqliteCommand Command(SqliteTransaction? tx, string sql, (string Name, object? Value)[] parameters)
{
var command = _db.CreateCommand();
command.CommandText = sql;
command.Transaction = tx;
foreach (var (name, value) in parameters) command.Parameters.AddWithValue(name, value ?? DBNull.Value);
return command;
}
private static object Db(DateTimeOffset? value) => value is { } date ? date.ToUnixTimeSeconds() : DBNull.Value;
private static DateTimeOffset Date(SqliteDataReader r, int i) => DateTimeOffset.FromUnixTimeSeconds(r.GetInt64(i));
private static DateTimeOffset? NullableDate(SqliteDataReader r, int i) => r.IsDBNull(i) ? null : Date(r, i);
private static PlayerAccountRecord ReadAccount(SqliteDataReader r) => new()
{
Id = r.GetString(0),
Reference = r.GetString(1),
EmailIndex = r.GetString(2),
EmailEncrypted = r.GetString(3),
EmailVerified = r.GetInt64(4) != 0,
PasswordHash = r.GetString(5),
PasswordChangedAt = Date(r, 6),
MustChangePassword = r.GetInt64(7) != 0,
OtpEnabled = r.GetInt64(8) != 0,
Status = r.GetString(9),
DisabledUntil = NullableDate(r, 10),
StatusChangedAt = NullableDate(r, 11),
CreatedAt = Date(r, 12),
LastLoginAt = NullableDate(r, 13),
TermsVersion = r.GetString(14),
FailedCount = (int)r.GetInt64(15),
LockLevel = (int)r.GetInt64(16),
LockedUntil = NullableDate(r, 17),
TrustedFailedCount = (int)r.GetInt64(18),
TrustedLockedUntil = NullableDate(r, 19)
};
private static PlayerDeviceRecord ReadDevice(SqliteDataReader r) => new()
{
Id = r.GetString(0),
AccountId = r.GetString(1),
KeyId = r.GetString(2),
PublicKey = r.GetString(3),
Label = r.GetString(4),
CreatedAt = Date(r, 5),
LastUsedAt = Date(r, 6),
RefreshHash = r.GetString(7),
PreviousRefreshHash = r.IsDBNull(8) ? null : r.GetString(8),
RefreshExpiresAt = Date(r, 9),
RefreshAbsoluteExpiresAt = Date(r, 10)
};
private static PlayerEventRecord ReadEvent(SqliteDataReader r) => new(
r.GetInt64(0), r.IsDBNull(1) ? null : r.GetString(1), Date(r, 2), r.GetString(3), r.GetString(4),
r.IsDBNull(5) ? null : r.GetString(5), r.IsDBNull(6) ? null : r.GetString(6), r.IsDBNull(7) ? null : r.GetString(7));
}
@@ -72,6 +72,23 @@
"Audit": {
"RetentionMonths": 12
},
// Comptes joueurs (phase 8) : désactivés par défaut. NE PAS ACTIVER EN PRODUCTION avant la fin de l'étape 8g
// (docs/PLAYER-ACCOUNTS-ARCHITECTURE.md). Activés, ils exigent la clé maîtresse : credential systemd chiffré
// « players-master-key » (server/deploy/LISEZMOI.md, étape 3 undecies), jamais dans le dossier des données.
"Players": {
"Enabled": false,
"RegistrationEnabled": true,
"TermsVersion": "1",
"HashMemoryKib": 19456,
"HashIterations": 2,
"FailuresPerLock": 5,
"LockMinutes": 15,
"SuspendAfterLocks": 4,
"RefreshIdleDays": 90,
"RefreshMaxDays": 365,
"EventsRetentionMonths": 12,
"EventsIpRetentionMonths": 6
},
"Limits": {
"RequestsPerMinute": 300,
// Morceaux de 4 Mo envoyés pendant une publication (à la place de RequestsPerMinute) : 1500 ≈ 100 Mo/s.
+37
View File
@@ -501,6 +501,39 @@ Comportement des launchers :
Historique des 20 dernières publications : `maintenance-history/` dans les données privées. Audit : `maintenance.enable`,
`maintenance.disable`, `maintenance.update` (message modifié pendant la maintenance).
## 3 undecies. Comptes joueurs : préparation (VPS)
> **Ne pas activer en production** avant la fin de l'étape 8g (launcher, OTP, menu d'administration, textes RGPD,
> nginx). Cette étape sert à préparer le VPS ou à essayer le serveur sur un VPS de test. Architecture :
> `docs/PLAYER-ACCOUNTS-ARCHITECTURE.md`.
Les comptes joueurs (adresse e-mail + mot de passe) sont stockés dans `/var/lib/launcher-api/players/players.db`
(SQLite, 600) : adresses **chiffrées**, mots de passe hachés (Argon2id + « poivre »). Toutes les clés dérivent d'une
**clé maîtresse** gardée **hors** du dossier des données, chiffrée par systemd (clé propre à ce VPS).
1. **Clé maîtresse** (une seule fois). Elle s'affiche une fois pour que tu en gardes une copie **hors ligne**
(gestionnaire de mots de passe) : perdue, les adresses sont illisibles et tous les mots de passe doivent être
réinitialisés ; sur un nouveau VPS, il faudra la rechiffrer avec `systemd-creds`.
```sh
sudo mkdir -p /etc/credstore.encrypted && sudo chmod 700 /etc/credstore.encrypted
KEY=$(sudo -u deploy /opt/launcher-api/GameLauncher.Server players-key)
echo "$KEY" # ← copie-la dans ton gestionnaire de mots de passe
printf '%s' "$KEY" | sudo systemd-creds encrypt --name=players-master-key - /etc/credstore.encrypted/players-master-key
unset KEY
```
2. **Complément systemd** : copie `server/deploy/launcher-api-players.conf` en
`/etc/systemd/system/launcher-api.service.d/players.conf`, puis `sudo systemctl daemon-reload`.
3. **Réglages** : dans `appsettings.Production.json`, section `LauncherApi` :
`"Players": { "Enabled": true, "TermsVersion": "2026-11" }` (version des conditions d'utilisation que les joueurs
acceptent). `"RegistrationEnabled": false` ferme la création de compte en gardant la connexion.
4. `sudo systemctl restart launcher-api` puis `journalctl -u launcher-api -n 20` : la ligne « Comptes joueurs activés »
doit apparaître. Clé absente, rangée dans le dossier des données ou lisible par d'autres comptes : le service refuse
de démarrer et l'explique dans le journal.
Sauvegarde : `players.db` contient des données personnelles chiffrées ; une sauvegarde cohérente se fait service arrêté
(ou, service en marche, avec `sqlite3 players.db ".backup /chemin"`, paquet `sqlite3`), et doit elle-même être chiffrée (étape 8g). Commandes de secours :
étape 9.
## 4. Créer ta clé d'administrateur (PC)
Double-clique `tools\admin-key.bat`. Choisis une phrase de passe (12 caractères minimum). Il crée :
@@ -586,6 +619,9 @@ pas, le script remet la précédente en place.
| Un administrateur a perdu tous ses PC | Il crée une nouvelle clé et t'envoie son `.pub`, puis `sudo -u deploy /opt/launcher-api/GameLauncher.Server add-key --admin <identifiant> --public-key /tmp/paul.pub` (vérifie l'empreinte avec lui) |
| Toutes tes clés perdues | Nouvelle clé (étape 4), puis `add-owner` (étape 5) ; révoque ensuite les anciennes clés |
| Plus personne ne peut gérer les groupes | Tout propriétaire le peut toujours (groupe système, toutes les permissions) ; sinon `add-owner` (étape 5) |
| Compte joueur : voir son état (sans son adresse) | `sudo -u deploy /opt/launcher-api/GameLauncher.Server players-show --ref PL-XXXX-XXXX` |
| Compte joueur suspendu (trop d'essais) ou bloqué | `sudo -u deploy /opt/launcher-api/GameLauncher.Server players-unlock --ref PL-XXXX-XXXX` |
| Compte joueur à désactiver / réactiver | `… players-disable --ref PL-XXXX-XXXX [--until AAAA-MM-JJ]` / `… players-enable --ref PL-XXXX-XXXX` |
Toujours `sudo -u deploy …` : lancé en root, l'outil refuse (les fichiers recréés appartiendraient à
root et le service ne pourrait plus les lire). Il affiche le dossier de données utilisé ; il lit les
@@ -633,6 +669,7 @@ recréer les administrateurs.
├─ feedback-forms-history/ 20 dernières révisions publiées
├─ maintenance.json (+ .bak) mode maintenance publié (référence de la révision)
├─ maintenance-history/ 20 dernières révisions publiées
├─ players/players.db (+ -wal, -shm) comptes joueurs (SQLite, 600 ; adresses chiffrées, mots de passe hachés)
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
/srv/exovival/www/catalog/ (exemple) dossier public servi par nginx (deploy:www-data, 2750)
├─ catalog.json catalogue lu par les launchers (écrit par le service, 644)
+11
View File
@@ -0,0 +1,11 @@
# =============================================================================
# Comptes joueurs (phase 8) : clé maîtresse transmise au service par systemd.
# /etc/systemd/system/launcher-api.service.d/players.conf
#
# La clé est chiffrée sur le disque (systemd-creds, clé de l'hôte) dans /etc/credstore.encrypted (root, 700) ;
# systemd la déchiffre au démarrage dans un dossier en mémoire visible du seul service ($CREDENTIALS_DIRECTORY).
# Elle n'est jamais dans /var/lib/launcher-api : une copie des données ou des sauvegardes ne la contient pas.
# Mise en place : server/deploy/LISEZMOI.md, étape 3 undecies. Sans ce fichier, laisser Players:Enabled à false.
# =============================================================================
[Service]
LoadCredentialEncrypted=players-master-key:/etc/credstore.encrypted/players-master-key
@@ -0,0 +1,319 @@
using System.Globalization;
using System.IO.Compression;
using System.Text;
namespace GameLauncher.Contracts.Accounts;
/// <summary>
/// Règles des mots de passe des comptes joueurs, appliquées À L'IDENTIQUE par le launcher (retour immédiat pendant la
/// saisie) et par le serveur (seul juge). Inspirées de la recommandation CNIL n° 2022-100 et de l'OWASP :
/// • 12 caractères au moins avec 3 types parmi minuscules, majuscules, chiffres, symboles ; ou 16 caractères au moins
/// sans contrainte (phrase de passe) ; 128 au plus ;
/// • refus des mots de passe les plus utilisés (dictionnaire public de 100 000 mots de passe) et de leurs variantes
/// simples (« P@ssw0rd2024! ») ;
/// • refus des suites triviales (répétitions, suites de clavier ou de chiffres) ;
/// • refus d'un mot de passe tiré de l'adresse e-mail ou du nom du jeu / du studio.
/// Le mot de passe est contrôlé et haché TEL QU'IL EST SAISI (aucune normalisation Unicode) : le serveur tourne en mode
/// « globalisation invariante » (sans ICU), où la normalisation n'est pas disponible ; launcher et serveur appliquent
/// donc exactement le même calcul. Les claviers Windows produisent des caractères accentués déjà composés.
/// </summary>
public static class PasswordRules
{
/// <summary>Version des règles (annoncée par le serveur ; changer si les règles évoluent).</summary>
public const string Version = "1";
public const int MinLength = 12;
public const int MinLengthWithoutComposition = 16;
public const int MaxLength = 128;
public const int RequiredClasses = 3;
/// <summary>Taille maximale d'un mot de passe en UTF-8 (128 caractères de 4 octets).</summary>
public const int MaxUtf8Bytes = 512;
/// <summary>Longueur minimale d'un « cœur » comparé au dictionnaire (en dessous, la comparaison n'a pas de sens).</summary>
private const int MinCoreLength = 4;
/// <summary>Après retrait de l'adresse e-mail ou du nom du jeu, il doit rester au moins ce nombre de caractères.</summary>
private const int MinRemainingLength = 8;
/// <summary>Mots propres au studio et aux jeux : un mot de passe construit dessus est refusé.</summary>
private static readonly string[] ProductWords =
{
"lanisysgames", "lanisys", "gamelauncher", "launcher", "exovivalzombiereborn", "exovivalzombie", "exovival",
"zombiereborn", "zombie"
};
/// <summary>Mots de passe français très courants (complément du dictionnaire, majoritairement anglophone).</summary>
private static readonly string[] FrenchWords =
{
"azerty", "azertyuiop", "motdepasse", "monmotdepasse", "soleil", "doudou", "loulou", "chouchou", "bonjour",
"coucou", "jetaime", "marseille", "paris", "camille", "chocolat", "princesse", "nicolas", "julien", "doudoune",
"bisounours", "vacances", "football", "olympique", "toulouse", "bordeaux", "nantes", "liberte", "france",
"motdepasse123", "123soleil", "chaton", "cheval", "minou", "nounours", "tresor", "amour", "ordinateur"
};
/// <summary>Rangées de claviers (AZERTY, QWERTY, QWERTZ) et suites courantes.</summary>
private static readonly string[] Sequences =
{
"azertyuiop", "qsdfghjklm", "wxcvbn", "qwertyuiop", "asdfghjkl", "zxcvbnm", "qwertzuiop", "yxcvbnm",
"1234567890", "0123456789", "abcdefghijklmnopqrstuvwxyz", "&é\"'(-è_çà", "azertyuiopqsdfghjklmwxcvbn",
"qwertyuiopasdfghjklzxcvbnm", "1qaz2wsx3edc4rfv", "1a2z3e4r5t6y", "a1z2e3r4t5y6"
};
/// <summary>
/// Erreurs du mot de passe (messages en français, prêts à afficher). Liste vide : mot de passe accepté.
/// <paramref name="email"/> : adresse du compte (facultative), pour refuser un mot de passe qui en est tiré.
/// </summary>
public static IReadOnlyList<string> Check(string? password, string? email = null)
{
var errors = new List<string>();
if (string.IsNullOrEmpty(password))
{
errors.Add("Le mot de passe est obligatoire.");
return errors;
}
var normalized = password;
var length = RuneCount(normalized);
if (normalized.Any(char.IsControl) || HasInvalidUnicode(normalized))
errors.Add("Le mot de passe contient un caractère non autorisé (retour à la ligne, tabulation…).");
if (length > MaxLength || Encoding.UTF8.GetByteCount(normalized) > MaxUtf8Bytes)
{
errors.Add($"Le mot de passe ne doit pas dépasser {MaxLength} caractères.");
return errors;
}
if (length < MinLength)
{
errors.Add($"Le mot de passe doit contenir au moins {MinLength} caractères.");
return errors;
}
if (length < MinLengthWithoutComposition && CountClasses(normalized) < RequiredClasses)
errors.Add($"Entre {MinLength} et {MinLengthWithoutComposition - 1} caractères, le mot de passe doit mélanger au moins " +
$"{RequiredClasses} types de caractères parmi : minuscules, majuscules, chiffres, symboles. " +
$"Vous pouvez aussi choisir une phrase de passe d'au moins {MinLengthWithoutComposition} caractères.");
if (errors.Count > 0) return errors;
var lower = normalized.ToLowerInvariant();
if (IsTrivial(lower))
errors.Add("Le mot de passe est une suite trop simple (répétition, suite de clavier, de lettres ou de chiffres).");
else if (IsCommon(lower))
errors.Add("Ce mot de passe fait partie des mots de passe les plus utilisés (ou en est une simple variante) : " +
"choisissez-en un autre.");
else if (IsDerivedFrom(lower, email))
errors.Add("Le mot de passe ressemble trop à votre adresse e-mail ou au nom du jeu : choisissez-en un autre.");
return errors;
}
/// <summary>Vrai si le mot de passe est accepté.</summary>
public static bool IsAcceptable(string? password, string? email = null) => Check(password, email).Count == 0;
// ------------------------------------------------------------------ Composition
private static int RuneCount(string text)
{
var count = 0;
foreach (var _ in text.EnumerateRunes()) count++;
return count;
}
/// <summary>Demi-caractère (surrogate) isolé : texte mal formé.</summary>
private static bool HasInvalidUnicode(string text)
{
for (var i = 0; i < text.Length; i++)
{
if (char.IsHighSurrogate(text[i]))
{
if (i + 1 >= text.Length || !char.IsLowSurrogate(text[i + 1])) return true;
i++;
}
else if (char.IsLowSurrogate(text[i]))
{
return true;
}
}
return false;
}
private static int CountClasses(string text)
{
bool lower = false, upper = false, digit = false, other = false;
foreach (var rune in text.EnumerateRunes())
{
if (Rune.IsLower(rune)) lower = true;
else if (Rune.IsUpper(rune)) upper = true;
else if (Rune.IsDigit(rune)) digit = true;
else other = true; // symboles, espaces, lettres sans casse
}
return (lower ? 1 : 0) + (upper ? 1 : 0) + (digit ? 1 : 0) + (other ? 1 : 0);
}
// ------------------------------------------------------------------ Suites triviales
private static bool IsTrivial(string lower)
{
var compact = new string(lower.Where(c => !char.IsWhiteSpace(c)).ToArray());
if (compact.Distinct().Count() <= 3) return true; // « aaaaaaaaaaaa », « abababababab », « 121212121212 »
foreach (var candidate in new[] { compact, Core(compact), Leet(Core(compact)) })
{
if (candidate.Length < MinCoreLength) continue;
if (IsRepeatedPattern(candidate)) return true;
foreach (var sequence in Sequences)
{
if (IsInsideRepeated(candidate, sequence) || IsInsideRepeated(candidate, Reverse(sequence))) return true;
}
}
return false;
}
/// <summary>Texte formé d'un même motif répété (« azeazeazeaze », « 123412341234 »).</summary>
private static bool IsRepeatedPattern(string text)
{
for (var size = 1; size <= text.Length / 2; size++)
{
if (text.Length % size != 0) continue;
var pattern = text[..size];
var repeated = true;
for (var i = size; i < text.Length && repeated; i += size)
repeated = string.CompareOrdinal(text, i, pattern, 0, size) == 0;
if (repeated) return true;
}
return false;
}
/// <summary>Le texte est contenu dans la suite répétée (« 345678901234 » dans « 1234567890 » × 3).</summary>
private static bool IsInsideRepeated(string text, string sequence)
{
var builder = new StringBuilder();
while (builder.Length < text.Length + sequence.Length) builder.Append(sequence);
return builder.ToString().Contains(text, StringComparison.Ordinal);
}
private static string Reverse(string text)
{
var chars = text.ToCharArray();
Array.Reverse(chars);
return new string(chars);
}
// ------------------------------------------------------------------ Dictionnaire
private static bool IsCommon(string lower)
{
var words = Blocklist.Value;
foreach (var candidate in Candidates(lower))
if (candidate.Length >= MinCoreLength && words.Contains(candidate)) return true;
return false;
}
/// <summary>Formes comparées au dictionnaire : telle quelle, sans chiffres/symboles au début et à la fin, puis
/// avec les substitutions courantes (« p@ssw0rd » → « password »).</summary>
private static IEnumerable<string> Candidates(string lower)
{
yield return lower;
var compact = new string(lower.Where(c => !char.IsWhiteSpace(c)).ToArray());
yield return compact;
var core = Core(compact);
yield return core;
yield return Leet(core);
yield return Core(Leet(compact));
}
/// <summary>Retire chiffres, espaces et symboles au début et à la fin (« !!dragon2024 » → « dragon »).</summary>
internal static string Core(string text)
{
var start = 0;
var end = text.Length;
while (start < end && !char.IsLetter(text[start])) start++;
while (end > start && !char.IsLetter(text[end - 1])) end--;
return text[start..end];
}
/// <summary>Substitutions courantes chiffres/symboles → lettres.</summary>
internal static string Leet(string text)
{
var chars = text.ToCharArray();
for (var i = 0; i < chars.Length; i++)
{
chars[i] = chars[i] switch
{
'0' => 'o',
'1' or '!' or '|' => 'i',
'3' or '€' => 'e',
'4' or '@' => 'a',
'5' or '$' => 's',
'7' or '+' => 't',
'8' => 'b',
'9' => 'g',
_ => chars[i]
};
}
return new string(chars);
}
/// <summary>
/// Dictionnaire chargé à la première utilisation (ressource intégrée, ~100 000 entrées) : chaque entrée est gardée
/// en minuscules et sous sa forme normalisée (sans chiffres/symboles aux extrémités, substitutions retirées).
/// </summary>
private static readonly Lazy<HashSet<string>> Blocklist = new(LoadBlocklist, LazyThreadSafetyMode.ExecutionAndPublication);
/// <summary>Nombre de formes connues du dictionnaire (diagnostic et tests ; le charge s'il ne l'est pas encore).</summary>
public static int BlocklistSize => Blocklist.Value.Count;
private static HashSet<string> LoadBlocklist()
{
var set = new HashSet<string>(StringComparer.Ordinal);
void Add(string word)
{
var lower = word.ToLowerInvariant();
if (lower.Length >= MinCoreLength) set.Add(lower);
var core = Leet(Core(lower));
if (core.Length >= MinCoreLength) set.Add(core);
}
using (var stream = typeof(PasswordRules).Assembly.GetManifestResourceStream(BlocklistResource)
?? throw new InvalidOperationException("Dictionnaire des mots de passe absent de GameLauncher.Contracts."))
using (var gzip = new GZipStream(stream, CompressionMode.Decompress))
using (var reader = new StreamReader(gzip, Encoding.UTF8))
{
while (reader.ReadLine() is { } line)
if (line.Length > 0) Add(line);
}
foreach (var word in FrenchWords) Add(word);
return set; // les mots du studio et des jeux sont traités à part (IsDerivedFrom), avec un message plus précis
}
internal const string BlocklistResource = "GameLauncher.Contracts.Accounts.password-blocklist.txt.gz";
// ------------------------------------------------------------------ Adresse e-mail et nom du jeu
private static bool IsDerivedFrom(string lower, string? email)
{
var words = new List<string>(ProductWords);
if (PlayerEmail.Normalize(email) is { } normalized)
{
var at = normalized.IndexOf('@');
var local = normalized[..at];
words.Add(local);
words.AddRange(local.Split('.', '_', '-', '+').Where(p => p.Length >= 3));
var domainLabel = normalized[(at + 1)..].Split('.')[0];
if (domainLabel.Length >= 4) words.Add(domainLabel);
}
foreach (var form in new[] { lower, Leet(lower) })
{
var remaining = new string(form.Where(char.IsLetterOrDigit).ToArray());
var removedAny = false;
foreach (var word in words.Where(w => w.Length >= 3).OrderByDescending(w => w.Length))
{
var clean = new string(word.Where(char.IsLetterOrDigit).ToArray());
if (clean.Length < 3 || !remaining.Contains(clean, StringComparison.Ordinal)) continue;
remaining = remaining.Replace(clean, "", StringComparison.Ordinal);
removedAny = true;
}
if (removedAny && remaining.Length < MinRemainingLength) return true;
}
return false;
}
}
@@ -0,0 +1,196 @@
namespace GameLauncher.Contracts.Accounts;
// Comptes joueurs (phase 8). Routes sous « v1/account/… » : aucune n'est une route d'administration, et aucune route
// d'administration n'accepte un jeton joueur. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.
/// <summary>État du service des comptes, annoncé dans GET v1/info (null : serveur sans comptes joueurs).</summary>
public sealed record PlayerAccountsInfo
{
/// <summary>Connexion possible (le launcher affiche le bouton « Compte »).</summary>
public bool Enabled { get; init; }
/// <summary>Création de compte ouverte.</summary>
public bool RegistrationOpen { get; init; }
/// <summary>Version des conditions d'utilisation et de la politique de confidentialité à accepter à la création.</summary>
public string TermsVersion { get; init; } = "";
/// <summary>Version des règles des mots de passe (<see cref="PasswordRules.Version"/>).</summary>
public string PasswordRulesVersion { get; init; } = "";
}
/// <summary>Demande d'un défi (POST v1/account/challenge).</summary>
public sealed record PlayerChallengeRequest
{
/// <summary>Une valeur de <see cref="PlayerChallengePurposes"/>.</summary>
public string Purpose { get; init; } = "";
}
/// <summary>
/// Défi joueur, SANS ÉTAT sur le serveur, à usage unique : il porte l'usage, l'échéance et la difficulté de la preuve de
/// travail (0 : aucune, renouvellement de session). Le launcher résout la preuve de travail
/// (<see cref="Feedback.FeedbackProof"/>) puis signe <see cref="PlayerMessages.Device"/> avec sa clé d'appareil.
/// </summary>
public sealed record PlayerChallengeResponse
{
public string ChallengeId { get; init; } = "";
public string Nonce { get; init; } = "";
public string Purpose { get; init; } = "";
public int Difficulty { get; init; }
public string ServerId { get; init; } = "";
public string Origin { get; init; } = "";
public DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>Preuve de possession de la clé d'appareil (création de compte, connexion).</summary>
public sealed record PlayerDeviceProof
{
/// <summary>Clé publique de l'appareil (SPKI ECDSA P-256, Base64).</summary>
public string PublicKey { get; init; } = "";
/// <summary>Signature (Base64, 64 octets) de <see cref="PlayerMessages.Device"/>.</summary>
public string Signature { get; init; } = "";
/// <summary>Nom générique de l'appareil (« Windows 11 · GameLauncher 3.4.0 »), jamais le nom Windows du PC.</summary>
public string? Label { get; init; }
}
/// <summary>Création de compte (POST v1/account/register).</summary>
public sealed record PlayerRegisterRequest
{
public string Email { get; init; } = "";
public string Password { get; init; } = "";
public string ChallengeId { get; init; } = "";
/// <summary>Solution de la preuve de travail du défi.</summary>
public string Solution { get; init; } = "";
/// <summary>Version des conditions acceptées par le joueur (doit être celle annoncée par le serveur).</summary>
public string AcceptedTermsVersion { get; init; } = "";
public PlayerDeviceProof Device { get; init; } = new();
}
/// <summary>Connexion (POST v1/account/login).</summary>
public sealed record PlayerLoginRequest
{
public string Email { get; init; } = "";
public string Password { get; init; } = "";
public string ChallengeId { get; init; } = "";
public string Solution { get; init; } = "";
public PlayerDeviceProof Device { get; init; } = new();
}
/// <summary>Renouvellement de session au lancement du launcher (POST v1/account/refresh).</summary>
public sealed record PlayerRefreshRequest
{
public string RefreshToken { get; init; } = "";
public string ChallengeId { get; init; } = "";
/// <summary>Signature (Base64) de <see cref="PlayerMessages.Device"/> (usage « refresh ») par la clé de l'appareil.</summary>
public string Signature { get; init; } = "";
}
/// <summary>Résultats possibles d'une connexion réussie (mot de passe correct).</summary>
public static class PlayerLoginStatus
{
/// <summary>Connecté : <see cref="PlayerLoginResponse.Session"/>.</summary>
public const string Ok = "ok";
/// <summary>
/// Un administrateur exige un nouveau mot de passe : <see cref="PlayerLoginResponse.PasswordChangeToken"/> permet
/// seulement d'appeler POST v1/account/password ; la session est ouverte une fois le mot de passe changé.
/// </summary>
public const string PasswordChangeRequired = "password_change_required";
}
public sealed record PlayerLoginResponse
{
public string Status { get; init; } = PlayerLoginStatus.Ok;
public PlayerSession? Session { get; init; }
public string? PasswordChangeToken { get; init; }
public DateTimeOffset? PasswordChangeExpiresAt { get; init; }
}
/// <summary>Session ouverte (création de compte, connexion, renouvellement, changement de mot de passe exigé).</summary>
public sealed record PlayerSession
{
/// <summary>Jeton d'accès (« Authorization: Bearer … »), court, à garder en mémoire uniquement.</summary>
public string AccessToken { get; init; } = "";
public DateTimeOffset AccessExpiresAt { get; init; }
/// <summary>
/// Jeton de renouvellement (connexion mémorisée), remplacé à chaque renouvellement : à enregistrer chiffré sur le PC
/// (DPAPI) à la place du précédent. Inutilisable sans la clé de l'appareil.
/// </summary>
public string RefreshToken { get; init; } = "";
/// <summary>Échéance du jeton de renouvellement (sans utilisation ; repoussée à chaque renouvellement, dans la limite d'un an).</summary>
public DateTimeOffset RefreshExpiresAt { get; init; }
public string DeviceId { get; init; } = "";
public PlayerAccountInfo Account { get; init; } = new();
}
/// <summary>Compte du joueur connecté (adresse masquée : l'adresse complète n'est jamais renvoyée).</summary>
public sealed record PlayerAccountInfo
{
/// <summary>Référence du compte (« PL-7KQ2-M9XD »), à communiquer au support.</summary>
public string Reference { get; init; } = "";
public string EmailMasked { get; init; } = "";
public bool EmailVerified { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastLoginAt { get; init; }
public DateTimeOffset PasswordChangedAt { get; init; }
public bool OtpEnabled { get; init; }
/// <summary>
/// Suspension de sécurité après de nombreux essais de mot de passe : les connexions depuis un NOUVEL appareil sont
/// refusées jusqu'à ce qu'un administrateur la lève ; cet appareil-ci (déjà connecté) continue de fonctionner.
/// </summary>
public bool NewDevicesSuspended { get; init; }
}
/// <summary>Un appareil connecté au compte (« Mes appareils »).</summary>
public sealed record PlayerDeviceInfo
{
public string Id { get; init; } = "";
public string Label { get; init; } = "";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset LastUsedAt { get; init; }
/// <summary>Appareil de la session en cours.</summary>
public bool Current { get; init; }
}
/// <summary>GET v1/account/me.</summary>
public sealed record PlayerMeResponse
{
public PlayerAccountInfo Account { get; init; } = new();
public IReadOnlyList<PlayerDeviceInfo> Devices { get; init; } = Array.Empty<PlayerDeviceInfo>();
}
/// <summary>Changement de mot de passe (POST v1/account/password), avec une session ou un jeton « changement exigé ».</summary>
public sealed record PlayerChangePasswordRequest
{
public string CurrentPassword { get; init; } = "";
public string NewPassword { get; init; } = "";
}
public sealed record PlayerChangePasswordResponse
{
/// <summary>Session ouverte après un changement EXIGÉ (jeton limité) ; null pour un changement ordinaire.</summary>
public PlayerSession? Session { get; init; }
/// <summary>Nombre d'autres appareils déconnectés.</summary>
public int DevicesSignedOut { get; init; }
}
/// <summary>Déconnexion (POST v1/account/logout).</summary>
public sealed record PlayerLogoutRequest
{
/// <summary>Vrai : tous les appareils du compte (« Déconnecter tous mes appareils »).</summary>
public bool AllDevices { get; init; }
}
@@ -0,0 +1,75 @@
namespace GameLauncher.Contracts.Accounts;
/// <summary>
/// Adresse e-mail d'un compte joueur : contrôle et forme normalisée (launcher et serveur). La forme normalisée sert
/// d'identifiant de connexion : une même adresse saisie en majuscules ou avec des espaces donne le même compte.
/// Règles volontairement simples et strictes : caractères latins sans accents (domaines internationaux sous leur forme
/// « xn-- »), un seul « @ », 254 caractères au plus.
/// </summary>
public static class PlayerEmail
{
public const int MaxLength = 254;
public const int MaxLocalLength = 64;
private const string LocalSymbols = "!#$%&'*+-/=?^_`{|}~.";
/// <summary>Adresse normalisée (minuscules, sans espaces autour), ou null si elle n'est pas valable.</summary>
public static string? Normalize(string? input)
{
if (string.IsNullOrWhiteSpace(input) || input.Length > MaxLength + 16) return null;
// Pas de normalisation Unicode (indisponible sur le serveur en globalisation invariante) : seuls les caractères
// ASCII sont acceptés, de la même façon par le launcher et par le serveur.
var text = input.Trim().ToLowerInvariant();
if (text.Length > MaxLength) return null;
var at = text.IndexOf('@');
if (at <= 0 || at != text.LastIndexOf('@') || at == text.Length - 1) return null;
var local = text[..at];
var domain = text[(at + 1)..];
if (domain.EndsWith('.')) domain = domain[..^1];
if (!IsValidLocal(local) || !IsValidDomain(domain)) return null;
var result = local + "@" + domain;
return result.Length <= MaxLength ? result : null;
}
public static bool IsValid(string? input) => Normalize(input) is not null;
/// <summary>
/// Adresse masquée pour l'affichage (« n***@e***.fr ») : première lettre de la partie locale et du domaine,
/// extension conservée. Chaîne vide si l'adresse n'est pas valable.
/// </summary>
public static string Mask(string? email)
{
if (Normalize(email) is not { } normalized) return "";
var at = normalized.IndexOf('@');
var local = normalized[..at];
var domain = normalized[(at + 1)..];
var lastDot = domain.LastIndexOf('.');
return $"{local[0]}***@{domain[0]}***{domain[lastDot..]}";
}
private static bool IsValidLocal(string local)
{
if (local.Length is 0 or > MaxLocalLength) return false;
if (local[0] == '.' || local[^1] == '.' || local.Contains("..", StringComparison.Ordinal)) return false;
foreach (var c in local)
if (!(c is >= 'a' and <= 'z' or >= '0' and <= '9' || LocalSymbols.Contains(c))) return false;
return true;
}
private static bool IsValidDomain(string domain)
{
if (domain.Length is 0 or > 253) return false;
var labels = domain.Split('.');
if (labels.Length < 2) return false;
foreach (var label in labels)
{
if (label.Length is 0 or > 63 || label[0] == '-' || label[^1] == '-') return false;
foreach (var c in label)
if (!(c is >= 'a' and <= 'z' or >= '0' and <= '9' or '-')) return false;
}
var tld = labels[^1];
return tld.StartsWith("xn--", StringComparison.Ordinal) || (tld.Length >= 2 && tld.All(c => c is >= 'a' and <= 'z'));
}
}
@@ -0,0 +1,111 @@
using System.Security.Cryptography;
using GameLauncher.Contracts.Security;
namespace GameLauncher.Contracts.Accounts;
/// <summary>
/// Messages signés par la CLÉ D'APPAREIL d'un joueur (ECDSA P-256 créée par le launcher, dans la puce TPM si elle existe).
/// En-tête propre aux joueurs (« GameLauncher/Player/… ») : une signature de joueur ne vaut jamais pour un message
/// d'administrateur (« GameLauncher/Admin/… »), et inversement.
///
/// La signature prouve que la requête vient de l'appareil qui possède la clé : un fichier de session copié sur un autre PC
/// ne permet pas de renouveler la session, et seul un appareil déjà utilisé avec succès est reconnu « de confiance » par
/// la protection contre la force brute.
/// </summary>
public static class PlayerMessages
{
public const string DeviceHeader = "GameLauncher/Player/Device/v1";
/// <summary>Message signé par la clé d'appareil pour une création de compte, une connexion ou un renouvellement.</summary>
/// <param name="purpose">Une valeur de <see cref="PlayerChallengePurposes"/>.</param>
/// <param name="deviceKeyId">Identifiant de la clé d'appareil (<see cref="AdminKeys.ComputeKeyId"/>).</param>
public static byte[] Device(string purpose, string origin, string serverId, string challengeId, string nonce, string deviceKeyId) =>
SignedMessages.Build(DeviceHeader,
("purpose", purpose), ("origin", origin), ("server", serverId), ("challenge", challengeId), ("nonce", nonce),
("device", deviceKeyId));
}
/// <summary>Usages d'un défi joueur (le défi est scellé pour un seul usage).</summary>
public static class PlayerChallengePurposes
{
public const string Register = "register";
public const string Login = "login";
public const string Refresh = "refresh";
public static bool IsValid(string? purpose) => purpose is Register or Login or Refresh;
}
/// <summary>
/// Formats des jetons joueurs. Préfixes distincts de tout jeton administrateur : le serveur rejette un jeton joueur
/// présenté à une route d'administration sans même le chercher, et inversement.
/// </summary>
public static class PlayerTokens
{
/// <summary>Jeton d'accès (15 min, en mémoire seulement côté launcher) : « glpa_ » + 256 bits.</summary>
public const string AccessPrefix = "glpa_";
/// <summary>Jeton de renouvellement (gardé chiffré sur le PC) : « glpr_ » + identifiant d'appareil + « . » + 256 bits.</summary>
public const string RefreshPrefix = "glpr_";
/// <summary>Jeton limité au changement de mot de passe exigé (10 min) : « glpc_ » + 256 bits.</summary>
public const string PasswordChangePrefix = "glpc_";
public const int MaxLength = 128;
public static string NewSecret() => Base64Url.Encode(RandomNumberGenerator.GetBytes(32));
/// <summary>Jeton de renouvellement : identifiant de l'appareil (32 hex) et secret, ou faux si mal formé.</summary>
public static bool TryParseRefresh(string? token, out string deviceId, out string secret)
{
deviceId = secret = "";
if (token is not { Length: > 40 and <= MaxLength } || !token.StartsWith(RefreshPrefix, StringComparison.Ordinal)) return false;
var body = token[RefreshPrefix.Length..];
var dot = body.IndexOf('.');
if (dot != 32) return false;
deviceId = body[..dot];
secret = body[(dot + 1)..];
return PlayerIds.IsValidId(deviceId) && Base64Url.TryDecode(secret, out var bytes) && bytes.Length == 32;
}
public static string FormatRefresh(string deviceId, string secret) => RefreshPrefix + deviceId + "." + secret;
}
/// <summary>Identifiants des comptes joueurs.</summary>
public static class PlayerIds
{
private const string Crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/// <summary>Identifiant interne (compte, appareil) : 128 bits en hexadécimal minuscule.</summary>
public static string NewId() => Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
public static bool IsValidId(string? id) => id is { Length: 32 } && id.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f');
/// <summary>
/// Référence affichée aux administrateurs et au joueur (« PL-7KQ2-M9XD », 40 bits, sans lettre ambiguë) : désigne un
/// compte dans le journal d'audit sans jamais y écrire l'adresse e-mail.
/// </summary>
public static string NewReference()
{
var bytes = RandomNumberGenerator.GetBytes(5);
ulong value = 0;
foreach (var b in bytes) value = (value << 8) | b;
var chars = new char[8];
for (var i = 7; i >= 0; i--)
{
chars[i] = Crockford[(int)(value & 31)];
value >>= 5;
}
return $"PL-{new string(chars, 0, 4)}-{new string(chars, 4, 4)}";
}
/// <summary>Référence normalisée (majuscules, tirets), ou null si elle n'est pas valable.</summary>
public static string? NormalizeReference(string? reference)
{
if (string.IsNullOrWhiteSpace(reference) || reference.Length > 20) return null;
var text = reference.Trim().ToUpperInvariant();
if (text.Length != 12 || !text.StartsWith("PL-", StringComparison.Ordinal) || text[7] != '-') return null;
foreach (var c in text[3..7] + text[8..])
if (!Crockford.Contains(c)) return null;
return text;
}
}
@@ -0,0 +1,19 @@
password-blocklist.txt.gz — dictionnaire des mots de passe les plus utilisés
=========================================================================
Contenu : les 99 839 mots de passe de la liste « 100k-most-used-passwords-NCSC.txt » (un par ligne, UTF-8, doublons
retirés, ordre d'origine conservé), compressés en gzip.
Origine :
• Liste publiée par le NCSC (National Cyber Security Centre, Royaume-Uni) : les 100 000 mots de passe les plus
fréquents dans les fuites recensées par le service « Have I Been Pwned » (Pwned Passwords).
• Copie utilisée : dépôt SecLists (https://github.com/danielmiessler/SecLists),
fichier Passwords/Common-Credentials/100k-most-used-passwords-NCSC.txt — licence MIT,
© 2018 Daniel Miessler.
Usage : GameLauncher.Contracts.Accounts.PasswordRules (même contrôle dans le launcher et sur le serveur). La liste est
normalisée au chargement (minuscules, substitutions courantes 0→o, 4→a, @→a…, chiffres et symboles de début et de fin
retirés) : les variantes simples (« P@ssw0rd2024! ») sont refusées comme l'original.
Mise à jour : remplacer le fichier .gz par une version plus récente de la même liste (même format), puis relancer les
tests (Contracts/PasswordRulesTests).
+29
View File
@@ -151,6 +151,35 @@ public static class ApiRoutes
/// <summary>Mode maintenance : lecture (tout administrateur connecté), publication signée (PUT, maintenance.manage).</summary>
public const string Maintenance = "v1/maintenance";
// ------------------------------------------------------------------ Comptes joueurs (phase 8)
// Routes des JOUEURS (jetons « glpa_ ») : aucune n'est une route d'administration, et les routes d'administration
// n'acceptent jamais un jeton joueur. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.
/// <summary>Défi joueur (création de compte, connexion, renouvellement) : anonyme, sans état sur le serveur.</summary>
public const string AccountChallenge = "v1/account/challenge";
/// <summary>Création de compte (anonyme : preuve de travail + clé d'appareil).</summary>
public const string AccountRegister = "v1/account/register";
/// <summary>Connexion par adresse e-mail et mot de passe (anonyme : preuve de travail + clé d'appareil).</summary>
public const string AccountLogin = "v1/account/login";
/// <summary>Renouvellement de la session mémorisée (jeton de renouvellement + signature de la clé d'appareil).</summary>
public const string AccountRefresh = "v1/account/refresh";
public const string AccountLogout = "v1/account/logout";
/// <summary>Compte connecté et ses appareils.</summary>
public const string AccountMe = "v1/account/me";
/// <summary>Changement de mot de passe (session, ou jeton « changement exigé »).</summary>
public const string AccountPassword = "v1/account/password";
public const string AccountDevicePattern = "v1/account/devices/{deviceId}";
/// <summary>Un appareil du compte (DELETE : le déconnecter). Identifiant : 32 caractères hexadécimaux.</summary>
public static string AccountDevice(string deviceId) => $"v1/account/devices/{deviceId}";
// ------------------------------------------------------------------ Publication d'une version
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
public const string PublicationPattern = "v1/publications/{id}";
@@ -15,6 +15,9 @@ public sealed record ServerInfo
/// <summary>Algorithmes de clé acceptés (« ECDSA-P256-SHA256 »).</summary>
public IReadOnlyList<string> KeyAlgorithms { get; init; } = Array.Empty<string>();
/// <summary>Comptes joueurs (phase 8) : null si le serveur ne les propose pas (ou serveur plus ancien).</summary>
public Accounts.PlayerAccountsInfo? Accounts { get; init; }
}
public sealed record HealthStatus
@@ -77,4 +80,43 @@ public static class ApiErrorCodes
/// <summary>Demandes des joueurs : défi anti-spam absent, expiré, déjà utilisé ou mal résolu.</summary>
public const string ProofInvalid = "proof_invalid";
// ------------------------------------------------------------------ Comptes joueurs (phase 8)
/// <summary>Adresse e-mail mal formée.</summary>
public const string EmailInvalid = "email_invalid";
/// <summary>Adresse déjà utilisée par un autre compte (création de compte).</summary>
public const string EmailTaken = "email_taken";
/// <summary>Mot de passe refusé par <see cref="Accounts.PasswordRules"/> (message : les raisons).</summary>
public const string PasswordRejected = "password_rejected";
/// <summary>Adresse ou mot de passe incorrect (message volontairement identique dans les deux cas).</summary>
public const string LoginFailed = "login_failed";
/// <summary>Trop d'essais : connexion bloquée temporairement (en-tête Retry-After).</summary>
public const string LoginLocked = "login_locked";
/// <summary>Compte désactivé par un administrateur (révélé seulement après un mot de passe correct).</summary>
public const string AccountDisabled = "account_disabled";
/// <summary>Compte suspendu après trop d'essais (connexion depuis un NOUVEL appareil refusée) : seul un administrateur
/// peut lever la suspension ; les appareils déjà connectés continuent de fonctionner.</summary>
public const string AccountSuspended = "account_suspended";
/// <summary>Un nouveau mot de passe est exigé avant toute autre action.</summary>
public const string PasswordChangeRequired = "password_change_required";
/// <summary>Session mémorisée révoquée, expirée ou réutilisée (vol suspecté) : nouvelle connexion nécessaire.</summary>
public const string SessionRevoked = "session_revoked";
/// <summary>Création de compte fermée, ou bloquée pour cette adresse IP / cet appareil.</summary>
public const string RegistrationClosed = "registration_closed";
/// <summary>Conditions d'utilisation non acceptées ou dans une version dépassée.</summary>
public const string TermsNotAccepted = "terms_not_accepted";
/// <summary>Preuve de possession de la clé d'appareil absente ou invalide.</summary>
public const string DeviceProofInvalid = "device_proof_invalid";
}
@@ -13,4 +13,9 @@
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<!-- Comptes joueurs : dictionnaire des mots de passe les plus utilisés (PasswordRules), voir Accounts\Resources\password-blocklist.SOURCE.txt. -->
<ItemGroup>
<EmbeddedResource Include="Accounts\Resources\password-blocklist.txt.gz" LogicalName="GameLauncher.Contracts.Accounts.password-blocklist.txt.gz" />
</ItemGroup>
</Project>
@@ -68,7 +68,7 @@ public static class SignedMessages
/// <summary>Origine normalisée d'une adresse : « https://hote[:port] », sans chemin ni barre finale.</summary>
public static string NormalizeOrigin(Uri uri) => uri.GetLeftPart(UriPartial.Authority).TrimEnd('/').ToLowerInvariant();
private static byte[] Build(string header, params (string Name, string Value)[] fields)
internal static byte[] Build(string header, params (string Name, string Value)[] fields)
{
var sb = new StringBuilder(header).Append('\n');
foreach (var (name, value) in fields)
@@ -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; } = "";
@@ -505,6 +505,9 @@ 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é",
+1 -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.4.0</Version>
<Version>3.4.2</Version>
<Authors>Lanisys</Authors>
<Company>LanisysGames</Company>
<Copyright>© LanisysGames</Copyright>
@@ -182,7 +182,8 @@ public class FeedbackPanelTests
public void Feedback_audit_actions_have_friendly_labels()
{
foreach (var action in new[] { "feedback.submit", "feedback.attachment", "feedback.view", "feedback.update", "feedback.delete",
"feedback.purge", "feedback.forms", "audit.purge", "keyring.publish", "release.sign" })
"feedback.purge", "feedback.forms", "feedback.form_create", "feedback.form_update",
"feedback.form_delete", "audit.purge", "keyring.publish", "release.sign" })
Assert.NotEqual(action, GameLauncher.Modules.Admin.Audit.AuditTexts.Action(action));
}
@@ -0,0 +1,168 @@
using GameLauncher.Contracts.Accounts;
namespace GameLauncher.Tests.Contracts;
/// <summary>Règles partagées des comptes joueurs : mots de passe, adresses e-mail, références, jetons.</summary>
public class PlayerAccountRulesTests
{
[Theory]
[InlineData("Tr0mbone-Lavande!")] // 17 caractères, 4 types
[InlineData("Vélo-Jaune-42-Cerf")] // accents
[InlineData("le chat dort sur la radio")] // phrase de passe, sans contrainte de types
[InlineData("Kq7#mP2x!Lz9")] // 12 caractères, 4 types
public void Strong_passwords_are_accepted(string password)
{
Assert.Empty(PasswordRules.Check(password, "joueur@example.fr"));
}
[Theory]
[InlineData("Court1!")] // moins de 12 caractères
[InlineData("toutenminuscule")] // 15 caractères, un seul type
[InlineData("Majusculelune")] // 13 caractères, deux types seulement
public void Short_or_uniform_passwords_are_refused(string password)
{
Assert.NotEmpty(PasswordRules.Check(password));
}
[Fact]
public void Twelve_characters_with_three_kinds_are_enough()
{
Assert.Empty(PasswordRules.Check("Majuscule-Lune")); // minuscules + majuscules + symbole
}
[Fact]
public void Sixteen_characters_need_no_composition()
{
Assert.Empty(PasswordRules.Check("TOUTENMAJUSCULEZ"));
Assert.NotEmpty(PasswordRules.Check("TOUTENMAJUSCULE")); // 15 : un seul type
}
[Theory]
[InlineData("Password1234!")]
[InlineData("P@ssw0rd2024!")]
[InlineData("Dragon2024!!")]
[InlineData("Football2024!")]
[InlineData("iloveyou2023!!")]
[InlineData("Motdepasse2026!")]
[InlineData("Soleil-2024-!!")]
public void Common_passwords_and_simple_variants_are_refused(string password)
{
var errors = PasswordRules.Check(password);
Assert.Single(errors);
Assert.Contains("les plus utilisés", errors[0]);
}
[Theory]
[InlineData("aaaaaaaaaaaaaaaa")]
[InlineData("abababababababab")]
[InlineData("1234567890123456")]
[InlineData("qwertyuiopasdfgh")]
[InlineData("azertyuiopqsdfgh")]
[InlineData("ZyxwvutsrqponmlK")]
[InlineData("azeazeazeazeazea")]
[InlineData("Azerty123456!")]
public void Trivial_sequences_are_refused(string password)
{
var errors = PasswordRules.Check(password);
Assert.Single(errors);
Assert.Contains("suite trop simple", errors[0]);
}
[Theory]
[InlineData("nicolas.deville1990", "nicolas.deville@example.fr")]
[InlineData("LanisysGames2026!", null)]
[InlineData("Exovival-Zombie-2026", null)]
public void Passwords_derived_from_the_email_or_the_game_are_refused(string password, string? email)
{
var errors = PasswordRules.Check(password, email);
Assert.Single(errors);
Assert.Contains("ressemble trop", errors[0]);
}
[Fact]
public void Control_characters_and_oversized_passwords_are_refused()
{
Assert.NotEmpty(PasswordRules.Check("Abcdef\n123456!"));
Assert.NotEmpty(PasswordRules.Check(new string('x', 120) + "Ab1!Cd2?Ef3"));
Assert.NotEmpty(PasswordRules.Check(""));
Assert.NotEmpty(PasswordRules.Check(null));
}
[Fact]
public void Blocklist_is_embedded_and_loaded()
{
Assert.True(PasswordRules.BlocklistSize > 90_000);
}
[Theory]
[InlineData(" Nicolas.Deville@Example.FR ", "nicolas.deville@example.fr")]
[InlineData("joueur+exovival@mail.example.com", "joueur+exovival@mail.example.com")]
[InlineData("a@b.co", "a@b.co")]
[InlineData("x@xn--caf-dma.fr", "x@xn--caf-dma.fr")]
public void Emails_are_normalized(string input, string expected)
{
Assert.Equal(expected, PlayerEmail.Normalize(input));
}
[Theory]
[InlineData("pas-une-adresse")]
[InlineData("a@b")]
[InlineData("a@@b.fr")]
[InlineData(".a@b.fr")]
[InlineData("a..b@c.fr")]
[InlineData("a b@c.fr")]
[InlineData("é@c.fr")]
[InlineData("joueur@example.fr")] // caractères « pleine chasse » : refusés (pas de normalisation Unicode)
[InlineData("a@-c.fr")]
[InlineData("a@c.f")]
[InlineData("a@c.123")]
[InlineData("")]
public void Invalid_emails_are_refused(string input)
{
Assert.Null(PlayerEmail.Normalize(input));
}
[Fact]
public void Email_is_masked_for_display()
{
Assert.Equal("n***@e***.fr", PlayerEmail.Mask("Nicolas@Example.fr"));
Assert.Equal("", PlayerEmail.Mask("invalide"));
}
[Fact]
public void References_are_random_and_normalizable()
{
var a = PlayerIds.NewReference();
var b = PlayerIds.NewReference();
Assert.NotEqual(a, b);
Assert.Matches("^PL-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$", a);
Assert.Equal(a, PlayerIds.NormalizeReference(a.ToLowerInvariant()));
Assert.Null(PlayerIds.NormalizeReference("PL-IIII-OOOO")); // lettres ambiguës exclues
Assert.Null(PlayerIds.NormalizeReference("XX-1234-5678"));
}
[Fact]
public void Refresh_tokens_carry_their_device_and_are_strictly_parsed()
{
var id = PlayerIds.NewId();
var secret = PlayerTokens.NewSecret();
var token = PlayerTokens.FormatRefresh(id, secret);
Assert.True(PlayerTokens.TryParseRefresh(token, out var parsedId, out var parsedSecret));
Assert.Equal(id, parsedId);
Assert.Equal(secret, parsedSecret);
Assert.False(PlayerTokens.TryParseRefresh(PlayerTokens.AccessPrefix + secret, out _, out _));
Assert.False(PlayerTokens.TryParseRefresh(PlayerTokens.RefreshPrefix + "zz" + id[2..] + "." + secret, out _, out _));
Assert.False(PlayerTokens.TryParseRefresh(PlayerTokens.RefreshPrefix + id + "." + secret[..10], out _, out _));
Assert.False(PlayerTokens.TryParseRefresh(null, out _, out _));
}
[Fact]
public void Device_message_is_domain_separated_from_admin_messages()
{
var player = System.Text.Encoding.UTF8.GetString(
PlayerMessages.Device(PlayerChallengePurposes.Login, "https://o", "srv", "c", "n", "0123456789abcdef"));
Assert.StartsWith(PlayerMessages.DeviceHeader + "\npurpose:login\n", player);
Assert.DoesNotContain("GameLauncher/Admin", player);
}
}
@@ -7,6 +7,7 @@
et attaquée comme le ferait un client réel ou malveillant.
• Admin : module d'administration du launcher (clés, connexion, invitation, verrouillage,
écrans) contre ce même serveur, sans interface graphique.
• Website : site vitrine (signature de launcher-update.json, manifests, liseuse Markdown).
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -35,6 +36,18 @@
<ProjectReference Include="..\..\src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj" />
</ItemGroup>
<!--
Site vitrine : ses services sont compilés ici plutôt que référencés. Une référence au projet du site
copierait son appsettings.json dans le dossier des tests, à la place de celui de la Launcher API
(même nom de fichier), et fausserait les tests du serveur.
-->
<ItemGroup>
<ProjectReference Include="..\..\src\GameLauncher.Core\GameLauncher.Core.csproj" />
<Compile Include="..\..\website\GameLauncher.Website\Services\*.cs" Link="Website\Site\%(Filename)%(Extension)" />
<Compile Include="..\..\website\GameLauncher.Website\Options\*.cs" Link="Website\Site\%(Filename)%(Extension)" />
<EmbeddedResource Include="..\..\src\GameLauncher\Security\update-signing.pub" Link="Website\Site\update-signing.pub" LogicalName="GameLauncher.Website.update-signing.pub" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
@@ -0,0 +1,210 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Storage.Players;
using Microsoft.Extensions.DependencyInjection;
namespace GameLauncher.Tests.Infrastructure;
/// <summary>
/// Launcher API avec les comptes joueurs activés : clé maîtresse dans un dossier temporaire SÉPARÉ du dossier des données
/// (le serveur refuse une clé rangée avec les données), preuves de travail faciles, limites relevées.
/// </summary>
public sealed class PlayerTestServer : IAsyncDisposable
{
public const string Password = "Tr0mbone-Lavande!";
public const string OtherPassword = "Cerf-Volant-Bleu-77";
private readonly string _keyDirectory;
private PlayerTestServer(TestApi api, string keyDirectory, string keyFile)
{
Api = api;
_keyDirectory = keyDirectory;
KeyFile = keyFile;
}
public TestApi Api { get; }
public string KeyFile { get; }
public PlayerStore Store => Api.Services.GetRequiredService<PlayerStore>();
public static string NewKeyFile(out string directory)
{
directory = Path.Combine(Path.GetTempPath(), "launcher-api-keys", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var file = Path.Combine(directory, "players-master-key");
File.WriteAllText(file, PlayerSecrets.NewMasterKey());
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(file, UnixFileMode.UserRead | UnixFileMode.UserWrite);
return file;
}
public static async Task<PlayerTestServer> StartAsync(IDictionary<string, string?>? settings = null)
{
var keyFile = NewKeyFile(out var keyDirectory);
var values = new Dictionary<string, string?>
{
["LauncherApi:Players:Enabled"] = "true",
["LauncherApi:Players:MasterKeyFile"] = keyFile,
["LauncherApi:Players:RegisterProofDifficulty"] = "8",
["LauncherApi:Players:LoginProofDifficulty"] = "8",
["LauncherApi:Players:AuthRequestsPerMinute"] = "100000",
["LauncherApi:Players:RegistrationsPerIpPerHour"] = "1000",
["LauncherApi:Players:RegistrationsPerIpPerDay"] = "1000",
["LauncherApi:Players:FailedLoginsPerIpPer15Minutes"] = "10000",
// Coût minimal accepté (OWASP : 7 Mio × 5 passes équivaut à 19 Mio × 2) : tests plus rapides.
["LauncherApi:Players:HashMemoryKib"] = "7168",
["LauncherApi:Players:HashIterations"] = "6"
};
foreach (var (key, value) in settings ?? new Dictionary<string, string?>()) values[key] = value;
try
{
return new PlayerTestServer(await TestApi.StartAsync(values), keyDirectory, keyFile);
}
catch
{
Directory.Delete(keyDirectory, recursive: true);
throw;
}
}
// ------------------------------------------------------------------ Requêtes d'un joueur
public async Task<PlayerChallengeResponse> ChallengeAsync(string purpose)
{
using var response = await Api.Http.PostAsync(ApiRoutes.AccountChallenge, TestApi.Json(new PlayerChallengeRequest { Purpose = purpose }));
Assert.True(response.IsSuccessStatusCode, $"défi : {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
return (await response.Content.ReadFromJsonAsync<PlayerChallengeResponse>(ApiJson.Options))!;
}
public static string Solve(PlayerChallengeResponse challenge)
{
Assert.True(Base64Url.TryDecode(challenge.Nonce, out var nonce));
return challenge.Difficulty == 0 ? "" : FeedbackProof.Solve(nonce, challenge.Difficulty);
}
public async Task<HttpResponseMessage> RegisterRawAsync(TestDevice device, string email, string password = Password, string terms = "1",
Func<PlayerRegisterRequest, PlayerRegisterRequest>? tamper = null)
{
var challenge = await ChallengeAsync(PlayerChallengePurposes.Register);
var request = new PlayerRegisterRequest
{
Email = email,
Password = password,
ChallengeId = challenge.ChallengeId,
Solution = Solve(challenge),
AcceptedTermsVersion = terms,
Device = device.Proof(PlayerChallengePurposes.Register, challenge)
};
return await Api.Http.PostAsync(ApiRoutes.AccountRegister, TestApi.Json(tamper?.Invoke(request) ?? request));
}
public async Task<PlayerSession> RegisterAsync(TestDevice device, string email, string password = Password)
{
using var response = await RegisterRawAsync(device, email, password);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.StatusCode == HttpStatusCode.OK, $"création : {(int)response.StatusCode} {body}");
var result = System.Text.Json.JsonSerializer.Deserialize<PlayerLoginResponse>(body, ApiJson.Options)!;
Assert.Equal(PlayerLoginStatus.Ok, result.Status);
return result.Session!;
}
public async Task<HttpResponseMessage> LoginRawAsync(TestDevice device, string email, string password = Password,
Func<PlayerLoginRequest, PlayerLoginRequest>? tamper = null)
{
var challenge = await ChallengeAsync(PlayerChallengePurposes.Login);
var request = new PlayerLoginRequest
{
Email = email,
Password = password,
ChallengeId = challenge.ChallengeId,
Solution = Solve(challenge),
Device = device.Proof(PlayerChallengePurposes.Login, challenge)
};
return await Api.Http.PostAsync(ApiRoutes.AccountLogin, TestApi.Json(tamper?.Invoke(request) ?? request));
}
public async Task<PlayerLoginResponse> LoginAsync(TestDevice device, string email, string password = Password)
{
using var response = await LoginRawAsync(device, email, password);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.StatusCode == HttpStatusCode.OK, $"connexion : {(int)response.StatusCode} {body}");
return System.Text.Json.JsonSerializer.Deserialize<PlayerLoginResponse>(body, ApiJson.Options)!;
}
public async Task<HttpResponseMessage> RefreshRawAsync(TestDevice device, string refreshToken)
{
var challenge = await ChallengeAsync(PlayerChallengePurposes.Refresh);
Assert.Equal(0, challenge.Difficulty);
var request = new PlayerRefreshRequest
{
RefreshToken = refreshToken,
ChallengeId = challenge.ChallengeId,
Signature = device.Proof(PlayerChallengePurposes.Refresh, challenge).Signature
};
return await Api.Http.PostAsync(ApiRoutes.AccountRefresh, TestApi.Json(request));
}
public async Task<PlayerSession> RefreshAsync(TestDevice device, string refreshToken)
{
using var response = await RefreshRawAsync(device, refreshToken);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.StatusCode == HttpStatusCode.OK, $"renouvellement : {(int)response.StatusCode} {body}");
return System.Text.Json.JsonSerializer.Deserialize<PlayerSession>(body, ApiJson.Options)!;
}
/// <summary>Requête avec un jeton (joueur ou administrateur) dans « Authorization: Bearer ».</summary>
public async Task<HttpResponseMessage> SendAsync(HttpMethod method, string route, string? token, object? body = null)
{
var request = new HttpRequestMessage(method, route);
if (token is not null) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (body is not null) request.Content = TestApi.Json(body);
return await Api.Http.SendAsync(request);
}
public async Task<PlayerMeResponse> MeAsync(string accessToken)
{
using var response = await SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, accessToken);
Assert.True(response.IsSuccessStatusCode, $"me : {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
return (await response.Content.ReadFromJsonAsync<PlayerMeResponse>(ApiJson.Options))!;
}
public async ValueTask DisposeAsync()
{
await Api.DisposeAsync();
try
{
Directory.Delete(_keyDirectory, recursive: true);
}
catch (IOException)
{
// dossier temporaire
}
}
}
/// <summary>Appareil d'un joueur de test : sa clé d'appareil (ECDSA P-256), comme celle que crée le launcher.</summary>
public sealed class TestDevice : IDisposable
{
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
public TestDevice(string label = "Windows 11 · GameLauncher tests") => Label = label;
public string Label { get; }
public byte[] PublicKey => _key.ExportSubjectPublicKeyInfo();
public string KeyId => AdminKeys.ComputeKeyId(PublicKey);
public PlayerDeviceProof Proof(string purpose, PlayerChallengeResponse challenge) => new()
{
PublicKey = Convert.ToBase64String(PublicKey),
Label = Label,
Signature = Convert.ToBase64String(AdminKeys.Sign(_key,
PlayerMessages.Device(purpose, challenge.Origin, challenge.ServerId, challenge.ChallengeId, challenge.Nonce, KeyId)))
};
public void Dispose() => _key.Dispose();
}
@@ -0,0 +1,27 @@
using Konscious.Security.Cryptography;
namespace GameLauncher.Tests.Server;
/// <summary>
/// Contrôle de la bibliothèque Argon2 utilisée pour les mots de passe des joueurs : vecteur de test officiel d'Argon2id
/// (RFC 9106, § 5.3), avec clé secrète et données associées comme pour notre « poivre ». Si une mise à jour du paquet
/// changeait le calcul, ce test échouerait avant que les joueurs ne puissent plus se connecter.
/// </summary>
public class Argon2VectorTests
{
[Fact]
public void Argon2id_matches_the_rfc_9106_test_vector()
{
using var argon2 = new Argon2id(Enumerable.Repeat((byte)0x01, 32).ToArray())
{
Salt = Enumerable.Repeat((byte)0x02, 16).ToArray(),
KnownSecret = Enumerable.Repeat((byte)0x03, 8).ToArray(),
AssociatedData = Enumerable.Repeat((byte)0x04, 12).ToArray(),
MemorySize = 32,
Iterations = 3,
DegreeOfParallelism = 4
};
Assert.Equal("0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659",
Convert.ToHexString(argon2.GetBytes(32)).ToLowerInvariant());
}
}
@@ -67,9 +67,22 @@ public class FeedbackFormsModuleTests
var published = FeedbackFormRules.Parse(File.ReadAllBytes(Path.Combine(directory, "feedback-forms.json")))!;
Assert.True(ContentSigning.VerifyWithKey(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(published),
published.Signature!, key.PublicKey));
var audit = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries.Single(e => e.Action == "feedback.forms");
Assert.Contains("Problème technique", audit.Details);
Assert.Contains("+3 champ(s)", audit.Details);
// Audit : une ligne de publication, puis une par type modifié, avec son nom et le détail de chaque champ.
var entries = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries;
var audit = entries.Single(e => e.Action == FeedbackFormsAudit.Published);
Assert.Contains("3 types modifiés", audit.Details);
Assert.Contains("« Problème technique »", audit.Details);
string Details(string name) =>
string.Join(" ", entries.Where(e => e.Action == FeedbackFormsAudit.Updated && e.Target == $"« {name} »").Select(e => e.Details));
var bug = Details("Problème technique");
Assert.Contains("nom affiché : « Bug » → « Problème technique »", bug);
Assert.Contains("champ supprimé : « Résultat attendu »", bug);
Assert.Contains("champ ajouté : « Où cela arrive-t-il ? » (cases à cocher, obligatoire, 3 choix)", bug);
Assert.Contains("champ ajouté : « Version affichée en jeu » (texte court, facultatif)", bug);
Assert.Contains("champ ajouté : « Le problème revient après un redémarrage » (case oui/non, facultatif)", bug);
Assert.Contains("champ « Note » modifié : étoiles 5 → 10", Details("Avis"));
Assert.Contains("masqué (plus proposé aux joueurs)", Details("Autre"));
Assert.DoesNotContain(entries, e => e.Target == "« Demande d'évolution »"); // inchangé : pas de ligne
// Demande avec la révision en vigueur : nouveaux champs acceptés, formulaire conservé avec la demande.
using var client = api.CreateClient();
@@ -166,9 +179,17 @@ public class FeedbackFormsModuleTests
Assert.Equal("type_1", state.Document.Forms[0].Kind); // ordre conservé
Assert.Equal(icon, state.Document.Forms[0].CustomIcon);
Assert.True(new FileInfo(Path.Combine(directory, "feedback-forms.json")).Length > 64 * 1024);
var audit = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries.Single(e => e.Action == "feedback.forms");
Assert.Contains("type(s) ajouté(s)", audit.Details);
Assert.Contains("« Autre »", audit.Details);
var entries = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries;
var audit = entries.Single(e => e.Action == FeedbackFormsAudit.Published);
Assert.Contains("5 types ajoutés", audit.Details);
Assert.Contains("1 type supprimé : « Autre »", audit.Details);
Assert.Equal(5, entries.Count(e => e.Action == FeedbackFormsAudit.Created));
var created = entries.Single(e => e.Action == FeedbackFormsAudit.Created && e.Target == "« Suggestion de niveau »");
Assert.Contains("icône importée", created.Details);
Assert.Contains("captures d'écran non proposées", created.Details);
Assert.Contains("champ « Message » (zone de texte, obligatoire)", created.Details);
Assert.Contains("icône importée", entries.Single(e => e.Action == FeedbackFormsAudit.Updated && e.Target == "« Bug »").Details);
Assert.Equal("« Autre »", entries.Single(e => e.Action == FeedbackFormsAudit.Deleted).Target);
using var client = api.CreateClient();
// Nouveau type : accepté, mais sans capture d'écran.
@@ -193,4 +214,54 @@ public class FeedbackFormsModuleTests
Assert.Equal("Suggestion de niveau", detail.Form!.Label);
Assert.Equal(icon, detail.Form.CustomIcon);
}
[Fact]
public void Audit_names_every_changed_field_and_spreads_long_details_over_numbered_lines()
{
var before = FeedbackFormRules.Defaults();
var many = Enumerable.Range(1, FeedbackFormRules.MaxOptions).Select(i => new FeedbackOption($"o{i}", new string('x', 50) + i)).ToList();
var after = new FeedbackFormsDocument
{
Revision = 1,
Forms = before.Forms.Select(form => form.Kind != FeedbackKinds.Bug ? form : form with
{
TitleHint = "Le souci en une phrase",
TechByDefault = false,
Fields = form.Fields.Select(field => field.Id switch
{
"severity" => field with { Label = "Gravité du problème", Required = false, ShowInList = false, Options = many },
"steps" => field with { Hint = "Autre aide." },
"actual" => field with { MaxLength = 800 },
_ => field
}).Reverse().ToList()
}).ToList()
};
var change = Assert.Single(FeedbackFormsAudit.Changes(before, after));
Assert.Equal(FeedbackFormsAudit.Updated, change.Action);
Assert.Equal("Bug", change.Name);
Assert.Equal("1 type modifié : « Bug »", FeedbackFormsAudit.Summary(before, after, new[] { change }));
// Rien n'est perdu : plusieurs lignes numérotées, chacune sous la limite d'un champ du journal.
var lines = FeedbackFormsAudit.Lines(change.Details);
Assert.True(lines.Count > 1);
Assert.All(lines, line => Assert.True(line.Length <= FeedbackFormsAudit.MaxDetailsLength, line));
Assert.StartsWith("(1/", lines[0]);
Assert.StartsWith($"({lines.Count}/{lines.Count}) ", lines[^1]);
var all = string.Join(" ", lines);
Assert.Contains("exemple de titre : « Le problème en une phrase » → « Le souci en une phrase »", all);
Assert.Contains("« Joindre les informations techniques » décoché par défaut", all);
Assert.Contains("champ « Gravité du problème » modifié : libellé « Gravité » → « Gravité du problème »", all);
Assert.Contains("champ « Gravité du problème » modifié : rendu facultatif", all);
Assert.Contains("champ « Gravité du problème » modifié : retiré de l'aperçu de la liste des demandes", all);
foreach (var option in many) Assert.Contains($"choix ajouté « {option.Label} »", all);
Assert.Contains("choix retiré « Mineur »", all);
Assert.Contains("champ « Étapes pour reproduire » modifié : aide modifiée", all);
Assert.Contains("champ « Résultat obtenu » modifié : longueur maximale 1500 → 800", all);
Assert.Contains("ordre des champs modifié", all);
// Publication sans changement : pas de ligne par type.
Assert.Empty(FeedbackFormsAudit.Changes(before, before with { Revision = 1 }));
Assert.Equal("aucun changement", FeedbackFormsAudit.Summary(before, before, Array.Empty<FeedbackFormChange>()));
}
}
@@ -0,0 +1,674 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Cli;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage.Players;
using GameLauncher.Tests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace GameLauncher.Tests.Server;
/// <summary>Comptes joueurs (étape 8a) : création, connexion, session mémorisée, anti-force brute, mot de passe, appareils.</summary>
public class PlayerAccountsTests
{
private const string Email = "joueur@example.fr";
private static async Task<ApiError> Error(HttpResponseMessage response) => await TestApi.ReadErrorAsync(response);
// ------------------------------------------------------------------ Activation
[Fact]
public async Task Accounts_are_disabled_by_default()
{
await using var api = await TestApi.StartAsync();
using var response = await api.Http.PostAsync(ApiRoutes.AccountChallenge,
TestApi.Json(new PlayerChallengeRequest { Purpose = PlayerChallengePurposes.Login }));
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
Assert.Equal(ApiErrorCodes.NotConfigured, (await Error(response)).Code);
Assert.Null((await api.InfoAsync()).Accounts);
}
[Fact]
public async Task Info_announces_accounts_when_enabled()
{
await using var server = await PlayerTestServer.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Players:TermsVersion"] = "2026-11" });
var info = (await server.Api.InfoAsync()).Accounts;
Assert.NotNull(info);
Assert.True(info!.Enabled);
Assert.True(info.RegistrationOpen);
Assert.Equal("2026-11", info.TermsVersion);
Assert.Equal(PasswordRules.Version, info.PasswordRulesVersion);
}
[Fact]
public async Task Enabled_without_master_key_does_not_start()
{
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Players:Enabled"] = "true"
}));
Assert.Contains("clé maîtresse", error.Message);
}
[Fact]
public async Task Master_key_inside_the_data_directory_is_refused()
{
var data = Path.Combine(Path.GetTempPath(), "launcher-api-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(data);
try
{
var key = Path.Combine(data, "players-master-key");
File.WriteAllText(key, PlayerSecrets.NewMasterKey());
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:DataDirectory"] = data,
["LauncherApi:Players:Enabled"] = "true",
["LauncherApi:Players:MasterKeyFile"] = key
}));
Assert.Contains("dossier des données", error.Message);
}
finally
{
Directory.Delete(data, recursive: true);
}
}
// ------------------------------------------------------------------ Création de compte
[Fact]
public async Task Register_opens_a_session_and_stores_the_email_encrypted()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var session = await server.RegisterAsync(device, " Joueur@Example.FR ");
Assert.StartsWith(PlayerTokens.AccessPrefix, session.AccessToken);
Assert.StartsWith(PlayerTokens.RefreshPrefix, session.RefreshToken);
Assert.Equal("j***@e***.fr", session.Account.EmailMasked);
Assert.Matches("^PL-", session.Account.Reference);
Assert.False(session.Account.EmailVerified);
var me = await server.MeAsync(session.AccessToken);
Assert.Equal(session.Account.Reference, me.Account.Reference);
var only = Assert.Single(me.Devices);
Assert.True(only.Current);
Assert.Equal(device.Label, only.Label);
// Ni l'adresse ni le mot de passe n'apparaissent en clair dans la base (ni dans son journal WAL).
var folder = Path.Combine(server.Api.DataDirectory, PlayerStore.FolderName);
foreach (var file in Directory.GetFiles(folder))
{
var bytes = await ReadSharedAsync(file);
var text = Encoding.UTF8.GetString(bytes);
Assert.DoesNotContain(Email, text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("joueur@", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(PlayerTestServer.Password, text);
}
// Journal du compte : création inscrite, avec l'adresse IP, sans adresse e-mail.
var events = server.Store.EventsAfter(0, 100);
var created = Assert.Single(events, e => e.Type == PlayerEvents.Register);
Assert.Equal(AuditResults.Ok, created.Result);
Assert.NotNull(created.Ip);
Assert.All(events, e => Assert.DoesNotContain("@", e.Details ?? ""));
}
private static async Task<byte[]> ReadSharedAsync(string path)
{
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
return memory.ToArray();
}
[Fact]
public async Task Register_refuses_bad_input()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
using (var weak = await server.RegisterRawAsync(device, Email, "P@ssw0rd2024!"))
{
Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode);
Assert.Equal(ApiErrorCodes.PasswordRejected, (await Error(weak)).Code);
}
using (var email = await server.RegisterRawAsync(device, "pas-une-adresse"))
Assert.Equal(ApiErrorCodes.EmailInvalid, (await Error(email)).Code);
using (var terms = await server.RegisterRawAsync(device, Email, terms: "0"))
Assert.Equal(ApiErrorCodes.TermsNotAccepted, (await Error(terms)).Code);
using (var proof = await server.RegisterRawAsync(device, Email, tamper: r => r with { Solution = "0" }))
Assert.Equal(ApiErrorCodes.ProofInvalid, (await Error(proof)).Code);
using (var signature = await server.RegisterRawAsync(device, Email,
tamper: r => r with { Device = r.Device with { Signature = Convert.ToBase64String(new byte[64]) } }))
Assert.Equal(ApiErrorCodes.DeviceProofInvalid, (await Error(signature)).Code);
using var other = new TestDevice();
using (var stolenKey = await server.RegisterRawAsync(device, Email,
tamper: r => r with { Device = r.Device with { PublicKey = Convert.ToBase64String(other.PublicKey) } }))
Assert.Equal(ApiErrorCodes.DeviceProofInvalid, (await Error(stolenKey)).Code);
Assert.Equal(0, server.Store.CountAccounts());
}
[Fact]
public async Task Same_email_cannot_be_registered_twice_whatever_its_case()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
await server.RegisterAsync(device, Email);
using var again = await server.RegisterRawAsync(device, "JOUEUR@example.fr", PlayerTestServer.OtherPassword);
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
Assert.Equal(ApiErrorCodes.EmailTaken, (await Error(again)).Code);
}
[Fact]
public async Task Challenge_is_single_use_and_bound_to_its_purpose()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var challenge = await server.ChallengeAsync(PlayerChallengePurposes.Register);
var request = new PlayerRegisterRequest
{
Email = Email,
Password = PlayerTestServer.Password,
ChallengeId = challenge.ChallengeId,
Solution = PlayerTestServer.Solve(challenge),
AcceptedTermsVersion = "1",
Device = device.Proof(PlayerChallengePurposes.Register, challenge)
};
using (var first = await server.Api.Http.PostAsync(ApiRoutes.AccountRegister, TestApi.Json(request)))
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
using (var replay = await server.Api.Http.PostAsync(ApiRoutes.AccountRegister, TestApi.Json(request with { Email = "autre@example.fr" })))
Assert.Equal(ApiErrorCodes.ProofInvalid, (await Error(replay)).Code);
// Un défi « création » ne sert pas à se connecter.
var registerChallenge = await server.ChallengeAsync(PlayerChallengePurposes.Register);
using var login = await server.Api.Http.PostAsync(ApiRoutes.AccountLogin, TestApi.Json(new PlayerLoginRequest
{
Email = Email,
Password = PlayerTestServer.Password,
ChallengeId = registerChallenge.ChallengeId,
Solution = PlayerTestServer.Solve(registerChallenge),
Device = device.Proof(PlayerChallengePurposes.Login, registerChallenge)
}));
Assert.Equal(ApiErrorCodes.ProofInvalid, (await Error(login)).Code);
}
[Fact]
public async Task Registrations_are_limited_per_ip_address()
{
await using var server = await PlayerTestServer.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Players:RegistrationsPerIpPerHour"] = "2",
["LauncherApi:Players:RegistrationsPerIpPerDay"] = "2"
});
using var device = new TestDevice();
await server.RegisterAsync(device, "a@example.fr");
await server.RegisterAsync(device, "b@example.fr");
using var third = await server.RegisterRawAsync(device, "c@example.fr");
Assert.Equal(HttpStatusCode.TooManyRequests, third.StatusCode);
Assert.NotNull(third.Headers.RetryAfter);
}
[Fact]
public async Task Registration_can_be_closed_while_login_stays_open()
{
await using var server = await PlayerTestServer.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Players:RegistrationEnabled"] = "false"
});
using var device = new TestDevice();
using var response = await server.RegisterRawAsync(device, Email);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(ApiErrorCodes.RegistrationClosed, (await Error(response)).Code);
Assert.False((await server.Api.InfoAsync()).Accounts!.RegistrationOpen);
}
// ------------------------------------------------------------------ Connexion et anti-force brute
[Fact]
public async Task Login_succeeds_and_failures_look_the_same_for_unknown_addresses()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
await server.RegisterAsync(pc, Email);
using var laptop = new TestDevice("Portable");
var ok = await server.LoginAsync(laptop, "JOUEUR@example.fr");
Assert.Equal(PlayerLoginStatus.Ok, ok.Status);
Assert.Equal(2, (await server.MeAsync(ok.Session!.AccessToken)).Devices.Count);
using var wrong = await server.LoginRawAsync(laptop, Email, PlayerTestServer.OtherPassword);
using var unknown = await server.LoginRawAsync(laptop, "personne@example.fr");
Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, unknown.StatusCode);
var wrongError = await Error(wrong);
var unknownError = await Error(unknown);
Assert.Equal(ApiErrorCodes.LoginFailed, wrongError.Code);
Assert.Equal(wrongError.Code, unknownError.Code);
Assert.Equal(wrongError.Message, unknownError.Message);
}
[Fact]
public async Task Repeated_failures_lock_new_devices_with_increasing_delays()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
await server.RegisterAsync(pc, Email);
using var attacker = new TestDevice("Attaquant");
for (var i = 1; i <= 4; i++)
{
using var failure = await server.LoginRawAsync(attacker, Email, "Mauvais-Mot-2-Passe" + i);
Assert.Equal(HttpStatusCode.Unauthorized, failure.StatusCode);
}
using (var fifth = await server.LoginRawAsync(attacker, Email, "Mauvais-Mot-2-Passe5"))
{
Assert.Equal(HttpStatusCode.TooManyRequests, fifth.StatusCode);
Assert.Equal(ApiErrorCodes.LoginLocked, (await Error(fifth)).Code);
Assert.Equal(TimeSpan.FromMinutes(15), fifth.Headers.RetryAfter!.Delta);
}
// Bloqué même avec le bon mot de passe, depuis un appareil inconnu…
using (var locked = await server.LoginRawAsync(attacker, Email))
Assert.Equal(HttpStatusCode.TooManyRequests, locked.StatusCode);
// … mais pas depuis l'appareil du joueur (appareil de confiance).
Assert.Equal(PlayerLoginStatus.Ok, (await server.LoginAsync(pc, Email)).Status);
// La connexion réussie a remis les compteurs à zéro : le blocage est levé.
server.Api.Time.Advance(TimeSpan.FromMinutes(1));
using (var after = await server.LoginRawAsync(attacker, Email))
Assert.Equal(HttpStatusCode.OK, after.StatusCode);
}
[Fact]
public async Task Second_lock_lasts_twice_as_long()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
await server.RegisterAsync(pc, Email);
using var attacker = new TestDevice();
await FailAsync(server, attacker, 5);
server.Api.Time.Advance(TimeSpan.FromMinutes(16));
await FailAsync(server, attacker, 4);
using var tenth = await server.LoginRawAsync(attacker, Email, "Encore-Faux-1234");
Assert.Equal(TimeSpan.FromMinutes(30), tenth.Headers.RetryAfter!.Delta);
}
private static async Task FailAsync(PlayerTestServer server, TestDevice device, int count, string email = Email)
{
for (var i = 0; i < count; i++)
{
using var response = await server.LoginRawAsync(device, email, $"Faux-Mot-De-Passe-{i}");
Assert.True(response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.TooManyRequests);
}
}
[Fact]
public async Task Unknown_addresses_are_locked_exactly_like_real_accounts()
{
await using var server = await PlayerTestServer.StartAsync();
using var attacker = new TestDevice();
await FailAsync(server, attacker, 4, "personne@example.fr");
using var fifth = await server.LoginRawAsync(attacker, "personne@example.fr", "Faux-Mot-De-Passe-5");
Assert.Equal(HttpStatusCode.TooManyRequests, fifth.StatusCode);
Assert.Equal(ApiErrorCodes.LoginLocked, (await Error(fifth)).Code);
Assert.Equal(TimeSpan.FromMinutes(15), fifth.Headers.RetryAfter!.Delta);
}
[Fact]
public async Task Continued_failures_suspend_new_devices_until_an_administrator_lifts_it()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var session = await server.RegisterAsync(pc, Email);
using var attacker = new TestDevice();
for (var lockNumber = 1; lockNumber <= 4; lockNumber++)
{
await FailAsync(server, attacker, 5);
server.Api.Time.Advance(TimeSpan.FromHours(2));
}
var account = server.Store.FindByReference(session.Account.Reference)!;
Assert.Equal(PlayerStatus.Suspended, account.Status);
// Bon mot de passe depuis un NOUVEL appareil : suspension révélée (seulement à qui connaît le mot de passe).
using (var refused = await server.LoginRawAsync(attacker, Email))
{
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);
var error = await Error(refused);
Assert.Equal(ApiErrorCodes.AccountSuspended, error.Code);
Assert.Contains(session.Account.Reference, error.Message);
}
// L'appareil du joueur continue de fonctionner (l'attaquant ne peut pas le priver de son compte).
var me = await server.MeAsync((await server.RefreshAsync(pc, session.RefreshToken)).AccessToken);
Assert.True(me.Account.NewDevicesSuspended);
Assert.Equal(PlayerLoginStatus.Ok, (await server.LoginAsync(pc, Email)).Status);
// Inscrit au journal d'audit des administrateurs, par référence (jamais l'adresse).
var audit = await File.ReadAllTextAsync(Directory.GetFiles(Path.Combine(server.Api.DataDirectory, "audit"), "audit-*.jsonl").Single());
Assert.Contains("players.suspend", audit);
Assert.Contains(session.Account.Reference, audit);
Assert.DoesNotContain("joueur@", audit);
// Levée par l'outil en ligne de commande (le menu d'administration viendra à l'étape 8d).
var output = new StringWriter();
Assert.Equal(0, AdminCli.Run(new[] { "players-unlock", "--ref", session.Account.Reference }, output, server.Api.DataDirectory));
Assert.Equal(PlayerLoginStatus.Ok, (await server.LoginAsync(attacker, Email)).Status);
}
[Fact]
public async Task Trusted_device_failures_lock_briefly_but_never_suspend()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var session = await server.RegisterAsync(pc, Email);
for (var round = 0; round < 3; round++)
{
await FailAsync(server, pc, 10);
server.Api.Time.Advance(TimeSpan.FromMinutes(20));
}
Assert.Equal(PlayerStatus.Active, server.Store.FindByReference(session.Account.Reference)!.Status);
}
[Fact]
public async Task Failed_logins_are_limited_per_ip_address_across_accounts()
{
await using var server = await PlayerTestServer.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Players:FailedLoginsPerIpPer15Minutes"] = "5"
});
using var attacker = new TestDevice();
for (var i = 0; i < 5; i++)
{
using var failure = await server.LoginRawAsync(attacker, $"victime{i}@example.fr");
Assert.Equal(HttpStatusCode.Unauthorized, failure.StatusCode);
}
using var throttled = await server.LoginRawAsync(attacker, "victime9@example.fr");
Assert.Equal(HttpStatusCode.TooManyRequests, throttled.StatusCode);
Assert.Equal(ApiErrorCodes.RateLimited, (await Error(throttled)).Code);
}
// ------------------------------------------------------------------ Session mémorisée
[Fact]
public async Task Refresh_rotates_the_token_and_detects_reuse()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var first = await server.RegisterAsync(pc, Email);
var second = await server.RefreshAsync(pc, first.RefreshToken);
Assert.NotEqual(first.RefreshToken, second.RefreshToken);
Assert.Equal(first.DeviceId, second.DeviceId);
await server.MeAsync(second.AccessToken);
// L'ancien jeton présenté de nouveau (copie volée) : l'appareil est déconnecté par précaution.
using (var reuse = await server.RefreshRawAsync(pc, first.RefreshToken))
Assert.Equal(ApiErrorCodes.SessionRevoked, (await Error(reuse)).Code);
using (var after = await server.RefreshRawAsync(pc, second.RefreshToken))
Assert.Equal(HttpStatusCode.Unauthorized, after.StatusCode);
using (var me = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, second.AccessToken))
Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode);
Assert.Contains(server.Store.EventsAfter(0, 100), e => e.Type == PlayerEvents.RefreshReuse);
}
[Fact]
public async Task Refresh_token_is_useless_without_the_device_key()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var session = await server.RegisterAsync(pc, Email);
using var thief = new TestDevice();
using var stolen = await server.RefreshRawAsync(thief, session.RefreshToken);
Assert.Equal(HttpStatusCode.Unauthorized, stolen.StatusCode);
Assert.Equal(ApiErrorCodes.DeviceProofInvalid, (await Error(stolen)).Code);
// Le vrai appareil n'est pas pénalisé.
await server.RefreshAsync(pc, session.RefreshToken);
}
[Fact]
public async Task Access_token_is_short_lived_and_session_expires_after_inactivity()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var session = await server.RegisterAsync(pc, Email);
server.Api.Time.Advance(TimeSpan.FromMinutes(16));
using (var expired = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, session.AccessToken))
Assert.Equal(HttpStatusCode.Unauthorized, expired.StatusCode);
var renewed = await server.RefreshAsync(pc, session.RefreshToken);
await server.MeAsync(renewed.AccessToken);
server.Api.Time.Advance(TimeSpan.FromDays(91));
using var stale = await server.RefreshRawAsync(pc, renewed.RefreshToken);
Assert.Equal(ApiErrorCodes.SessionRevoked, (await Error(stale)).Code);
}
[Fact]
public async Task Refresh_is_logged_at_most_once_a_day_per_device()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var session = await server.RegisterAsync(pc, Email);
server.Api.Time.Advance(TimeSpan.FromDays(1));
var a = await server.RefreshAsync(pc, session.RefreshToken);
var b = await server.RefreshAsync(pc, a.RefreshToken);
await server.RefreshAsync(pc, b.RefreshToken);
Assert.Single(server.Store.EventsAfter(0, 100), e => e.Type == PlayerEvents.Refresh);
}
// ------------------------------------------------------------------ Déconnexion et appareils
[Fact]
public async Task Logout_ends_this_device_or_all_devices()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
using var laptop = new TestDevice("Portable");
var pcSession = await server.RegisterAsync(pc, Email);
var laptopSession = (await server.LoginAsync(laptop, Email)).Session!;
using (var logout = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountLogout, laptopSession.AccessToken, new PlayerLogoutRequest()))
Assert.Equal(HttpStatusCode.NoContent, logout.StatusCode);
using (var gone = await server.RefreshRawAsync(laptop, laptopSession.RefreshToken))
Assert.Equal(HttpStatusCode.Unauthorized, gone.StatusCode);
Assert.Single((await server.MeAsync(pcSession.AccessToken)).Devices);
var again = (await server.LoginAsync(laptop, Email)).Session!;
using (var all = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountLogout, again.AccessToken, new PlayerLogoutRequest { AllDevices = true }))
Assert.Equal(HttpStatusCode.NoContent, all.StatusCode);
using (var pcGone = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, pcSession.AccessToken))
Assert.Equal(HttpStatusCode.Unauthorized, pcGone.StatusCode);
using (var pcRefresh = await server.RefreshRawAsync(pc, pcSession.RefreshToken))
Assert.Equal(HttpStatusCode.Unauthorized, pcRefresh.StatusCode);
}
[Fact]
public async Task A_device_can_be_signed_out_from_another_one()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
using var laptop = new TestDevice("Portable");
var pcSession = await server.RegisterAsync(pc, Email);
var laptopSession = (await server.LoginAsync(laptop, Email)).Session!;
using (var removed = await server.SendAsync(HttpMethod.Delete, ApiRoutes.AccountDevice(laptopSession.DeviceId), pcSession.AccessToken))
Assert.Equal(HttpStatusCode.NoContent, removed.StatusCode);
using (var gone = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, laptopSession.AccessToken))
Assert.Equal(HttpStatusCode.Unauthorized, gone.StatusCode);
// L'appareil d'un autre compte est introuvable.
using var stranger = new TestDevice();
var other = await server.RegisterAsync(stranger, "autre@example.fr");
using var foreign = await server.SendAsync(HttpMethod.Delete, ApiRoutes.AccountDevice(other.DeviceId), pcSession.AccessToken);
Assert.Equal(HttpStatusCode.NotFound, foreign.StatusCode);
}
[Fact]
public async Task Oldest_devices_are_signed_out_beyond_the_limit()
{
await using var server = await PlayerTestServer.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Players:MaxDevicesPerAccount"] = "2" });
using var a = new TestDevice("A");
using var b = new TestDevice("B");
using var c = new TestDevice("C");
var first = await server.RegisterAsync(a, Email);
server.Api.Time.Advance(TimeSpan.FromMinutes(1));
await server.LoginAsync(b, Email);
server.Api.Time.Advance(TimeSpan.FromMinutes(1));
var third = (await server.LoginAsync(c, Email)).Session!;
var devices = (await server.MeAsync(third.AccessToken)).Devices;
Assert.Equal(new[] { "C", "B" }, devices.Select(d => d.Label).ToArray());
using var oldest = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, first.AccessToken);
Assert.Equal(HttpStatusCode.Unauthorized, oldest.StatusCode);
}
// ------------------------------------------------------------------ Mot de passe
[Fact]
public async Task Changing_the_password_signs_out_the_other_devices()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
using var laptop = new TestDevice("Portable");
var pcSession = await server.RegisterAsync(pc, Email);
var laptopSession = (await server.LoginAsync(laptop, Email)).Session!;
using (var wrong = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountPassword, pcSession.AccessToken,
new PlayerChangePasswordRequest { CurrentPassword = "Pas-Le-Bon-123", NewPassword = PlayerTestServer.OtherPassword }))
Assert.Equal(ApiErrorCodes.LoginFailed, (await Error(wrong)).Code);
using (var weak = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountPassword, pcSession.AccessToken,
new PlayerChangePasswordRequest { CurrentPassword = PlayerTestServer.Password, NewPassword = "azertyuiop1234" }))
Assert.Equal(ApiErrorCodes.PasswordRejected, (await Error(weak)).Code);
using (var same = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountPassword, pcSession.AccessToken,
new PlayerChangePasswordRequest { CurrentPassword = PlayerTestServer.Password, NewPassword = PlayerTestServer.Password }))
Assert.Equal(ApiErrorCodes.PasswordRejected, (await Error(same)).Code);
using (var ok = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountPassword, pcSession.AccessToken,
new PlayerChangePasswordRequest { CurrentPassword = PlayerTestServer.Password, NewPassword = PlayerTestServer.OtherPassword }))
{
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
var result = (await ok.Content.ReadFromJsonAsync<PlayerChangePasswordResponse>(ApiJson.Options))!;
Assert.Equal(1, result.DevicesSignedOut);
Assert.Null(result.Session);
}
await server.MeAsync(pcSession.AccessToken); // cet appareil reste connecté
using (var other = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, laptopSession.AccessToken))
Assert.Equal(HttpStatusCode.Unauthorized, other.StatusCode);
using (var old = await server.LoginRawAsync(laptop, Email))
Assert.Equal(HttpStatusCode.Unauthorized, old.StatusCode);
Assert.Equal(PlayerLoginStatus.Ok, (await server.LoginAsync(laptop, Email, PlayerTestServer.OtherPassword)).Status);
}
[Fact]
public async Task Required_password_change_opens_the_session_only_once_done()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var initial = await server.RegisterAsync(pc, Email);
var account = server.Store.FindByReference(initial.Account.Reference)!;
Assert.True(server.Store.SetMustChangePassword(account.Id, true));
// Les sessions en cours sont refusées, le renouvellement aussi.
using (var me = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, initial.AccessToken))
Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode);
using (var refresh = await server.RefreshRawAsync(pc, initial.RefreshToken))
Assert.Equal(ApiErrorCodes.PasswordChangeRequired, (await Error(refresh)).Code);
var login = await server.LoginAsync(pc, Email);
Assert.Equal(PlayerLoginStatus.PasswordChangeRequired, login.Status);
Assert.Null(login.Session);
Assert.StartsWith(PlayerTokens.PasswordChangePrefix, login.PasswordChangeToken);
// Le jeton limité ne donne accès à rien d'autre.
using (var limited = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, login.PasswordChangeToken))
Assert.Equal(HttpStatusCode.Forbidden, limited.StatusCode);
using var change = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountPassword, login.PasswordChangeToken,
new PlayerChangePasswordRequest { CurrentPassword = PlayerTestServer.Password, NewPassword = PlayerTestServer.OtherPassword });
Assert.Equal(HttpStatusCode.OK, change.StatusCode);
var result = (await change.Content.ReadFromJsonAsync<PlayerChangePasswordResponse>(ApiJson.Options))!;
Assert.NotNull(result.Session);
await server.MeAsync(result.Session!.AccessToken);
Assert.False(server.Store.FindById(account.Id)!.MustChangePassword);
// Le jeton limité ne sert qu'une fois.
using var reuse = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountPassword, login.PasswordChangeToken,
new PlayerChangePasswordRequest { CurrentPassword = PlayerTestServer.OtherPassword, NewPassword = "Troisieme-Mot-De-Passe-9" });
Assert.Equal(HttpStatusCode.Unauthorized, reuse.StatusCode);
}
// ------------------------------------------------------------------ Désactivation (outil en ligne de commande)
[Fact]
public async Task Disabled_account_is_refused_immediately_and_reactivated_at_the_end_date()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var session = await server.RegisterAsync(pc, Email);
var until = server.Api.Time.GetUtcNow().AddDays(3).UtcDateTime.ToString("yyyy-MM-dd'T'HH:mm", System.Globalization.CultureInfo.InvariantCulture);
// L'outil en ligne de commande lit l'heure réelle : l'échéance est calculée sur la même horloge que celle du serveur de test.
var output = new StringWriter();
Assert.Equal(0, AdminCli.Run(new[] { "players-disable", "--ref", session.Account.Reference, "--until", until }, output, server.Api.DataDirectory));
using (var me = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, session.AccessToken))
Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode);
using (var login = await server.LoginRawAsync(pc, Email))
{
Assert.Equal(HttpStatusCode.Forbidden, login.StatusCode);
Assert.Equal(ApiErrorCodes.AccountDisabled, (await Error(login)).Code);
}
using (var wrongPassword = await server.LoginRawAsync(pc, Email, "Faux-Mot-De-Passe-1"))
Assert.Equal(ApiErrorCodes.LoginFailed, (await Error(wrongPassword)).Code); // état non révélé sans le bon mot de passe
server.Api.Time.Advance(TimeSpan.FromDays(4));
Assert.Equal(PlayerLoginStatus.Ok, (await server.LoginAsync(pc, Email)).Status);
Assert.Equal(PlayerStatus.Active, server.Store.FindByReference(session.Account.Reference)!.Status);
}
[Fact]
public async Task Command_line_shows_an_account_without_its_email()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
var session = await server.RegisterAsync(pc, Email);
var output = new StringWriter();
Assert.Equal(0, AdminCli.Run(new[] { "players-show", "--ref", session.Account.Reference.ToLowerInvariant() }, output, server.Api.DataDirectory));
var text = output.ToString();
Assert.Contains(session.Account.Reference, text);
Assert.Contains("active", text);
Assert.DoesNotContain("joueur@", text);
var missing = new StringWriter();
Assert.Equal(2, AdminCli.Run(new[] { "players-show", "--ref", "PL-0000-0000" }, missing, server.Api.DataDirectory));
}
// ------------------------------------------------------------------ Conservation
[Fact]
public async Task Account_events_lose_their_ip_after_six_months_and_disappear_after_twelve()
{
await using var server = await PlayerTestServer.StartAsync();
using var pc = new TestDevice();
await server.RegisterAsync(pc, Email);
var retention = server.Api.Services.GetServices<IHostedService>().OfType<RetentionService>().Single();
server.Api.Time.Advance(TimeSpan.FromDays(200));
retention.RunOnce();
var events = server.Store.EventsAfter(0, 100);
Assert.NotEmpty(events);
Assert.All(events, e => Assert.Null(e.Ip));
server.Api.Time.Advance(TimeSpan.FromDays(200));
retention.RunOnce();
Assert.Empty(server.Store.EventsAfter(0, 100));
}
}
@@ -0,0 +1,107 @@
using System.Net;
using System.Text.RegularExpressions;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Security;
using GameLauncher.Tests.Infrastructure;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace GameLauncher.Tests.Server;
/// <summary>
/// Étanchéité entre comptes joueurs et administrateurs : un jeton joueur, quel qu'il soit, n'ouvre AUCUNE route
/// d'administration (toutes les routes déclarées sont essayées), et un jeton d'administrateur n'ouvre aucune route joueur.
/// </summary>
public class PlayerSeparationTests
{
[Fact]
public async Task Player_tokens_never_open_an_administration_route()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var session = await server.RegisterAsync(device, "joueur@example.fr");
var account = server.Store.FindByReference(session.Account.Reference)!;
server.Store.SetMustChangePassword(account.Id, true);
var limited = (await server.LoginAsync(device, "joueur@example.fr")).PasswordChangeToken!;
server.Store.SetMustChangePassword(account.Id, false);
var access = (await server.LoginAsync(device, "joueur@example.fr")).Session!.AccessToken;
var adminRoutes = AdministrationRoutes(server.Api).ToList();
Assert.True(adminRoutes.Count >= 40, $"{adminRoutes.Count} routes d'administration trouvées seulement");
foreach (var token in new[] { access, limited, session.RefreshToken })
{
foreach (var (method, path) in adminRoutes)
{
using var response = await server.SendAsync(new HttpMethod(method), path, token, method is "GET" or "DELETE" ? null : new { });
Assert.True(response.StatusCode == HttpStatusCode.Unauthorized,
$"{method} {path} avec un jeton joueur ({token[..5]}…) : {(int)response.StatusCode}");
}
}
}
[Fact]
public async Task Administrator_tokens_never_open_a_player_route()
{
await using var server = await PlayerTestServer.StartAsync();
using var owner = server.Api.AddOwner();
var admin = await server.Api.RawLoginAsync(owner);
foreach (var (method, path) in new[]
{
("GET", ApiRoutes.AccountMe), ("POST", ApiRoutes.AccountLogout), ("POST", ApiRoutes.AccountPassword),
("DELETE", ApiRoutes.AccountDevice(PlayerIds.NewId()))
})
{
using var response = await server.SendAsync(new HttpMethod(method), path, admin.Token, method == "POST" ? new { } : null);
Assert.True(response.StatusCode == HttpStatusCode.Unauthorized, $"{method} {path} avec un jeton d'administrateur : {(int)response.StatusCode}");
}
// Un jeton d'administrateur présenté comme jeton de renouvellement est refusé.
using var refresh = await server.RefreshRawAsync(new TestDevice(), admin.Token);
Assert.Equal(HttpStatusCode.Unauthorized, refresh.StatusCode);
}
[Fact]
public async Task Player_token_cannot_be_used_on_the_admin_session_route_even_with_signature_headers()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var session = await server.RegisterAsync(device, "joueur@example.fr");
using var request = new HttpRequestMessage(HttpMethod.Get, ApiRoutes.AuthMe);
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", session.AccessToken);
using var response = await server.Api.Http.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Equal(ApiErrorCodes.Unauthorized, (await TestApi.ReadErrorAsync(response)).Code);
}
/// <summary>
/// Toutes les routes déclarées qui exigent une autorisation d'ADMINISTRATEUR (règle « AdminSession » ou « perm:… »),
/// avec des valeurs d'exemple à la place des paramètres.
/// </summary>
private static IEnumerable<(string Method, string Path)> AdministrationRoutes(TestApi api)
{
var sources = api.Services.GetRequiredService<EndpointDataSource>();
foreach (var endpoint in sources.Endpoints.OfType<RouteEndpoint>())
{
var policies = endpoint.Metadata.GetOrderedMetadata<IAuthorizeData>().Select(a => a.Policy ?? "").ToList();
if (!policies.Any(p => p == AdminSessionHandler.SchemeName || p.StartsWith("perm:", StringComparison.Ordinal))) continue;
Assert.DoesNotContain(PlayerAuthorization.PlayerPolicy, policies);
var template = endpoint.RoutePattern.RawText ?? "";
var path = Regex.Replace(template, @"\{([^}:]+)(:[^}]+)?\}", m => m.Groups[2].Value switch
{
":guid" => Guid.NewGuid().ToString("D"),
":int" => "1",
_ => "abc0123456789abcdef0123456789abc"
});
if (path.StartsWith(ApiRoutes.Prefix + "/", StringComparison.Ordinal)) path = path[(ApiRoutes.Prefix.Length + 1)..];
path = path.TrimStart('/');
foreach (var method in endpoint.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods ?? new[] { "GET" })
yield return (method, path);
}
}
}
@@ -0,0 +1,115 @@
using System.Security.Cryptography;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage.Players;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Server;
/// <summary>Secrets des comptes joueurs : clé maîtresse, chiffrement des champs, index aveugle, empreintes des mots de passe.</summary>
public class PlayerStorageTests
{
private static PlayerSecrets NewSecrets() => new(RandomNumberGenerator.GetBytes(32));
private static LauncherApiOptions.PlayersSettings FastSettings() => new()
{
HashMemoryKib = 7168,
HashIterations = 6,
HashParallelism = 1,
MaxConcurrentHashes = 2
};
[Fact]
public void Email_is_encrypted_and_bound_to_its_account()
{
var secrets = NewSecrets();
var a = secrets.EncryptEmail("compte-a", "joueur@example.fr");
var b = secrets.EncryptEmail("compte-a", "joueur@example.fr");
Assert.NotEqual(a, b); // nonce aléatoire
Assert.StartsWith("1.", a);
Assert.DoesNotContain("joueur", a);
Assert.Equal("joueur@example.fr", secrets.DecryptEmail("compte-a", a));
// Copiée sur un autre compte, ou déchiffrée avec une autre clé maîtresse : refusée.
Assert.ThrowsAny<CryptographicException>(() => secrets.DecryptEmail("compte-b", a));
Assert.ThrowsAny<CryptographicException>(() => NewSecrets().DecryptEmail("compte-a", a));
// Les champs libres utilisent une autre sous-clé.
Assert.ThrowsAny<CryptographicException>(() => secrets.DecryptField("compte-a", "email", a));
}
[Fact]
public void Email_index_is_deterministic_but_secret()
{
var secrets = NewSecrets();
Assert.Equal(secrets.EmailIndex("joueur@example.fr"), secrets.EmailIndex("joueur@example.fr"));
Assert.NotEqual(secrets.EmailIndex("joueur@example.fr"), secrets.EmailIndex("joueur2@example.fr"));
Assert.NotEqual(secrets.EmailIndex("joueur@example.fr"), NewSecrets().EmailIndex("joueur@example.fr"));
Assert.Matches("^[0-9a-f]{64}$", secrets.EmailIndex("joueur@example.fr"));
}
[Fact]
public void Master_key_file_is_validated()
{
var file = PlayerTestServer.NewKeyFile(out var directory);
try
{
Assert.NotNull(PlayerSecrets.Load(file));
File.WriteAllText(file, "pas du base64 !");
Assert.Throws<InvalidOperationException>(() => PlayerSecrets.Load(file));
File.WriteAllText(file, Convert.ToBase64String(new byte[16]));
Assert.ThrowsAny<Exception>(() => PlayerSecrets.Load(file));
File.WriteAllText(file, Convert.ToBase64String(new byte[32])); // que des zéros
Assert.ThrowsAny<CryptographicException>(() => PlayerSecrets.Load(file));
if (!OperatingSystem.IsWindows())
{
File.WriteAllText(file, PlayerSecrets.NewMasterKey());
File.SetUnixFileMode(file, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.OtherRead);
var error = Assert.Throws<InvalidOperationException>(() => PlayerSecrets.Load(file));
Assert.Contains("accessible à d'autres comptes", error.Message);
}
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public async Task Password_hash_uses_argon2id_with_pepper_and_records_its_parameters()
{
var secrets = NewSecrets();
var hasher = new PlayerPasswordHasher(secrets, FastSettings());
var hash = await hasher.HashAsync("Tr0mbone-Lavande!");
Assert.Matches(@"^\$argon2id\$v=19\$m=7168,t=6,p=1,k=1\$[A-Za-z0-9_-]{22}\$[A-Za-z0-9_-]{43}$", hash);
Assert.NotEqual(hash, await hasher.HashAsync("Tr0mbone-Lavande!")); // sel aléatoire
Assert.Equal((true, false), await hasher.VerifyAsync("Tr0mbone-Lavande!", hash));
Assert.Equal((false, false), await hasher.VerifyAsync("tr0mbone-lavande!", hash));
// Aucune normalisation Unicode (indisponible sur le serveur) : des caractères « pleine chasse » sont un autre mot de passe.
Assert.False((await hasher.VerifyAsync("Tr0mbone-Lavande!", hash)).Valid);
// Sans le poivre (autre clé maîtresse), l'empreinte ne correspond plus : une copie de la base seule est inutile.
Assert.False((await new PlayerPasswordHasher(NewSecrets(), FastSettings()).VerifyAsync("Tr0mbone-Lavande!", hash)).Valid);
Assert.False((await hasher.VerifyAsync("Tr0mbone-Lavande!", "n'importe quoi")).Valid);
}
[Fact]
public async Task Hash_made_with_older_parameters_is_flagged_for_rehash()
{
var secrets = NewSecrets();
var old = await new PlayerPasswordHasher(secrets, FastSettings()).HashAsync("Tr0mbone-Lavande!");
var stronger = FastSettings();
stronger.HashIterations = 7;
Assert.Equal((true, true), await new PlayerPasswordHasher(secrets, stronger).VerifyAsync("Tr0mbone-Lavande!", old));
}
[Fact]
public void Weak_hash_settings_are_refused_at_startup()
{
var options = new LauncherApiOptions { PublicOrigin = "https://launcher.example.fr", DataDirectory = Path.GetTempPath() };
options.Players.HashMemoryKib = 8192;
options.Players.HashIterations = 2; // 16 Mio : moins que 19 Mio × 2
Assert.Throws<InvalidOperationException>(() => options.Validate(AppContext.BaseDirectory));
}
}
@@ -0,0 +1,320 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
using GameLauncher.Website.Services;
using Microsoft.Extensions.Logging.Abstractions;
namespace GameLauncher.Tests.Website;
/// <summary>
/// Site vitrine : lecture des fichiers du serveur du launcher (signature, adresses, bornes) et liseuse
/// Markdown des actualités (aucun HTML ni lien dangereux ne doit passer).
/// </summary>
public sealed class WebsiteRemoteContentTests : IDisposable
{
private static readonly Uri UpdateUrl = new("https://launcher.example/launcher/launcher-update.json");
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
private readonly IReadOnlyList<UpdateSignature.TrustedKey> _trusted;
public WebsiteRemoteContentTests()
{
_trusted = UpdateSignature.ParsePublicKeys(_key.ExportSubjectPublicKeyInfoPem());
}
public void Dispose() => _key.Dispose();
private static LauncherRelease Release(string file = "GameLauncher-3.4.0.exe", string product = "EZRLauncher") => new()
{
Product = product,
Version = "3.4.0",
File = file,
Size = 1234,
Sha256 = new string('A', 64),
PublishedAt = new DateTimeOffset(2026, 11, 10, 12, 0, 0, TimeSpan.Zero),
PriorityVersion = "3.4.0",
Changelog = new()
{
new ChangelogEntry
{
Version = "3.4.0",
Title = "Launcher 3.4.0",
Sections = new()
{
new ChangelogSection { Title = "EN BREF", Items = { new ChangelogItem { Text = "Nouveauté" } } },
new ChangelogSection { Title = "DÉVELOPPEMENT", Items = { new ChangelogItem { Text = "Interne" } } },
new ChangelogSection { Title = "Serveur", Items = { new ChangelogItem { Text = "Rien" } } }
}
},
new ChangelogEntry { Version = "3.3.1", Title = "Ancienne" }
}
};
private byte[] Envelope(LauncherRelease release, ECDsa? key = null) =>
Encoding.UTF8.GetBytes(UpdateSignature.CreateEnvelope(JsonSerializer.SerializeToUtf8Bytes(release, JsonDefaults.Options), key ?? _key));
private static readonly string[] Hidden = { "Serveur", "Developpement" };
[Fact]
public void Launcher_signature_valide_donne_la_version_l_adresse_et_les_notes_filtrees()
{
var snapshot = RemoteContentReader.ReadLauncher(Envelope(Release()), UpdateUrl, _trusted, "EZRLauncher", Hidden, DateTimeOffset.UtcNow);
Assert.Equal("3.4.0", snapshot.Version);
Assert.Equal("https://launcher.example/launcher/GameLauncher-3.4.0.exe", snapshot.DownloadUrl.ToString());
Assert.Equal("GameLauncher-3.4.0.exe", snapshot.FileName);
Assert.Equal(new string('a', 64), snapshot.Sha256);
Assert.True(snapshot.IsPriority);
Assert.NotNull(snapshot.LatestNotes);
Assert.Equal("Launcher 3.4.0", snapshot.LatestNotes!.Title);
// Sections réservées à l'équipe retirées, sans tenir compte des accents ni de la casse.
Assert.Equal(new[] { "EN BREF" }, snapshot.LatestNotes.Sections.Select(s => s.Title).ToArray());
}
[Fact]
public void Launcher_contenu_modifie_est_refuse()
{
var json = Encoding.UTF8.GetString(Envelope(Release()));
var envelope = JsonSerializer.Deserialize<SignedEnvelope>(json, JsonDefaults.Options)!;
var payload = Encoding.UTF8.GetString(Convert.FromBase64String(envelope.Payload)).Replace("GameLauncher-3.4.0.exe", "Piege-3.4.0.exe");
envelope.Payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
var tampered = JsonSerializer.SerializeToUtf8Bytes(envelope, JsonDefaults.Options);
Assert.Throws<RemoteContentException>(() =>
RemoteContentReader.ReadLauncher(tampered, UpdateUrl, _trusted, null, Hidden, DateTimeOffset.UtcNow));
}
[Fact]
public void Launcher_signe_par_une_autre_cle_est_refuse()
{
using var other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
Assert.Throws<RemoteContentException>(() =>
RemoteContentReader.ReadLauncher(Envelope(Release(), other), UpdateUrl, _trusted, null, Hidden, DateTimeOffset.UtcNow));
}
[Theory]
[InlineData("https://ailleurs.example/GameLauncher.exe")]
[InlineData("http://launcher.example/launcher/GameLauncher.exe")]
[InlineData("https://launcher.example:8443/launcher/GameLauncher.exe")]
[InlineData("GameLauncher.exe?x=1")]
[InlineData("notes.txt")]
public void Launcher_executable_hors_du_serveur_ou_inattendu_est_refuse(string file)
{
// Même signé, un fichier ne peut pas envoyer les visiteurs vers un autre serveur.
Assert.Throws<RemoteContentException>(() =>
RemoteContentReader.ReadLauncher(Envelope(Release(file)), UpdateUrl, _trusted, null, Hidden, DateTimeOffset.UtcNow));
}
[Fact]
public void Launcher_produit_inattendu_est_refuse()
{
Assert.Throws<RemoteContentException>(() =>
RemoteContentReader.ReadLauncher(Envelope(Release(product: "AutreLauncher")), UpdateUrl, _trusted, "EZRLauncher", Hidden, DateTimeOffset.UtcNow));
}
[Fact]
public void Launcher_cle_publique_integree_au_site()
{
var keys = RemoteContentReader.LoadEmbeddedKeys();
Assert.NotEmpty(keys);
Assert.All(keys, k => Assert.Equal(16, k.KeyId.Length));
}
private static byte[] Manifest(string gameId, int entries, string extraChangelogText = "Texte")
{
var manifest = new
{
formatVersion = 1,
gameId,
version = "1.8",
releaseDate = new DateTimeOffset(2026, 11, 1, 18, 0, 0, TimeSpan.FromHours(1)),
archive = new { url = "jeu.zip", size = 1000L, sha256 = new string('b', 64) },
files = new[] { new { path = "a.bin", size = 600L }, new { path = "b.bin", size = 400L } },
changelog = Enumerable.Range(0, entries).Select(i => new
{
version = $"1.{entries - i}",
title = "Version‮ " + i,
sections = new[] { new { title = "NOUVEAU", items = new[] { new { text = extraChangelogText } } } }
})
};
return JsonSerializer.SerializeToUtf8Bytes(manifest);
}
[Fact]
public void Jeu_manifest_lu_borne_et_nettoye_comme_dans_le_launcher()
{
var snapshot = RemoteContentReader.ReadGame(Manifest("EZR", 50, new string('x', 5000)), "EZR", DateTimeOffset.UtcNow);
Assert.Equal("1.8", snapshot.Version);
Assert.Equal(1000, snapshot.DownloadSize);
Assert.Equal(1000, snapshot.InstalledSize);
Assert.Equal(ChangelogLimits.MaxEntries, snapshot.Changelog.Count);
Assert.DoesNotContain('‮', snapshot.Changelog[0].Title!);
Assert.True(snapshot.Changelog[0].Sections[0].Items[0].Text.Length <= ChangelogLimits.MaxTextLength);
}
[Fact]
public void Jeu_manifest_d_un_autre_jeu_est_refuse()
{
Assert.Throws<RemoteContentException>(() => RemoteContentReader.ReadGame(Manifest("AUTRE", 1), "EZR", DateTimeOffset.UtcNow));
}
[Fact]
public void Jeu_manifest_illisible_est_refuse()
{
Assert.Throws<RemoteContentException>(() => RemoteContentReader.ReadGame(Encoding.UTF8.GetBytes("{ pas du json"), "EZR", DateTimeOffset.UtcNow));
Assert.Throws<RemoteContentException>(() => RemoteContentReader.ReadGame(Encoding.UTF8.GetBytes("{\"gameId\":\"EZR\",\"version\":\"abc\"}"), "EZR", DateTimeOffset.UtcNow));
}
[Theory]
[InlineData("https://launcher.example/manifest.json", false, true)]
[InlineData("http://launcher.example/manifest.json", true, false)]
[InlineData("http://127.0.0.1:8099/manifest.json", false, false)]
[InlineData("http://127.0.0.1:8099/manifest.json", true, true)]
[InlineData("https://user:secret@launcher.example/manifest.json", false, false)]
[InlineData("file:///etc/passwd", true, false)]
[InlineData("pas une adresse", false, false)]
public void Adresses_lues_https_seulement(string url, bool allowLoopbackHttp, bool expected)
{
Assert.Equal(expected, RemoteFetcher.IsAllowedUrl(url, allowLoopbackHttp, out _));
}
[Fact]
public void Erreur_reseau_detaillee_certificat_refuse()
{
var ex = new HttpRequestException("The SSL connection could not be established, see inner exception.",
new System.Security.Authentication.AuthenticationException("The remote certificate is invalid: PartialChain"));
var text = RemoteFetcher.Describe(ex);
Assert.StartsWith("certificat HTTPS refusé", text);
Assert.Contains("PartialChain", text);
Assert.StartsWith("serveur injoignable", RemoteFetcher.Describe(new HttpRequestException("Name or service not known")));
}
[Fact]
public async Task Lecture_bornee_au_dela_de_la_limite()
{
using var small = new MemoryStream(new byte[100]);
Assert.NotNull(await RemoteFetcher.ReadBoundedAsync(small, 100, CancellationToken.None));
using var big = new MemoryStream(new byte[101]);
Assert.Null(await RemoteFetcher.ReadBoundedAsync(big, 100, CancellationToken.None));
}
}
public sealed class WebsiteMarkdownTests
{
[Fact]
public void Html_brut_affiche_jamais_interprete()
{
var html = SafeMarkdown.ToHtml("Bonjour <script>alert(1)</script> <img src=x onerror=alert(1)>");
Assert.DoesNotContain("<script", html);
Assert.DoesNotContain("<img", html);
Assert.Contains("&lt;script&gt;", html);
}
[Theory]
[InlineData("[clic](javascript:alert(1))")]
[InlineData("[clic](JaVaScRiPt:alert(1))")]
[InlineData("[clic](data:text/html;base64,PHNjcmlwdD4=)")]
[InlineData("[clic](//evil.example/x)")]
[InlineData("[clic](vbscript:msgbox)")]
public void Liens_dangereux_retires(string markdown)
{
var html = SafeMarkdown.ToHtml(markdown);
Assert.DoesNotContain("href", html);
Assert.Contains("clic", html);
}
[Fact]
public void Lien_externe_sans_acces_a_la_page_d_origine()
{
var html = SafeMarkdown.ToHtml("Voir [le site](https://example.org/page).");
Assert.Contains("<a href=\"https://example.org/page\" rel=\"noopener noreferrer\">le site</a>", html);
var local = SafeMarkdown.ToHtml("[Notes](/jeux/exovival-zombie-reborn/notes-de-version)");
Assert.Contains("<a href=\"/jeux/exovival-zombie-reborn/notes-de-version\">Notes</a>", local);
}
[Fact]
public void Attribut_impossible_a_injecter_dans_un_lien()
{
var html = SafeMarkdown.ToHtml("[x](/a\"onmouseover=alert(1))");
Assert.DoesNotContain("\"onmouseover", html);
}
[Theory]
[InlineData("![photo](https://evil.example/x.png)")]
[InlineData("![photo](/img/../appsettings.json)")]
[InlineData("![photo](/uploads/x.png)")]
public void Images_hors_du_site_refusees(string markdown)
{
Assert.DoesNotContain("<img", SafeMarkdown.ToHtml(markdown));
}
[Fact]
public void Syntaxe_prise_en_charge()
{
var html = SafeMarkdown.ToHtml("# Titre\n\nUn **gras**, un *italique* et `du code`.\n\n- un\n- deux\n - sous-point\n\n1. premier\n2. second\n\n> citation\n\n---\n\n![Le gymnase](/img/exovival/vhs-gymnase.webp)");
Assert.Contains("<h2>Titre</h2>", html);
Assert.Contains("<strong>gras</strong>", html);
Assert.Contains("<em>italique</em>", html);
Assert.Contains("<code>du code</code>", html);
Assert.Contains("<ul><li>un</li><li>deux<ul><li>sous-point</li></ul></li></ul>", html);
Assert.Contains("<ol><li>premier</li><li>second</li></ol>", html);
Assert.Contains("<blockquote><p>citation</p></blockquote>", html);
Assert.Contains("<hr>", html);
Assert.Contains("<img src=\"/img/exovival/vhs-gymnase.webp\" alt=\"Le gymnase\"", html);
}
}
public sealed class WebsiteNewsTests
{
[Fact]
public void Article_lu_avec_son_en_tete()
{
var article = NewsService.Parse("mon-article", "---\ntitle: \"Titre : avec deux-points\"\ndate: 2026-11-10\nsummary: Résumé\ntheme: exovival\nimage: /img/exovival/couloir.webp\n---\nCorps **gras**.");
Assert.NotNull(article);
Assert.Equal("Titre : avec deux-points", article!.Title);
Assert.Equal(new DateOnly(2026, 11, 10), article.Date);
Assert.Equal("exovival", article.Theme);
Assert.Equal("/img/exovival/couloir.webp", article.Image);
Assert.Contains("<strong>gras</strong>", article.Html);
}
[Theory]
[InlineData("Mon_Article")]
[InlineData("../secret")]
[InlineData("")]
public void Nom_de_fichier_incorrect_ignore(string slug)
{
Assert.Null(NewsService.Parse(slug, "---\ntitle: T\ndate: 2026-11-10\n---\nx"));
}
[Fact]
public void En_tete_incomplet_ignore_et_valeurs_inconnues_neutralisees()
{
Assert.Null(NewsService.Parse("a", "---\ntitle: T\n---\nx"));
Assert.Null(NewsService.Parse("a", "title: T\ndate: 2026-11-10\nx"));
var article = NewsService.Parse("a", "---\ntitle: T\ndate: 2026-11-10\ntheme: <script>\nimage: https://evil.example/x.png\n---\nx");
Assert.Equal("launcher", article!.Theme);
Assert.Null(article.Image);
}
[Fact]
public void Brouillons_masques_hors_developpement()
{
var folder = Directory.CreateTempSubdirectory("news-tests").FullName;
try
{
File.WriteAllText(Path.Combine(folder, "publie.md"), "---\ntitle: A\ndate: 2026-11-10\n---\nx");
File.WriteAllText(Path.Combine(folder, "brouillon.md"), "---\ntitle: B\ndate: 2026-11-11\ndraft: true\n---\nx");
Assert.Single(new NewsService(folder, includeDrafts: false, NullLogger.Instance).All);
Assert.Equal(2, new NewsService(folder, includeDrafts: true, NullLogger.Instance).All.Count);
Assert.Null(new NewsService(folder, includeDrafts: false, NullLogger.Instance).Find("../publie"));
}
finally
{
Directory.Delete(folder, true);
}
}
}
+51 -7
View File
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Globalization;
using System.IO;
using System.Text;
@@ -9,6 +10,7 @@ using System.Windows.Controls.Primitives;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using SkiaSharp;
namespace UiSnapshot;
@@ -54,7 +56,7 @@ internal interface ISceneSet
}
/// <summary>
/// Rendu des fenêtres en PNG et diagnostic de leur mise en page. Les fenêtres sont affichées HORS de
/// Rendu des fenêtres en PNG (ou WebP) et diagnostic de leur mise en page. Les fenêtres sont affichées HORS de
/// l'écran (à droite de tous les moniteurs), sans prendre le focus : rien n'apparaît pendant l'exécution.
/// </summary>
internal sealed class Capture
@@ -222,7 +224,7 @@ internal sealed class Capture
root.UpdateLayout();
var image = Render(root);
Save(image, record.Id + ".png", record);
Save(image, record.Id + _options.ImageExtension, record);
record.Size = $"{image.PixelWidth}×{image.PixelHeight}";
Diagnose(root, record);
@@ -267,16 +269,58 @@ internal sealed class Capture
private void Save(BitmapSource image, string fileName, SceneRecord record)
{
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(image));
using (var stream = File.Create(Path.Combine(OutputDirectory, fileName)))
encoder.Save(stream);
{
if (_options.Format == SnapshotOptions.FormatWebp)
{
SaveWebp(image, stream, _options.Quality);
}
else
{
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(image));
encoder.Save(stream);
}
}
record.Files.Add(fileName);
}
/// <summary>
/// WebP (--format webp) : Windows (WIC) sait lire le WebP mais pas l'écrire ; l'encodage passe par
/// SkiaSharp (bibliothèque graphique maintenue par Microsoft, licence MIT, utilisée par cet outil de
/// développement seulement : jamais par le launcher). Les pixels du rendu WPF (BGRA prémultiplié) sont
/// copiés tels quels ; la transparence (--backdrop transparent) est conservée.
/// </summary>
private static void SaveWebp(BitmapSource image, Stream stream, int quality)
{
if (image.Format != PixelFormats.Pbgra32)
image = new FormatConvertedBitmap(image, PixelFormats.Pbgra32, null, 0);
var width = image.PixelWidth;
var height = image.PixelHeight;
var stride = width * 4;
var pixels = new byte[stride * height];
image.CopyPixels(pixels, stride, 0);
var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned);
try
{
var info = new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul);
using var pixmap = new SKPixmap(info, handle.AddrOfPinnedObject(), stride);
var compression = quality >= 100 ? SKWebpEncoderCompression.Lossless : SKWebpEncoderCompression.Lossy;
using var data = pixmap.Encode(new SKWebpEncoderOptions(compression, quality))
?? throw new InvalidOperationException("Encodage WebP impossible (SkiaSharp).");
data.SaveTo(stream);
}
finally
{
handle.Free();
}
}
/// <summary>
/// Zone qui défile (la plus grande de l'écran) : une capture par « page » supplémentaire
/// (&lt;scène&gt;.p2.png…, 4 au plus), puis retour en haut. La dernière capture montre toujours le BAS
/// (&lt;scène&gt;.p2.png ou .p2.webp…, 4 au plus), puis retour en haut. La dernière capture montre toujours le BAS
/// de la zone (une page très longue n'a pas de pages intermédiaires, mais sa fin est visible).
/// </summary>
private async Task ScrollPagesAsync(FrameworkElement root, SceneRecord record)
@@ -295,7 +339,7 @@ internal sealed class Capture
var target = page == 5 ? scroller.ScrollableHeight : Math.Min(offset, scroller.ScrollableHeight);
scroller.ScrollToVerticalOffset(target);
await SettleAsync(160);
Save(Render(root), $"{record.Id}.p{page}.png", record);
Save(Render(root), $"{record.Id}.p{page}{_options.ImageExtension}", record);
if (target >= scroller.ScrollableHeight) break;
}
scroller.ScrollToVerticalOffset(original);
+9
View File
@@ -621,6 +621,15 @@ internal sealed class SampleWorld
Add(2.01, "feedback.submit", AuditResults.Ok, null, $"{game} FB-7K2M-Q9XD", "Bug ; 2 capture(s) d'écran annoncée(s)", ip: null);
Add(9, "feedback.submit", AuditResults.Denied, null, details: "3 demande(s) refusée(s) : limite par adresse atteinte", ip: null);
Add(30, "feedback.update", AuditResults.Ok, "Camille", $"{game} FB-3PQR-8WXT", "Nouvelle → En cours ; note modifiée");
// Publication des formulaires : une ligne de résumé, puis une par type de demande (nom et détail des champs).
Add(12, "feedback.forms", AuditResults.Ok, "Camille", "révision 4",
$"1 type ajouté : « Suggestion de niveau » ; 1 type modifié : « Problème technique » ; signé (clé {OwnerKeyId})");
Add(12, "feedback.form_create", AuditResults.Ok, "Camille", "« Suggestion de niveau »",
"icône importée ; captures d'écran non proposées ; 1 champ(s) ; champ « Message » (zone de texte, obligatoire)");
Add(12, "feedback.form_update", AuditResults.Ok, "Camille", "« Problème technique »",
"nom affiché : « Bug » → « Problème technique » ; champ supprimé : « Résultat attendu » ; champ « Gravité » modifié : " +
"rendu facultatif, choix ajouté « Très grave », choix renommé « Plantage » → « Plantage du jeu » ; " +
"champ ajouté : « Version affichée en jeu » (texte court, facultatif)");
Add(1, "admins.invite", AuditResults.Ok, OwnerName, "Nicolas", "nouvel appareil pour Nicolas");
Add(3, "admins.invite", AuditResults.Ok, OwnerName, "Alex", "Alex (editor)");
Add(5, "auth.enroll", AuditResults.Ok, null, "Léo", "clé en attente d'approbation", "198.51.100.7");
+25 -4
View File
@@ -1,6 +1,6 @@
# UiSnapshot — captures de toutes les fenêtres du launcher
Outil de développement : il rend **chaque écran du launcher en PNG**, avec des données de démonstration,
Outil de développement : il rend **chaque écran du launcher en PNG** (ou en WebP, `--format webp`), avec des données de démonstration,
et signale les problèmes de mise en page. Il sert à vérifier un réglage graphique (WPF) sans cliquer
partout, et à montrer l'interface à quelqu'un qui ne peut pas lancer le launcher (Claude, par exemple).
@@ -8,6 +8,7 @@ partout, et à montrer l'interface à quelqu'un qui ne peut pas lancer le launch
tools\ui-snapshot.bat toutes les scènes, thème de chaque jeu
tools\ui-snapshot.bat --filter "main-*" seulement la fenêtre principale
tools\ui-snapshot.bat --filter "admin-page-*" --theme all --scale 2
tools\ui-snapshot.bat --filter "main-*" --format webp captures pour le site vitrine (WebP)
tools\ui-snapshot.bat --list liste des scènes
tools\ui-snapshot.bat --help toutes les options
```
@@ -19,14 +20,34 @@ arguments éventuels dans Propriétés › Déboguer.
| Fichier | Contenu |
|---|---|
| `<scène>.png` | La fenêtre telle qu'affichée (cadre, coins arrondis, ombre) sur un fond neutre |
| `<scène>.p2.png`… | Pages suivantes quand une zone défile (jusqu'à 4) |
| `<scène>.png` (ou `.webp`) | La fenêtre telle qu'affichée (cadre, coins arrondis, ombre) sur un fond neutre |
| `<scène>.p2.png`… (ou `.p2.webp`) | Pages suivantes quand une zone défile (jusqu'à 4) |
| `report.md` | **À lire en premier** : texte coupé ou tronqué, éléments qui dépassent de la fenêtre, images sans source, erreurs de liaison (`{Binding}` vers une propriété inexistante), messages que l'écran a tenté d'afficher, routes de l'API non simulées |
| `index.html` | Planche de toutes les captures (clic : taille réelle) |
| `<scène>.tree.txt` | Avec `--tree` : arbre des éléments (type, `x:Name`, position, texte) pour retrouver un élément dans le XAML |
| `launcher.log` | Journal du launcher pendant les captures |
Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les pixels du PNG).
Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les pixels des images).
## Captures pour le site vitrine (WebP)
Le site (`website\GameLauncher.Website\wwwroot\img\launcher\`) utilise des images **WebP** : plus légères que le PNG
à qualité égale. `--format webp` les produit directement :
```
tools\ui-snapshot.bat --filter "main-*,launcher-update-verified" --format webp --quality 85
```
* `--quality` : 1 à 100 (défaut 85, très bon compromis pour des captures d'interface) ; **100 = sans perte**.
Ignorée en PNG, toujours sans perte.
* `--backdrop transparent` : la transparence (coins arrondis, ombre) est conservée en WebP.
* `--scale 1` (défaut) donne la taille de la fenêtre (environ 1200 px de large), adaptée au site ; `--scale 2`
double la taille et le poids.
* Copier ensuite les fichiers voulus de `artifacts\ui-snapshots` dans `wwwroot\img\launcher\` sous les noms
utilisés par les pages (`apercu-exovival.webp`, `apercu-menu.webp`, `apercu-telechargement.webp`,
`apercu-multijeux.webp`, `mise-a-jour-verifiee.webp`), puis republier le site.
* Encodage par **SkiaSharp** (paquet NuGet maintenu par Microsoft, licence MIT, restauré automatiquement par
Visual Studio) : Windows sait lire le WebP mais pas l'écrire. Seul cet outil l'utilise.
## Scènes
+8 -4
View File
@@ -7,6 +7,10 @@ namespace UiSnapshot;
/// <summary>report.md (problèmes détectés, à lire en premier) et index.html (planche de toutes les captures).</summary>
internal static class Report
{
/// <summary>Image de capture (PNG ou WebP, selon --format), par opposition aux autres fichiers produits.</summary>
private static bool IsImage(string file) =>
file.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || file.EndsWith(".webp", StringComparison.OrdinalIgnoreCase);
public static void Write(Capture capture, SnapshotOptions options, TimeSpan duration)
{
File.WriteAllText(Path.Combine(capture.OutputDirectory, "report.md"), Markdown(capture, options, duration), new UTF8Encoding(false));
@@ -21,7 +25,7 @@ internal static class Report
sb.AppendLine($"Échelle {options.Scale:0.##} · thème {(options.Theme ?? "du jeu")} · attente {options.WaitMilliseconds} ms · " +
$"{(options.Filters.Count == 0 ? "toutes les scènes" : "filtre " + string.Join(", ", options.Filters))} · " +
$"{duration.TotalSeconds:0} s");
sb.AppendLine("Positions en unités WPF (× échelle pour les pixels du PNG). Données de démonstration, aucun serveur réel.");
sb.AppendLine("Positions en unités WPF (× échelle pour les pixels des images). Données de démonstration, aucun serveur réel.");
sb.AppendLine();
sb.AppendLine("| Scène | Image | Taille | Problèmes | Remarques |");
@@ -29,7 +33,7 @@ internal static class Report
foreach (var r in capture.Records)
{
var status = r.Error is not null ? "**ÉCHEC**" : r.Skipped is not null ? "ignorée" : (r.Issues.Count + r.Bindings.Count).ToString();
sb.AppendLine($"| `{r.Id}` | {string.Join(" ", r.Files.Where(f => f.EndsWith(".png", StringComparison.Ordinal)))} | {r.Size} | {status} | {r.Notes.Count} |");
sb.AppendLine($"| `{r.Id}` | {string.Join(" ", r.Files.Where(IsImage))} | {r.Size} | {status} | {r.Notes.Count} |");
}
sb.AppendLine();
@@ -72,12 +76,12 @@ internal static class Report
sb.AppendLine(".pages a{color:var(--muted);margin-right:8px;font-size:12px}");
sb.AppendLine("ul{margin:6px 0 0;padding-left:18px;font-size:12px}li.warn{color:var(--warn)}li.bad{color:var(--bad)}li.note{color:var(--muted)}");
sb.AppendLine("</style></head><body>");
sb.AppendLine($"<h1>Captures du launcher</h1><p class=\"sub\">{DateTime.Now:dd/MM/yyyy HH:mm} · échelle {options.Scale:0.##} · " +
sb.AppendLine($"<h1>Captures du launcher</h1><p class=\"sub\">{DateTime.Now:dd/MM/yyyy HH:mm} · échelle {options.Scale:0.##} · {options.Format.ToUpperInvariant()} · " +
$"{capture.Records.Count} scène(s) · données de démonstration · détails dans report.md</p>");
sb.AppendLine("<div class=\"grid\">");
foreach (var r in capture.Records)
{
var pngs = r.Files.Where(f => f.EndsWith(".png", StringComparison.Ordinal)).ToList();
var pngs = r.Files.Where(IsImage).ToList();
sb.AppendLine("<div class=\"card\">");
if (pngs.Count > 0) sb.AppendLine($"<a href=\"{E(pngs[0])}\"><img loading=\"lazy\" src=\"{E(pngs[0])}\" alt=\"{E(r.Title)}\"></a>");
sb.AppendLine($"<div class=\"meta\"><div class=\"id\">{E(r.Id)}</div><div class=\"title\">{E(r.Title)} {E(r.Size)}</div>");
+29 -1
View File
@@ -29,6 +29,18 @@ internal sealed class SnapshotOptions
/// <summary>Pages supplémentaires (.p2.png…) quand une zone défile.</summary>
public bool Scroll { get; private set; } = true;
/// <summary>Format des images : « png » (défaut) ou « webp » (format des images du site vitrine).</summary>
public string Format { get; private set; } = FormatPng;
/// <summary>Qualité WebP, 1 à 100 (100 : sans perte). Ignorée en PNG (toujours sans perte).</summary>
public int Quality { get; private set; } = 85;
public const string FormatPng = "png";
public const string FormatWebp = "webp";
/// <summary>Extension des images produites, avec le point (« .png », « .webp »).</summary>
public string ImageExtension => "." + Format;
/// <summary>Fond derrière les fenêtres (coins arrondis, ombres) ; null : transparent.</summary>
public Color? Backdrop { get; private set; } = Color.FromRgb(0x1B, 0x1D, 0x22);
@@ -52,11 +64,14 @@ internal sealed class SnapshotOptions
--tree écrit aussi l'arbre visuel de chaque scène (<scène>.tree.txt)
--no-scroll pas de pages supplémentaires pour les zones qui défilent
--backdrop <couleur> fond derrière les fenêtres : #RRGGBB (défaut #1B1D22) ou « transparent »
--format <png|webp> format des images : « png » (défaut, sans perte) ou « webp » (format des
images du site vitrine, website\GameLauncher.Website\wwwroot\img)
--quality <1-100> qualité WebP (défaut 85 ; 100 = sans perte) ; ignorée en PNG
--out <dossier> dossier des captures (défaut : artifacts\ui-snapshots)
--open ouvre index.html à la fin
--help cette aide
Résultat : un PNG par scène, index.html (planche de toutes les captures) et report.md
Résultat : une image par scène (PNG ou WebP), index.html (planche de toutes les captures) et report.md
(problèmes détectés : texte coupé, débordement, erreurs de liaison, messages affichés…).
""";
@@ -117,6 +132,19 @@ internal sealed class SnapshotOptions
}
}
break;
case "--format":
var format = Next().Trim().TrimStart('.').ToLowerInvariant();
if (format is not (FormatPng or FormatWebp))
throw new ArgumentException($"Format inconnu : « {format} » (png ou webp).");
options.Format = format;
break;
case "--quality":
var qualityText = Next();
if (!int.TryParse(qualityText, NumberStyles.None, CultureInfo.InvariantCulture, out var quality) ||
quality < 1 || quality > 100)
throw new ArgumentException($"Qualité invalide : « {qualityText} » (1 à 100).");
options.Quality = quality;
break;
case "--out":
case "-o":
options.OutputDirectory = Next();
+1 -1
View File
@@ -113,7 +113,7 @@ internal sealed class SnapshotRunner
var marker = Path.Combine(output, MarkerFile);
if (Directory.Exists(output) && File.Exists(marker) && _options.Filters.Count == 0)
{
foreach (var pattern in new[] { "*.png", "*.tree.txt", "report.md", "index.html", "launcher.log", "launcher.old.log" })
foreach (var pattern in new[] { "*.png", "*.webp", "*.tree.txt", "report.md", "index.html", "launcher.log", "launcher.old.log" })
{
foreach (var file in Directory.GetFiles(output, pattern, SearchOption.TopDirectoryOnly))
{
+10
View File
@@ -20,6 +20,16 @@
<SatelliteResourceLanguages>fr</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<!--
Option « format webp » de l'outil : Windows (WIC) lit le WebP mais ne sait pas l'écrire. SkiaSharp (maintenu par Microsoft,
licence MIT) encode les captures en WebP. Outil de développement uniquement : le launcher, la Launcher API
et le site ne dépendent pas de ce paquet.
-->
<PackageReference Include="SkiaSharp" Version="3.119.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Win32" Version="3.119.1" />
</ItemGroup>
<ItemGroup>
<!-- Le launcher lui-même : ses vraies fenêtres, thèmes, styles et ViewModels sont rendus tels quels. -->
<ProjectReference Include="..\..\src\GameLauncher\GameLauncher.csproj" />
+40 -44
View File
@@ -1,70 +1,66 @@
# Launcher 3.4.0
# Launcher 3.4.2
LAUNCHER - NOTES DE MISE À JOUR
Mises à jour prioritaires du launcher - Mise à jour du 10 novembre 2026
Version de maintenance du launcher - Mise à jour du 10 novembre 2026
=========================================================================
Une version du launcher peut désormais être publiée comme prioritaire, pour un correctif critique (faille de sécurité…) : elle s'installe au démarrage même si vous avez désactivé la mise à jour automatique du launcher. Votre réglage n'est pas modifié : les versions normales suivantes ne s'installeront toujours pas toutes seules.
Version de maintenance : pour les joueurs, le launcher fonctionne exactement comme en 3.4.1. Le nom de l'éditeur par défaut est désormais LanisysGames, et le journal d'audit du menu d'administration détaille les modifications des formulaires des demandes.
Aucune action sur le serveur. Le launcher se met à jour automatiquement.
Le launcher se met à jour automatiquement. Administrateurs : mettre à jour la Launcher API (voir SERVEUR).
------------------------------------------------------------------
EN BREF
------------------------------------------------------------------
- NOUVEAU : version prioritaire, installée au démarrage même avec
la mise à jour automatique du launcher désactivée.
- NOUVEAU : pastille « Prioritaire » dans la fenêtre de mise à
jour, avertissement dans le bandeau et les paramètres tant
qu'elle n'est pas installée.
- SÉCURITÉ : l'indication « prioritaire » est signée, comme le
reste de la mise à jour.
- QUALITÉ : nouveaux tests automatisés.
- INFO : changement du nom de l'éditeur par défaut. Les propriétés
de GameLauncher.exe (clic droit > Propriétés > Détails)
indiquent désormais LanisysGames.
- INFO : aucun changement dans l'utilisation du launcher :
installation, mises à jour, vérification et réparation des
jeux restent identiques. Vos jeux, vos paramètres et vos
dossiers d'installation sont conservés.
- SÉCURITÉ : comme pour chaque version, la signature numérique
et l'empreinte SHA-256 sont vérifiées avant l'installation.
------------------------------------------------------------------
VERSION PRIORITAIRE
MENU D'ADMINISTRATION
------------------------------------------------------------------
- Votre réglage « Mettre à jour le launcher automatiquement »
n'est jamais modifié : effet ponctuel, le temps d'installer le
correctif.
- « Plus tard » reste possible : nouvelle tentative au prochain
démarrage du launcher.
- Une erreur de téléchargement est retentée au démarrage suivant.
- Si la nouvelle version ne démarre pas, l'ancienne est remise en
place et le bouton « Vérifier les mises à jour » permet de
réessayer.
- Mise à jour automatique désactivée : le launcher vérifie
désormais au démarrage si une version prioritaire existe.
------------------------------------------------------------------
SÉCURITÉ
------------------------------------------------------------------
- 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.
- Seule une version signée et strictement supérieure peut être
installée, comme avant.
- Journal d'audit : une ligne par type de demande ajouté, modifié
ou supprimé lors d'une publication des formulaires, avec son nom
(« Type de demande ajouté », « modifié », « supprimé »).
- Type modifié : tous ses changements (nom affiché, description,
exemple de titre, masqué, icône, captures d'écran,
informations techniques) et chaque champ ajouté, supprimé ou
modifié, avec ce qui a changé (libellé, type, obligatoire,
aide, longueur, étoiles, choix, aperçu, ordre).
- Ligne de la publication : révision, types ajoutés, modifiés et
supprimés par leur nom, ordre des types, signature.
- Détail trop long : réparti sur plusieurs lignes numérotées
(1/2, 2/2...), rien n'est tronqué.
------------------------------------------------------------------
SERVEUR
------------------------------------------------------------------
- Aucun changement.
- Launcher API à mettre à jour (server/deploy/LISEZMOI.md,
étape 8) pour obtenir le détail des formulaires dans le
journal d'audit. Aucun réglage ni bloc nginx à ajouter.
------------------------------------------------------------------
DÉVELOPPEMENT
------------------------------------------------------------------
- publish-launcher-update.bat --priority (version prioritaire,
confirmation demandée) et --clear-priority (ne reprend pas la
priorité déjà publiée).
- LauncherPublisher : options --priority, --clear-priority, --yes ;
ligne « Priorité » dans publish et verify.
- Core : LauncherRelease.PriorityVersion (reprise d'une
publication à l'autre), LauncherUpdateCheck.IsPriority.
- UiSnapshot : scène launcher-update-priority.
- Tests : LauncherPriorityTests.
- Changement du nom de l'éditeur par défaut (LauncherConfig) et
des propriétés communes des projets (Directory.Build.props :
société, auteurs, copyright).
- Documentation : captures docs/preview.png et
docs/preview-update.png mises à jour (changement de
l'éditeur), avec une version WebP de chacune.
- Serveur : FeedbackFormsAudit (détail des publications des
formulaires) remplace FeedbackFormsStore.Describe.
- UiSnapshot : exemples des nouvelles lignes dans la page
« Journal d'audit ».
- Tests : 1 nouveau test, 3 tests complétés.
La sécurité des mises à jour du launcher ne change pas : la signature numérique et l'empreinte SHA-256 de chaque nouvelle version sont vérifiées avant toute installation.
+50
View File
@@ -0,0 +1,50 @@
@echo off
REM ---------------------------------------------------------------------------
REM Publie le SITE VITRINE pour le VPS (Linux x64, autonome : aucun .NET a
REM installer sur le serveur) et prepare les fichiers a envoyer :
REM Publish\Site\lanisys-site.tar.gz site + service systemd
REM Publish\Site\installer-site.sh script d'installation / mise a jour
REM Guide complet : website\deploy\LISEZMOI.md
REM ---------------------------------------------------------------------------
setlocal
set ROOT=%~dp0..
set OUT=%ROOT%\Publish\Site
set STAGE=%OUT%\lanisys-site
if exist "%STAGE%" rmdir /s /q "%STAGE%"
if exist "%OUT%\lanisys-site.tar.gz" del /q "%OUT%\lanisys-site.tar.gz"
set HASH=
echo === 1/2 Compilation (linux-x64) ===
dotnet publish "%ROOT%\website\GameLauncher.Website\GameLauncher.Website.csproj" -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -p:DebugType=None -o "%STAGE%"
if errorlevel 1 goto :error
if exist "%STAGE%\appsettings.Development.json" del /q "%STAGE%\appsettings.Development.json"
echo.
echo === 2/2 Archive ===
mkdir "%STAGE%\deploy"
copy /Y "%ROOT%\website\deploy\lanisys-site.service" "%STAGE%\deploy\" >nul
copy /Y "%ROOT%\website\deploy\installer-site.sh" "%OUT%\" >nul
tar -czf "%OUT%\lanisys-site.tar.gz" -C "%STAGE%" .
if errorlevel 1 goto :error
REM Empreinte SHA-256 : verifiee par installer-site.sh (archive alteree = refusee)
for /f %%h in ('powershell -NoProfile -Command "(Get-FileHash -Algorithm SHA256 -LiteralPath '%OUT%\lanisys-site.tar.gz').Hash.ToLower()"') do set HASH=%%h
if "%HASH%"=="" goto :error
> "%OUT%\lanisys-site.tar.gz.sha256" echo %HASH%
echo.
echo Pret. Envoi sur le VPS du site (compte admin) :
echo scp "%OUT%\lanisys-site.tar.gz" "%OUT%\installer-site.sh" admin@^<VPS du site^>:/tmp/
echo Puis, sur le VPS :
echo sudo sh /tmp/installer-site.sh /tmp/lanisys-site.tar.gz %HASH%
echo (empreinte aussi enregistree dans lanisys-site.tar.gz.sha256)
explorer "%OUT%"
pause
exit /b 0
:error
echo.
echo La publication a echoue.
pause
exit /b 1
+2 -1
View File
@@ -2,7 +2,7 @@
rem ============================================================================
rem Captures de toutes les fenêtres du launcher (outil tools\UiSnapshot).
rem Compile le launcher et l'outil, puis écrit dans artifacts\ui-snapshots :
rem - un PNG par écran (états de la fenêtre principale, paramètres, dialogues,
rem - une image par écran, PNG ou WebP (états de la fenêtre principale, paramètres, dialogues,
rem mise à jour du launcher, menu d'administration et chacune de ses pages) ;
rem - index.html : planche de toutes les captures ;
rem - report.md : problèmes détectés (texte coupé, débordement, liaisons…).
@@ -12,6 +12,7 @@ rem Exemples :
rem tools\ui-snapshot.bat
rem tools\ui-snapshot.bat --filter "main-*" --scale 2
rem tools\ui-snapshot.bat --filter "admin-page-*" --theme all --open
rem tools\ui-snapshot.bat --filter "main-*" --format webp --quality 85 (images du site vitrine)
rem tools\ui-snapshot.bat --list
rem ============================================================================
setlocal
@@ -0,0 +1,21 @@
---
title: Bienvenue sur le site de LanisysGames
date: 2026-11-10
summary: Nos jeux, le launcher et les nouvelles du studio ont maintenant leur adresse.
topic: Studio
image: /img/launcher/planete.webp
imageAlt: Planète aux anneaux sur un ciel violet, l'illustration du launcher.
---
Jusqu'ici, il fallait passer par le launcher pour suivre nos jeux. Ce site rassemble désormais tout au même endroit :
- **le launcher**, à télécharger en un clic, avec les nouveautés de sa dernière version ;
- **Exovival Zombie Reborn**, son histoire, ce qui vous attend dans l'école Jean-Moulin, et toutes ses [notes de version](/jeux/exovival-zombie-reborn/notes-de-version) ;
- **les actualités** du studio, comme celle-ci.
## Un site discret
Pas de compte, pas de cookie, pas de mesure d'audience : le site ne vous suit pas. Les polices et les images sont servies par le site lui-même, sans passer par un service externe. Tout est expliqué sur la page [Confidentialité](/confidentialite).
## Une question, une idée ?
Le bouton « Nous contacter » ouvre votre messagerie. Pour un bug dans un jeu, le bouton **Aide** du launcher reste le plus pratique : il joint automatiquement la version du jeu, et vous pouvez ajouter des captures d'écran.
@@ -0,0 +1,30 @@
---
title: "Exovival Zombie Reborn 1.8 : les zombies vous entendent"
date: 2026-11-01
summary: Huit zombies rôdent dès le début de la partie, ils réagissent au bruit de vos pas, et de petits soins à fabriquer soi-même font leur apparition.
topic: Exovival Zombie Reborn
theme: exovival
image: /img/exovival/vhs-gymnase.webp
imageAlt: Le gymnase de l'école, image extraite d'une cassette VHS du jeu.
---
La version 1.8 remplit l'école de zombies, leur donne de l'oreille et rééquilibre les armes. Merci aux bêta-testeurs pour leurs retours !
## L'école n'est plus vide
Huit zombies rôdent dès le début de la partie : autour du car scolaire, dans la cour de service, sur le toit bas, au gymnase et à l'étage. Plus lents et moins forts que ceux qui vous traquent pendant l'histoire, ils se distancent en marchant. Mais attention aux groupes : quand l'un d'eux attaque, ceux d'à côté accourent.
## Faites moins de bruit
- Marcher fait du bruit, courir s'entend de beaucoup plus loin, même dans le dos d'un zombie.
- Un mur ou une porte fermée étouffe vos pas.
- **Accroupi** (touche Ctrl), vous êtes silencieux : de quoi approcher un zombie par derrière.
## Des armes plus lisibles
Chaque arme fait toujours les mêmes dégâts, et viser la tête les double. La hache reste la plus puissante : un seul coup à la tête suffit.
## Se soigner à la Resident Evil
Des herbes et des huiles médicinales sont cachées dans l'école. Mélangez-les dans l'inventaire pour obtenir une potion bien plus efficace. Une note de l'infirmière, quelque part dans les toilettes, vous explique tout.
Le détail complet est dans les [notes de version](/jeux/exovival-zombie-reborn/notes-de-version). La mise à jour s'installe automatiquement avec le launcher.
@@ -0,0 +1,21 @@
---
title: "Launcher 3.4 : les correctifs importants arrivent chez tout le monde"
date: 2026-11-10
summary: Une mise à jour critique du launcher s'installe désormais même si vous avez désactivé la mise à jour automatique. Votre réglage, lui, ne change pas.
topic: Launcher
image: /img/launcher/apercu-exovival.webp
imageAlt: Le launcher ouvert sur Exovival Zombie Reborn.
---
Vous pouvez choisir, dans les paramètres du launcher, de ne pas le mettre à jour automatiquement. C'est votre droit, et ça le reste. Mais si une faille de sécurité devait un jour être corrigée, il faut que le correctif arrive chez tout le monde.
## Ce qui change
- Une version du launcher peut être publiée comme **prioritaire**. Elle s'installe au démarrage, même avec la mise à jour automatique désactivée.
- **Votre réglage n'est pas modifié** : une fois le correctif installé, les versions normales suivantes ne s'installeront toujours pas toutes seules.
- La fenêtre de mise à jour affiche une pastille « Prioritaire ». « Plus tard » reste possible : le launcher réessaiera au prochain démarrage.
## Et la sécurité ?
L'indication « prioritaire » fait partie du contenu signé de la mise à jour : un serveur piraté ne pourrait ni imposer une mise à jour, ni retirer la priorité d'un correctif. Comme avant, seule une version signée et plus récente peut s'installer.
Le launcher se met à jour tout seul. Pour une première installation, il se [télécharge ici](/launcher#telecharger).
@@ -0,0 +1,50 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<!--
Site vitrine (LanisysGames) : présentation des jeux et du launcher, téléchargement du launcher,
notes de version, actualités. ASP.NET Core Razor Pages, sans base de données, sans formulaire,
sans cookie, sans paquet NuGet tiers.
Isolé de la Launcher API : le site ne l'appelle jamais. Il lit seulement, côté serveur et en
arrière-plan, les fichiers publics launcher-update.json (signature vérifiée avec la même clé
publique que le launcher) et manifest.json de chaque jeu (notes de version).
Voir website\deploy\LISEZMOI.md.
Publication pour le VPS (autonome linux-x64 : aucun runtime .NET à installer sur le serveur) :
tools\publish-site.bat
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>GameLauncher.Website</RootNamespace>
<AssemblyName>GameLauncher.Website</AssemblyName>
<Platforms>AnyCPU;x64</Platforms>
<Version>1.0.0</Version>
<Product>Site vitrine LanisysGames</Product>
<!-- Pas de dépendance à libicu sur le VPS (comme la Launcher API) : les dates sont mises en forme
en français par le site lui-même (Services\FrenchText.cs). -->
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Petit VPS : ramasse-miettes « poste de travail », moins gourmand en mémoire. -->
<ServerGarbageCollection>false</ServerGarbageCollection>
<!-- Pas de web.config (IIS) dans la publication : le site tourne derrière nginx. -->
<IsTransformWebConfigDisabled>true</IsTransformWebConfigDisabled>
</PropertyGroup>
<ItemGroup>
<!-- Notes de version : même modèle, mêmes bornes et même nettoyage que le launcher. -->
<ProjectReference Include="..\..\src\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
<!-- launcher-update.json : même vérification de signature que le launcher (UpdateSignature). -->
<ProjectReference Include="..\..\src\GameLauncher.Core\GameLauncher.Core.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Clé publique de mise à jour du launcher, INTÉGRÉE à l'exécutable (comme dans le launcher) :
elle ne peut pas être remplacée par un fichier déposé sur le serveur. -->
<EmbeddedResource Include="..\..\src\GameLauncher\Security\update-signing.pub" Link="Security\update-signing.pub" LogicalName="GameLauncher.Website.update-signing.pub" />
</ItemGroup>
<ItemGroup>
<!-- Actualités (Markdown) : publiées avec le site, lues au démarrage. -->
<None Update="Content\**\*.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,48 @@
namespace GameLauncher.Website.Hosting;
/// <summary>
/// En-têtes de sécurité de toutes les réponses, et refus de tout ce qui n'est pas une lecture.
///
/// Politique de sécurité du contenu (CSP) stricte : scripts, styles, images et polices du site
/// uniquement, aucun script en ligne, aucune ressource externe (ni police Google, ni statistiques),
/// aucun formulaire, aucune intégration du site dans un cadre. Le site n'a pas de cookie.
/// </summary>
public static class SecurityHeaders
{
public const string ContentSecurityPolicy =
"default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; " +
"manifest-src 'self'; connect-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; " +
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
public static IApplicationBuilder UseSiteSecurityHeaders(this IApplicationBuilder app) => app.Use(async (context, next) =>
{
var request = context.Request;
if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method))
{
// Site en lecture seule : ni formulaire, ni API.
context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
context.Response.Headers.Allow = "GET, HEAD";
return;
}
context.Response.OnStarting(() =>
{
var headers = context.Response.Headers;
headers.ContentSecurityPolicy = ContentSecurityPolicy;
headers.XContentTypeOptions = "nosniff";
headers.XFrameOptions = "DENY";
headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
headers["Permissions-Policy"] =
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), " +
"payment=(), usb=(), interest-cohort=(), browsing-topics=()";
headers["Cross-Origin-Opener-Policy"] = "same-origin";
headers["Cross-Origin-Resource-Policy"] = "same-origin";
headers["X-Permitted-Cross-Domain-Policies"] = "none";
headers.Remove("Server");
headers.Remove("X-Powered-By");
return Task.CompletedTask;
});
await next();
});
}
@@ -0,0 +1,34 @@
using System.Xml.Linq;
using GameLauncher.Website.Options;
using GameLauncher.Website.Services;
namespace GameLauncher.Website.Hosting;
/// <summary>Plan du site (sitemap.xml) pour les moteurs de recherche.</summary>
public static class Sitemap
{
private static readonly XNamespace Ns = "http://www.sitemaps.org/schemas/sitemap/0.9";
public static string Build(SiteOptions options, NewsService news)
{
var root = options.PublicUrl.TrimEnd('/');
var paths = new List<(string Path, string? LastModified)>
{
("/", null), ("/launcher", null), ("/jeux", null), ("/actualites", null),
("/mentions-legales", null), ("/confidentialite", null)
};
foreach (var game in options.VisibleGames)
{
paths.Add(($"/jeux/{game.Slug}", null));
if (!string.IsNullOrWhiteSpace(game.ManifestUrl)) paths.Add(($"/jeux/{game.Slug}/notes-de-version", null));
}
paths.AddRange(news.All.Select(a => ($"/actualites/{a.Slug}", (string?)FrenchText.IsoDate(a.Date))));
var document = new XDocument(new XDeclaration("1.0", "utf-8", null),
new XElement(Ns + "urlset",
paths.Select(p => new XElement(Ns + "url",
new XElement(Ns + "loc", root + p.Path),
p.LastModified is null ? null : new XElement(Ns + "lastmod", p.LastModified)))));
return document.Declaration + "\n" + document.Root;
}
}
@@ -0,0 +1,36 @@
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
namespace GameLauncher.Website.Hosting;
/// <summary>
/// Contrôle du certificat du serveur du launcher : EXACTEMENT la règle par défaut de .NET (aucune erreur
/// acceptée), avec en plus, en cas de refus, le détail dans le journal du site (nom attendu, certificat
/// présenté, maillons de la chaîne et leurs erreurs). Sans ce détail, .NET ne dit que « The SSL connection
/// could not be established ».
/// </summary>
public static class TlsDiagnostics
{
public static RemoteCertificateValidationCallback Create(ILogger logger) => (sender, certificate, chain, errors) =>
{
if (errors == SslPolicyErrors.None) return true;
var host = (sender as SslStream)?.TargetHostName ?? "?";
var links = chain?.ChainElements.Select((e, i) =>
$"[{i}] {e.Certificate.Subject} (émis par {e.Certificate.Issuer}, jusqu'au {e.Certificate.NotAfter:yyyy-MM-dd})" +
(e.ChainElementStatus.Length == 0 ? "" : " : " + string.Join(", ", e.ChainElementStatus.Select(s => s.Status))))
.ToList() ?? new List<string>();
var chainStatus = chain?.ChainStatus.Select(s => s.Status.ToString()).Distinct().ToList() ?? new List<string>();
logger.LogError(
"Certificat de {Host} REFUSÉ ({Errors}). Chaîne : {Status}. Maillons reçus et reconstruits :{Links}{Hint}",
host, errors, chainStatus.Count == 0 ? "—" : string.Join(", ", chainStatus),
links.Count == 0 ? " aucun" : "\n " + string.Join("\n ", links),
chainStatus.Contains(nameof(X509ChainStatusFlags.PartialChain))
? "\n → Chaîne incomplète : le serveur n'envoie pas le certificat intermédiaire. Sur le VPS du launcher, " +
"nginx doit utiliser fullchain.pem (et non cert.pem) dans ssl_certificate (voir website/deploy/LISEZMOI.md, Dépannage)."
: "");
return false;
};
}
@@ -0,0 +1,137 @@
namespace GameLauncher.Website.Options;
/// <summary>
/// Réglages du site (section « Site » d'appsettings.json, complétée sur le VPS par
/// appsettings.Production.json, conservé d'une mise à jour à l'autre).
/// </summary>
public sealed class SiteOptions
{
public const string Section = "Site";
/// <summary>Adresse publique du site (https), sans « / » final : liens absolus (Open Graph, plan du site).</summary>
public string PublicUrl { get; set; } = "";
/// <summary>Nom du studio, affiché dans l'en-tête et le pied de page.</summary>
public string StudioName { get; set; } = "LanisysGames";
/// <summary>
/// Adresse du bouton « Nous contacter » (lien mailto: : le joueur écrit avec sa propre messagerie,
/// le site ne reçoit ni ne stocke rien). Vide : bouton masqué.
/// </summary>
public string ContactEmail { get; set; } = "";
public LauncherOptions Launcher { get; set; } = new();
public List<GameOptions> Games { get; set; } = new();
public RemoteOptions Remote { get; set; } = new();
public LegalOptions Legal { get; set; } = new();
/// <summary>Jeux affichés (masqués exclus), dans l'ordre du fichier.</summary>
public IEnumerable<GameOptions> VisibleGames => Games.Where(g => g.Visible);
public GameOptions? FindGame(string? slug) =>
string.IsNullOrEmpty(slug) ? null : VisibleGames.FirstOrDefault(g => string.Equals(g.Slug, slug, StringComparison.Ordinal));
}
public sealed class LauncherOptions
{
/// <summary>Nom du launcher affiché sur le site.</summary>
public string Name { get; set; } = "Launcher LanisysGames";
/// <summary>
/// launcher-update.json (https) : version, exécutable à télécharger et notes de version, SIGNÉS.
/// Le site n'affiche le bouton de téléchargement que si la signature est valide.
/// </summary>
public string UpdateUrl { get; set; } = "";
/// <summary>
/// Identifiant de produit attendu dans launcher-update.json (« launcherId », ligne « Produit » de
/// LauncherPublisher verify). Vide : non contrôlé (le produit lu est inscrit au journal du site).
/// </summary>
public string Product { get; set; } = "";
/// <summary>Page « Publications » de Gitea : historique complet des versions du launcher. Vide : lien masqué.</summary>
public string ReleasesUrl { get; set; } = "";
/// <summary>
/// Sections des notes du launcher qui ne concernent pas les joueurs (serveur, développement…),
/// retirées de l'affichage. Comparaison sans tenir compte de la casse ni des accents.
/// </summary>
public List<string> HiddenSections { get; set; } = new();
}
public sealed class GameOptions
{
/// <summary>Adresse de la page : /jeux/&lt;slug&gt; (lettres minuscules, chiffres, tirets).</summary>
public string Slug { get; set; } = "";
public string Name { get; set; } = "";
/// <summary>Thème de la page : « exovival », « multi » ou « launcher ».</summary>
public string Theme { get; set; } = "launcher";
/// <summary>« available » (disponible dans le launcher) ou « upcoming » (en préparation).</summary>
public string Status { get; set; } = "upcoming";
public bool Visible { get; set; } = true;
/// <summary>Identifiant du jeu (« gameId » du manifest), contrôlé à la lecture du manifest.</summary>
public string GameId { get; set; } = "";
/// <summary>manifest.json du jeu (https). Vide : pas de notes de version.</summary>
public string ManifestUrl { get; set; } = "";
/// <summary>Accroche (cartes de l'accueil et de la liste des jeux).</summary>
public string Tagline { get; set; } = "";
/// <summary>Présentation courte d'un jeu en préparation (page générique).</summary>
public string Teaser { get; set; } = "";
/// <summary>Image de la carte (chemin local commençant par /img/).</summary>
public string CardImage { get; set; } = "";
public string CardImageAlt { get; set; } = "";
public bool IsAvailable => string.Equals(Status, "available", StringComparison.OrdinalIgnoreCase);
/// <summary>Page dédiée (Pages/Jeux/&lt;…&gt;.cshtml) plutôt que la page générique.</summary>
public bool HasDedicatedPage { get; set; }
}
public sealed class RemoteOptions
{
/// <summary>Intervalle entre deux lectures des fichiers JSON (minutes).</summary>
public int RefreshMinutes { get; set; } = 10;
/// <summary>Délai maximal d'une lecture (secondes).</summary>
public int TimeoutSeconds { get; set; } = 20;
/// <summary>Taille maximale d'un manifest.json (Mo) : il contient la liste de tous les fichiers du jeu.</summary>
public int MaxManifestMegabytes { get; set; } = 32;
/// <summary>
/// Développement uniquement : autorise http://localhost (fichiers de test servis en local).
/// Ignoré en production (https obligatoire).
/// </summary>
public bool AllowLoopbackHttp { get; set; }
}
public sealed class LegalOptions
{
/// <summary>Éditeur du site (nom ou raison sociale).</summary>
public string Publisher { get; set; } = "";
/// <summary>Statut et coordonnées de l'éditeur (adresse, SIRET… ou mention « éditeur non professionnel »).</summary>
public List<string> PublisherDetails { get; set; } = new();
/// <summary>Directeur de la publication.</summary>
public string Director { get; set; } = "";
/// <summary>Hébergeur du site (nom, adresse, téléphone).</summary>
public List<string> Host { get; set; } = new();
/// <summary>Durée de conservation des journaux du serveur web, en jours (page « Confidentialité »).</summary>
public int LogRetentionDays { get; set; } = 14;
}
@@ -0,0 +1,54 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
namespace GameLauncher.Website.Options;
/// <summary>Contrôle des réglages au démarrage : un réglage incorrect arrête le site avec un message clair.</summary>
public sealed partial class SiteOptionsValidator : IValidateOptions<SiteOptions>
{
public static readonly string[] Themes = { "launcher", "exovival", "multi" };
public ValidateOptionsResult Validate(string? name, SiteOptions options)
{
var errors = new List<string>();
if (!string.IsNullOrWhiteSpace(options.PublicUrl) &&
(!Uri.TryCreate(options.PublicUrl, UriKind.Absolute, out var site) || site.Scheme is not ("https" or "http") ||
options.PublicUrl.EndsWith('/')))
errors.Add("Site:PublicUrl doit être une adresse absolue, sans « / » final (ex. https://www.lanisys.fr).");
if (!string.IsNullOrWhiteSpace(options.ContactEmail) && !EmailRegex().IsMatch(options.ContactEmail))
errors.Add("Site:ContactEmail n'est pas une adresse e-mail simple (ex. contact@lanisys.fr).");
if (!string.IsNullOrWhiteSpace(options.Launcher.ReleasesUrl) &&
(!Uri.TryCreate(options.Launcher.ReleasesUrl, UriKind.Absolute, out var releases) || releases.Scheme != "https"))
errors.Add("Site:Launcher:ReleasesUrl doit être une adresse https.");
var slugs = new HashSet<string>(StringComparer.Ordinal);
foreach (var game in options.Games)
{
game.Slug ??= "";
if (!SlugRegex().IsMatch(game.Slug))
errors.Add($"Site:Games : « {game.Slug} » n'est pas un slug valide (minuscules, chiffres, tirets).");
else if (!slugs.Add(game.Slug))
errors.Add($"Site:Games : slug « {game.Slug} » en double.");
if (string.IsNullOrWhiteSpace(game.Name))
errors.Add($"Site:Games : nom manquant pour « {game.Slug} ».");
if (!Themes.Contains(game.Theme))
errors.Add($"Site:Games : thème inconnu « {game.Theme} » pour « {game.Slug} » ({string.Join(", ", Themes)}).");
if (!string.IsNullOrWhiteSpace(game.CardImage) && !game.CardImage.StartsWith("/img/", StringComparison.Ordinal))
errors.Add($"Site:Games : l'image de « {game.Slug} » doit être un fichier du site (/img/…).");
}
if (options.Remote.RefreshMinutes < 1)
errors.Add("Site:Remote:RefreshMinutes doit valoir au moins 1.");
return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(errors);
}
[GeneratedRegex(@"^[a-z0-9]+(?:-[a-z0-9]+)*$")]
private static partial Regex SlugRegex();
[GeneratedRegex(@"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$")]
private static partial Regex EmailRegex();
}
@@ -0,0 +1,35 @@
@page "/actualites/{slug:maxlength(80)}"
@model ArticleModel
@{
var article = Model.Article;
ViewData["Nav"] = "actualites";
ViewData["Theme"] = article.Theme;
ViewData["Title"] = article.Title;
ViewData["Description"] = article.Summary;
ViewData["OgType"] = "article";
if (article.Image is not null && article.Image.EndsWith(".jpg", StringComparison.Ordinal)) ViewData["OgImage"] = article.Image;
}
<article>
<header class="article-head">
<div class="wrap wrap-narrow">
<p class="news-date">
<time datetime="@FrenchText.IsoDate(article.Date)">@FrenchText.Date(article.Date)</time>@if (article.Topic is not null)
{<text>, </text><span class="news-topic">@article.Topic</span>}
</p>
<h1>@article.Title</h1>
<p class="lead">@article.Summary</p>
</div>
</header>
<div class="wrap wrap-narrow">
@if (article.Image is not null)
{
<figure class="article-cover"><img src="@article.Image" alt="@(article.ImageAlt ?? "")" width="1280" height="720"></figure>
}
<div class="prose">
@* HTML produit par SafeMarkdown : tout le texte de l'article y est encodé, seules ses balises existent. *@
@Html.Raw(article.Html)
</div>
<a class="article-back" href="/actualites">Toutes les actualités</a>
</div>
</article>
@@ -0,0 +1,23 @@
using GameLauncher.Website.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace GameLauncher.Website.Pages.Actualites;
public sealed class ArticleModel : PageModel
{
private readonly NewsService _news;
public ArticleModel(NewsService news) => _news = news;
public NewsArticle Article { get; private set; } = null!;
public IActionResult OnGet(string slug)
{
// Recherche dans les articles chargés au démarrage : l'adresse n'atteint jamais le disque.
var article = _news.Find(slug);
if (article is null) return NotFound();
Article = article;
return Page();
}
}
@@ -0,0 +1,27 @@
@page "/actualites"
@inject NewsService News
@{
ViewData["Nav"] = "actualites";
ViewData["Title"] = "Actualités";
ViewData["Description"] = "Les nouvelles des jeux et du launcher : sorties, mises à jour, coulisses.";
}
<section class="page-hero">
<div class="wrap">
<h1>Actualités</h1>
<p class="lead">Sorties, mises à jour et coulisses de nos jeux et du launcher.</p>
</div>
</section>
<section class="section-tight">
<div class="wrap">
@if (News.All.Count == 0)
{
<p class="notice">Aucune actualité pour le moment. Les nouveautés des jeux sont aussi dans leurs notes de version.</p>
}
else
{
<partial name="_NewsList" model="News.All" />
}
</div>
</section>
@@ -0,0 +1,29 @@
@page "/confidentialite"
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@{
var site = SiteOptionsMonitor.CurrentValue;
ViewData["Title"] = "Confidentialité";
ViewData["Description"] = "Ce que le site enregistre (presque rien) et pourquoi.";
Uri.TryCreate(site.Launcher.UpdateUrl, UriKind.Absolute, out var launcherServer);
}
<section class="page-hero">
<div class="wrap wrap-narrow legal">
<h1>Confidentialité</h1>
<p class="lead">Ce site ne vous demande rien et ne vous suit pas : pas de compte, pas de formulaire, pas de cookie, pas de mesure d'audience, pas de publicité, aucun service externe (les polices et les images sont servies par le site lui-même).</p>
<h2>Journaux du serveur</h2>
<p>Comme tout serveur web, le nôtre note chaque requête : adresse IP, date, page demandée, navigateur. Ces journaux servent uniquement à la sécurité du site (repérer une attaque, un abus) et au diagnostic des pannes. Ils sont effacés au bout de @site.Legal.LogRetentionDays jours et ne sont ni croisés avec d'autres données, ni transmis à qui que ce soit, sauf obligation légale.</p>
<p>Base légale : notre intérêt légitime à protéger le site (article 6.1.f du RGPD).</p>
<h2>Téléchargement du launcher</h2>
<p>Le bouton « Télécharger le launcher » vous envoie directement vers notre serveur de téléchargement@(launcherServer is null ? "" : $" ({launcherServer.Host})"), qui tient lui aussi un journal technique dans les mêmes conditions.</p>
<h2>Nous contacter</h2>
<p>Le bouton « Nous contacter » ouvre votre propre messagerie. Votre message et votre adresse e-mail ne passent pas par ce site : nous ne les utilisons que pour vous répondre, et les supprimons quand l'échange est terminé.</p>
<h2>Vos droits</h2>
<p>Vous pouvez demander l'accès à vos données, leur rectification ou leur effacement@(string.IsNullOrWhiteSpace(site.ContactEmail) ? "" : " en écrivant à ")@if (!string.IsNullOrWhiteSpace(site.ContactEmail))
{<a href="mailto:@site.ContactEmail">@site.ContactEmail</a>}. Vous pouvez aussi adresser une réclamation à la CNIL (<a href="https://www.cnil.fr" rel="noopener noreferrer">cnil.fr</a>).</p>
</div>
</section>
@@ -0,0 +1,22 @@
@page "/erreur/{code:int?}"
@model ErreurModel
@{
ViewData["Title"] = Model.Code == 404 ? "Page introuvable" : "Erreur";
}
<section class="error-page">
<div class="wrap wrap-narrow">
<p class="error-code" aria-hidden="true">@Model.Code</p>
@if (Model.Code == 404)
{
<h1>Cette page n'existe pas.</h1>
<p class="lead">L'adresse est peut-être mal saisie, ou la page a été déplacée.</p>
}
else
{
<h1>Le site a rencontré un problème.</h1>
<p class="lead">Réessayez dans un instant. Si le problème continue, écrivez-nous.</p>
}
<p><a class="btn btn-primary" href="/">Revenir à l'accueil</a></p>
</div>
</section>
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace GameLauncher.Website.Pages;
/// <summary>Page d'erreur (404, 500…) : aucun détail technique n'est montré au visiteur.</summary>
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public sealed class ErreurModel : PageModel
{
public int Code { get; private set; } = 500;
public void OnGet(int? code)
{
Code = code is >= 400 and <= 599 ? code.Value : 500;
// Accès direct à /erreur/… : garder le code d'erreur dans la réponse.
if (HttpContext.Features.Get<Microsoft.AspNetCore.Diagnostics.IStatusCodeReExecuteFeature>() is null)
Response.StatusCode = Code;
}
}
@@ -0,0 +1,77 @@
@page
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@inject NewsService News
@{
var site = SiteOptionsMonitor.CurrentValue;
ViewData["Nav"] = "accueil";
ViewData["Theme"] = "launcher";
ViewData["Description"] = $"{site.StudioName}, studio indépendant : Exovival Zombie Reborn et le launcher pour installer, mettre à jour et lancer nos jeux sur Windows.";
var available = site.VisibleGames.Where(g => g.IsAvailable).ToList();
var upcoming = site.VisibleGames.Where(g => !g.IsAvailable).ToList();
var latest = News.Latest(3).ToList();
}
<section class="home-hero">
<div class="wrap">
<div>
<h1>Nos jeux s'installent en un clic.</h1>
<p class="lead">Le launcher @site.StudioName installe nos jeux, les garde à jour et vérifie chaque fichier. Téléchargez-le une fois : il s'occupe du reste, et de lui-même.</p>
<partial name="_LauncherDownload" />
</div>
<figure class="hero-shot">
<img src="/img/launcher/apercu-exovival.webp" width="1168" height="708"
alt="Le launcher ouvert sur Exovival Zombie Reborn : bouton Installer, taille du téléchargement et notes de version.">
</figure>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="section-head">
<h2>Nos jeux</h2>
<a href="/jeux">Tous les jeux</a>
</div>
@foreach (var game in available)
{
<a class="game-feature" href="/jeux/@game.Slug">
<div class="game-feature-media">
@if (!string.IsNullOrWhiteSpace(game.CardImage))
{
<img src="@game.CardImage" alt="@game.CardImageAlt" width="1920" height="1080" loading="lazy">
}
</div>
<div class="game-feature-body">
<span class="status">Disponible dans le launcher</span>
<h3>@game.Name</h3>
<p>@game.Tagline</p>
<span class="game-feature-link">Découvrir le jeu</span>
</div>
</a>
}
@foreach (var game in upcoming)
{
<a class="game-teaser" href="/jeux/@game.Slug">
<div>
<span class="status">En préparation</span>
<h3>@game.Name</h3>
<p>@game.Tagline</p>
</div>
</a>
}
</div>
</section>
@if (latest.Count > 0)
{
<section class="section">
<div class="wrap">
<div class="section-head">
<h2>Actualités</h2>
<a href="/actualites">Toutes les actualités</a>
</div>
<partial name="_NewsList" model="latest" />
</div>
</section>
}
@@ -0,0 +1,208 @@
@page "/jeux/exovival-zombie-reborn"
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@inject RemoteContentStore Store
@{
const string Slug = "exovival-zombie-reborn";
var game = SiteOptionsMonitor.CurrentValue.FindGame(Slug);
var snapshot = Store.GetGame(Slug);
var latest = snapshot?.Changelog.FirstOrDefault();
ViewData["Nav"] = "jeux";
ViewData["Theme"] = "exovival";
ViewData["Title"] = "Exovival Zombie Reborn";
ViewData["Description"] = "Exovival Zombie Reborn : survival horror à la première personne. La ville a été évacuée, votre fille Léa manque à l'appel. Retournez seul dans l'école plongée dans le noir.";
}
<section class="exo-hero" data-uv-hero>
<div class="exo-hero-photo">
<img src="/img/exovival/couloir.webp" srcset="/img/exovival/couloir-960.webp 960w, /img/exovival/couloir.webp 1920w" sizes="100vw"
width="1920" height="1080" alt="" fetchpriority="high">
</div>
<div class="exo-uv" role="img" aria-label="Écrit à l'encre invisible sur le mur : Léa ♥ Papa."></div>
<div class="exo-torch"></div>
<div class="wrap">
<p class="exo-mark">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><use href="#biohazard" /></svg>
Survival horror à la première personne
</p>
<h1 class="exo-title">Exovival Zombie Reborn</h1>
<p class="lead">La ville a été évacuée en pleine nuit. À l'appel du point de rassemblement, une élève manquait : Léa, dix ans, votre fille. Vous retournez seul à l'école Jean-Moulin. Plus de courant, des portes condamnées, et quelque chose qui gratte dans les couloirs.</p>
<div class="exo-actions">
<a class="btn btn-primary btn-large" href="/launcher#telecharger">@Html.Icon("Download") Jouer avec le launcher</a>
<a class="btn btn-ghost btn-large" href="/jeux/@Slug/notes-de-version">Notes de version</a>
</div>
<p class="uv-hint">@Html.Icon("Flashlight") Promenez votre lampe UV sur le mur : Léa vous a laissé un message.</p>
</div>
<svg width="0" height="0" class="visually-hidden" aria-hidden="true" focusable="false">
<defs>
<mask id="biohazard-crescents" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#fff" />
<circle cx="12" cy="6.3" r="3.75" fill="#000" />
<circle cx="17.63" cy="16.05" r="3.75" fill="#000" />
<circle cx="6.37" cy="16.05" r="3.75" fill="#000" />
<circle cx="12" cy="12.8" r="3.9" fill="#000" />
</mask>
<mask id="biohazard-ring" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
<rect width="24" height="24" fill="#fff" />
<circle cx="12" cy="12.8" r="2.2" fill="#000" />
<circle cx="12" cy="6.3" r="3.75" fill="#000" />
<circle cx="17.63" cy="16.05" r="3.75" fill="#000" />
<circle cx="6.37" cy="16.05" r="3.75" fill="#000" />
</mask>
<symbol id="biohazard" viewBox="0 0 24 24">
<g fill="currentColor" mask="url(#biohazard-crescents)">
<circle cx="12" cy="7.8" r="5.3" />
<circle cx="16.33" cy="15.3" r="5.3" />
<circle cx="7.67" cy="15.3" r="5.3" />
</g>
<circle cx="12" cy="12.8" r="3.3" fill="currentColor" mask="url(#biohazard-ring)" />
</symbol>
</defs>
</svg>
</section>
<section class="section">
<div class="wrap exo-story">
<div class="prose">
<p>Le soir de l'épidémie, tout le quartier a été évacué. Les cars sont partis, les portes ont été condamnées. Au point de rassemblement, on a fait l'appel classe par classe. En CM1, il manquait une élève : Léa.</p>
<p>Vous revenez seul, de nuit, avec une lampe torche et ce que vous trouverez sur place. L'école Jean-Moulin n'a plus de courant. Des pas résonnent quelque part à l'étage.</p>
<p>Léa et vous avez un jeu secret : des messages à l'encre invisible, écrits avec le stylo espion que vous lui avez offert pour son anniversaire. Elle vous a laissé une piste. Il faut la suivre jusqu'au bout.</p>
</div>
<div>
<h2>Ceux qui étaient là</h2>
<ul class="cast">
<li><b>Léa</b><span>Dix ans, en CM1. Votre fille.</span></li>
<li><b>Mme Roussel</b><span>Sa maîtresse, qui filme la classe au caméscope.</span></li>
<li><b>Mme Garnier</b><span>La directrice. Elle a laissé un mot dans la classe.</span></li>
<li><b>M. Bernard</b><span>Le concierge. Il connaît chaque recoin et en garde les clés.</span></li>
<li><b>L'agent de sécurité</b><span>De garde la nuit de l'évacuation.</span></li>
</ul>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Ce qui vous attend</h2>
<div class="exo-features">
<div class="exo-feature">
<h3>@Html.Icon("Flashlight") Le noir, et peu de piles</h3>
<p>Lampe torche, briquet, bougie, lanterne à pétrole : chaque source de lumière s'épuise. Pour rallumer l'école, il faudra trouver des fusibles, régler une armoire électrique et remettre en marche un groupe électrogène qui consomme son carburant.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("Puzzle") Des énigmes à l'ancienne</h3>
<p>Digicodes, cadenas, coffres à combinaison, crochetage, labyrinthe à bille, circuit électrique à réparer, leviers à actionner dans le bon ordre. Les indices se cachent dans les papiers, sur les cassettes, et sur les murs, à la lampe UV.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("Cassette") Cassettes, caméscope et caméras</h3>
<p>Le magnétoscope de la salle des profs relit les cassettes de l'école. Le caméscope de Mme Roussel voit dans le noir. Une fois le courant revenu, les caméras de surveillance montrent des pièces où l'on ne peut pas entrer.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("Ear") Les zombies vous entendent</h3>
<p>Marcher fait du bruit, courir s'entend de loin, même dans leur dos ; un mur ou une porte fermée étouffe vos pas. Accroupi, vous êtes silencieux. Cachez-vous dans un casier, observez en vous penchant, contournez. Et attention aux groupes : un zombie qui attaque attire ceux d'à côté.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("Axe") Couteau, hache, pistolet</h3>
<p>Chaque arme a ses dégâts, et viser la tête les double. Un coup de hache bien placé suffit ; au pistolet, on vise avec les organes de visée et on compte ses balles.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("Medical") Se soigner à l'ancienne</h3>
<p>Herbes et huiles médicinales à ramasser, à mélanger en potion dans l'inventaire. La trousse de secours reste le meilleur remède. Rien n'est gaspillé : un soin ne s'utilise pas quand vous êtes en pleine forme.</p>
</div>
<div class="exo-feature">
<h3>@Html.Icon("History") Sauvegardes</h3>
<p>Jusqu'à dix sauvegardes, plus des sauvegardes automatiques aux moments clés. Après une mort, « Recommencer » reprend à la dernière. Le jeu est en français et en anglais.</p>
</div>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Cassettes retrouvées</h2>
<p class="lead">Des images enregistrées dans l'école, avant et pendant la nuit de l'évacuation. Elles sont dans le jeu, sur les cassettes à glisser dans le magnétoscope.</p>
<div class="tapes">
<figure class="tape">
<div class="tape-screen"><img src="/img/exovival/vhs-couloir-nuit.webp" width="640" height="480" loading="lazy" alt="Couloir de l'école filmé en vision nocturne verte, horodatage du 12 octobre 2026 à 21 h 47."></div>
<figcaption><span class="tape-label">Soir de l'évacuation</span><span class="tape-text">Le couloir, en vision nocturne.</span></figcaption>
</figure>
<figure class="tape">
<div class="tape-screen"><img src="/img/exovival/vhs-escalier.webp" width="640" height="480" loading="lazy" alt="Cage d'escalier filmée en vision nocturne, vue plongeante."></div>
<figcaption><span class="tape-label">Soir de l'évacuation</span><span class="tape-text">L'escalier, caméra à la main.</span></figcaption>
</figure>
<figure class="tape">
<div class="tape-screen"><img src="/img/exovival/vhs-bibliotheque.webp" width="640" height="480" loading="lazy" alt="La bibliothèque de l'école éclairée, rayonnages et tables, image VHS datée du 19 décembre 2025."></div>
<figcaption><span class="tape-label">Spectacle de Noël</span><span class="tape-text">La bibliothèque, quand tout allait bien.</span></figcaption>
</figure>
<figure class="tape">
<div class="tape-screen"><img src="/img/exovival/vhs-gymnase.webp" width="640" height="480" loading="lazy" alt="Le gymnase de l'école en plein jour, parquet et paniers de basket, image VHS datée du 3 mars 2026."></div>
<figcaption><span class="tape-label">EPS</span><span class="tape-text">Le gymnase, un matin de mars.</span></figcaption>
</figure>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="abri">
<div class="abri-head">
<span class="abri-tag">En développement</span>
<h2>Sous l'école, l'abri</h2>
<p>La prochaine extension du niveau ouvre ce que la ville avait muré : un abri de défense passive inauguré en 1962, le parking souterrain de l'ancien commissariat sur trois niveaux, et les égouts de 1890 qui courent sous le quartier. Au programme : de nouvelles énigmes, le stand de tir de la police, des caméras de surveillance et les enregistrements laissés par ceux qui sont passés avant vous.</p>
<p class="small">Images de développement : elles peuvent encore changer avant la sortie.</p>
</div>
<div class="abri-grid">
<figure><img src="/img/exovival/abri-collecteur.webp" width="1280" height="720" loading="lazy" alt="Galerie d'égout voûtée, cerclée de conduites métalliques."><figcaption>Le collecteur</figcaption></figure>
<figure><img src="/img/exovival/abri-camera.webp" width="1280" height="720" loading="lazy" alt="Image d'une caméra de surveillance : CAM 11, égouts, grand collecteur."><figcaption>Caméra 11</figcaption></figure>
<figure><img src="/img/exovival/abri-decantation.webp" width="1280" height="720" loading="lazy" alt="Salle de décantation circulaire avec une fosse noyée au centre."><figcaption>La salle de décantation</figcaption></figure>
<figure><img src="/img/exovival/abri-parking.webp" width="1280" height="720" loading="lazy" alt="Parking souterrain vide, piliers de béton, éclairage de secours."><figcaption>Le parking, niveau -1</figcaption></figure>
<figure><img src="/img/exovival/abri-grand-collecteur.webp" width="1280" height="720" loading="lazy" alt="Grand tunnel d'égout en briques avec une rigole d'eau au centre."><figcaption>Le grand collecteur</figcaption></figure>
</div>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="section-head">
<h2>Dernière mise à jour</h2>
<a href="/jeux/@Slug/notes-de-version">Toutes les notes de version</a>
</div>
@if (latest is not null)
{
<div class="changelog">
<partial name="_Release" model="latest" />
</div>
}
else
{
<p class="notice">Les notes de version sont momentanément indisponibles.</p>
}
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Pour jouer</h2>
<dl class="specs">
@if (snapshot is not null)
{
<div class="spec"><dt>Version</dt><dd>@snapshot.Version</dd></div>
@if (snapshot.DownloadSize > 0)
{
<div class="spec"><dt>Téléchargement</dt><dd>@FrenchText.Size(snapshot.DownloadSize)</dd></div>
}
@if (snapshot.InstalledSize > 0)
{
<div class="spec"><dt>Espace disque</dt><dd>@FrenchText.Size(snapshot.InstalledSize)</dd></div>
}
}
<div class="spec"><dt>Système</dt><dd>Windows 10 ou 11, 64 bits</dd></div>
<div class="spec"><dt>Graphismes</dt><dd>DirectX 11</dd></div>
<div class="spec"><dt>Langues</dt><dd>Français, anglais</dd></div>
</dl>
<div class="section-tight">
<p class="lead">Exovival Zombie Reborn s'installe et se met à jour avec le launcher. Téléchargez-le, choisissez le jeu, cliquez sur Installer.</p>
<partial name="_LauncherDownload" />
</div>
</div>
</section>
@@ -0,0 +1,26 @@
@page "/jeux/{slug:maxlength(80)}"
@model GameModel
@* Page générique d'un jeu sans page dédiée (jeu en préparation, par exemple). *@
@{
var game = Model.Game;
ViewData["Nav"] = "jeux";
ViewData["Theme"] = game.Theme;
ViewData["Title"] = game.Name;
ViewData["Description"] = string.IsNullOrWhiteSpace(game.Tagline) ? game.Name : game.Tagline;
}
<section class="upcoming">
<div class="wrap">
<span class="status">@(game.IsAvailable ? "Disponible dans le launcher" : "En préparation")</span>
<h1>@game.Name</h1>
<p class="lead">@(string.IsNullOrWhiteSpace(game.Teaser) ? game.Tagline : game.Teaser)</p>
@if (game.IsAvailable)
{
<partial name="_LauncherDownload" />
}
else
{
<p class="muted">En attendant, <a href="/actualites">suivez les actualités</a> ou installez le launcher : le jeu y apparaîtra dès sa sortie.</p>
}
</div>
</section>
@@ -0,0 +1,25 @@
using GameLauncher.Website.Options;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Options;
namespace GameLauncher.Website.Pages.Jeux;
public sealed class GameModel : PageModel
{
private readonly IOptionsMonitor<SiteOptions> _options;
public GameModel(IOptionsMonitor<SiteOptions> options) => _options = options;
public GameOptions Game { get; private set; } = null!;
public IActionResult OnGet(string slug)
{
var game = _options.CurrentValue.FindGame(slug);
if (game is null) return NotFound();
// Les jeux qui ont leur propre page (Pages/Jeux/<Jeu>.cshtml) y sont servis directement.
if (game.HasDedicatedPage) return NotFound();
Game = game;
return Page();
}
}
@@ -0,0 +1,49 @@
@page "/jeux"
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@inject RemoteContentStore Store
@{
var site = SiteOptionsMonitor.CurrentValue;
ViewData["Nav"] = "jeux";
ViewData["Title"] = "Nos jeux";
ViewData["Description"] = $"Les jeux de {site.StudioName}, à installer avec le launcher.";
}
<section class="page-hero">
<div class="wrap">
<h1>Nos jeux</h1>
<p class="lead">Tous nos jeux s'installent et se mettent à jour avec le launcher. Un seul téléchargement, pour tous.</p>
</div>
</section>
<section class="section-tight">
<div class="wrap">
@foreach (var game in site.VisibleGames.Where(g => g.IsAvailable))
{
var snapshot = Store.GetGame(game.Slug);
<a class="game-feature" href="/jeux/@game.Slug">
<div class="game-feature-media">
@if (!string.IsNullOrWhiteSpace(game.CardImage))
{
<img src="@game.CardImage" alt="@game.CardImageAlt" width="1920" height="1080" loading="lazy">
}
</div>
<div class="game-feature-body">
<span class="status">@(snapshot is null ? "Disponible dans le launcher" : $"Version {snapshot.Version} dans le launcher")</span>
<h3>@game.Name</h3>
<p>@game.Tagline</p>
<span class="game-feature-link">Découvrir le jeu</span>
</div>
</a>
}
@foreach (var game in site.VisibleGames.Where(g => !g.IsAvailable))
{
<a class="game-teaser" href="/jeux/@game.Slug">
<div>
<span class="status">En préparation</span>
<h3>@game.Name</h3>
<p>@game.Tagline</p>
</div>
</a>
}
</div>
</section>
@@ -0,0 +1,45 @@
@page "/jeux/{slug:maxlength(80)}/notes-de-version"
@model NotesModel
@inject RemoteContentStore Store
@{
var game = Model.Game;
var snapshot = Model.Snapshot;
ViewData["Nav"] = "jeux";
ViewData["Theme"] = game.Theme;
ViewData["Title"] = $"Notes de version – {game.Name}";
ViewData["Description"] = $"Toutes les nouveautés et corrections de {game.Name}, version par version.";
}
<section class="page-hero">
<div class="wrap wrap-narrow">
<p><a href="/jeux/@game.Slug">@game.Name</a></p>
<h1>Notes de version</h1>
@if (snapshot is not null)
{
<p class="lead">Version actuelle : <strong>@snapshot.Version</strong>@(snapshot.ReleaseDate != default ? $", publiée le {FrenchText.Date(snapshot.ReleaseDate)}" : "").
Le launcher affiche les mêmes notes et installe la mise à jour pour vous.</p>
}
</div>
</section>
<section class="section-tight">
<div class="wrap wrap-narrow">
@if (snapshot is null)
{
<p class="notice">@(Store.FirstRefreshDone ? "Les notes de version sont momentanément indisponibles. Réessayez dans quelques minutes." : "Les notes de version se chargent. Rechargez la page dans quelques secondes.")</p>
}
else if (snapshot.Changelog.Count == 0)
{
<p class="notice">Aucune note de version publiée pour le moment.</p>
}
else
{
<div class="changelog">
@foreach (var entry in snapshot.Changelog)
{
<partial name="_Release" model="entry" view-data='new ViewDataDictionary(ViewData) { ["Anchor"] = "v" + System.Text.RegularExpressions.Regex.Replace(entry.Version, "[^A-Za-z0-9._-]", "") }' />
}
</div>
}
</div>
</section>
@@ -0,0 +1,32 @@
using GameLauncher.Website.Options;
using GameLauncher.Website.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Options;
namespace GameLauncher.Website.Pages.Jeux;
/// <summary>Toutes les notes de version d'un jeu (manifest.json lu par le site, jamais par le navigateur).</summary>
public sealed class NotesModel : PageModel
{
private readonly IOptionsMonitor<SiteOptions> _options;
private readonly RemoteContentStore _store;
public NotesModel(IOptionsMonitor<SiteOptions> options, RemoteContentStore store)
{
_options = options;
_store = store;
}
public GameOptions Game { get; private set; } = null!;
public GameSnapshot? Snapshot { get; private set; }
public IActionResult OnGet(string slug)
{
var game = _options.CurrentValue.FindGame(slug);
if (game is null || string.IsNullOrWhiteSpace(game.ManifestUrl)) return NotFound();
Game = game;
Snapshot = _store.GetGame(game.Slug);
return Page();
}
}
@@ -0,0 +1,197 @@
@page "/launcher"
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@inject RemoteContentStore Store
@{
var site = SiteOptionsMonitor.CurrentValue;
var launcher = Store.Launcher;
ViewData["Nav"] = "launcher";
ViewData["Theme"] = "launcher";
ViewData["Title"] = "Le launcher";
ViewData["Description"] = "Téléchargez le launcher LanisysGames pour Windows : installation en un clic, mises à jour automatiques, vérification et réparation des fichiers, mises à jour signées.";
}
<section class="page-hero launcher-hero" id="telecharger">
<div class="wrap">
<div>
<h1>Installez, jouez, on s'occupe du reste.</h1>
<p class="lead">Le launcher télécharge nos jeux, applique les mises à jour sans tout retélécharger, vérifie chaque fichier et répare ce qui manque. Un seul fichier à télécharger, rien d'autre à installer.</p>
@{ ViewData["DownloadDetails"] = true; }
<partial name="_LauncherDownload" view-data="ViewData" />
</div>
<aside class="download-panel" aria-label="Fiche du launcher">
<h2>En bref</h2>
<dl class="facts">
@if (launcher is not null)
{
<dt>Version</dt><dd>@launcher.Version</dd>
@if (launcher.PublishedAt != default)
{
<dt>Publiée le</dt><dd><time datetime="@FrenchText.IsoDate(launcher.PublishedAt)">@FrenchText.Date(launcher.PublishedAt)</time></dd>
}
<dt>Taille</dt><dd>@FrenchText.Size(launcher.Size)</dd>
}
<dt>Système</dt><dd>Windows 10 ou 11, 64 bits</dd>
<dt>À installer</dt><dd>Rien : un seul fichier, .NET inclus</dd>
<dt>Droits</dt><dd>Aucun droit administrateur</dd>
<dt>Prix</dt><dd>Gratuit</dd>
</dl>
</aside>
</div>
</section>
<section class="section-tight">
<div class="wrap">
<div class="shot-strip">
<figure class="shot shot-main">
<img src="/img/launcher/apercu-menu.webp" width="1200" height="740" loading="lazy"
alt="Le launcher avec son menu ouvert : rechercher les mises à jour, vérifier l'intégrité des fichiers, réparer, localiser une installation, ouvrir le dossier du jeu, paramètres, désinstaller.">
<figcaption>Tout se fait depuis un seul menu : vérifier, réparer, localiser, désinstaller.</figcaption>
</figure>
<div class="shot-side">
<figure class="shot">
<img src="/img/launcher/apercu-telechargement.webp" width="1200" height="740" loading="lazy"
alt="Téléchargement en cours : barre de progression, vitesse et temps restant.">
<figcaption>Une mise à jour ne télécharge que les fichiers modifiés.</figcaption>
</figure>
<figure class="shot">
<img src="/img/launcher/apercu-multijeux.webp" width="1256" height="708" loading="lazy"
alt="Le launcher avec la liste des jeux à gauche, chaque jeu avec son propre thème.">
<figcaption>Plusieurs jeux, chacun avec son ambiance.</figcaption>
</figure>
</div>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Ce que fait le launcher</h2>
<p class="lead">Pensé pour qu'un jeu reste jouable quoi qu'il arrive : coupure pendant un téléchargement, fichier abîmé, dossier déplacé ou supprimé par erreur.</p>
<div class="feature-columns">
<div class="feature-group">
<h3>@Html.Icon("CloudDownload") Installer et mettre à jour</h3>
<ul>
<li><strong>Installation en un clic</strong>, dans le dossier de votre choix ; l'espace disque est vérifié avant de commencer.</li>
<li><strong>Téléchargement repris</strong> là où il s'était arrêté après une coupure ou une annulation.</li>
<li><strong>Mises à jour différentielles</strong> : seuls les fichiers nouveaux ou modifiés sont téléchargés. Vos sauvegardes ne sont jamais touchées.</li>
<li>Automatiques ou manuelles, au choix, jeu par jeu. Recherche au démarrage, régulièrement pendant que le launcher est ouvert, et quand vous quittez le jeu.</li>
</ul>
</div>
<div class="feature-group">
<h3>@Html.Icon("Wrench") Vérifier et réparer</h3>
<ul>
<li><strong>Vérification de l'intégrité</strong> : chaque fichier du jeu est comparé à son empreinte SHA-256, même hors ligne.</li>
<li><strong>Réparation</strong> : seuls les fichiers abîmés ou manquants sont retéléchargés. Si le dossier du jeu a été supprimé à la main, le launcher le réinstalle au même endroit.</li>
<li><strong>Localiser une installation</strong> : le jeu est déjà sur votre PC ? Indiquez son dossier, le launcher le reconnaît et le met à jour.</li>
<li>Les raccourcis du Bureau sont remis en état s'ils pointent vers un ancien emplacement.</li>
</ul>
</div>
<div class="feature-group">
<h3>@Html.Icon("Gamepad") Au quotidien</h3>
<ul>
<li><strong>Raccourci du Bureau</strong> : il ouvre le launcher, applique les mises à jour puis lance le jeu.</li>
<li><strong>Jouer hors ligne</strong> : sans connexion, un jeu installé se lance quand même.</li>
<li>Notes de version intégrées, à lire en grand avant de jouer.</li>
<li>Pendant la partie, le launcher peut se réduire, se cacher près de l'horloge ou se fermer. Plein écran avec F11.</li>
</ul>
</div>
<div class="feature-group">
<h3>@Html.Icon("Delete") Désinstaller proprement</h3>
<ul>
<li>Depuis le launcher ou depuis <strong>Paramètres Windows › Applications installées</strong>, comme n'importe quel logiciel.</li>
<li>Par défaut, vos <strong>sauvegardes et réglages sont conservés</strong>.</li>
<li>Une case permet de tout effacer : sauvegardes, réglages, journaux et fichiers ajoutés dans le dossier du jeu. Les dossiers sensibles de Windows sont protégés.</li>
</ul>
</div>
<div class="feature-group">
<h3>@Html.Icon("Forum") Nous écrire depuis le launcher</h3>
<ul>
<li>Le bouton <strong>Aide</strong> envoie un avis, une idée ou un bug sur le jeu sélectionné.</li>
<li><strong>Anonyme</strong> : aucun compte, aucune adresse IP enregistrée. Vous recevez une référence pour en reparler.</li>
<li>Jusqu'à trois captures d'écran, nettoyées de leurs métadonnées avant l'envoi.</li>
</ul>
</div>
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<div class="trust">
<div>
<h2>Des mises à jour qu'on ne peut pas falsifier</h2>
<p class="muted">Chaque version du launcher est signée numériquement sur notre poste, avec une clé qui ne quitte jamais notre PC. Avant de se mettre à jour, le launcher vérifie cette signature et l'empreinte du fichier téléchargé : même un serveur piraté ne pourrait pas vous faire installer autre chose.</p>
<div class="feature-group"><ul aria-label="Protections">
<li><strong>Jamais de retour en arrière</strong> : seule une version plus récente peut s'installer.</li>
<li><strong>Retour automatique</strong> à la version précédente si la nouvelle ne démarre pas.</li>
<li><strong>Correctifs importants</strong> installés au démarrage, même si vous avez désactivé la mise à jour automatique ; votre réglage n'est pas modifié.</li>
<li>Les versions des jeux et les informations affichées dans le launcher sont signées elles aussi.</li>
</ul></div>
</div>
<img src="/img/launcher/mise-a-jour-verifiee.webp" width="600" height="626" loading="lazy"
alt="Fenêtre de mise à jour du launcher : nouvelle version disponible, signature et empreinte vérifiées, notes de version.">
</div>
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Première installation</h2>
<ol class="steps">
<li><strong>Téléchargez le launcher.</strong> Le bouton en haut de cette page donne toujours la dernière version, vérifiée par le site.</li>
<li><strong>Rangez-le dans un dossier à vous, puis double-cliquez dessus.</strong> Un dossier « Jeux » fait très bien l'affaire. Évitez « Program Files » : le launcher ne pourrait pas s'y mettre à jour tout seul.</li>
<li><strong>Windows affiche « Windows a protégé votre ordinateur » ?</strong> Cliquez sur « Informations complémentaires » puis « Exécuter quand même ». Pour être sûr du fichier, comparez d'abord son empreinte avec celle indiquée sous le bouton de téléchargement.</li>
<li><strong>Choisissez un jeu et cliquez sur Installer.</strong> Le launcher propose un dossier et peut créer un raccourci sur le Bureau.</li>
</ol>
</div>
</section>
<section class="section" id="notes">
<div class="wrap">
<div class="section-head">
<h2>Nouveautés de la dernière version</h2>
@if (!string.IsNullOrWhiteSpace(site.Launcher.ReleasesUrl))
{
<a href="@site.Launcher.ReleasesUrl" rel="noopener noreferrer">Toutes les versions sur notre Gitea</a>
}
</div>
@if (launcher?.LatestNotes is { } notes)
{
<div class="changelog">
<partial name="_Release" model="notes" />
</div>
}
else
{
<p class="notice">Les notes de version sont momentanément indisponibles.</p>
}
</div>
</section>
<section class="section">
<div class="wrap">
<h2>Questions fréquentes</h2>
<div class="faq">
<details>
<summary>Où sont mes sauvegardes ?</summary>
<p>Dans votre dossier utilisateur, à part du jeu : <code>%APPDATA%\LanisysGames\</code> puis le nom du jeu. Mettre à jour, réparer ou réinstaller un jeu ne les touche pas. Le menu « … » › Paramètres du jeu ouvre ce dossier directement.</p>
</details>
<details>
<summary>Le jeu est déjà sur mon PC, faut-il tout retélécharger ?</summary>
<p>Non. Cliquez sur « Localiser l'installation » sous le bouton Installer et choisissez le dossier du jeu : le launcher le reconnaît, puis ne télécharge que ce qui a changé.</p>
</details>
<details>
<summary>Puis-je déplacer le launcher ?</summary>
<p>Oui. Au démarrage suivant depuis son nouvel emplacement, il met à jour ses raccourcis et l'entrée « Applications installées » de Windows.</p>
</details>
<details>
<summary>Le launcher a-t-il besoin d'Internet ?</summary>
<p>Pour installer et mettre à jour, oui. Un jeu déjà installé se lance aussi hors ligne, et la vérification des fichiers fonctionne sans connexion.</p>
</details>
<details>
<summary>Comment tout désinstaller ?</summary>
<p>Menu « … » › Désinstaller, ou Paramètres Windows › Applications installées. Cochez la case proposée pour supprimer aussi sauvegardes, réglages et journaux. Le launcher lui-même est un simple fichier : supprimez-le quand vous n'avez plus de jeu installé.</p>
</details>
</div>
</div>
</section>
@@ -0,0 +1,46 @@
@page "/mentions-legales"
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@{
var site = SiteOptionsMonitor.CurrentValue;
var legal = site.Legal;
ViewData["Title"] = "Mentions légales";
ViewData["Description"] = "Éditeur, hébergement et propriété intellectuelle du site.";
}
<section class="page-hero">
<div class="wrap wrap-narrow legal">
<h1>Mentions légales</h1>
<h2>Éditeur du site</h2>
<address>
<strong>@(string.IsNullOrWhiteSpace(legal.Publisher) ? site.StudioName : legal.Publisher)</strong><br>
@foreach (var line in legal.PublisherDetails)
{
@line<br>
}
@if (!string.IsNullOrWhiteSpace(site.ContactEmail))
{
<text>Contact : </text><a href="mailto:@site.ContactEmail">@site.ContactEmail</a>
}
</address>
@if (!string.IsNullOrWhiteSpace(legal.Director))
{
<p>Directeur de la publication : @legal.Director</p>
}
<h2>Hébergement</h2>
<address>
@foreach (var line in legal.Host)
{
@line<br>
}
</address>
<h2>Propriété intellectuelle</h2>
<p>Les textes, images, captures et vidéos des jeux présentés sur ce site appartiennent à @site.StudioName, sauf mention contraire. Toute reproduction sans autorisation est interdite.</p>
<p>Polices de caractères : Sora et Barlow, sous licence SIL Open Font License 1.1 (<a href="/fonts/OFL-Sora.txt">Sora</a>, <a href="/fonts/OFL-Barlow.txt">Barlow</a>, <a href="/fonts/OFL-Barlow-Condensed.txt">Barlow Condensed</a>). Icônes : Material Design Icons, licence Apache 2.0.</p>
<h2>Données personnelles</h2>
<p>Voir la page <a href="/confidentialite">Confidentialité</a>.</p>
</div>
</section>
@@ -0,0 +1,41 @@
@inject RemoteContentStore Store
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@* Bouton de téléchargement du launcher : affiché seulement si launcher-update.json est signé par la clé
du launcher (vérifiée par le site). L'exécutable est téléchargé directement depuis le serveur du launcher. *@
@{
var launcher = Store.Launcher;
var showDetails = ViewData["DownloadDetails"] as bool? ?? false;
}
@if (launcher is not null)
{
<div class="download">
<a class="btn btn-primary btn-large" href="@launcher.DownloadUrl">
@Html.Icon("Download") Télécharger le launcher
</a>
<p class="download-meta">
Version <strong>@launcher.Version</strong>, @FrenchText.Size(launcher.Size), pour Windows 10 et 11 (64 bits).
@if (launcher.IsPriority)
{
<span class="badge-priority">Correctif important</span>
}
</p>
@if (showDetails)
{
<details class="download-check">
<summary>@Html.Icon("ShieldCheck") Signature vérifiée</summary>
<p>Le site a vérifié la signature numérique de cette version avec la clé publique intégrée au launcher. Après le téléchargement, vous pouvez comparer l'empreinte du fichier :</p>
<code class="hash" id="empreinte-launcher">@launcher.Sha256</code>
<p class="small">Dans PowerShell : <code>Get-FileHash .\@launcher.FileName</code>
<button class="btn btn-ghost small" type="button" data-copy="empreinte-launcher" hidden>Copier l'empreinte</button></p>
</details>
}
</div>
}
else if (!Store.FirstRefreshDone)
{
<p class="notice">Le lien de téléchargement se prépare. Rechargez la page dans quelques secondes.</p>
}
else
{
<p class="notice"><strong>Le téléchargement est momentanément indisponible.</strong> Le site n'a pas pu vérifier la dernière version du launcher ; réessayez dans quelques minutes@(string.IsNullOrWhiteSpace(SiteOptionsMonitor.CurrentValue.ContactEmail) ? "." : " ou écrivez-nous.")</p>
}
@@ -0,0 +1,116 @@
@inject IOptionsMonitor<SiteOptions> SiteOptionsMonitor
@{
var site = SiteOptionsMonitor.CurrentValue;
var theme = ViewData["Theme"] as string ?? "launcher";
var nav = ViewData["Nav"] as string ?? "";
var pageTitle = ViewData["Title"] as string;
var fullTitle = string.IsNullOrEmpty(pageTitle) ? site.StudioName : $"{pageTitle} – {site.StudioName}";
var description = ViewData["Description"] as string ?? $"{site.StudioName} : nos jeux et le launcher pour les installer, les mettre à jour et les lancer en un clic.";
var root = site.PublicUrl.TrimEnd('/');
var path = Context.Request.Path.Value ?? "/";
var ogImage = ViewData["OgImage"] as string ?? (theme == "exovival" ? "/img/og/exovival.jpg" : "/img/og/launcher.jpg");
string? Current(string key) => nav == key ? "page" : null;
}
<!DOCTYPE html>
<html lang="fr" data-theme="@theme">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@fullTitle</title>
<meta name="description" content="@description">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="@(theme == "exovival" ? "#060708" : theme == "multi" ? "#0a0f14" : "#0b0d13")">
@if (!string.IsNullOrEmpty(root))
{
<link rel="canonical" href="@(root + (path == "/" ? "/" : path))">
<meta property="og:type" content="@(ViewData["OgType"] as string ?? "website")">
<meta property="og:site_name" content="@site.StudioName">
<meta property="og:locale" content="fr_FR">
<meta property="og:title" content="@(pageTitle ?? site.StudioName)">
<meta property="og:description" content="@description">
<meta property="og:url" content="@(root + path)">
<meta property="og:image" content="@(root + ogImage)">
<meta name="twitter:card" content="summary_large_image">
}
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png">
<link rel="preload" href="/fonts/@(theme == "exovival" ? "barlow-condensed-800" : "sora-variable").woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true">
<script src="~/js/site.js" asp-append-version="true" defer></script>
</head>
<body>
<a class="skip-link" href="#contenu">Aller au contenu</a>
<header class="site-header">
<div class="wrap">
<a class="brand" href="/">
<img src="/img/icon-192.png" alt="" width="30" height="30">
<span>@site.StudioName</span>
</a>
<button class="nav-toggle" type="button" aria-expanded="false" aria-controls="menu-principal">
@Html.Icon("Menu")
<span class="visually-hidden">Menu</span>
</button>
<nav class="site-nav" id="menu-principal" aria-label="Navigation principale">
<a href="/" aria-current="@Current("accueil")">Accueil</a>
<a href="/launcher" aria-current="@Current("launcher")">Launcher</a>
<a href="/jeux" aria-current="@Current("jeux")">Jeux</a>
<a href="/actualites" aria-current="@Current("actualites")">Actualités</a>
</nav>
<div class="header-actions">
@if (!string.IsNullOrWhiteSpace(site.ContactEmail))
{
<a class="btn btn-ghost" href="mailto:@site.ContactEmail">@Html.Icon("EmailOutline") Nous contacter</a>
}
<a class="btn btn-primary" href="/launcher#telecharger">@Html.Icon("Download") Télécharger le launcher</a>
</div>
</div>
</header>
<main id="contenu">
@RenderBody()
</main>
<footer class="site-footer">
<div class="wrap">
<div class="footer-brand">
<a class="brand" href="/">
<img src="/img/icon-192.png" alt="" width="30" height="30">
<span>@site.StudioName</span>
</a>
<p class="small">Studio indépendant. Nos jeux s'installent, se mettent à jour et se lancent depuis notre launcher, pour Windows.</p>
</div>
<nav aria-label="Jeux">
<h2>Jeux</h2>
<ul>
@foreach (var game in site.VisibleGames)
{
<li><a href="/jeux/@game.Slug">@game.Name</a></li>
}
</ul>
</nav>
<nav aria-label="Launcher">
<h2>Launcher</h2>
<ul>
<li><a href="/launcher">Présentation</a></li>
<li><a href="/launcher#telecharger">Télécharger</a></li>
<li><a href="/launcher#notes">Notes de version</a></li>
</ul>
</nav>
<nav aria-label="Informations">
<h2>Informations</h2>
<ul>
<li><a href="/actualites">Actualités</a></li>
@if (!string.IsNullOrWhiteSpace(site.ContactEmail))
{
<li><a href="mailto:@site.ContactEmail">Nous contacter</a></li>
}
<li><a href="/mentions-legales">Mentions légales</a></li>
<li><a href="/confidentialite">Confidentialité</a></li>
</ul>
</nav>
<p class="footer-note">© @DateTime.UtcNow.Year @site.StudioName. Ce site n'utilise ni cookie, ni outil de mesure d'audience, ni service externe.</p>
</div>
</footer>
</body>
</html>
@@ -0,0 +1,22 @@
@model IReadOnlyList<NewsArticle>
<ul class="news-list">
@foreach (var article in Model)
{
<li>
<a class="news-item" href="/actualites/@article.Slug" data-topic-theme="@article.Theme">
@if (article.Image is not null)
{
<img src="@article.Image" alt="@(article.ImageAlt ?? "")" loading="lazy" width="640" height="360">
}
<div class="news-item-body">
<span class="news-date">
<time datetime="@FrenchText.IsoDate(article.Date)">@FrenchText.Date(article.Date)</time>@if (article.Topic is not null)
{<text>, </text><span class="news-topic">@article.Topic</span>}
</span>
<h3>@article.Title</h3>
<p>@article.Summary</p>
</div>
</a>
</li>
}
</ul>
@@ -0,0 +1,66 @@
@model ChangelogEntry
@* Une version des notes : même structure que dans le launcher (titre, sous-titre, introduction, sections).
Tous les textes viennent du manifest : ils sont encodés par Razor (jamais interprétés comme du HTML). *@
@{
var anchor = ViewData["Anchor"] as string;
}
<article class="release" id="@anchor">
<header class="release-head">
<span class="version-pill">@Model.Version</span>
@if (Model.Date != default)
{
<time datetime="@FrenchText.IsoDate(Model.Date)">@FrenchText.Date(Model.Date)</time>
}
</header>
@if (!string.IsNullOrWhiteSpace(Model.Title))
{
<h3>@Model.Title</h3>
}
@if (Model.HasSubtitle)
{
<p class="release-subtitle">@Model.Subtitle</p>
}
@foreach (var paragraph in Model.Introduction)
{
<p class="release-intro">@paragraph</p>
}
@foreach (var part in Model.DisplaySections)
{
<section class="release-section">
@if (part.HasTitle)
{
<h4>@part.Title</h4>
}
<ul class="release-items">
@foreach (var item in part.Items)
{
if (item.IsComment)
{
<li class="is-comment">@item.Text</li>
continue;
}
<li>
@if (item.HasTag)
{
<span class="tag">@item.Tag</span>
}
@item.Text
@if (item.HasDetails)
{
<ul class="release-details">
@foreach (var detail in item.Details)
{
<li>@detail</li>
}
</ul>
}
@if (item.HasNote)
{
<p class="release-note">@item.Note</p>
}
</li>
}
</ul>
</section>
}
</article>
@@ -0,0 +1,7 @@
@using GameLauncher.Website
@using GameLauncher.Website.Options
@using GameLauncher.Website.Services
@using GameLauncher.Contracts.Changelog
@using Microsoft.Extensions.Options
@namespace GameLauncher.Website.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}
+140
View File
@@ -0,0 +1,140 @@
using System.Net;
using System.Net.Security;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Unicode;
using System.Threading.RateLimiting;
using GameLauncher.Website.Hosting;
using GameLauncher.Website.Options;
using GameLauncher.Website.Services;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
// --- Serveur web (Kestrel, derrière nginx) ---------------------------------------------------------
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.AddServerHeader = false;
kestrel.Limits.MaxRequestBodySize = 1024; // aucun envoi de données attendu
kestrel.Limits.MaxRequestHeadersTotalSize = 16 * 1024;
kestrel.Limits.MaxRequestLineSize = 4 * 1024;
kestrel.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(60);
});
// --- Réglages ------------------------------------------------------------------------------------
builder.Services.AddOptions<SiteOptions>().Bind(builder.Configuration.GetSection(SiteOptions.Section)).ValidateOnStart();
builder.Services.AddSingleton<IValidateOptions<SiteOptions>, SiteOptionsValidator>();
// Textes en UTF-8 lisible (é, à…) dans le HTML : les caractères restent encodés là où il le faut (<, >, ", &).
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
builder.Services.Configure<RouteOptions>(routes =>
{
routes.LowercaseUrls = true;
routes.AppendTrailingSlash = false;
});
// --- Lecture des fichiers publics du launcher ---------------------------------------------------
builder.Services.AddHttpClient(RemoteFetcher.HttpClientName, client =>
{
client.Timeout = Timeout.InfiniteTimeSpan; // délai géré par RemoteFetcher (réglable)
client.DefaultRequestHeaders.UserAgent.ParseAdd("LanisysSite/1.0");
})
.ConfigurePrimaryHttpMessageHandler(services => new SocketsHttpHandler
{
// Même règle que par défaut (certificat valide exigé), avec le détail d'un refus dans le journal.
SslOptions = new SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = TlsDiagnostics.Create(
services.GetRequiredService<ILoggerFactory>().CreateLogger("GameLauncher.Website.Tls"))
},
AllowAutoRedirect = false,
UseCookies = false,
UseProxy = false,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Brotli | DecompressionMethods.Deflate,
ConnectTimeout = TimeSpan.FromSeconds(10),
PooledConnectionLifetime = TimeSpan.FromMinutes(15),
MaxResponseHeadersLength = 64
});
builder.Services.AddSingleton<RemoteFetcher>();
builder.Services.AddSingleton<RemoteContentStore>();
builder.Services.AddHostedService<RemoteContentRefresher>();
builder.Services.AddSingleton<NewsService>();
// Aucun cookie ni formulaire : les clés de protection des données (utilisées seulement par l'anti-falsification
// d'ASP.NET Core) restent en mémoire, rien n'est écrit sur le disque du VPS.
builder.Services.AddDataProtection().UseEphemeralDataProtectionProvider();
// --- Pages ---------------------------------------------------------------------------------------
builder.Services.AddRazorPages(pages =>
{
pages.Conventions.ConfigureFilter(new Microsoft.AspNetCore.Mvc.IgnoreAntiforgeryTokenAttribute());
});
// --- Limitation du nombre de requêtes par adresse IP (en plus de celle de nginx) ------------------
builder.Services.AddRateLimiter(limiter =>
{
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "inconnue",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 300, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
});
builder.Services.AddHsts(hsts =>
{
hsts.MaxAge = TimeSpan.FromDays(365);
hsts.IncludeSubDomains = false; // les autres sous-domaines (launcher, git…) gèrent leur propre politique
});
var app = builder.Build();
// nginx (même machine) transmet l'adresse du visiteur et le protocole https.
var forwarded = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto };
app.UseForwardedHeaders(forwarded);
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/erreur");
app.UseHsts();
}
app.UseSiteSecurityHeaders();
app.UseRateLimiter();
app.UseStatusCodePagesWithReExecute("/erreur/{0}");
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = context =>
{
// Fichiers avec empreinte (?v=… ajouté par asp-append-version) : cache long.
var versioned = context.Context.Request.Query.ContainsKey("v");
context.Context.Response.Headers.CacheControl = versioned ? "public, max-age=31536000, immutable" : "public, max-age=86400";
}
});
app.UseRouting();
app.MapRazorPages();
// Contrôle de fonctionnement (installer-site.sh) : uniquement depuis la machine elle-même.
app.MapGet("/healthz", (HttpContext context) =>
context.Connection.RemoteIpAddress is { } ip && IPAddress.IsLoopback(ip) ? Results.Text("ok") : Results.NotFound());
app.MapGet("/robots.txt", (IOptionsMonitor<SiteOptions> options) =>
{
var text = new StringBuilder("User-agent: *\nAllow: /\n");
if (!string.IsNullOrWhiteSpace(options.CurrentValue.PublicUrl))
text.Append("Sitemap: ").Append(options.CurrentValue.PublicUrl).Append("/sitemap.xml\n");
return Results.Text(text.ToString(), "text/plain; charset=utf-8");
});
app.MapGet("/sitemap.xml", (IOptionsMonitor<SiteOptions> options, NewsService news) =>
Results.Text(Sitemap.Build(options.CurrentValue, news), "application/xml; charset=utf-8"));
app.Run();
@@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"Development": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "http://127.0.0.1:5090/",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,62 @@
namespace GameLauncher.Website.Services;
/// <summary>
/// Mise en forme en français, sans dépendre des données de culture du système (le site est compilé en
/// « InvariantGlobalization », comme la Launcher API : rien à installer sur le VPS).
/// </summary>
public static class FrenchText
{
private static readonly string[] Months =
{
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre"
};
/// <summary>Fuseau des dates affichées (heure de Paris) ; UTC si le système ne le connaît pas.</summary>
private static readonly TimeZoneInfo Paris = FindParis();
/// <summary>« 1er novembre 2026 », « 10 novembre 2026 ».</summary>
public static string Date(DateOnly date) =>
$"{(date.Day == 1 ? "1er" : date.Day.ToString(System.Globalization.CultureInfo.InvariantCulture))} {Months[date.Month - 1]} {date.Year}";
/// <summary>Date (heure de Paris) d'un instant ; vide si la date est absente.</summary>
public static string Date(DateTimeOffset value) =>
value == default ? "" : Date(DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(value, Paris).DateTime));
/// <summary>Format ISO pour l'attribut datetime de &lt;time&gt;.</summary>
public static string IsoDate(DateTimeOffset value) =>
value == default ? "" : DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(value, Paris).DateTime).ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
public static string IsoDate(DateOnly value) => value.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
/// <summary>« 74,2 Mo », « 1,29 Go » (unités binaires, comme le launcher).</summary>
public static string Size(long bytes)
{
if (bytes <= 0) return "—";
string[] units = { "octets", "Ko", "Mo", "Go", "To" };
double value = bytes;
var unit = 0;
while (value >= 1024 && unit < units.Length - 1)
{
value /= 1024;
unit++;
}
var format = unit == 0 ? "0" : value >= 100 ? "0" : value >= 10 ? "0.0" : "0.00";
return value.ToString(format, System.Globalization.CultureInfo.InvariantCulture).Replace('.', ',') + " " + units[unit];
}
private static TimeZoneInfo FindParis()
{
foreach (var id in new[] { "Europe/Paris", "Romance Standard Time" })
{
try
{
return TimeZoneInfo.FindSystemTimeZoneById(id);
}
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
{
}
}
return TimeZoneInfo.Utc;
}
}
@@ -0,0 +1,67 @@
namespace GameLauncher.Website.Services;
/// <summary>
/// Icônes de l'interface : tracés 24×24 repris de src\GameLauncher\Themes\Icons.xaml (Material Design Icons,
/// licence Apache 2.0), pour la même identité visuelle que le launcher ; les dernières (Menu…Windows) dans le même style.
/// </summary>
public static class IconPaths
{
public static readonly IReadOnlyDictionary<string, string> All = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Download"] = "M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z",
["CloudDownload"] = "M17,13L12,18L7,13H10V9H14V13M19.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",
["Refresh"] = "M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z",
["Wrench"] = "M22.7,19L13.6,9.9C14.5,7.6 14,4.9 12.1,3C10.1,1 7.1,0.6 4.7,1.7L9,6L6,9L1.6,4.7C0.4,7.1 0.9,10.1 2.9,12.1C4.8,14 7.5,14.5 9.8,13.6L18.9,22.7C19.3,23.1 19.9,23.1 20.3,22.7L22.6,20.4C23.1,20 23.1,19.3 22.7,19Z",
["ShieldCheck"] = "M10,17L6,13L7.41,11.58L10,14.17L16.59,7.58L18,9M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1Z",
["Folder"] = "M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z",
["Locate"] = "M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z",
["Delete"] = "M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z",
["CheckCircle"] = "M12,2C6.5,2 2,6.5 2,12C2,17.5 6.5,22 12,22C17.5,22 22,17.5 22,12C22,6.5 17.5,2 12,2M10,17L5,12L6.41,10.59L10,14.17L17.59,6.58L19,8L10,17Z",
["Help"] = "M10,19H13V22H10V19M12,2C17.35,2.22 19.68,7.62 16.5,11.67C15.67,12.67 14.33,13.33 13.67,14.17C13,15 13,16 13,17H10C10,15.33 10,13.92 10.67,12.92C11.33,11.92 12.67,11.33 13.5,10.67C15.92,8.43 15.32,5.26 12,5A3,3 0 0,0 9,8H6A6,6 0 0,1 12,2Z",
["History"] = "M13.5,8H12V13L16.28,15.54L17,14.33L13.5,12.25V8M13,3A9,9 0 0,0 4,12H1L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,20 10.5,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3Z",
["Harddisk"] = "M6,2H18A2,2 0 0,1 20,4V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V4A2,2 0 0,1 6,2M12,4A6,6 0 0,0 6,10C6,13.31 8.69,16 12.1,16L11.22,13.77C10.95,13.29 11.11,12.68 11.59,12.4L12.45,11.9C12.93,11.63 13.54,11.79 13.82,12.27L15.74,14.69C17.12,13.59 18,11.9 18,10A6,6 0 0,0 12,4M12,9A1,1 0 0,1 13,10A1,1 0 0,1 12,11A1,1 0 0,1 11,10A1,1 0 0,1 12,9M7,18A1,1 0 0,0 6,19A1,1 0 0,0 7,20A1,1 0 0,0 8,19A1,1 0 0,0 7,18M12.09,13.27L14.58,19.58L17.17,18.08L12.95,12.77L12.09,13.27Z",
["Gamepad"] = "M7,6H17A6,6 0 0,1 23,12A6,6 0 0,1 17,18C15.22,18 13.63,17.23 12.53,16H11.47C10.37,17.23 8.78,18 7,18A6,6 0 0,1 1,12A6,6 0 0,1 7,6M6,9V11H4V13H6V15H8V13H10V11H8V9H6M15.5,12A1.5,1.5 0 0,0 14,13.5A1.5,1.5 0 0,0 15.5,15A1.5,1.5 0 0,0 17,13.5A1.5,1.5 0 0,0 15.5,12M18.5,9A1.5,1.5 0 0,0 17,10.5A1.5,1.5 0 0,0 18.5,12A1.5,1.5 0 0,0 20,10.5A1.5,1.5 0 0,0 18.5,9Z",
["Update"] = "M21,10.12H14.22L16.96,7.3C14.23,4.6 9.81,4.5 7.08,7.2C4.35,9.91 4.35,14.28 7.08,17C9.81,19.7 14.23,19.7 16.96,17C18.32,15.65 19,14.08 19,12.1H21C21,14.08 20.12,16.65 18.36,18.39C14.85,21.87 9.15,21.87 5.64,18.39C2.14,14.92 2.11,9.28 5.62,5.81C9.13,2.34 14.76,2.34 18.27,5.81L21,3V10.12M12.5,8V12.25L16,14.33L15.28,15.54L11,13V8H12.5Z",
["Web"] = "M16.36,14C16.44,13.34 16.5,12.68 16.5,12C16.5,11.32 16.44,10.66 16.36,10H19.74C19.9,10.64 20,11.31 20,12C20,12.69 19.9,13.36 19.74,14M14.59,19.56C15.19,18.45 15.65,17.25 15.97,16H18.92C17.96,17.65 16.43,18.93 14.59,19.56M14.34,14H9.66C9.56,13.34 9.5,12.68 9.5,12C9.5,11.32 9.56,10.65 9.66,10H14.34C14.43,10.65 14.5,11.32 14.5,12C14.5,12.68 14.43,13.34 14.34,14M12,19.96C11.17,18.76 10.5,17.43 10.09,16H13.91C13.5,17.43 12.83,18.76 12,19.96M8,8H5.08C6.03,6.34 7.57,5.06 9.4,4.44C8.8,5.55 8.35,6.75 8,8M5.08,16H8C8.35,17.25 8.8,18.45 9.4,19.56C7.57,18.93 6.03,17.65 5.08,16M4.26,14C4.1,13.36 4,12.69 4,12C4,11.31 4.1,10.64 4.26,10H7.64C7.56,10.66 7.5,11.32 7.5,12C7.5,12.68 7.56,13.34 7.64,14M12,4.03C12.83,5.23 13.5,6.57 13.91,8H10.09C10.5,6.57 11.17,5.23 12,4.03M18.92,8H15.97C15.65,6.75 15.19,5.55 14.59,4.44C16.43,5.07 17.96,6.34 18.92,8M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z",
["ContentCopy"] = "M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z",
["Fingerprint"] = "M17.81,4.47C17.73,4.47 17.65,4.45 17.58,4.41C15.66,3.42 14,3 12,3C10.03,3 8.15,3.47 6.44,4.41C6.2,4.54 5.9,4.45 5.76,4.21C5.63,3.97 5.72,3.66 5.96,3.53C7.82,2.5 9.86,2 12,2C14.14,2 16,2.47 18.04,3.5C18.29,3.65 18.38,3.95 18.25,4.19C18.16,4.37 18,4.47 17.81,4.47M3.5,9.72C3.4,9.72 3.3,9.69 3.21,9.63C3,9.47 2.93,9.16 3.09,8.93C4.08,7.53 5.34,6.43 6.84,5.66C10,4.04 14,4.03 17.15,5.65C18.65,6.42 19.91,7.5 20.9,8.9C21.06,9.12 21,9.44 20.78,9.6C20.55,9.76 20.24,9.71 20.08,9.5C19.18,8.22 18.04,7.23 16.69,6.54C13.82,5.07 10.15,5.07 7.29,6.55C5.93,7.25 4.79,8.25 3.89,9.5C3.81,9.65 3.66,9.72 3.5,9.72M9.75,21.79C9.62,21.79 9.5,21.74 9.4,21.64C8.53,20.77 8.06,20.21 7.39,19C6.7,17.77 6.34,16.27 6.34,14.66C6.34,11.69 8.88,9.27 12,9.27C15.12,9.27 17.66,11.69 17.66,14.66A0.5,0.5 0 0,1 17.16,15.16A0.5,0.5 0 0,1 16.66,14.66C16.66,12.24 14.57,10.27 12,10.27C9.43,10.27 7.34,12.24 7.34,14.66C7.34,16.1 7.66,17.43 8.27,18.5C8.91,19.66 9.35,20.15 10.12,20.93C10.31,21.13 10.31,21.44 10.12,21.64C10,21.74 9.88,21.79 9.75,21.79M16.92,19.94C15.73,19.94 14.68,19.64 13.82,19.05C12.33,18.04 11.44,16.4 11.44,14.66A0.5,0.5 0 0,1 11.94,14.16A0.5,0.5 0 0,1 12.44,14.66C12.44,16.07 13.16,17.4 14.38,18.22C15.09,18.7 15.92,18.93 16.92,18.93C17.16,18.93 17.56,18.9 17.96,18.83C18.23,18.78 18.5,18.96 18.54,19.24C18.59,19.5 18.41,19.77 18.13,19.82C17.56,19.93 17.06,19.94 16.92,19.94M14.91,22C14.87,22 14.82,22 14.78,22C13.19,21.54 12.15,20.95 11.06,19.88C9.66,18.5 8.89,16.64 8.89,14.66C8.89,13.04 10.27,11.72 11.97,11.72C13.67,11.72 15.05,13.04 15.05,14.66C15.05,15.73 16,16.6 17.13,16.6C18.28,16.6 19.21,15.73 19.21,14.66C19.21,10.89 15.96,7.83 11.96,7.83C9.12,7.83 6.5,9.41 5.35,11.86C4.96,12.67 4.76,13.62 4.76,14.66C4.76,15.44 4.83,16.67 5.43,18.27C5.53,18.53 5.4,18.82 5.14,18.91C4.88,19 4.59,18.87 4.5,18.62C4,17.31 3.77,16 3.77,14.66C3.77,13.46 4,12.37 4.45,11.42C5.78,8.63 8.73,6.82 11.96,6.82C16.5,6.82 20.21,10.33 20.21,14.65C20.21,16.27 18.83,17.59 17.13,17.59C15.43,17.59 14.05,16.27 14.05,14.65C14.05,13.58 13.12,12.71 11.97,12.71C10.82,12.71 9.89,13.58 9.89,14.65C9.89,16.36 10.55,17.96 11.76,19.16C12.71,20.1 13.62,20.62 15.03,21C15.3,21.08 15.45,21.36 15.38,21.62C15.33,21.85 15.12,22 14.91,22Z",
["EmailOutline"] = "M22 6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H20C21.1 20 22 19.1 22 18V6M20 6L12 11L4 6H20M20 18H4V8L12 13L20 8V18Z",
["Forum"] = "M17,12V3A1,1 0 0,0 16,2H3A1,1 0 0,0 2,3V17L6,13H16A1,1 0 0,0 17,12M21,6H19V15H6V17A1,1 0 0,0 7,18H18L22,22V7A1,1 0 0,0 21,6Z",
["Lock"] = "M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z",
["Fullscreen"] = "M5,5H10V7H7V10H5V5M14,5H19V10H17V7H14V5M17,14H19V19H14V17H17V14M10,17V19H5V14H7V17H10Z",
["Lightbulb"] = "M12,2A7,7 0 0,1 19,9C19,11.38 17.81,13.47 16,14.74V17A1,1 0 0,1 15,18H9A1,1 0 0,1 8,17V14.74C6.19,13.47 5,11.38 5,9A7,7 0 0,1 12,2M9,21V20H15V21A1,1 0 0,1 14,22H10A1,1 0 0,1 9,21M12,4A5,5 0 0,0 7,9C7,11.05 8.23,12.81 10,13.58V16H14V13.58C15.77,12.81 17,11.05 17,9A5,5 0 0,0 12,4Z",
["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",
["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",
["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",
["Broom"] = "M19.36,2.72L20.78,4.14L15.06,9.85C16.13,11.39 16.28,13.24 15.38,14.44L9.06,8.12C10.26,7.22 12.11,7.37 13.65,8.44L19.36,2.72M5.93,17.57C3.92,15.56 2.69,13.16 2.35,10.92L7.23,8.83L14.67,16.27L12.58,21.15C10.34,20.81 7.94,19.58 5.93,17.57Z",
["PlayCircleOutline"] = "M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M10,16.5L16,12L10,7.5V16.5Z",
["ArrowLeft"] = "M20,11V13H8L13.5,18.5L12.08,19.92L4.16,12L12.08,4.08L13.5,5.5L8,11H20Z",
["OpenInNew"] = "M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z",
["Play"] = "M8,5.14V19.14L19,12.14L8,5.14Z",
["Star"] = "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z",
["Tune"] = "M3,17V19H9V17H3M3,5V7H13V5H3M13,21V19H21V17H13V15H11V21H13M7,9V11H3V13H7V15H9V9H7M21,13V11H11V13H21M15,9H17V7H21V5H17V3H15V9Z",
["FileDocument"] = "M13,9H18.5L13,3.5V9M6,2H14L20,8V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V4C4,2.89 4.89,2 6,2M15,18V16H6V18H15M18,14V12H6V14H18Z",
["ClockOutline"] = "M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z",
["Server"] = "M4,1H20A1,1 0 0,1 21,2V6A1,1 0 0,1 20,7H4A1,1 0 0,1 3,6V2A1,1 0 0,1 4,1M4,9H20A1,1 0 0,1 21,10V14A1,1 0 0,1 20,15H4A1,1 0 0,1 3,14V10A1,1 0 0,1 4,9M4,17H20A1,1 0 0,1 21,18V22A1,1 0 0,1 20,23H4A1,1 0 0,1 3,22V18A1,1 0 0,1 4,17M9,5H10V3H9V5M9,13H10V11H9V13M9,21H10V19H9V21M5,3V5H7V3H5M5,11V13H7V11H5M5,19V21H7V19H5Z",
["Menu"] = "M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z",
["Close"] = "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z",
["Flashlight"] = "M9,10L6,5H18L15,10H9M18,4H6V2H18V4M9,22V11H15V22H9M12,13A1,1 0 0,0 11,14A1,1 0 0,0 12,15A1,1 0 0,0 13,14A1,1 0 0,0 12,13Z",
["Cassette"] = "M4,5A2,2 0 0,0 2,7V17A2,2 0 0,0 4,19H6L7,17H17L18,19H20A2,2 0 0,0 22,17V7A2,2 0 0,0 20,5H4M7,9.5A2,2 0 0,1 9,11.5A2,2 0 0,1 7,13.5A2,2 0 0,1 5,11.5A2,2 0 0,1 7,9.5M17,9.5A2,2 0 0,1 19,11.5A2,2 0 0,1 17,13.5A2,2 0 0,1 15,11.5A2,2 0 0,1 17,9.5M9,10.5H15V12.5H9V10.5Z",
["Ear"] = "M17,20C16.71,20 16.44,19.94 16.24,19.85C15.53,19.5 15.03,18.97 14.53,17.47C14,15.91 13.06,15.2 12.15,14.5C11.37,13.9 10.56,13.28 9.86,12C9.34,11.05 9,10 9,9C9,6.2 11.2,4 14,4C16.8,4 19,6.2 19,9H21C21,5.07 17.93,2 14,2C10.07,2 7,5.07 7,9C7,10.26 7.38,11.65 8.07,12.9C8.98,14.55 10.04,15.38 10.9,16.04C11.7,16.65 12.27,17.09 12.63,18.1C13.3,20.1 14.16,21.22 15.4,21.73C15.89,21.92 16.42,22 17,22A4,4 0 0,0 21,18H19A2,2 0 0,1 17,20M11.5,9A2.5,2.5 0 0,0 14,11.5A2.5,2.5 0 0,0 16.5,9A2.5,2.5 0 0,0 14,6.5A2.5,2.5 0 0,0 11.5,9Z",
["Medical"] = "M10,3L8,5V7H4A2,2 0 0,0 2,9V19A2,2 0 0,0 4,21H20A2,2 0 0,0 22,19V9A2,2 0 0,0 20,7H16V5L14,3H10M10,5H14V7H10V5M11,10H13V13H16V15H13V18H11V15H8V13H11V10Z",
["Puzzle"] = "M20.5,11H19V7C19,5.89 18.1,5 17,5H13V3.5A2.5,2.5 0 0,0 10.5,1A2.5,2.5 0 0,0 8,3.5V5H4A2,2 0 0,0 2,7V10.8H3.5C5,10.8 6.2,12 6.2,13.5C6.2,15 5,16.2 3.5,16.2H2V20A2,2 0 0,0 4,22H7.8V20.5C7.8,19 9,17.8 10.5,17.8C12,17.8 13.2,19 13.2,20.5V22H17A2,2 0 0,0 19,20V16H20.5A2.5,2.5 0 0,0 23,13.5A2.5,2.5 0 0,0 20.5,11Z",
["Axe"] = "M12.5,6.2L6.2,12.5L4.5,10.8C2.8,9.2 2.8,6.6 4.5,4.9C6.1,3.3 8.7,3.3 10.3,4.9L12.5,6.2M13.9,7.6L21.7,15.4L20.3,16.8L18.2,14.7L16.8,16.1L18.9,18.2L17.5,19.6L9.7,11.8L13.9,7.6Z",
["Cctv"] = "M18.15,4.94C17.77,4.91 17.37,5 17,5.2L8.35,10.2C7.39,10.76 7.07,12 7.62,12.94L9.12,15.53C9.67,16.5 10.89,16.82 11.85,16.27L13.65,15.23C13.92,15.69 14.32,16.06 14.81,16.27V18.04C14.81,19.13 15.68,20 16.77,20H22V18.04H16.77V16.27C17.95,15.77 18.5,14.4 18,13.2C17.92,13 17.81,12.83 17.69,12.66L19.5,11.64C20.45,11.08 20.78,9.86 20.22,8.91L18.72,6.32C18.35,5.68 17.73,5 17.15,4.94M6.22,13.17L2,13.87L2.75,15.17L4.75,18.63L7.5,15.34L6.22,13.17Z",
["Windows"] = "M3,12V6.75L9,5.43V11.91L3,12M20,3V11.75L10,11.9V5.21L20,3M3,13L9,13.09V19.9L3,18.75V13M20,13.25V22L10,20.09V13.1L20,13.25Z",
};
}
/// <summary>Icône SVG en ligne (<c>@Html.Icon("Download")</c>) : tracés constants, aucune donnée de visiteur.</summary>
public static class IconHtmlExtensions
{
public static Microsoft.AspNetCore.Html.IHtmlContent Icon(this Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper _, string name) =>
IconPaths.All.TryGetValue(name, out var path)
? new Microsoft.AspNetCore.Html.HtmlString(
$"<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\"><path fill=\"currentColor\" d=\"{path}\"/></svg>")
: Microsoft.AspNetCore.Html.HtmlString.Empty;
}
@@ -0,0 +1,156 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace GameLauncher.Website.Services;
/// <summary>Article de la page « Actualités » (fichier Content/News/&lt;slug&gt;.md).</summary>
public sealed class NewsArticle
{
public required string Slug { get; init; }
public required string Title { get; init; }
public DateOnly Date { get; init; }
public required string Summary { get; init; }
/// <summary>Thème de la page : celui du jeu concerné (« exovival »…), sinon « launcher ».</summary>
public string Theme { get; init; } = "launcher";
/// <summary>Jeu ou produit concerné, affiché sur la carte (« Exovival Zombie Reborn », « Launcher »…).</summary>
public string? Topic { get; init; }
/// <summary>Image de couverture (chemin /img/…), facultative.</summary>
public string? Image { get; init; }
public string? ImageAlt { get; init; }
/// <summary>Corps de l'article, déjà converti par <see cref="SafeMarkdown"/>.</summary>
public required string Html { get; init; }
public bool Draft { get; init; }
}
/// <summary>
/// Actualités : un fichier Markdown par article dans Content/News, versionné avec le site.
/// Lus une fois au démarrage (publier une actualité = publier le site) ; aucune entrée de visiteur
/// n'atteint le système de fichiers (le slug d'une adresse est cherché dans un dictionnaire en mémoire).
///
/// En-tête de chaque fichier (entre deux lignes « --- ») :
/// title: Titre de l'article
/// date: 2026-11-10
/// summary: Une ou deux phrases pour la liste des actualités
/// topic: Exovival Zombie Reborn (facultatif)
/// theme: exovival (facultatif : launcher, exovival, multi)
/// image: /img/exovival/vhs-gymnase.webp (facultatif)
/// imageAlt: Description de l'image (facultatif)
/// draft: true (facultatif : visible seulement en développement)
/// </summary>
public sealed partial class NewsService
{
public const string Folder = "Content/News";
private const int MaxArticles = 500;
private static readonly string[] Themes = { "launcher", "exovival", "multi" };
private readonly IReadOnlyList<NewsArticle> _articles;
private readonly Dictionary<string, NewsArticle> _bySlug;
public NewsService(IWebHostEnvironment environment, ILogger<NewsService> logger)
: this(Path.Combine(environment.ContentRootPath, Folder), environment.IsDevelopment(), logger)
{
}
internal NewsService(string folder, bool includeDrafts, ILogger logger)
{
var list = new List<NewsArticle>();
if (Directory.Exists(folder))
{
foreach (var file in Directory.EnumerateFiles(folder, "*.md", SearchOption.TopDirectoryOnly).Take(MaxArticles))
{
try
{
var article = Parse(Path.GetFileNameWithoutExtension(file), File.ReadAllText(file));
if (article is null)
{
logger.LogWarning("Actualité ignorée (nom de fichier ou en-tête incorrect) : {File}", Path.GetFileName(file));
continue;
}
if (article.Draft && !includeDrafts) continue;
list.Add(article);
}
catch (IOException ex)
{
logger.LogWarning(ex, "Actualité illisible : {File}", Path.GetFileName(file));
}
}
}
_articles = list.OrderByDescending(a => a.Date).ThenBy(a => a.Slug, StringComparer.Ordinal).ToList();
_bySlug = _articles.ToDictionary(a => a.Slug, StringComparer.Ordinal);
logger.LogInformation("{Count} actualité(s) chargée(s).", _articles.Count);
}
public IReadOnlyList<NewsArticle> All => _articles;
public IEnumerable<NewsArticle> Latest(int count) => _articles.Take(count);
public NewsArticle? Find(string? slug) => slug is not null && _bySlug.TryGetValue(slug, out var a) ? a : null;
public static bool IsValidSlug(string? slug) => slug is not null && slug.Length <= 80 && SlugRegex().IsMatch(slug);
internal static NewsArticle? Parse(string slug, string text)
{
if (!IsValidSlug(slug)) return null;
text = text.Replace("\r\n", "\n");
if (text.StartsWith('')) text = text[1..];
if (!text.StartsWith("---\n", StringComparison.Ordinal)) return null;
var end = text.IndexOf("\n---", 4, StringComparison.Ordinal);
if (end < 0) return null;
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in text[4..end].Split('\n'))
{
var colon = raw.IndexOf(':');
if (colon <= 0) continue;
var value = raw[(colon + 1)..].Trim();
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"') value = value[1..^1];
fields[raw[..colon].Trim()] = value;
}
var bodyStart = text.IndexOf('\n', end + 1);
var body = bodyStart < 0 ? "" : text[(bodyStart + 1)..];
if (!fields.TryGetValue("title", out var title) || string.IsNullOrWhiteSpace(title)) return null;
if (!fields.TryGetValue("date", out var dateText) ||
!DateOnly.TryParseExact(dateText, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) return null;
fields.TryGetValue("summary", out var summary);
fields.TryGetValue("theme", out var theme);
fields.TryGetValue("topic", out var topic);
fields.TryGetValue("image", out var image);
fields.TryGetValue("imageAlt", out var imageAlt);
fields.TryGetValue("draft", out var draft);
return new NewsArticle
{
Slug = slug,
Title = Limit(title, 160),
Date = date,
Summary = string.IsNullOrWhiteSpace(summary) ? SafeMarkdown.ToPlainText(body, 220) : Limit(summary, 300),
Theme = theme is not null && Themes.Contains(theme) ? theme : "launcher",
Topic = string.IsNullOrWhiteSpace(topic) ? null : Limit(topic, 60),
Image = image is not null && SafeMarkdown.IsSafeImage(image) ? image : null,
ImageAlt = string.IsNullOrWhiteSpace(imageAlt) ? null : Limit(imageAlt, 200),
Html = SafeMarkdown.ToHtml(body),
Draft = string.Equals(draft, "true", StringComparison.OrdinalIgnoreCase)
};
}
private static string Limit(string value, int max)
{
value = new string(value.Where(c => !char.IsControl(c)).ToArray()).Trim();
return value.Length <= max ? value : value[..(max - 1)].TrimEnd() + "…";
}
[GeneratedRegex(@"^[a-z0-9]+(?:-[a-z0-9]+)*$")]
private static partial Regex SlugRegex();
}
@@ -0,0 +1,225 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
namespace GameLauncher.Website.Services;
/// <summary>Dernière version du launcher, lue dans un launcher-update.json dont la signature est valide.</summary>
public sealed class LauncherSnapshot
{
public required string Version { get; init; }
public required string Product { get; init; }
public DateTimeOffset PublishedAt { get; init; }
public required Uri DownloadUrl { get; init; }
public required string FileName { get; init; }
public long Size { get; init; }
public required string Sha256 { get; init; }
public bool IsPriority { get; init; }
/// <summary>Notes de la dernière version seulement (sections réservées à l'équipe retirées).</summary>
public ChangelogEntry? LatestNotes { get; init; }
public DateTimeOffset CheckedAt { get; init; }
}
/// <summary>Résumé d'un manifest.json de jeu : version et notes de version (la liste des fichiers n'est pas gardée).</summary>
public sealed class GameSnapshot
{
public required string Version { get; init; }
public DateTimeOffset ReleaseDate { get; init; }
public long DownloadSize { get; init; }
public long InstalledSize { get; init; }
public IReadOnlyList<ChangelogEntry> Changelog { get; init; } = Array.Empty<ChangelogEntry>();
public DateTimeOffset CheckedAt { get; init; }
}
/// <summary>Contenu lu par erreur ou refusé : jamais affiché.</summary>
public sealed class RemoteContentException : Exception
{
public RemoteContentException(string message, Exception? inner = null) : base(message, inner) { }
}
/// <summary>
/// Lecture et contrôle des fichiers JSON du serveur du launcher. Fonctions pures (sans réseau) :
/// testées dans GameLauncher.Tests (WebsiteRemoteContentTests).
/// </summary>
public static class RemoteContentReader
{
/// <summary>
/// Vérifie launcher-update.json avec les clés publiques intégrées (même code que le launcher :
/// <see cref="UpdateSignature.ReadRelease"/>), puis contrôle l'adresse de l'exécutable : https, même
/// serveur que launcher-update.json (un fichier signé ne peut pas envoyer les visiteurs ailleurs).
/// </summary>
public static LauncherSnapshot ReadLauncher(byte[] json, Uri updateUrl, IReadOnlyCollection<UpdateSignature.TrustedKey> keys,
string? expectedProduct, IEnumerable<string> hiddenSections, DateTimeOffset now)
{
LauncherRelease release;
try
{
release = UpdateSignature.ReadRelease(DecodeUtf8(json), keys);
}
catch (UpdateSecurityException ex)
{
throw new RemoteContentException(ex.Message, ex);
}
if (!string.IsNullOrWhiteSpace(expectedProduct) &&
!string.Equals(release.Product, expectedProduct.Trim(), StringComparison.Ordinal))
throw new RemoteContentException($"produit inattendu « {release.Product} » (attendu : « {expectedProduct} »).");
if (!Uri.TryCreate(updateUrl, release.File, out var download) ||
download.Scheme != updateUrl.Scheme ||
!string.Equals(download.IdnHost, updateUrl.IdnHost, StringComparison.OrdinalIgnoreCase) ||
download.Port != updateUrl.Port ||
!string.IsNullOrEmpty(download.Query) || !string.IsNullOrEmpty(download.Fragment) || !string.IsNullOrEmpty(download.UserInfo))
throw new RemoteContentException($"adresse de l'exécutable refusée « {release.File} » (même serveur que launcher-update.json attendu).");
var fileName = Path.GetFileName(download.AbsolutePath);
if (!fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) || fileName.Length > 100)
throw new RemoteContentException($"nom d'exécutable inattendu « {fileName} ».");
var version = VersionUtil.Display(release.ParsedVersion);
var notes = release.Changelog.FirstOrDefault(e => VersionUtil.TryParse(e.Version, out var v) && v == release.ParsedVersion)
?? release.Changelog.FirstOrDefault()
?? release.ToLegacyEntry();
return new LauncherSnapshot
{
Version = version,
Product = release.Product,
PublishedAt = release.PublishedAt,
DownloadUrl = download,
FileName = fileName,
Size = release.Size,
Sha256 = release.Sha256.ToLowerInvariant(),
IsPriority = release.IsPriorityRelease,
LatestNotes = notes is null ? null : WithoutSections(notes, hiddenSections),
CheckedAt = now
};
}
/// <summary>
/// Lit le résumé d'un manifest.json : version, dates, tailles et notes de version, bornées et nettoyées
/// comme dans le launcher (<see cref="ChangelogLimits.Normalize"/>). Le jeu doit être celui attendu.
/// </summary>
public static GameSnapshot ReadGame(byte[] json, string expectedGameId, DateTimeOffset now)
{
ManifestSummary? manifest;
try
{
manifest = JsonSerializer.Deserialize<ManifestSummary>(json, ManifestJson);
}
catch (JsonException ex)
{
throw new RemoteContentException("manifest illisible : " + ex.Message, ex);
}
if (manifest is null) throw new RemoteContentException("manifest vide.");
if (!string.IsNullOrWhiteSpace(expectedGameId) &&
!string.Equals(manifest.GameId, expectedGameId, StringComparison.OrdinalIgnoreCase))
throw new RemoteContentException($"manifest d'un autre jeu (« {manifest.GameId} », attendu « {expectedGameId} »).");
if (!VersionUtil.TryParse(manifest.Version, out var parsed))
throw new RemoteContentException($"version illisible « {manifest.Version} ».");
long installed = 0;
foreach (var file in manifest.Files ?? new List<ManifestFileSize>())
if (file is not null && file.Size > 0) installed += file.Size;
return new GameSnapshot
{
Version = ChangelogLimits.Clean(manifest.Version, ChangelogLimits.MaxVersionLength) ?? VersionUtil.Display(parsed),
ReleaseDate = manifest.ReleaseDate,
DownloadSize = Math.Max(0, manifest.Archive?.Size ?? 0),
InstalledSize = installed,
Changelog = ChangelogLimits.Normalize(manifest.Changelog),
CheckedAt = now
};
}
/// <summary>Copie d'une entrée sans les sections dont le titre figure dans <paramref name="hidden"/>.</summary>
public static ChangelogEntry WithoutSections(ChangelogEntry entry, IEnumerable<string> hidden)
{
var keys = hidden.Select(Fold).Where(k => k.Length > 0).ToHashSet(StringComparer.Ordinal);
if (keys.Count == 0) return entry;
return new ChangelogEntry
{
Version = entry.Version,
Date = entry.Date,
Title = entry.Title,
Subtitle = entry.Subtitle,
Introduction = entry.Introduction.ToList(),
Changes = entry.Changes.ToList(),
Sections = entry.Sections.Where(s => s.Title is null || !keys.Contains(Fold(s.Title))).ToList()
};
}
/// <summary>Clé de comparaison : sans accents, sans casse, espaces réduits.</summary>
internal static string Fold(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return "";
var decomposed = text.Trim().Normalize(NormalizationForm.FormD);
var sb = new StringBuilder(decomposed.Length);
foreach (var c in decomposed)
{
if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark) continue;
sb.Append(char.IsWhiteSpace(c) ? ' ' : char.ToUpperInvariant(c));
}
return string.Join(' ', sb.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
/// <summary>Clés publiques intégrées à l'exécutable du site (même fichier que le launcher).</summary>
public static IReadOnlyList<UpdateSignature.TrustedKey> LoadEmbeddedKeys()
{
using var stream = typeof(RemoteContentReader).Assembly.GetManifestResourceStream("GameLauncher.Website.update-signing.pub")
?? throw new InvalidOperationException("Clé publique de mise à jour absente de l'exécutable.");
using var reader = new StreamReader(stream, Encoding.UTF8);
try
{
return UpdateSignature.ParsePublicKeys(reader.ReadToEnd());
}
catch (CryptographicException ex)
{
throw new InvalidOperationException("Clé publique de mise à jour illisible.", ex);
}
}
private static string DecodeUtf8(byte[] bytes)
{
// BOM toléré (fichier édité à la main), comme File.ReadAllText.
var span = bytes.AsSpan();
if (span.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) span = span[3..];
return Encoding.UTF8.GetString(span);
}
private static readonly JsonSerializerOptions ManifestJson = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
MaxDepth = 32
};
private sealed class ManifestSummary
{
public string? GameId { get; set; }
public string? Version { get; set; }
public DateTimeOffset ReleaseDate { get; set; }
public ArchiveSize? Archive { get; set; }
public List<ManifestFileSize>? Files { get; set; }
public List<ChangelogEntry>? Changelog { get; set; }
}
private sealed class ArchiveSize
{
public long Size { get; set; }
}
private sealed class ManifestFileSize
{
public long Size { get; set; }
}
}
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more