Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f125da4f60 | ||
|
|
0c6c686ceb | ||
|
|
f07ffa156a | ||
|
|
7885fe81e0 | ||
|
|
2596736ddb | ||
|
|
c99e21c351 | ||
|
|
dd8bd00091 | ||
|
|
edcb902be5 | ||
|
|
c00685eaec | ||
|
|
02b7af8253 | ||
|
|
723df05cf3 | ||
|
|
85e69e6a2c | ||
|
|
7a6ad2247b | ||
|
|
19f207d430 | ||
|
|
a35e419133 | ||
|
|
a9d07f6df8 | ||
|
|
b398e4059a | ||
|
|
69f943ea53 | ||
|
|
4c368cb1e0 | ||
|
|
eca812a718 | ||
|
|
e4f2d443e8 | ||
|
|
6e7b480db5 | ||
|
|
1c7735d25a | ||
|
|
7b153b81ee | ||
|
|
8fe67053e1 | ||
|
|
927df6c2b2 | ||
|
|
197f5186d0 | ||
|
|
98cf17c294 | ||
|
|
4da86bcc6a | ||
|
|
52761f53b6 | ||
|
|
cf75a22f19 | ||
|
|
009c84de9f | ||
|
|
9f9b9b9d4d | ||
|
|
12ab8377c9 | ||
|
|
5aa22df409 | ||
|
|
3df009912b | ||
|
|
89570947aa | ||
|
|
14b2167218 | ||
|
|
2e4d2ee76e | ||
|
|
7d9032fe20 | ||
|
|
ccd6b1a430 | ||
|
|
e4a21c6f5d | ||
|
|
1a7f45c38b | ||
|
|
b48d4222db | ||
|
|
51eecbb57a | ||
|
|
edd2633455 | ||
|
|
0e26e6eb11 | ||
|
|
916b53446a | ||
|
|
0e371518b5 | ||
|
|
153e498345 | ||
|
|
1e9f49a733 | ||
|
|
5f6c667030 | ||
|
|
93dd844221 | ||
|
|
5ad8025946 | ||
|
|
080000e13c | ||
|
|
3422b92f81 | ||
|
|
683cc7a9dc | ||
|
|
7bc229de53 | ||
|
|
83e3d61634 | ||
|
|
b088c6caa7 | ||
|
|
658a1cce72 | ||
|
|
bac8ebeb55 | ||
|
|
76eaa48342 | ||
|
|
97df0ba721 |
No files matched your search
@@ -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,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>
|
||||
@@ -33,6 +33,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
||||
tools\ui-snapshot.bat = tools\ui-snapshot.bat
|
||||
tools\sign-keyring.bat = tools\sign-keyring.bat
|
||||
tools\publish-server.bat = tools\publish-server.bat
|
||||
tools\publish-site.bat = tools\publish-site.bat
|
||||
server-example\web.config = server-example\web.config
|
||||
EndProjectSection
|
||||
EndProject
|
||||
@@ -70,6 +71,21 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Admin"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Feedback", "src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj", "{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Accounts", "src\GameLauncher.Modules.Accounts\GameLauncher.Modules.Accounts.csproj", "{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "website", "website", "{8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Website", "website\GameLauncher.Website\GameLauncher.Website.csproj", "{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
website\deploy\LISEZMOI.md = website\deploy\LISEZMOI.md
|
||||
website\deploy\installer-site.sh = website\deploy\installer-site.sh
|
||||
website\deploy\lanisys-site.service = website\deploy\lanisys-site.service
|
||||
website\deploy\nginx-site.conf = website\deploy\nginx-site.conf
|
||||
website\deploy\nginx-site-zones.conf = website\deploy\nginx-site-zones.conf
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -174,6 +190,14 @@ Global
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|x64.ActiveCfg = Release|x64
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|x64.Build.0 = Release|x64
|
||||
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Debug|x64.Build.0 = Debug|x64
|
||||
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Release|x64.ActiveCfg = Release|x64
|
||||
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Release|x64.Build.0 = Release|x64
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|x64.ActiveCfg = Debug|x64
|
||||
@@ -182,6 +206,14 @@ Global
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.ActiveCfg = Release|x64
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.Build.0 = Release|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.Build.0 = Debug|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.ActiveCfg = Release|x64
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.Build.0 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -200,7 +232,10 @@ Global
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
|
||||
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
|
||||
{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
|
||||
|
||||
@@ -32,7 +32,7 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
|
||||
| Projet | Rôle |
|
||||
|---|---|
|
||||
| `src/GameLauncher` | Application WPF (fenêtres, thème, ViewModels, registre, raccourci) |
|
||||
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`) |
|
||||
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`), mode maintenance (`Maintenance/`) |
|
||||
| `tools/ManifestBuilder` | Outil console de publication : génère `manifest.json`, l'archive ZIP et le dossier `objects/` |
|
||||
| `tools/LauncherPublisher` | Outil console de publication **du launcher** : clés de signature, `launcher-update.json` signé (voir §7) |
|
||||
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, groupes et permissions, messages signés, catalogue, notes de version (modèle et limites), contrôle des manifests publiés (voir §8) |
|
||||
@@ -41,6 +41,7 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
|
||||
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux »), « Notes de version » (`Changelog/`) et « Publication » (`Publishing/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
|
||||
| `src/GameLauncher.Modules.Feedback` | Module « Aide » côté joueur (sans WPF) : formulaire de demande (avis, demande d'évolution, bug, autre) et envoi anonyme ; fenêtre dans `src\GameLauncher\Views\FeedbackWindow.xaml` |
|
||||
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
|
||||
| `website/GameLauncher.Website` | **Site vitrine** (ASP.NET Core Razor Pages) : présentation des jeux et du launcher, téléchargement, notes de version, actualités ; isolé de la Launcher API (voir §9) |
|
||||
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
|
||||
| `tools/UiSnapshot` | Captures de **toutes les fenêtres** (états de la fenêtre principale, dialogues, menu d'administration et ses pages) avec des données de démonstration, et rapport des problèmes de mise en page : `tools\ui-snapshot.bat` (voir `tools\UiSnapshot\LISEZMOI.md`) |
|
||||
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
|
||||
@@ -52,7 +53,8 @@ src/GameLauncher/
|
||||
├─ Assets/ background.png (thème par défaut), Themes/exovival-background.jpg, launcher.ico
|
||||
├─ Themes/ Default.xaml et Exovival.xaml (thèmes), Icons.xaml (icônes), Controls.xaml (styles),
|
||||
│ Changelog.xaml (modèles des notes de version), Admin.xaml (menu d'administration,
|
||||
│ dont les pages « Jeux », « Notes de version » et « Publier une version »)
|
||||
│ dont les pages « Jeux », « Notes de version » et « Publier une version »),
|
||||
│ Maintenance.xaml (palette rouge / orange du mode maintenance)
|
||||
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow,
|
||||
│ FeedbackWindow (bouton « Aide »)
|
||||
│ └─ Admin/ AdminWindow, LoginView, EnrollView (menu d'administration ; ViewModels dans GameLauncher.Modules.Admin)
|
||||
@@ -366,7 +368,7 @@ fait au démarrage suivant. Les entrées *Applications installées* (`Uninstall\
|
||||
| Vignette d'un jeu (liste des jeux) | `launcher.json` → `"icon"` (image carrée) ; sinon fond du thème + initiales |
|
||||
| Icône | Remplacez `Assets/launcher.ico` |
|
||||
| Icônes de l'interface | `Themes/Icons.xaml` (tracés 24×24, compatibles [Material Design Icons](https://pictogrammers.com/library/mdi/)) |
|
||||
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
|
||||
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran (ou WebP avec `--format webp`) dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
|
||||
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` ; sans recompiler : menu d'administration › **Jeux** (§8) |
|
||||
| Fréquence des recherches de mises à jour | `launcher.json` → `updateCheckIntervalMinutes` |
|
||||
| Dossier des données du jeu Unity (« Société\Produit ») | `launcher.json` → `gameDataFolder` |
|
||||
@@ -428,7 +430,7 @@ Le launcher (un seul `GameLauncher.exe`) vérifie au démarrage s'il existe une
|
||||
lui-même, la télécharge, l'installe et **redémarre tout seul**. Réglable dans **Paramètres du launcher** (roue crantée) :
|
||||
interrupteur « Mettre à jour le launcher automatiquement » (commun à tous les jeux) et bouton
|
||||
**« Vérifier les mises à jour »** (puis « Installer la x.y.z »). Option désactivée : un bandeau signale
|
||||
simplement qu'une nouvelle version existe.
|
||||
simplement qu'une nouvelle version existe — sauf pour une **version prioritaire** (ci-dessous), installée quand même.
|
||||
|
||||
### Sécurité
|
||||
|
||||
@@ -440,6 +442,7 @@ simplement qu'une nouvelle version existe.
|
||||
| Pas de rétrogradation | Seule une version **strictement supérieure** est installée ; le produit (`launcherId`) doit correspondre. |
|
||||
| Tailles bornées | `launcher-update.json` ≤ 256 Ko ; un serveur qui envoie plus que la taille annoncée est coupé (vaut aussi pour les fichiers du jeu). |
|
||||
| Retour arrière automatique | Si la nouvelle version ne démarre pas dans les 60 s (ou n'est pas la version annoncée), elle est arrêtée et l'ancienne remise en place. Cette version n'est plus proposée automatiquement (bouton manuel uniquement). |
|
||||
| Version prioritaire signée | L'indication « prioritaire » fait partie du contenu signé : un serveur compromis ne peut ni imposer une mise à jour, ni retirer la priorité d'un correctif. Elle ne permet pas d'installer autre chose qu'une version signée, strictement supérieure. |
|
||||
|
||||
### Déroulement
|
||||
|
||||
@@ -462,6 +465,22 @@ La mise à jour est refusée tant qu'un jeu est lancé ou qu'un téléchargement
|
||||
est dans un dossier protégé (Program Files), il ne peut pas se remplacer : le message l'explique.
|
||||
Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la version publiée se remplace.
|
||||
|
||||
### Version prioritaire (correctif critique)
|
||||
|
||||
Une version publiée avec **`--priority`** est installée au démarrage **même chez les joueurs qui ont
|
||||
désactivé** « Mettre à jour le launcher automatiquement ». Exemple : une faille critique est corrigée en 3.4.0.
|
||||
|
||||
| Point | Comportement |
|
||||
|---|---|
|
||||
| Réglage du joueur | **Jamais modifié** : `autoUpdate` reste à `false` dans `self-update.json`. Effet ponctuel : une fois le launcher à jour, les versions normales suivantes ne sont plus installées automatiquement chez lui. |
|
||||
| Versions suivantes | La priorité est **reprise** par les publications suivantes (`priorityVersion` dans `launcher-update.json`) : si la 3.5.0 (normale) sort après le correctif 3.4.0, un launcher encore en 3.3.x passe directement en 3.5.0 ; un launcher déjà en 3.4.0 n'est pas concerné. `--clear-priority` arrête la reprise (priorité publiée par erreur). |
|
||||
| Fenêtre de mise à jour | Pastille **« Prioritaire »** et texte « Mise à jour importante… votre réglage ne change pas ». « Plus tard » reste possible : nouvelle tentative au démarrage suivant, bandeau d'avertissement en attendant. |
|
||||
| Erreur de téléchargement | Retentée au démarrage suivant (une version normale n'est plus retentée automatiquement). |
|
||||
| La nouvelle version ne démarre pas | Retour arrière, puis plus de tentative automatique (sinon 60 s d'attente à chaque lancement) : bandeau d'avertissement et statut orange dans les Paramètres du launcher, installation par le bouton. |
|
||||
| Dossier protégé (Program Files) | Pas d'installation possible sans droits administrateur : bandeau d'avertissement. |
|
||||
| Option désactivée | Le launcher interroge désormais le serveur au démarrage (`launcher-update.json`, 1 à 2 Ko, 6 s au plus) pour savoir si une version prioritaire existe. |
|
||||
| Launchers antérieurs à 3.4.0 | Ignorent l'indication : ils ne suivent que leur propre réglage. La priorité ne vaut que pour les joueurs déjà en 3.4.0 ou plus. |
|
||||
|
||||
### Mise en place (une seule fois)
|
||||
|
||||
1. **Clés** : double-cliquez `tools\keygen-launcher.bat`. Choisissez une phrase de passe (12 caractères
|
||||
@@ -489,6 +508,9 @@ Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la ver
|
||||
signé par votre clé. Les launchers antérieurs à 1.1.3 reçoivent toujours le titre et la liste « à plat ».
|
||||
3. Double-cliquez `tools\publish-launcher-update.bat` : compilation, signature (phrase de passe demandée),
|
||||
puis envoi WinSCP — l'exe d'abord, `launcher-update.json` **en dernier**.
|
||||
**Correctif critique** : depuis une invite de commandes, `tools\publish-launcher-update.bat --priority`
|
||||
(confirmation demandée avant la signature). `--clear-priority` ne reprend pas la priorité déjà publiée.
|
||||
Le résumé affiché indique la priorité (« normale », « PRIORITAIRE » ou « version prioritaire x.y.z reprise »).
|
||||
|
||||
Vérification à tout moment :
|
||||
`dotnet run --project tools\LauncherPublisher -- verify --manifest https://…/launcher/launcher-update.json --public-key src\GameLauncher\Security\update-signing.pub`
|
||||
@@ -512,7 +534,7 @@ Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4
|
||||
**Notes de version**. Phase 5 livrée : la page **Publier une version**. Phase 6 livrée : les pages
|
||||
**Mes appareils**, **Administrateurs** et **Journal d'audit**. Phase 7 livrée : la **signature du contenu**
|
||||
servi aux joueurs (page **Signature**, voir plus bas). Ajoutées ensuite : la page **Fichiers du serveur**, puis le
|
||||
bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
|
||||
bouton **Aide** des joueurs et la page **Demandes des joueurs**, puis le **mode maintenance** (voir plus bas).
|
||||
|
||||
* **Ergonomie commune aux pages** : les boutons d'action (« Recharger », « Publier », « Enregistrer »,
|
||||
« Actualiser », « Exporter… ») restent affichés en bas de la fenêtre, quelle que soit la position dans la
|
||||
@@ -520,6 +542,18 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
|
||||
molette fait défiler ce qui est sous le curseur : elle ne change jamais une liste déroulante fermée (même si
|
||||
elle vient d'être utilisée), et une liste ou un champ qui ne peut plus défiler laisse défiler la page.
|
||||
|
||||
* **Page « Maintenance »** (permission `maintenance.manage`) : **active ou désactive le mode maintenance de tous les
|
||||
launchers** (tous les jeux), avec une précision facultative pour les joueurs (6 lignes, 300 caractères, aucun lien ;
|
||||
aperçu en direct). Chez les joueurs : fond et accents **rouge / orange**, carte « Maintenance en cours — merci de
|
||||
revenir plus tard » au centre (avec la précision et l'heure de début), bouton principal « MAINTENANCE » ; installation,
|
||||
mises à jour (automatiques comprises), vérification, réparation, localisation, lancement (raccourcis compris),
|
||||
paramètres, bouton « Aide » et site officiel **bloqués** ; seules la **désinstallation** (menu « … » ou Windows) et le
|
||||
menu d'administration restent disponibles. Lu au démarrage avant la recherche des mises à jour, puis toutes les 2 min
|
||||
(1 min pendant une maintenance) ; le launcher de l'administrateur bascule aussitôt. **Signé** (autorisation « mode
|
||||
maintenance » du trousseau) et vérifié comme le catalogue ; hors ligne au démarrage : pas de maintenance. Les
|
||||
téléchargements commencés se terminent, les parties en cours ne sont pas fermées, les mises à jour du launcher
|
||||
lui-même continuent. Le tableau de bord rappelle une maintenance en cours. Mise en place : `server/deploy/LISEZMOI.md`,
|
||||
étape 3 decies.
|
||||
* **Bouton « Aide »** (joueurs, barre de titre, bulle avec un point d'interrogation) : **avis**, **demande
|
||||
d'évolution**, **bug** ou **autre**, pour le **jeu sélectionné** (affiché, non modifiable pendant la saisie). Champs
|
||||
prédéfinis selon le type (note en étoiles, catégorie, gravité, fréquence, étapes pour reproduire…), contrôlés avant
|
||||
@@ -534,7 +568,8 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
|
||||
leurs champs (zone de texte, texte court, liste déroulante, cases à cocher, case oui/non, étoiles), modifiables ou
|
||||
masqués, avec un aperçu identique à la fenêtre des joueurs et un contrôle en direct ; communs à tous les jeux, **signés** (autorisation « formulaires » du trousseau) et vérifiés par le launcher
|
||||
à l'ouverture de la fenêtre « Aide », sans aucun lien autorisé dans les libellés (`server/deploy/LISEZMOI.md`,
|
||||
étape 3 nonies). Chaque demande garde le formulaire tel qu'à l'envoi.
|
||||
étape 3 nonies). Chaque demande garde le formulaire tel qu'à l'envoi. Journal d'audit : chaque publication indique
|
||||
les types ajoutés, modifiés ou supprimés par leur nom, avec le détail de chaque champ modifié.
|
||||
* **Page « Demandes des joueurs »** (permission `feedback.read` ; traitement : `feedback.manage`) : choix du jeu
|
||||
(nombre de nouvelles demandes sur chaque tuile), filtres (type, statut, recherche), fiche complète, statut (Nouvelle,
|
||||
En cours, Traitée, Rejetée, Doublon), note interne, suppression, captures d'écran (miniature, agrandissement). Une
|
||||
@@ -627,7 +662,9 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
|
||||
depuis la page **Signature** (propriétaires), qui montre aussi l'état de chaque contenu. Côté joueur,
|
||||
`"keyringUrl"` et `"contentSigning"` (`"warn"` puis `"require"`) dans `launcher.json` (intégré à l'exe) ;
|
||||
en « require », un manifest non signé, mal signé ou plus ancien que le dernier vu est refusé (le jeu installé
|
||||
reste jouable), et un catalogue refusé est ignoré. Après le retrait d'une clé compromise, relevez
|
||||
reste jouable), et un catalogue refusé est ignoré. Le trousseau est retéléchargé sans redémarrer le launcher quand
|
||||
un contenu est signé par une clé absente ou non autorisée pour ce contenu (au plus une fois par minute), et au plus
|
||||
toutes les 30 minutes (une clé retirée n'est plus acceptée par un launcher laissé ouvert). Après le retrait d'une clé compromise, relevez
|
||||
`"keyringMinSequence"` au numéro du nouveau trousseau. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 quinquies.
|
||||
* **Page « Journal d'audit »** (permission `audit.read` : propriétaires et administrateurs) : connexions,
|
||||
invitations, approbations, publications, modifications et refus, les plus récents d'abord (par pages
|
||||
@@ -672,3 +709,44 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
|
||||
| Créer sa clé, tester la connexion, inviter/approuver | Menu d'administration du launcher (pages « Mes appareils », « Administrateurs », « Groupes et permissions ») ; en ligne de commande : `tools\admin-key.bat` (`help` pour la liste) |
|
||||
| Signer le trousseau des clés autorisées à signer le contenu | Page « Signature » › « Exporter la demande… », puis glisser le fichier sur `tools\sign-keyring.bat` (clé racine, hors ligne ; nouvelles clés signalées, à vérifier de vive voix), puis « Publier le trousseau signé… » |
|
||||
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
|
||||
|
||||
## 9. Site vitrine
|
||||
|
||||
Site public du studio : `website/GameLauncher.Website` (ASP.NET Core Razor Pages, .NET 8), à déployer de
|
||||
préférence sur un **autre VPS** que celui du launcher. Installation, nginx, HTTPS, mentions légales :
|
||||
**`website/deploy/LISEZMOI.md`**. Publication : `tools\publish-site.bat`. En local : projet de démarrage
|
||||
*GameLauncher.Website*, F5 → `http://127.0.0.1:5090/`.
|
||||
|
||||
| Page | Contenu |
|
||||
|---|---|
|
||||
| `/` | Accueil : téléchargement du launcher, jeux, dernières actualités |
|
||||
| `/launcher` | Présentation du launcher (fonctions joueur), téléchargement, première installation, notes de la **dernière** version, lien vers les publications Gitea, questions fréquentes |
|
||||
| `/jeux`, `/jeux/exovival-zombie-reborn` | Liste des jeux ; page d'Exovival (histoire, contenu, cassettes, extension « L'abri » en développement, configuration) |
|
||||
| `/jeux/<jeu>/notes-de-version` | **Toutes** les notes de version du jeu, lues dans son `manifest.json` |
|
||||
| `/jeux/projet-multijoueur` | Jeu en préparation (page générique, `Site:Games` d'`appsettings.json`) |
|
||||
| `/actualites` | Articles en Markdown : `website/GameLauncher.Website/Content/News/*.md` |
|
||||
| `/mentions-legales`, `/confidentialite` | Éditeur, hébergeur ; données (aucun cookie, journaux 14 jours) |
|
||||
|
||||
* **Thème par univers** : `launcher` (violet / cyan, comme le thème « default »), `exovival` (noir, vert, titres
|
||||
condensés ; en-tête « lampe UV » qui révèle un message à l'encre invisible) et `multi` (jeu en préparation).
|
||||
* **Isolé de la Launcher API** : le site ne l'appelle jamais. Seuls `launcher-update.json` et les `manifest.json`
|
||||
sont lus, **côté serveur**, en arrière-plan (toutes les 10 min, requêtes conditionnelles, taille et délai bornés,
|
||||
https uniquement, aucune redirection suivie). Les pages ne déclenchent aucune requête sortante.
|
||||
* **Téléchargement du launcher** : affiché seulement si la signature de `launcher-update.json` est valide (même
|
||||
clé publique et même code que le launcher, clé intégrée à l'exécutable du site) et si l'exe est sur le même
|
||||
serveur. Version, taille et empreinte SHA-256 affichées. Fichier refusé = bouton retiré ; serveur injoignable =
|
||||
dernière version valide conservée. Les sections « Serveur », « Développement »… des notes du launcher ne sont
|
||||
pas affichées (`Site:Launcher:HiddenSections`).
|
||||
* **Notes de version** : mêmes bornes et même nettoyage que dans le launcher (`ChangelogLimits.Normalize`), textes
|
||||
toujours encodés (jamais interprétés comme du HTML).
|
||||
* **Sécurité HTTP** : politique de sécurité du contenu stricte (aucun script en ligne, aucune ressource externe,
|
||||
polices servies par le site), HSTS, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, `Permissions-Policy` ;
|
||||
lecture seule (`GET`/`HEAD`, le reste en 405) ; limite de requêtes par IP (site + nginx) ; aucun cookie ;
|
||||
aucune donnée écrite sur le disque.
|
||||
* **Service** (`website/deploy/lanisys-site.service`) : compte temporaire systemd, système en lecture seule,
|
||||
connexions sortantes limitées au serveur du launcher (liste écrite par `installer-site.sh`).
|
||||
* **Liseuse Markdown** des actualités (`Services/SafeMarkdown.cs`, sans dépendance) : HTML brut affiché tel quel,
|
||||
liens `javascript:`/`data:` retirés, images du site uniquement.
|
||||
* Tests : `tests/GameLauncher.Tests/Website/WebsiteTests.cs` (signature, adresses, bornes, Markdown, actualités).
|
||||
* Captures du launcher (`wwwroot/img/launcher/`) : reprises de `docs/preview*.png` ; à remplacer par des captures
|
||||
à jour, directement en WebP : `tools\ui-snapshot.bat --filter "main-*" --format webp` (voir `tools\UiSnapshot\LISEZMOI.md`).
|
||||
@@ -68,7 +68,7 @@ Conséquences :
|
||||
| `src/GameLauncher.Modules` | net8.0 | **Nouveau (phase 2).** Socle des modules, sans WPF : `ILauncherModule`, registre, pages d'administration, boîtes de dialogue abstraites, MVVM commun |
|
||||
| `src/GameLauncher.Modules.Admin` | net8.0 | **Nouveau (phase 2).** Module « Administration » : services (clés, connexion, invitation, verrouillage) et ViewModels, testés sans interface |
|
||||
| `src/GameLauncher.Modules.Feedback` | net8.0 | **Nouveau (7d).** Module « Aide » côté joueur : formulaire de demande (avis, évolution, bug, autre) généré à partir des formulaires partagés, envoi anonyme (§ 11 quinquies) |
|
||||
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) |
|
||||
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) ; **7f** : lecture et vérification du mode maintenance (`Maintenance/`) |
|
||||
| `src/GameLauncher.Contracts` | net8.0 | **Nouveau.** Protocole partagé launcher ⇄ serveur : routes, DTO, rôles et permissions, messages signés, contrôle des clés publiques ; catalogue (phase 3) ; modèle et bornes des notes de version (phase 4) |
|
||||
| `src/GameLauncher.ApiClient` | net8.0 | **Nouveau.** Client HTTP de l'API (connexion par défi signé, requêtes signées), fichier de clé chiffré |
|
||||
| `server/GameLauncher.Server` | net8.0 (ASP.NET Core) | **Nouveau.** Launcher API : modules serveur, authentification, administrateurs, audit, outil en ligne de commande |
|
||||
@@ -80,7 +80,8 @@ Dépendances : `Contracts` ne dépend de rien ; `ApiClient` → `Contracts` ; `S
|
||||
(uniquement : le serveur n'utilise pas `Core`) ; `Core` → `Contracts` (règles du catalogue, phase 3) ;
|
||||
`Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ; `Modules.Feedback` → `Modules` ;
|
||||
`GameLauncher` → `Core`, `Modules`, `Modules.Admin`, `Modules.Feedback`.
|
||||
Aucun paquet NuGet n'est nécessaire en dehors du projet de tests (xUnit).
|
||||
Paquets NuGet : xUnit (projet de tests) ; depuis la phase 8, le serveur utilise aussi `Microsoft.Data.Sqlite` (Microsoft)
|
||||
et `Konscious.Security.Cryptography.Argon2` (licence MIT) pour les comptes joueurs (`docs/PLAYER-ACCOUNTS-ARCHITECTURE.md`).
|
||||
|
||||
## 5. Modules
|
||||
|
||||
@@ -324,6 +325,8 @@ premier démarrage ou à la migration d'`admins.json` (format 1 → 2) avec les
|
||||
| `files.manage` | ✔ | ✔ | | Fichiers du serveur : consultation du site, suppression des fichiers inutilisés, nettoyage d'« objects » |
|
||||
| `feedback.read` | ✔ | ✔ | | Demandes des joueurs : lecture (consultations inscrites au journal) — § 11 quinquies |
|
||||
| `feedback.manage` | ✔ | ✔ | | Demandes des joueurs : statut, note interne, suppression |
|
||||
| `feedback.forms` | ✔ | ✔ | | Formulaires des demandes : modification et publication signée — § 11 sexies |
|
||||
| `maintenance.manage` | ✔ | ✔ | | Mode maintenance du launcher : activation, désactivation, message (publication signée) — § 11 septies |
|
||||
|
||||
Une permission intégrée ajoutée après l'installation (ex. `feedback.*`) est donnée **automatiquement** au groupe
|
||||
Propriétaire seulement : les groupes existants ne changent pas d'eux-mêmes (aucune élévation de droits sans l'action
|
||||
@@ -825,7 +828,7 @@ avant refusent encore un trousseau contenant `forms` et gardent le précédent (
|
||||
| Route | Accès | Signée | Rôle |
|
||||
|---|---|:-:|---|
|
||||
| `GET v1/feedback/forms` | administrateur connecté | | Formulaires en vigueur, publication possible, adresse publique, signataire |
|
||||
| `PUT v1/feedback/forms` | `feedback.forms` | ✔ | Publication : `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 sinon), audit `feedback.forms` (changements par type) |
|
||||
| `PUT v1/feedback/forms` | `feedback.forms` | ✔ | Publication : `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 sinon), audit `feedback.forms` (révision, types ajoutés, modifiés et supprimés par leur nom, signature), puis une ligne par type de demande : `feedback.form_create`, `feedback.form_update` ou `feedback.form_delete`, cible « nom du type », détail de chaque changement (nom, description, réglages, champ ajouté, supprimé ou modifié avec ce qui a changé : libellé, type, obligatoire, aide, longueur, étoiles, choix, aperçu), sur plusieurs lignes numérotées (1/2, 2/2…) au-delà de 480 caractères |
|
||||
|
||||
**Serveur** (`Storage/FeedbackFormsStore`) : référence privée `feedback-forms.json` (+ `.bak`, historique de 20
|
||||
révisions), copie **publique** à côté du trousseau (`FeedbackFormsPublicFileFullPath`, 644, atomique ; public d'abord).
|
||||
@@ -866,6 +869,78 @@ autorisation « formulaires »), signature par la clé de ce PC, requête signé
|
||||
« Recharger », rien n'est écrasé). « Annuler les modifications », « Revenir à l'origine » (formulaires d'origine dans
|
||||
l'éditeur, à publier ensuite), « Recharger ». Libellé d'audit : « Formulaires des demandes publiés ».
|
||||
|
||||
### 11 septies. Mode maintenance du launcher (7f ✅)
|
||||
|
||||
**Besoin** : depuis le menu, un bouton pour mettre **tous les launchers** (tous les jeux) en maintenance : thème rouge /
|
||||
orange, « Maintenance en cours — merci de revenir plus tard » au centre, et **tout bloqué** sauf la **désinstallation
|
||||
des jeux** et le **menu d'administration** ; puis la terminer.
|
||||
|
||||
**Document** (`Contracts/Maintenance/MaintenanceDocument`) : `maintenance.json` = format `gamelauncher-maintenance`,
|
||||
version 1, **révision**, `enabled`, `message` (facultatif), `since` (début, à la seconde ; absent hors maintenance),
|
||||
`updatedAt` (posé par le serveur, hors signature), signature ; révision 0 = jamais publié (inactif). Règles
|
||||
(`MaintenanceRules`, communes au menu, au serveur et au launcher) : format et version, révision ≥ 1 ; message
|
||||
nettoyé comme les textes des demandes (`FeedbackRules.CleanMultiline` : caractères de contrôle, invisibles, de mise en
|
||||
forme, d'inversion du sens d'écriture, d'usage privé refusés), **300 caractères, 6 lignes, aucun lien**
|
||||
(`FeedbackLinks`) ; maintenance active : début présent, à la seconde, pas plus de 10 min dans le futur (serveur) ;
|
||||
inactive : ni début ni message ; **8 Ko** au plus (sous la limite de 64 Ko de l'API : pas de bloc nginx d'API). Titre et
|
||||
sous-titre affichés aux joueurs **fixes** (dans le code) : seul le message est publié. Condensé signé : format, version,
|
||||
révision, état, message, début.
|
||||
|
||||
**Signature** : type de contenu `maintenance` (identifiant signé `maintenance`) et autorisation du trousseau
|
||||
**`maintenance`** (permission `maintenance.manage` : Propriétaire d'office, Administrateur sur une installation neuve,
|
||||
pas l'Éditeur). Les launchers 3.2 acceptent un trousseau qui la contient (autorisation inconnue sans effet) ; ceux
|
||||
d'avant le refusent et gardent le précédent (publier la 3.3 avant de re-signer). Un serveur compromis ne peut donc ni
|
||||
déclencher une fausse maintenance, ni prolonger une maintenance chez les launchers en « require » (date de signature
|
||||
non antérieure à la dernière acceptée, révision non inférieure).
|
||||
|
||||
| Route | Accès | Signée | Rôle |
|
||||
|---|---|:-:|---|
|
||||
| `GET v1/maintenance` | administrateur connecté | | État en vigueur, publication possible, adresse publique, signataire (tableau de bord) |
|
||||
| `PUT v1/maintenance` | `maintenance.manage` | ✔ | `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 `revision_conflict` sinon), début inchangé pendant une maintenance ; audit `maintenance.enable`, `maintenance.disable` ou `maintenance.update` |
|
||||
|
||||
**Serveur** (`Modules/Maintenance/MaintenanceModule`, `Storage/MaintenanceStore`) : référence privée `maintenance.json`
|
||||
(+ `.bak`, historique de 20 révisions `maintenance-history/`), copie **publique** à côté du trousseau
|
||||
(`MaintenancePublicFileFullPath`, 644, atomique ; public d'abord) ; nom réservé (refus au démarrage si le catalogue ou
|
||||
le trousseau s'appellent ainsi). Document signé obligatoire. Le serveur ne bloque rien lui-même : l'API reste utilisable
|
||||
(c'est elle qui termine la maintenance). `GET v1/signing` renvoie aussi sa signature (`SigningState.Maintenance`).
|
||||
SQLite écarté : un seul petit document, publié en fichier de toute façon. nginx : `location = /catalog/maintenance.json`,
|
||||
sans cache.
|
||||
|
||||
**Launcher (joueur)** : `Core/Maintenance/MaintenanceService` lit `maintenance.json` (adresse déduite du trousseau,
|
||||
sinon du catalogue) **au démarrage**, en parallèle du catalogue et de la mise à jour du launcher, et l'attend **4 s au
|
||||
plus avant la recherche des mises à jour des jeux** (pas d'attente pour `--uninstall`) ; puis toutes les **2 min** (1 min
|
||||
pendant une maintenance) et aussitôt après une publication depuis le menu de ce PC (`IAdminContext.NotifyMaintenanceChanged`).
|
||||
8 Ko au plus, 8 s au plus ; règles contrôlées même si signé ; signature vérifiée par
|
||||
`ContentTrustService.VerifyMaintenanceAsync` (autorisation `maintenance`, date de signature non antérieure) ; révision
|
||||
inférieure à l'état connu refusée ; même fichier que la fois précédente (déjà accepté) : rien à revérifier ; fichier
|
||||
refusé : revérifié à chaque relecture (un trousseau re-signé avec l'autorisation `maintenance` pendant que le launcher
|
||||
tourne est retéléchargé et pris en compte, 3.3.1). **Rien n'est gardé sur le
|
||||
disque** : injoignable au démarrage = pas de maintenance (le jeu installé reste jouable hors ligne, un joueur sans
|
||||
connexion n'est jamais bloqué) ; pendant que le launcher tourne, une coupure ou un fichier refusé garde le dernier état
|
||||
vérifié, un 404 termine la maintenance.
|
||||
|
||||
Blocage (`LauncherViewModel.ApplyMaintenance`, `GameViewModel.IsMaintenance`) : commandes désactivées (sauf
|
||||
`UninstallCommand`, l'annulation d'une opération commencée et la fermeture du bandeau) **et** refus à l'entrée de chaque
|
||||
action (installation, mise à jour, réparation, vérification, localisation, oubli, lancement, raccourci du Bureau,
|
||||
paramètres, recherche de mises à jour, `RunOperationAsync` sauf la désinstallation) avec un bandeau d'explication ;
|
||||
recherche automatique et mise à jour automatique suspendues, relancées à la fin de la maintenance ; paramètres du
|
||||
launcher, bouton « Aide » et site officiel masqués ou désactivés. Non interrompus : opération déjà commencée (toujours
|
||||
annulable), jeu déjà lancé, mise à jour du launcher lui-même (signée par la clé racine : moyen de corriger un launcher
|
||||
défectueux). Affichage : `Themes/Maintenance.xaml` (palette rouge / orange, mêmes clés que les thèmes) ajouté aux
|
||||
ressources de la fenêtre principale seulement, carte au centre (cône de chantier `Icon.TrafficCone`, bandes de
|
||||
chantier, titre, sous-titre, message en texte brut sélectionnable, « Depuis le … », rappel « la désinstallation reste
|
||||
possible »), bouton principal « MAINTENANCE » désactivé ; début et fin avec le fondu enchaîné d'un changement de jeu.
|
||||
**Ce n'est pas une barrière de sécurité** : un launcher modifié peut l'ignorer ; les serveurs du jeu doivent refuser
|
||||
eux-mêmes les connexions si nécessaire.
|
||||
|
||||
**Menu d'administration** : module `MaintenanceAdminModule`, page **« Maintenance »** (`MaintenancePanelViewModel`,
|
||||
permission `maintenance.manage`, ordre 5, icône cône) : état publié (en cours depuis…, par…, terminée le…), message
|
||||
(compteur, contrôle en direct, aperçu de la carte), « Activer la maintenance » (confirmation détaillée), « Modifier le
|
||||
message », « Terminer la maintenance » (confirmation), contrôle du trousseau des joueurs avant chaque publication
|
||||
(`PlayerSigningCheck`), 409 : état relu, saisie gardée. Tableau de bord : bandeau « Maintenance en cours » pour tout
|
||||
administrateur (bouton « Gérer » avec la permission). Page « Signature » : ligne « Mode maintenance ». Journal d'audit :
|
||||
catégorie « Mode maintenance ».
|
||||
|
||||
## 12. Signature de bout en bout pour les joueurs (phase 7 ✅)
|
||||
|
||||
Sans signature, une prise de contrôle du VPS permettrait de distribuer un faux jeu. Désormais :
|
||||
@@ -894,6 +969,13 @@ côté du catalogue, 0644, atomique) et un historique ; audit `keyring.publish`
|
||||
des administrateurs expose désormais la clé publique des clés **actives** seulement (celle d'une clé en attente
|
||||
permettrait de calculer l'empreinte à dicter).
|
||||
|
||||
**Contenu en ligne** (page Signature, `GET v1/signing` : `SigningState`) : versions en ligne de chaque jeu, catalogue,
|
||||
puis **formulaires des demandes** (`FeedbackFormsSignatureState` : révision, condensé, signature, signataire ;
|
||||
révision 0 = formulaires d'origine intégrés au launcher, rien à signer ; fichier de référence illisible signalé).
|
||||
Chaque contenu est vérifié par le launcher avec le trousseau publié (accepté ou refusé par les joueurs, et pourquoi)
|
||||
et avec le trousseau proposé (« sa clé ne sera plus dans le trousseau proposé » ; l'export de la demande le rappelle).
|
||||
Un serveur antérieur à la 3.2.1 n'envoie pas les formulaires : la ligne n'apparaît pas.
|
||||
|
||||
**Signature du contenu** (`ContentSigning`) : propriété `signature` `{ keyId, signedAt, value }` dans le JSON
|
||||
(atomique avec le contenu, ignorée par les anciens launchers), sur le message `GameLauncher/Content/v1`
|
||||
(type, identifiant du jeu ou « catalog », date à la seconde, clé, condensé). Condensés canoniques, calculés par
|
||||
@@ -937,8 +1019,12 @@ Trousseau téléchargé au démarrage (en parallèle du catalogue), vérifié (s
|
||||
s'il est plus ancien que le dernier vu, différent sous le même numéro, ou plus ancien que `keyringMinSequence`
|
||||
(launcher.json intégré, à relever après le retrait d'une clé compromise) ; hors ligne : dernier trousseau vu
|
||||
(revérifié à la lecture du cache). Aucune erreur de téléchargement (délai, réponse invalide, bogue) ne remonte à
|
||||
l'appelant : le cache est utilisé. Contenu signé par une clé absente du trousseau : nouveau téléchargement, au plus
|
||||
une fois toutes les 5 minutes. Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
|
||||
l'appelant : le cache est utilisé. Nouveau téléchargement du trousseau (mêmes contrôles) : après un échec réseau
|
||||
(1 min), quand un contenu est signé par une clé absente du trousseau **ou présente sans l'autorisation de ce contenu**
|
||||
(clé ajoutée ou autorisation donnée depuis : au plus une fois par minute, `KeyChangeRetryDelay`), et quand le trousseau
|
||||
en mémoire a plus de 30 minutes (`KeyringMaxAge`, à la vérification suivante : un launcher laissé ouvert cesse de faire
|
||||
confiance à une clé retirée). Avant la 3.3.1, une autorisation ajoutée à une clé déjà connue n'était vue qu'au
|
||||
redémarrage (mode maintenance refusé à chaque relecture après la re-signature du trousseau). Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
|
||||
vérifiés : clé du trousseau autorisée, signature valide, **date de signature ≥ la dernière acceptée** pour ce
|
||||
contenu (un serveur compromis ne peut pas resservir une ancienne version légitime mais vulnérable). « warn » :
|
||||
journal seulement ; « require » : manifest refusé (`ContentSecurityException`, message « Mise à jour bloquée par
|
||||
@@ -969,6 +1055,10 @@ trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; re
|
||||
| **7b** | Page « Fichiers du serveur » (suppression des fichiers inutilisés, nettoyage d'« objects »), période du journal d'audit, barres d'actions fixes et retour en haut, molette sans effet sur les listes fermées | ✅ |
|
||||
| **7c** | Groupes d'administrateurs et permissions personnalisées (page « Groupes et permissions », module serveur `Groups`, `admins.json` format 2) ; envoi des images du catalogue depuis la page « Jeux » (« Parcourir… », images nommées par leur empreinte et vérifiées par les joueurs) | ✅ |
|
||||
| **7d** | Demandes des joueurs : bouton « Aide » (avis, évolution, bug, autre ; anonyme, preuve de travail, limites), module serveur `Feedback`, page « Demandes des joueurs », purge automatique (demandes et journal d'audit) | ✅ |
|
||||
| **7e** | Formulaires des demandes modifiables depuis le menu (types, champs, icônes importées), signés (autorisation `forms`) | ✅ |
|
||||
| **7f** | Mode maintenance du launcher : page « Maintenance », module serveur `Maintenance`, document signé (autorisation `maintenance`), blocage côté joueur sauf la désinstallation, thème rouge / orange | ✅ |
|
||||
| **8a** | Comptes joueurs — socle serveur (module `Accounts`, schéma d'authentification `PlayerSession` totalement séparé des administrateurs, base SQLite chiffrée, Argon2id + poivre, session mémorisée liée à une clé d'appareil, anti-force brute, journal des comptes, outil en ligne de commande) ; désactivé par défaut. Suite (8b à 8h) : `docs/PLAYER-ACCOUNTS-ARCHITECTURE.md`, § 12 | ✅ |
|
||||
| **8b** | Comptes joueurs — launcher : bouton « Compte », connexion / création de compte / nouveau mot de passe exigé, session mémorisée (clé d'appareil dans la puce TPM ou clé CNG non exportable, jeton chiffré DPAPI), « Mon compte » (profil, appareils, activité, export JSON, suppression) ; serveur : activité, export, suppression, `IPersonalDataProvider`, comptes inactifs (sommeil après 24 mois, suppression 12 mois plus tard). Bouton absent tant que `playerAccounts` vaut `false` dans le launcher.json intégré | ✅ |
|
||||
|
||||
Chaque phase compile, est testée et laisse le launcher utilisable. Pour les joueurs, rien ne
|
||||
change : le menu d'administration est discret et ne contacte le serveur qu'une fois ouvert.
|
||||
@@ -977,8 +1067,8 @@ change : le menu d'administration est discret et ne contacte le serveur qu'une f
|
||||
|
||||
| Option | Pourquoi elle n'a pas été retenue |
|
||||
|---|---|
|
||||
| Identifiant + mot de passe + OTP | Mots de passe hameçonnables ; secrets OTP stockés sur le serveur |
|
||||
| Identifiant + mot de passe + OTP (administrateurs) | Mots de passe hameçonnables ; secrets OTP stockés sur le serveur. Les **comptes joueurs** (phase 8), totalement séparés, utilisent adresse + mot de passe + OTP facultatif : ils n'ouvrent jamais le menu d'administration |
|
||||
| Envoi SFTP direct depuis le launcher | Pas de droits par rôle, pas d'audit, pas de validation serveur, clé SFTP = accès total aux fichiers |
|
||||
| Secret partagé dans l'exe | Extractible par n'importe quel joueur |
|
||||
| Base de données (SQL) | Inutile à cette échelle : fichiers JSON écrits atomiquement, verrou inter-processus, sauvegarde. SQLite n'est pas exclu : sa légitimité est évaluée **module par module** (voir § 11 quinquies pour les demandes des joueurs) |
|
||||
| Base de données (SQL) | Inutile à cette échelle : fichiers JSON écrits atomiquement, verrou inter-processus, sauvegarde. SQLite n'est pas exclu : sa légitimité est évaluée **module par module** (voir § 11 quinquies pour les demandes des joueurs) ; **retenu pour les comptes joueurs** (relations, unicité, volumes : `PLAYER-ACCOUNTS-ARCHITECTURE.md` § 6) |
|
||||
| Thème XAML distant | Un XAML téléchargé peut référencer du code : seuls les thèmes intégrés sont sélectionnables |
|
||||
@@ -0,0 +1,300 @@
|
||||
# Comptes joueurs — Architecture (phase 8)
|
||||
|
||||
> Document de référence du module « comptes joueurs ». Plan validé le 2026-11-10 (copie du plan dans les documents du
|
||||
> projet : `GameLauncher-Comptes-Joueurs-Plan.md`). Statut des étapes : § 12.
|
||||
> À relire avec `ADMIN-ARCHITECTURE.md` (socle, principes de sécurité) et la fiche RGPD / CNIL du projet.
|
||||
|
||||
## 1. Besoin
|
||||
|
||||
* Compte joueur = **adresse e-mail + mot de passe**, **OTP (TOTP) facultatif** (étape 8c).
|
||||
* **Jamais obligatoire** pour lancer le launcher, installer ou jouer (jeux solo : anonyme). Obligatoire seulement pour
|
||||
la future partie sociale.
|
||||
* Bouton « Aide » : **anonyme** (comme aujourd'hui, aucune réponse) **ou connecté** (suivi + échanges avec les
|
||||
administrateurs) — étape 8f.
|
||||
* Menu admin (étape 8d), avec permissions : supprimer, désactiver / réactiver (durée ou indéfini), forcer le changement
|
||||
de mot de passe, mot de passe temporaire, réinitialiser l'OTP ; blocages par IP publique et par identifiants matériels
|
||||
du PC (étape 8e).
|
||||
* **Aucun lien** avec les comptes administrateurs : un compte joueur ne peut **jamais** ouvrir le menu d'administration.
|
||||
* Dictionnaire public des pires mots de passe ; anti-force brute (blocage temporaire puis suspension levée par un
|
||||
administrateur) ; **connexion mémorisée** (comme Steam) ; **audit** de chaque action joueur et administrateur.
|
||||
|
||||
## 2. Principes
|
||||
|
||||
1. **Le serveur est la seule barrière** : chaque règle est revérifiée côté serveur avec le même code partagé
|
||||
(`GameLauncher.Contracts.Accounts`).
|
||||
2. **Deux mondes étanches** : administrateurs = clé publique/privée et requêtes signées ; joueurs = mot de passe.
|
||||
Schémas d'authentification ASP.NET distincts (`AdminSession` / `PlayerSession`), jetons de préfixes distincts
|
||||
(`glpa_`, `glpr_`, `glpc_` pour les joueurs), stockages distincts (`admins.json` + sessions en mémoire / `players.db`),
|
||||
routes distinctes (`v1/account/…`). Chaque règle d'autorisation d'administration **désigne explicitement** le schéma
|
||||
`AdminSession` ; le gestionnaire administrateur ignore tout jeton commençant par `glp`. Un test essaie **toutes** les
|
||||
routes d'administration déclarées avec des jetons joueurs (accès, changement exigé, renouvellement) : 401 attendu
|
||||
partout (`PlayerSeparationTests`).
|
||||
3. **Minimisation** : ni nom, ni date de naissance, ni adresse postale, ni nom Windows du PC. Le pseudo viendra avec le
|
||||
module social.
|
||||
4. **Une copie des données du serveur ne livre rien d'exploitable** : adresses chiffrées, mots de passe en Argon2id avec
|
||||
un « poivre », clé maîtresse hors du dossier des données.
|
||||
5. **Désactivé par défaut** (`LauncherApi:Players:Enabled = false`) ; création de compte désactivable séparément.
|
||||
6. Le launcher ne contacte l'API que si le joueur l'a voulu (connexion, création) ou s'il a une session mémorisée.
|
||||
|
||||
## 3. Protocole
|
||||
|
||||
### 3.1 Clé d'appareil
|
||||
|
||||
Le launcher crée une clé ECDSA P-256 **par PC et par compte Windows** (`Platform\PlayerDeviceKeyStore.cs`) : dans la puce
|
||||
TPM 2.0 (« Microsoft Platform Crypto Provider ») si elle existe, **sans code PIN**, jamais exportable ; sinon clé logicielle
|
||||
du « Microsoft Software Key Storage Provider », marquée **non exportable** et protégée par Windows. Nom de clé propre aux
|
||||
joueurs (`GameLauncher.PlayerDevice.<éditeur><dossier>.v1`), distinct des clés d'administrateur. La création de compte, la connexion et chaque renouvellement de
|
||||
session sont **signés** par cette clé (`PlayerMessages.Device`) :
|
||||
|
||||
```
|
||||
GameLauncher/Player/Device/v1
|
||||
purpose:<register|login|refresh>
|
||||
origin:https://launcher.lanisys.fr
|
||||
server:<serverId>
|
||||
challenge:<challengeId>
|
||||
nonce:<nonce>
|
||||
device:<keyId de la clé d'appareil>
|
||||
```
|
||||
|
||||
En-tête propre aux joueurs (séparation de domaine avec `GameLauncher/Admin/…`). Bénéfices : un jeton de renouvellement
|
||||
copié sur un autre PC est inutilisable ; seul un appareil déjà connecté avec succès est « de confiance » pour
|
||||
l'anti-force brute.
|
||||
|
||||
### 3.2 Défi
|
||||
|
||||
`POST v1/account/challenge { purpose }` → `{ challengeId, nonce, purpose, difficulty, serverId, origin, expiresAt }`.
|
||||
Défi **sans état** (usage, échéance, difficulté et nonce scellés par HMAC, secret en mémoire, domaine
|
||||
`GameLauncher/Player/Challenge/v1`), valable 5 min, **à usage unique** (consommé seulement avec une signature d'appareil
|
||||
valide). Preuve de travail (`FeedbackProof`) : 20 bits pour la création de compte, 16 pour la connexion, aucune pour le
|
||||
renouvellement. Un défi d'un usage ne sert pas à un autre.
|
||||
|
||||
### 3.3 Routes
|
||||
|
||||
| Route | Accès | Rôle |
|
||||
|---|---|---|
|
||||
| `POST v1/account/challenge` | anonyme | Défi (§ 3.2) |
|
||||
| `POST v1/account/register` | anonyme + défi + clé d'appareil | Création de compte → session ouverte |
|
||||
| `POST v1/account/login` | anonyme + défi + clé d'appareil | Connexion → session, ou jeton « changement exigé » |
|
||||
| `POST v1/account/refresh` | jeton de renouvellement + signature de l'appareil | Session renouvelée (jeton remplacé) |
|
||||
| `POST v1/account/logout` | session | Déconnexion de cet appareil, ou de tous (`allDevices`) |
|
||||
| `GET v1/account/me` | session | Compte (adresse **masquée**) et appareils connectés |
|
||||
| `POST v1/account/password` | session ou jeton « changement exigé » | Nouveau mot de passe |
|
||||
| `DELETE v1/account/devices/{deviceId}` | session | Déconnecter un appareil du compte |
|
||||
| `GET v1/account/activity` | session | 50 derniers événements du compte : type, résultat, appareil s'il est toujours connecté, IP **masquée** (`203.0.113.x`, IPv6 en /48), aucun détail interne |
|
||||
| `POST v1/account/export` | session + **mot de passe** | Export JSON de toutes ses données (`GameLauncher/PlayerData/v1`, § 10) |
|
||||
| `POST v1/account/delete` | session + **mot de passe** | Suppression définitive du compte (§ 10) |
|
||||
|
||||
Limite de débit propre (`account`, `Players:AuthRequestsPerMinute`, 20/min par IP, IPv6 par /64) sur le défi, la
|
||||
création, la connexion, le renouvellement, le changement de mot de passe, l'export et la suppression, en plus de la limite générale. `GET v1/info`
|
||||
annonce `accounts` (activés, création ouverte, version des conditions, version des règles des mots de passe) ; absent
|
||||
si les comptes sont désactivés.
|
||||
|
||||
### 3.4 Jetons et session mémorisée
|
||||
|
||||
| Jeton | Format | Durée | Où |
|
||||
|---|---|---|---|
|
||||
| Accès | `glpa_` + 256 bits | 15 min | mémoire du launcher ; serveur : empreinte en mémoire |
|
||||
| Renouvellement | `glpr_` + identifiant d'appareil + `.` + 256 bits | 90 jours sans utilisation, 1 an au plus | PC (`account\session.dat`, chiffré DPAPI) si « Rester connecté » ; serveur : empreinte en base |
|
||||
| Changement exigé | `glpc_` + 256 bits | 10 min, usage unique | mémoire |
|
||||
|
||||
Renouvellement : jeton **remplacé à chaque fois** (l'ancien devient « précédent ») ; un jeton précédent présenté de
|
||||
nouveau = copie volée (ou réponse perdue) → l'appareil est déconnecté par précaution (`session.reuse`). Deux
|
||||
renouvellements simultanés avec le même jeton : un seul réussit. Un redémarrage du service efface les jetons d'accès : le
|
||||
launcher renouvelle sa session sans rien demander au joueur.
|
||||
|
||||
À chaque requête authentifiée : jeton valide **et** compte actif (ou suspendu, § 5) **et** appareil toujours enregistré
|
||||
**et** aucun changement de mot de passe exigé. Une désactivation, une déconnexion ou un changement de mot de passe
|
||||
prennent effet immédiatement.
|
||||
|
||||
### 3.5 Launcher (étape 8b)
|
||||
|
||||
* **Bouton « Compte »** dans la barre de titre (icône de compte, pastille verte = connecté, orange = reconnexion ou
|
||||
serveur injoignable), présent seulement si `"playerAccounts": true` **et** `apiUrl` sont dans le `launcher.json`
|
||||
**intégré** (Release) ; masqué pendant une maintenance. Le launcher ne contacte l'API des comptes que si une session est
|
||||
mémorisée (reprise au démarrage, sans bloquer l'affichage) ou quand le joueur ouvre la fenêtre.
|
||||
* **Fenêtre « Compte »** (`Views\AccountWindow.xaml`, ViewModel `AccountWindowViewModel` du projet
|
||||
`GameLauncher.Modules.Accounts`, sans WPF, testé) : connexion (« Rester connecté sur ce PC », aide « mot de passe
|
||||
oublié »), création de compte (règles du mot de passe vérifiées **en direct** avec `PasswordRules`, confirmation,
|
||||
acceptation des conditions), nouveau mot de passe exigé, puis **Mon compte** : Profil (adresse masquée, référence à
|
||||
copier, dates, changement de mot de passe), Appareils (déconnecter un appareil, tous les appareils), Activité (adresses
|
||||
IP masquées, essais refusés signalés), Mes données (export JSON, suppression définitive : case à cocher + confirmation
|
||||
+ mot de passe).
|
||||
* **Mots de passe** : jamais liés au ViewModel ; lus dans les `PasswordBox` au moment de l'action, puis effacés. Seul le
|
||||
mot de passe tapé à la connexion est gardé en mémoire quelques minutes quand un administrateur exige d'en changer (le
|
||||
serveur le redemande).
|
||||
* **Session** (`PlayerAccountService`) : jeton d'accès en mémoire, renouvelé une minute avant son échéance ou aussitôt
|
||||
après un redémarrage du serveur (un seul renouvellement à la fois) ; jeton de renouvellement enregistré **chiffré DPAPI**
|
||||
(`Platform\DpapiProtector.cs`, utilisateur Windows, entropie `GameLauncher/Player/Session/v1`, écriture atomique) et
|
||||
remplacé sur le disque à chaque renouvellement. Session ignorée si elle vient d'un autre serveur ou d'une autre clé
|
||||
d'appareil ; illisible (autre utilisateur, autre PC) = supprimée. Sans « Rester connecté », rien n'est écrit et la
|
||||
session est fermée sur le serveur à la sortie du launcher (3 s au plus). Session refusée au renouvellement (expirée,
|
||||
déconnectée ailleurs, compte désactivé) : effacée, raison affichée à l'ouverture de la fenêtre ; serveur injoignable :
|
||||
conservée (« hors ligne »), nouvel essai à la prochaine action.
|
||||
* **Nom de l'appareil** envoyé au serveur : générique (« Windows 11 · GameLauncher 3.5.0 »), jamais le nom Windows du PC.
|
||||
* Captures : `tools\ui-snapshot.bat --filter "account-*,main-account"`.
|
||||
|
||||
## 4. Mots de passe et adresses
|
||||
|
||||
* **Règles** (`PasswordRules`, identiques launcher / serveur) : 12 caractères au moins avec 3 types parmi minuscules,
|
||||
majuscules, chiffres, symboles ; ou 16 caractères sans contrainte (phrase de passe) ; 128 au plus ; pas de caractère
|
||||
de contrôle. Refus : mots de passe du **dictionnaire** (« 100k-most-used-passwords-NCSC », 99 839 entrées, ressource
|
||||
intégrée compressée, + une liste de mots de passe français courants) et leurs variantes simples (minuscules,
|
||||
substitutions `0→o 1→i 3→e 4→a 5→s 7→t @→a $→s…`, chiffres et symboles retirés aux extrémités :
|
||||
« P@ssw0rd2024! » ≡ « password ») ; suites triviales (répétitions, rangées de claviers AZERTY / QWERTY / QWERTZ, suites
|
||||
de lettres ou de chiffres, motifs répétés) ; mot de passe tiré de l'adresse e-mail ou des noms du studio et des jeux
|
||||
(moins de 8 caractères restants une fois ces mots retirés).
|
||||
* **Aucune normalisation Unicode** : le serveur tourne en « globalisation invariante » (sans ICU), où elle n'est pas
|
||||
disponible ; le mot de passe est contrôlé et haché tel qu'il est saisi, de la même façon partout.
|
||||
* **Adresse** (`PlayerEmail`) : minuscules, caractères ASCII seulement (domaines internationaux sous forme `xn--`), un
|
||||
seul `@`, 254 caractères au plus ; masquée pour l'affichage (`n***@e***.fr`). Non vérifiée tant que le module e-mail
|
||||
n'existe pas (`emailVerified = false`).
|
||||
* **Empreinte** (`PlayerPasswordHasher`) : Argon2id (RFC 9106 ; paquet `Konscious.Security.Cryptography.Argon2`, MIT),
|
||||
19 Mio, 2 passes, parallélisme 1 (OWASP), sel 128 bits, **poivre = clé secrète d'Argon2** dérivée de la clé maîtresse.
|
||||
Format PHC `$argon2id$v=19$m=19456,t=2,p=1,k=1$<sel>$<empreinte>` : un relèvement des réglages est appliqué à la
|
||||
connexion suivante de chaque joueur. Calculs limités à 4 simultanés (file d'attente, 503 au-delà de 20 s) ; même calcul
|
||||
pour une adresse inconnue (la durée de réponse ne révèle rien). Vecteur officiel de la RFC vérifié par un test
|
||||
(`Argon2VectorTests`).
|
||||
|
||||
## 5. Anti-force brute
|
||||
|
||||
1. Limites de débit (nginx et ASP.NET) et preuve de travail.
|
||||
2. **Par adresse IP** (en mémoire) : 30 échecs de connexion / 15 min tous comptes confondus → 429 ; 3 créations de
|
||||
compte / heure et 5 / jour → 429 ; plafond global de 500 créations / jour → 503.
|
||||
3. **Par compte, depuis un appareil inconnu** : blocage après **5 échecs consécutifs** (15 min, puis 30, puis 60 :
|
||||
durée doublée à chaque blocage) ; au **4ᵉ blocage (20 échecs consécutifs)**, le compte est **suspendu**. Le blocage
|
||||
s'applique avant tout calcul (429 + `Retry-After`).
|
||||
4. **Appareils de confiance** (déjà connectés, prouvés par leur clé) : leurs échecs sont comptés à part (blocage de
|
||||
15 min après 10 échecs), **jamais de suspension**.
|
||||
5. **Suspension = connexions depuis un NOUVEL appareil refusées** jusqu'à ce qu'un administrateur la lève (outil en ligne
|
||||
de commande dès maintenant, menu d'administration à l'étape 8d). Les appareils déjà connectés continuent de
|
||||
fonctionner (`newDevicesSuspended` dans `GET v1/account/me`) : un tiers qui connaît l'adresse peut déclencher la
|
||||
suspension, mais pas priver le joueur de son compte. Inscrite au journal d'audit (`players.suspend`, par référence).
|
||||
6. **Rien ne révèle qu'une adresse a un compte** : message identique pour une adresse inconnue et un mauvais mot de
|
||||
passe, même durée de calcul, et une adresse inconnue se **bloque exactement comme un compte réel** (blocage « miroir »
|
||||
en mémoire). L'état du compte (désactivé, suspendu) n'est révélé qu'après un **mot de passe correct**. Seule exception
|
||||
inévitable sans e-mail de confirmation : la création de compte indique qu'une adresse est déjà prise (atténué par les
|
||||
points 1 et 2).
|
||||
7. Une connexion réussie remet les compteurs à zéro.
|
||||
|
||||
## 6. Stockage
|
||||
|
||||
**SQLite retenu** pour ce module (analyse « module par module ») : relations (compte ⇄ appareils ⇄ journal, puis
|
||||
conversations et blocages), unicité stricte des adresses sous transaction, volumes non bornés, recherches par index.
|
||||
`Microsoft.Data.Sqlite` (Microsoft), sans Entity Framework, requêtes paramétrées uniquement.
|
||||
|
||||
* Fichier : `/var/lib/launcher-api/players/players.db` (dossier 0700, fichier 0600 créé avant l'ouverture ; les fichiers
|
||||
`-wal` et `-shm` reprennent ses droits). Journal WAL, clés étrangères, `secure_delete` (lignes supprimées écrasées),
|
||||
écriture synchrone complète. Schéma versionné (`PRAGMA user_version`, version 1). Une seule connexion protégée par un
|
||||
verrou ; l'outil en ligne de commande peut ouvrir la base en même temps.
|
||||
* Tables : `accounts` (identifiant 128 bits, référence `PL-XXXX-XXXX`, index aveugle et adresse chiffrée, empreinte du
|
||||
mot de passe, « changement exigé », champs OTP réservés à l'étape 8c, état, échéance de désactivation, motif chiffré,
|
||||
dates, version des conditions acceptée, compteurs d'échecs et blocages) ; `devices` (clé publique, nom générique,
|
||||
empreintes du jeton de renouvellement en vigueur et précédent, échéances) ; `account_events` (journal, § 7).
|
||||
* **Chiffrement** (`PlayerSecrets`) : clé maîtresse de 256 bits → sous-clés HKDF-SHA256 par usage (chiffrement des
|
||||
adresses, index aveugle, poivre, champs libres). Adresse : AES-256-GCM, nonce aléatoire, **liée au compte** (données
|
||||
associées : une valeur copiée sur un autre compte ne se déchiffre pas), préfixe de version de clé (`1.`). Recherche et
|
||||
unicité par **index aveugle** HMAC-SHA256 de l'adresse normalisée.
|
||||
* **Clé maîtresse hors du dossier des données** : credential systemd chiffré (`LoadCredentialEncrypted`,
|
||||
`players-master-key`, lu dans `$CREDENTIALS_DIRECTORY`) ou `Players:MasterKeyFile` (chemin absolu). Le serveur refuse
|
||||
de démarrer si la clé manque, est dans le dossier des données, est accessible à d'autres comptes, ou n'est pas une clé
|
||||
de 32 octets. **À sauvegarder hors ligne** : perdue, les adresses sont illisibles et tous les mots de passe doivent
|
||||
être réinitialisés. Commande : `GameLauncher.Server players-key`.
|
||||
* Les demandes des joueurs restent en fichiers JSON (étape 8f : identifiant opaque du compte ajouté, messages dans
|
||||
SQLite, chiffrés).
|
||||
|
||||
## 7. Audit
|
||||
|
||||
* **Actions des joueurs et du système sur un compte** → table `account_events` : création, connexion réussie ou
|
||||
refusée, blocage, suspension, déconnexion (un appareil / tous), changement de mot de passe (dont exigé), appareil
|
||||
déconnecté, renouvellement (une ligne par appareil et par jour au plus), réutilisation d'un jeton. Avec l'adresse IP et
|
||||
l'identifiant de la clé d'appareil, **jamais l'adresse e-mail ni le mot de passe**. Les échecs sur une adresse sans
|
||||
compte sont inscrits sans compte.
|
||||
* **Actions des administrateurs et du système** → journal d'audit existant, par **référence** de compte : `players.suspend`
|
||||
(suspension automatique), `cli.players_unlock`, `cli.players_enable`, `cli.players_disable`, `players.events_purge`,
|
||||
`players.self_delete` (compte supprimé par son titulaire), `players.inactive` (bilan quotidien des comptes inactifs),
|
||||
`players.inactive_delete` (compte inactif supprimé).
|
||||
* Nouveaux événements du journal des comptes (8b) : `account.export`, `account.delete` (essai refusé), `account.inactive`,
|
||||
`account.reactivated`. Libellés français communs au launcher et au futur menu : `PlayerActivityText` (Contracts).
|
||||
Les libellés lisibles et la catégorie « Comptes joueurs » du journal du menu arrivent avec l'étape 8d.
|
||||
* **Conservation** (`RetentionService`, au démarrage puis chaque jour) : journal des comptes 12 mois
|
||||
(`Players:EventsRetentionMonths`), adresses IP effacées au-delà de 6 mois (`Players:EventsIpRetentionMonths`).
|
||||
|
||||
## 8. Outil en ligne de commande (secours, sur le VPS)
|
||||
|
||||
Un compte est désigné par sa **référence** ; l'adresse e-mail n'est jamais affichée (aucune clé maîtresse nécessaire).
|
||||
|
||||
| Commande | Rôle |
|
||||
|---|---|
|
||||
| `players-key` | Génère une clé maîtresse (sortie standard, à chiffrer avec `systemd-creds`) |
|
||||
| `players-show --ref PL-XXXX-XXXX` | État, dates, échecs, appareils, derniers événements |
|
||||
| `players-unlock --ref …` | Lève une suspension ou un blocage (compte réactivé) |
|
||||
| `players-disable --ref … [--until AAAA-MM-JJ[THH:MM]]` | Désactive (UTC ; sans date : indéfiniment) |
|
||||
| `players-enable --ref …` | Réactive |
|
||||
|
||||
## 9. Réglages (`LauncherApi:Players`)
|
||||
|
||||
| Réglage | Défaut | Rôle |
|
||||
|---|---|---|
|
||||
| `Enabled` | `false` | Service des comptes |
|
||||
| `RegistrationEnabled` | `true` | Création de compte ouverte |
|
||||
| `MasterKeyFile` | vide | Clé maîtresse (vide : credential systemd `players-master-key`) |
|
||||
| `TermsVersion` | `1` | Version des conditions à accepter à la création |
|
||||
| `HashMemoryKib`, `HashIterations`, `HashParallelism` | 19456, 2, 1 | Argon2id (coût minimal refusé en dessous de 19 Mio × 2) |
|
||||
| `MaxConcurrentHashes` | 4 | Calculs simultanés |
|
||||
| `RegisterProofDifficulty`, `LoginProofDifficulty` | 20, 16 | Preuves de travail |
|
||||
| `FailuresPerLock`, `LockMinutes`, `SuspendAfterLocks` | 5, 15, 4 | Anti-force brute (appareils inconnus) |
|
||||
| `TrustedFailuresPerLock` | 10 | Appareils de confiance |
|
||||
| `FailedLoginsPerIpPer15Minutes` | 30 | Échecs par IP |
|
||||
| `RegistrationsPerIpPerHour`, `…PerDay`, `MaxRegistrationsPerDay` | 3, 5, 500 | Créations de compte |
|
||||
| `AuthRequestsPerMinute` | 20 | Limite de débit des routes `account` |
|
||||
| `AccessTokenMinutes`, `RefreshIdleDays`, `RefreshMaxDays` | 15, 90, 365 | Sessions |
|
||||
| `MaxDevicesPerAccount` | 20 | Au-delà, le moins récemment utilisé est déconnecté |
|
||||
| `EventsRetentionMonths`, `EventsIpRetentionMonths` | 12, 6 | Conservation du journal des comptes |
|
||||
| `InactiveDisableMonths`, `InactiveDeleteMonths` | 24, 12 | Comptes inactifs : mis en sommeil, puis supprimés (0 : désactivé) |
|
||||
|
||||
## 10. RGPD / CNIL
|
||||
|
||||
* Base légale : exécution du contrat (compte, échanges) ; intérêt légitime (journaux de sécurité, anti-force brute,
|
||||
blocages). Aucun traitement par consentement.
|
||||
* **Droits d'accès et de portabilité** (étape 8b) : « Mon compte › Mes données › Exporter » (`POST v1/account/export`,
|
||||
mot de passe exigé car le document contient l'adresse complète) : compte (adresse en clair, dates, état, conditions
|
||||
acceptées), appareils (nom, identifiant de clé, dates), activité (1000 lignes au plus, avec IP et détails), sections des
|
||||
autres modules, notes. Jamais l'empreinte du mot de passe, les jetons ni les clés publiques complètes. Export inscrit au
|
||||
journal du compte.
|
||||
* **Droit à l'effacement** (étape 8b) : « Supprimer mon compte » (`POST v1/account/delete`, mot de passe exigé) :
|
||||
données des autres modules d'abord (`IPersonalDataProvider.Erase`), puis compte, appareils et journal du compte
|
||||
(cascade SQLite, `secure_delete`), sessions fermées. Seule trace : une ligne `players.self_delete` du journal d'audit
|
||||
(référence, IP, 12 mois). L'adresse redevient libre. Le jeu et ses sauvegardes sur le PC ne sont pas touchés.
|
||||
* **`IPersonalDataProvider`** (serveur, `Modules\Accounts\PlayerPersonalData.cs`) : chaque futur module qui garde des
|
||||
données liées à un compte (demandes connectées 8f, amis…) déclare son export et son effacement ; l'export et la
|
||||
suppression du compte les couvrent sans modifier le module des comptes.
|
||||
* **Comptes inactifs** (CNIL) : un compte **actif** sans aucune activité (connexion, renouvellement, création) depuis
|
||||
`InactiveDisableMonths` (24 mois) est **mis en sommeil** (`inactive`, appareils déconnectés) ; une connexion avec le bon
|
||||
mot de passe le **réveille**. Après `InactiveDeleteMonths` (12 mois) de sommeil, il est **supprimé** comme ci-dessus
|
||||
(`players.inactive_delete`). Appliqué au démarrage puis chaque jour (`RetentionService`, ligne `players.inactive`). Les
|
||||
comptes désactivés ou suspendus par un administrateur ne sont pas concernés. Le joueur ne peut pas encore être prévenu
|
||||
avant l'échéance (module e-mail, étape 8h).
|
||||
* Étape 8g : registre des traitements, politique de confidentialité et CGU, brouillon d'AIPD (mineurs, identifiants
|
||||
matériels, messagerie).
|
||||
|
||||
## 11. Limites assumées
|
||||
|
||||
* Adresse non vérifiée tant que le module e-mail n'existe pas (étape 8h) : n'importe qui peut créer un compte avec une
|
||||
adresse qui n'est pas la sienne ; l'administrateur peut le supprimer (étape 8d).
|
||||
* La création de compte révèle qu'une adresse est déjà utilisée (§ 5, point 6).
|
||||
* Rotation de la clé maîtresse : prévue par le format (version de clé), pas encore outillée.
|
||||
|
||||
## 12. Étapes
|
||||
|
||||
| Étape | Contenu | Statut |
|
||||
|---|---|---|
|
||||
| **8a** | Socle serveur : contrats (`Contracts/Accounts`), module `Accounts`, schéma `PlayerSession`, base SQLite chiffrée, Argon2id + poivre, création / connexion / renouvellement / déconnexion / mot de passe / appareils, anti-force brute, journal des comptes, purge, outil en ligne de commande, tests (dont l'étanchéité sur toutes les routes d'administration) | ✅ |
|
||||
| **8b** | Launcher : bouton Compte, fenêtre de connexion / création / nouveau mot de passe exigé, session mémorisée (clé d'appareil TPM ou CNG non exportable, jeton chiffré DPAPI), « Mon compte » (profil, appareils, activité, export, suppression) ; serveur : activité, export, suppression, `IPersonalDataProvider`, comptes inactifs ; captures UiSnapshot ; tests | ✅ |
|
||||
| 8c | OTP (TOTP, codes de secours, QR maison) | à venir |
|
||||
| 8d | Menu d'administration « Comptes joueurs » | à venir |
|
||||
| 8e | Blocages par IP et par identifiants matériels (un blocage depuis une fiche vise les deux) | à venir |
|
||||
| 8f | Demandes connectées et échanges avec les administrateurs | à venir |
|
||||
| 8g | Mise en production (nginx, credential, sauvegardes, textes RGPD, AIPD, revue de sécurité indépendante) | à venir |
|
||||
| 8h | E-mails (vérification, réinitialisation autonome) | futur |
|
||||
|
||||
Activation en production : après 8a, 8b, 8c, 8d et 8g au minimum.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 443 KiB After Width: | Height: | Size: 416 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 419 KiB After Width: | Height: | Size: 395 KiB |
@@ -19,7 +19,8 @@ public static class AdminCli
|
||||
private static readonly string[] Commands = { "add-owner", "add-key", "list", "revoke-admin", "revoke-key", "server-id", "help" };
|
||||
|
||||
public static bool IsCommand(string[] args) =>
|
||||
args.Length > 0 && Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase);
|
||||
args.Length > 0 && (Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase) ||
|
||||
PlayersCli.Commands.Contains(args[0], StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
public static int Run(string[] args, TextWriter? output = null, string? dataDirectoryOverride = null)
|
||||
{
|
||||
@@ -29,6 +30,9 @@ public static class AdminCli
|
||||
var command = args[0].ToLowerInvariant();
|
||||
if (command == "help") return Help(o);
|
||||
var options = ReadOptions(args);
|
||||
// Clé maîtresse des comptes joueurs : n'utilise pas le dossier des données (peut être lancée par root, la sortie
|
||||
// étant directement chiffrée par systemd-creds).
|
||||
if (command == "players-key") return PlayersCli.Key(o, interactive: output is null);
|
||||
|
||||
// Lancé par erreur avec « sudo » (root) au lieu de « sudo -u deploy » : les fichiers recréés
|
||||
// appartiendraient à root et le service ne pourrait plus les lire.
|
||||
@@ -37,7 +41,7 @@ public static class AdminCli
|
||||
"ne lancez pas cet outil en root : sudo -u deploy /opt/launcher-api/GameLauncher.Server " + command + " …");
|
||||
|
||||
var dataDirectory = dataDirectoryOverride ?? ResolveDataDirectory(options);
|
||||
if (command != "add-owner" && command != "server-id" &&
|
||||
if (command != "add-owner" && command != "server-id" && !command.StartsWith("players-", StringComparison.Ordinal) &&
|
||||
!File.Exists(Path.Combine(dataDirectory, AdminStore.FileName)))
|
||||
throw new InvalidOperationException(
|
||||
$"aucun fichier {AdminStore.FileName} dans {dataDirectory} : mauvais dossier de données ? (option --data-dir)");
|
||||
@@ -57,6 +61,11 @@ public static class AdminCli
|
||||
case "server-id":
|
||||
o.WriteLine(ServerIdentity.LoadOrCreate(dataDirectory));
|
||||
return 0;
|
||||
case "players-show":
|
||||
case "players-unlock":
|
||||
case "players-disable":
|
||||
case "players-enable":
|
||||
return PlayersCli.Run(command, options, dataDirectory, audit, actor, o);
|
||||
default: return Help(o);
|
||||
}
|
||||
}
|
||||
@@ -219,6 +228,9 @@ public static class AdminCli
|
||||
revoke-key --key <identifiant> Révoque une clé (poste perdu ou volé).
|
||||
server-id Affiche l'identifiant de ce serveur.
|
||||
|
||||
Comptes joueurs (désignés par leur référence ; l'adresse e-mail n'est jamais affichée) :
|
||||
""" + "\n" + PlayersCli.HelpText + "\n" + """
|
||||
|
||||
Option commune : --data-dir <dossier> (sinon LauncherApi:DataDirectory d'appsettings.json).
|
||||
""");
|
||||
return 0;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Globalization;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Storage;
|
||||
using GameLauncher.Server.Storage.Players;
|
||||
|
||||
namespace GameLauncher.Server.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Commandes de secours des comptes joueurs (sur le VPS, sous le compte du service). Elles désignent un compte par sa
|
||||
/// RÉFÉRENCE (« PL-7KQ2-M9XD ») et n'affichent jamais l'adresse e-mail : elles n'ont pas besoin de la clé maîtresse.
|
||||
/// Le service n'a pas besoin d'être arrêté (SQLite gère l'accès simultané) ; l'état du compte est relu à chaque requête.
|
||||
/// La gestion courante se fait depuis le menu d'administration (étape 8d).
|
||||
/// </summary>
|
||||
internal static class PlayersCli
|
||||
{
|
||||
public static readonly string[] Commands = { "players-key", "players-show", "players-unlock", "players-disable", "players-enable" };
|
||||
|
||||
public const string HelpText = """
|
||||
players-key Génère une clé maîtresse des comptes joueurs (à chiffrer avec systemd-creds, voir LISEZMOI).
|
||||
players-show --ref <PL-XXXX-XXXX> État d'un compte joueur (sans son adresse e-mail).
|
||||
players-unlock --ref <PL-XXXX-XXXX> Lève une suspension ou un blocage (compte réactivé).
|
||||
players-disable --ref <PL-XXXX-XXXX> [--until AAAA-MM-JJ[THH:MM]] Désactive un compte (UTC ; sans --until : indéfiniment).
|
||||
players-enable --ref <PL-XXXX-XXXX> Réactive un compte désactivé.
|
||||
""";
|
||||
|
||||
/// <summary>Nouvelle clé maîtresse : sur la sortie standard (à rediriger vers systemd-creds), mode d'emploi sur stderr.</summary>
|
||||
public static int Key(TextWriter o, bool interactive)
|
||||
{
|
||||
o.WriteLine(PlayerSecrets.NewMasterKey());
|
||||
if (interactive)
|
||||
Console.Error.WriteLine("Clé maîtresse générée (32 octets, Base64). À chiffrer immédiatement, sans la laisser sur le disque, ex. :\n" +
|
||||
" GameLauncher.Server players-key | sudo systemd-creds encrypt --name=players-master-key - " +
|
||||
"/etc/credstore.encrypted/players-master-key\n" +
|
||||
"Gardez-en une copie HORS LIGNE (gestionnaire de mots de passe) : perdue, les adresses e-mail sont " +
|
||||
"illisibles et tous les mots de passe doivent être réinitialisés.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static int Run(string command, Dictionary<string, string> options, string dataDirectory, AuditLog audit, string actor, TextWriter o)
|
||||
{
|
||||
var path = Path.Combine(dataDirectory, PlayerStore.FolderName, PlayerStore.FileName);
|
||||
if (!File.Exists(path)) throw new InvalidOperationException($"aucune base des comptes joueurs ({path}).");
|
||||
using var store = new PlayerStore(dataDirectory, TimeProvider.System);
|
||||
var reference = PlayerIds.NormalizeReference(Get(options, "ref"))
|
||||
?? throw new ArgumentException("--ref <PL-XXXX-XXXX> obligatoire (référence du compte).");
|
||||
var account = store.FindByReference(reference) ?? throw new InvalidOperationException($"aucun compte {reference}.");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
switch (command)
|
||||
{
|
||||
case "players-show":
|
||||
Show(store, account, o);
|
||||
return 0;
|
||||
|
||||
case "players-unlock":
|
||||
case "players-enable":
|
||||
store.SetStatus(account.Id, PlayerStatus.Active, null, null, actor, now);
|
||||
store.AddEvent(account.Id, command == "players-unlock" ? PlayerEvents.Unlocked : PlayerEvents.Enabled, AuditResults.Ok, null,
|
||||
details: $"par {actor} (outil en ligne de commande)");
|
||||
audit.Write("cli." + command.Replace('-', '_'), AuditResults.Ok, actorName: actor, target: reference,
|
||||
details: $"ancien état : {account.Status}");
|
||||
o.WriteLine($"Compte {reference} réactivé (blocages remis à zéro).");
|
||||
return 0;
|
||||
|
||||
case "players-disable":
|
||||
DateTimeOffset? until = null;
|
||||
if (Get(options, "until") is { Length: > 0 } text)
|
||||
{
|
||||
if (!DateTimeOffset.TryParseExact(text, new[] { "yyyy-MM-dd", "yyyy-MM-dd'T'HH:mm" }, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) || date <= now)
|
||||
throw new ArgumentException("--until : date future attendue, AAAA-MM-JJ ou AAAA-MM-JJTHH:MM (UTC).");
|
||||
until = date;
|
||||
}
|
||||
store.SetStatus(account.Id, PlayerStatus.Disabled, until, null, actor, now);
|
||||
store.AddEvent(account.Id, PlayerEvents.Disabled, AuditResults.Ok, null,
|
||||
details: $"par {actor} (outil en ligne de commande){(until is { } u ? $" jusqu'au {u:yyyy-MM-dd HH:mm} UTC" : " indéfiniment")}");
|
||||
audit.Write("cli.players_disable", AuditResults.Ok, actorName: actor, target: reference,
|
||||
details: until is { } end ? $"jusqu'au {end:yyyy-MM-dd HH:mm} UTC" : "indéfiniment");
|
||||
o.WriteLine($"Compte {reference} désactivé{(until is { } d ? $" jusqu'au {d:yyyy-MM-dd HH:mm} UTC" : " indéfiniment")} " +
|
||||
"(ses sessions sont refusées dès la requête suivante).");
|
||||
return 0;
|
||||
}
|
||||
throw new ArgumentException($"commande inconnue : {command}");
|
||||
}
|
||||
|
||||
private static void Show(PlayerStore store, PlayerAccountRecord account, TextWriter o)
|
||||
{
|
||||
static string Date(DateTimeOffset? d) => d is { } v ? v.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture) : "—";
|
||||
var devices = store.Devices(account.Id);
|
||||
o.WriteLine($"Compte {account.Reference}");
|
||||
o.WriteLine($" État : {account.Status}{(account.DisabledUntil is { } u ? $" jusqu'au {Date(u)}" : "")}");
|
||||
o.WriteLine($" Créé le : {Date(account.CreatedAt)}");
|
||||
o.WriteLine($" Dernière connexion : {Date(account.LastLoginAt)}");
|
||||
o.WriteLine($" Mot de passe changé : {Date(account.PasswordChangedAt)}{(account.MustChangePassword ? " (nouveau mot de passe exigé)" : "")}");
|
||||
o.WriteLine($" Échecs (inconnus) : {account.FailedCount} en cours, {account.LockLevel} blocage(s), bloqué jusqu'au {Date(account.LockedUntil)}");
|
||||
o.WriteLine($" Échecs (confiance) : {account.TrustedFailedCount} en cours, bloqué jusqu'au {Date(account.TrustedLockedUntil)}");
|
||||
o.WriteLine($" Appareils connectés : {devices.Count}");
|
||||
foreach (var device in devices)
|
||||
o.WriteLine($" · {device.Label} (clé {device.KeyId}) — dernière utilisation {Date(device.LastUsedAt)}");
|
||||
o.WriteLine(" Derniers événements :");
|
||||
foreach (var e in store.Events(account.Id, 10))
|
||||
o.WriteLine($" {Date(e.At)} {e.Type} {e.Result} {e.Details}");
|
||||
}
|
||||
|
||||
private static string? Get(Dictionary<string, string> options, string name) =>
|
||||
options.TryGetValue(name, out var value) ? value : null;
|
||||
}
|
||||
@@ -25,6 +25,18 @@
|
||||
<ProjectReference Include="..\..\src\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Comptes joueurs (phase 8, docs\PLAYER-ACCOUNTS-ARCHITECTURE.md) :
|
||||
• Microsoft.Data.Sqlite (Microsoft) : base players.db ; embarque la bibliothèque native SQLite (SQLitePCLRaw,
|
||||
bibliothèque e_sqlite3 de linux-x64 incluse à la publication autonome) ;
|
||||
• Konscious.Security.Cryptography.Argon2 (licence MIT, 100 % C#) : empreintes Argon2id des mots de passe (RFC 9106),
|
||||
absentes de .NET 8.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.34" />
|
||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="GameLauncher.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -32,7 +32,9 @@ public static class ApiModules
|
||||
new Modules.Publishing.PublishingModule(),
|
||||
new Modules.Signing.SigningModule(),
|
||||
new Modules.Files.FilesModule(),
|
||||
new Modules.Feedback.FeedbackModule()
|
||||
new Modules.Feedback.FeedbackModule(),
|
||||
new Modules.Maintenance.MaintenanceModule(),
|
||||
new Modules.Accounts.AccountsModule()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,4 +52,8 @@ public static class RateLimitPolicies
|
||||
|
||||
/// <summary>Demandes des joueurs : défis et envois anonymes (en plus de la limite générale).</summary>
|
||||
public const string Feedback = "feedback";
|
||||
|
||||
/// <summary>Comptes joueurs : défis, création, connexion, renouvellement, changement de mot de passe (en plus de la
|
||||
/// limite générale).</summary>
|
||||
public const string Account = "account";
|
||||
}
|
||||
@@ -53,6 +53,9 @@ public sealed class LauncherApiOptions
|
||||
/// <summary>Journal d'audit : durée de conservation (purge automatique).</summary>
|
||||
public AuditSettings Audit { get; set; } = new();
|
||||
|
||||
/// <summary>Comptes joueurs (phase 8) : désactivés par défaut. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.</summary>
|
||||
public PlayersSettings Players { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Jeux dont le manifest.json publié peut être modifié par l'API (notes de version, phase 4), par
|
||||
/// identifiant de jeu (GameId de launcher.json). Absent : notes non modifiables en ligne.
|
||||
@@ -177,6 +180,85 @@ public sealed class LauncherApiOptions
|
||||
public int RetentionMonths { get; set; } = 12;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Comptes joueurs : adresse e-mail + mot de passe, connexion mémorisée liée à une clé d'appareil. Données dans
|
||||
/// « players/players.db » (SQLite, dossier des données privées), adresses e-mail chiffrées, mots de passe hachés
|
||||
/// (Argon2id + clé secrète). La clé maîtresse est hors du dossier des données (credential systemd chiffré).
|
||||
/// </summary>
|
||||
public sealed class PlayersSettings
|
||||
{
|
||||
/// <summary>Service activé (faux par défaut : les routes « v1/account/… » répondent 503 « not_configured »).</summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>Création de compte ouverte (la connexion reste possible si faux).</summary>
|
||||
public bool RegistrationEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Fichier de la clé maîtresse (32 octets en Base64). Vide : « players-master-key » dans le dossier des credentials
|
||||
/// systemd ($CREDENTIALS_DIRECTORY, voir server/deploy/LISEZMOI.md). Jamais dans le dossier des données, jamais
|
||||
/// lisible par le groupe ou les autres comptes.
|
||||
/// </summary>
|
||||
public string MasterKeyFile { get; set; } = "";
|
||||
|
||||
/// <summary>Version des conditions d'utilisation / politique de confidentialité à accepter à la création du compte.</summary>
|
||||
public string TermsVersion { get; set; } = "1";
|
||||
|
||||
/// <summary>Argon2id : mémoire (Kio), passes, parallélisme. OWASP : 19 Mio et 2 passes au minimum (ou équivalent).</summary>
|
||||
public int HashMemoryKib { get; set; } = 19456;
|
||||
public int HashIterations { get; set; } = 2;
|
||||
public int HashParallelism { get; set; } = 1;
|
||||
|
||||
/// <summary>Calculs de mot de passe simultanés (les suivants attendent) : protège le VPS d'un afflux de connexions.</summary>
|
||||
public int MaxConcurrentHashes { get; set; } = 4;
|
||||
|
||||
/// <summary>Preuve de travail exigée à la création de compte et à la connexion (bits à zéro).</summary>
|
||||
public int RegisterProofDifficulty { get; set; } = 20;
|
||||
public int LoginProofDifficulty { get; set; } = 16;
|
||||
|
||||
/// <summary>Durée de validité d'un défi joueur.</summary>
|
||||
public int ChallengeLifetimeSeconds { get; set; } = 300;
|
||||
|
||||
/// <summary>Anti-force brute (appareils inconnus) : blocage après N échecs consécutifs, durée doublée à chaque
|
||||
/// blocage ; au blocage n° SuspendAfterLocks, le compte est SUSPENDU pour les nouveaux appareils (seul un
|
||||
/// administrateur lève la suspension ; les appareils déjà connectés continuent de fonctionner).</summary>
|
||||
public int FailuresPerLock { get; set; } = 5;
|
||||
public int LockMinutes { get; set; } = 15;
|
||||
public int SuspendAfterLocks { get; set; } = 4;
|
||||
|
||||
/// <summary>Appareils de confiance (déjà connectés) : blocage temporaire après N échecs, jamais de suspension.</summary>
|
||||
public int TrustedFailuresPerLock { get; set; } = 10;
|
||||
|
||||
/// <summary>Échecs de connexion acceptés par adresse IP (IPv6 : /64) sur 15 minutes, tous comptes confondus.</summary>
|
||||
public int FailedLoginsPerIpPer15Minutes { get; set; } = 30;
|
||||
|
||||
/// <summary>Créations de compte par adresse IP (IPv6 : /64) par heure et par jour, et au total par jour.</summary>
|
||||
public int RegistrationsPerIpPerHour { get; set; } = 3;
|
||||
public int RegistrationsPerIpPerDay { get; set; } = 5;
|
||||
public int MaxRegistrationsPerDay { get; set; } = 500;
|
||||
|
||||
/// <summary>Requêtes par minute et par adresse IP sur les routes de défi, création, connexion et renouvellement.</summary>
|
||||
public int AuthRequestsPerMinute { get; set; } = 20;
|
||||
|
||||
/// <summary>Jeton d'accès (minutes) ; session mémorisée : jours sans utilisation, puis durée maximale (jours).</summary>
|
||||
public int AccessTokenMinutes { get; set; } = 15;
|
||||
public int RefreshIdleDays { get; set; } = 90;
|
||||
public int RefreshMaxDays { get; set; } = 365;
|
||||
|
||||
/// <summary>Appareils connectés par compte (au-delà, le moins récemment utilisé est déconnecté).</summary>
|
||||
public int MaxDevicesPerAccount { get; set; } = 20;
|
||||
|
||||
/// <summary>Journal des comptes : conservation (mois), et effacement des adresses IP au-delà de N mois.</summary>
|
||||
public int EventsRetentionMonths { get; set; } = 12;
|
||||
public int EventsIpRetentionMonths { get; set; } = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Comptes inactifs (CNIL) : mis en sommeil après N mois sans aucune activité (appareils déconnectés ; une connexion
|
||||
/// avec le bon mot de passe le réveille), puis supprimés définitivement après M mois de sommeil. 0 : désactivé.
|
||||
/// </summary>
|
||||
public int InactiveDisableMonths { get; set; } = 24;
|
||||
public int InactiveDeleteMonths { get; set; } = 12;
|
||||
}
|
||||
|
||||
/// <summary>Un jeu publié sur ce serveur.</summary>
|
||||
public sealed class GameSettings
|
||||
{
|
||||
@@ -248,6 +330,15 @@ public sealed class LauncherApiOptions
|
||||
/// <summary>Adresse publique des formulaires des demandes, calculée par <see cref="Validate"/>.</summary>
|
||||
public string FeedbackFormsPublicUrl { get; private set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Fichier public du mode maintenance (maintenance.json, à côté du trousseau ; null : publication désactivée), calculé
|
||||
/// par <see cref="Validate"/>. Les launchers le lisent dans le même dossier que le trousseau.
|
||||
/// </summary>
|
||||
public string? MaintenancePublicFileFullPath { get; private set; }
|
||||
|
||||
/// <summary>Adresse publique du mode maintenance, calculée par <see cref="Validate"/>.</summary>
|
||||
public string MaintenancePublicUrl { get; private set; } = "";
|
||||
|
||||
/// <summary>Racine du site public pour la page « Fichiers du serveur » (null : page désactivée), calculée par <see cref="Validate"/>.</summary>
|
||||
public string? FilesRootFullPath { get; private set; }
|
||||
|
||||
@@ -255,6 +346,9 @@ public sealed class LauncherApiOptions
|
||||
public IReadOnlyDictionary<string, string> GameManifestFiles { get; private set; } =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Fichier de la clé maîtresse des comptes joueurs (null : comptes désactivés), calculé par <see cref="Validate"/>.</summary>
|
||||
public string? PlayersMasterKeyFullPath { get; private set; }
|
||||
|
||||
/// <summary>Jeux acceptés par la réception des demandes des joueurs, calculés par <see cref="Validate"/>.</summary>
|
||||
public IReadOnlySet<string> FeedbackGameIds { get; private set; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -289,6 +383,7 @@ public sealed class LauncherApiOptions
|
||||
ValidateGames();
|
||||
ValidateFiles();
|
||||
ValidateFeedback();
|
||||
ValidatePlayers();
|
||||
|
||||
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
|
||||
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
|
||||
@@ -303,6 +398,71 @@ public sealed class LauncherApiOptions
|
||||
Require(Audit.RetentionMonths is >= 1 and <= 120, "Audit:RetentionMonths (1 à 120)");
|
||||
}
|
||||
|
||||
private void ValidatePlayers()
|
||||
{
|
||||
var players = Players;
|
||||
PlayersMasterKeyFullPath = null;
|
||||
Require(players.HashMemoryKib is >= 7168 and <= 1_048_576, "Players:HashMemoryKib (7168 à 1048576)");
|
||||
Require(players.HashIterations is >= 1 and <= 20, "Players:HashIterations (1 à 20)");
|
||||
// OWASP : coût au moins équivalent à 19 Mio × 2 passes (ex. 12 Mio × 3, 9 Mio × 4, 7 Mio × 5).
|
||||
Require((long)players.HashMemoryKib * players.HashIterations >= 19456L * 2, "Players:HashMemoryKib × HashIterations (au moins 38912)");
|
||||
Require(players.HashParallelism is >= 1 and <= 16, "Players:HashParallelism (1 à 16)");
|
||||
Require(players.MaxConcurrentHashes is >= 1 and <= 64, "Players:MaxConcurrentHashes (1 à 64)");
|
||||
Require(players.RegisterProofDifficulty is >= Contracts.Feedback.FeedbackProof.MinDifficulty and <= Contracts.Feedback.FeedbackProof.MaxDifficulty &&
|
||||
players.LoginProofDifficulty is >= Contracts.Feedback.FeedbackProof.MinDifficulty and <= Contracts.Feedback.FeedbackProof.MaxDifficulty,
|
||||
$"Players:*ProofDifficulty ({Contracts.Feedback.FeedbackProof.MinDifficulty} à {Contracts.Feedback.FeedbackProof.MaxDifficulty})");
|
||||
Require(players.ChallengeLifetimeSeconds is >= 30 and <= 3600, "Players:ChallengeLifetimeSeconds (30 à 3600)");
|
||||
Require(players.FailuresPerLock is >= 3 and <= 20, "Players:FailuresPerLock (3 à 20)");
|
||||
Require(players.LockMinutes is >= 1 and <= 1440, "Players:LockMinutes (1 à 1440)");
|
||||
Require(players.SuspendAfterLocks is >= 2 and <= 20, "Players:SuspendAfterLocks (2 à 20)");
|
||||
Require(players.TrustedFailuresPerLock is >= 3 and <= 50, "Players:TrustedFailuresPerLock (3 à 50)");
|
||||
Require(players.FailedLoginsPerIpPer15Minutes is >= 5 and <= 10_000, "Players:FailedLoginsPerIpPer15Minutes (5 à 10000)");
|
||||
Require(players.RegistrationsPerIpPerHour is >= 1 and <= 1000 && players.RegistrationsPerIpPerDay >= players.RegistrationsPerIpPerHour &&
|
||||
players.RegistrationsPerIpPerDay <= 10_000, "Players:RegistrationsPerIp*");
|
||||
Require(players.MaxRegistrationsPerDay is >= 1 and <= 1_000_000, "Players:MaxRegistrationsPerDay");
|
||||
Require(players.AuthRequestsPerMinute is >= 1 and <= 1_000_000, "Players:AuthRequestsPerMinute");
|
||||
Require(players.AccessTokenMinutes is >= 1 and <= 60, "Players:AccessTokenMinutes (1 à 60)");
|
||||
Require(players.RefreshIdleDays is >= 1 and <= 365, "Players:RefreshIdleDays (1 à 365)");
|
||||
Require(players.RefreshMaxDays >= players.RefreshIdleDays && players.RefreshMaxDays <= 730, "Players:RefreshMaxDays (RefreshIdleDays à 730)");
|
||||
Require(players.MaxDevicesPerAccount is >= 1 and <= 100, "Players:MaxDevicesPerAccount (1 à 100)");
|
||||
Require(players.EventsRetentionMonths is >= 1 and <= 120, "Players:EventsRetentionMonths (1 à 120)");
|
||||
Require(players.EventsIpRetentionMonths is >= 1 && players.EventsIpRetentionMonths <= players.EventsRetentionMonths,
|
||||
"Players:EventsIpRetentionMonths (1 à EventsRetentionMonths)");
|
||||
// Au moins un an (au-delà de la durée maximale d'une session mémorisée) : un joueur régulier n'est jamais mis en sommeil.
|
||||
Require(players.InactiveDisableMonths == 0 || players.InactiveDisableMonths is >= 12 and <= 120,
|
||||
"Players:InactiveDisableMonths (0, ou 12 à 120)");
|
||||
Require(players.InactiveDeleteMonths is >= 0 and <= 120, "Players:InactiveDeleteMonths (0 à 120)");
|
||||
if (Contracts.ApiText.CleanLine(players.TermsVersion, 32) is null)
|
||||
throw new InvalidOperationException($"{Section}:Players:TermsVersion : une version (ex. « 2026-11 ») est obligatoire.");
|
||||
if (!players.Enabled) return;
|
||||
|
||||
string file;
|
||||
if (!string.IsNullOrWhiteSpace(players.MasterKeyFile))
|
||||
{
|
||||
file = players.MasterKeyFile.Trim();
|
||||
if (!Path.IsPathRooted(file))
|
||||
throw new InvalidOperationException($"{Section}:Players:MasterKeyFile doit être un chemin absolu.");
|
||||
}
|
||||
else if (Environment.GetEnvironmentVariable("CREDENTIALS_DIRECTORY") is { Length: > 0 } credentials)
|
||||
{
|
||||
file = Path.Combine(credentials, "players-master-key");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:Players:Enabled = true, mais aucune clé maîtresse : credential systemd « players-master-key » " +
|
||||
"(LoadCredentialEncrypted, voir server/deploy/LISEZMOI.md) ou Players:MasterKeyFile.");
|
||||
}
|
||||
var full = Path.GetFullPath(file);
|
||||
var directory = Path.GetDirectoryName(full) ?? "";
|
||||
var keyDirectory = RealPath(directory);
|
||||
var dataDirectory = RealPath(DataDirectoryFullPath);
|
||||
if (IsSameOrInside(keyDirectory, dataDirectory))
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:Players:MasterKeyFile : la clé maîtresse ne doit pas être dans le dossier des données (une copie des données ne doit pas la contenir).");
|
||||
PlayersMasterKeyFullPath = full;
|
||||
}
|
||||
|
||||
private void ValidateFeedback()
|
||||
{
|
||||
var feedback = Feedback;
|
||||
@@ -403,11 +563,24 @@ public sealed class LauncherApiOptions
|
||||
? Path.Combine(Path.GetDirectoryName(keyringFile)!, FeedbackFormsFileName)
|
||||
: null;
|
||||
FeedbackFormsPublicUrl = new Uri(new Uri(KeyringPublicUrl), FeedbackFormsFileName).AbsoluteUri;
|
||||
|
||||
// Mode maintenance : même règle (les launchers en déduisent l'adresse à partir de celle du trousseau).
|
||||
MaintenancePublicFileFullPath = KeyringPublicFileFullPath is { } keyringPath
|
||||
? Path.Combine(Path.GetDirectoryName(keyringPath)!, MaintenanceFileName)
|
||||
: null;
|
||||
MaintenancePublicUrl = new Uri(new Uri(KeyringPublicUrl), MaintenanceFileName).AbsoluteUri;
|
||||
if (MaintenancePublicFileFullPath is { } maintenance &&
|
||||
new[] { CatalogPublicFileFullPath, KeyringPublicFileFullPath }.Any(f => string.Equals(f, maintenance, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:Keyring:PublicFile / Catalog:PublicFile : « {MaintenanceFileName} » est réservé au mode maintenance, choisissez un autre nom.");
|
||||
}
|
||||
|
||||
/// <summary>Nom du fichier public des formulaires (à côté de keyring.json).</summary>
|
||||
public const string FeedbackFormsFileName = "feedback-forms.json";
|
||||
|
||||
/// <summary>Nom du fichier public du mode maintenance (à côté de keyring.json).</summary>
|
||||
public const string MaintenanceFileName = "maintenance.json";
|
||||
|
||||
private void ValidateGames()
|
||||
{
|
||||
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -9,7 +9,11 @@ namespace GameLauncher.Server.Hosting;
|
||||
/// puis toutes les 24 heures :
|
||||
/// • journal d'audit : fichiers mensuels plus anciens que <c>LauncherApi:Audit:RetentionMonths</c> (12 mois) ;
|
||||
/// • demandes des joueurs : closes depuis <c>Feedback:RetentionClosedMonths</c> (12), et toutes au-delà de
|
||||
/// <c>Feedback:RetentionMaxMonths</c> (24).
|
||||
/// <c>Feedback:RetentionMaxMonths</c> (24) ;
|
||||
/// • journal des comptes joueurs (si activés) : lignes au-delà de <c>Players:EventsRetentionMonths</c> (12), adresses IP
|
||||
/// effacées au-delà de <c>Players:EventsIpRetentionMonths</c> (6) ;
|
||||
/// • comptes joueurs inactifs : mis en sommeil après <c>Players:InactiveDisableMonths</c> (24) sans activité, supprimés
|
||||
/// après <c>Players:InactiveDeleteMonths</c> (12) de sommeil (« players.inactive »).
|
||||
/// Chaque purge qui supprime quelque chose est inscrite au journal (« audit.purge », « feedback.purge »). Une erreur est
|
||||
/// écrite dans journalctl sans arrêter le service ; la purge suivante réessaie.
|
||||
/// </summary>
|
||||
@@ -22,12 +26,14 @@ public sealed class RetentionService : BackgroundService
|
||||
private readonly LauncherApiOptions _options;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger<RetentionService> _logger;
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
public RetentionService(AuditLog audit, FeedbackStore feedback, IOptions<LauncherApiOptions> options, TimeProvider time,
|
||||
ILogger<RetentionService> logger)
|
||||
ILogger<RetentionService> logger, IServiceProvider services)
|
||||
{
|
||||
_audit = audit;
|
||||
_feedback = feedback;
|
||||
_services = services;
|
||||
_options = options.Value;
|
||||
_time = time;
|
||||
_logger = logger;
|
||||
@@ -76,5 +82,34 @@ public sealed class RetentionService : BackgroundService
|
||||
{
|
||||
_logger.LogError(ex, "Purge des demandes des joueurs impossible");
|
||||
}
|
||||
|
||||
if (!_options.Players.Enabled) return;
|
||||
try
|
||||
{
|
||||
var players = _services.GetRequiredService<Storage.Players.PlayerStore>();
|
||||
var (deleted, anonymized) = players.PurgeEvents(_time.GetUtcNow(), _options.Players.EventsRetentionMonths,
|
||||
_options.Players.EventsIpRetentionMonths);
|
||||
if (deleted > 0 || anonymized > 0)
|
||||
_audit.Write("players.events_purge", AuditResults.Ok,
|
||||
details: $"journal des comptes joueurs : {deleted} ligne(s) supprimée(s), {anonymized} adresse(s) IP effacée(s) ; " +
|
||||
$"conservation {_options.Players.EventsRetentionMonths} mois, IP {_options.Players.EventsIpRetentionMonths} mois");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Purge du journal des comptes joueurs impossible");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (dormant, deleted) = _services.GetRequiredService<Modules.Accounts.PlayerPersonalData>().ApplyInactivity(_time.GetUtcNow());
|
||||
if (dormant > 0 || deleted > 0)
|
||||
_audit.Write("players.inactive", AuditResults.Ok,
|
||||
details: $"comptes joueurs inactifs : {dormant} mis en sommeil (aucune activité depuis {_options.Players.InactiveDisableMonths} mois), " +
|
||||
$"{deleted} supprimé(s) (en sommeil depuis {_options.Players.InactiveDeleteMonths} mois)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Politique des comptes joueurs inactifs impossible");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,8 @@ public static class ServerApp
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.EnrollmentsPerHour, TimeSpan.FromHours(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.Feedback, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Feedback.RequestsPerMinute, TimeSpan.FromMinutes(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.Account, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Players.AuthRequestsPerMinute, TimeSpan.FromMinutes(1))));
|
||||
});
|
||||
|
||||
// Protection des données d'ASP.NET Core (cookies, jetons anti-falsification) : inutilisée par l'API,
|
||||
@@ -123,6 +125,13 @@ public static class ServerApp
|
||||
var admins = app.Services.GetRequiredService<AdminStore>().Read();
|
||||
app.Logger.LogInformation("Launcher API : origine {Origin}, serveur {ServerId}, {Count} administrateur(s), données {Data}",
|
||||
identity.Origin, identity.ServerId, admins.Admins.Count, options.DataDirectoryFullPath);
|
||||
// Comptes joueurs : clé maîtresse et base ouvertes dès le démarrage (clé absente ou base illisible : pas de démarrage).
|
||||
if (options.Players.Enabled)
|
||||
{
|
||||
var players = app.Services.GetRequiredService<Storage.Players.PlayerStore>();
|
||||
app.Services.GetRequiredService<Storage.Players.PlayerPasswordHasher>();
|
||||
app.Logger.LogInformation("Comptes joueurs activés : {Count} compte(s), base {Database}", players.CountAccounts(), players.DatabasePath);
|
||||
}
|
||||
if (!admins.UsableOwners().Any())
|
||||
app.Logger.LogWarning("Aucun propriétaire actif : créez-le avec « GameLauncher.Server add-owner » (voir server/deploy/LISEZMOI.md).");
|
||||
return app;
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using GameLauncher.Server.Storage.Players;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Comptes joueurs (phase 8a) : création de compte, connexion par adresse e-mail et mot de passe, connexion mémorisée
|
||||
/// (jeton de renouvellement lié à une clé d'appareil), déconnexion, changement de mot de passe, appareils connectés.
|
||||
///
|
||||
/// Sécurité (détail : docs\PLAYER-ACCOUNTS-ARCHITECTURE.md) :
|
||||
/// • routes anonymes protégées par un défi sans état + preuve de travail + preuve de possession de la clé d'appareil
|
||||
/// (<see cref="PlayerGuard"/>), limites par adresse IP ;
|
||||
/// • anti-force brute : blocage temporaire (durée doublée), puis SUSPENSION que seul un administrateur lève. La
|
||||
/// suspension bloque les connexions depuis un NOUVEL appareil ; les appareils déjà connectés (« de confiance »,
|
||||
/// prouvés par leur clé) continuent de fonctionner : un tiers qui connaît l'adresse peut déclencher la suspension,
|
||||
/// mais pas priver le joueur de son compte. Une adresse inconnue se comporte exactement comme un compte réel ;
|
||||
/// • réponses identiques pour une adresse inconnue et un mauvais mot de passe (même durée de calcul) ; l'état du compte
|
||||
/// (désactivé, suspendu) n'est révélé qu'après un mot de passe correct ;
|
||||
/// • adresses e-mail chiffrées, mots de passe en Argon2id + poivre, clé maîtresse hors du dossier des données ;
|
||||
/// • journal des comptes (actions des joueurs) dans la base, sans adresse e-mail ; actions du système et des
|
||||
/// administrateurs dans le journal d'audit, par référence de compte.
|
||||
/// Totalement séparé des administrateurs : aucune route d'administration n'accepte un jeton joueur.
|
||||
/// </summary>
|
||||
public sealed class AccountsModule : IApiModule
|
||||
{
|
||||
public string Name => "Accounts";
|
||||
|
||||
/// <summary>Longueur maximale acceptée d'un mot de passe SAISI (bien au-delà des règles : borne le travail du serveur).</summary>
|
||||
private const int MaxSubmittedPasswordLength = 1024;
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
|
||||
{
|
||||
services.AddSingleton<PlayerGuard>();
|
||||
services.AddSingleton<PlayerSessionStore>();
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var api = sp.GetRequiredService<IOptions<LauncherApiOptions>>().Value;
|
||||
return PlayerSecrets.Load(api.PlayersMasterKeyFullPath
|
||||
?? throw new InvalidOperationException("Comptes joueurs désactivés : aucune clé maîtresse."));
|
||||
});
|
||||
services.AddSingleton(sp => new PlayerPasswordHasher(sp.GetRequiredService<PlayerSecrets>(),
|
||||
sp.GetRequiredService<IOptions<LauncherApiOptions>>().Value.Players));
|
||||
services.AddSingleton<PlayerStore>();
|
||||
services.AddSingleton<PlayerPersonalData>();
|
||||
services.AddPlayerAuthentication();
|
||||
}
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapPost(ApiRoutes.AccountChallenge, Challenge).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
|
||||
api.MapPost(ApiRoutes.AccountRegister, Register).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
|
||||
api.MapPost(ApiRoutes.AccountLogin, Login).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
|
||||
api.MapPost(ApiRoutes.AccountRefresh, Refresh).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Account);
|
||||
api.MapPost(ApiRoutes.AccountLogout, Logout).RequirePlayer();
|
||||
api.MapGet(ApiRoutes.AccountMe, Me).RequirePlayer();
|
||||
api.MapPost(ApiRoutes.AccountPassword, ChangePassword).RequirePlayerOrPasswordChange().RequireRateLimiting(RateLimitPolicies.Account);
|
||||
api.MapDelete(ApiRoutes.AccountDevicePattern, DeleteDevice).RequirePlayer();
|
||||
api.MapGet(ApiRoutes.AccountActivity, Activity).RequirePlayer();
|
||||
api.MapPost(ApiRoutes.AccountExport, Export).RequirePlayer().RequireRateLimiting(RateLimitPolicies.Account);
|
||||
api.MapPost(ApiRoutes.AccountDelete, DeleteAccount).RequirePlayer().RequireRateLimiting(RateLimitPolicies.Account);
|
||||
}
|
||||
|
||||
private static IResult Disabled() => ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Les comptes joueurs ne sont pas activés sur ce serveur.");
|
||||
|
||||
private static IResult LoginFailed() => ApiResults.Error(401, ApiErrorCodes.LoginFailed, "Adresse e-mail ou mot de passe incorrect.");
|
||||
|
||||
private static IResult TooManyAttempts(HttpContext context, DateTimeOffset until, DateTimeOffset now)
|
||||
{
|
||||
var wait = until - now;
|
||||
var seconds = Math.Max(1, (int)Math.Ceiling(wait.TotalSeconds));
|
||||
context.Response.Headers.RetryAfter = seconds.ToString(CultureInfo.InvariantCulture);
|
||||
var minutes = Math.Max(1, (int)Math.Ceiling(wait.TotalMinutes));
|
||||
return ApiResults.Error(429, ApiErrorCodes.LoginLocked,
|
||||
$"Trop d'essais : la connexion est bloquée pendant encore {minutes} minute{(minutes > 1 ? "s" : "")}.");
|
||||
}
|
||||
|
||||
private static IResult RateLimited(HttpContext context, TimeSpan retryAfter, string message)
|
||||
{
|
||||
context.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
|
||||
return ApiResults.Error(429, ApiErrorCodes.RateLimited, message);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Défi
|
||||
|
||||
private static IResult Challenge(PlayerChallengeRequest request, IOptions<LauncherApiOptions> options, PlayerGuard guard,
|
||||
ServerIdentity server)
|
||||
{
|
||||
if (!options.Value.Players.Enabled) return Disabled();
|
||||
if (!PlayerChallengePurposes.IsValid(request.Purpose)) return ApiResults.BadRequest("Usage du défi inconnu.");
|
||||
return Results.Json(guard.CreateChallenge(request.Purpose, server.ServerId, server.Origin), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Création de compte
|
||||
|
||||
private static async Task<IResult> Register(PlayerRegisterRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
|
||||
IServiceProvider services, PlayerGuard guard, PlayerSessionStore sessions, ServerIdentity server, TimeProvider time)
|
||||
{
|
||||
var settings = options.Value.Players;
|
||||
if (!settings.Enabled) return Disabled();
|
||||
if (!settings.RegistrationEnabled)
|
||||
return ApiResults.Error(403, ApiErrorCodes.RegistrationClosed, "La création de compte est momentanément fermée.");
|
||||
var store = services.GetRequiredService<PlayerStore>();
|
||||
var secrets = services.GetRequiredService<PlayerSecrets>();
|
||||
var hasher = services.GetRequiredService<PlayerPasswordHasher>();
|
||||
var ip = context.ClientIp();
|
||||
|
||||
// 1. Défi et preuve de travail, puis possession de la clé d'appareil, puis consommation du défi.
|
||||
var device = CheckDevice(guard, server, PlayerChallengePurposes.Register, request.ChallengeId, request.Solution, request.Device);
|
||||
if (device.Error is { } deviceError) return deviceError;
|
||||
var proof = device.Proof!;
|
||||
|
||||
// 2. Contenu : conditions acceptées, adresse, mot de passe (mêmes règles que le launcher).
|
||||
if (!string.Equals(request.AcceptedTermsVersion, settings.TermsVersion, StringComparison.Ordinal))
|
||||
return ApiResults.Error(400, ApiErrorCodes.TermsNotAccepted,
|
||||
"Acceptez les conditions d'utilisation et la politique de confidentialité en vigueur pour créer un compte.");
|
||||
if (PlayerEmail.Normalize(request.Email) is not { } email)
|
||||
return ApiResults.Error(400, ApiErrorCodes.EmailInvalid, "Adresse e-mail invalide.");
|
||||
if (request.Password is not { Length: > 0 and <= MaxSubmittedPasswordLength })
|
||||
return ApiResults.Error(400, ApiErrorCodes.PasswordRejected, "Mot de passe invalide.");
|
||||
var errors = PasswordRules.Check(request.Password, email);
|
||||
if (errors.Count > 0) return ApiResults.Error(400, ApiErrorCodes.PasswordRejected, string.Join(" ", errors));
|
||||
|
||||
// 3. Limites par adresse IP (en mémoire) puis plafond quotidien global.
|
||||
var now = time.GetUtcNow();
|
||||
if (!guard.TryCountRegistration(context.RateLimitPartition(), out var retryAfter))
|
||||
return RateLimited(context, retryAfter, "Plusieurs comptes ont été créés depuis votre connexion récemment : réessayez plus tard.");
|
||||
if (store.CountCreatedSince(now - TimeSpan.FromDays(1)) >= settings.MaxRegistrationsPerDay)
|
||||
return ApiResults.Error(503, ApiErrorCodes.RegistrationClosed,
|
||||
"Trop de comptes ont été créés aujourd'hui : réessayez demain.");
|
||||
|
||||
// 4. Adresse libre ? (la création révèle forcément qu'une adresse est prise : limites ci-dessus + preuve de travail).
|
||||
var emailIndex = secrets.EmailIndex(email);
|
||||
if (store.FindByEmailIndex(emailIndex) is not null)
|
||||
{
|
||||
store.AddEvent(null, PlayerEvents.Register, AuditResults.Denied, ip, proof.KeyId, "adresse déjà utilisée");
|
||||
return ApiResults.Error(409, ApiErrorCodes.EmailTaken, "Cette adresse e-mail est déjà utilisée par un compte.");
|
||||
}
|
||||
|
||||
// 5. Empreinte (calcul coûteux, hors verrou), puis création sous transaction (unicité revérifiée).
|
||||
var hash = await hasher.HashAsync(request.Password, context.RequestAborted);
|
||||
var accountId = PlayerIds.NewId();
|
||||
var account = new PlayerAccountRecord
|
||||
{
|
||||
Id = accountId,
|
||||
Reference = PlayerIds.NewReference(),
|
||||
EmailIndex = emailIndex,
|
||||
EmailEncrypted = secrets.EncryptEmail(accountId, email),
|
||||
PasswordHash = hash,
|
||||
PasswordChangedAt = now,
|
||||
CreatedAt = now,
|
||||
LastLoginAt = now,
|
||||
TermsVersion = settings.TermsVersion
|
||||
};
|
||||
var (deviceRecord, refreshToken) = NewDevice(accountId, proof, settings, now);
|
||||
if (!store.TryCreate(account, deviceRecord, PlayerIds.NewReference, out var created))
|
||||
return ApiResults.Error(409, ApiErrorCodes.EmailTaken, "Cette adresse e-mail est déjà utilisée par un compte.");
|
||||
|
||||
store.AddEvent(created.Id, PlayerEvents.Register, AuditResults.Ok, ip, proof.KeyId,
|
||||
$"conditions version {settings.TermsVersion} ; appareil « {proof.Label} »");
|
||||
return Results.Json(new PlayerLoginResponse
|
||||
{
|
||||
Status = PlayerLoginStatus.Ok,
|
||||
Session = OpenSession(created, deviceRecord, refreshToken, sessions, secrets)
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Connexion
|
||||
|
||||
private static async Task<IResult> Login(PlayerLoginRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
|
||||
IServiceProvider services, PlayerGuard guard, PlayerSessionStore sessions, ServerIdentity server, AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var settings = options.Value.Players;
|
||||
if (!settings.Enabled) return Disabled();
|
||||
var store = services.GetRequiredService<PlayerStore>();
|
||||
var secrets = services.GetRequiredService<PlayerSecrets>();
|
||||
var hasher = services.GetRequiredService<PlayerPasswordHasher>();
|
||||
var ip = context.ClientIp();
|
||||
var partition = context.RateLimitPartition();
|
||||
|
||||
var device = CheckDevice(guard, server, PlayerChallengePurposes.Login, request.ChallengeId, request.Solution, request.Device);
|
||||
if (device.Error is { } deviceError) return deviceError;
|
||||
var proof = device.Proof!;
|
||||
if (guard.IsIpThrottled(partition, out var ipRetry))
|
||||
return RateLimited(context, ipRetry, "Trop d'échecs de connexion depuis votre connexion Internet : réessayez plus tard.");
|
||||
|
||||
var now = time.GetUtcNow();
|
||||
var password = request.Password is { Length: > 0 and <= MaxSubmittedPasswordLength } p ? p : null;
|
||||
var email = PlayerEmail.Normalize(request.Email);
|
||||
if (email is null || password is null)
|
||||
{
|
||||
if (password is not null) await hasher.DummyAsync(password, context.RequestAborted);
|
||||
guard.CountFailedLogin(partition);
|
||||
return LoginFailed();
|
||||
}
|
||||
|
||||
var emailIndex = secrets.EmailIndex(email);
|
||||
var account = store.FindByEmailIndex(emailIndex);
|
||||
if (account is null)
|
||||
{
|
||||
// Adresse sans compte : même calcul, même blocage, même réponse qu'un mauvais mot de passe.
|
||||
if (guard.UnknownLockedUntil(emailIndex) is { } unknownUntil) return TooManyAttempts(context, unknownUntil, now);
|
||||
await hasher.DummyAsync(password, context.RequestAborted);
|
||||
guard.CountFailedLogin(partition);
|
||||
store.AddEvent(null, PlayerEvents.LoginFailed, AuditResults.Denied, ip, proof.KeyId, "adresse sans compte");
|
||||
return guard.CountUnknownFailure(emailIndex) is { } lockedNow ? TooManyAttempts(context, lockedNow, now) : LoginFailed();
|
||||
}
|
||||
|
||||
var trusted = store.FindDeviceByKey(account.Id, proof.KeyId) is not null;
|
||||
if ((trusted ? account.TrustedLockedUntil : account.LockedUntil) is { } until && until > now)
|
||||
{
|
||||
store.AddEvent(account.Id, PlayerEvents.LoginFailed, AuditResults.Denied, ip, proof.KeyId, "connexion bloquée (trop d'essais)");
|
||||
return TooManyAttempts(context, until, now);
|
||||
}
|
||||
|
||||
var (valid, rehash) = await hasher.VerifyAsync(password, account.PasswordHash, context.RequestAborted);
|
||||
if (!valid)
|
||||
{
|
||||
var outcome = store.RecordFailure(account.Id, trusted, now, settings);
|
||||
guard.CountFailedLogin(partition);
|
||||
store.AddEvent(account.Id, PlayerEvents.LoginFailed, AuditResults.Denied, ip, proof.KeyId,
|
||||
trusted ? "mot de passe incorrect (appareil de confiance)" : "mot de passe incorrect (appareil inconnu)");
|
||||
if (outcome.Suspended)
|
||||
{
|
||||
var count = settings.FailuresPerLock * settings.SuspendAfterLocks;
|
||||
store.AddEvent(account.Id, PlayerEvents.Suspended, AuditResults.Ok, null, null,
|
||||
$"{count} échecs de connexion consécutifs depuis des appareils inconnus");
|
||||
audit.Write("players.suspend", AuditResults.Ok, actorName: "système", ip: ip, target: account.Reference,
|
||||
details: $"compte joueur suspendu automatiquement (nouveaux appareils) : {count} échecs de connexion consécutifs " +
|
||||
"depuis des appareils inconnus");
|
||||
}
|
||||
if (outcome.LockedUntil is { } lockedUntil)
|
||||
{
|
||||
store.AddEvent(account.Id, PlayerEvents.Locked, AuditResults.Ok, ip, proof.KeyId,
|
||||
$"blocage n° {outcome.LockLevel} jusqu'au {lockedUntil.UtcDateTime:yyyy-MM-dd HH:mm} UTC" +
|
||||
(trusted ? " (appareils de confiance)" : " (appareils inconnus)"));
|
||||
return TooManyAttempts(context, lockedUntil, now);
|
||||
}
|
||||
return LoginFailed();
|
||||
}
|
||||
|
||||
// Mot de passe correct : l'état du compte peut maintenant être révélé.
|
||||
if (StateRefusal(store, account, now, trusted) is { } refusal)
|
||||
{
|
||||
store.AddEvent(account.Id, PlayerEvents.Login, AuditResults.Denied, ip, proof.KeyId, refusal.Reason);
|
||||
return refusal.Result;
|
||||
}
|
||||
|
||||
store.RecordSuccess(account.Id, now, rehash ? await hasher.HashAsync(password, context.RequestAborted) : null);
|
||||
account = store.FindById(account.Id) ?? account;
|
||||
|
||||
if (account.MustChangePassword)
|
||||
{
|
||||
var (changeToken, change) = sessions.CreatePasswordChange(account.Id, proof.PublicKey, proof.KeyId, proof.Label);
|
||||
store.AddEvent(account.Id, PlayerEvents.PasswordChangeForced, AuditResults.Ok, ip, proof.KeyId,
|
||||
"connexion acceptée : nouveau mot de passe exigé avant d'ouvrir la session");
|
||||
return Results.Json(new PlayerLoginResponse
|
||||
{
|
||||
Status = PlayerLoginStatus.PasswordChangeRequired,
|
||||
PasswordChangeToken = changeToken,
|
||||
PasswordChangeExpiresAt = change.ExpiresAt
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
var (deviceRecord, refreshToken) = NewDevice(account.Id, proof, settings, now);
|
||||
var removed = store.SaveDevice(deviceRecord, settings.MaxDevicesPerAccount);
|
||||
sessions.RemoveDevices(removed);
|
||||
store.AddEvent(account.Id, PlayerEvents.Login, AuditResults.Ok, ip, proof.KeyId,
|
||||
$"appareil « {proof.Label} »{(trusted ? "" : " (nouvel appareil)")}" +
|
||||
(account.Status == PlayerStatus.Suspended ? " ; compte suspendu pour les nouveaux appareils" : ""));
|
||||
return Results.Json(new PlayerLoginResponse
|
||||
{
|
||||
Status = PlayerLoginStatus.Ok,
|
||||
Session = OpenSession(account, deviceRecord, refreshToken, sessions, secrets)
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refus lié à l'état du compte, révélé seulement à qui connaît le mot de passe ou possède une session mémorisée :
|
||||
/// désactivé (tout est refusé), suspendu (seuls les appareils de confiance passent). Une désactivation arrivée à
|
||||
/// échéance est levée au passage.
|
||||
/// </summary>
|
||||
private static (IResult Result, string Reason)? StateRefusal(PlayerStore store, PlayerAccountRecord account, DateTimeOffset now,
|
||||
bool trustedDevice)
|
||||
{
|
||||
switch (account.Status)
|
||||
{
|
||||
case PlayerStatus.Active:
|
||||
return null;
|
||||
case PlayerStatus.Suspended when trustedDevice:
|
||||
return null;
|
||||
case PlayerStatus.Inactive:
|
||||
// Compte en sommeil (longue inactivité) : la connexion avec le bon mot de passe le réveille.
|
||||
store.SetStatus(account.Id, PlayerStatus.Active, null, null, "système (connexion après inactivité)", now);
|
||||
store.AddEvent(account.Id, PlayerEvents.Reactivated, AuditResults.Ok, null, null, "connexion après une longue inactivité");
|
||||
return null;
|
||||
case PlayerStatus.Disabled when account.DisabledUntil is { } end && end <= now:
|
||||
store.SetStatus(account.Id, PlayerStatus.Active, null, null, "système (fin de la désactivation)", now);
|
||||
store.AddEvent(account.Id, PlayerEvents.Enabled, AuditResults.Ok, null, null, "fin de la désactivation temporaire");
|
||||
return null;
|
||||
case PlayerStatus.Disabled:
|
||||
var until = account.DisabledUntil is { } date
|
||||
? $" jusqu'au {date.UtcDateTime.ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture)} (UTC)"
|
||||
: "";
|
||||
return (ApiResults.Error(403, ApiErrorCodes.AccountDisabled,
|
||||
$"Ce compte est désactivé{until}. Contactez le support en indiquant la référence {account.Reference}."),
|
||||
"compte désactivé");
|
||||
default:
|
||||
return (ApiResults.Error(403, ApiErrorCodes.AccountSuspended,
|
||||
"Par sécurité, les connexions depuis un nouvel appareil sont suspendues sur ce compte après de nombreux " +
|
||||
"essais de mot de passe. Connectez-vous depuis un appareil déjà utilisé, ou contactez le support en indiquant " +
|
||||
$"la référence {account.Reference} : un administrateur pourra lever la suspension."),
|
||||
"compte suspendu (nouvel appareil)");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Renouvellement (connexion mémorisée)
|
||||
|
||||
private static IResult Refresh(PlayerRefreshRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
|
||||
IServiceProvider services, PlayerGuard guard, PlayerSessionStore sessions, ServerIdentity server, TimeProvider time)
|
||||
{
|
||||
var settings = options.Value.Players;
|
||||
if (!settings.Enabled) return Disabled();
|
||||
var store = services.GetRequiredService<PlayerStore>();
|
||||
var secrets = services.GetRequiredService<PlayerSecrets>();
|
||||
var ip = context.ClientIp();
|
||||
var revoked = ApiResults.Error(401, ApiErrorCodes.SessionRevoked, "Session expirée ou révoquée : reconnectez-vous.");
|
||||
|
||||
if (!PlayerTokens.TryParseRefresh(request.RefreshToken, out var deviceId, out var secret)) return revoked;
|
||||
if (guard.Validate(request.ChallengeId, PlayerChallengePurposes.Refresh, null) is not { } nonce)
|
||||
return ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Défi absent, expiré ou invalide : réessayez.");
|
||||
if (store.FindDevice(deviceId) is not { } device) return revoked;
|
||||
|
||||
// Possession de la clé de l'appareil : un jeton copié sur un autre PC ne sert à rien.
|
||||
var signature = AdminKeys.DecodeSignature(request.Signature);
|
||||
byte[] spki;
|
||||
try
|
||||
{
|
||||
spki = Convert.FromBase64String(device.PublicKey);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return revoked;
|
||||
}
|
||||
if (signature is null || !AdminKeys.Verify(spki,
|
||||
PlayerMessages.Device(PlayerChallengePurposes.Refresh, server.Origin, server.ServerId, request.ChallengeId, nonce, device.KeyId),
|
||||
signature))
|
||||
{
|
||||
store.AddEvent(device.AccountId, PlayerEvents.Refresh, AuditResults.Denied, ip, device.KeyId, "signature de l'appareil invalide");
|
||||
return ApiResults.Error(401, ApiErrorCodes.DeviceProofInvalid, "Cet appareil n'a pas pu prouver son identité : reconnectez-vous.");
|
||||
}
|
||||
if (!guard.TryConsume(request.ChallengeId))
|
||||
return ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Défi déjà utilisé : réessayez.");
|
||||
|
||||
var now = time.GetUtcNow();
|
||||
var hash = HashSecret(secret);
|
||||
if (device.PreviousRefreshHash is { } previous && FixedEquals(hash, previous))
|
||||
{
|
||||
// Jeton déjà remplacé et présenté de nouveau : copie volée (ou réponse perdue). L'appareil est déconnecté.
|
||||
store.DeleteDevice(device.Id);
|
||||
sessions.RemoveDevices(new[] { device.Id });
|
||||
store.AddEvent(device.AccountId, PlayerEvents.RefreshReuse, AuditResults.Denied, ip, device.KeyId,
|
||||
"jeton de renouvellement déjà utilisé : appareil déconnecté par précaution");
|
||||
return revoked;
|
||||
}
|
||||
if (!FixedEquals(hash, device.RefreshHash)) return revoked;
|
||||
if (now >= device.RefreshExpiresAt || now >= device.RefreshAbsoluteExpiresAt)
|
||||
{
|
||||
store.DeleteDevice(device.Id);
|
||||
store.AddEvent(device.AccountId, PlayerEvents.Refresh, AuditResults.Denied, ip, device.KeyId, "session mémorisée expirée");
|
||||
return revoked;
|
||||
}
|
||||
|
||||
var account = store.FindById(device.AccountId);
|
||||
if (account is null) return revoked;
|
||||
if (StateRefusal(store, account, now, trustedDevice: true) is { } refusal)
|
||||
{
|
||||
store.AddEvent(account.Id, PlayerEvents.Refresh, AuditResults.Denied, ip, device.KeyId, refusal.Reason);
|
||||
return refusal.Result;
|
||||
}
|
||||
if (account.MustChangePassword)
|
||||
return ApiResults.Error(403, ApiErrorCodes.PasswordChangeRequired,
|
||||
"Un nouveau mot de passe est exigé : reconnectez-vous pour le choisir.");
|
||||
|
||||
var newSecret = PlayerTokens.NewSecret();
|
||||
var idle = Min(now + TimeSpan.FromDays(settings.RefreshIdleDays), device.RefreshAbsoluteExpiresAt);
|
||||
if (!store.RotateRefresh(device.Id, device.RefreshHash, HashSecret(newSecret), idle, now)) return revoked;
|
||||
// Journal : un renouvellement par appareil et par jour au plus (le launcher en fait un à chaque lancement).
|
||||
if (device.LastUsedAt.UtcDateTime.Date != now.UtcDateTime.Date)
|
||||
store.AddEvent(account.Id, PlayerEvents.Refresh, AuditResults.Ok, ip, device.KeyId, $"appareil « {device.Label} »");
|
||||
|
||||
var rotated = device with { RefreshExpiresAt = idle, LastUsedAt = now };
|
||||
return Results.Json(OpenSession(account, rotated, PlayerTokens.FormatRefresh(device.Id, newSecret), sessions, secrets), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Session ouverte
|
||||
|
||||
private static IResult Logout(PlayerLogoutRequest? request, HttpContext context, IServiceProvider services, PlayerSessionStore sessions)
|
||||
{
|
||||
var player = context.Player();
|
||||
var store = services.GetRequiredService<PlayerStore>();
|
||||
var ip = context.ClientIp();
|
||||
if (request?.AllDevices == true)
|
||||
{
|
||||
var removed = store.DeleteDevices(player.Account.Id);
|
||||
sessions.RemoveWhere(s => s.AccountId == player.Account.Id);
|
||||
store.AddEvent(player.Account.Id, PlayerEvents.LogoutAll, AuditResults.Ok, ip, player.Device?.KeyId,
|
||||
$"{removed.Count} appareil(s) déconnecté(s)");
|
||||
}
|
||||
else if (player.Device is { } device)
|
||||
{
|
||||
store.DeleteDevice(device.Id);
|
||||
sessions.RemoveDevices(new[] { device.Id });
|
||||
store.AddEvent(player.Account.Id, PlayerEvents.Logout, AuditResults.Ok, ip, device.KeyId, $"appareil « {device.Label} »");
|
||||
}
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult Me(HttpContext context, IServiceProvider services)
|
||||
{
|
||||
var player = context.Player();
|
||||
var store = services.GetRequiredService<PlayerStore>();
|
||||
var secrets = services.GetRequiredService<PlayerSecrets>();
|
||||
return Results.Json(new PlayerMeResponse
|
||||
{
|
||||
Account = Info(player.Account, secrets),
|
||||
Devices = store.Devices(player.Account.Id).Select(d => new PlayerDeviceInfo
|
||||
{
|
||||
Id = d.Id,
|
||||
Label = d.Label,
|
||||
CreatedAt = d.CreatedAt,
|
||||
LastUsedAt = d.LastUsedAt,
|
||||
Current = d.Id == player.Device?.Id
|
||||
}).ToList()
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ChangePassword(PlayerChangePasswordRequest request, HttpContext context, IOptions<LauncherApiOptions> options,
|
||||
IServiceProvider services, PlayerSessionStore sessions, TimeProvider time)
|
||||
{
|
||||
var settings = options.Value.Players;
|
||||
var player = context.Player();
|
||||
var store = services.GetRequiredService<PlayerStore>();
|
||||
var secrets = services.GetRequiredService<PlayerSecrets>();
|
||||
var hasher = services.GetRequiredService<PlayerPasswordHasher>();
|
||||
var ip = context.ClientIp();
|
||||
var account = player.Account;
|
||||
var deviceKey = player.Device?.KeyId ?? player.Session.DeviceKeyId;
|
||||
var now = time.GetUtcNow();
|
||||
|
||||
if (account.TrustedLockedUntil is { } until && until > now) return TooManyAttempts(context, until, now);
|
||||
if (request.CurrentPassword is not { Length: > 0 and <= MaxSubmittedPasswordLength } ||
|
||||
request.NewPassword is not { Length: > 0 and <= MaxSubmittedPasswordLength })
|
||||
return ApiResults.BadRequest("Mot de passe actuel et nouveau mot de passe obligatoires.");
|
||||
|
||||
if (await CheckCurrentPasswordAsync(request.CurrentPassword, account, PlayerEvents.PasswordChange, deviceKey, context, store, hasher,
|
||||
sessions, settings, now) is { } refused)
|
||||
return refused;
|
||||
|
||||
var email = secrets.DecryptEmail(account.Id, account.EmailEncrypted);
|
||||
var errors = PasswordRules.Check(request.NewPassword, email).ToList();
|
||||
if (errors.Count == 0 &&
|
||||
string.Equals(request.NewPassword, request.CurrentPassword, StringComparison.Ordinal))
|
||||
errors.Add("Le nouveau mot de passe doit être différent de l'actuel.");
|
||||
if (errors.Count > 0) return ApiResults.Error(400, ApiErrorCodes.PasswordRejected, string.Join(" ", errors));
|
||||
|
||||
var hash = await hasher.HashAsync(request.NewPassword, context.RequestAborted);
|
||||
store.SetPassword(account.Id, hash, now);
|
||||
store.RecordSuccess(account.Id, now, null);
|
||||
|
||||
if (player.Session.Scope == PlayerScopes.PasswordChange)
|
||||
{
|
||||
// Changement EXIGÉ : tous les appareils sont déconnectés, puis la session s'ouvre sur cet appareil.
|
||||
var removed = store.DeleteDevices(account.Id);
|
||||
sessions.RemoveWhere(s => s.AccountId == account.Id);
|
||||
var proof = new DeviceProofResult(player.Session.DevicePublicKey!, player.Session.DeviceKeyId!, player.Session.DeviceLabel!);
|
||||
var (deviceRecord, refreshToken) = NewDevice(account.Id, proof, settings, now);
|
||||
store.SaveDevice(deviceRecord, settings.MaxDevicesPerAccount);
|
||||
store.AddEvent(account.Id, PlayerEvents.PasswordChange, AuditResults.Ok, ip, proof.KeyId,
|
||||
$"nouveau mot de passe (exigé) ; {removed.Count} autre(s) appareil(s) déconnecté(s)");
|
||||
var refreshed = store.FindById(account.Id) ?? account;
|
||||
return Results.Json(new PlayerChangePasswordResponse
|
||||
{
|
||||
Session = OpenSession(refreshed, deviceRecord, refreshToken, sessions, secrets),
|
||||
DevicesSignedOut = removed.Count
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
// Changement ordinaire : les AUTRES appareils sont déconnectés (celui-ci reste connecté).
|
||||
var others = store.DeleteDevices(account.Id, exceptDeviceId: player.Device!.Id);
|
||||
sessions.RemoveWhere(s => s.AccountId == account.Id && s.DeviceId != player.Device.Id);
|
||||
store.AddEvent(account.Id, PlayerEvents.PasswordChange, AuditResults.Ok, ip, deviceKey,
|
||||
$"nouveau mot de passe ; {others.Count} autre(s) appareil(s) déconnecté(s)");
|
||||
return Results.Json(new PlayerChangePasswordResponse { DevicesSignedOut = others.Count }, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult DeleteDevice(string deviceId, HttpContext context, IServiceProvider services, PlayerSessionStore sessions)
|
||||
{
|
||||
var player = context.Player();
|
||||
var store = services.GetRequiredService<PlayerStore>();
|
||||
if (!PlayerIds.IsValidId(deviceId) || store.FindDevice(deviceId) is not { } device || device.AccountId != player.Account.Id)
|
||||
return ApiResults.NotFound("Appareil introuvable.");
|
||||
store.DeleteDevice(device.Id);
|
||||
sessions.RemoveDevices(new[] { device.Id });
|
||||
store.AddEvent(player.Account.Id, PlayerEvents.DeviceRemoved, AuditResults.Ok, context.ClientIp(), player.Device?.KeyId,
|
||||
$"appareil « {device.Label} » déconnecté{(device.Id == player.Device?.Id ? " (celui-ci)" : "")}");
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Activité, export et suppression (RGPD)
|
||||
|
||||
/// <summary>Activité récente du compte (50 dernières lignes), sans détail interne, adresses IP masquées.</summary>
|
||||
private static IResult Activity(HttpContext context, IServiceProvider services)
|
||||
{
|
||||
var player = context.Player();
|
||||
var store = services.GetRequiredService<PlayerStore>();
|
||||
var labels = store.Devices(player.Account.Id).ToDictionary(d => d.KeyId, d => d.Label, StringComparer.Ordinal);
|
||||
var currentKey = player.Device?.KeyId;
|
||||
return Results.Json(new PlayerActivityResponse
|
||||
{
|
||||
Entries = store.Events(player.Account.Id, 50).Select(e => new PlayerActivityEntry
|
||||
{
|
||||
At = e.At,
|
||||
Type = e.Type,
|
||||
Success = e.Result == AuditResults.Ok,
|
||||
Device = e.DeviceKeyId is { } key && labels.TryGetValue(key, out var label) ? label : null,
|
||||
ThisDevice = currentKey is not null && e.DeviceKeyId == currentKey,
|
||||
Ip = MaskIp(e.Ip)
|
||||
}).ToList()
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export de toutes les données du compte (droits d'accès et de portabilité), mot de passe exigé : le document contient
|
||||
/// l'adresse e-mail complète, que l'API ne renvoie jamais autrement.
|
||||
/// </summary>
|
||||
private static async Task<IResult> Export(PlayerPasswordConfirmation request, HttpContext context, IOptions<LauncherApiOptions> options,
|
||||
IServiceProvider services, PlayerSessionStore sessions, ServerIdentity server, TimeProvider time)
|
||||
{
|
||||
var player = context.Player();
|
||||
var store = services.GetRequiredService<PlayerStore>();
|
||||
var now = time.GetUtcNow();
|
||||
if (await ConfirmAsync(request, player, PlayerEvents.Export, context, options.Value.Players, services, sessions, now) is { } refused)
|
||||
return refused;
|
||||
var account = store.FindById(player.Account.Id) ?? player.Account;
|
||||
var document = services.GetRequiredService<PlayerPersonalData>().Export(account, server.Origin, now);
|
||||
store.AddEvent(account.Id, PlayerEvents.Export, AuditResults.Ok, context.ClientIp(), player.Device?.KeyId, null);
|
||||
return Results.Bytes(document, "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
/// <summary>Suppression définitive du compte par son titulaire (droit à l'effacement), mot de passe exigé.</summary>
|
||||
private static async Task<IResult> DeleteAccount(PlayerPasswordConfirmation request, HttpContext context, IOptions<LauncherApiOptions> options,
|
||||
IServiceProvider services, PlayerSessionStore sessions, TimeProvider time)
|
||||
{
|
||||
var player = context.Player();
|
||||
var now = time.GetUtcNow();
|
||||
if (await ConfirmAsync(request, player, PlayerEvents.Delete, context, options.Value.Players, services, sessions, now) is { } refused)
|
||||
return refused;
|
||||
services.GetRequiredService<PlayerPersonalData>().Erase(player.Account, byPlayer: true, context.ClientIp());
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Mot de passe actuel d'une action sensible (export, suppression) : présence, blocage en cours, vérification.</summary>
|
||||
private static async Task<IResult?> ConfirmAsync(PlayerPasswordConfirmation? request, AuthenticatedPlayer player, string eventType,
|
||||
HttpContext context, LauncherApiOptions.PlayersSettings settings, IServiceProvider services, PlayerSessionStore sessions, DateTimeOffset now)
|
||||
{
|
||||
var account = player.Account;
|
||||
if (account.TrustedLockedUntil is { } until && until > now) return TooManyAttempts(context, until, now);
|
||||
if (request?.Password is not { Length: > 0 and <= MaxSubmittedPasswordLength } password)
|
||||
return ApiResults.BadRequest("Mot de passe obligatoire.");
|
||||
return await CheckCurrentPasswordAsync(password, account, eventType, player.Device?.KeyId, context,
|
||||
services.GetRequiredService<PlayerStore>(), services.GetRequiredService<PlayerPasswordHasher>(), sessions, settings, now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie le mot de passe actuel d'un joueur DÉJÀ connecté. Un échec compte comme un échec de connexion depuis un appareil
|
||||
/// de confiance : une session volée qui tente de deviner le mot de passe est bloquée, puis toutes les sessions du compte
|
||||
/// sont fermées. Null si le mot de passe est correct.
|
||||
/// </summary>
|
||||
private static async Task<IResult?> CheckCurrentPasswordAsync(string password, PlayerAccountRecord account, string eventType, string? deviceKey,
|
||||
HttpContext context, PlayerStore store, PlayerPasswordHasher hasher, PlayerSessionStore sessions,
|
||||
LauncherApiOptions.PlayersSettings settings, DateTimeOffset now)
|
||||
{
|
||||
var (valid, _) = await hasher.VerifyAsync(password, account.PasswordHash, context.RequestAborted);
|
||||
if (valid) return null;
|
||||
var outcome = store.RecordFailure(account.Id, trusted: true, now, settings);
|
||||
store.AddEvent(account.Id, eventType, AuditResults.Denied, context.ClientIp(), deviceKey, "mot de passe actuel incorrect");
|
||||
if (outcome.LockedUntil is { } locked)
|
||||
{
|
||||
sessions.RemoveWhere(s => s.AccountId == account.Id);
|
||||
return TooManyAttempts(context, locked, now);
|
||||
}
|
||||
return ApiResults.Error(400, ApiErrorCodes.LoginFailed, "Mot de passe actuel incorrect.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adresse IP masquée pour l'affichage au joueur : IPv4 « 203.0.113.x », IPv6 réduite à son préfixe /48
|
||||
/// (« 2001:db8:85a3::/48 »). Elle peut être celle d'un tiers (essai de mot de passe) : jamais affichée en entier.
|
||||
/// </summary>
|
||||
internal static string? MaskIp(string? ip)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ip) || !IPAddress.TryParse(ip, out var address)) return null;
|
||||
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
|
||||
var bytes = address.GetAddressBytes();
|
||||
if (bytes.Length == 4) return $"{bytes[0]}.{bytes[1]}.{bytes[2]}.x";
|
||||
var prefix = new byte[16];
|
||||
Array.Copy(bytes, prefix, 6);
|
||||
return new IPAddress(prefix) + "/48";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
/// <summary>Clé d'appareil vérifiée : clé publique (Base64 canonique), identifiant, nom générique.</summary>
|
||||
private sealed record DeviceProofResult(string PublicKey, string KeyId, string Label);
|
||||
|
||||
/// <summary>
|
||||
/// Défi (sceau, usage, échéance, preuve de travail) puis signature de la clé d'appareil, puis consommation du défi
|
||||
/// (un défi ne sert qu'une fois, et seulement avec une signature valide).
|
||||
/// </summary>
|
||||
private static (DeviceProofResult? Proof, IResult? Error) CheckDevice(PlayerGuard guard, ServerIdentity server, string purpose,
|
||||
string? challengeId, string? solution, PlayerDeviceProof? device)
|
||||
{
|
||||
if (guard.Validate(challengeId, purpose, solution) is not { } nonce)
|
||||
return (null, ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Vérification anti-robot échouée ou expirée : réessayez."));
|
||||
var deviceError = ApiResults.Error(400, ApiErrorCodes.DeviceProofInvalid, "Clé de l'appareil invalide.");
|
||||
if (device is null) return (null, deviceError);
|
||||
byte[] spki;
|
||||
try
|
||||
{
|
||||
spki = AdminKeys.ParsePublicKey(device.PublicKey);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return (null, deviceError);
|
||||
}
|
||||
var keyId = AdminKeys.ComputeKeyId(spki);
|
||||
var signature = AdminKeys.DecodeSignature(device.Signature);
|
||||
if (signature is null ||
|
||||
!AdminKeys.Verify(spki, PlayerMessages.Device(purpose, server.Origin, server.ServerId, challengeId!, nonce, keyId), signature))
|
||||
return (null, deviceError);
|
||||
if (!guard.TryConsume(challengeId!))
|
||||
return (null, ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Vérification déjà utilisée : réessayez."));
|
||||
var label = ApiText.CleanLine(device.Label, ApiText.MaxKeyLabelLength) ?? "Appareil";
|
||||
return (new DeviceProofResult(Convert.ToBase64String(spki), keyId, label), null);
|
||||
}
|
||||
|
||||
private static (PlayerDeviceRecord Device, string RefreshToken) NewDevice(string accountId, DeviceProofResult proof,
|
||||
LauncherApiOptions.PlayersSettings settings, DateTimeOffset now)
|
||||
{
|
||||
var deviceId = PlayerIds.NewId();
|
||||
var secret = PlayerTokens.NewSecret();
|
||||
var absolute = now + TimeSpan.FromDays(settings.RefreshMaxDays);
|
||||
var device = new PlayerDeviceRecord
|
||||
{
|
||||
Id = deviceId,
|
||||
AccountId = accountId,
|
||||
KeyId = proof.KeyId,
|
||||
PublicKey = proof.PublicKey,
|
||||
Label = proof.Label,
|
||||
CreatedAt = now,
|
||||
LastUsedAt = now,
|
||||
RefreshHash = HashSecret(secret),
|
||||
RefreshExpiresAt = Min(now + TimeSpan.FromDays(settings.RefreshIdleDays), absolute),
|
||||
RefreshAbsoluteExpiresAt = absolute
|
||||
};
|
||||
return (device, PlayerTokens.FormatRefresh(deviceId, secret));
|
||||
}
|
||||
|
||||
private static PlayerSession OpenSession(PlayerAccountRecord account, PlayerDeviceRecord device, string refreshToken,
|
||||
PlayerSessionStore sessions, PlayerSecrets secrets)
|
||||
{
|
||||
var (accessToken, access) = sessions.CreateAccess(account.Id, device.Id);
|
||||
return new PlayerSession
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
AccessExpiresAt = access.ExpiresAt,
|
||||
RefreshToken = refreshToken,
|
||||
RefreshExpiresAt = device.RefreshExpiresAt,
|
||||
DeviceId = device.Id,
|
||||
Account = Info(account, secrets)
|
||||
};
|
||||
}
|
||||
|
||||
private static PlayerAccountInfo Info(PlayerAccountRecord account, PlayerSecrets secrets) => new()
|
||||
{
|
||||
Reference = account.Reference,
|
||||
EmailMasked = PlayerEmail.Mask(secrets.DecryptEmail(account.Id, account.EmailEncrypted)),
|
||||
EmailVerified = account.EmailVerified,
|
||||
CreatedAt = account.CreatedAt,
|
||||
LastLoginAt = account.LastLoginAt,
|
||||
PasswordChangedAt = account.PasswordChangedAt,
|
||||
OtpEnabled = account.OtpEnabled,
|
||||
NewDevicesSuspended = account.Status == PlayerStatus.Suspended
|
||||
};
|
||||
|
||||
/// <summary>Empreinte d'un secret de jeton de renouvellement (seule valeur conservée).</summary>
|
||||
private static string HashSecret(string secret) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("GameLauncher/Player/Refresh/v1\n" + secret))).ToLowerInvariant();
|
||||
|
||||
private static bool FixedEquals(string a, string b) =>
|
||||
CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(a), Encoding.ASCII.GetBytes(b));
|
||||
|
||||
private static DateTimeOffset Min(DateTimeOffset a, DateTimeOffset b) => a <= b ? a : b;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using GameLauncher.Server.Storage.Players;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Données d'un joueur détenues par un module (RGPD : droits d'accès, de portabilité et d'effacement). Chaque module qui
|
||||
/// conserve des données liées à un compte joueur (demandes connectées et échanges à l'étape 8f, amis, sauvegardes…) déclare
|
||||
/// une implémentation (<c>services.AddSingleton<IPersonalDataProvider, …>()</c>) : l'export et la suppression du compte
|
||||
/// la couvrent alors automatiquement, sans modifier le module des comptes.
|
||||
/// </summary>
|
||||
public interface IPersonalDataProvider
|
||||
{
|
||||
/// <summary>Nom de la section dans l'export (« feedback »…), en minuscules.</summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>Données du joueur, lisibles par machine (null : rien pour ce compte).</summary>
|
||||
JsonNode? Export(PlayerAccountRecord account);
|
||||
|
||||
/// <summary>Efface (ou détache du compte) les données du joueur. Appelé AVANT la suppression du compte lui-même.</summary>
|
||||
void Erase(PlayerAccountRecord account);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export et effacement des données d'un compte joueur, à la demande du joueur ou par la politique des comptes inactifs
|
||||
/// (CNIL : mise en sommeil après <c>Players:InactiveDisableMonths</c> sans activité, suppression après
|
||||
/// <c>Players:InactiveDeleteMonths</c> de sommeil). L'export contient TOUT ce que le serveur sait du compte, y compris
|
||||
/// l'adresse e-mail complète ; jamais l'empreinte du mot de passe ni les secrets des sessions.
|
||||
/// </summary>
|
||||
public sealed class PlayerPersonalData
|
||||
{
|
||||
private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true };
|
||||
|
||||
private readonly PlayerStore _store;
|
||||
private readonly PlayerSecrets _secrets;
|
||||
private readonly PlayerSessionStore _sessions;
|
||||
private readonly IReadOnlyList<IPersonalDataProvider> _providers;
|
||||
private readonly AuditLog _audit;
|
||||
private readonly LauncherApiOptions.PlayersSettings _settings;
|
||||
|
||||
public PlayerPersonalData(PlayerStore store, PlayerSecrets secrets, PlayerSessionStore sessions, IEnumerable<IPersonalDataProvider> providers,
|
||||
AuditLog audit, IOptions<LauncherApiOptions> options)
|
||||
{
|
||||
_store = store;
|
||||
_secrets = secrets;
|
||||
_sessions = sessions;
|
||||
_providers = providers.ToList();
|
||||
_audit = audit;
|
||||
_settings = options.Value.Players;
|
||||
var duplicate = _providers.GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase).FirstOrDefault(g => g.Count() > 1);
|
||||
if (duplicate is not null) throw new InvalidOperationException($"Données personnelles : section « {duplicate.Key} » déclarée deux fois.");
|
||||
}
|
||||
|
||||
/// <summary>Document d'export (JSON indenté, UTF-8).</summary>
|
||||
public byte[] Export(PlayerAccountRecord account, string origin, DateTimeOffset now)
|
||||
{
|
||||
var devices = _store.Devices(account.Id);
|
||||
var document = new JsonObject
|
||||
{
|
||||
["format"] = PlayerDataExport.Format,
|
||||
["exportedAt"] = now,
|
||||
["service"] = origin,
|
||||
["account"] = new JsonObject
|
||||
{
|
||||
["reference"] = account.Reference,
|
||||
["email"] = _secrets.DecryptEmail(account.Id, account.EmailEncrypted),
|
||||
["emailVerified"] = account.EmailVerified,
|
||||
["createdAt"] = account.CreatedAt,
|
||||
["lastLoginAt"] = account.LastLoginAt,
|
||||
["passwordChangedAt"] = account.PasswordChangedAt,
|
||||
["termsVersionAccepted"] = account.TermsVersion,
|
||||
["status"] = account.Status,
|
||||
["disabledUntil"] = account.DisabledUntil,
|
||||
["twoFactorEnabled"] = account.OtpEnabled
|
||||
},
|
||||
["devices"] = new JsonArray(devices.Select(d => (JsonNode)new JsonObject
|
||||
{
|
||||
["label"] = d.Label,
|
||||
["deviceKeyId"] = d.KeyId,
|
||||
["connectedAt"] = d.CreatedAt,
|
||||
["lastUsedAt"] = d.LastUsedAt,
|
||||
["rememberedUntil"] = d.RefreshExpiresAt
|
||||
}).ToArray()),
|
||||
["activity"] = new JsonArray(_store.Events(account.Id, 1000).Select(e => (JsonNode)new JsonObject
|
||||
{
|
||||
["at"] = e.At,
|
||||
["type"] = e.Type,
|
||||
["description"] = PlayerActivityText.Describe(e.Type, e.Result == AuditResults.Ok),
|
||||
["result"] = e.Result,
|
||||
["ip"] = e.Ip,
|
||||
["deviceKeyId"] = e.DeviceKeyId,
|
||||
["details"] = e.Details
|
||||
}).ToArray())
|
||||
};
|
||||
|
||||
var modules = new JsonObject();
|
||||
foreach (var provider in _providers)
|
||||
{
|
||||
if (provider.Export(account) is { } data) modules[provider.Name] = data;
|
||||
}
|
||||
document["modules"] = modules;
|
||||
document["notes"] = new JsonArray(
|
||||
"Le mot de passe n'est jamais conservé : seule une empreinte irréversible existe sur le serveur, elle n'est pas exportée.",
|
||||
$"Activité : {_settings.EventsRetentionMonths} mois au plus ; adresses IP effacées au bout de {_settings.EventsIpRetentionMonths} mois.",
|
||||
"Les clés d'appareil privées ne quittent jamais votre PC : seul leur identifiant public figure ici.");
|
||||
return JsonSerializer.SerializeToUtf8Bytes(document, Indented);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supprime DÉFINITIVEMENT le compte : données des modules, appareils, journal du compte, sessions ouvertes. Une ligne
|
||||
/// du journal d'audit (référence seulement) garde la trace de la suppression.
|
||||
/// </summary>
|
||||
/// <param name="byPlayer">Vrai : à la demande du joueur ; faux : politique des comptes inactifs.</param>
|
||||
public bool Erase(PlayerAccountRecord account, bool byPlayer, string? ip)
|
||||
{
|
||||
foreach (var provider in _providers) provider.Erase(account);
|
||||
_sessions.RemoveWhere(s => s.AccountId == account.Id);
|
||||
if (!_store.DeleteAccount(account.Id)) return false;
|
||||
_sessions.RemoveWhere(s => s.AccountId == account.Id); // session ouverte pendant la suppression
|
||||
_audit.Write(byPlayer ? "players.self_delete" : "players.inactive_delete", AuditResults.Ok,
|
||||
actorName: byPlayer ? "joueur" : "système", ip: ip, target: account.Reference,
|
||||
details: byPlayer
|
||||
? "compte joueur supprimé définitivement à la demande de son titulaire"
|
||||
: $"compte joueur supprimé définitivement après {_settings.InactiveDeleteMonths} mois de sommeil (inactivité)");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Politique des comptes inactifs (au démarrage puis chaque jour) : mise en sommeil des comptes actifs sans activité
|
||||
/// depuis <c>InactiveDisableMonths</c>, suppression de ceux en sommeil depuis <c>InactiveDeleteMonths</c>.
|
||||
/// Renvoie (mis en sommeil, supprimés).
|
||||
/// </summary>
|
||||
public (int Dormant, int Deleted) ApplyInactivity(DateTimeOffset now)
|
||||
{
|
||||
if (_settings.InactiveDisableMonths <= 0) return (0, 0);
|
||||
var dormant = 0;
|
||||
foreach (var account in _store.FindInactive(now.AddMonths(-_settings.InactiveDisableMonths)))
|
||||
{
|
||||
if (!_store.MarkInactive(account.Id, now)) continue;
|
||||
_sessions.RemoveWhere(s => s.AccountId == account.Id);
|
||||
_store.AddEvent(account.Id, PlayerEvents.Inactive, AuditResults.Ok, null, null,
|
||||
$"aucune activité depuis {_settings.InactiveDisableMonths} mois : appareils déconnectés");
|
||||
dormant++;
|
||||
}
|
||||
|
||||
var deleted = 0;
|
||||
if (_settings.InactiveDeleteMonths > 0)
|
||||
{
|
||||
foreach (var account in _store.FindDormantSince(now.AddMonths(-_settings.InactiveDeleteMonths)))
|
||||
{
|
||||
if (Erase(account, byPlayer: false, ip: null)) deleted++;
|
||||
}
|
||||
}
|
||||
return (dormant, deleted);
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,7 @@ public sealed class FeedbackModule : IApiModule
|
||||
/// <summary>
|
||||
/// Publication des formulaires : requête signée (permission feedback.forms) ET formulaires signés par la clé de la
|
||||
/// session sur leur condensé (les launchers revérifient avec le trousseau : autorisation « forms »), règles complètes,
|
||||
/// révision attendue (409 si quelqu'un a publié entre-temps), audit.
|
||||
/// révision attendue (409 si quelqu'un a publié entre-temps), audit détaillé par type de demande (<see cref="FeedbackFormsAudit"/>).
|
||||
/// </summary>
|
||||
private static IResult PublishForms(FeedbackFormsPublishRequest request, HttpContext context, FeedbackFormsStore forms, AdminStore admins,
|
||||
AuditLog audit, TimeProvider time, ILogger<FeedbackModule> logger)
|
||||
@@ -222,8 +222,17 @@ public sealed class FeedbackModule : IApiModule
|
||||
"Le serveur n'a pas pu écrire les formulaires publics : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
|
||||
audit.Write("feedback.forms", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}", $"{FeedbackFormsStore.Describe(previous, published)} ; signé (clé {signature.KeyId})");
|
||||
// Une ligne par type de demande ajouté, modifié ou supprimé (son nom et tous ses changements, champs compris, sur
|
||||
// plusieurs lignes numérotées si besoin), puis la ligne de la publication (révision, résumé, signature). Écrites
|
||||
// de la dernière à la première : le journal, affiché du plus récent au plus ancien, les montre dans l'ordre.
|
||||
var changes = FeedbackFormsAudit.Changes(previous, published);
|
||||
foreach (var (action, name, details) in changes
|
||||
.SelectMany(c => FeedbackFormsAudit.Lines(c.Details).Select(details => (c.Action, c.Name, details)))
|
||||
.Reverse())
|
||||
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"« {name} »", details);
|
||||
audit.Write(FeedbackFormsAudit.Published, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}", $"{FeedbackFormsAudit.Summary(previous, published, changes)} ; signé (clé {signature.KeyId})");
|
||||
return Results.Json(FormsState(forms, admins), ApiJson.Options);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance du launcher (commun à tous les jeux, <see cref="MaintenanceStore"/>) :
|
||||
/// • GET v1/maintenance : état en vigueur, pour tout administrateur connecté (contenu public de toute façon) ;
|
||||
/// • PUT v1/maintenance : activation, désactivation ou nouveau message — permission maintenance.manage, requête signée
|
||||
/// ET document signé par la clé de la session (autorisation « maintenance » du trousseau, vérifiée par les launchers),
|
||||
/// règles complètes, révision attendue (409 si quelqu'un a publié entre-temps), audit.
|
||||
/// Le serveur ne bloque rien lui-même : ce sont les launchers des joueurs qui, en lisant le fichier public, bloquent tout
|
||||
/// sauf la désinstallation des jeux et le menu d'administration. L'API d'administration reste utilisable pendant la
|
||||
/// maintenance (c'est elle qui permet de la terminer).
|
||||
/// </summary>
|
||||
public sealed class MaintenanceModule : IApiModule
|
||||
{
|
||||
public string Name => "Maintenance";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<MaintenanceStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.Maintenance, Get).RequireAdmin();
|
||||
api.MapPut(ApiRoutes.Maintenance, Publish).RequirePermission(AdminPermissions.MaintenanceManage).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult Get(MaintenanceStore store, AdminStore admins, ILogger<MaintenanceModule> logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Results.Json(State(store, admins), ApiJson.Options);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogError(ex, "Lecture du mode maintenance");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/ " +
|
||||
"dans les données privées du serveur.");
|
||||
}
|
||||
}
|
||||
|
||||
private static MaintenanceState State(MaintenanceStore store, AdminStore admins)
|
||||
{
|
||||
var current = store.Current;
|
||||
return new MaintenanceState
|
||||
{
|
||||
Document = current,
|
||||
PublishingEnabled = store.PublishingEnabled,
|
||||
PublicUrl = store.PublicUrl,
|
||||
UpdatedBy = ContentSignatures.SignerName(admins.Read(), current.Signature)
|
||||
};
|
||||
}
|
||||
|
||||
private static IResult Publish(MaintenancePublishRequest request, HttpContext context, MaintenanceStore store, AdminStore admins,
|
||||
AuditLog audit, TimeProvider time, ILogger<MaintenanceModule> logger)
|
||||
{
|
||||
if (request.Document is null) return ApiResults.BadRequest("Document de maintenance absent.");
|
||||
var document = request.Document with { UpdatedAt = null };
|
||||
var actor = context.Admin();
|
||||
if (document.Signature is not { } signature)
|
||||
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired, "Le mode maintenance doit être signé.");
|
||||
var now = time.GetUtcNow();
|
||||
ContentSignatures.RequireFromActor(signature, ContentKinds.Maintenance, ContentKinds.MaintenanceId,
|
||||
MaintenanceRules.Digest(document), actor, now);
|
||||
|
||||
MaintenanceDocument published, previous;
|
||||
try
|
||||
{
|
||||
(published, previous) = store.Publish(document, request.BaseRevision, now);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
logger.LogError(ex, "Publication du mode maintenance");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/.");
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.LogError(ex, "Publication du mode maintenance");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire le fichier public du mode maintenance : vérifiez le dossier et ses droits " +
|
||||
"(server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
|
||||
var action = published.Enabled switch
|
||||
{
|
||||
true when !previous.Enabled => "maintenance.enable",
|
||||
false when previous.Enabled => "maintenance.disable",
|
||||
_ => "maintenance.update"
|
||||
};
|
||||
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}", $"{MaintenanceRules.Describe(published)} ; signé (clé {signature.KeyId})");
|
||||
logger.LogInformation("Mode maintenance {Action} par {Admin} (révision {Revision})", action, actor.Admin.DisplayName, published.Revision);
|
||||
return Results.Json(State(store, admins), ApiJson.Options);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
@@ -13,7 +15,8 @@ namespace GameLauncher.Server.Modules.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Signature du contenu servi aux joueurs (phase 7) :
|
||||
/// • GET v1/signing (admins.manage) : trousseau publié, signatures des manifests et du catalogue ;
|
||||
/// • GET v1/signing (admins.manage) : trousseau publié, signatures des manifests, du catalogue, des formulaires des demandes
|
||||
/// et du mode maintenance ;
|
||||
/// • PUT v1/keyring (admins.manage, requête signée) : publication d'un trousseau signé HORS LIGNE par la clé racine ;
|
||||
/// • GET / POST v1/games/{gameId}/signature (releases.publish) : signature de la version déjà en ligne.
|
||||
/// La signature des nouvelles versions et du catalogue se fait à leur publication (modules Publishing et Catalog).
|
||||
@@ -33,8 +36,8 @@ public sealed class SigningModule : IApiModule
|
||||
api.MapPost(ApiRoutes.ManifestSignaturePattern, SignManifest).RequirePermission(AdminPermissions.ReleasesPublish).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult State(KeyringStore keyrings, CatalogStore catalogs, AdminStore admins, IOptions<LauncherApiOptions> options,
|
||||
ILogger<SigningModule> logger)
|
||||
private static IResult State(KeyringStore keyrings, CatalogStore catalogs, FeedbackFormsStore forms, MaintenanceStore maintenance,
|
||||
AdminStore admins, IOptions<LauncherApiOptions> options, ILogger<SigningModule> logger)
|
||||
{
|
||||
var settings = options.Value;
|
||||
var db = admins.Read();
|
||||
@@ -78,6 +81,55 @@ public sealed class SigningModule : IApiModule
|
||||
};
|
||||
}
|
||||
|
||||
// Formulaires des demandes en vigueur (référence privée, recopiée à l'identique dans le fichier public).
|
||||
FeedbackFormsSignatureState? feedbackForms = null;
|
||||
if (forms.PublishingEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var current = forms.Current;
|
||||
feedbackForms = current.Revision == 0
|
||||
? new FeedbackFormsSignatureState()
|
||||
: new FeedbackFormsSignatureState
|
||||
{
|
||||
Revision = current.Revision,
|
||||
Digest = FeedbackFormRules.Digest(current),
|
||||
Signature = current.Signature,
|
||||
SignerName = ContentSignatures.SignerName(db, current.Signature)
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
|
||||
{
|
||||
logger.LogWarning(ex, "Signature des formulaires des demandes");
|
||||
feedbackForms = new FeedbackFormsSignatureState { Unreadable = true };
|
||||
}
|
||||
}
|
||||
|
||||
// Mode maintenance en vigueur (référence privée, recopiée à l'identique dans le fichier public).
|
||||
MaintenanceSignatureState? maintenanceState = null;
|
||||
if (maintenance.PublishingEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var current = maintenance.Current;
|
||||
maintenanceState = current.Revision == 0
|
||||
? new MaintenanceSignatureState()
|
||||
: new MaintenanceSignatureState
|
||||
{
|
||||
Revision = current.Revision,
|
||||
Enabled = current.Enabled,
|
||||
Digest = MaintenanceRules.Digest(current),
|
||||
Signature = current.Signature,
|
||||
SignerName = ContentSignatures.SignerName(db, current.Signature)
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
|
||||
{
|
||||
logger.LogWarning(ex, "Signature du mode maintenance");
|
||||
maintenanceState = new MaintenanceSignatureState { Unreadable = true };
|
||||
}
|
||||
}
|
||||
|
||||
KeyringDocument? keyring;
|
||||
try
|
||||
{
|
||||
@@ -97,7 +149,9 @@ public sealed class SigningModule : IApiModule
|
||||
KeyringPublicUrl = keyrings.PublishingEnabled ? settings.KeyringPublicUrl : null,
|
||||
RequireSignatures = settings.ContentSigning.Require,
|
||||
Manifests = manifests,
|
||||
Catalog = catalog
|
||||
Catalog = catalog,
|
||||
FeedbackForms = feedbackForms,
|
||||
Maintenance = maintenanceState
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Reflection;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.System;
|
||||
|
||||
@@ -21,13 +23,23 @@ public sealed class SystemModule : IApiModule
|
||||
var plus = version.IndexOf('+');
|
||||
if (plus > 0) version = version[..plus]; // sans l'empreinte du commit
|
||||
|
||||
api.MapGet(ApiRoutes.Info, (ServerIdentity server) => Results.Json(new ServerInfo
|
||||
api.MapGet(ApiRoutes.Info, (ServerIdentity server, IOptions<LauncherApiOptions> options) => Results.Json(new ServerInfo
|
||||
{
|
||||
ServerId = server.ServerId,
|
||||
Origin = server.Origin,
|
||||
ApiVersion = ApiRoutes.Version,
|
||||
ServerVersion = version,
|
||||
KeyAlgorithms = new[] { AdminKeys.Algorithm }
|
||||
KeyAlgorithms = new[] { AdminKeys.Algorithm },
|
||||
// Comptes joueurs : annoncés seulement s'ils sont activés (le launcher n'affiche le bouton « Compte » qu'alors).
|
||||
Accounts = options.Value.Players.Enabled
|
||||
? new PlayerAccountsInfo
|
||||
{
|
||||
Enabled = true,
|
||||
RegistrationOpen = options.Value.Players.RegistrationEnabled,
|
||||
TermsVersion = options.Value.Players.TermsVersion,
|
||||
PasswordRulesVersion = PasswordRules.Version
|
||||
}
|
||||
: null
|
||||
}, ApiJson.Options));
|
||||
|
||||
api.MapGet(ApiRoutes.Health, () => Results.Json(new HealthStatus(), ApiJson.Options));
|
||||
|
||||
@@ -63,7 +63,13 @@ public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSc
|
||||
if (!header.StartsWith("Bearer ", StringComparison.Ordinal))
|
||||
return Task.FromResult(AuthenticateResult.Fail("Schéma d'authentification inattendu."));
|
||||
|
||||
var session = _sessions.Find(header["Bearer ".Length..].Trim());
|
||||
var token = header["Bearer ".Length..].Trim();
|
||||
// Jeton de JOUEUR (« glp… ») : jamais cherché parmi les sessions d'administrateurs. Un compte joueur ne peut ouvrir
|
||||
// aucune route d'administration (voir PlayerSessionHandler).
|
||||
if (token.StartsWith("glp", StringComparison.Ordinal))
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
|
||||
var session = _sessions.Find(token);
|
||||
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session absente ou expirée."));
|
||||
|
||||
var database = _store.Read();
|
||||
@@ -115,12 +121,16 @@ public static class AdminAuthorization
|
||||
{
|
||||
services.AddAuthentication(AdminSessionHandler.SchemeName)
|
||||
.AddScheme<AuthenticationSchemeOptions, AdminSessionHandler>(AdminSessionHandler.SchemeName, null);
|
||||
// Schéma désigné explicitement dans chaque règle : une route d'administration n'accepte QUE la session d'un
|
||||
// administrateur, jamais celle d'un joueur (schéma « PlayerSession »), même si le schéma par défaut changeait.
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(AdminSessionHandler.SchemeName, p => p.RequireAuthenticatedUser());
|
||||
options.AddPolicy(AdminSessionHandler.SchemeName,
|
||||
p => p.AddAuthenticationSchemes(AdminSessionHandler.SchemeName).RequireAuthenticatedUser());
|
||||
foreach (var permission in AdminPermissions.All)
|
||||
options.AddPolicy(PolicyName(permission),
|
||||
p => p.RequireAuthenticatedUser().RequireClaim(AdminSessionHandler.PermissionClaim, permission));
|
||||
p => p.AddAuthenticationSchemes(AdminSessionHandler.SchemeName).RequireAuthenticatedUser()
|
||||
.RequireClaim(AdminSessionHandler.PermissionClaim, permission));
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage.Players;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Joueur authentifié pour la requête en cours.</summary>
|
||||
public sealed record AuthenticatedPlayer(PlayerAccessSession Session, PlayerAccountRecord Account, PlayerDeviceRecord? Device);
|
||||
|
||||
/// <summary>
|
||||
/// Schéma « PlayerSession » : jeton joueur (« Authorization: Bearer glpa_… » ou « glpc_… »). TOTALEMENT distinct du
|
||||
/// schéma des administrateurs (<see cref="AdminSessionHandler"/>) : autre stockage, autres préfixes, et les routes
|
||||
/// d'administration n'utilisent jamais ce schéma (leurs règles désignent explicitement « AdminSession »). Un compte joueur
|
||||
/// ne peut donc ouvrir aucune route d'administration, quels que soient ses droits ou son état.
|
||||
///
|
||||
/// À chaque requête : jeton valide ET compte actif (ou suspendu : la suspension ne vise que les nouveaux appareils) ET
|
||||
/// (session complète) appareil toujours enregistré et aucun changement de mot de passe exigé. Une désactivation ou une
|
||||
/// déconnexion prend effet immédiatement.
|
||||
/// </summary>
|
||||
public sealed class PlayerSessionHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "PlayerSession";
|
||||
public const string ScopeClaim = "player_scope";
|
||||
private const string ContextKey = "GameLauncher.Player";
|
||||
|
||||
private readonly PlayerSessionStore _sessions;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly LauncherApiOptions _options;
|
||||
|
||||
public PlayerSessionHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder,
|
||||
PlayerSessionStore sessions, IServiceProvider services, IOptions<LauncherApiOptions> apiOptions)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_sessions = sessions;
|
||||
_services = services;
|
||||
_options = apiOptions.Value;
|
||||
}
|
||||
|
||||
public static AuthenticatedPlayer? Get(HttpContext context) =>
|
||||
context.Items.TryGetValue(ContextKey, out var value) ? value as AuthenticatedPlayer : null;
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!_options.Players.Enabled) return Task.FromResult(AuthenticateResult.NoResult());
|
||||
var header = Request.Headers.Authorization.ToString();
|
||||
if (!header.StartsWith("Bearer ", StringComparison.Ordinal)) return Task.FromResult(AuthenticateResult.NoResult());
|
||||
var token = header["Bearer ".Length..].Trim();
|
||||
if (!token.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) &&
|
||||
!token.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal))
|
||||
return Task.FromResult(AuthenticateResult.NoResult()); // pas un jeton joueur
|
||||
|
||||
var session = _sessions.Find(token);
|
||||
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session joueur absente ou expirée."));
|
||||
|
||||
var store = _services.GetRequiredService<PlayerStore>();
|
||||
var account = store.FindById(session.AccountId);
|
||||
// Suspendu : les appareils déjà connectés gardent l'accès (seuls les nouveaux appareils sont bloqués).
|
||||
if (account is null || account.Status is not (PlayerStatus.Active or PlayerStatus.Suspended))
|
||||
{
|
||||
_sessions.RemoveWhere(s => s.AccountId == session.AccountId);
|
||||
return Task.FromResult(AuthenticateResult.Fail("Compte supprimé, désactivé ou suspendu."));
|
||||
}
|
||||
|
||||
PlayerDeviceRecord? device = null;
|
||||
if (session.Scope == PlayerScopes.Full)
|
||||
{
|
||||
device = session.DeviceId is { } deviceId ? store.FindDevice(deviceId) : null;
|
||||
if (device is null || device.AccountId != account.Id || account.MustChangePassword)
|
||||
{
|
||||
_sessions.Remove(session);
|
||||
return Task.FromResult(AuthenticateResult.Fail("Appareil déconnecté ou nouveau mot de passe exigé."));
|
||||
}
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, account.Id),
|
||||
new(ScopeClaim, session.Scope)
|
||||
};
|
||||
Context.Items[ContextKey] = new AuthenticatedPlayer(session, account, device);
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
|
||||
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName)));
|
||||
}
|
||||
|
||||
protected override Task HandleChallengeAsync(AuthenticationProperties properties) =>
|
||||
ApiResults.WriteAsync(Context, 401, ApiErrorCodes.Unauthorized, "Connexion au compte requise (session absente ou expirée).");
|
||||
|
||||
protected override Task HandleForbiddenAsync(AuthenticationProperties properties) =>
|
||||
ApiResults.WriteAsync(Context, 403, ApiErrorCodes.PasswordChangeRequired,
|
||||
"Choisissez d'abord un nouveau mot de passe.");
|
||||
}
|
||||
|
||||
public static class PlayerAuthorization
|
||||
{
|
||||
public const string PlayerPolicy = "player";
|
||||
public const string PasswordChangePolicy = "player-password-change";
|
||||
|
||||
/// <summary>Route réservée à un joueur connecté (session complète).</summary>
|
||||
public static TBuilder RequirePlayer<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.RequireAuthorization(PlayerPolicy);
|
||||
|
||||
/// <summary>Route ouverte à une session complète OU au jeton limité « changement de mot de passe exigé ».</summary>
|
||||
public static TBuilder RequirePlayerOrPasswordChange<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.RequireAuthorization(PasswordChangePolicy);
|
||||
|
||||
public static IServiceCollection AddPlayerAuthentication(this IServiceCollection services)
|
||||
{
|
||||
services.AddAuthentication()
|
||||
.AddScheme<AuthenticationSchemeOptions, PlayerSessionHandler>(PlayerSessionHandler.SchemeName, null);
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(PlayerPolicy, p => p.AddAuthenticationSchemes(PlayerSessionHandler.SchemeName).RequireAuthenticatedUser()
|
||||
.RequireClaim(PlayerSessionHandler.ScopeClaim, PlayerScopes.Full));
|
||||
options.AddPolicy(PasswordChangePolicy, p => p.AddAuthenticationSchemes(PlayerSessionHandler.SchemeName).RequireAuthenticatedUser()
|
||||
.RequireClaim(PlayerSessionHandler.ScopeClaim, PlayerScopes.Full, PlayerScopes.PasswordChange));
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Joueur authentifié de la requête (les routes protégées en ont toujours un).</summary>
|
||||
public static AuthenticatedPlayer Player(this HttpContext context) =>
|
||||
PlayerSessionHandler.Get(context) ?? throw new InvalidOperationException("Route non protégée par RequirePlayer.");
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Protections des routes ANONYMES des comptes joueurs (création, connexion, renouvellement) :
|
||||
/// • défis SANS ÉTAT (usage, échéance, difficulté et nonce scellés par HMAC, secret en mémoire, domaine propre aux
|
||||
/// joueurs) : en demander ne coûte rien au serveur ; seuls les défis utilisés sont mémorisés (refus du rejeu) ;
|
||||
/// • preuve de travail (création de compte, connexion) : coûteuse pour un robot, négligeable pour un joueur ;
|
||||
/// • compteurs en mémoire par adresse IP (IPv6 : /64) : créations de compte, échecs de connexion ;
|
||||
/// • blocage « miroir » des adresses inconnues : une adresse sans compte se bloque exactement comme un compte réel
|
||||
/// (la réponse « trop d'essais » ne révèle donc pas qu'un compte existe).
|
||||
/// </summary>
|
||||
public sealed class PlayerGuard
|
||||
{
|
||||
private const int NonceLength = 16;
|
||||
private const int MacLength = 16;
|
||||
private const int PayloadLength = 1 + 8 + 1 + NonceLength; // usage + échéance + difficulté + nonce
|
||||
private const int MaxTracked = 100_000;
|
||||
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Player/Challenge/v1\n");
|
||||
|
||||
private readonly byte[] _secret = RandomNumberGenerator.GetBytes(32);
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _used = new(StringComparer.Ordinal);
|
||||
private readonly SlidingCounters _registrations = new();
|
||||
private readonly SlidingCounters _failedLogins = new();
|
||||
private readonly Dictionary<string, (int Failed, int Level, DateTimeOffset? Until)> _unknown = new(StringComparer.Ordinal);
|
||||
private readonly LauncherApiOptions.PlayersSettings _settings;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public PlayerGuard(IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
{
|
||||
_settings = options.Value.Players;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Défis
|
||||
|
||||
public PlayerChallengeResponse CreateChallenge(string purpose, string serverId, string origin)
|
||||
{
|
||||
var code = PurposeCode(purpose);
|
||||
var difficulty = purpose switch
|
||||
{
|
||||
PlayerChallengePurposes.Register => _settings.RegisterProofDifficulty,
|
||||
PlayerChallengePurposes.Login => _settings.LoginProofDifficulty,
|
||||
_ => 0
|
||||
};
|
||||
var expires = _time.GetUtcNow() + TimeSpan.FromSeconds(_settings.ChallengeLifetimeSeconds);
|
||||
var payload = new byte[PayloadLength];
|
||||
payload[0] = code;
|
||||
BinaryPrimitives.WriteInt64BigEndian(payload.AsSpan(1), expires.ToUnixTimeSeconds());
|
||||
payload[9] = (byte)difficulty;
|
||||
RandomNumberGenerator.Fill(payload.AsSpan(10));
|
||||
|
||||
var id = new byte[PayloadLength + MacLength];
|
||||
payload.CopyTo(id, 0);
|
||||
Mac(payload).CopyTo(id, PayloadLength);
|
||||
return new PlayerChallengeResponse
|
||||
{
|
||||
ChallengeId = Base64Url.Encode(id),
|
||||
Nonce = Base64Url.Encode(payload.AsSpan(10)),
|
||||
Purpose = purpose,
|
||||
Difficulty = difficulty,
|
||||
ServerId = serverId,
|
||||
Origin = origin,
|
||||
ExpiresAt = DateTimeOffset.FromUnixTimeSeconds(expires.ToUnixTimeSeconds())
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie le défi (sceau, usage, échéance) et la preuve de travail, SANS le consommer. Renvoie le nonce (à vérifier
|
||||
/// dans la signature de l'appareil), ou null.
|
||||
/// </summary>
|
||||
public string? Validate(string? challengeId, string purpose, string? solution)
|
||||
{
|
||||
if (challengeId is not { Length: > 0 and <= 64 } || !Base64Url.TryDecode(challengeId, out var id) || id.Length != PayloadLength + MacLength)
|
||||
return null;
|
||||
var payload = id.AsSpan(0, PayloadLength);
|
||||
if (!CryptographicOperations.FixedTimeEquals(Mac(payload), id.AsSpan(PayloadLength))) return null;
|
||||
if (payload[0] != PurposeCode(purpose)) return null;
|
||||
var now = _time.GetUtcNow();
|
||||
var expires = DateTimeOffset.FromUnixTimeSeconds(BinaryPrimitives.ReadInt64BigEndian(payload[1..9]));
|
||||
if (expires <= now) return null;
|
||||
int difficulty = payload[9];
|
||||
if (purpose != PlayerChallengePurposes.Refresh)
|
||||
{
|
||||
// Difficulté scellée à la création ; jamais en dessous du minimum pour la création de compte et la connexion.
|
||||
if (difficulty < FeedbackProof.MinDifficulty || !FeedbackProof.Verify(payload[10..], difficulty, solution)) return null;
|
||||
}
|
||||
return Base64Url.Encode(payload[10..]);
|
||||
}
|
||||
|
||||
/// <summary>Consomme le défi (après vérification de la signature de l'appareil). Faux s'il a déjà servi (rejeu).</summary>
|
||||
public bool TryConsume(string challengeId)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
if (_used.Count >= MaxTracked)
|
||||
{
|
||||
foreach (var (key, until) in _used)
|
||||
if (until <= now) _used.TryRemove(key, out _);
|
||||
if (_used.Count >= MaxTracked) throw new StoreBusyException("Trop de défis récents.");
|
||||
}
|
||||
return _used.TryAdd(challengeId, now + TimeSpan.FromSeconds(_settings.ChallengeLifetimeSeconds + 5));
|
||||
}
|
||||
|
||||
private static byte PurposeCode(string purpose) => purpose switch
|
||||
{
|
||||
PlayerChallengePurposes.Register => 1,
|
||||
PlayerChallengePurposes.Login => 2,
|
||||
PlayerChallengePurposes.Refresh => 3,
|
||||
_ => throw new ArgumentException("Usage de défi inconnu.", nameof(purpose))
|
||||
};
|
||||
|
||||
private byte[] Mac(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var data = new byte[Domain.Length + payload.Length];
|
||||
Domain.CopyTo(data, 0);
|
||||
payload.CopyTo(data.AsSpan(Domain.Length));
|
||||
return HMACSHA256.HashData(_secret, data)[..MacLength];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Créations de compte par adresse IP
|
||||
|
||||
/// <summary>Compte une création de compte pour cette adresse. Faux (rien n'est compté) si la limite horaire ou
|
||||
/// quotidienne est atteinte ; <paramref name="retryAfter"/> : délai avant la prochaine place.</summary>
|
||||
public bool TryCountRegistration(string partition, out TimeSpan retryAfter) =>
|
||||
_registrations.TryAdd(partition, _time.GetUtcNow(),
|
||||
(TimeSpan.FromHours(1), _settings.RegistrationsPerIpPerHour), (TimeSpan.FromDays(1), _settings.RegistrationsPerIpPerDay),
|
||||
out retryAfter);
|
||||
|
||||
// ------------------------------------------------------------------ Échecs de connexion par adresse IP
|
||||
|
||||
/// <summary>Vrai si cette adresse a trop échoué récemment (toutes adresses e-mail confondues).</summary>
|
||||
public bool IsIpThrottled(string partition, out TimeSpan retryAfter) =>
|
||||
_failedLogins.IsFull(partition, _time.GetUtcNow(), TimeSpan.FromMinutes(15), _settings.FailedLoginsPerIpPer15Minutes, out retryAfter);
|
||||
|
||||
public void CountFailedLogin(string partition) =>
|
||||
_failedLogins.Add(partition, _time.GetUtcNow(), TimeSpan.FromMinutes(15));
|
||||
|
||||
// ------------------------------------------------------------------ Adresses inconnues (blocage miroir)
|
||||
|
||||
/// <summary>Fin du blocage d'une adresse SANS compte (même règle que pour un compte réel), ou null.</summary>
|
||||
public DateTimeOffset? UnknownLockedUntil(string emailIndex)
|
||||
{
|
||||
lock (_unknown)
|
||||
return _unknown.TryGetValue(emailIndex, out var state) && state.Until > _time.GetUtcNow() ? state.Until : null;
|
||||
}
|
||||
|
||||
/// <summary>Échec sur une adresse sans compte : compté comme pour un compte (sans suspension, qui n'est jamais
|
||||
/// révélée avant un mot de passe correct). Renvoie la fin du blocage déclenché, ou null.</summary>
|
||||
public DateTimeOffset? CountUnknownFailure(string emailIndex)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
lock (_unknown)
|
||||
{
|
||||
if (_unknown.Count >= MaxTracked)
|
||||
{
|
||||
foreach (var key in _unknown.Where(p => p.Value.Until is null || p.Value.Until <= now).Select(p => p.Key).ToList())
|
||||
_unknown.Remove(key);
|
||||
if (_unknown.Count >= MaxTracked) _unknown.Clear();
|
||||
}
|
||||
_unknown.TryGetValue(emailIndex, out var state);
|
||||
var failed = state.Failed + 1;
|
||||
var level = state.Level;
|
||||
DateTimeOffset? until = null;
|
||||
if (failed >= _settings.FailuresPerLock)
|
||||
{
|
||||
failed = 0;
|
||||
level++;
|
||||
until = now + Storage.Players.PlayerStore.LockDuration(_settings, level);
|
||||
}
|
||||
_unknown[emailIndex] = (failed, level, until ?? state.Until);
|
||||
return until;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Compteurs en mémoire sur des fenêtres glissantes (aucune adresse n'est écrite sur le disque).</summary>
|
||||
private sealed class SlidingCounters
|
||||
{
|
||||
private readonly Dictionary<string, List<DateTimeOffset>> _events = new(StringComparer.Ordinal);
|
||||
|
||||
public bool TryAdd(string key, DateTimeOffset now, (TimeSpan Window, int Max) first, (TimeSpan Window, int Max) second,
|
||||
out TimeSpan retryAfter)
|
||||
{
|
||||
lock (_events)
|
||||
{
|
||||
var times = Get(key, now, second.Window);
|
||||
retryAfter = TimeSpan.Zero;
|
||||
foreach (var (window, max) in new[] { first, second })
|
||||
{
|
||||
var recent = times.Where(t => now - t < window).OrderBy(t => t).ToList();
|
||||
if (recent.Count >= max)
|
||||
{
|
||||
retryAfter = recent[recent.Count - max] + window - now;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
times.Add(now);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsFull(string key, DateTimeOffset now, TimeSpan window, int max, out TimeSpan retryAfter)
|
||||
{
|
||||
lock (_events)
|
||||
{
|
||||
retryAfter = TimeSpan.Zero;
|
||||
if (!_events.TryGetValue(key, out var times)) return false;
|
||||
var recent = times.Where(t => now - t < window).OrderBy(t => t).ToList();
|
||||
if (recent.Count < max) return false;
|
||||
retryAfter = recent[recent.Count - max] + window - now;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Add(string key, DateTimeOffset now, TimeSpan window)
|
||||
{
|
||||
lock (_events) Get(key, now, window).Add(now);
|
||||
}
|
||||
|
||||
private List<DateTimeOffset> Get(string key, DateTimeOffset now, TimeSpan keep)
|
||||
{
|
||||
if (_events.Count >= MaxTracked)
|
||||
{
|
||||
foreach (var stale in _events.Where(p => p.Value.All(t => now - t >= keep)).Select(p => p.Key).ToList())
|
||||
_events.Remove(stale);
|
||||
if (_events.Count >= MaxTracked) throw new StoreBusyException("Trop d'adresses suivies.");
|
||||
}
|
||||
if (!_events.TryGetValue(key, out var times)) _events[key] = times = new List<DateTimeOffset>();
|
||||
times.RemoveAll(t => now - t >= keep);
|
||||
return times;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Portée d'un jeton joueur.</summary>
|
||||
public static class PlayerScopes
|
||||
{
|
||||
/// <summary>Session complète (jeton « glpa_ »).</summary>
|
||||
public const string Full = "full";
|
||||
|
||||
/// <summary>Seulement le changement de mot de passe exigé (jeton « glpc_ »).</summary>
|
||||
public const string PasswordChange = "password_change";
|
||||
}
|
||||
|
||||
/// <summary>Jeton joueur valide (en mémoire uniquement : seule l'empreinte du jeton est gardée).</summary>
|
||||
public sealed class PlayerAccessSession
|
||||
{
|
||||
public required string TokenHash { get; init; }
|
||||
public required string Scope { get; init; }
|
||||
public required string AccountId { get; init; }
|
||||
|
||||
/// <summary>Appareil de la session (portée complète).</summary>
|
||||
public string? DeviceId { get; init; }
|
||||
|
||||
/// <summary>Clé publique et nom de l'appareil (portée « changement exigé » : l'appareil est enregistré une fois le
|
||||
/// mot de passe changé).</summary>
|
||||
public string? DevicePublicKey { get; init; }
|
||||
public string? DeviceKeyId { get; init; }
|
||||
public string? DeviceLabel { get; init; }
|
||||
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Jetons d'accès des joueurs, en mémoire (un redémarrage du service les efface : le launcher renouvelle sa session avec
|
||||
/// son jeton de renouvellement, sans rien demander au joueur). Totalement séparés des sessions des administrateurs
|
||||
/// (<see cref="SessionStore"/>) : autre stockage, autre préfixe, autre schéma d'authentification.
|
||||
/// </summary>
|
||||
public sealed class PlayerSessionStore
|
||||
{
|
||||
private const int MaxSessions = 200_000;
|
||||
private static readonly TimeSpan PasswordChangeLifetime = TimeSpan.FromMinutes(10);
|
||||
|
||||
private readonly ConcurrentDictionary<string, PlayerAccessSession> _byHash = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _time;
|
||||
private readonly LauncherApiOptions.PlayersSettings _settings;
|
||||
|
||||
public PlayerSessionStore(IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
{
|
||||
_time = time;
|
||||
_settings = options.Value.Players;
|
||||
}
|
||||
|
||||
public int Count => _byHash.Count;
|
||||
|
||||
/// <summary>Jeton d'accès d'une session complète sur un appareil.</summary>
|
||||
public (string Token, PlayerAccessSession Session) CreateAccess(string accountId, string deviceId)
|
||||
{
|
||||
var token = PlayerTokens.AccessPrefix + PlayerTokens.NewSecret();
|
||||
var session = new PlayerAccessSession
|
||||
{
|
||||
TokenHash = Hash(token),
|
||||
Scope = PlayerScopes.Full,
|
||||
AccountId = accountId,
|
||||
DeviceId = deviceId,
|
||||
ExpiresAt = _time.GetUtcNow() + TimeSpan.FromMinutes(_settings.AccessTokenMinutes)
|
||||
};
|
||||
Add(session);
|
||||
return (token, session);
|
||||
}
|
||||
|
||||
/// <summary>Jeton limité au changement de mot de passe exigé (10 min), qui retient l'appareil à enregistrer ensuite.</summary>
|
||||
public (string Token, PlayerAccessSession Session) CreatePasswordChange(string accountId, string devicePublicKey, string deviceKeyId,
|
||||
string deviceLabel)
|
||||
{
|
||||
var token = PlayerTokens.PasswordChangePrefix + PlayerTokens.NewSecret();
|
||||
var session = new PlayerAccessSession
|
||||
{
|
||||
TokenHash = Hash(token),
|
||||
Scope = PlayerScopes.PasswordChange,
|
||||
AccountId = accountId,
|
||||
DevicePublicKey = devicePublicKey,
|
||||
DeviceKeyId = deviceKeyId,
|
||||
DeviceLabel = deviceLabel,
|
||||
ExpiresAt = _time.GetUtcNow() + PasswordChangeLifetime
|
||||
};
|
||||
Add(session);
|
||||
return (token, session);
|
||||
}
|
||||
|
||||
/// <summary>Session du jeton, si elle est encore valide.</summary>
|
||||
public PlayerAccessSession? Find(string? token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token) || token.Length > PlayerTokens.MaxLength) return null;
|
||||
if (!token.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) &&
|
||||
!token.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal))
|
||||
return null;
|
||||
var hash = Hash(token);
|
||||
if (!_byHash.TryGetValue(hash, out var session)) return null;
|
||||
if (session.ExpiresAt <= _time.GetUtcNow())
|
||||
{
|
||||
_byHash.TryRemove(hash, out _);
|
||||
return null;
|
||||
}
|
||||
// Le préfixe doit correspondre à la portée (un jeton ne change jamais de portée).
|
||||
var expectedPrefix = session.Scope == PlayerScopes.Full ? PlayerTokens.AccessPrefix : PlayerTokens.PasswordChangePrefix;
|
||||
return token.StartsWith(expectedPrefix, StringComparison.Ordinal) ? session : null;
|
||||
}
|
||||
|
||||
public void Remove(PlayerAccessSession session) => _byHash.TryRemove(session.TokenHash, out _);
|
||||
|
||||
/// <summary>Ferme toutes les sessions d'un compte (ou d'appareils donnés).</summary>
|
||||
public int RemoveWhere(Func<PlayerAccessSession, bool> predicate)
|
||||
{
|
||||
var removed = 0;
|
||||
foreach (var (hash, session) in _byHash)
|
||||
if (predicate(session) && _byHash.TryRemove(hash, out _)) removed++;
|
||||
return removed;
|
||||
}
|
||||
|
||||
public int RemoveDevices(IEnumerable<string> deviceIds)
|
||||
{
|
||||
var set = deviceIds.ToHashSet(StringComparer.Ordinal);
|
||||
return set.Count == 0 ? 0 : RemoveWhere(s => s.DeviceId is { } id && set.Contains(id));
|
||||
}
|
||||
|
||||
private void Add(PlayerAccessSession session)
|
||||
{
|
||||
if (_byHash.Count >= MaxSessions)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
RemoveWhere(s => s.ExpiresAt <= now);
|
||||
if (_byHash.Count >= MaxSessions) throw new StoreBusyException("Trop de sessions de joueurs ouvertes.");
|
||||
}
|
||||
_byHash[session.TokenHash] = session;
|
||||
}
|
||||
|
||||
internal static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>Changement d'un type de demande lors d'une publication des formulaires, pour le journal d'audit.</summary>
|
||||
/// <param name="Action">Action d'audit (<see cref="FeedbackFormsAudit.Created"/>, <see cref="FeedbackFormsAudit.Updated"/>…).</param>
|
||||
/// <param name="Name">Nom du type tel qu'affiché aux joueurs (après publication, ou avant suppression).</param>
|
||||
/// <param name="Details">Détail de chaque changement, en clair.</param>
|
||||
public sealed record FeedbackFormChange(string Action, string Name, IReadOnlyList<string> Details);
|
||||
|
||||
/// <summary>
|
||||
/// Détail d'une publication des formulaires pour le journal d'audit : une ligne « feedback.forms » (révision, résumé,
|
||||
/// signature), puis une ligne par type de demande ajouté, modifié ou supprimé, avec son nom et TOUS ses changements
|
||||
/// (présentation, réglages, chaque champ ajouté, supprimé ou modifié, et ce qui a changé dans ce champ).
|
||||
/// Un champ d'audit étant borné (<see cref="AuditLog"/>), un détail trop long est réparti sur plusieurs lignes
|
||||
/// numérotées (1/2, 2/2…) plutôt que tronqué.
|
||||
/// </summary>
|
||||
public static class FeedbackFormsAudit
|
||||
{
|
||||
public const string Published = "feedback.forms";
|
||||
public const string Created = "feedback.form_create";
|
||||
public const string Updated = "feedback.form_update";
|
||||
public const string Deleted = "feedback.form_delete";
|
||||
|
||||
/// <summary>Longueur au plus d'un détail d'audit (le journal tronque à 500 caractères).</summary>
|
||||
internal const int MaxDetailsLength = 480;
|
||||
|
||||
/// <summary>Valeur montrée en entier dans un changement (« « avant » → « après » ») jusqu'à cette longueur.</summary>
|
||||
private const int MaxShownValueLength = 80;
|
||||
|
||||
private const string Separator = " ; ";
|
||||
|
||||
/// <summary>Types ajoutés (dans l'ordre publié), modifiés, puis supprimés.</summary>
|
||||
public static IReadOnlyList<FeedbackFormChange> Changes(FeedbackFormsDocument before, FeedbackFormsDocument after)
|
||||
{
|
||||
var changes = new List<FeedbackFormChange>();
|
||||
foreach (var form in after.Forms)
|
||||
{
|
||||
var old = before.Find(form.Kind);
|
||||
if (old is null) changes.Add(new FeedbackFormChange(Created, form.Label, CreatedDetails(form)));
|
||||
else if (FormDetails(old, form) is { Count: > 0 } details) changes.Add(new FeedbackFormChange(Updated, form.Label, details));
|
||||
}
|
||||
foreach (var form in before.Forms.Where(f => after.Find(f.Kind) is null))
|
||||
changes.Add(new FeedbackFormChange(Deleted, form.Label,
|
||||
new[] { "plus proposé aux joueurs (les demandes déjà reçues de ce type restent consultables)" }));
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// <summary>Résumé de la ligne « feedback.forms » : types ajoutés, modifiés, supprimés (par leur nom), ordre des types.</summary>
|
||||
public static string Summary(FeedbackFormsDocument before, FeedbackFormsDocument after, IReadOnlyList<FeedbackFormChange> changes)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
AddGroup(parts, changes, Created, "type ajouté", "types ajoutés");
|
||||
AddGroup(parts, changes, Updated, "type modifié", "types modifiés");
|
||||
AddGroup(parts, changes, Deleted, "type supprimé", "types supprimés");
|
||||
var kept = after.Forms.Select(f => f.Kind).Where(k => before.Find(k) is not null).ToList();
|
||||
if (!before.Forms.Select(f => f.Kind).Where(kept.Contains).SequenceEqual(kept)) parts.Add("ordre des types modifié");
|
||||
return parts.Count == 0 ? "aucun changement" : string.Join(Separator, parts);
|
||||
}
|
||||
|
||||
private static void AddGroup(List<string> parts, IReadOnlyList<FeedbackFormChange> changes, string action, string one, string many)
|
||||
{
|
||||
var names = changes.Where(c => c.Action == action).Select(c => Quote(c.Name)).ToList();
|
||||
if (names.Count > 0) parts.Add($"{names.Count} {(names.Count == 1 ? one : many)} : {string.Join(", ", names)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Détails d'un changement répartis en lignes d'audit d'au plus <paramref name="maxLength"/> caractères, numérotées
|
||||
/// s'il en faut plusieurs (aucun détail n'est perdu, un détail démesuré est coupé entre deux lignes).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> Lines(IReadOnlyList<string> details, int maxLength = MaxDetailsLength)
|
||||
{
|
||||
// Place réservée au numéro « (12/12) ».
|
||||
var room = Math.Max(20, maxLength - 8);
|
||||
var lines = new List<string>();
|
||||
var current = "";
|
||||
foreach (var detail in details.SelectMany(d => Split(d, room)))
|
||||
{
|
||||
if (current.Length == 0) current = detail;
|
||||
else if (current.Length + Separator.Length + detail.Length <= room) current += Separator + detail;
|
||||
else
|
||||
{
|
||||
lines.Add(current);
|
||||
current = detail;
|
||||
}
|
||||
}
|
||||
if (current.Length > 0) lines.Add(current);
|
||||
return lines.Count <= 1 ? lines : lines.Select((line, i) => $"({i + 1}/{lines.Count}) {line}").ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Split(string text, int room)
|
||||
{
|
||||
for (var i = 0; i < text.Length; i += room)
|
||||
yield return text.Substring(i, Math.Min(room, text.Length - i));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Types de demande
|
||||
|
||||
private static List<string> CreatedDetails(FeedbackForm form)
|
||||
{
|
||||
var details = new List<string>();
|
||||
if (form.Hidden) details.Add("masqué (pas encore proposé aux joueurs)");
|
||||
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »" : "icône importée");
|
||||
if (!form.AllowAttachments) details.Add("captures d'écran non proposées");
|
||||
if (form.TechByDefault) details.Add("« Joindre les informations techniques » coché par défaut");
|
||||
details.Add($"{form.Fields.Count} champ(s)");
|
||||
details.AddRange(form.Fields.Select(f => $"champ « {f.Label} » ({FieldSummary(f)})"));
|
||||
return details;
|
||||
}
|
||||
|
||||
private static List<string> FormDetails(FeedbackForm old, FeedbackForm form)
|
||||
{
|
||||
var details = new List<string>();
|
||||
if (old.Label != form.Label) details.Add($"nom affiché : {Quote(old.Label)} → {Quote(form.Label)}");
|
||||
if (old.Hidden != form.Hidden) details.Add(form.Hidden ? "masqué (plus proposé aux joueurs)" : "de nouveau proposé aux joueurs");
|
||||
if (old.Description != form.Description) details.Add(ValueChange("description", feminine: true, old.Description, form.Description));
|
||||
if (old.TitleHint != form.TitleHint) details.Add(ValueChange("exemple de titre", feminine: false, old.TitleHint, form.TitleHint));
|
||||
if (old.TechByDefault != form.TechByDefault)
|
||||
details.Add($"« Joindre les informations techniques » {(form.TechByDefault ? "coché" : "décoché")} par défaut");
|
||||
if (old.CustomIcon != form.CustomIcon)
|
||||
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »"
|
||||
: old.CustomIcon is null ? "icône importée" : "icône importée remplacée");
|
||||
else if (form.CustomIcon is null && old.IconKey != form.IconKey)
|
||||
details.Add($"icône : « {old.IconKey} » → « {form.IconKey} »");
|
||||
if (old.AllowAttachments != form.AllowAttachments)
|
||||
details.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
|
||||
|
||||
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var field in old.Fields.Where(f => !newIds.Contains(f.Id)))
|
||||
details.Add($"champ supprimé : « {field.Label} »");
|
||||
foreach (var field in form.Fields)
|
||||
{
|
||||
if (old.Field(field.Id) is not { } before)
|
||||
{
|
||||
details.Add($"champ ajouté : « {field.Label} » ({FieldSummary(field)})");
|
||||
continue;
|
||||
}
|
||||
var parts = FieldDetails(before, field);
|
||||
if (parts.Count == 0) continue;
|
||||
var prefix = $"champ « {field.Label} » modifié : ";
|
||||
var combined = prefix + string.Join(", ", parts);
|
||||
// Champ très modifié (choix nombreux) : un détail par changement, chacun avec le nom du champ.
|
||||
if (combined.Length <= MaxDetailsLength / 2) details.Add(combined);
|
||||
else details.AddRange(parts.Select(p => prefix + p));
|
||||
}
|
||||
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
|
||||
details.Add("ordre des champs modifié");
|
||||
return details;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Champs
|
||||
|
||||
private static List<string> FieldDetails(FeedbackField old, FeedbackField field)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (old.Label != field.Label) parts.Add($"libellé {Quote(old.Label)} → {Quote(field.Label)}");
|
||||
if (old.Kind != field.Kind) parts.Add($"type {KindText(old.Kind)} → {KindText(field.Kind)}");
|
||||
if (old.Required != field.Required) parts.Add(field.Required ? "rendu obligatoire" : "rendu facultatif");
|
||||
if ((old.Hint ?? "") != (field.Hint ?? ""))
|
||||
parts.Add(string.IsNullOrEmpty(old.Hint) ? "aide ajoutée" : string.IsNullOrEmpty(field.Hint) ? "aide retirée" : "aide modifiée");
|
||||
if (field.IsText && old.MaxLength != field.MaxLength) parts.Add($"longueur maximale {old.MaxLength} → {field.MaxLength}");
|
||||
if (field.Kind == FeedbackFieldKind.Rating && old.RatingMax != field.RatingMax)
|
||||
parts.Add($"étoiles {old.RatingMax} → {field.RatingMax}");
|
||||
if (old.ShowInList != field.ShowInList)
|
||||
parts.Add(field.ShowInList ? "affiché en aperçu dans la liste des demandes" : "retiré de l'aperçu de la liste des demandes");
|
||||
parts.AddRange(OptionDetails(old.Choices, field.Choices));
|
||||
// Filet : tout autre écart (nouveau réglage d'une version future) reste signalé.
|
||||
if (parts.Count == 0 && JsonSerializer.Serialize(old) != JsonSerializer.Serialize(field)) parts.Add("réglages modifiés");
|
||||
return parts;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> OptionDetails(IReadOnlyList<FeedbackOption> old, IReadOnlyList<FeedbackOption> now)
|
||||
{
|
||||
var oldIds = old.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var newIds = now.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var option in now.Where(o => !oldIds.Contains(o.Id))) yield return $"choix ajouté « {option.Label} »";
|
||||
foreach (var option in old.Where(o => !newIds.Contains(o.Id))) yield return $"choix retiré « {option.Label} »";
|
||||
foreach (var option in now)
|
||||
if (old.FirstOrDefault(o => o.Id == option.Id) is { } before && before.Label != option.Label)
|
||||
yield return $"choix renommé « {before.Label} » → « {option.Label} »";
|
||||
if (!old.Select(o => o.Id).Where(newIds.Contains).SequenceEqual(now.Select(o => o.Id).Where(oldIds.Contains)))
|
||||
yield return "ordre des choix modifié";
|
||||
}
|
||||
|
||||
private static string FieldSummary(FeedbackField field)
|
||||
{
|
||||
var parts = new List<string> { KindText(field.Kind), field.Required ? "obligatoire" : "facultatif" };
|
||||
if (field.Kind == FeedbackFieldKind.Rating) parts.Add($"{field.RatingMax} étoiles");
|
||||
if (field.Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes) parts.Add($"{field.Choices.Count} choix");
|
||||
if (field.ShowInList) parts.Add("en aperçu dans la liste");
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
|
||||
private static string KindText(FeedbackFieldKind kind) => kind switch
|
||||
{
|
||||
FeedbackFieldKind.Multiline => "zone de texte",
|
||||
FeedbackFieldKind.Text => "texte court",
|
||||
FeedbackFieldKind.Choice => "liste de choix",
|
||||
FeedbackFieldKind.Checkboxes => "cases à cocher",
|
||||
FeedbackFieldKind.Rating => "note en étoiles",
|
||||
FeedbackFieldKind.YesNo => "case oui/non",
|
||||
_ => kind.ToString()
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ Texte
|
||||
|
||||
/// <summary>« nom : « avant » → « après » », ou « nom modifié » si l'une des valeurs est longue.</summary>
|
||||
private static string ValueChange(string name, bool feminine, string? before, string? after)
|
||||
{
|
||||
var e = feminine ? "e" : "";
|
||||
before = OneLine(before);
|
||||
after = OneLine(after);
|
||||
if (before.Length == 0) return $"{name} ajouté{e} : {Quote(after)}";
|
||||
if (after.Length == 0) return $"{name} retiré{e}";
|
||||
return before.Length <= MaxShownValueLength && after.Length <= MaxShownValueLength
|
||||
? $"{name} : {Quote(before)} → {Quote(after)}"
|
||||
: $"{name} modifié{e}";
|
||||
}
|
||||
|
||||
private static string OneLine(string? value) =>
|
||||
string.Join(' ', (value ?? "").Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
|
||||
private static string Quote(string? value) => $"« {OneLine(value)} »";
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Server.Hosting;
|
||||
@@ -143,47 +142,4 @@ public sealed class FeedbackFormsStore
|
||||
// L'historique est une aide : son échec n'annule pas la publication.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Résumé des changements pour l'audit (types ajoutés, supprimés, réordonnés, masqués ou réaffichés ; champs ajoutés,
|
||||
/// retirés, modifiés ; captures d'écran, icône).
|
||||
/// </summary>
|
||||
public static string Describe(FeedbackFormsDocument before, FeedbackFormsDocument after)
|
||||
{
|
||||
var changes = new List<string>();
|
||||
var added = after.Forms.Where(f => before.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
|
||||
var removed = before.Forms.Where(f => after.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
|
||||
if (added.Count > 0) changes.Add($"type(s) ajouté(s) : {string.Join(", ", added)}");
|
||||
if (removed.Count > 0) changes.Add($"type(s) supprimé(s) : {string.Join(", ", removed)}");
|
||||
var kept = after.Forms.Select(f => f.Kind).Where(k => before.Find(k) is not null).ToList();
|
||||
if (!before.Forms.Select(f => f.Kind).Where(kept.Contains).SequenceEqual(kept)) changes.Add("ordre des types modifié");
|
||||
foreach (var form in after.Forms)
|
||||
{
|
||||
var old = before.Find(form.Kind);
|
||||
if (old is null) continue;
|
||||
var parts = new List<string>();
|
||||
if (old.Hidden != form.Hidden) parts.Add(form.Hidden ? "masqué" : "de nouveau proposé");
|
||||
if (old.Label != form.Label || old.Description != form.Description || old.TitleHint != form.TitleHint ||
|
||||
old.TechByDefault != form.TechByDefault)
|
||||
parts.Add("présentation modifiée");
|
||||
if (old.IconKey != form.IconKey || old.CustomIcon != form.CustomIcon)
|
||||
parts.Add(form.CustomIcon is null ? "icône d'origine" : "icône importée");
|
||||
if (old.AllowAttachments != form.AllowAttachments)
|
||||
parts.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
|
||||
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var addedFields = newIds.Count(id => !oldIds.Contains(id));
|
||||
var removedFields = oldIds.Count(id => !newIds.Contains(id));
|
||||
var changed = form.Fields.Count(f => old.Field(f.Id) is { } o &&
|
||||
JsonSerializer.Serialize(o) != JsonSerializer.Serialize(f));
|
||||
if (addedFields > 0) parts.Add($"+{addedFields} champ(s)");
|
||||
if (removedFields > 0) parts.Add($"-{removedFields} champ(s)");
|
||||
if (changed > 0) parts.Add($"{changed} champ(s) modifié(s)");
|
||||
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
|
||||
parts.Add("ordre modifié");
|
||||
var name = old.Label == form.Label ? old.Label : $"{old.Label} → « {form.Label} »";
|
||||
if (parts.Count > 0) changes.Add($"{name} : {string.Join(", ", parts)}");
|
||||
}
|
||||
return changes.Count == 0 ? "aucun changement" : string.Join(" ; ", changes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance du launcher (commun à tous les jeux), activé ou désactivé depuis le menu d'administration.
|
||||
/// • Référence : maintenance.json dans les données privées (+ historique des 20 dernières révisions) ;
|
||||
/// • Publication : copie dans le fichier PUBLIC servi par nginx, à côté du trousseau (les launchers en déduisent
|
||||
/// l'adresse), écrite atomiquement, lisible par tous ;
|
||||
/// • Toujours signé (autorisation « maintenance » du trousseau) : le serveur vérifie que la signature est celle de la
|
||||
/// session et que le document respecte les règles, le launcher vérifie la signature avec le trousseau.
|
||||
/// Rien de publié : maintenance inactive (révision 0).
|
||||
/// SQLite n'est pas utilisé : un seul petit document, modifié rarement, à publier de toute façon en fichier.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceStore
|
||||
{
|
||||
public const string FileName = "maintenance.json";
|
||||
private const string HistoryFolder = "maintenance-history";
|
||||
private const int HistoryLength = 20;
|
||||
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string _path;
|
||||
private readonly string _lockPath;
|
||||
private readonly string _historyDirectory;
|
||||
private readonly string? _publicFile;
|
||||
private readonly object _lock = new();
|
||||
private MaintenanceDocument? _current;
|
||||
|
||||
public MaintenanceStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.DataDirectoryFullPath, options.Value.MaintenancePublicFileFullPath, options.Value.MaintenancePublicUrl) { }
|
||||
|
||||
public MaintenanceStore(string dataDirectory, string? publicFile, string? publicUrl = null)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
_path = Path.Combine(dataDirectory, FileName);
|
||||
_lockPath = _path + ".lock";
|
||||
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
|
||||
_publicFile = publicFile;
|
||||
PublicUrl = publicUrl;
|
||||
}
|
||||
|
||||
public bool PublishingEnabled => _publicFile is not null;
|
||||
|
||||
public string? PublicUrl { get; }
|
||||
|
||||
/// <summary>État en vigueur : celui publié, sinon maintenance inactive (révision 0).</summary>
|
||||
/// <exception cref="InvalidDataException">Fichier de référence illisible.</exception>
|
||||
public MaintenanceDocument Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock) return _current ??= Read() ?? MaintenanceRules.Defaults();
|
||||
}
|
||||
}
|
||||
|
||||
private MaintenanceDocument? Read()
|
||||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
try
|
||||
{
|
||||
return MaintenanceRules.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
}
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException($"{FileName} illisible : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publie un document signé (signature déjà contrôlée par l'appelant) : règles complètes, révision attendue (409 si
|
||||
/// une autre publication a eu lieu), révision suivante obligatoire. Pendant une maintenance, le début annoncé ne
|
||||
/// change pas (modification du message seulement).
|
||||
/// </summary>
|
||||
/// <returns>Document publié (avec la date) et celui d'avant.</returns>
|
||||
public (MaintenanceDocument Published, MaintenanceDocument Previous) Publish(MaintenanceDocument document, long baseRevision, DateTimeOffset now)
|
||||
{
|
||||
if (_publicFile is null)
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Publication du mode maintenance non configurée : le fichier public du trousseau (LauncherApi:Keyring:PublicFile, ou " +
|
||||
"LauncherApi:Catalog:PublicFile) est nécessaire, le mode maintenance est publié à côté.");
|
||||
if (document.Signature is null || string.IsNullOrEmpty(document.Signature.Value))
|
||||
throw new ApiRejection(400, ApiErrorCodes.SignatureRequired, "Mode maintenance non signé.");
|
||||
var errors = new List<string>();
|
||||
if (!MaintenanceRules.Validate(document, errors, now))
|
||||
throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
|
||||
|
||||
lock (_lock)
|
||||
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
|
||||
{
|
||||
var previous = Read() ?? MaintenanceRules.Defaults();
|
||||
if (baseRevision != previous.Revision)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Le mode maintenance a été modifié entre-temps (révision {previous.Revision}) : rechargez la page, puis recommencez.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
if (document.Revision != previous.Revision + 1)
|
||||
throw ApiRejection.BadRequest($"Révision invalide : {previous.Revision + 1} attendue.");
|
||||
if (document.Enabled && previous.Enabled && document.Since != previous.Since)
|
||||
throw ApiRejection.BadRequest("Maintenance déjà en cours : sa date de début ne change pas (seul le message peut être modifié).");
|
||||
|
||||
var published = document with { UpdatedAt = DateTimeOffset.FromUnixTimeSeconds(now.ToUnixTimeSeconds()) };
|
||||
var json = MaintenanceRules.Serialize(published);
|
||||
if (Encoding.UTF8.GetByteCount(json) > MaintenanceRules.MaxBytes)
|
||||
throw ApiRejection.BadRequest($"Mode maintenance trop volumineux (au plus {MaintenanceRules.MaxBytes / 1024} Ko).");
|
||||
|
||||
// Public d'abord : en cas d'échec, la référence privée (qui porte la révision) reste l'ancienne.
|
||||
PublicFileWriter.Write(_publicFile, json, "fichier public du mode maintenance");
|
||||
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
|
||||
WriteHistory(published.Revision, json);
|
||||
_current = published;
|
||||
return (published, previous);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteHistory(long revision, string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_historyDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"maintenance-{revision:D6}.json"), json, keepBackup: false);
|
||||
foreach (var old in Directory.GetFiles(_historyDirectory, "maintenance-*.json").OrderByDescending(f => f, StringComparer.Ordinal)
|
||||
.Skip(HistoryLength))
|
||||
File.Delete(old);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// L'historique est une aide : son échec n'annule pas la publication.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Konscious.Security.Cryptography;
|
||||
|
||||
namespace GameLauncher.Server.Storage.Players;
|
||||
|
||||
/// <summary>
|
||||
/// Empreintes des mots de passe des joueurs : Argon2id (RFC 9106, cité par la CNIL et l'OWASP), sel aléatoire de 128 bits
|
||||
/// par compte, et « poivre » = clé secrète d'Argon2 dérivée de la clé maîtresse (hors du dossier des données). Format PHC :
|
||||
/// <c>$argon2id$v=19$m=19456,t=2,p=1,k=1$<sel>$<empreinte></c> (k : version du poivre). Les paramètres sont
|
||||
/// gardés avec l'empreinte : un relèvement des réglages s'applique à la connexion suivante de chaque joueur.
|
||||
///
|
||||
/// Les calculs (≈ 19 Mio de mémoire chacun) passent par une file bornée : un afflux de connexions ne peut pas épuiser
|
||||
/// la mémoire ou le processeur du VPS (les requêtes en trop attendent, puis reçoivent 503).
|
||||
/// </summary>
|
||||
public sealed class PlayerPasswordHasher
|
||||
{
|
||||
private const int SaltLength = 16;
|
||||
private const int HashLength = 32;
|
||||
private const string Prefix = "$argon2id$v=19$";
|
||||
private static readonly TimeSpan QueueTimeout = TimeSpan.FromSeconds(20);
|
||||
|
||||
private readonly PlayerSecrets _secrets;
|
||||
private readonly LauncherApiOptions.PlayersSettings _settings;
|
||||
private readonly SemaphoreSlim _slots;
|
||||
|
||||
public PlayerPasswordHasher(PlayerSecrets secrets, LauncherApiOptions.PlayersSettings settings)
|
||||
{
|
||||
_secrets = secrets;
|
||||
_settings = settings;
|
||||
_slots = new SemaphoreSlim(settings.MaxConcurrentHashes, settings.MaxConcurrentHashes);
|
||||
}
|
||||
|
||||
/// <summary>Empreinte d'un nouveau mot de passe (UTF-8 du mot de passe tel que saisi).</summary>
|
||||
public async Task<string> HashAsync(string password, CancellationToken ct = default)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltLength);
|
||||
var m = _settings.HashMemoryKib;
|
||||
var t = _settings.HashIterations;
|
||||
var p = _settings.HashParallelism;
|
||||
var hash = await ComputeAsync(password, salt, m, t, p, ct);
|
||||
return string.Create(CultureInfo.InvariantCulture,
|
||||
$"{Prefix}m={m},t={t},p={p},k={PlayerSecrets.KeyVersion}${Base64Url.Encode(salt)}${Base64Url.Encode(hash)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie un mot de passe. <c>NeedsRehash</c> : empreinte calculée avec d'anciens réglages (à remplacer après une
|
||||
/// connexion réussie). Une empreinte illisible ne correspond à rien.
|
||||
/// </summary>
|
||||
public async Task<(bool Valid, bool NeedsRehash)> VerifyAsync(string password, string stored, CancellationToken ct = default)
|
||||
{
|
||||
if (!TryParse(stored, out var m, out var t, out var p, out var salt, out var expected))
|
||||
{
|
||||
await DummyAsync(password, ct);
|
||||
return (false, false);
|
||||
}
|
||||
var actual = await ComputeAsync(password, salt, m, t, p, ct);
|
||||
var valid = CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
var rehash = valid && (m != _settings.HashMemoryKib || t != _settings.HashIterations || p != _settings.HashParallelism);
|
||||
return (valid, rehash);
|
||||
}
|
||||
|
||||
/// <summary>Même calcul pour une adresse inconnue : la durée de réponse ne révèle pas si le compte existe.</summary>
|
||||
public Task DummyAsync(string password, CancellationToken ct = default) =>
|
||||
ComputeAsync(password, RandomNumberGenerator.GetBytes(SaltLength), _settings.HashMemoryKib, _settings.HashIterations,
|
||||
_settings.HashParallelism, ct);
|
||||
|
||||
private async Task<byte[]> ComputeAsync(string password, byte[] salt, int memoryKib, int iterations, int parallelism, CancellationToken ct)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(password);
|
||||
if (!await _slots.WaitAsync(QueueTimeout, ct))
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(bytes);
|
||||
throw new StoreBusyException("Trop de calculs de mots de passe en attente.");
|
||||
}
|
||||
try
|
||||
{
|
||||
using var argon2 = new Argon2id(bytes)
|
||||
{
|
||||
Salt = salt,
|
||||
KnownSecret = _secrets.PasswordPepper,
|
||||
MemorySize = memoryKib,
|
||||
Iterations = iterations,
|
||||
DegreeOfParallelism = parallelism
|
||||
};
|
||||
return argon2.GetBytes(HashLength);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_slots.Release();
|
||||
CryptographicOperations.ZeroMemory(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParse(string stored, out int m, out int t, out int p, out byte[] salt, out byte[] hash)
|
||||
{
|
||||
m = t = p = 0;
|
||||
salt = hash = Array.Empty<byte>();
|
||||
if (string.IsNullOrEmpty(stored) || !stored.StartsWith(Prefix, StringComparison.Ordinal)) return false;
|
||||
var parts = stored[Prefix.Length..].Split('$');
|
||||
if (parts.Length != 3) return false;
|
||||
int k = 0;
|
||||
foreach (var pair in parts[0].Split(','))
|
||||
{
|
||||
var eq = pair.IndexOf('=');
|
||||
if (eq <= 0 || !int.TryParse(pair[(eq + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out var value)) return false;
|
||||
switch (pair[..eq])
|
||||
{
|
||||
case "m": m = value; break;
|
||||
case "t": t = value; break;
|
||||
case "p": p = value; break;
|
||||
case "k": k = value; break;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
if (m is < 8 or > 4_194_304 || t is < 1 or > 100 || p is < 1 or > 64 || k != PlayerSecrets.KeyVersion) return false;
|
||||
return Base64Url.TryDecode(parts[1], out salt) && salt.Length == SaltLength &&
|
||||
Base64Url.TryDecode(parts[2], out hash) && hash.Length == HashLength;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
namespace GameLauncher.Server.Storage.Players;
|
||||
|
||||
/// <summary>États d'un compte joueur.</summary>
|
||||
public static class PlayerStatus
|
||||
{
|
||||
public const string Active = "active";
|
||||
|
||||
/// <summary>Désactivé par un administrateur (jusqu'à une date, ou indéfiniment).</summary>
|
||||
public const string Disabled = "disabled";
|
||||
|
||||
/// <summary>
|
||||
/// Suspendu après trop d'échecs de connexion depuis des appareils inconnus : connexions depuis un NOUVEL appareil
|
||||
/// refusées jusqu'à ce qu'un administrateur lève la suspension ; les appareils déjà connectés continuent de fonctionner.
|
||||
/// </summary>
|
||||
public const string Suspended = "suspended";
|
||||
|
||||
/// <summary>
|
||||
/// En sommeil après une longue inactivité (<c>Players:InactiveDisableMonths</c>) : appareils déconnectés ; une connexion
|
||||
/// avec le bon mot de passe le réveille. Supprimé au bout de <c>Players:InactiveDeleteMonths</c> de plus.
|
||||
/// </summary>
|
||||
public const string Inactive = "inactive";
|
||||
}
|
||||
|
||||
/// <summary>Compte joueur tel qu'enregistré (adresse chiffrée ; jamais renvoyé tel quel par l'API).</summary>
|
||||
public sealed record PlayerAccountRecord
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Reference { get; init; }
|
||||
public required string EmailIndex { get; init; }
|
||||
public required string EmailEncrypted { get; init; }
|
||||
public bool EmailVerified { get; init; }
|
||||
public required string PasswordHash { get; init; }
|
||||
public DateTimeOffset PasswordChangedAt { get; init; }
|
||||
public bool MustChangePassword { get; init; }
|
||||
public bool OtpEnabled { get; init; }
|
||||
public string Status { get; init; } = PlayerStatus.Active;
|
||||
public DateTimeOffset? DisabledUntil { get; init; }
|
||||
public DateTimeOffset? StatusChangedAt { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? LastLoginAt { get; init; }
|
||||
public required string TermsVersion { get; init; }
|
||||
|
||||
/// <summary>Échecs consécutifs depuis des appareils inconnus (série en cours), et nombre de blocages successifs.</summary>
|
||||
public int FailedCount { get; init; }
|
||||
public int LockLevel { get; init; }
|
||||
public DateTimeOffset? LockedUntil { get; init; }
|
||||
|
||||
/// <summary>Échecs consécutifs depuis des appareils de confiance, et leur blocage temporaire.</summary>
|
||||
public int TrustedFailedCount { get; init; }
|
||||
public DateTimeOffset? TrustedLockedUntil { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Appareil connecté (clé publique de l'appareil et session mémorisée).</summary>
|
||||
public sealed record PlayerDeviceRecord
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string AccountId { get; init; }
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>Clé publique de l'appareil (SPKI P-256, Base64).</summary>
|
||||
public required string PublicKey { get; init; }
|
||||
|
||||
public required string Label { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset LastUsedAt { get; init; }
|
||||
|
||||
/// <summary>SHA-256 du jeton de renouvellement en vigueur, et du précédent (détection d'une réutilisation).</summary>
|
||||
public required string RefreshHash { get; init; }
|
||||
public string? PreviousRefreshHash { get; init; }
|
||||
|
||||
public DateTimeOffset RefreshExpiresAt { get; init; }
|
||||
public DateTimeOffset RefreshAbsoluteExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Ligne du journal des comptes.</summary>
|
||||
public sealed record PlayerEventRecord(long Id, string? AccountId, DateTimeOffset At, string Type, string Result, string? Ip, string? DeviceKeyId,
|
||||
string? Details);
|
||||
|
||||
/// <summary>Résultat d'un échec de connexion pour un compte existant.</summary>
|
||||
public sealed record PlayerFailureOutcome(DateTimeOffset? LockedUntil, bool Suspended, int LockLevel);
|
||||
|
||||
/// <summary>Types d'événements du journal des comptes (actions des joueurs et du système).</summary>
|
||||
public static class PlayerEvents
|
||||
{
|
||||
public const string Register = "account.register";
|
||||
public const string Login = "account.login";
|
||||
public const string LoginFailed = "account.login_failed";
|
||||
public const string Locked = "account.locked";
|
||||
public const string Suspended = "account.suspended";
|
||||
public const string Unlocked = "account.unlocked";
|
||||
public const string Disabled = "account.disabled";
|
||||
public const string Enabled = "account.enabled";
|
||||
public const string Logout = "account.logout";
|
||||
public const string LogoutAll = "account.logout_all";
|
||||
public const string PasswordChange = "account.password_change";
|
||||
public const string PasswordChangeForced = "account.password_change_required";
|
||||
public const string Refresh = "session.refresh";
|
||||
public const string RefreshReuse = "session.reuse";
|
||||
public const string DeviceRemoved = "device.removed";
|
||||
public const string Export = "account.export";
|
||||
public const string Delete = "account.delete";
|
||||
public const string Inactive = "account.inactive";
|
||||
public const string Reactivated = "account.reactivated";
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
|
||||
namespace GameLauncher.Server.Storage.Players;
|
||||
|
||||
/// <summary>
|
||||
/// Clés des comptes joueurs, toutes dérivées (HKDF-SHA256) d'une clé maîtresse de 256 bits gardée HORS du dossier des
|
||||
/// données (credential systemd chiffré, déchiffré par systemd au démarrage dans un dossier en mémoire visible du seul
|
||||
/// service). Une copie de /var/lib/launcher-api ou de ses sauvegardes ne permet donc ni de lire les adresses e-mail, ni
|
||||
/// de tester des mots de passe hors ligne (le « poivre » des empreintes en dérive aussi).
|
||||
///
|
||||
/// Une sous-clé par usage : chiffrement des adresses, index aveugle (recherche par adresse sans la stocker en clair),
|
||||
/// poivre des mots de passe, et pour les étapes suivantes : secrets OTP, notes des administrateurs, messages.
|
||||
/// </summary>
|
||||
public sealed class PlayerSecrets
|
||||
{
|
||||
/// <summary>Version de la clé maîtresse, écrite devant chaque valeur chiffrée (rotation future).</summary>
|
||||
public const int KeyVersion = 1;
|
||||
|
||||
private const int KeyLength = 32;
|
||||
private const int NonceLength = 12;
|
||||
private const int TagLength = 16;
|
||||
private static readonly byte[] HkdfSalt = Encoding.ASCII.GetBytes("GameLauncher/Player/Keys/v1");
|
||||
|
||||
private readonly byte[] _emailKey;
|
||||
private readonly byte[] _indexKey;
|
||||
private readonly byte[] _pepper;
|
||||
private readonly byte[] _fieldKey;
|
||||
|
||||
public PlayerSecrets(ReadOnlySpan<byte> masterKey)
|
||||
{
|
||||
if (masterKey.Length != KeyLength) throw new CryptographicException("La clé maîtresse des comptes joueurs doit faire 32 octets.");
|
||||
if (masterKey.IndexOfAnyExcept((byte)0) < 0) throw new CryptographicException("Clé maîtresse vide (que des zéros).");
|
||||
_emailKey = Derive(masterKey, "email-encryption/v1");
|
||||
_indexKey = Derive(masterKey, "email-index/v1");
|
||||
_pepper = Derive(masterKey, "password-pepper/v1");
|
||||
_fieldKey = Derive(masterKey, "field-encryption/v1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lit la clé maîtresse (Base64 de 32 octets). Sous Linux, le fichier ne doit être ni lisible ni modifiable par les
|
||||
/// autres comptes, ni modifiable par le groupe (un credential systemd n'est lisible que par le service).
|
||||
/// </summary>
|
||||
public static PlayerSecrets Load(string path)
|
||||
{
|
||||
if (!File.Exists(path)) throw new InvalidOperationException($"Clé maîtresse des comptes joueurs introuvable : {path}");
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
var mode = File.GetUnixFileMode(path);
|
||||
const UnixFileMode forbidden = UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute;
|
||||
if ((mode & forbidden) != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Clé maîtresse des comptes joueurs accessible à d'autres comptes ({path}) : droits 0400 ou 0600 attendus.");
|
||||
}
|
||||
var text = File.ReadAllText(path).Trim();
|
||||
byte[] key;
|
||||
try
|
||||
{
|
||||
key = Convert.FromBase64String(text);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
throw new InvalidOperationException("Clé maîtresse des comptes joueurs illisible : 32 octets en Base64 attendus (commande players-key).");
|
||||
}
|
||||
try
|
||||
{
|
||||
return new PlayerSecrets(key);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Nouvelle clé maîtresse (commande « players-key »), en Base64.</summary>
|
||||
public static string NewMasterKey() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(KeyLength));
|
||||
|
||||
/// <summary>Poivre des empreintes de mots de passe (« clé secrète » d'Argon2id).</summary>
|
||||
public byte[] PasswordPepper => _pepper;
|
||||
|
||||
/// <summary>
|
||||
/// Index aveugle d'une adresse NORMALISÉE : HMAC-SHA256 avec une clé secrète. Permet de retrouver un compte et
|
||||
/// d'imposer l'unicité des adresses sans conserver l'adresse en clair ; sans la clé, impossible de tester une adresse.
|
||||
/// </summary>
|
||||
public string EmailIndex(string normalizedEmail) =>
|
||||
Convert.ToHexString(HMACSHA256.HashData(_indexKey, Encoding.UTF8.GetBytes("GameLauncher/Player/Email/v1\n" + normalizedEmail)))
|
||||
.ToLowerInvariant();
|
||||
|
||||
/// <summary>Chiffre l'adresse d'un compte (liée à ce compte : une valeur copiée sur un autre compte ne se déchiffre pas).</summary>
|
||||
public string EncryptEmail(string accountId, string normalizedEmail) =>
|
||||
Encrypt(_emailKey, Encoding.UTF8.GetBytes(normalizedEmail), Associated("email", accountId));
|
||||
|
||||
public string DecryptEmail(string accountId, string value) =>
|
||||
Encoding.UTF8.GetString(Decrypt(_emailKey, value, Associated("email", accountId)));
|
||||
|
||||
/// <summary>Chiffre un champ libre d'un compte (motifs, notes…), lié au compte et au nom du champ.</summary>
|
||||
public string EncryptField(string accountId, string field, string text) =>
|
||||
Encrypt(_fieldKey, Encoding.UTF8.GetBytes(text), Associated(field, accountId));
|
||||
|
||||
public string DecryptField(string accountId, string field, string value) =>
|
||||
Encoding.UTF8.GetString(Decrypt(_fieldKey, value, Associated(field, accountId)));
|
||||
|
||||
private static byte[] Associated(string field, string accountId) =>
|
||||
Encoding.UTF8.GetBytes($"GameLauncher/Player/Field/v1\n{field}\n{accountId}");
|
||||
|
||||
private static byte[] Derive(ReadOnlySpan<byte> masterKey, string purpose) =>
|
||||
HKDF.DeriveKey(HashAlgorithmName.SHA256, masterKey.ToArray(), KeyLength, HkdfSalt, Encoding.ASCII.GetBytes(purpose));
|
||||
|
||||
/// <summary>Format : « 1. » (version de la clé) + Base64Url(nonce ‖ chiffré ‖ étiquette), AES-256-GCM.</summary>
|
||||
private static string Encrypt(byte[] key, byte[] plaintext, byte[] associated)
|
||||
{
|
||||
var buffer = new byte[NonceLength + plaintext.Length + TagLength];
|
||||
var nonce = buffer.AsSpan(0, NonceLength);
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
using var aes = new AesGcm(key, TagLength);
|
||||
aes.Encrypt(nonce, plaintext, buffer.AsSpan(NonceLength, plaintext.Length), buffer.AsSpan(NonceLength + plaintext.Length), associated);
|
||||
return $"{KeyVersion}.{Base64Url.Encode(buffer)}";
|
||||
}
|
||||
|
||||
private static byte[] Decrypt(byte[] key, string value, byte[] associated)
|
||||
{
|
||||
var dot = value.IndexOf('.');
|
||||
if (dot <= 0 || value[..dot] != KeyVersion.ToString(System.Globalization.CultureInfo.InvariantCulture) ||
|
||||
!Base64Url.TryDecode(value[(dot + 1)..], out var buffer) || buffer.Length < NonceLength + TagLength)
|
||||
throw new CryptographicException("Valeur chiffrée illisible ou d'une autre version de clé.");
|
||||
var length = buffer.Length - NonceLength - TagLength;
|
||||
var plaintext = new byte[length];
|
||||
using var aes = new AesGcm(key, TagLength);
|
||||
aes.Decrypt(buffer.AsSpan(0, NonceLength), buffer.AsSpan(NonceLength, length), buffer.AsSpan(NonceLength + length), plaintext, associated);
|
||||
return plaintext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
using System.Globalization;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage.Players;
|
||||
|
||||
/// <summary>
|
||||
/// Base des comptes joueurs : SQLite (players/players.db dans les données privées, dossier 0700, fichier 0600).
|
||||
///
|
||||
/// Pourquoi SQLite ici (et pas des fichiers JSON comme les autres modules) : relations (compte ⇄ appareils ⇄ journal, et
|
||||
/// bientôt conversations, blocages), unicité stricte des adresses sous transaction, volumes non bornés, recherches par
|
||||
/// index. Réglages : journal WAL, clés étrangères, effacement sécurisé (secure_delete : une ligne supprimée est écrasée
|
||||
/// sur le disque), écriture synchrone complète. Requêtes PARAMÉTRÉES uniquement.
|
||||
///
|
||||
/// Une seule connexion, protégée par un verrou : SQLite n'accepte qu'un écrivain à la fois, les requêtes sont courtes,
|
||||
/// et les calculs coûteux (empreintes de mots de passe) sont faits AVANT d'entrer ici. L'outil en ligne de commande peut
|
||||
/// ouvrir la base en même temps (attente bornée).
|
||||
///
|
||||
/// Le contenu sensible est chiffré AVANT d'arriver ici (adresse e-mail : <see cref="PlayerSecrets"/>) : cette classe ne
|
||||
/// connaît pas la clé maîtresse.
|
||||
/// </summary>
|
||||
public sealed class PlayerStore : IDisposable
|
||||
{
|
||||
public const string FolderName = "players";
|
||||
public const string FileName = "players.db";
|
||||
|
||||
/// <summary>Version du schéma (PRAGMA user_version).</summary>
|
||||
public const int SchemaVersion = 1;
|
||||
|
||||
private readonly object _lock = new();
|
||||
private readonly SqliteConnection _db;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public PlayerStore(IOptions<LauncherApiOptions> options, TimeProvider time) : this(options.Value.DataDirectoryFullPath, time) { }
|
||||
|
||||
public PlayerStore(string dataDirectory, TimeProvider time)
|
||||
{
|
||||
_time = time;
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
var folder = Path.Combine(dataDirectory, FolderName);
|
||||
SecureFiles.EnsureDirectory(folder);
|
||||
DatabasePath = Path.Combine(folder, FileName);
|
||||
// Fichier créé en 0600 AVANT que SQLite ne l'ouvre (les fichiers -wal et -shm reprennent ses droits).
|
||||
if (!File.Exists(DatabasePath)) SecureFiles.TryCreateNew(DatabasePath, "");
|
||||
|
||||
_db = new SqliteConnection(new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = DatabasePath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
Pooling = false
|
||||
}.ToString());
|
||||
_db.Open();
|
||||
try
|
||||
{
|
||||
Execute(null, "PRAGMA busy_timeout = 5000;");
|
||||
Scalar(null, "PRAGMA journal_mode = WAL;");
|
||||
Execute(null, "PRAGMA foreign_keys = ON; PRAGMA secure_delete = ON; PRAGMA synchronous = FULL;");
|
||||
Migrate();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_db.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public string DatabasePath { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock) _db.Dispose();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Schéma
|
||||
|
||||
private void Migrate()
|
||||
{
|
||||
var version = Convert.ToInt64(Scalar(null, "PRAGMA user_version;"), CultureInfo.InvariantCulture);
|
||||
if (version > SchemaVersion)
|
||||
throw new InvalidOperationException(
|
||||
$"Base des comptes joueurs en version {version}, plus récente que ce serveur ({SchemaVersion}) : mettez le serveur à jour.");
|
||||
if (version == SchemaVersion) return;
|
||||
|
||||
using var tx = _db.BeginTransaction();
|
||||
if (version < 1)
|
||||
{
|
||||
Execute(tx, """
|
||||
CREATE TABLE accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
reference TEXT NOT NULL UNIQUE,
|
||||
email_index TEXT NOT NULL UNIQUE,
|
||||
email_enc TEXT NOT NULL,
|
||||
email_verified INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT NOT NULL,
|
||||
password_changed_at INTEGER NOT NULL,
|
||||
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||
totp_secret_enc TEXT,
|
||||
totp_last_step INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
disabled_until INTEGER,
|
||||
status_reason_enc TEXT,
|
||||
status_changed_at INTEGER,
|
||||
status_changed_by TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_login_at INTEGER,
|
||||
terms_version TEXT NOT NULL,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
lock_level INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until INTEGER,
|
||||
trusted_failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
trusted_locked_until INTEGER
|
||||
);
|
||||
CREATE INDEX ix_accounts_created ON accounts(created_at);
|
||||
CREATE INDEX ix_accounts_status ON accounts(status);
|
||||
|
||||
CREATE TABLE devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
key_id TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER NOT NULL,
|
||||
refresh_hash TEXT NOT NULL,
|
||||
previous_refresh_hash TEXT,
|
||||
refresh_expires_at INTEGER NOT NULL,
|
||||
refresh_absolute_expires_at INTEGER NOT NULL,
|
||||
UNIQUE (account_id, key_id)
|
||||
);
|
||||
CREATE INDEX ix_devices_account ON devices(account_id);
|
||||
|
||||
CREATE TABLE account_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id TEXT REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
at INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
device_key TEXT,
|
||||
details TEXT
|
||||
);
|
||||
CREATE INDEX ix_events_account ON account_events(account_id, at);
|
||||
CREATE INDEX ix_events_at ON account_events(at);
|
||||
""");
|
||||
}
|
||||
Execute(tx, $"PRAGMA user_version = {SchemaVersion};");
|
||||
tx.Commit();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Comptes
|
||||
|
||||
private const string AccountColumns =
|
||||
"id, reference, email_index, email_enc, email_verified, password_hash, password_changed_at, must_change_password, " +
|
||||
"totp_secret_enc IS NOT NULL, status, disabled_until, status_changed_at, created_at, last_login_at, terms_version, " +
|
||||
"failed_count, lock_level, locked_until, trusted_failed_count, trusted_locked_until";
|
||||
|
||||
public PlayerAccountRecord? FindByEmailIndex(string emailIndex) => Locked(() =>
|
||||
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE email_index = $v;", ReadAccount, ("$v", emailIndex)));
|
||||
|
||||
public PlayerAccountRecord? FindById(string accountId) => Locked(() =>
|
||||
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE id = $v;", ReadAccount, ("$v", accountId)));
|
||||
|
||||
public PlayerAccountRecord? FindByReference(string reference) => Locked(() =>
|
||||
QuerySingle(null, $"SELECT {AccountColumns} FROM accounts WHERE reference = $v;", ReadAccount, ("$v", reference)));
|
||||
|
||||
public int CountAccounts() => Locked(() => Convert.ToInt32(Scalar(null, "SELECT COUNT(*) FROM accounts;"), CultureInfo.InvariantCulture));
|
||||
|
||||
public int CountCreatedSince(DateTimeOffset since) => Locked(() =>
|
||||
Convert.ToInt32(Scalar(null, "SELECT COUNT(*) FROM accounts WHERE created_at >= $t;", ("$t", since.ToUnixTimeSeconds())),
|
||||
CultureInfo.InvariantCulture));
|
||||
|
||||
/// <summary>
|
||||
/// Crée le compte et son premier appareil, sous transaction. Faux si l'adresse (index) est déjà utilisée. La référence
|
||||
/// est retirée au sort de nouveau en cas de collision (40 bits : rare).
|
||||
/// </summary>
|
||||
public bool TryCreate(PlayerAccountRecord account, PlayerDeviceRecord device, Func<string> newReference, out PlayerAccountRecord created)
|
||||
{
|
||||
created = account;
|
||||
lock (_lock)
|
||||
{
|
||||
using var tx = _db.BeginTransaction();
|
||||
if (Scalar(tx, "SELECT 1 FROM accounts WHERE email_index = $v;", ("$v", account.EmailIndex)) is not null) return false;
|
||||
var reference = account.Reference;
|
||||
for (var attempt = 0; Scalar(tx, "SELECT 1 FROM accounts WHERE reference = $v;", ("$v", reference)) is not null; attempt++)
|
||||
{
|
||||
if (attempt > 20) throw new InvalidOperationException("Impossible de tirer une référence de compte libre.");
|
||||
reference = newReference();
|
||||
}
|
||||
created = account with { Reference = reference };
|
||||
Execute(tx, """
|
||||
INSERT INTO accounts (id, reference, email_index, email_enc, email_verified, password_hash, password_changed_at,
|
||||
must_change_password, status, created_at, last_login_at, terms_version)
|
||||
VALUES ($id, $ref, $idx, $enc, 0, $hash, $pwd, 0, $status, $created, $login, $terms);
|
||||
""",
|
||||
("$id", created.Id), ("$ref", created.Reference), ("$idx", created.EmailIndex), ("$enc", created.EmailEncrypted),
|
||||
("$hash", created.PasswordHash), ("$pwd", created.PasswordChangedAt.ToUnixTimeSeconds()), ("$status", PlayerStatus.Active),
|
||||
("$created", created.CreatedAt.ToUnixTimeSeconds()), ("$login", Db(created.LastLoginAt)), ("$terms", created.TermsVersion));
|
||||
InsertDevice(tx, device);
|
||||
tx.Commit();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Connexion réussie : compteurs d'échecs remis à zéro, date de connexion, empreinte éventuellement remplacée.</summary>
|
||||
public void RecordSuccess(string accountId, DateTimeOffset now, string? newPasswordHash) => Locked(() =>
|
||||
{
|
||||
Execute(null, """
|
||||
UPDATE accounts SET failed_count = 0, lock_level = 0, locked_until = NULL, trusted_failed_count = 0,
|
||||
trusted_locked_until = NULL, last_login_at = $now,
|
||||
password_hash = COALESCE($hash, password_hash)
|
||||
WHERE id = $id;
|
||||
""", ("$now", now.ToUnixTimeSeconds()), ("$hash", (object?)newPasswordHash ?? DBNull.Value), ("$id", accountId));
|
||||
return true;
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Échec de connexion (mot de passe faux) sur un compte existant, sous transaction. Appareil inconnu : blocage tous les
|
||||
/// <paramref name="failuresPerLock"/> échecs (durée doublée à chaque fois), suspension au blocage n°
|
||||
/// <paramref name="suspendAfterLocks"/>. Appareil de confiance : blocage temporaire seulement.
|
||||
/// </summary>
|
||||
public PlayerFailureOutcome RecordFailure(string accountId, bool trusted, DateTimeOffset now, LauncherApiOptions.PlayersSettings settings)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
using var tx = _db.BeginTransaction();
|
||||
var account = QuerySingle(tx, $"SELECT {AccountColumns} FROM accounts WHERE id = $v;", ReadAccount, ("$v", accountId))
|
||||
?? throw new InvalidOperationException("Compte disparu.");
|
||||
PlayerFailureOutcome outcome;
|
||||
if (trusted)
|
||||
{
|
||||
var count = account.TrustedFailedCount + 1;
|
||||
DateTimeOffset? until = null;
|
||||
if (count >= settings.TrustedFailuresPerLock)
|
||||
{
|
||||
until = now + TimeSpan.FromMinutes(settings.LockMinutes);
|
||||
count = 0;
|
||||
}
|
||||
Execute(tx, "UPDATE accounts SET trusted_failed_count = $c, trusted_locked_until = COALESCE($u, trusted_locked_until) WHERE id = $id;",
|
||||
("$c", count), ("$u", Db(until)), ("$id", accountId));
|
||||
outcome = new PlayerFailureOutcome(until, false, account.LockLevel);
|
||||
}
|
||||
else
|
||||
{
|
||||
var count = account.FailedCount + 1;
|
||||
var level = account.LockLevel;
|
||||
DateTimeOffset? until = null;
|
||||
var suspended = false;
|
||||
if (count >= settings.FailuresPerLock)
|
||||
{
|
||||
count = 0;
|
||||
level++;
|
||||
if (level >= settings.SuspendAfterLocks && account.Status != PlayerStatus.Suspended) suspended = true;
|
||||
// Le blocage temporaire s'applique AUSSI au moment de la suspension et après : vu de l'extérieur, un compte
|
||||
// suspendu se comporte comme une adresse inconnue (PlayerGuard.CountUnknownFailure), la suspension
|
||||
// n'est révélée qu'après un mot de passe correct.
|
||||
until = now + LockDuration(settings, level);
|
||||
}
|
||||
Execute(tx, """
|
||||
UPDATE accounts SET failed_count = $c, lock_level = $l, locked_until = COALESCE($u, locked_until),
|
||||
status = CASE WHEN $s = 1 THEN 'suspended' ELSE status END,
|
||||
status_changed_at = CASE WHEN $s = 1 THEN $now ELSE status_changed_at END,
|
||||
status_changed_by = CASE WHEN $s = 1 THEN 'système (échecs de connexion)' ELSE status_changed_by END,
|
||||
disabled_until = CASE WHEN $s = 1 THEN NULL ELSE disabled_until END
|
||||
WHERE id = $id;
|
||||
""", ("$c", count), ("$l", level), ("$u", Db(until)), ("$s", suspended ? 1 : 0), ("$now", now.ToUnixTimeSeconds()),
|
||||
("$id", accountId));
|
||||
outcome = new PlayerFailureOutcome(until, suspended, level);
|
||||
}
|
||||
tx.Commit();
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Durée du blocage n° <paramref name="level"/> (1, 2, 3…) : <c>LockMinutes</c> doublée à chaque blocage, plafonnée
|
||||
/// au niveau précédant la suspension et à 24 h. Même règle pour les adresses inconnues (blocage miroir).
|
||||
/// </summary>
|
||||
public static TimeSpan LockDuration(LauncherApiOptions.PlayersSettings settings, int level)
|
||||
{
|
||||
var capped = Math.Clamp(level, 1, Math.Max(1, settings.SuspendAfterLocks - 1));
|
||||
return TimeSpan.FromMinutes(Math.Min(24 * 60, settings.LockMinutes * Math.Pow(2, capped - 1)));
|
||||
}
|
||||
|
||||
/// <summary>Nouveau mot de passe : empreinte, date, « changement exigé » levé (ou posé).</summary>
|
||||
public void SetPassword(string accountId, string passwordHash, DateTimeOffset now, bool mustChange = false) => Locked(() =>
|
||||
{
|
||||
Execute(null, "UPDATE accounts SET password_hash = $h, password_changed_at = $now, must_change_password = $m WHERE id = $id;",
|
||||
("$h", passwordHash), ("$now", now.ToUnixTimeSeconds()), ("$m", mustChange ? 1 : 0), ("$id", accountId));
|
||||
return true;
|
||||
});
|
||||
|
||||
/// <summary>Exige (ou non) un nouveau mot de passe à la prochaine connexion.</summary>
|
||||
public bool SetMustChangePassword(string accountId, bool value) => Locked(() =>
|
||||
Execute(null, "UPDATE accounts SET must_change_password = $m WHERE id = $id;", ("$m", value ? 1 : 0), ("$id", accountId)) == 1);
|
||||
|
||||
/// <summary>Change l'état du compte (désactivation, réactivation, levée d'une suspension) ; remet à zéro les blocages
|
||||
/// quand le compte redevient actif.</summary>
|
||||
public bool SetStatus(string accountId, string status, DateTimeOffset? until, string? reasonEncrypted, string changedBy, DateTimeOffset now) =>
|
||||
Locked(() => Execute(null, """
|
||||
UPDATE accounts SET status = $s, disabled_until = $u, status_reason_enc = $r, status_changed_at = $now, status_changed_by = $by,
|
||||
failed_count = CASE WHEN $s = 'active' THEN 0 ELSE failed_count END,
|
||||
lock_level = CASE WHEN $s = 'active' THEN 0 ELSE lock_level END,
|
||||
locked_until = CASE WHEN $s = 'active' THEN NULL ELSE locked_until END,
|
||||
trusted_failed_count = CASE WHEN $s = 'active' THEN 0 ELSE trusted_failed_count END,
|
||||
trusted_locked_until = CASE WHEN $s = 'active' THEN NULL ELSE trusted_locked_until END
|
||||
WHERE id = $id;
|
||||
""", ("$s", status), ("$u", Db(until)), ("$r", (object?)reasonEncrypted ?? DBNull.Value), ("$now", now.ToUnixTimeSeconds()),
|
||||
("$by", changedBy), ("$id", accountId)) == 1);
|
||||
|
||||
/// <summary>
|
||||
/// Supprime DÉFINITIVEMENT le compte : ses appareils et son journal partent avec lui (clés étrangères en cascade,
|
||||
/// lignes écrasées sur le disque : secure_delete). Faux si le compte n'existe plus.
|
||||
/// </summary>
|
||||
public bool DeleteAccount(string accountId) => Locked(() => Execute(null, "DELETE FROM accounts WHERE id = $id;", ("$id", accountId)) == 1);
|
||||
|
||||
/// <summary>
|
||||
/// Comptes ACTIFS sans aucune activité (création, connexion, utilisation d'un appareil) depuis <paramref name="before"/>.
|
||||
/// Les comptes désactivés ou suspendus par un administrateur ne sont pas concernés (leur sort relève de l'administration).
|
||||
/// </summary>
|
||||
public IReadOnlyList<PlayerAccountRecord> FindInactive(DateTimeOffset before, int limit = 1000) => Locked(() =>
|
||||
Query(null, $"""
|
||||
SELECT {AccountColumns} FROM accounts a
|
||||
WHERE status = 'active'
|
||||
AND MAX(created_at, COALESCE(last_login_at, 0),
|
||||
COALESCE((SELECT MAX(last_used_at) FROM devices d WHERE d.account_id = a.id), 0)) < $t
|
||||
ORDER BY created_at LIMIT $l;
|
||||
""", ReadAccount, ("$t", before.ToUnixTimeSeconds()), ("$l", Math.Clamp(limit, 1, 10_000))));
|
||||
|
||||
/// <summary>Met le compte en sommeil (inactivité) et déconnecte ses appareils, si son état n'a pas changé entre-temps.</summary>
|
||||
public bool MarkInactive(string accountId, DateTimeOffset now)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
using var tx = _db.BeginTransaction();
|
||||
var changed = Execute(tx, """
|
||||
UPDATE accounts SET status = 'inactive', disabled_until = NULL, status_changed_at = $now,
|
||||
status_changed_by = 'système (inactivité)'
|
||||
WHERE id = $id AND status = 'active';
|
||||
""", ("$now", now.ToUnixTimeSeconds()), ("$id", accountId)) == 1;
|
||||
if (changed) Execute(tx, "DELETE FROM devices WHERE account_id = $a;", ("$a", accountId));
|
||||
tx.Commit();
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Comptes en sommeil depuis avant <paramref name="before"/> (à supprimer).</summary>
|
||||
public IReadOnlyList<PlayerAccountRecord> FindDormantSince(DateTimeOffset before, int limit = 1000) => Locked(() =>
|
||||
Query(null, $"SELECT {AccountColumns} FROM accounts WHERE status = 'inactive' AND status_changed_at < $t ORDER BY status_changed_at LIMIT $l;",
|
||||
ReadAccount, ("$t", before.ToUnixTimeSeconds()), ("$l", Math.Clamp(limit, 1, 10_000))));
|
||||
|
||||
// ------------------------------------------------------------------ Appareils
|
||||
|
||||
private const string DeviceColumns =
|
||||
"id, account_id, key_id, public_key, label, created_at, last_used_at, refresh_hash, previous_refresh_hash, " +
|
||||
"refresh_expires_at, refresh_absolute_expires_at";
|
||||
|
||||
public PlayerDeviceRecord? FindDevice(string deviceId) => Locked(() =>
|
||||
QuerySingle(null, $"SELECT {DeviceColumns} FROM devices WHERE id = $v;", ReadDevice, ("$v", deviceId)));
|
||||
|
||||
public PlayerDeviceRecord? FindDeviceByKey(string accountId, string keyId) => Locked(() =>
|
||||
QuerySingle(null, $"SELECT {DeviceColumns} FROM devices WHERE account_id = $a AND key_id = $k;", ReadDevice,
|
||||
("$a", accountId), ("$k", keyId)));
|
||||
|
||||
public IReadOnlyList<PlayerDeviceRecord> Devices(string accountId) => Locked(() =>
|
||||
Query(null, $"SELECT {DeviceColumns} FROM devices WHERE account_id = $a ORDER BY last_used_at DESC;", ReadDevice, ("$a", accountId)));
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre une session mémorisée sur un appareil : appareil créé, ou remplacé s'il existait (même clé : nouvelle
|
||||
/// connexion depuis le même PC). Au-delà de <paramref name="maxDevices"/>, les appareils les moins récemment utilisés
|
||||
/// sont déconnectés. Renvoie les identifiants des appareils retirés (sessions à fermer).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SaveDevice(PlayerDeviceRecord device, int maxDevices)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
using var tx = _db.BeginTransaction();
|
||||
var removed = Query(tx, "SELECT id FROM devices WHERE account_id = $a AND key_id = $k;", r => r.GetString(0),
|
||||
("$a", device.AccountId), ("$k", device.KeyId)).ToList();
|
||||
Execute(tx, "DELETE FROM devices WHERE account_id = $a AND key_id = $k;", ("$a", device.AccountId), ("$k", device.KeyId));
|
||||
InsertDevice(tx, device);
|
||||
var excess = Query(tx, "SELECT id FROM devices WHERE account_id = $a AND id <> $id ORDER BY last_used_at DESC, created_at DESC;",
|
||||
r => r.GetString(0), ("$a", device.AccountId), ("$id", device.Id)).Skip(Math.Max(0, maxDevices - 1)).ToList();
|
||||
foreach (var id in excess) Execute(tx, "DELETE FROM devices WHERE id = $id;", ("$id", id));
|
||||
tx.Commit();
|
||||
removed.AddRange(excess);
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remplace le jeton de renouvellement SI celui présenté est toujours celui en vigueur (deux renouvellements simultanés
|
||||
/// avec le même jeton : un seul réussit).
|
||||
/// </summary>
|
||||
public bool RotateRefresh(string deviceId, string expectedHash, string newHash, DateTimeOffset idleExpiry, DateTimeOffset now) => Locked(() =>
|
||||
Execute(null, """
|
||||
UPDATE devices SET previous_refresh_hash = refresh_hash, refresh_hash = $new, refresh_expires_at = $exp, last_used_at = $now
|
||||
WHERE id = $id AND refresh_hash = $old;
|
||||
""", ("$new", newHash), ("$exp", idleExpiry.ToUnixTimeSeconds()), ("$now", now.ToUnixTimeSeconds()), ("$id", deviceId),
|
||||
("$old", expectedHash)) == 1);
|
||||
|
||||
public bool DeleteDevice(string deviceId) => Locked(() => Execute(null, "DELETE FROM devices WHERE id = $id;", ("$id", deviceId)) == 1);
|
||||
|
||||
/// <summary>Déconnecte tous les appareils du compte (sauf <paramref name="exceptDeviceId"/>) ; renvoie leurs identifiants.</summary>
|
||||
public IReadOnlyList<string> DeleteDevices(string accountId, string? exceptDeviceId = null)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
using var tx = _db.BeginTransaction();
|
||||
var ids = Query(tx, "SELECT id FROM devices WHERE account_id = $a AND id <> $e;", r => r.GetString(0),
|
||||
("$a", accountId), ("$e", exceptDeviceId ?? "")).ToList();
|
||||
Execute(tx, "DELETE FROM devices WHERE account_id = $a AND id <> $e;", ("$a", accountId), ("$e", exceptDeviceId ?? ""));
|
||||
tx.Commit();
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
private void InsertDevice(SqliteTransaction tx, PlayerDeviceRecord device) =>
|
||||
Execute(tx, """
|
||||
INSERT INTO devices (id, account_id, key_id, public_key, label, created_at, last_used_at, refresh_hash, previous_refresh_hash,
|
||||
refresh_expires_at, refresh_absolute_expires_at)
|
||||
VALUES ($id, $a, $k, $pk, $label, $created, $used, $rh, NULL, $exp, $abs);
|
||||
""", ("$id", device.Id), ("$a", device.AccountId), ("$k", device.KeyId), ("$pk", device.PublicKey), ("$label", device.Label),
|
||||
("$created", device.CreatedAt.ToUnixTimeSeconds()), ("$used", device.LastUsedAt.ToUnixTimeSeconds()), ("$rh", device.RefreshHash),
|
||||
("$exp", device.RefreshExpiresAt.ToUnixTimeSeconds()), ("$abs", device.RefreshAbsoluteExpiresAt.ToUnixTimeSeconds()));
|
||||
|
||||
// ------------------------------------------------------------------ Journal des comptes
|
||||
|
||||
/// <summary>
|
||||
/// Ajoute une ligne au journal des comptes. Jamais d'adresse e-mail ni de mot de passe : identifiant du compte, type,
|
||||
/// résultat, adresse IP (effacée au bout de quelques mois), clé d'appareil, détail court.
|
||||
/// </summary>
|
||||
public void AddEvent(string? accountId, string type, string result, string? ip, string? deviceKeyId = null, string? details = null)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
Locked(() => Execute(null, """
|
||||
INSERT INTO account_events (account_id, at, type, result, ip, device_key, details) VALUES ($a, $at, $t, $r, $ip, $k, $d);
|
||||
""", ("$a", (object?)accountId ?? DBNull.Value), ("$at", now.ToUnixTimeSeconds()), ("$t", type), ("$r", result),
|
||||
("$ip", (object?)Trim(ip, 64) ?? DBNull.Value), ("$k", (object?)Trim(deviceKeyId, 32) ?? DBNull.Value),
|
||||
("$d", (object?)Trim(details, 300) ?? DBNull.Value)));
|
||||
}
|
||||
|
||||
/// <summary>Journal d'un compte, le plus récent d'abord.</summary>
|
||||
public IReadOnlyList<PlayerEventRecord> Events(string accountId, int limit) => Locked(() =>
|
||||
Query(null, "SELECT id, account_id, at, type, result, ip, device_key, details FROM account_events WHERE account_id = $a ORDER BY id DESC LIMIT $l;",
|
||||
ReadEvent, ("$a", accountId), ("$l", Math.Clamp(limit, 1, 1000))));
|
||||
|
||||
/// <summary>Lignes du journal postérieures à <paramref name="afterId"/> (tous comptes, tests et outils).</summary>
|
||||
public IReadOnlyList<PlayerEventRecord> EventsAfter(long afterId, int limit) => Locked(() =>
|
||||
Query(null, "SELECT id, account_id, at, type, result, ip, device_key, details FROM account_events WHERE id > $i ORDER BY id LIMIT $l;",
|
||||
ReadEvent, ("$i", afterId), ("$l", Math.Clamp(limit, 1, 10_000))));
|
||||
|
||||
/// <summary>
|
||||
/// Conservation : lignes plus anciennes que <paramref name="months"/> supprimées, adresses IP plus anciennes que
|
||||
/// <paramref name="ipMonths"/> effacées. Renvoie (supprimées, anonymisées).
|
||||
/// </summary>
|
||||
public (int Deleted, int Anonymized) PurgeEvents(DateTimeOffset now, int months, int ipMonths) => Locked(() =>
|
||||
{
|
||||
var deleted = Execute(null, "DELETE FROM account_events WHERE at < $t;", ("$t", now.AddMonths(-months).ToUnixTimeSeconds()));
|
||||
var anonymized = Execute(null, "UPDATE account_events SET ip = NULL WHERE ip IS NOT NULL AND at < $t;",
|
||||
("$t", now.AddMonths(-ipMonths).ToUnixTimeSeconds()));
|
||||
return (deleted, anonymized);
|
||||
});
|
||||
|
||||
private static string? Trim(string? value, int max)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var clean = new string(value.Select(c => char.IsControl(c) ? ' ' : c).ToArray()).Trim();
|
||||
return clean.Length <= max ? clean : clean[..max];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Accès SQLite
|
||||
|
||||
private T Locked<T>(Func<T> work)
|
||||
{
|
||||
lock (_lock) return work();
|
||||
}
|
||||
|
||||
private int Execute(SqliteTransaction? tx, string sql, params (string Name, object? Value)[] parameters)
|
||||
{
|
||||
using var command = Command(tx, sql, parameters);
|
||||
return command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private object? Scalar(SqliteTransaction? tx, string sql, params (string Name, object? Value)[] parameters)
|
||||
{
|
||||
using var command = Command(tx, sql, parameters);
|
||||
return command.ExecuteScalar();
|
||||
}
|
||||
|
||||
private List<T> Query<T>(SqliteTransaction? tx, string sql, Func<SqliteDataReader, T> read, params (string Name, object? Value)[] parameters)
|
||||
{
|
||||
using var command = Command(tx, sql, parameters);
|
||||
using var reader = command.ExecuteReader();
|
||||
var rows = new List<T>();
|
||||
while (reader.Read()) rows.Add(read(reader));
|
||||
return rows;
|
||||
}
|
||||
|
||||
private T? QuerySingle<T>(SqliteTransaction? tx, string sql, Func<SqliteDataReader, T> read, params (string Name, object? Value)[] parameters)
|
||||
where T : class =>
|
||||
Query(tx, sql, read, parameters).FirstOrDefault();
|
||||
|
||||
private SqliteCommand Command(SqliteTransaction? tx, string sql, (string Name, object? Value)[] parameters)
|
||||
{
|
||||
var command = _db.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.Transaction = tx;
|
||||
foreach (var (name, value) in parameters) command.Parameters.AddWithValue(name, value ?? DBNull.Value);
|
||||
return command;
|
||||
}
|
||||
|
||||
private static object Db(DateTimeOffset? value) => value is { } date ? date.ToUnixTimeSeconds() : DBNull.Value;
|
||||
|
||||
private static DateTimeOffset Date(SqliteDataReader r, int i) => DateTimeOffset.FromUnixTimeSeconds(r.GetInt64(i));
|
||||
|
||||
private static DateTimeOffset? NullableDate(SqliteDataReader r, int i) => r.IsDBNull(i) ? null : Date(r, i);
|
||||
|
||||
private static PlayerAccountRecord ReadAccount(SqliteDataReader r) => new()
|
||||
{
|
||||
Id = r.GetString(0),
|
||||
Reference = r.GetString(1),
|
||||
EmailIndex = r.GetString(2),
|
||||
EmailEncrypted = r.GetString(3),
|
||||
EmailVerified = r.GetInt64(4) != 0,
|
||||
PasswordHash = r.GetString(5),
|
||||
PasswordChangedAt = Date(r, 6),
|
||||
MustChangePassword = r.GetInt64(7) != 0,
|
||||
OtpEnabled = r.GetInt64(8) != 0,
|
||||
Status = r.GetString(9),
|
||||
DisabledUntil = NullableDate(r, 10),
|
||||
StatusChangedAt = NullableDate(r, 11),
|
||||
CreatedAt = Date(r, 12),
|
||||
LastLoginAt = NullableDate(r, 13),
|
||||
TermsVersion = r.GetString(14),
|
||||
FailedCount = (int)r.GetInt64(15),
|
||||
LockLevel = (int)r.GetInt64(16),
|
||||
LockedUntil = NullableDate(r, 17),
|
||||
TrustedFailedCount = (int)r.GetInt64(18),
|
||||
TrustedLockedUntil = NullableDate(r, 19)
|
||||
};
|
||||
|
||||
private static PlayerDeviceRecord ReadDevice(SqliteDataReader r) => new()
|
||||
{
|
||||
Id = r.GetString(0),
|
||||
AccountId = r.GetString(1),
|
||||
KeyId = r.GetString(2),
|
||||
PublicKey = r.GetString(3),
|
||||
Label = r.GetString(4),
|
||||
CreatedAt = Date(r, 5),
|
||||
LastUsedAt = Date(r, 6),
|
||||
RefreshHash = r.GetString(7),
|
||||
PreviousRefreshHash = r.IsDBNull(8) ? null : r.GetString(8),
|
||||
RefreshExpiresAt = Date(r, 9),
|
||||
RefreshAbsoluteExpiresAt = Date(r, 10)
|
||||
};
|
||||
|
||||
private static PlayerEventRecord ReadEvent(SqliteDataReader r) => new(
|
||||
r.GetInt64(0), r.IsDBNull(1) ? null : r.GetString(1), Date(r, 2), r.GetString(3), r.GetString(4),
|
||||
r.IsDBNull(5) ? null : r.GetString(5), r.IsDBNull(6) ? null : r.GetString(6), r.IsDBNull(7) ? null : r.GetString(7));
|
||||
}
|
||||
@@ -72,6 +72,26 @@
|
||||
"Audit": {
|
||||
"RetentionMonths": 12
|
||||
},
|
||||
// Comptes joueurs (phase 8) : désactivés par défaut. NE PAS ACTIVER EN PRODUCTION avant la fin de l'étape 8g
|
||||
// (docs/PLAYER-ACCOUNTS-ARCHITECTURE.md). Activés, ils exigent la clé maîtresse : credential systemd chiffré
|
||||
// « players-master-key » (server/deploy/LISEZMOI.md, étape 3 undecies), jamais dans le dossier des données.
|
||||
"Players": {
|
||||
"Enabled": false,
|
||||
"RegistrationEnabled": true,
|
||||
"TermsVersion": "1",
|
||||
"HashMemoryKib": 19456,
|
||||
"HashIterations": 2,
|
||||
"FailuresPerLock": 5,
|
||||
"LockMinutes": 15,
|
||||
"SuspendAfterLocks": 4,
|
||||
"RefreshIdleDays": 90,
|
||||
"RefreshMaxDays": 365,
|
||||
"EventsRetentionMonths": 12,
|
||||
"EventsIpRetentionMonths": 6,
|
||||
// Comptes inactifs : mis en sommeil après 24 mois sans activité, supprimés 12 mois plus tard (0 : désactivé).
|
||||
"InactiveDisableMonths": 24,
|
||||
"InactiveDeleteMonths": 12
|
||||
},
|
||||
"Limits": {
|
||||
"RequestsPerMinute": 300,
|
||||
// Morceaux de 4 Mo envoyés pendant une publication (à la place de RequestsPerMinute) : 1500 ≈ 100 Mo/s.
|
||||
|
||||
@@ -286,8 +286,11 @@ Limites :
|
||||
textes) ;
|
||||
* un launcher accepte tout trousseau signé par la clé racine dont le numéro est au moins `keyringMinSequence`
|
||||
et au moins celui du dernier trousseau qu'il a vu ;
|
||||
* un appareil ajouté au trousseau pendant qu'un launcher tourne est connu de ce launcher au plus tard 5 minutes
|
||||
après (nouveau téléchargement du trousseau) ou à son prochain démarrage.
|
||||
* un appareil ajouté au trousseau, ou une autorisation ajoutée à un appareil, pendant qu'un launcher tourne est
|
||||
connu de ce launcher dès le contenu suivant signé par cet appareil (nouveau téléchargement du trousseau, au plus une
|
||||
fois par minute) ;
|
||||
* un launcher laissé ouvert retélécharge le trousseau toutes les 30 minutes au plus (à la vérification suivante) : un
|
||||
appareil retiré, ou une autorisation enlevée, n'y est plus accepté sans attendre son redémarrage.
|
||||
|
||||
La clé racine (`launcher-signing.key`) ne doit jamais aller sur le VPS ni dans Git, ni servir de clé
|
||||
d'administrateur (le menu d'administration la refuse, quel que soit le nom du fichier) : c'est elle qui protège
|
||||
@@ -454,6 +457,92 @@ joueur remplit le sien, le serveur répond 409 : le launcher 3.2 relit les formu
|
||||
inchangés. Chaque demande conserve le formulaire tel qu'à l'envoi : elle reste lisible si un champ est renommé ou
|
||||
supprimé ensuite. Historique des 20 dernières publications : `feedback-forms-history/` dans les données privées.
|
||||
|
||||
## 3 decies. Mode maintenance du launcher (PC et VPS)
|
||||
|
||||
Le menu d'administration › page **« Maintenance »** active ou désactive le mode maintenance de **tous les launchers**
|
||||
(tous les jeux) : fond et accents rouge / orange, « Maintenance en cours — merci de revenir plus tard » au centre avec,
|
||||
au choix, une précision (6 lignes, 300 caractères, **aucun lien**). Pendant la maintenance, les joueurs ne peuvent plus
|
||||
installer, mettre à jour, vérifier, réparer, localiser ni lancer un jeu, ni ouvrir les paramètres ou la fenêtre « Aide » :
|
||||
seuls la **désinstallation** et le menu d'administration restent disponibles. Le document est **signé** : un serveur
|
||||
compromis ne peut ni déclencher une fausse maintenance ni la prolonger chez les launchers en mode `require`.
|
||||
|
||||
Prérequis : étapes 3 bis (catalogue) et 3 quinquies (trousseau).
|
||||
|
||||
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : le fichier est écrit à
|
||||
côté du trousseau (`/srv/exovival/www/catalog/maintenance.json`, 644), dans le dossier déjà autorisé en écriture
|
||||
(`catalog.conf`, étape 3 bis).
|
||||
2. **nginx** : ajoute le bloc `location = /catalog/maintenance.json { … }` de `server/deploy/nginx-catalog.conf`, à côté
|
||||
de celui du trousseau (aucun cache : la fin de la maintenance doit être vue tout de suite), puis
|
||||
`sudo nginx -t && sudo systemctl reload nginx`. Aucun bloc à ajouter dans `nginx-api.conf` (petite requête).
|
||||
3. **Launcher 3.3** : publie-le (mise à jour signée habituelle) **avant** l'étape 4. Les launchers 3.2 acceptent le
|
||||
nouveau trousseau (autorisation inconnue d'eux, sans effet) ; les launchers antérieurs à la 3.2 le refusent et
|
||||
gardent le précédent, comme pour les formulaires. Les launchers antérieurs à la 3.3 ignorent la maintenance.
|
||||
4. **Permission et trousseau** :
|
||||
1. page **Groupes et permissions** : donne `maintenance.manage` (« Mode maintenance ») au groupe qui doit pouvoir
|
||||
l'activer (le groupe Propriétaire l'a d'office ; Administrateur l'a sur une installation neuve) ;
|
||||
2. signe un nouveau trousseau (étape 3 quinquies, point 3) : l'autorisation **« mode maintenance »** des appareils
|
||||
concernés y apparaît, signalée « AUTORISATIONS ÉLARGIES » par `tools\sign-keyring.bat` : c'est attendu.
|
||||
5. **Activer** depuis la page « Maintenance » (message facultatif, confirmation), **désactiver** de même. Les launchers
|
||||
relisent le fichier au démarrage (avant toute mise à jour automatique), puis toutes les 2 minutes (toutes les minutes
|
||||
pendant une maintenance) ; celui de l'administrateur qui publie bascule aussitôt. Un launcher resté ouvert pendant
|
||||
la re-signature du trousseau (point 4) prend la nouvelle autorisation en compte à la relecture suivante
|
||||
(launcher 3.3.1 et suivants ; en 3.3.0, il fallait le redémarrer).
|
||||
|
||||
Comportement des launchers :
|
||||
- **Hors ligne** ou fichier injoignable au démarrage : pas de maintenance (le jeu installé reste jouable hors ligne).
|
||||
Pendant que le launcher tourne, une coupure garde le dernier état vérifié.
|
||||
- Fichier non signé, mal signé, hors limites ou plus ancien que le dernier vu (`contentSigning` « require ») : ignoré.
|
||||
En « warn », il est appliqué et signalé dans le journal ; en « off », il est appliqué sans vérification.
|
||||
- Une opération déjà commencée (téléchargement…) se termine ; un jeu déjà lancé n'est pas fermé. Les mises à jour du
|
||||
**launcher lui-même** (signées par la clé racine) continuent : c'est le moyen de corriger un launcher défectueux.
|
||||
- C'est un **confort d'affichage**, pas une barrière de sécurité : un launcher modifié peut l'ignorer. Si les serveurs
|
||||
du jeu doivent refuser les connexions, c'est à eux de le faire.
|
||||
|
||||
Historique des 20 dernières publications : `maintenance-history/` dans les données privées. Audit : `maintenance.enable`,
|
||||
`maintenance.disable`, `maintenance.update` (message modifié pendant la maintenance).
|
||||
|
||||
## 3 undecies. Comptes joueurs : préparation (VPS)
|
||||
|
||||
> **Ne pas activer en production** avant la fin de l'étape 8g (launcher, OTP, menu d'administration, textes RGPD,
|
||||
> nginx). Cette étape sert à préparer le VPS ou à essayer le serveur sur un VPS de test. Architecture :
|
||||
> `docs/PLAYER-ACCOUNTS-ARCHITECTURE.md`.
|
||||
|
||||
Les comptes joueurs (adresse e-mail + mot de passe) sont stockés dans `/var/lib/launcher-api/players/players.db`
|
||||
(SQLite, 600) : adresses **chiffrées**, mots de passe hachés (Argon2id + « poivre »). Toutes les clés dérivent d'une
|
||||
**clé maîtresse** gardée **hors** du dossier des données, chiffrée par systemd (clé propre à ce VPS).
|
||||
|
||||
1. **Clé maîtresse** (une seule fois). Elle s'affiche une fois pour que tu en gardes une copie **hors ligne**
|
||||
(gestionnaire de mots de passe) : perdue, les adresses sont illisibles et tous les mots de passe doivent être
|
||||
réinitialisés ; sur un nouveau VPS, il faudra la rechiffrer avec `systemd-creds`.
|
||||
```sh
|
||||
sudo mkdir -p /etc/credstore.encrypted && sudo chmod 700 /etc/credstore.encrypted
|
||||
KEY=$(sudo -u deploy /opt/launcher-api/GameLauncher.Server players-key)
|
||||
echo "$KEY" # ← copie-la dans ton gestionnaire de mots de passe
|
||||
printf '%s' "$KEY" | sudo systemd-creds encrypt --name=players-master-key - /etc/credstore.encrypted/players-master-key
|
||||
unset KEY
|
||||
```
|
||||
2. **Complément systemd** : copie `server/deploy/launcher-api-players.conf` en
|
||||
`/etc/systemd/system/launcher-api.service.d/players.conf`, puis `sudo systemctl daemon-reload`.
|
||||
3. **Réglages** : dans `appsettings.Production.json`, section `LauncherApi` :
|
||||
`"Players": { "Enabled": true, "TermsVersion": "2026-11" }` (version des conditions d'utilisation que les joueurs
|
||||
acceptent). `"RegistrationEnabled": false` ferme la création de compte en gardant la connexion.
|
||||
4. `sudo systemctl restart launcher-api` puis `journalctl -u launcher-api -n 20` : la ligne « Comptes joueurs activés »
|
||||
doit apparaître. Clé absente, rangée dans le dossier des données ou lisible par d'autres comptes : le service refuse
|
||||
de démarrer et l'explique dans le journal.
|
||||
|
||||
5. **Launcher** (étape 8b) : le bouton « Compte » n'apparaît que dans un launcher publié avec `"playerAccounts": true`
|
||||
dans `src\GameLauncher\launcher.json` (lu dans le fichier intégré à l'exe, comme `apiUrl`). Pour un essai, un launcher
|
||||
**Debug** pointé sur un serveur de test suffit ; ne publie pas de launcher avec `true` avant l'ouverture des comptes.
|
||||
|
||||
Comptes inactifs : sans activité pendant `Players:InactiveDisableMonths` (24 mois), un compte est mis en sommeil (une
|
||||
connexion le réveille), puis supprimé après `Players:InactiveDeleteMonths` (12 mois) ; `0` désactive la règle. Chaque
|
||||
jour, le bilan est inscrit au journal d'audit (`players.inactive`). Les joueurs exportent leurs données et suppriment leur
|
||||
compte eux-mêmes depuis le launcher (« Mon compte › Mes données ») : `players.self_delete` dans le journal.
|
||||
|
||||
Sauvegarde : `players.db` contient des données personnelles chiffrées ; une sauvegarde cohérente se fait service arrêté
|
||||
(ou, service en marche, avec `sqlite3 players.db ".backup /chemin"`, paquet `sqlite3`), et doit elle-même être chiffrée (étape 8g). Commandes de secours :
|
||||
étape 9.
|
||||
|
||||
## 4. Créer ta clé d'administrateur (PC)
|
||||
|
||||
Double-clique `tools\admin-key.bat`. Choisis une phrase de passe (12 caractères minimum). Il crée :
|
||||
@@ -539,6 +628,9 @@ pas, le script remet la précédente en place.
|
||||
| Un administrateur a perdu tous ses PC | Il crée une nouvelle clé et t'envoie son `.pub`, puis `sudo -u deploy /opt/launcher-api/GameLauncher.Server add-key --admin <identifiant> --public-key /tmp/paul.pub` (vérifie l'empreinte avec lui) |
|
||||
| Toutes tes clés perdues | Nouvelle clé (étape 4), puis `add-owner` (étape 5) ; révoque ensuite les anciennes clés |
|
||||
| Plus personne ne peut gérer les groupes | Tout propriétaire le peut toujours (groupe système, toutes les permissions) ; sinon `add-owner` (étape 5) |
|
||||
| Compte joueur : voir son état (sans son adresse) | `sudo -u deploy /opt/launcher-api/GameLauncher.Server players-show --ref PL-XXXX-XXXX` |
|
||||
| Compte joueur suspendu (trop d'essais) ou bloqué | `sudo -u deploy /opt/launcher-api/GameLauncher.Server players-unlock --ref PL-XXXX-XXXX` |
|
||||
| Compte joueur à désactiver / réactiver | `… players-disable --ref PL-XXXX-XXXX [--until AAAA-MM-JJ]` / `… players-enable --ref PL-XXXX-XXXX` |
|
||||
|
||||
Toujours `sudo -u deploy …` : lancé en root, l'outil refuse (les fichiers recréés appartiendraient à
|
||||
root et le service ne pourrait plus les lire). Il affiche le dossier de données utilisé ; il lit les
|
||||
@@ -584,11 +676,15 @@ recréer les administrateurs.
|
||||
│ et leurs captures <id>-<n>.png|jpg (sans métadonnées)
|
||||
├─ feedback-forms.json (+ .bak) formulaires des demandes publiés (référence de la révision)
|
||||
├─ feedback-forms-history/ 20 dernières révisions publiées
|
||||
├─ maintenance.json (+ .bak) mode maintenance publié (référence de la révision)
|
||||
├─ maintenance-history/ 20 dernières révisions publiées
|
||||
├─ players/players.db (+ -wal, -shm) comptes joueurs (SQLite, 600 ; adresses chiffrées, mots de passe hachés)
|
||||
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
|
||||
/srv/exovival/www/catalog/ (exemple) dossier public servi par nginx (deploy:www-data, 2750)
|
||||
├─ catalog.json catalogue lu par les launchers (écrit par le service, 644)
|
||||
├─ keyring.json trousseau publié (écrit par le service, 644)
|
||||
├─ feedback-forms.json formulaires des demandes signés (écrit par le service, 644)
|
||||
├─ maintenance.json mode maintenance signé (écrit par le service, 644)
|
||||
└─ images/ images du catalogue (envoyées en SFTP)
|
||||
/etc/systemd/system/launcher-api.service
|
||||
/etc/systemd/system/launcher-api.service.d/catalog.conf écriture du dossier public (conservé)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# =============================================================================
|
||||
# Comptes joueurs (phase 8) : clé maîtresse transmise au service par systemd.
|
||||
# /etc/systemd/system/launcher-api.service.d/players.conf
|
||||
#
|
||||
# La clé est chiffrée sur le disque (systemd-creds, clé de l'hôte) dans /etc/credstore.encrypted (root, 700) ;
|
||||
# systemd la déchiffre au démarrage dans un dossier en mémoire visible du seul service ($CREDENTIALS_DIRECTORY).
|
||||
# Elle n'est jamais dans /var/lib/launcher-api : une copie des données ou des sauvegardes ne la contient pas.
|
||||
# Mise en place : server/deploy/LISEZMOI.md, étape 3 undecies. Sans ce fichier, laisser Players:Enabled à false.
|
||||
# =============================================================================
|
||||
[Service]
|
||||
LoadCredentialEncrypted=players-master-key:/etc/credstore.encrypted/players-master-key
|
||||
@@ -1,6 +1,6 @@
|
||||
# =============================================================================
|
||||
# Catalogue des jeux, trousseau et formulaires des demandes (écrits par la Launcher API) — voir LISEZMOI.md,
|
||||
# étapes 3 bis, 3 quinquies et 3 nonies :
|
||||
# Catalogue des jeux, trousseau, formulaires des demandes et mode maintenance (écrits par la Launcher API) —
|
||||
# voir LISEZMOI.md, étapes 3 bis, 3 quinquies, 3 nonies et 3 decies :
|
||||
#
|
||||
# Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival,
|
||||
# AVANT « location / { … } » : (le bloc ci-dessous)
|
||||
@@ -36,3 +36,12 @@ location = /catalog/feedback-forms.json {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# Mode maintenance (launcher 3.3) : activé depuis le menu, signé (autorisation « maintenance » du trousseau), relu par les
|
||||
# launchers au démarrage puis toutes les 1 à 2 minutes. Jamais mis en cache : la fin de la maintenance doit être vue tout de suite.
|
||||
location = /catalog/maintenance.json {
|
||||
limit_except GET { deny all; }
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using GameLauncher.Contracts.Files;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
@@ -315,6 +316,37 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
new FeedbackFormsPublishRequest { BaseRevision = baseRevision, Document = document with { Signature = signature } }, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Mode maintenance en vigueur (révision 0 : jamais publié, inactif).</summary>
|
||||
public Task<MaintenanceState> GetMaintenanceAsync(CancellationToken ct = default) =>
|
||||
GetAsync<MaintenanceState>(ApiRoutes.Maintenance, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Active, désactive ou modifie le mode maintenance : révision <paramref name="baseRevision"/> + 1, signé par la clé de
|
||||
/// cette session (autorisation « maintenance » du trousseau requise chez les joueurs), puis envoyé en requête signée.
|
||||
/// </summary>
|
||||
/// <param name="message">Précision affichée aux joueurs, déjà nettoyée (<see cref="MaintenanceRules.CleanMessage"/>) ; ignorée à la désactivation.</param>
|
||||
/// <param name="since">
|
||||
/// Début de la maintenance : celui de la maintenance en cours pour un nouveau message, null pour en commencer une
|
||||
/// (l'heure du serveur, à la seconde) ; ignoré à la désactivation.
|
||||
/// </param>
|
||||
public async Task<MaintenanceState> PublishMaintenanceAsync(bool enabled, string? message, DateTimeOffset? since, long baseRevision,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var document = new MaintenanceDocument
|
||||
{
|
||||
Revision = baseRevision + 1,
|
||||
Enabled = enabled,
|
||||
Message = enabled ? message : null,
|
||||
Since = enabled ? since ?? ContentSigning.Truncate(DateTimeOffset.UtcNow + _clockOffset) : null
|
||||
};
|
||||
var errors = new List<string>();
|
||||
if (!MaintenanceRules.Validate(document, errors)) throw new ArgumentException(string.Join(" ", errors.Take(5)), nameof(message));
|
||||
var signature = await SignContentAsync(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), ct)
|
||||
.ConfigureAwait(false);
|
||||
return await SendSignedAsync<MaintenanceState>(HttpMethod.Put, ApiRoutes.Maintenance,
|
||||
new MaintenancePublishRequest { BaseRevision = baseRevision, Document = document with { Signature = signature } }, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Contenu d'une capture d'écran (à revérifier avec FeedbackImages.Sanitize avant de l'afficher).</summary>
|
||||
public Task<byte[]> GetFeedbackAttachmentAsync(string gameId, string id, int index, CancellationToken ct = default) =>
|
||||
SendCoreAsync<byte[]>(HttpMethod.Get, ApiRoutes.GameFeedbackAttachment(CheckGameId(gameId), CheckFeedbackId(id), index), null,
|
||||
|
||||
@@ -304,7 +304,7 @@ public sealed class LauncherApiClient : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Le serveur doit annoncer la même origine que celle configurée (sinon les signatures seraient refusées).</summary>
|
||||
private void EnsureSameOrigin(string serverOrigin)
|
||||
internal void EnsureSameOrigin(string serverOrigin)
|
||||
{
|
||||
if (!string.Equals(serverOrigin, Origin, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ApiException(0, "origin_mismatch",
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
/// <summary>
|
||||
/// Clé d'appareil d'un joueur (ECDSA P-256, une par PC et par compte Windows) : créée dans la puce de sécurité (TPM) quand
|
||||
/// elle existe, sinon clé logicielle non exportable protégée par Windows. La clé privée ne quitte jamais le PC.
|
||||
/// Totalement distincte des clés d'administrateur (<see cref="IAdminSigner"/>) : jamais l'une pour l'autre.
|
||||
/// </summary>
|
||||
public interface IPlayerDeviceKey
|
||||
{
|
||||
/// <summary>Clé publique (SubjectPublicKeyInfo DER).</summary>
|
||||
byte[] PublicKey { get; }
|
||||
|
||||
/// <summary>Identifiant de la clé (<see cref="AdminKeys.ComputeKeyId"/>).</summary>
|
||||
string KeyId { get; }
|
||||
|
||||
/// <summary>Signature ECDSA P-256 / SHA-256 au format IEEE P1363 (64 octets).</summary>
|
||||
ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes des comptes joueurs de la Launcher API (« v1/account/… »). Chaque création de compte, connexion et
|
||||
/// renouvellement : défi du serveur (même origine que l'adresse configurée), preuve de travail résolue hors du fil de
|
||||
/// l'interface, signature par la clé d'appareil. Les jetons sont passés par l'appelant (jeton d'accès gardé en mémoire,
|
||||
/// jeton de renouvellement chiffré sur le PC) et ne partent que vers l'adresse de l'API (<see cref="LauncherApiClient.Resolve"/>).
|
||||
/// </summary>
|
||||
public sealed class PlayerAccountApi
|
||||
{
|
||||
/// <summary>Taille maximale acceptée pour l'export des données (le serveur borne l'activité à 1000 lignes).</summary>
|
||||
public const int MaxExportBytes = 4 * 1024 * 1024;
|
||||
|
||||
private readonly LauncherApiClient _client;
|
||||
|
||||
public PlayerAccountApi(LauncherApiClient client) => _client = client;
|
||||
|
||||
public LauncherApiClient Client => _client;
|
||||
|
||||
public Task<PlayerLoginResponse> RegisterAsync(string email, string password, string termsVersion, IPlayerDeviceKey key,
|
||||
string deviceLabel, CancellationToken ct = default) =>
|
||||
SendWithDeviceAsync<PlayerLoginResponse>(PlayerChallengePurposes.Register, ApiRoutes.AccountRegister, key, deviceLabel,
|
||||
(challenge, solution, proof) => new PlayerRegisterRequest
|
||||
{
|
||||
Email = email,
|
||||
Password = password,
|
||||
ChallengeId = challenge.ChallengeId,
|
||||
Solution = solution,
|
||||
AcceptedTermsVersion = termsVersion,
|
||||
Device = proof
|
||||
}, ct);
|
||||
|
||||
public Task<PlayerLoginResponse> LoginAsync(string email, string password, IPlayerDeviceKey key, string deviceLabel,
|
||||
CancellationToken ct = default) =>
|
||||
SendWithDeviceAsync<PlayerLoginResponse>(PlayerChallengePurposes.Login, ApiRoutes.AccountLogin, key, deviceLabel,
|
||||
(challenge, solution, proof) => new PlayerLoginRequest
|
||||
{
|
||||
Email = email,
|
||||
Password = password,
|
||||
ChallengeId = challenge.ChallengeId,
|
||||
Solution = solution,
|
||||
Device = proof
|
||||
}, ct);
|
||||
|
||||
/// <summary>Renouvellement de la session mémorisée : un nouveau jeton de renouvellement remplace celui présenté.</summary>
|
||||
public async Task<PlayerSession> RefreshAsync(string refreshToken, IPlayerDeviceKey key, CancellationToken ct = default)
|
||||
{
|
||||
if (!PlayerTokens.TryParseRefresh(refreshToken, out _, out _))
|
||||
throw new ArgumentException("Jeton de renouvellement mal formé.", nameof(refreshToken));
|
||||
var (challenge, _) = await ChallengeAsync(PlayerChallengePurposes.Refresh, ct).ConfigureAwait(false);
|
||||
var signature = await SignAsync(key, PlayerChallengePurposes.Refresh, challenge, ct).ConfigureAwait(false);
|
||||
return Check(await _client.SendAsync<PlayerSession>(HttpMethod.Post, ApiRoutes.AccountRefresh,
|
||||
LauncherApiClient.Serialize(new PlayerRefreshRequest
|
||||
{
|
||||
RefreshToken = refreshToken,
|
||||
ChallengeId = challenge.ChallengeId,
|
||||
Signature = signature
|
||||
}), null, ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
public Task<PlayerMeResponse> MeAsync(string accessToken, CancellationToken ct = default) =>
|
||||
_client.SendAsync<PlayerMeResponse>(HttpMethod.Get, ApiRoutes.AccountMe, null, Bearer(accessToken), ct);
|
||||
|
||||
public Task<PlayerActivityResponse> ActivityAsync(string accessToken, CancellationToken ct = default) =>
|
||||
_client.SendAsync<PlayerActivityResponse>(HttpMethod.Get, ApiRoutes.AccountActivity, null, Bearer(accessToken), ct);
|
||||
|
||||
/// <param name="token">Jeton d'accès, ou jeton limité « changement de mot de passe exigé ».</param>
|
||||
public async Task<PlayerChangePasswordResponse> ChangePasswordAsync(string token, string currentPassword, string newPassword,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var response = await _client.SendAsync<PlayerChangePasswordResponse>(HttpMethod.Post, ApiRoutes.AccountPassword,
|
||||
LauncherApiClient.Serialize(new PlayerChangePasswordRequest { CurrentPassword = currentPassword, NewPassword = newPassword }),
|
||||
Bearer(token), ct).ConfigureAwait(false);
|
||||
if (response.Session is { } session) Check(session);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(string accessToken, bool allDevices, CancellationToken ct = default) =>
|
||||
await _client.SendAsync<NoContent>(HttpMethod.Post, ApiRoutes.AccountLogout,
|
||||
LauncherApiClient.Serialize(new PlayerLogoutRequest { AllDevices = allDevices }), Bearer(accessToken), ct).ConfigureAwait(false);
|
||||
|
||||
public async Task RemoveDeviceAsync(string accessToken, string deviceId, CancellationToken ct = default)
|
||||
{
|
||||
if (!PlayerIds.IsValidId(deviceId)) throw new ArgumentException("Identifiant d'appareil invalide.", nameof(deviceId));
|
||||
await _client.SendAsync<NoContent>(HttpMethod.Delete, ApiRoutes.AccountDevice(deviceId), null, Bearer(accessToken), ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Export de toutes ses données (document JSON, vérifié avant d'être rendu), mot de passe exigé.</summary>
|
||||
public async Task<byte[]> ExportAsync(string accessToken, string password, CancellationToken ct = default)
|
||||
{
|
||||
var bytes = await _client.SendAsync<byte[]>(HttpMethod.Post, ApiRoutes.AccountExport,
|
||||
LauncherApiClient.Serialize(new PlayerPasswordConfirmation { Password = password }), Bearer(accessToken), ct,
|
||||
timeout: TimeSpan.FromMinutes(1), maxResponseBytes: MaxExportBytes).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(bytes);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object ||
|
||||
!document.RootElement.TryGetProperty("format", out var format) || format.GetString() != PlayerDataExport.Format)
|
||||
throw new JsonException("format inattendu");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new ApiException(200, "invalid_response", "Le serveur a renvoyé un export illisible.", ex);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>Suppression DÉFINITIVE du compte (mot de passe exigé).</summary>
|
||||
public async Task DeleteAccountAsync(string accessToken, string password, CancellationToken ct = default) =>
|
||||
await _client.SendAsync<NoContent>(HttpMethod.Post, ApiRoutes.AccountDelete,
|
||||
LauncherApiClient.Serialize(new PlayerPasswordConfirmation { Password = password }), Bearer(accessToken), ct).ConfigureAwait(false);
|
||||
|
||||
// ------------------------------------------------------------------ Défi, preuve de travail, clé d'appareil
|
||||
|
||||
private async Task<T> SendWithDeviceAsync<T>(string purpose, string route, IPlayerDeviceKey key, string deviceLabel,
|
||||
Func<PlayerChallengeResponse, string, PlayerDeviceProof, object> build, CancellationToken ct)
|
||||
{
|
||||
var (challenge, solution) = await ChallengeAsync(purpose, ct).ConfigureAwait(false);
|
||||
var proof = new PlayerDeviceProof
|
||||
{
|
||||
PublicKey = Convert.ToBase64String(key.PublicKey),
|
||||
Signature = await SignAsync(key, purpose, challenge, ct).ConfigureAwait(false),
|
||||
Label = deviceLabel
|
||||
};
|
||||
var request = build(challenge, solution, proof);
|
||||
var response = await _client.SendAsync<T>(HttpMethod.Post, route, JsonSerializer.SerializeToUtf8Bytes(request, request.GetType(), ApiJson.Options),
|
||||
null, ct, timeout: TimeSpan.FromMinutes(1)).ConfigureAwait(false);
|
||||
if (response is PlayerLoginResponse { Session: { } session }) Check(session);
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Défi du serveur, contrôlé (usage, origine, difficulté, valeurs bornées), puis preuve de travail résolue hors du fil
|
||||
/// de l'interface (« » si la difficulté est nulle : renouvellement).
|
||||
/// </summary>
|
||||
private async Task<(PlayerChallengeResponse Challenge, string Solution)> ChallengeAsync(string purpose, CancellationToken ct)
|
||||
{
|
||||
var challenge = await _client.SendAsync<PlayerChallengeResponse>(HttpMethod.Post, ApiRoutes.AccountChallenge,
|
||||
LauncherApiClient.Serialize(new PlayerChallengeRequest { Purpose = purpose }), null, ct).ConfigureAwait(false);
|
||||
if (challenge.Purpose != purpose || challenge.Difficulty is < 0 or > FeedbackProof.MaxDifficulty ||
|
||||
!IsPlainToken(challenge.ChallengeId, 512) || !IsPlainToken(challenge.ServerId, 128) ||
|
||||
!Base64Url.TryDecode(challenge.Nonce, out var nonce) || nonce.Length is 0 or > 64)
|
||||
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible.");
|
||||
_client.EnsureSameOrigin(challenge.Origin);
|
||||
var solution = challenge.Difficulty == 0
|
||||
? ""
|
||||
: await Task.Run(() => FeedbackProof.Solve(nonce, challenge.Difficulty, ct), ct).ConfigureAwait(false);
|
||||
return (challenge, solution);
|
||||
}
|
||||
|
||||
private async Task<string> SignAsync(IPlayerDeviceKey key, string purpose, PlayerChallengeResponse challenge, CancellationToken ct)
|
||||
{
|
||||
var message = PlayerMessages.Device(purpose, challenge.Origin, challenge.ServerId, challenge.ChallengeId, challenge.Nonce, key.KeyId);
|
||||
return Convert.ToBase64String(await key.SignAsync(message, ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
/// <summary>Session reçue : jetons bien formés (ils seront renvoyés tels quels dans un en-tête puis enregistrés).</summary>
|
||||
private static PlayerSession Check(PlayerSession session)
|
||||
{
|
||||
if (!session.AccessToken.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) || !IsPlainToken(session.AccessToken, PlayerTokens.MaxLength) ||
|
||||
!PlayerTokens.TryParseRefresh(session.RefreshToken, out var deviceId, out _) || deviceId != session.DeviceId)
|
||||
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible (session).");
|
||||
return session;
|
||||
}
|
||||
|
||||
private static Action<HttpRequestMessage> Bearer(string token)
|
||||
{
|
||||
if (!IsPlainToken(token, PlayerTokens.MaxLength) ||
|
||||
!(token.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) || token.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal)))
|
||||
throw new ArgumentException("Jeton de session invalide.", nameof(token));
|
||||
return request => request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
/// <summary>Jeton ou identifiant « simple » : caractères ASCII visibles uniquement, longueur bornée.</summary>
|
||||
private static bool IsPlainToken(string? value, int maxLength) =>
|
||||
value is { Length: > 0 } && value.Length <= maxLength && value.All(c => c is > ' ' and < '\u007f');
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
using System.Globalization;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Contracts.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Règles des mots de passe des comptes joueurs, appliquées À L'IDENTIQUE par le launcher (retour immédiat pendant la
|
||||
/// saisie) et par le serveur (seul juge). Inspirées de la recommandation CNIL n° 2022-100 et de l'OWASP :
|
||||
/// • 12 caractères au moins avec 3 types parmi minuscules, majuscules, chiffres, symboles ; ou 16 caractères au moins
|
||||
/// sans contrainte (phrase de passe) ; 128 au plus ;
|
||||
/// • refus des mots de passe les plus utilisés (dictionnaire public de 100 000 mots de passe) et de leurs variantes
|
||||
/// simples (« P@ssw0rd2024! ») ;
|
||||
/// • refus des suites triviales (répétitions, suites de clavier ou de chiffres) ;
|
||||
/// • refus d'un mot de passe tiré de l'adresse e-mail ou du nom du jeu / du studio.
|
||||
/// Le mot de passe est contrôlé et haché TEL QU'IL EST SAISI (aucune normalisation Unicode) : le serveur tourne en mode
|
||||
/// « globalisation invariante » (sans ICU), où la normalisation n'est pas disponible ; launcher et serveur appliquent
|
||||
/// donc exactement le même calcul. Les claviers Windows produisent des caractères accentués déjà composés.
|
||||
/// </summary>
|
||||
public static class PasswordRules
|
||||
{
|
||||
/// <summary>Version des règles (annoncée par le serveur ; changer si les règles évoluent).</summary>
|
||||
public const string Version = "1";
|
||||
|
||||
public const int MinLength = 12;
|
||||
public const int MinLengthWithoutComposition = 16;
|
||||
public const int MaxLength = 128;
|
||||
public const int RequiredClasses = 3;
|
||||
|
||||
/// <summary>Taille maximale d'un mot de passe en UTF-8 (128 caractères de 4 octets).</summary>
|
||||
public const int MaxUtf8Bytes = 512;
|
||||
|
||||
/// <summary>Longueur minimale d'un « cœur » comparé au dictionnaire (en dessous, la comparaison n'a pas de sens).</summary>
|
||||
private const int MinCoreLength = 4;
|
||||
|
||||
/// <summary>Après retrait de l'adresse e-mail ou du nom du jeu, il doit rester au moins ce nombre de caractères.</summary>
|
||||
private const int MinRemainingLength = 8;
|
||||
|
||||
/// <summary>Mots propres au studio et aux jeux : un mot de passe construit dessus est refusé.</summary>
|
||||
private static readonly string[] ProductWords =
|
||||
{
|
||||
"lanisysgames", "lanisys", "gamelauncher", "launcher", "exovivalzombiereborn", "exovivalzombie", "exovival",
|
||||
"zombiereborn", "zombie"
|
||||
};
|
||||
|
||||
/// <summary>Mots de passe français très courants (complément du dictionnaire, majoritairement anglophone).</summary>
|
||||
private static readonly string[] FrenchWords =
|
||||
{
|
||||
"azerty", "azertyuiop", "motdepasse", "monmotdepasse", "soleil", "doudou", "loulou", "chouchou", "bonjour",
|
||||
"coucou", "jetaime", "marseille", "paris", "camille", "chocolat", "princesse", "nicolas", "julien", "doudoune",
|
||||
"bisounours", "vacances", "football", "olympique", "toulouse", "bordeaux", "nantes", "liberte", "france",
|
||||
"motdepasse123", "123soleil", "chaton", "cheval", "minou", "nounours", "tresor", "amour", "ordinateur"
|
||||
};
|
||||
|
||||
/// <summary>Rangées de claviers (AZERTY, QWERTY, QWERTZ) et suites courantes.</summary>
|
||||
private static readonly string[] Sequences =
|
||||
{
|
||||
"azertyuiop", "qsdfghjklm", "wxcvbn", "qwertyuiop", "asdfghjkl", "zxcvbnm", "qwertzuiop", "yxcvbnm",
|
||||
"1234567890", "0123456789", "abcdefghijklmnopqrstuvwxyz", "&é\"'(-è_çà", "azertyuiopqsdfghjklmwxcvbn",
|
||||
"qwertyuiopasdfghjklzxcvbnm", "1qaz2wsx3edc4rfv", "1a2z3e4r5t6y", "a1z2e3r4t5y6"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Erreurs du mot de passe (messages en français, prêts à afficher). Liste vide : mot de passe accepté.
|
||||
/// <paramref name="email"/> : adresse du compte (facultative), pour refuser un mot de passe qui en est tiré.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> Check(string? password, string? email = null)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
errors.Add("Le mot de passe est obligatoire.");
|
||||
return errors;
|
||||
}
|
||||
|
||||
var normalized = password;
|
||||
var length = RuneCount(normalized);
|
||||
if (normalized.Any(char.IsControl) || HasInvalidUnicode(normalized))
|
||||
errors.Add("Le mot de passe contient un caractère non autorisé (retour à la ligne, tabulation…).");
|
||||
if (length > MaxLength || Encoding.UTF8.GetByteCount(normalized) > MaxUtf8Bytes)
|
||||
{
|
||||
errors.Add($"Le mot de passe ne doit pas dépasser {MaxLength} caractères.");
|
||||
return errors;
|
||||
}
|
||||
if (length < MinLength)
|
||||
{
|
||||
errors.Add($"Le mot de passe doit contenir au moins {MinLength} caractères.");
|
||||
return errors;
|
||||
}
|
||||
if (length < MinLengthWithoutComposition && CountClasses(normalized) < RequiredClasses)
|
||||
errors.Add($"Entre {MinLength} et {MinLengthWithoutComposition - 1} caractères, le mot de passe doit mélanger au moins " +
|
||||
$"{RequiredClasses} types de caractères parmi : minuscules, majuscules, chiffres, symboles. " +
|
||||
$"Vous pouvez aussi choisir une phrase de passe d'au moins {MinLengthWithoutComposition} caractères.");
|
||||
if (errors.Count > 0) return errors;
|
||||
|
||||
var lower = normalized.ToLowerInvariant();
|
||||
if (IsTrivial(lower))
|
||||
errors.Add("Le mot de passe est une suite trop simple (répétition, suite de clavier, de lettres ou de chiffres).");
|
||||
else if (IsCommon(lower))
|
||||
errors.Add("Ce mot de passe fait partie des mots de passe les plus utilisés (ou en est une simple variante) : " +
|
||||
"choisissez-en un autre.");
|
||||
else if (IsDerivedFrom(lower, email))
|
||||
errors.Add("Le mot de passe ressemble trop à votre adresse e-mail ou au nom du jeu : choisissez-en un autre.");
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>Vrai si le mot de passe est accepté.</summary>
|
||||
public static bool IsAcceptable(string? password, string? email = null) => Check(password, email).Count == 0;
|
||||
|
||||
// ------------------------------------------------------------------ Composition
|
||||
|
||||
private static int RuneCount(string text)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var _ in text.EnumerateRunes()) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>Demi-caractère (surrogate) isolé : texte mal formé.</summary>
|
||||
private static bool HasInvalidUnicode(string text)
|
||||
{
|
||||
for (var i = 0; i < text.Length; i++)
|
||||
{
|
||||
if (char.IsHighSurrogate(text[i]))
|
||||
{
|
||||
if (i + 1 >= text.Length || !char.IsLowSurrogate(text[i + 1])) return true;
|
||||
i++;
|
||||
}
|
||||
else if (char.IsLowSurrogate(text[i]))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int CountClasses(string text)
|
||||
{
|
||||
bool lower = false, upper = false, digit = false, other = false;
|
||||
foreach (var rune in text.EnumerateRunes())
|
||||
{
|
||||
if (Rune.IsLower(rune)) lower = true;
|
||||
else if (Rune.IsUpper(rune)) upper = true;
|
||||
else if (Rune.IsDigit(rune)) digit = true;
|
||||
else other = true; // symboles, espaces, lettres sans casse
|
||||
}
|
||||
return (lower ? 1 : 0) + (upper ? 1 : 0) + (digit ? 1 : 0) + (other ? 1 : 0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Suites triviales
|
||||
|
||||
private static bool IsTrivial(string lower)
|
||||
{
|
||||
var compact = new string(lower.Where(c => !char.IsWhiteSpace(c)).ToArray());
|
||||
if (compact.Distinct().Count() <= 3) return true; // « aaaaaaaaaaaa », « abababababab », « 121212121212 »
|
||||
|
||||
foreach (var candidate in new[] { compact, Core(compact), Leet(Core(compact)) })
|
||||
{
|
||||
if (candidate.Length < MinCoreLength) continue;
|
||||
if (IsRepeatedPattern(candidate)) return true;
|
||||
foreach (var sequence in Sequences)
|
||||
{
|
||||
if (IsInsideRepeated(candidate, sequence) || IsInsideRepeated(candidate, Reverse(sequence))) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Texte formé d'un même motif répété (« azeazeazeaze », « 123412341234 »).</summary>
|
||||
private static bool IsRepeatedPattern(string text)
|
||||
{
|
||||
for (var size = 1; size <= text.Length / 2; size++)
|
||||
{
|
||||
if (text.Length % size != 0) continue;
|
||||
var pattern = text[..size];
|
||||
var repeated = true;
|
||||
for (var i = size; i < text.Length && repeated; i += size)
|
||||
repeated = string.CompareOrdinal(text, i, pattern, 0, size) == 0;
|
||||
if (repeated) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Le texte est contenu dans la suite répétée (« 345678901234 » dans « 1234567890 » × 3).</summary>
|
||||
private static bool IsInsideRepeated(string text, string sequence)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
while (builder.Length < text.Length + sequence.Length) builder.Append(sequence);
|
||||
return builder.ToString().Contains(text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string Reverse(string text)
|
||||
{
|
||||
var chars = text.ToCharArray();
|
||||
Array.Reverse(chars);
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Dictionnaire
|
||||
|
||||
private static bool IsCommon(string lower)
|
||||
{
|
||||
var words = Blocklist.Value;
|
||||
foreach (var candidate in Candidates(lower))
|
||||
if (candidate.Length >= MinCoreLength && words.Contains(candidate)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Formes comparées au dictionnaire : telle quelle, sans chiffres/symboles au début et à la fin, puis
|
||||
/// avec les substitutions courantes (« p@ssw0rd » → « password »).</summary>
|
||||
private static IEnumerable<string> Candidates(string lower)
|
||||
{
|
||||
yield return lower;
|
||||
var compact = new string(lower.Where(c => !char.IsWhiteSpace(c)).ToArray());
|
||||
yield return compact;
|
||||
var core = Core(compact);
|
||||
yield return core;
|
||||
yield return Leet(core);
|
||||
yield return Core(Leet(compact));
|
||||
}
|
||||
|
||||
/// <summary>Retire chiffres, espaces et symboles au début et à la fin (« !!dragon2024 » → « dragon »).</summary>
|
||||
internal static string Core(string text)
|
||||
{
|
||||
var start = 0;
|
||||
var end = text.Length;
|
||||
while (start < end && !char.IsLetter(text[start])) start++;
|
||||
while (end > start && !char.IsLetter(text[end - 1])) end--;
|
||||
return text[start..end];
|
||||
}
|
||||
|
||||
/// <summary>Substitutions courantes chiffres/symboles → lettres.</summary>
|
||||
internal static string Leet(string text)
|
||||
{
|
||||
var chars = text.ToCharArray();
|
||||
for (var i = 0; i < chars.Length; i++)
|
||||
{
|
||||
chars[i] = chars[i] switch
|
||||
{
|
||||
'0' => 'o',
|
||||
'1' or '!' or '|' => 'i',
|
||||
'3' or '€' => 'e',
|
||||
'4' or '@' => 'a',
|
||||
'5' or '$' => 's',
|
||||
'7' or '+' => 't',
|
||||
'8' => 'b',
|
||||
'9' => 'g',
|
||||
_ => chars[i]
|
||||
};
|
||||
}
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dictionnaire chargé à la première utilisation (ressource intégrée, ~100 000 entrées) : chaque entrée est gardée
|
||||
/// en minuscules et sous sa forme normalisée (sans chiffres/symboles aux extrémités, substitutions retirées).
|
||||
/// </summary>
|
||||
private static readonly Lazy<HashSet<string>> Blocklist = new(LoadBlocklist, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
/// <summary>Nombre de formes connues du dictionnaire (diagnostic et tests ; le charge s'il ne l'est pas encore).</summary>
|
||||
public static int BlocklistSize => Blocklist.Value.Count;
|
||||
|
||||
private static HashSet<string> LoadBlocklist()
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.Ordinal);
|
||||
void Add(string word)
|
||||
{
|
||||
var lower = word.ToLowerInvariant();
|
||||
if (lower.Length >= MinCoreLength) set.Add(lower);
|
||||
var core = Leet(Core(lower));
|
||||
if (core.Length >= MinCoreLength) set.Add(core);
|
||||
}
|
||||
|
||||
using (var stream = typeof(PasswordRules).Assembly.GetManifestResourceStream(BlocklistResource)
|
||||
?? throw new InvalidOperationException("Dictionnaire des mots de passe absent de GameLauncher.Contracts."))
|
||||
using (var gzip = new GZipStream(stream, CompressionMode.Decompress))
|
||||
using (var reader = new StreamReader(gzip, Encoding.UTF8))
|
||||
{
|
||||
while (reader.ReadLine() is { } line)
|
||||
if (line.Length > 0) Add(line);
|
||||
}
|
||||
foreach (var word in FrenchWords) Add(word);
|
||||
return set; // les mots du studio et des jeux sont traités à part (IsDerivedFrom), avec un message plus précis
|
||||
|
||||
}
|
||||
|
||||
internal const string BlocklistResource = "GameLauncher.Contracts.Accounts.password-blocklist.txt.gz";
|
||||
|
||||
// ------------------------------------------------------------------ Adresse e-mail et nom du jeu
|
||||
|
||||
private static bool IsDerivedFrom(string lower, string? email)
|
||||
{
|
||||
var words = new List<string>(ProductWords);
|
||||
if (PlayerEmail.Normalize(email) is { } normalized)
|
||||
{
|
||||
var at = normalized.IndexOf('@');
|
||||
var local = normalized[..at];
|
||||
words.Add(local);
|
||||
words.AddRange(local.Split('.', '_', '-', '+').Where(p => p.Length >= 3));
|
||||
var domainLabel = normalized[(at + 1)..].Split('.')[0];
|
||||
if (domainLabel.Length >= 4) words.Add(domainLabel);
|
||||
}
|
||||
|
||||
foreach (var form in new[] { lower, Leet(lower) })
|
||||
{
|
||||
var remaining = new string(form.Where(char.IsLetterOrDigit).ToArray());
|
||||
var removedAny = false;
|
||||
foreach (var word in words.Where(w => w.Length >= 3).OrderByDescending(w => w.Length))
|
||||
{
|
||||
var clean = new string(word.Where(char.IsLetterOrDigit).ToArray());
|
||||
if (clean.Length < 3 || !remaining.Contains(clean, StringComparison.Ordinal)) continue;
|
||||
remaining = remaining.Replace(clean, "", StringComparison.Ordinal);
|
||||
removedAny = true;
|
||||
}
|
||||
if (removedAny && remaining.Length < MinRemainingLength) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
namespace GameLauncher.Contracts.Accounts;
|
||||
|
||||
// Comptes joueurs (phase 8). Routes sous « v1/account/… » : aucune n'est une route d'administration, et aucune route
|
||||
// d'administration n'accepte un jeton joueur. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.
|
||||
|
||||
/// <summary>État du service des comptes, annoncé dans GET v1/info (null : serveur sans comptes joueurs).</summary>
|
||||
public sealed record PlayerAccountsInfo
|
||||
{
|
||||
/// <summary>Connexion possible (le launcher affiche le bouton « Compte »).</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>Création de compte ouverte.</summary>
|
||||
public bool RegistrationOpen { get; init; }
|
||||
|
||||
/// <summary>Version des conditions d'utilisation et de la politique de confidentialité à accepter à la création.</summary>
|
||||
public string TermsVersion { get; init; } = "";
|
||||
|
||||
/// <summary>Version des règles des mots de passe (<see cref="PasswordRules.Version"/>).</summary>
|
||||
public string PasswordRulesVersion { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Demande d'un défi (POST v1/account/challenge).</summary>
|
||||
public sealed record PlayerChallengeRequest
|
||||
{
|
||||
/// <summary>Une valeur de <see cref="PlayerChallengePurposes"/>.</summary>
|
||||
public string Purpose { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Défi joueur, SANS ÉTAT sur le serveur, à usage unique : il porte l'usage, l'échéance et la difficulté de la preuve de
|
||||
/// travail (0 : aucune, renouvellement de session). Le launcher résout la preuve de travail
|
||||
/// (<see cref="Feedback.FeedbackProof"/>) puis signe <see cref="PlayerMessages.Device"/> avec sa clé d'appareil.
|
||||
/// </summary>
|
||||
public sealed record PlayerChallengeResponse
|
||||
{
|
||||
public string ChallengeId { get; init; } = "";
|
||||
public string Nonce { get; init; } = "";
|
||||
public string Purpose { get; init; } = "";
|
||||
public int Difficulty { get; init; }
|
||||
public string ServerId { get; init; } = "";
|
||||
public string Origin { get; init; } = "";
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Preuve de possession de la clé d'appareil (création de compte, connexion).</summary>
|
||||
public sealed record PlayerDeviceProof
|
||||
{
|
||||
/// <summary>Clé publique de l'appareil (SPKI ECDSA P-256, Base64).</summary>
|
||||
public string PublicKey { get; init; } = "";
|
||||
|
||||
/// <summary>Signature (Base64, 64 octets) de <see cref="PlayerMessages.Device"/>.</summary>
|
||||
public string Signature { get; init; } = "";
|
||||
|
||||
/// <summary>Nom générique de l'appareil (« Windows 11 · GameLauncher 3.4.0 »), jamais le nom Windows du PC.</summary>
|
||||
public string? Label { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Création de compte (POST v1/account/register).</summary>
|
||||
public sealed record PlayerRegisterRequest
|
||||
{
|
||||
public string Email { get; init; } = "";
|
||||
public string Password { get; init; } = "";
|
||||
public string ChallengeId { get; init; } = "";
|
||||
|
||||
/// <summary>Solution de la preuve de travail du défi.</summary>
|
||||
public string Solution { get; init; } = "";
|
||||
|
||||
/// <summary>Version des conditions acceptées par le joueur (doit être celle annoncée par le serveur).</summary>
|
||||
public string AcceptedTermsVersion { get; init; } = "";
|
||||
|
||||
public PlayerDeviceProof Device { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Connexion (POST v1/account/login).</summary>
|
||||
public sealed record PlayerLoginRequest
|
||||
{
|
||||
public string Email { get; init; } = "";
|
||||
public string Password { get; init; } = "";
|
||||
public string ChallengeId { get; init; } = "";
|
||||
public string Solution { get; init; } = "";
|
||||
public PlayerDeviceProof Device { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Renouvellement de session au lancement du launcher (POST v1/account/refresh).</summary>
|
||||
public sealed record PlayerRefreshRequest
|
||||
{
|
||||
public string RefreshToken { get; init; } = "";
|
||||
public string ChallengeId { get; init; } = "";
|
||||
|
||||
/// <summary>Signature (Base64) de <see cref="PlayerMessages.Device"/> (usage « refresh ») par la clé de l'appareil.</summary>
|
||||
public string Signature { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Résultats possibles d'une connexion réussie (mot de passe correct).</summary>
|
||||
public static class PlayerLoginStatus
|
||||
{
|
||||
/// <summary>Connecté : <see cref="PlayerLoginResponse.Session"/>.</summary>
|
||||
public const string Ok = "ok";
|
||||
|
||||
/// <summary>
|
||||
/// Un administrateur exige un nouveau mot de passe : <see cref="PlayerLoginResponse.PasswordChangeToken"/> permet
|
||||
/// seulement d'appeler POST v1/account/password ; la session est ouverte une fois le mot de passe changé.
|
||||
/// </summary>
|
||||
public const string PasswordChangeRequired = "password_change_required";
|
||||
}
|
||||
|
||||
public sealed record PlayerLoginResponse
|
||||
{
|
||||
public string Status { get; init; } = PlayerLoginStatus.Ok;
|
||||
public PlayerSession? Session { get; init; }
|
||||
public string? PasswordChangeToken { get; init; }
|
||||
public DateTimeOffset? PasswordChangeExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Session ouverte (création de compte, connexion, renouvellement, changement de mot de passe exigé).</summary>
|
||||
public sealed record PlayerSession
|
||||
{
|
||||
/// <summary>Jeton d'accès (« Authorization: Bearer … »), court, à garder en mémoire uniquement.</summary>
|
||||
public string AccessToken { get; init; } = "";
|
||||
|
||||
public DateTimeOffset AccessExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Jeton de renouvellement (connexion mémorisée), remplacé à chaque renouvellement : à enregistrer chiffré sur le PC
|
||||
/// (DPAPI) à la place du précédent. Inutilisable sans la clé de l'appareil.
|
||||
/// </summary>
|
||||
public string RefreshToken { get; init; } = "";
|
||||
|
||||
/// <summary>Échéance du jeton de renouvellement (sans utilisation ; repoussée à chaque renouvellement, dans la limite d'un an).</summary>
|
||||
public DateTimeOffset RefreshExpiresAt { get; init; }
|
||||
|
||||
public string DeviceId { get; init; } = "";
|
||||
public PlayerAccountInfo Account { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Compte du joueur connecté (adresse masquée : l'adresse complète n'est jamais renvoyée).</summary>
|
||||
public sealed record PlayerAccountInfo
|
||||
{
|
||||
/// <summary>Référence du compte (« PL-7KQ2-M9XD »), à communiquer au support.</summary>
|
||||
public string Reference { get; init; } = "";
|
||||
|
||||
public string EmailMasked { get; init; } = "";
|
||||
public bool EmailVerified { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? LastLoginAt { get; init; }
|
||||
public DateTimeOffset PasswordChangedAt { get; init; }
|
||||
public bool OtpEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suspension de sécurité après de nombreux essais de mot de passe : les connexions depuis un NOUVEL appareil sont
|
||||
/// refusées jusqu'à ce qu'un administrateur la lève ; cet appareil-ci (déjà connecté) continue de fonctionner.
|
||||
/// </summary>
|
||||
public bool NewDevicesSuspended { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Un appareil connecté au compte (« Mes appareils »).</summary>
|
||||
public sealed record PlayerDeviceInfo
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public string Label { get; init; } = "";
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset LastUsedAt { get; init; }
|
||||
|
||||
/// <summary>Appareil de la session en cours.</summary>
|
||||
public bool Current { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>GET v1/account/me.</summary>
|
||||
public sealed record PlayerMeResponse
|
||||
{
|
||||
public PlayerAccountInfo Account { get; init; } = new();
|
||||
public IReadOnlyList<PlayerDeviceInfo> Devices { get; init; } = Array.Empty<PlayerDeviceInfo>();
|
||||
}
|
||||
|
||||
/// <summary>Changement de mot de passe (POST v1/account/password), avec une session ou un jeton « changement exigé ».</summary>
|
||||
public sealed record PlayerChangePasswordRequest
|
||||
{
|
||||
public string CurrentPassword { get; init; } = "";
|
||||
public string NewPassword { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record PlayerChangePasswordResponse
|
||||
{
|
||||
/// <summary>Session ouverte après un changement EXIGÉ (jeton limité) ; null pour un changement ordinaire.</summary>
|
||||
public PlayerSession? Session { get; init; }
|
||||
|
||||
/// <summary>Nombre d'autres appareils déconnectés.</summary>
|
||||
public int DevicesSignedOut { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Déconnexion (POST v1/account/logout).</summary>
|
||||
public sealed record PlayerLogoutRequest
|
||||
{
|
||||
/// <summary>Vrai : tous les appareils du compte (« Déconnecter tous mes appareils »).</summary>
|
||||
public bool AllDevices { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Mot de passe actuel, exigé pour les actions sensibles : export des données (POST v1/account/export), suppression du
|
||||
/// compte (POST v1/account/delete).</summary>
|
||||
public sealed record PlayerPasswordConfirmation
|
||||
{
|
||||
public string Password { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>GET v1/account/activity : activité récente du compte, la plus récente d'abord.</summary>
|
||||
public sealed record PlayerActivityResponse
|
||||
{
|
||||
public IReadOnlyList<PlayerActivityEntry> Entries { get; init; } = Array.Empty<PlayerActivityEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Une ligne de l'activité du compte. Le libellé est calculé par le launcher (<see cref="PlayerActivityText"/>) ; aucun détail
|
||||
/// interne n'est renvoyé, et l'adresse IP est MASQUÉE (« 203.0.113.x ») : elle peut être celle d'un tiers (essai de mot de passe).
|
||||
/// </summary>
|
||||
public sealed record PlayerActivityEntry
|
||||
{
|
||||
public DateTimeOffset At { get; init; }
|
||||
|
||||
/// <summary>Type d'événement (« account.login »…), voir <see cref="PlayerActivityText"/>.</summary>
|
||||
public string Type { get; init; } = "";
|
||||
|
||||
/// <summary>Faux : tentative refusée (mot de passe incorrect, connexion bloquée…).</summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>Nom générique de l'appareil s'il est toujours connecté au compte, sinon null.</summary>
|
||||
public string? Device { get; init; }
|
||||
|
||||
/// <summary>Événement venu de l'appareil de la session en cours.</summary>
|
||||
public bool ThisDevice { get; init; }
|
||||
|
||||
/// <summary>Adresse IP masquée, ou null (effacée après quelques mois, ou action du système).</summary>
|
||||
public string? Ip { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Document d'export des données d'un joueur (POST v1/account/export), lisible par machine (JSON).</summary>
|
||||
public static class PlayerDataExport
|
||||
{
|
||||
/// <summary>Valeur de la propriété « format » du document.</summary>
|
||||
public const string Format = "GameLauncher/PlayerData/v1";
|
||||
|
||||
/// <summary>Nom de fichier proposé à l'enregistrement (« donnees-compte-PL-7KQ2-M9XD-2026-11-11.json »).</summary>
|
||||
public static string SuggestedFileName(string reference, DateTimeOffset date)
|
||||
{
|
||||
var safe = new string((reference ?? "").Where(c => char.IsAsciiLetterOrDigit(c) || c == '-').ToArray());
|
||||
return $"donnees-compte-{(safe.Length > 0 ? safe : "joueur")}-{date:yyyy-MM-dd}.json";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace GameLauncher.Contracts.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Libellés français des événements du journal des comptes joueurs (« Activité récente » du launcher, export des données,
|
||||
/// et plus tard le menu d'administration). Un type inconnu (serveur plus récent) est affiché tel quel.
|
||||
/// </summary>
|
||||
public static class PlayerActivityText
|
||||
{
|
||||
public static string Describe(string type, bool success) => type switch
|
||||
{
|
||||
"account.register" => "Création du compte",
|
||||
"account.login" => success ? "Connexion" : "Connexion refusée (compte désactivé ou suspendu)",
|
||||
"account.login_failed" => "Mot de passe incorrect",
|
||||
"account.locked" => "Connexion bloquée temporairement (trop d'essais)",
|
||||
"account.suspended" => "Nouveaux appareils suspendus (trop d'essais)",
|
||||
"account.unlocked" => "Suspension levée",
|
||||
"account.disabled" => "Compte désactivé",
|
||||
"account.enabled" => "Compte réactivé",
|
||||
"account.inactive" => "Compte mis en sommeil (inactivité)",
|
||||
"account.reactivated" => "Compte réveillé (connexion après une longue inactivité)",
|
||||
"account.logout" => "Déconnexion",
|
||||
"account.logout_all" => "Déconnexion de tous les appareils",
|
||||
"account.password_change" => success ? "Mot de passe modifié" : "Changement de mot de passe refusé (mot de passe actuel incorrect)",
|
||||
"account.password_change_required" => "Nouveau mot de passe exigé",
|
||||
"account.export" => success ? "Export de vos données" : "Export refusé (mot de passe incorrect)",
|
||||
"account.delete" => success ? "Suppression du compte" : "Suppression du compte refusée (mot de passe incorrect)",
|
||||
"session.refresh" => success ? "Reconnexion automatique" : "Reconnexion automatique refusée",
|
||||
"session.reuse" => "Jeton de connexion réutilisé : appareil déconnecté par précaution",
|
||||
"device.removed" => "Appareil déconnecté",
|
||||
_ => type
|
||||
};
|
||||
|
||||
/// <summary>Vrai si le type a un libellé (sinon <see cref="Describe"/> renvoie le type tel quel).</summary>
|
||||
public static bool IsKnown(string type) => Describe(type, true) != type;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace GameLauncher.Contracts.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Adresse e-mail d'un compte joueur : contrôle et forme normalisée (launcher et serveur). La forme normalisée sert
|
||||
/// d'identifiant de connexion : une même adresse saisie en majuscules ou avec des espaces donne le même compte.
|
||||
/// Règles volontairement simples et strictes : caractères latins sans accents (domaines internationaux sous leur forme
|
||||
/// « xn-- »), un seul « @ », 254 caractères au plus.
|
||||
/// </summary>
|
||||
public static class PlayerEmail
|
||||
{
|
||||
public const int MaxLength = 254;
|
||||
public const int MaxLocalLength = 64;
|
||||
|
||||
private const string LocalSymbols = "!#$%&'*+-/=?^_`{|}~.";
|
||||
|
||||
/// <summary>Adresse normalisée (minuscules, sans espaces autour), ou null si elle n'est pas valable.</summary>
|
||||
public static string? Normalize(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Length > MaxLength + 16) return null;
|
||||
// Pas de normalisation Unicode (indisponible sur le serveur en globalisation invariante) : seuls les caractères
|
||||
// ASCII sont acceptés, de la même façon par le launcher et par le serveur.
|
||||
var text = input.Trim().ToLowerInvariant();
|
||||
if (text.Length > MaxLength) return null;
|
||||
|
||||
var at = text.IndexOf('@');
|
||||
if (at <= 0 || at != text.LastIndexOf('@') || at == text.Length - 1) return null;
|
||||
var local = text[..at];
|
||||
var domain = text[(at + 1)..];
|
||||
if (domain.EndsWith('.')) domain = domain[..^1];
|
||||
|
||||
if (!IsValidLocal(local) || !IsValidDomain(domain)) return null;
|
||||
var result = local + "@" + domain;
|
||||
return result.Length <= MaxLength ? result : null;
|
||||
}
|
||||
|
||||
public static bool IsValid(string? input) => Normalize(input) is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Adresse masquée pour l'affichage (« n***@e***.fr ») : première lettre de la partie locale et du domaine,
|
||||
/// extension conservée. Chaîne vide si l'adresse n'est pas valable.
|
||||
/// </summary>
|
||||
public static string Mask(string? email)
|
||||
{
|
||||
if (Normalize(email) is not { } normalized) return "";
|
||||
var at = normalized.IndexOf('@');
|
||||
var local = normalized[..at];
|
||||
var domain = normalized[(at + 1)..];
|
||||
var lastDot = domain.LastIndexOf('.');
|
||||
return $"{local[0]}***@{domain[0]}***{domain[lastDot..]}";
|
||||
}
|
||||
|
||||
private static bool IsValidLocal(string local)
|
||||
{
|
||||
if (local.Length is 0 or > MaxLocalLength) return false;
|
||||
if (local[0] == '.' || local[^1] == '.' || local.Contains("..", StringComparison.Ordinal)) return false;
|
||||
foreach (var c in local)
|
||||
if (!(c is >= 'a' and <= 'z' or >= '0' and <= '9' || LocalSymbols.Contains(c))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsValidDomain(string domain)
|
||||
{
|
||||
if (domain.Length is 0 or > 253) return false;
|
||||
var labels = domain.Split('.');
|
||||
if (labels.Length < 2) return false;
|
||||
foreach (var label in labels)
|
||||
{
|
||||
if (label.Length is 0 or > 63 || label[0] == '-' || label[^1] == '-') return false;
|
||||
foreach (var c in label)
|
||||
if (!(c is >= 'a' and <= 'z' or >= '0' and <= '9' or '-')) return false;
|
||||
}
|
||||
var tld = labels[^1];
|
||||
return tld.StartsWith("xn--", StringComparison.Ordinal) || (tld.Length >= 2 && tld.All(c => c is >= 'a' and <= 'z'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Contracts.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Messages signés par la CLÉ D'APPAREIL d'un joueur (ECDSA P-256 créée par le launcher, dans la puce TPM si elle existe).
|
||||
/// En-tête propre aux joueurs (« GameLauncher/Player/… ») : une signature de joueur ne vaut jamais pour un message
|
||||
/// d'administrateur (« GameLauncher/Admin/… »), et inversement.
|
||||
///
|
||||
/// La signature prouve que la requête vient de l'appareil qui possède la clé : un fichier de session copié sur un autre PC
|
||||
/// ne permet pas de renouveler la session, et seul un appareil déjà utilisé avec succès est reconnu « de confiance » par
|
||||
/// la protection contre la force brute.
|
||||
/// </summary>
|
||||
public static class PlayerMessages
|
||||
{
|
||||
public const string DeviceHeader = "GameLauncher/Player/Device/v1";
|
||||
|
||||
/// <summary>Message signé par la clé d'appareil pour une création de compte, une connexion ou un renouvellement.</summary>
|
||||
/// <param name="purpose">Une valeur de <see cref="PlayerChallengePurposes"/>.</param>
|
||||
/// <param name="deviceKeyId">Identifiant de la clé d'appareil (<see cref="AdminKeys.ComputeKeyId"/>).</param>
|
||||
public static byte[] Device(string purpose, string origin, string serverId, string challengeId, string nonce, string deviceKeyId) =>
|
||||
SignedMessages.Build(DeviceHeader,
|
||||
("purpose", purpose), ("origin", origin), ("server", serverId), ("challenge", challengeId), ("nonce", nonce),
|
||||
("device", deviceKeyId));
|
||||
}
|
||||
|
||||
/// <summary>Usages d'un défi joueur (le défi est scellé pour un seul usage).</summary>
|
||||
public static class PlayerChallengePurposes
|
||||
{
|
||||
public const string Register = "register";
|
||||
public const string Login = "login";
|
||||
public const string Refresh = "refresh";
|
||||
|
||||
public static bool IsValid(string? purpose) => purpose is Register or Login or Refresh;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats des jetons joueurs. Préfixes distincts de tout jeton administrateur : le serveur rejette un jeton joueur
|
||||
/// présenté à une route d'administration sans même le chercher, et inversement.
|
||||
/// </summary>
|
||||
public static class PlayerTokens
|
||||
{
|
||||
/// <summary>Jeton d'accès (15 min, en mémoire seulement côté launcher) : « glpa_ » + 256 bits.</summary>
|
||||
public const string AccessPrefix = "glpa_";
|
||||
|
||||
/// <summary>Jeton de renouvellement (gardé chiffré sur le PC) : « glpr_ » + identifiant d'appareil + « . » + 256 bits.</summary>
|
||||
public const string RefreshPrefix = "glpr_";
|
||||
|
||||
/// <summary>Jeton limité au changement de mot de passe exigé (10 min) : « glpc_ » + 256 bits.</summary>
|
||||
public const string PasswordChangePrefix = "glpc_";
|
||||
|
||||
public const int MaxLength = 128;
|
||||
|
||||
public static string NewSecret() => Base64Url.Encode(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
/// <summary>Jeton de renouvellement : identifiant de l'appareil (32 hex) et secret, ou faux si mal formé.</summary>
|
||||
public static bool TryParseRefresh(string? token, out string deviceId, out string secret)
|
||||
{
|
||||
deviceId = secret = "";
|
||||
if (token is not { Length: > 40 and <= MaxLength } || !token.StartsWith(RefreshPrefix, StringComparison.Ordinal)) return false;
|
||||
var body = token[RefreshPrefix.Length..];
|
||||
var dot = body.IndexOf('.');
|
||||
if (dot != 32) return false;
|
||||
deviceId = body[..dot];
|
||||
secret = body[(dot + 1)..];
|
||||
return PlayerIds.IsValidId(deviceId) && Base64Url.TryDecode(secret, out var bytes) && bytes.Length == 32;
|
||||
}
|
||||
|
||||
public static string FormatRefresh(string deviceId, string secret) => RefreshPrefix + deviceId + "." + secret;
|
||||
}
|
||||
|
||||
/// <summary>Identifiants des comptes joueurs.</summary>
|
||||
public static class PlayerIds
|
||||
{
|
||||
private const string Crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
|
||||
/// <summary>Identifiant interne (compte, appareil) : 128 bits en hexadécimal minuscule.</summary>
|
||||
public static string NewId() => Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
|
||||
|
||||
public static bool IsValidId(string? id) => id is { Length: 32 } && id.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f');
|
||||
|
||||
/// <summary>
|
||||
/// Référence affichée aux administrateurs et au joueur (« PL-7KQ2-M9XD », 40 bits, sans lettre ambiguë) : désigne un
|
||||
/// compte dans le journal d'audit sans jamais y écrire l'adresse e-mail.
|
||||
/// </summary>
|
||||
public static string NewReference()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(5);
|
||||
ulong value = 0;
|
||||
foreach (var b in bytes) value = (value << 8) | b;
|
||||
var chars = new char[8];
|
||||
for (var i = 7; i >= 0; i--)
|
||||
{
|
||||
chars[i] = Crockford[(int)(value & 31)];
|
||||
value >>= 5;
|
||||
}
|
||||
return $"PL-{new string(chars, 0, 4)}-{new string(chars, 4, 4)}";
|
||||
}
|
||||
|
||||
/// <summary>Référence normalisée (majuscules, tirets), ou null si elle n'est pas valable.</summary>
|
||||
public static string? NormalizeReference(string? reference)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reference) || reference.Length > 20) return null;
|
||||
var text = reference.Trim().ToUpperInvariant();
|
||||
if (text.Length != 12 || !text.StartsWith("PL-", StringComparison.Ordinal) || text[7] != '-') return null;
|
||||
foreach (var c in text[3..7] + text[8..])
|
||||
if (!Crockford.Contains(c)) return null;
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
password-blocklist.txt.gz — dictionnaire des mots de passe les plus utilisés
|
||||
=========================================================================
|
||||
|
||||
Contenu : les 99 839 mots de passe de la liste « 100k-most-used-passwords-NCSC.txt » (un par ligne, UTF-8, doublons
|
||||
retirés, ordre d'origine conservé), compressés en gzip.
|
||||
|
||||
Origine :
|
||||
• Liste publiée par le NCSC (National Cyber Security Centre, Royaume-Uni) : les 100 000 mots de passe les plus
|
||||
fréquents dans les fuites recensées par le service « Have I Been Pwned » (Pwned Passwords).
|
||||
• Copie utilisée : dépôt SecLists (https://github.com/danielmiessler/SecLists),
|
||||
fichier Passwords/Common-Credentials/100k-most-used-passwords-NCSC.txt — licence MIT,
|
||||
© 2018 Daniel Miessler.
|
||||
|
||||
Usage : GameLauncher.Contracts.Accounts.PasswordRules (même contrôle dans le launcher et sur le serveur). La liste est
|
||||
normalisée au chargement (minuscules, substitutions courantes 0→o, 4→a, @→a…, chiffres et symboles de début et de fin
|
||||
retirés) : les variantes simples (« P@ssw0rd2024! ») sont refusées comme l'original.
|
||||
|
||||
Mise à jour : remplacer le fichier .gz par une version plus récente de la même liste (même format), puis relancer les
|
||||
tests (Contracts/PasswordRulesTests).
|
||||
Binary file not shown.
@@ -147,6 +147,48 @@ public static class ApiRoutes
|
||||
/// <summary>Contenu d'une capture d'écran (feedback.read).</summary>
|
||||
public static string GameFeedbackAttachment(string gameId, string id, int index) => $"v1/games/{gameId}/feedback/{id}/attachments/{index}";
|
||||
|
||||
// ------------------------------------------------------------------ Mode maintenance du launcher
|
||||
/// <summary>Mode maintenance : lecture (tout administrateur connecté), publication signée (PUT, maintenance.manage).</summary>
|
||||
public const string Maintenance = "v1/maintenance";
|
||||
|
||||
// ------------------------------------------------------------------ Comptes joueurs (phase 8)
|
||||
// Routes des JOUEURS (jetons « glpa_ ») : aucune n'est une route d'administration, et les routes d'administration
|
||||
// n'acceptent jamais un jeton joueur. Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.
|
||||
|
||||
/// <summary>Défi joueur (création de compte, connexion, renouvellement) : anonyme, sans état sur le serveur.</summary>
|
||||
public const string AccountChallenge = "v1/account/challenge";
|
||||
|
||||
/// <summary>Création de compte (anonyme : preuve de travail + clé d'appareil).</summary>
|
||||
public const string AccountRegister = "v1/account/register";
|
||||
|
||||
/// <summary>Connexion par adresse e-mail et mot de passe (anonyme : preuve de travail + clé d'appareil).</summary>
|
||||
public const string AccountLogin = "v1/account/login";
|
||||
|
||||
/// <summary>Renouvellement de la session mémorisée (jeton de renouvellement + signature de la clé d'appareil).</summary>
|
||||
public const string AccountRefresh = "v1/account/refresh";
|
||||
|
||||
public const string AccountLogout = "v1/account/logout";
|
||||
|
||||
/// <summary>Compte connecté et ses appareils.</summary>
|
||||
public const string AccountMe = "v1/account/me";
|
||||
|
||||
/// <summary>Changement de mot de passe (session, ou jeton « changement exigé »).</summary>
|
||||
public const string AccountPassword = "v1/account/password";
|
||||
|
||||
public const string AccountDevicePattern = "v1/account/devices/{deviceId}";
|
||||
|
||||
/// <summary>Un appareil du compte (DELETE : le déconnecter). Identifiant : 32 caractères hexadécimaux.</summary>
|
||||
public static string AccountDevice(string deviceId) => $"v1/account/devices/{deviceId}";
|
||||
|
||||
/// <summary>Activité récente du compte (connexions, échecs, changements…), adresses IP masquées.</summary>
|
||||
public const string AccountActivity = "v1/account/activity";
|
||||
|
||||
/// <summary>Export de toutes ses données (RGPD : accès et portabilité), mot de passe exigé. Document JSON.</summary>
|
||||
public const string AccountExport = "v1/account/export";
|
||||
|
||||
/// <summary>Suppression définitive du compte par son titulaire (RGPD : effacement), mot de passe exigé.</summary>
|
||||
public const string AccountDelete = "v1/account/delete";
|
||||
|
||||
// ------------------------------------------------------------------ Publication d'une version
|
||||
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
|
||||
public const string PublicationPattern = "v1/publications/{id}";
|
||||
|
||||
@@ -15,6 +15,9 @@ public sealed record ServerInfo
|
||||
|
||||
/// <summary>Algorithmes de clé acceptés (« ECDSA-P256-SHA256 »).</summary>
|
||||
public IReadOnlyList<string> KeyAlgorithms { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Comptes joueurs (phase 8) : null si le serveur ne les propose pas (ou serveur plus ancien).</summary>
|
||||
public Accounts.PlayerAccountsInfo? Accounts { get; init; }
|
||||
}
|
||||
|
||||
public sealed record HealthStatus
|
||||
@@ -77,4 +80,43 @@ public static class ApiErrorCodes
|
||||
|
||||
/// <summary>Demandes des joueurs : défi anti-spam absent, expiré, déjà utilisé ou mal résolu.</summary>
|
||||
public const string ProofInvalid = "proof_invalid";
|
||||
|
||||
// ------------------------------------------------------------------ Comptes joueurs (phase 8)
|
||||
|
||||
/// <summary>Adresse e-mail mal formée.</summary>
|
||||
public const string EmailInvalid = "email_invalid";
|
||||
|
||||
/// <summary>Adresse déjà utilisée par un autre compte (création de compte).</summary>
|
||||
public const string EmailTaken = "email_taken";
|
||||
|
||||
/// <summary>Mot de passe refusé par <see cref="Accounts.PasswordRules"/> (message : les raisons).</summary>
|
||||
public const string PasswordRejected = "password_rejected";
|
||||
|
||||
/// <summary>Adresse ou mot de passe incorrect (message volontairement identique dans les deux cas).</summary>
|
||||
public const string LoginFailed = "login_failed";
|
||||
|
||||
/// <summary>Trop d'essais : connexion bloquée temporairement (en-tête Retry-After).</summary>
|
||||
public const string LoginLocked = "login_locked";
|
||||
|
||||
/// <summary>Compte désactivé par un administrateur (révélé seulement après un mot de passe correct).</summary>
|
||||
public const string AccountDisabled = "account_disabled";
|
||||
|
||||
/// <summary>Compte suspendu après trop d'essais (connexion depuis un NOUVEL appareil refusée) : seul un administrateur
|
||||
/// peut lever la suspension ; les appareils déjà connectés continuent de fonctionner.</summary>
|
||||
public const string AccountSuspended = "account_suspended";
|
||||
|
||||
/// <summary>Un nouveau mot de passe est exigé avant toute autre action.</summary>
|
||||
public const string PasswordChangeRequired = "password_change_required";
|
||||
|
||||
/// <summary>Session mémorisée révoquée, expirée ou réutilisée (vol suspecté) : nouvelle connexion nécessaire.</summary>
|
||||
public const string SessionRevoked = "session_revoked";
|
||||
|
||||
/// <summary>Création de compte fermée, ou bloquée pour cette adresse IP / cet appareil.</summary>
|
||||
public const string RegistrationClosed = "registration_closed";
|
||||
|
||||
/// <summary>Conditions d'utilisation non acceptées ou dans une version dépassée.</summary>
|
||||
public const string TermsNotAccepted = "terms_not_accepted";
|
||||
|
||||
/// <summary>Preuve de possession de la clé d'appareil absente ou invalide.</summary>
|
||||
public const string DeviceProofInvalid = "device_proof_invalid";
|
||||
}
|
||||
@@ -13,4 +13,9 @@
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Comptes joueurs : dictionnaire des mots de passe les plus utilisés (PasswordRules), voir Accounts\Resources\password-blocklist.SOURCE.txt. -->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Accounts\Resources\password-blocklist.txt.gz" LogicalName="GameLauncher.Contracts.Accounts.password-blocklist.txt.gz" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
namespace GameLauncher.Contracts.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance du launcher (maintenance.json, commun à tous les jeux), activé ou désactivé depuis le menu
|
||||
/// d'administration. Fichier PUBLIC servi par nginx à côté du trousseau, signé par la clé d'un administrateur autorisée
|
||||
/// « maintenance » dans le trousseau : les launchers des joueurs le vérifient comme le catalogue avant d'en tenir compte.
|
||||
/// Maintenance active : le launcher passe au rouge / orange, affiche le message au centre et bloque tout, sauf la
|
||||
/// désinstallation des jeux et le menu d'administration.
|
||||
/// Révision 0 = rien n'a jamais été publié (maintenance inactive).
|
||||
/// </summary>
|
||||
public sealed record MaintenanceDocument
|
||||
{
|
||||
public const string FormatName = "gamelauncher-maintenance";
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
public string Format { get; init; } = FormatName;
|
||||
public int Version { get; init; } = CurrentVersion;
|
||||
|
||||
/// <summary>Révision : +1 à chaque publication (couverte par la signature).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Vrai : maintenance en cours.</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Précision facultative affichée aux joueurs sous le texte principal (« Mise à jour des serveurs, retour vers 18 h »).
|
||||
/// Texte brut, sans lien, quelques lignes au plus (<see cref="MaintenanceRules"/>).
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>Début de la maintenance (à la seconde, UTC) ; null quand elle est désactivée. Couvert par la signature.</summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>Date de publication (posée par le serveur, hors signature).</summary>
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>État du mode maintenance pour le menu d'administration (GET v1/maintenance).</summary>
|
||||
public sealed record MaintenanceState
|
||||
{
|
||||
/// <summary>Document en vigueur (révision 0, maintenance inactive, si rien n'a été publié).</summary>
|
||||
public MaintenanceDocument Document { get; init; } = new();
|
||||
|
||||
/// <summary>Publication possible (fichier public configuré sur le serveur).</summary>
|
||||
public bool PublishingEnabled { get; init; }
|
||||
|
||||
/// <summary>Adresse publique du fichier (lue par les launchers).</summary>
|
||||
public string? PublicUrl { get; init; }
|
||||
|
||||
/// <summary>Administrateur et appareil de la clé qui a signé la dernière publication, si le serveur la connaît.</summary>
|
||||
public string? UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Publication du mode maintenance (PUT v1/maintenance, requête signée, maintenance.manage).</summary>
|
||||
public sealed record MaintenancePublishRequest
|
||||
{
|
||||
/// <summary>Révision lue avant la modification (409 si une autre publication a eu lieu entre-temps).</summary>
|
||||
public long BaseRevision { get; init; }
|
||||
|
||||
/// <summary>Document signé, révision <see cref="BaseRevision"/> + 1.</summary>
|
||||
public MaintenanceDocument Document { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Règles du mode maintenance, communes au menu (contrôle avant de signer), au serveur (refus avant publication) et au
|
||||
/// launcher des joueurs (refus d'un fichier hors limites, même signé). Le message est du texte brut, déjà nettoyé
|
||||
/// (<see cref="CleanMessage"/>) et SANS lien : rien ne doit pouvoir renvoyer le joueur vers un site depuis ce bandeau.
|
||||
/// </summary>
|
||||
public static class MaintenanceRules
|
||||
{
|
||||
/// <summary>Taille de maintenance.json (et de la requête de publication, sous la limite de 64 Ko de l'API).</summary>
|
||||
public const int MaxBytes = 8 * 1024;
|
||||
|
||||
public const int MaxMessageLength = 300;
|
||||
public const int MaxMessageLines = 6;
|
||||
|
||||
/// <summary>Texte principal affiché aux joueurs (non modifiable : toujours présent, même sans message).</summary>
|
||||
public const string Title = "Maintenance en cours";
|
||||
|
||||
public const string Subtitle = "Merci de revenir plus tard.";
|
||||
|
||||
/// <summary>Écart toléré entre le début annoncé et l'heure du serveur (horloges des PC).</summary>
|
||||
public static readonly TimeSpan MaxSinceSkew = TimeSpan.FromMinutes(10);
|
||||
|
||||
private static readonly JsonSerializerOptions FileOptions = new(ApiJson.Options) { WriteIndented = true };
|
||||
|
||||
private static readonly JsonSerializerOptions CanonicalOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>Rien de publié (révision 0) : maintenance inactive.</summary>
|
||||
public static MaintenanceDocument Defaults() => new() { Revision = 0, Enabled = false };
|
||||
|
||||
/// <summary>Lit maintenance.json (null : vide).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible ou trop volumineux.</exception>
|
||||
public static MaintenanceDocument? Parse(ReadOnlySpan<byte> json)
|
||||
{
|
||||
if (json.Length > MaxBytes) throw new JsonException($"fichier de maintenance trop volumineux (au plus {MaxBytes / 1024} Ko)");
|
||||
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
|
||||
return JsonSerializer.Deserialize<MaintenanceDocument>(json, ApiJson.Options);
|
||||
}
|
||||
|
||||
public static string Serialize(MaintenanceDocument document) => JsonSerializer.Serialize(document, FileOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Condensé signé : format, version, révision, état, message et début (à la seconde). La date de publication et la
|
||||
/// signature n'en font pas partie.
|
||||
/// </summary>
|
||||
public static string Digest(MaintenanceDocument document)
|
||||
{
|
||||
var canonical = new CanonicalMaintenance(document.Format ?? "", document.Version, document.Revision, document.Enabled,
|
||||
string.IsNullOrEmpty(document.Message) ? null : document.Message,
|
||||
document.Since is { } since ? since.ToUnixTimeSeconds() : null);
|
||||
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, CanonicalOptions));
|
||||
}
|
||||
|
||||
private sealed record CanonicalMaintenance(string Format, int Version, long Revision, bool Enabled, string? Message, long? Since);
|
||||
|
||||
/// <summary>
|
||||
/// Nettoie le message saisi par l'administrateur (fins de ligne, espaces, lignes vides ; mêmes refus que les demandes
|
||||
/// des joueurs : caractères invisibles, de mise en forme ou d'inversion du sens d'écriture), puis vérifie les bornes et
|
||||
/// l'absence de lien. Null si vide ; <paramref name="problem"/> : raison du refus.
|
||||
/// </summary>
|
||||
public static string? CleanMessage(string? value, out string? problem)
|
||||
{
|
||||
var text = FeedbackRules.CleanMultiline(value, MaxMessageLength, out problem);
|
||||
if (problem is not null) return null;
|
||||
if (text is null) return null;
|
||||
if (text.Count(c => c == '\n') + 1 > MaxMessageLines)
|
||||
{
|
||||
problem = $"{MaxMessageLines} lignes au plus.";
|
||||
return null;
|
||||
}
|
||||
if (FeedbackLinks.Contains(text))
|
||||
{
|
||||
problem = "aucun lien ni adresse de site n'est accepté (les joueurs ne doivent pas être renvoyés vers un site depuis ce message).";
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle d'un document publié (révision 1 au moins). Les erreurs, en français, sont ajoutées à <paramref name="errors"/>.
|
||||
/// </summary>
|
||||
/// <param name="now">Heure de référence (serveur) : le début ne peut pas être dans le futur ; null : non contrôlé (launcher).</param>
|
||||
public static bool Validate(MaintenanceDocument? document, ICollection<string> errors, DateTimeOffset? now = null)
|
||||
{
|
||||
var count = errors.Count;
|
||||
if (document is null)
|
||||
{
|
||||
errors.Add("Document de maintenance absent.");
|
||||
return false;
|
||||
}
|
||||
if (document.Format != MaintenanceDocument.FormatName || document.Version != MaintenanceDocument.CurrentVersion)
|
||||
errors.Add($"Format du mode maintenance non pris en charge ({document.Format} v{document.Version}).");
|
||||
if (document.Revision < 1) errors.Add("Révision invalide (1 au moins).");
|
||||
|
||||
if (document.Message is { } message)
|
||||
{
|
||||
var clean = CleanMessage(message, out var problem);
|
||||
if (problem is not null) errors.Add($"Message : {problem}");
|
||||
else if (!string.Equals(clean, message, StringComparison.Ordinal)) errors.Add("Message : texte non nettoyé (espaces, lignes vides).");
|
||||
}
|
||||
|
||||
if (document.Enabled)
|
||||
{
|
||||
if (document.Since is not { } since || since == default) errors.Add("Début de la maintenance absent.");
|
||||
else if (since.UtcTicks % TimeSpan.TicksPerSecond != 0)
|
||||
errors.Add("Début de la maintenance : à la seconde près.");
|
||||
else if (now is { } reference && since > reference + MaxSinceSkew)
|
||||
errors.Add("Début de la maintenance dans le futur : vérifiez l'heure de Windows.");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (document.Since is not null) errors.Add("Maintenance désactivée : pas de date de début.");
|
||||
if (document.Message is not null) errors.Add("Maintenance désactivée : pas de message.");
|
||||
}
|
||||
return errors.Count == count;
|
||||
}
|
||||
|
||||
/// <summary>Résumé pour l'audit et les journaux (« activée — message : … », « désactivée »).</summary>
|
||||
public static string Describe(MaintenanceDocument document) =>
|
||||
document.Enabled
|
||||
? "activée" + (document.Message is { } message ? $" — message : « {OneLine(message)} »" : " (sans message)")
|
||||
: "désactivée";
|
||||
|
||||
private static string OneLine(string text)
|
||||
{
|
||||
var line = text.Replace('\n', ' ');
|
||||
return line.Length <= 120 ? line : line[..119] + "…";
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,13 @@ public static class AdminPermissions
|
||||
/// <summary>Formulaires des demandes (champs proposés aux joueurs) : modification et publication signée.</summary>
|
||||
public const string FeedbackForms = "feedback.forms";
|
||||
|
||||
/// <summary>Mode maintenance du launcher : activation, désactivation et message affiché aux joueurs (publication signée).</summary>
|
||||
public const string MaintenanceManage = "maintenance.manage";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[]
|
||||
{
|
||||
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage, FeedbackForms
|
||||
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage, FeedbackForms,
|
||||
MaintenanceManage
|
||||
};
|
||||
|
||||
/// <summary>Permission intégrée (vérifiée par le code : jamais supprimable).</summary>
|
||||
@@ -57,6 +61,7 @@ public static class AdminPermissions
|
||||
FeedbackRead => "Demandes des joueurs (lecture)",
|
||||
FeedbackManage => "Demandes des joueurs (traitement)",
|
||||
FeedbackForms => "Formulaires des demandes",
|
||||
MaintenanceManage => "Mode maintenance",
|
||||
_ => null
|
||||
};
|
||||
|
||||
@@ -76,6 +81,9 @@ public static class AdminPermissions
|
||||
"(à donner avec la lecture).",
|
||||
FeedbackForms => "Modifier, masquer et publier les formulaires du bouton « Aide » (champs proposés aux joueurs). " +
|
||||
"Publication signée : la clé doit avoir l'autorisation « formulaires » du trousseau.",
|
||||
MaintenanceManage => "Activer ou désactiver le mode maintenance du launcher (tout est bloqué chez les joueurs, sauf la " +
|
||||
"désinstallation des jeux) et modifier son message. Publication signée : la clé doit avoir " +
|
||||
"l'autorisation « mode maintenance » du trousseau.",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -27,7 +27,8 @@ public static class AdminRoles
|
||||
{
|
||||
AdminPermissions.AuditRead, AdminPermissions.GamesEdit,
|
||||
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish, AdminPermissions.FilesManage,
|
||||
AdminPermissions.FeedbackRead, AdminPermissions.FeedbackManage, AdminPermissions.FeedbackForms
|
||||
AdminPermissions.FeedbackRead, AdminPermissions.FeedbackManage, AdminPermissions.FeedbackForms,
|
||||
AdminPermissions.MaintenanceManage
|
||||
},
|
||||
[Editor] = new[] { AdminPermissions.GamesEdit, AdminPermissions.ChangelogEdit }
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ public static class SignedMessages
|
||||
/// <summary>Origine normalisée d'une adresse : « https://hote[:port] », sans chemin ni barre finale.</summary>
|
||||
public static string NormalizeOrigin(Uri uri) => uri.GetLeftPart(UriPartial.Authority).TrimEnd('/').ToLowerInvariant();
|
||||
|
||||
private static byte[] Build(string header, params (string Name, string Value)[] fields)
|
||||
internal static byte[] Build(string header, params (string Name, string Value)[] fields)
|
||||
{
|
||||
var sb = new StringBuilder(header).Append('\n');
|
||||
foreach (var (name, value) in fields)
|
||||
|
||||
@@ -29,6 +29,7 @@ public static class ContentKinds
|
||||
public const string Manifest = "manifest";
|
||||
public const string Catalog = "catalog";
|
||||
public const string FeedbackForms = "feedback-forms";
|
||||
public const string Maintenance = "maintenance";
|
||||
|
||||
/// <summary>Identifiant signé d'un catalogue (il n'y en a qu'un).</summary>
|
||||
public const string CatalogId = "catalog";
|
||||
@@ -36,11 +37,15 @@ public static class ContentKinds
|
||||
/// <summary>Identifiant signé des formulaires des demandes (communs à tous les jeux).</summary>
|
||||
public const string FeedbackFormsId = "feedback-forms";
|
||||
|
||||
/// <summary>Identifiant signé du mode maintenance (commun à tous les jeux).</summary>
|
||||
public const string MaintenanceId = "maintenance";
|
||||
|
||||
/// <summary>Autorisation du trousseau requise pour signer ce type de contenu.</summary>
|
||||
public static string ScopeOf(string kind) => kind switch
|
||||
{
|
||||
Manifest => SigningScopes.Releases,
|
||||
FeedbackForms => SigningScopes.Forms,
|
||||
Maintenance => SigningScopes.Maintenance,
|
||||
_ => SigningScopes.Catalog
|
||||
};
|
||||
}
|
||||
@@ -57,7 +62,10 @@ public static class SigningScopes
|
||||
/// <summary>Signer les formulaires des demandes (champs affichés aux joueurs) : permission feedback.forms.</summary>
|
||||
public const string Forms = "forms";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog, Forms };
|
||||
/// <summary>Signer le mode maintenance (activation, message affiché aux joueurs) : permission maintenance.manage.</summary>
|
||||
public const string Maintenance = "maintenance";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog, Forms, Maintenance };
|
||||
|
||||
/// <summary>
|
||||
/// Autorisation bien formée : une autorisation inconnue de cette version (ajoutée par une version plus récente) est
|
||||
@@ -74,6 +82,7 @@ public static class SigningScopes
|
||||
if (set.Contains(AdminPermissions.ReleasesPublish)) scopes.Add(Releases);
|
||||
if (set.Contains(AdminPermissions.GamesEdit)) scopes.Add(Catalog);
|
||||
if (set.Contains(AdminPermissions.FeedbackForms)) scopes.Add(Forms);
|
||||
if (set.Contains(AdminPermissions.MaintenanceManage)) scopes.Add(Maintenance);
|
||||
return scopes;
|
||||
}
|
||||
|
||||
@@ -82,6 +91,7 @@ public static class SigningScopes
|
||||
Releases => "versions du jeu",
|
||||
Catalog => "catalogue",
|
||||
Forms => "formulaires des demandes",
|
||||
Maintenance => "mode maintenance",
|
||||
_ => scope
|
||||
};
|
||||
}
|
||||
@@ -219,6 +229,7 @@ public static class ContentSigning
|
||||
{
|
||||
ContentKinds.Catalog => ContentKinds.CatalogId,
|
||||
ContentKinds.FeedbackForms => ContentKinds.FeedbackFormsId,
|
||||
ContentKinds.Maintenance => ContentKinds.MaintenanceId,
|
||||
_ => id.ToLowerInvariant()
|
||||
};
|
||||
|
||||
|
||||
@@ -55,6 +55,12 @@ public sealed record SigningState
|
||||
|
||||
/// <summary>Catalogue publié (null : publication du catalogue non configurée).</summary>
|
||||
public CatalogSignatureState? Catalog { get; init; }
|
||||
|
||||
/// <summary>Formulaires des demandes (null : publication des formulaires non configurée, ou serveur antérieur à la 3.2.1).</summary>
|
||||
public FeedbackFormsSignatureState? FeedbackForms { get; init; }
|
||||
|
||||
/// <summary>Mode maintenance (null : publication non configurée, ou serveur antérieur à la 3.3.0).</summary>
|
||||
public MaintenanceSignatureState? Maintenance { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CatalogSignatureState
|
||||
@@ -64,3 +70,38 @@ public sealed record CatalogSignatureState
|
||||
public ContentSignature? Signature { get; init; }
|
||||
public string? SignerName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Signature des formulaires des demandes en vigueur (feedback-forms.json).</summary>
|
||||
public sealed record FeedbackFormsSignatureState
|
||||
{
|
||||
/// <summary>Révision en vigueur (0 : formulaires d'origine, intégrés au launcher, jamais publiés).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Condensé du contenu signé (vide : formulaires d'origine, ou fichier illisible).</summary>
|
||||
public string Digest { get; init; } = "";
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
public string? SignerName { get; init; }
|
||||
|
||||
/// <summary>Vrai : le fichier de référence des formulaires est illisible sur le serveur.</summary>
|
||||
public bool Unreadable { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Signature du mode maintenance en vigueur (maintenance.json).</summary>
|
||||
public sealed record MaintenanceSignatureState
|
||||
{
|
||||
/// <summary>Révision en vigueur (0 : jamais publié, maintenance inactive).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Maintenance en cours.</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>Condensé du contenu signé (vide : jamais publié, ou fichier illisible).</summary>
|
||||
public string Digest { get; init; } = "";
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
public string? SignerName { get; init; }
|
||||
|
||||
/// <summary>Vrai : le fichier de référence du mode maintenance est illisible sur le serveur.</summary>
|
||||
public bool Unreadable { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance publié depuis le menu d'administration (maintenance.json, à côté du trousseau), lu par le launcher
|
||||
/// des joueurs :
|
||||
/// • au démarrage, AVANT la recherche des mises à jour des jeux (le launcher l'attend quelques secondes au plus) ;
|
||||
/// • puis toutes les <see cref="NormalInterval"/> (<see cref="ActiveInterval"/> pendant une maintenance, pour en voir la
|
||||
/// fin rapidement) et à la demande (publication depuis le menu d'administration de ce PC) ;
|
||||
/// • fichier borné en taille, contrôlé avec les règles du mode maintenance (même signé, un fichier hors limites est
|
||||
/// refusé) ; signature vérifiée avec le trousseau (autorisation « maintenance »), retour en arrière refusé (révision
|
||||
/// et date de signature), selon le mode « contentSigning » du launcher, comme le catalogue.
|
||||
/// Rien n'est conservé sur le disque (hormis la date de la dernière signature acceptée, dans l'état des signatures) :
|
||||
/// au démarrage, un serveur injoignable ne bloque jamais le launcher (le jeu installé reste jouable hors ligne). Pendant
|
||||
/// que le launcher tourne, une coupure ou un fichier refusé garde le dernier état vérifié ; un fichier absent (404)
|
||||
/// termine la maintenance.
|
||||
/// Ce n'est pas une barrière de sécurité (un launcher modifié peut l'ignorer) : c'est un affichage pour les joueurs.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceService
|
||||
{
|
||||
public static readonly TimeSpan NormalInterval = TimeSpan.FromMinutes(2);
|
||||
public static readonly TimeSpan ActiveInterval = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(8);
|
||||
|
||||
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
|
||||
private readonly ContentTrustService? _trust;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly object _stateLock = new();
|
||||
private byte[]? _lastAccepted;
|
||||
private bool _unreachableLogged;
|
||||
private MaintenanceDocument? _current;
|
||||
|
||||
/// <param name="url">Adresse du fichier publié (null : jamais de maintenance).</param>
|
||||
/// <param name="trust">Vérification des signatures (null : mode « off », document utilisé sans signature).</param>
|
||||
/// <param name="time">Horloge (tests).</param>
|
||||
public MaintenanceService(Func<Uri, long, CancellationToken, Task<byte[]>> download, Uri? url, ContentTrustService? trust,
|
||||
TimeProvider? time = null)
|
||||
{
|
||||
_download = download;
|
||||
Url = url;
|
||||
_trust = trust;
|
||||
_time = time ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Uri? Url { get; }
|
||||
|
||||
/// <summary>Dernier état accepté (null : rien de connu, ou rien de publié).</summary>
|
||||
public MaintenanceDocument? Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_stateLock) return _current;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maintenance en cours (dernier état vérifié).</summary>
|
||||
public bool IsActive => Current is { Enabled: true };
|
||||
|
||||
/// <summary>
|
||||
/// L'état affiché change (début, fin, nouveau message). Levé hors du fil de l'interface : l'appelant s'y ramène.
|
||||
/// Null : plus de maintenance (fichier absent).
|
||||
/// </summary>
|
||||
public event Action<MaintenanceDocument?>? Changed;
|
||||
|
||||
/// <summary>Adresse du fichier publié, déduite de celle du trousseau (ou, à défaut, du catalogue) : même dossier.</summary>
|
||||
public static Uri? PublishedUrl(string? keyringUrl, string? catalogUrl)
|
||||
{
|
||||
var baseUrl = keyringUrl ?? catalogUrl;
|
||||
return baseUrl is not null && Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) ? new Uri(uri, "maintenance.json") : null;
|
||||
}
|
||||
|
||||
/// <summary>Relit le fichier publié. Jamais d'exception (sauf annulation demandée par l'appelant).</summary>
|
||||
/// <returns>Vrai : maintenance en cours (dernier état vérifié).</returns>
|
||||
public async Task<bool> RefreshAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (Url is null) return false;
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(FetchTimeout);
|
||||
bytes = await _download(Url, MaintenanceRules.MaxBytes, timeout.Token).ConfigureAwait(false);
|
||||
_unreachableLogged = false;
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
// Rien de publié (ou fichier retiré à la main) : pas de maintenance.
|
||||
_unreachableLogged = false;
|
||||
_lastAccepted = null;
|
||||
Apply(null);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
|
||||
{
|
||||
// Réseau, délai dépassé, réponse trop grande : dernier état vérifié conservé, nouvel essai plus tard.
|
||||
if (!_unreachableLogged)
|
||||
Log.Warn($"Mode maintenance injoignable : {ex.Message}" + (_current is { Enabled: true } ? " (maintenance toujours affichée)." : "."));
|
||||
_unreachableLogged = true;
|
||||
return _current is { Enabled: true };
|
||||
}
|
||||
|
||||
// Même fichier que la dernière fois : déjà vérifié.
|
||||
if (_lastAccepted is not null && bytes.AsSpan().SequenceEqual(_lastAccepted)) return _current is { Enabled: true };
|
||||
|
||||
var document = await AcceptAsync(bytes, ct).ConfigureAwait(false);
|
||||
if (document is null) return _current is { Enabled: true };
|
||||
_lastAccepted = bytes;
|
||||
Apply(document);
|
||||
return document.Enabled;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Relecture périodique jusqu'à la fermeture du launcher (rythme plus rapide pendant une maintenance).</summary>
|
||||
public async Task RunAsync(CancellationToken lifetime)
|
||||
{
|
||||
if (Url is null) return;
|
||||
try
|
||||
{
|
||||
while (!lifetime.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(IsActive ? ActiveInterval : NormalInterval, _time, lifetime).ConfigureAwait(false);
|
||||
await RefreshAsync(lifetime).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (lifetime.IsCancellationRequested)
|
||||
{
|
||||
// fermeture du launcher
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Relecture du mode maintenance interrompue", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fichier lisible, dans les règles, pas plus ancien que l'état connu, à la signature acceptée ; sinon null.</summary>
|
||||
private async Task<MaintenanceDocument?> AcceptAsync(byte[] bytes, CancellationToken ct)
|
||||
{
|
||||
MaintenanceDocument? document;
|
||||
try
|
||||
{
|
||||
document = MaintenanceRules.Parse(bytes);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Log.Error($"Mode maintenance illisible ({ex.Message}) : ignoré.");
|
||||
return null;
|
||||
}
|
||||
var errors = new List<string>();
|
||||
if (!MaintenanceRules.Validate(document, errors))
|
||||
{
|
||||
Log.Error($"Mode maintenance refusé : {errors[0]}");
|
||||
return null;
|
||||
}
|
||||
if (_current is { } current && document!.Revision < current.Revision)
|
||||
{
|
||||
Log.Error($"Mode maintenance révision {document.Revision} refusé : plus ancien que la révision {current.Revision} déjà vue (retour en arrière).");
|
||||
return null;
|
||||
}
|
||||
if (_trust is not null && !await _trust.VerifyMaintenanceAsync(document!, ct).ConfigureAwait(false)) return null;
|
||||
return document;
|
||||
}
|
||||
|
||||
private void Apply(MaintenanceDocument? document)
|
||||
{
|
||||
MaintenanceDocument? previous;
|
||||
lock (_stateLock)
|
||||
{
|
||||
previous = _current;
|
||||
_current = document;
|
||||
}
|
||||
if (SameDisplay(previous, document)) return;
|
||||
Log.Info(document is { Enabled: true }
|
||||
? $"Mode maintenance ACTIVÉ (révision {document.Revision}) : tout est bloqué sauf la désinstallation et l'administration."
|
||||
: previous is { Enabled: true } ? "Mode maintenance terminé." : $"Mode maintenance : inactif (révision {document?.Revision ?? 0}).");
|
||||
try
|
||||
{
|
||||
Changed?.Invoke(document);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Application du mode maintenance", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Même affichage pour le joueur (état, message, début) : rien à signaler.</summary>
|
||||
private static bool SameDisplay(MaintenanceDocument? a, MaintenanceDocument? b)
|
||||
{
|
||||
var activeA = a is { Enabled: true };
|
||||
var activeB = b is { Enabled: true };
|
||||
if (!activeA && !activeB) return true;
|
||||
return activeA == activeB && a!.Message == b!.Message && a.Since == b.Since;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace GameLauncher.Core.Models;
|
||||
/// </summary>
|
||||
public sealed class LauncherCatalog
|
||||
{
|
||||
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl", "catalogUrl", "keyringUrl", "contentSigning", "keyringMinSequence" };
|
||||
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl", "catalogUrl", "keyringUrl", "contentSigning", "keyringMinSequence", "playerAccounts" };
|
||||
|
||||
/// <summary>Nom par défaut du dossier des données du launcher (%AppData%\<Publisher>\GameLauncher).</summary>
|
||||
public const string DefaultDataFolderName = "GameLauncher";
|
||||
@@ -126,6 +126,13 @@ public sealed class LauncherCatalog
|
||||
/// </summary>
|
||||
public long KeyringMinSequence { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// « playerAccounts » : bouton « Compte » (comptes joueurs : connexion, création, « Mon compte »), avec l'API de
|
||||
/// <see cref="ApiUrl"/>. Faux par défaut : à activer quand les comptes sont ouverts sur le serveur. Comme apiUrl, le
|
||||
/// launcher publié ne le lit que dans le launcher.json intégré à l'exe.
|
||||
/// </summary>
|
||||
public bool PlayerAccounts { get; private set; }
|
||||
|
||||
/// <summary>Affichage modifié par le catalogue (voir Catalog.CatalogMerger).</summary>
|
||||
internal void ApplyDisplay(string launcherName, string? defaultGameId, IReadOnlyList<LauncherConfig> games)
|
||||
{
|
||||
@@ -317,7 +324,8 @@ public sealed class LauncherCatalog
|
||||
ApiUrlError = singleApiUrlError,
|
||||
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var singleCatalogError),
|
||||
CatalogUrlError = singleCatalogError,
|
||||
DataFolderName = ReadDataFolder(root)
|
||||
DataFolderName = ReadDataFolder(root),
|
||||
PlayerAccounts = ReadFlag(root, "playerAccounts")
|
||||
}.WithContentSigning(root);
|
||||
}
|
||||
|
||||
@@ -372,7 +380,8 @@ public sealed class LauncherCatalog
|
||||
ApiUrlError = apiUrlError,
|
||||
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var catalogError),
|
||||
CatalogUrlError = catalogError,
|
||||
DataFolderName = ReadDataFolder(root)
|
||||
DataFolderName = ReadDataFolder(root),
|
||||
PlayerAccounts = ReadFlag(root, "playerAccounts")
|
||||
}.WithContentSigning(root);
|
||||
}
|
||||
|
||||
@@ -406,6 +415,10 @@ public sealed class LauncherCatalog
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Booléen facultatif (faux si absent ou mal écrit).</summary>
|
||||
private static bool ReadFlag(JsonObject root, string name) =>
|
||||
Get(root, name) is JsonValue value && value.TryGetValue<bool>(out var flag) && flag;
|
||||
|
||||
/// <summary>« dataFolder » : nom simple de dossier (« GameLauncher » par défaut).</summary>
|
||||
private static string ReadDataFolder(JsonObject root)
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ public sealed class LauncherConfig
|
||||
|
||||
public string GameName { get; set; } = "Mon Jeu";
|
||||
|
||||
public string Publisher { get; set; } = "Xbhost";
|
||||
public string Publisher { get; set; } = "LanisysGames";
|
||||
|
||||
public string Tagline { get; set; } = "";
|
||||
|
||||
|
||||
@@ -50,9 +50,58 @@ public sealed partial class LauncherRelease
|
||||
/// </summary>
|
||||
public List<ChangelogEntry> Changelog { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Version PRIORITAIRE la plus récente publiée jusqu'à celle-ci incluse (ex. "3.4.0"), ou null s'il n'y en a pas.
|
||||
/// Un launcher plus ancien que cette version installe la mise à jour au démarrage MÊME SI le joueur a désactivé
|
||||
/// la mise à jour automatique (correctif critique). Le réglage du joueur (self-update.json) n'est jamais modifié :
|
||||
/// une fois le launcher à jour, il n'est plus concerné (effet ponctuel).
|
||||
/// Reprise d'une publication à l'autre par LauncherPublisher (voir <see cref="ResolvePriorityVersion"/>) : une
|
||||
/// version normale publiée après un correctif critique l'impose toujours aux launchers restés trop anciens.
|
||||
/// Fait partie du contenu SIGNÉ : un serveur compromis ne peut ni l'ajouter, ni la retirer, ni la modifier.
|
||||
/// Ignorée par les launchers antérieurs à 3.4.0 (propriété inconnue).
|
||||
/// </summary>
|
||||
public string? PriorityVersion { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public Version ParsedVersion => VersionUtil.Parse(Version);
|
||||
|
||||
/// <summary>Cette version a elle-même été publiée comme prioritaire (option --priority).</summary>
|
||||
[JsonIgnore]
|
||||
public bool IsPriorityRelease => TryGetPriorityVersion(out var priority) && priority == ParsedVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Vrai si un launcher en version <paramref name="currentVersion"/> doit installer cette mise à jour même avec la
|
||||
/// mise à jour automatique désactivée : il est plus ancien que la dernière version prioritaire publiée.
|
||||
/// </summary>
|
||||
public bool IsPriorityFor(Version currentVersion) =>
|
||||
TryGetPriorityVersion(out var priority) && priority > currentVersion;
|
||||
|
||||
/// <summary>Version prioritaire lisible (null ou illisible : faux).</summary>
|
||||
public bool TryGetPriorityVersion(out Version priority)
|
||||
{
|
||||
priority = new Version(0, 0, 0);
|
||||
return !string.IsNullOrWhiteSpace(PriorityVersion) && VersionUtil.TryParse(PriorityVersion, out priority);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Version prioritaire à inscrire dans une nouvelle publication (LauncherPublisher) :
|
||||
/// <list type="bullet">
|
||||
/// <item><paramref name="markPriority"/> : la version publiée elle-même ;</item>
|
||||
/// <item><paramref name="clearPriority"/> : aucune (priorité publiée par erreur, par exemple) ;</item>
|
||||
/// <item>sinon : celle de la publication précédente (<paramref name="previous"/>, dont la signature a été
|
||||
/// VÉRIFIÉE par l'appelant), si elle n'est pas supérieure à la version publiée.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static string? ResolvePriorityVersion(Version published, bool markPriority, bool clearPriority, LauncherRelease? previous)
|
||||
{
|
||||
if (markPriority && clearPriority)
|
||||
throw new ArgumentException("Une publication ne peut pas à la fois être prioritaire et retirer la priorité.");
|
||||
if (markPriority) return VersionUtil.Display(published);
|
||||
if (clearPriority || previous is null || !previous.TryGetPriorityVersion(out var carried) || carried > published)
|
||||
return null;
|
||||
return VersionUtil.Display(carried);
|
||||
}
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Format != 1)
|
||||
@@ -67,6 +116,12 @@ public sealed partial class LauncherRelease
|
||||
throw new UpdateSecurityException("Description de mise à jour invalide : taille incorrecte.");
|
||||
if (!Sha256Regex().IsMatch(Sha256 ?? ""))
|
||||
throw new UpdateSecurityException("Description de mise à jour invalide : empreinte SHA-256 incorrecte.");
|
||||
if (string.IsNullOrWhiteSpace(PriorityVersion)) PriorityVersion = null;
|
||||
else if (!VersionUtil.TryParse(PriorityVersion, out var priority))
|
||||
throw new UpdateSecurityException($"Description de mise à jour invalide : version prioritaire « {PriorityVersion} » illisible.");
|
||||
else if (priority > ParsedVersion)
|
||||
throw new UpdateSecurityException(
|
||||
$"Description de mise à jour invalide : version prioritaire {PriorityVersion} supérieure à la version publiée ({Version}).");
|
||||
Notes ??= new List<string>();
|
||||
// Textes affichés tels quels dans l'interface : mêmes bornes et nettoyage que les notes du jeu.
|
||||
Changelog = ChangelogLimits.Normalize(Changelog);
|
||||
|
||||
@@ -10,6 +10,12 @@ public sealed record LauncherUpdateCheck(Version CurrentVersion, LauncherRelease
|
||||
public bool IsAvailable => Release is not null && DownloadUri is not null;
|
||||
public string CurrentDisplay => VersionUtil.Display(CurrentVersion);
|
||||
public string AvailableDisplay => Release is null ? "" : VersionUtil.Display(Release.ParsedVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Mise à jour PRIORITAIRE pour ce launcher (plus ancien que la dernière version prioritaire publiée) :
|
||||
/// installée au démarrage même si la mise à jour automatique est désactivée. Information signée.
|
||||
/// </summary>
|
||||
public bool IsPriority => IsAvailable && Release!.IsPriorityFor(CurrentVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
@@ -40,8 +41,23 @@ public sealed class ContentSecurityException : Exception
|
||||
public sealed class ContentTrustService
|
||||
{
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan UnknownKeyRetryDelay = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>Nouvel essai après un échec réseau (trousseau jamais obtenu pendant ce démarrage).</summary>
|
||||
public static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Nouveau téléchargement, au plus à ce rythme, quand un contenu est signé par une clé absente du trousseau ou non
|
||||
/// autorisée pour ce contenu : clé ajoutée ou autorisation donnée (ex. « maintenance ») pendant que le launcher tourne.
|
||||
/// Plus court que la relecture du mode maintenance, pour qu'une activation juste après la republication du trousseau
|
||||
/// soit vue à la relecture suivante.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan KeyChangeRetryDelay = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Âge maximal du trousseau en mémoire : au-delà, il est retéléchargé à la vérification suivante (une clé retirée ou
|
||||
/// une autorisation enlevée est prise en compte sans redémarrer le launcher).
|
||||
/// </summary>
|
||||
public static readonly TimeSpan KeyringMaxAge = TimeSpan.FromMinutes(30);
|
||||
private static readonly TimeSpan MaxFutureSignedAt = TimeSpan.FromDays(1);
|
||||
|
||||
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
|
||||
@@ -81,12 +97,15 @@ public sealed class ContentTrustService
|
||||
|
||||
/// <summary>
|
||||
/// Trousseau à utiliser : celui du serveur s'il est valide et pas plus ancien que le dernier vu, sinon le dernier
|
||||
/// vu (cache), sinon null. Téléchargé une fois par démarrage (nouvel essai après un échec réseau, ou après quelques
|
||||
/// minutes quand un contenu est signé par une clé encore inconnue : clé ajoutée pendant que le launcher tourne).
|
||||
/// vu (cache), sinon null. Téléchargé au démarrage, puis de nouveau :
|
||||
/// • après un échec réseau (<see cref="RetryDelay"/>) ;
|
||||
/// • quand un contenu est signé par une clé absente ou non autorisée pour ce contenu (<see cref="KeyChangeRetryDelay"/>) ;
|
||||
/// • quand il a plus de <see cref="KeyringMaxAge"/> (launcher ouvert longtemps).
|
||||
/// Chaque nouveau trousseau passe par les mêmes contrôles (signature racine, numéro jamais en baisse).
|
||||
/// </summary>
|
||||
public Task<KeyringDocument?> GetKeyringAsync(CancellationToken ct = default) => GetKeyringAsync(false, ct);
|
||||
|
||||
private async Task<KeyringDocument?> GetKeyringAsync(bool unknownKey, CancellationToken ct)
|
||||
private async Task<KeyringDocument?> GetKeyringAsync(bool keyChanged, CancellationToken ct)
|
||||
{
|
||||
Task<KeyringDocument?> task;
|
||||
lock (_lock)
|
||||
@@ -94,7 +113,8 @@ public sealed class ContentTrustService
|
||||
var elapsed = _time.GetUtcNow() - _lastAttempt;
|
||||
if (_keyringTask is null
|
||||
|| (_keyringTask.IsCompleted && !_keyringFetched && elapsed > RetryDelay)
|
||||
|| (_keyringTask.IsCompleted && unknownKey && elapsed > UnknownKeyRetryDelay))
|
||||
|| (_keyringTask.IsCompleted && keyChanged && elapsed > KeyChangeRetryDelay)
|
||||
|| (_keyringTask.IsCompleted && elapsed > KeyringMaxAge))
|
||||
{
|
||||
_lastAttempt = _time.GetUtcNow();
|
||||
_keyringTask = FetchKeyringAsync();
|
||||
@@ -150,7 +170,7 @@ public sealed class ContentTrustService
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or JsonException or NotSupportedException or FormatException)
|
||||
{
|
||||
lock (_lock) _keyringFetched = true; // contenu refusé : pas de nouvel essai pendant ce démarrage
|
||||
lock (_lock) _keyringFetched = true; // contenu refusé : nouvel essai seulement aux conditions de GetKeyringAsync
|
||||
Log.Error($"Trousseau du serveur refusé : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu reste utilisé)."));
|
||||
return cached;
|
||||
}
|
||||
@@ -210,6 +230,26 @@ public sealed class ContentTrustService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie le mode maintenance téléchargé (maintenance.json) : clé du trousseau autorisée « maintenance », signature
|
||||
/// valide, pas plus ancien que le dernier accepté (un serveur compromis ne peut pas resservir une ancienne maintenance
|
||||
/// légitime). Faux : à ignorer (état précédent conservé).
|
||||
/// </summary>
|
||||
public async Task<bool> VerifyMaintenanceAsync(MaintenanceDocument maintenance, CancellationToken ct = default)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return true;
|
||||
try
|
||||
{
|
||||
await VerifyAsync(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(maintenance), maintenance.Signature,
|
||||
$"Mode maintenance (révision {maintenance.Revision}, {MaintenanceRules.Describe(maintenance)})", ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (ContentSecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Revérifie les formulaires en cache, sans réseau, avec le dernier trousseau vérifié (fichier modifié sur le disque : ignoré).</summary>
|
||||
public bool VerifyCachedFeedbackForms(FeedbackFormsDocument forms)
|
||||
{
|
||||
@@ -253,8 +293,9 @@ public sealed class ContentTrustService
|
||||
private async Task VerifyAsync(string kind, string id, string digest, ContentSignature? signature, string what, CancellationToken ct)
|
||||
{
|
||||
var keyring = await GetKeyringAsync(false, ct).ConfigureAwait(false);
|
||||
// Clé inconnue (ajoutée depuis le démarrage) : nouveau téléchargement du trousseau, limité dans le temps.
|
||||
if (keyring is not null && signature?.KeyId is { } keyId && !keyring.Keys.Any(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal)))
|
||||
// Clé absente du trousseau, ou présente sans l'autorisation de ce contenu (clé ajoutée, ou autorisation donnée,
|
||||
// depuis le dernier téléchargement) : nouveau téléchargement du trousseau, limité dans le temps.
|
||||
if (keyring is not null && KeyMayBeNewer(keyring, kind, signature))
|
||||
keyring = await GetKeyringAsync(true, ct).ConfigureAwait(false);
|
||||
|
||||
string problem;
|
||||
@@ -301,6 +342,14 @@ public sealed class ContentTrustService
|
||||
if (!Report(what, problem)) throw new ContentSecurityException($"{what} : {problem}.");
|
||||
}
|
||||
|
||||
/// <summary>La signature vient d'une clé que ce trousseau ne connaît pas, ou n'autorise pas pour ce contenu.</summary>
|
||||
private static bool KeyMayBeNewer(KeyringDocument keyring, string kind, ContentSignature? signature)
|
||||
{
|
||||
if (signature?.KeyId is not { } keyId) return false;
|
||||
var key = keyring.Keys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal));
|
||||
return key?.Scopes is not { } scopes || !scopes.Contains(ContentKinds.ScopeOf(kind), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
private sealed class TrustState
|
||||
|
||||
@@ -0,0 +1,890 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Accounts;
|
||||
|
||||
/// <summary>Écrans de la fenêtre « Compte ».</summary>
|
||||
public enum AccountStep
|
||||
{
|
||||
Loading,
|
||||
|
||||
/// <summary>Comptes non ouverts sur le serveur, ou serveur injoignable (bouton « Réessayer »).</summary>
|
||||
Unavailable,
|
||||
SignIn,
|
||||
Register,
|
||||
|
||||
/// <summary>Nouveau mot de passe exigé par un administrateur (après une connexion réussie).</summary>
|
||||
PasswordChange,
|
||||
|
||||
/// <summary>« Mon compte » (onglets <see cref="AccountSection"/>).</summary>
|
||||
Account
|
||||
}
|
||||
|
||||
/// <summary>Onglets de « Mon compte ».</summary>
|
||||
public enum AccountSection
|
||||
{
|
||||
Profile,
|
||||
Devices,
|
||||
Activity,
|
||||
Data
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fenêtre « Compte » (bouton de la barre de titre) : connexion, création de compte, nouveau mot de passe exigé, puis
|
||||
/// « Mon compte » (profil et mot de passe, appareils connectés, activité récente, export et suppression des données).
|
||||
/// Les mots de passe ne sont JAMAIS gardés par ce ViewModel : la vue (PasswordBox) les passe aux méthodes au moment de
|
||||
/// l'action puis efface ses champs (<see cref="ClearPasswordsRequested"/>) ; seul le mot de passe tapé à la connexion est
|
||||
/// conservé quelques minutes quand un administrateur exige d'en changer (il est demandé par le serveur).
|
||||
/// </summary>
|
||||
public sealed class AccountWindowViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private static readonly CultureInfo French = CultureInfo.GetCultureInfo("fr-FR");
|
||||
|
||||
/// <summary>Durée de validité du jeton « changement exigé » (serveur : 10 minutes).</summary>
|
||||
private static readonly TimeSpan PasswordChangeWindow = TimeSpan.FromMinutes(9);
|
||||
|
||||
private readonly IPlayerAccountService _service;
|
||||
private readonly IModuleDialogs _dialogs;
|
||||
private readonly IUiDispatcher _ui;
|
||||
private readonly string _publisher;
|
||||
private readonly CancellationTokenSource _lifetime = new();
|
||||
|
||||
private AccountStep _step = AccountStep.Loading;
|
||||
private AccountSection _section = AccountSection.Profile;
|
||||
private bool _isBusy;
|
||||
private string _busyText = "";
|
||||
private string? _errorMessage;
|
||||
private string? _successMessage;
|
||||
private string? _unavailableText;
|
||||
private string _email = "";
|
||||
private bool _rememberMe = true;
|
||||
private bool _showForgotHelp;
|
||||
private bool _acceptTerms;
|
||||
private bool _registrationOpen;
|
||||
private string _termsVersion = "";
|
||||
private bool _isChangingPassword;
|
||||
private bool _deleteConfirmed;
|
||||
private bool _isActivityLoading;
|
||||
private bool _activityLoaded;
|
||||
private string? _changeToken;
|
||||
private string? _changeCurrentPassword;
|
||||
private DateTime _changeStarted;
|
||||
private PlayerAccountInfo? _account;
|
||||
|
||||
public AccountWindowViewModel(IPlayerAccountService service, IModuleDialogs dialogs, IUiDispatcher ui, string publisher)
|
||||
{
|
||||
_service = service;
|
||||
_dialogs = dialogs;
|
||||
_ui = ui;
|
||||
_publisher = string.IsNullOrWhiteSpace(publisher) ? "LanisysGames" : publisher.Trim();
|
||||
|
||||
CloseCommand = new RelayCommand(() => CloseRequested?.Invoke(), () => !IsBusy);
|
||||
RetryCommand = new AsyncRelayCommand(InitializeAsync, () => !IsBusy);
|
||||
ShowRegisterCommand = new RelayCommand(() => GoTo(AccountStep.Register), () => !IsBusy && RegistrationOpen);
|
||||
ShowSignInCommand = new RelayCommand(() => GoTo(AccountStep.SignIn), () => !IsBusy);
|
||||
ToggleForgotCommand = new RelayCommand(() => ShowForgotHelp = !ShowForgotHelp);
|
||||
ShowChangePasswordCommand = new RelayCommand(() =>
|
||||
{
|
||||
ClearMessages();
|
||||
IsChangingPassword = true;
|
||||
}, () => !IsBusy);
|
||||
CancelChangePasswordCommand = new RelayCommand(() => IsChangingPassword = false, () => !IsBusy);
|
||||
CopyReferenceCommand = new RelayCommand(CopyReference, () => !string.IsNullOrEmpty(Reference));
|
||||
SignOutCommand = new AsyncRelayCommand(() => SignOutAsync(allDevices: false), () => !IsBusy);
|
||||
SignOutAllCommand = new AsyncRelayCommand(() => SignOutAsync(allDevices: true), () => !IsBusy);
|
||||
RefreshCommand = new AsyncRelayCommand(RefreshAccountAsync, () => !IsBusy);
|
||||
|
||||
_service.Changed += OnServiceChanged;
|
||||
}
|
||||
|
||||
/// <summary>La vue efface ses champs de mot de passe (changement d'écran, action terminée).</summary>
|
||||
public event Action? ClearPasswordsRequested;
|
||||
|
||||
public event Action? CloseRequested;
|
||||
|
||||
public PasswordRulesViewModel NewPassword { get; } = new();
|
||||
public ObservableCollection<PlayerDeviceItem> Devices { get; } = new();
|
||||
public ObservableCollection<PlayerActivityItem> Activity { get; } = new();
|
||||
|
||||
public RelayCommand CloseCommand { get; }
|
||||
public AsyncRelayCommand RetryCommand { get; }
|
||||
public RelayCommand ShowRegisterCommand { get; }
|
||||
public RelayCommand ShowSignInCommand { get; }
|
||||
public RelayCommand ToggleForgotCommand { get; }
|
||||
public RelayCommand ShowChangePasswordCommand { get; }
|
||||
public RelayCommand CancelChangePasswordCommand { get; }
|
||||
public RelayCommand CopyReferenceCommand { get; }
|
||||
public AsyncRelayCommand SignOutCommand { get; }
|
||||
public AsyncRelayCommand SignOutAllCommand { get; }
|
||||
public AsyncRelayCommand RefreshCommand { get; }
|
||||
|
||||
// ------------------------------------------------------------------ Écran
|
||||
|
||||
public AccountStep Step
|
||||
{
|
||||
get => _step;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _step, value)) return;
|
||||
OnPropertyChanged(nameof(IsLoading));
|
||||
OnPropertyChanged(nameof(IsUnavailable));
|
||||
OnPropertyChanged(nameof(IsSignIn));
|
||||
OnPropertyChanged(nameof(IsRegister));
|
||||
OnPropertyChanged(nameof(IsPasswordChange));
|
||||
OnPropertyChanged(nameof(IsAccount));
|
||||
OnPropertyChanged(nameof(Title));
|
||||
OnPropertyChanged(nameof(Subtitle));
|
||||
OnPropertyChanged(nameof(HeaderIcon));
|
||||
ShowRegisterCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsLoading => Step == AccountStep.Loading;
|
||||
public bool IsUnavailable => Step == AccountStep.Unavailable;
|
||||
public bool IsSignIn => Step == AccountStep.SignIn;
|
||||
public bool IsRegister => Step == AccountStep.Register;
|
||||
public bool IsPasswordChange => Step == AccountStep.PasswordChange;
|
||||
public bool IsAccount => Step == AccountStep.Account;
|
||||
|
||||
public string Title => Step switch
|
||||
{
|
||||
AccountStep.SignIn => "Se connecter",
|
||||
AccountStep.Register => "Créer un compte",
|
||||
AccountStep.PasswordChange => "Nouveau mot de passe exigé",
|
||||
AccountStep.Account => "Mon compte",
|
||||
_ => "Compte"
|
||||
};
|
||||
|
||||
public string Subtitle => Step switch
|
||||
{
|
||||
AccountStep.SignIn => $"Avec votre compte {_publisher}",
|
||||
AccountStep.Register => "Gratuit : une adresse e-mail et un mot de passe suffisent",
|
||||
AccountStep.PasswordChange => $"Demandé par l'équipe {_publisher}",
|
||||
AccountStep.Account => EmailMasked,
|
||||
AccountStep.Unavailable => "Service indisponible",
|
||||
_ => "Connexion au serveur…"
|
||||
};
|
||||
|
||||
/// <summary>Clé de l'icône d'en-tête (Themes\Icons.xaml).</summary>
|
||||
public string HeaderIcon => Step switch
|
||||
{
|
||||
AccountStep.Register => "AccountKey",
|
||||
AccountStep.PasswordChange => "ShieldKey",
|
||||
_ => "AccountCircle"
|
||||
};
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isBusy, value)) return;
|
||||
OnPropertyChanged(nameof(IsIdle));
|
||||
CloseCommand.RaiseCanExecuteChanged();
|
||||
RetryCommand.RaiseCanExecuteChanged();
|
||||
ShowRegisterCommand.RaiseCanExecuteChanged();
|
||||
ShowSignInCommand.RaiseCanExecuteChanged();
|
||||
ShowChangePasswordCommand.RaiseCanExecuteChanged();
|
||||
CancelChangePasswordCommand.RaiseCanExecuteChanged();
|
||||
SignOutCommand.RaiseCanExecuteChanged();
|
||||
SignOutAllCommand.RaiseCanExecuteChanged();
|
||||
RefreshCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Saisie et boutons utilisables (aucune action en cours).</summary>
|
||||
public bool IsIdle => !IsBusy;
|
||||
|
||||
public string BusyText
|
||||
{
|
||||
get => _busyText;
|
||||
private set => SetProperty(ref _busyText, value);
|
||||
}
|
||||
|
||||
public string? ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
|
||||
|
||||
public string? SuccessMessage
|
||||
{
|
||||
get => _successMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _successMessage, value)) OnPropertyChanged(nameof(HasSuccess));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasSuccess => !string.IsNullOrEmpty(SuccessMessage);
|
||||
|
||||
public string? UnavailableText
|
||||
{
|
||||
get => _unavailableText;
|
||||
private set => SetProperty(ref _unavailableText, value);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Connexion et création
|
||||
|
||||
/// <summary>Adresse e-mail saisie (connexion et création), gardée d'un écran à l'autre.</summary>
|
||||
public string Email
|
||||
{
|
||||
get => _email;
|
||||
set => SetProperty(ref _email, value ?? "");
|
||||
}
|
||||
|
||||
public int EmailMaxLength => PlayerEmail.MaxLength;
|
||||
public int PasswordMaxLength => PasswordRules.MaxLength;
|
||||
|
||||
/// <summary>« Rester connecté sur ce PC » (session mémorisée, chiffrée par Windows).</summary>
|
||||
public bool RememberMe
|
||||
{
|
||||
get => _rememberMe;
|
||||
set => SetProperty(ref _rememberMe, value);
|
||||
}
|
||||
|
||||
public bool ShowForgotHelp
|
||||
{
|
||||
get => _showForgotHelp;
|
||||
private set => SetProperty(ref _showForgotHelp, value);
|
||||
}
|
||||
|
||||
public string ForgotHelpText =>
|
||||
$"Contactez l'équipe {_publisher} (Discord, e-mail…) en indiquant l'adresse de votre compte : un administrateur pourra " +
|
||||
"vous donner un mot de passe temporaire. La réinitialisation par e-mail arrivera dans une prochaine version.";
|
||||
|
||||
public bool RegistrationOpen
|
||||
{
|
||||
get => _registrationOpen;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _registrationOpen, value)) return;
|
||||
OnPropertyChanged(nameof(RegistrationClosed));
|
||||
ShowRegisterCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool RegistrationClosed => !RegistrationOpen;
|
||||
|
||||
public bool AcceptTerms
|
||||
{
|
||||
get => _acceptTerms;
|
||||
set => SetProperty(ref _acceptTerms, value);
|
||||
}
|
||||
|
||||
public string TermsText => string.IsNullOrEmpty(_termsVersion)
|
||||
? "J'accepte les conditions d'utilisation et la politique de confidentialité."
|
||||
: $"J'accepte les conditions d'utilisation et la politique de confidentialité (version {_termsVersion}).";
|
||||
|
||||
public string PrivacyText =>
|
||||
"Conservés : votre adresse e-mail (chiffrée), une empreinte irréversible du mot de passe, les appareils connectés et " +
|
||||
"l'activité de connexion (12 mois, adresses IP 6 mois). Export et suppression à tout moment depuis « Mon compte ».";
|
||||
|
||||
public string RememberText => "Rester connecté sur ce PC";
|
||||
|
||||
/// <summary>Saisie de la vue : contrôle en direct du nouveau mot de passe (création, changement).</summary>
|
||||
public void UpdateNewPassword(string password, string confirmation) =>
|
||||
NewPassword.Update(password, confirmation, Step == AccountStep.Register ? Email : null);
|
||||
|
||||
public async Task SignInAsync(string password)
|
||||
{
|
||||
if (IsBusy) return;
|
||||
ClearMessages();
|
||||
if (PlayerEmail.Normalize(Email) is null)
|
||||
{
|
||||
ErrorMessage = "Adresse e-mail invalide.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
ErrorMessage = "Saisissez votre mot de passe.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync("Vérification anti-robot puis connexion…", async () =>
|
||||
{
|
||||
var result = await _service.SignInAsync(Email, password, RememberMe, _lifetime.Token);
|
||||
if (result.PasswordChangeRequired)
|
||||
{
|
||||
_changeToken = result.PasswordChangeToken;
|
||||
_changeCurrentPassword = password; // exigé par le serveur pour le changement ; oublié ensuite
|
||||
_changeStarted = DateTime.UtcNow;
|
||||
GoTo(AccountStep.PasswordChange);
|
||||
return;
|
||||
}
|
||||
await EnterAccountAsync();
|
||||
});
|
||||
}
|
||||
|
||||
public async Task RegisterAsync(string password, string confirmation)
|
||||
{
|
||||
if (IsBusy) return;
|
||||
ClearMessages();
|
||||
if (PlayerEmail.Normalize(Email) is null)
|
||||
{
|
||||
ErrorMessage = "Adresse e-mail invalide.";
|
||||
return;
|
||||
}
|
||||
NewPassword.Update(password, confirmation, Email);
|
||||
if (!NewPassword.IsAccepted)
|
||||
{
|
||||
ErrorMessage = NewPassword.Problems.FirstOrDefault() ?? "Mot de passe refusé.";
|
||||
return;
|
||||
}
|
||||
if (!NewPassword.ConfirmationMatches)
|
||||
{
|
||||
ErrorMessage = "Les deux mots de passe ne sont pas identiques.";
|
||||
return;
|
||||
}
|
||||
if (!AcceptTerms)
|
||||
{
|
||||
ErrorMessage = "Acceptez les conditions d'utilisation et la politique de confidentialité pour créer votre compte.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync("Vérification anti-robot puis création du compte (quelques secondes)…", async () =>
|
||||
{
|
||||
await _service.RegisterAsync(Email, password, _termsVersion, RememberMe, _lifetime.Token);
|
||||
await EnterAccountAsync();
|
||||
SuccessMessage = "Bienvenue ! Votre compte est créé.";
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Nouveau mot de passe exigé
|
||||
|
||||
public string PasswordChangeText =>
|
||||
$"Pour la sécurité de votre compte, l'équipe {_publisher} vous demande de choisir un nouveau mot de passe. " +
|
||||
"Vos autres appareils seront déconnectés.";
|
||||
|
||||
public async Task CompleteRequiredPasswordChangeAsync(string password, string confirmation)
|
||||
{
|
||||
if (IsBusy) return;
|
||||
ClearMessages();
|
||||
if (_changeToken is null || _changeCurrentPassword is null || DateTime.UtcNow - _changeStarted > PasswordChangeWindow)
|
||||
{
|
||||
ForgetRequiredChange();
|
||||
GoTo(AccountStep.SignIn);
|
||||
ErrorMessage = "Délai dépassé : reconnectez-vous pour choisir votre nouveau mot de passe.";
|
||||
return;
|
||||
}
|
||||
NewPassword.Update(password, confirmation, null);
|
||||
if (!NewPassword.IsValid)
|
||||
{
|
||||
ErrorMessage = NewPassword.IsAccepted ? "Les deux mots de passe ne sont pas identiques." : NewPassword.Problems.FirstOrDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync("Enregistrement du nouveau mot de passe…", async () =>
|
||||
{
|
||||
await _service.CompleteRequiredPasswordChangeAsync(_changeToken, _changeCurrentPassword, password, RememberMe, _lifetime.Token);
|
||||
ForgetRequiredChange();
|
||||
await EnterAccountAsync();
|
||||
SuccessMessage = "Nouveau mot de passe enregistré : vous êtes connecté.";
|
||||
});
|
||||
}
|
||||
|
||||
private void ForgetRequiredChange()
|
||||
{
|
||||
_changeToken = null;
|
||||
_changeCurrentPassword = null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Mon compte
|
||||
|
||||
public AccountSection Section
|
||||
{
|
||||
get => _section;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _section, value)) return;
|
||||
OnPropertyChanged(nameof(IsProfileSection));
|
||||
OnPropertyChanged(nameof(IsDevicesSection));
|
||||
OnPropertyChanged(nameof(IsActivitySection));
|
||||
OnPropertyChanged(nameof(IsDataSection));
|
||||
ClearMessages();
|
||||
IsChangingPassword = false;
|
||||
DeleteConfirmed = false;
|
||||
ClearPasswordsRequested?.Invoke();
|
||||
if (value == AccountSection.Activity && !_activityLoaded) _ = LoadActivityAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// Onglets (RadioButton : IsChecked lié dans les deux sens).
|
||||
public bool IsProfileSection
|
||||
{
|
||||
get => Section == AccountSection.Profile;
|
||||
set { if (value) Section = AccountSection.Profile; }
|
||||
}
|
||||
|
||||
public bool IsDevicesSection
|
||||
{
|
||||
get => Section == AccountSection.Devices;
|
||||
set { if (value) Section = AccountSection.Devices; }
|
||||
}
|
||||
|
||||
public bool IsActivitySection
|
||||
{
|
||||
get => Section == AccountSection.Activity;
|
||||
set { if (value) Section = AccountSection.Activity; }
|
||||
}
|
||||
|
||||
public bool IsDataSection
|
||||
{
|
||||
get => Section == AccountSection.Data;
|
||||
set { if (value) Section = AccountSection.Data; }
|
||||
}
|
||||
|
||||
public string EmailMasked => _account?.EmailMasked ?? "";
|
||||
public string Reference => _account?.Reference ?? "";
|
||||
public string CreatedText => FormatDate(_account?.CreatedAt);
|
||||
public string LastLoginText => FormatDate(_account?.LastLoginAt);
|
||||
public string PasswordChangedText => FormatDate(_account?.PasswordChangedAt);
|
||||
public bool NewDevicesSuspended => _account?.NewDevicesSuspended == true;
|
||||
public string AvatarText => EmailMasked.Length > 0 ? EmailMasked[..1].ToUpperInvariant() : "?";
|
||||
|
||||
public string SuspendedText =>
|
||||
"Par sécurité, les connexions depuis un NOUVEL appareil sont suspendues sur ce compte après de nombreux essais de mot de " +
|
||||
$"passe. Cet appareil continue de fonctionner. Contactez l'équipe {_publisher} avec la référence {Reference} pour lever la suspension.";
|
||||
|
||||
public bool IsChangingPassword
|
||||
{
|
||||
get => _isChangingPassword;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isChangingPassword, value)) return;
|
||||
NewPassword.Reset();
|
||||
ClearPasswordsRequested?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ChangePasswordAsync(string currentPassword, string password, string confirmation)
|
||||
{
|
||||
if (IsBusy) return;
|
||||
ClearMessages();
|
||||
if (string.IsNullOrEmpty(currentPassword))
|
||||
{
|
||||
ErrorMessage = "Saisissez votre mot de passe actuel.";
|
||||
return;
|
||||
}
|
||||
NewPassword.Update(password, confirmation, null);
|
||||
if (!NewPassword.IsValid)
|
||||
{
|
||||
ErrorMessage = NewPassword.IsAccepted ? "Les deux mots de passe ne sont pas identiques." : NewPassword.Problems.FirstOrDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync("Enregistrement du nouveau mot de passe…", async () =>
|
||||
{
|
||||
var others = await _service.ChangePasswordAsync(currentPassword, password, _lifetime.Token);
|
||||
IsChangingPassword = false;
|
||||
await LoadMeAsync();
|
||||
SuccessMessage = others switch
|
||||
{
|
||||
0 => "Mot de passe modifié.",
|
||||
1 => "Mot de passe modifié. Votre autre appareil a été déconnecté.",
|
||||
_ => $"Mot de passe modifié. Vos {others} autres appareils ont été déconnectés."
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public bool IsActivityLoading
|
||||
{
|
||||
get => _isActivityLoading;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _isActivityLoading, value)) OnPropertyChanged(nameof(IsActivityEmpty));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsActivityEmpty => !IsActivityLoading && _activityLoaded && Activity.Count == 0;
|
||||
|
||||
public bool DeleteConfirmed
|
||||
{
|
||||
get => _deleteConfirmed;
|
||||
set => SetProperty(ref _deleteConfirmed, value);
|
||||
}
|
||||
|
||||
public string ExportText =>
|
||||
"Téléchargez toutes les données de votre compte (adresse e-mail, appareils, activité…) dans un fichier JSON lisible " +
|
||||
"par machine. Votre mot de passe est demandé, car le fichier contient votre adresse complète.";
|
||||
|
||||
public string DeleteText =>
|
||||
"Votre compte, vos appareils connectés et l'activité de votre compte sont effacés DÉFINITIVEMENT. Le jeu et ses " +
|
||||
"sauvegardes sur ce PC ne sont pas touchés. Une simple trace de la suppression (référence du compte, date) est gardée " +
|
||||
"12 mois dans le journal de sécurité.";
|
||||
|
||||
/// <summary>Export des données : emplacement choisi d'abord (annulable sans rien envoyer), puis téléchargement.</summary>
|
||||
public async Task ExportAsync(string password)
|
||||
{
|
||||
if (IsBusy) return;
|
||||
ClearMessages();
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
ErrorMessage = "Saisissez votre mot de passe pour exporter vos données.";
|
||||
return;
|
||||
}
|
||||
var path = _dialogs.PickSaveFile("Exporter mes données", "Fichier JSON (*.json)|*.json",
|
||||
PlayerDataExport.SuggestedFileName(Reference, DateTimeOffset.Now), Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments));
|
||||
if (path is null) return;
|
||||
|
||||
await RunAsync("Préparation de l'export…", async () =>
|
||||
{
|
||||
var bytes = await _service.ExportAsync(password, _lifetime.Token);
|
||||
await File.WriteAllBytesAsync(path, bytes, _lifetime.Token);
|
||||
ClearPasswordsRequested?.Invoke();
|
||||
SuccessMessage = $"Vos données ont été enregistrées dans {Path.GetFileName(path)}.";
|
||||
Log.Info("Compte : données exportées.");
|
||||
});
|
||||
}
|
||||
|
||||
public async Task DeleteAccountAsync(string password)
|
||||
{
|
||||
if (IsBusy) return;
|
||||
ClearMessages();
|
||||
if (!DeleteConfirmed)
|
||||
{
|
||||
ErrorMessage = "Cochez la case pour confirmer que vous comprenez que la suppression est définitive.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
ErrorMessage = "Saisissez votre mot de passe pour supprimer votre compte.";
|
||||
return;
|
||||
}
|
||||
if (!_dialogs.Confirm("Supprimer définitivement le compte ?",
|
||||
$"Le compte {EmailMasked} ({Reference}) sera supprimé définitivement. Cette action est irréversible.",
|
||||
"Supprimer mon compte", "Annuler", danger: true))
|
||||
return;
|
||||
|
||||
await RunAsync("Suppression du compte…", async () =>
|
||||
{
|
||||
await _service.DeleteAccountAsync(password, _lifetime.Token);
|
||||
Email = "";
|
||||
GoTo(AccountStep.SignIn);
|
||||
SuccessMessage = "Votre compte a été supprimé définitivement.";
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Chargement
|
||||
|
||||
/// <summary>Ouverture de la fenêtre (et « Réessayer ») : compte connecté, sinon état du service puis connexion.</summary>
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
ClearMessages();
|
||||
_ = Task.Run(() => PasswordRules.Check("préchargement-du-dictionnaire")); // dictionnaire prêt avant la première frappe
|
||||
if (_service.State is PlayerAccountState.SignedIn or PlayerAccountState.Restoring or PlayerAccountState.Offline)
|
||||
{
|
||||
await RunAsync("Chargement du compte…", EnterAccountAsync, Step == AccountStep.Account ? null : AccountStep.Loading);
|
||||
return;
|
||||
}
|
||||
|
||||
Step = AccountStep.Loading;
|
||||
IsBusy = true;
|
||||
BusyText = "Connexion au serveur…";
|
||||
try
|
||||
{
|
||||
var info = await _service.GetServiceInfoAsync(_lifetime.Token);
|
||||
if (info is not { Enabled: true })
|
||||
{
|
||||
UnavailableText = "Les comptes joueurs ne sont pas encore ouverts. Revenez bientôt !";
|
||||
Step = AccountStep.Unavailable;
|
||||
return;
|
||||
}
|
||||
RegistrationOpen = info.RegistrationOpen;
|
||||
_termsVersion = info.TermsVersion;
|
||||
OnPropertyChanged(nameof(TermsText));
|
||||
Step = AccountStep.SignIn;
|
||||
if (_service.Notice is { } notice) ErrorMessage = notice;
|
||||
}
|
||||
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
|
||||
{
|
||||
// fenêtre fermée
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
UnavailableText = PlayerAccountTexts.Describe(ex);
|
||||
Step = AccountStep.Unavailable;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnterAccountAsync()
|
||||
{
|
||||
Section = AccountSection.Profile;
|
||||
_activityLoaded = false;
|
||||
Activity.Clear();
|
||||
await LoadMeAsync();
|
||||
GoTo(AccountStep.Account, keepMessages: true);
|
||||
}
|
||||
|
||||
private async Task RefreshAccountAsync()
|
||||
{
|
||||
ClearMessages();
|
||||
await RunAsync("Actualisation…", async () =>
|
||||
{
|
||||
await LoadMeAsync();
|
||||
if (Section == AccountSection.Activity) await LoadActivityCoreAsync();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task LoadMeAsync()
|
||||
{
|
||||
var me = await _service.GetMeAsync(_lifetime.Token);
|
||||
SetAccount(me.Account);
|
||||
Devices.Clear();
|
||||
foreach (var device in me.Devices.OrderByDescending(d => d.Current).ThenByDescending(d => d.LastUsedAt))
|
||||
Devices.Add(new PlayerDeviceItem(device, FormatDate(device.CreatedAt), FormatDate(device.LastUsedAt), RemoveDeviceAsync));
|
||||
}
|
||||
|
||||
private async Task LoadActivityAsync()
|
||||
{
|
||||
if (IsBusy) return;
|
||||
try
|
||||
{
|
||||
await LoadActivityCoreAsync();
|
||||
}
|
||||
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
|
||||
{
|
||||
// fenêtre fermée
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadActivityCoreAsync()
|
||||
{
|
||||
IsActivityLoading = true;
|
||||
try
|
||||
{
|
||||
var activity = await _service.GetActivityAsync(_lifetime.Token);
|
||||
Activity.Clear();
|
||||
foreach (var entry in activity.Entries) Activity.Add(new PlayerActivityItem(entry, FormatDateTime(entry.At)));
|
||||
_activityLoaded = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsActivityLoading = false;
|
||||
OnPropertyChanged(nameof(IsActivityEmpty));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveDeviceAsync(PlayerDeviceItem device)
|
||||
{
|
||||
if (IsBusy) return;
|
||||
ClearMessages();
|
||||
if (!_dialogs.Confirm("Déconnecter cet appareil ?",
|
||||
$"« {device.Label} » devra se reconnecter avec le mot de passe pour accéder au compte.", "Déconnecter", "Annuler"))
|
||||
return;
|
||||
await RunAsync("Déconnexion de l'appareil…", async () =>
|
||||
{
|
||||
await _service.RemoveDeviceAsync(device.Id, _lifetime.Token);
|
||||
if (_service.State == PlayerAccountState.SignedOut) return; // c'était cet appareil
|
||||
await LoadMeAsync();
|
||||
SuccessMessage = $"« {device.Label} » a été déconnecté.";
|
||||
});
|
||||
}
|
||||
|
||||
private async Task SignOutAsync(bool allDevices)
|
||||
{
|
||||
if (IsBusy) return;
|
||||
ClearMessages();
|
||||
if (allDevices && !_dialogs.Confirm("Déconnecter tous vos appareils ?",
|
||||
"Tous les appareils connectés à votre compte, y compris celui-ci, devront se reconnecter avec le mot de passe.",
|
||||
"Tout déconnecter", "Annuler", danger: true))
|
||||
return;
|
||||
await RunAsync(allDevices ? "Déconnexion de tous les appareils…" : "Déconnexion…", async () =>
|
||||
{
|
||||
await _service.SignOutAsync(allDevices, _lifetime.Token);
|
||||
GoTo(AccountStep.SignIn);
|
||||
SuccessMessage = allDevices ? "Tous vos appareils ont été déconnectés." : "Vous êtes déconnecté.";
|
||||
});
|
||||
}
|
||||
|
||||
private void CopyReference()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Reference)) return;
|
||||
_dialogs.CopyToClipboard(Reference);
|
||||
ClearMessages();
|
||||
SuccessMessage = $"Référence {Reference} copiée.";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
/// <summary>Action longue : saisie bloquée, erreurs affichées en clair ; session perdue → écran de connexion.</summary>
|
||||
private async Task RunAsync(string busyText, Func<Task> work, AccountStep? stepWhileBusy = null)
|
||||
{
|
||||
IsBusy = true;
|
||||
BusyText = busyText;
|
||||
if (stepWhileBusy is { } step) Step = step;
|
||||
try
|
||||
{
|
||||
await work();
|
||||
}
|
||||
catch (PlayerSessionLostException ex)
|
||||
{
|
||||
GoTo(AccountStep.SignIn);
|
||||
await EnsureServiceInfoAsync();
|
||||
ErrorMessage = ex.Message;
|
||||
}
|
||||
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
|
||||
{
|
||||
// fenêtre fermée pendant l'action
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (Step == AccountStep.Loading) Step = _service.State == PlayerAccountState.SignedOut ? AccountStep.SignIn : AccountStep.Account;
|
||||
ShowError(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
ClearPasswordsRequested?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Informations du service (création ouverte, conditions) si l'écran de connexion est atteint sans elles.</summary>
|
||||
private async Task EnsureServiceInfoAsync()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_termsVersion)) return;
|
||||
try
|
||||
{
|
||||
if (await _service.GetServiceInfoAsync(_lifetime.Token) is { } info)
|
||||
{
|
||||
RegistrationOpen = info.Enabled && info.RegistrationOpen;
|
||||
_termsVersion = info.TermsVersion;
|
||||
OnPropertyChanged(nameof(TermsText));
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Log.Warn($"Compte : état du service illisible ({ex.Message}).");
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowError(Exception ex)
|
||||
{
|
||||
ErrorMessage = PlayerAccountTexts.Describe(ex);
|
||||
Log.Warn($"Compte : {ex.GetType().Name} : {ex.Message}");
|
||||
}
|
||||
|
||||
private void GoTo(AccountStep step, bool keepMessages = false)
|
||||
{
|
||||
if (!keepMessages) ClearMessages();
|
||||
if (step != AccountStep.PasswordChange) ForgetRequiredChange();
|
||||
if (step is AccountStep.SignIn or AccountStep.Register)
|
||||
{
|
||||
AcceptTerms = false;
|
||||
ShowForgotHelp = false;
|
||||
if (step == AccountStep.Register && !RegistrationOpen) step = AccountStep.SignIn;
|
||||
}
|
||||
IsChangingPassword = false;
|
||||
DeleteConfirmed = false;
|
||||
NewPassword.Reset();
|
||||
Step = step;
|
||||
ClearPasswordsRequested?.Invoke();
|
||||
}
|
||||
|
||||
private void ClearMessages()
|
||||
{
|
||||
ErrorMessage = null;
|
||||
SuccessMessage = null;
|
||||
}
|
||||
|
||||
private void SetAccount(PlayerAccountInfo? account)
|
||||
{
|
||||
_account = account;
|
||||
foreach (var name in new[]
|
||||
{
|
||||
nameof(EmailMasked), nameof(Reference), nameof(CreatedText), nameof(LastLoginText), nameof(PasswordChangedText),
|
||||
nameof(NewDevicesSuspended), nameof(AvatarText), nameof(SuspendedText), nameof(Subtitle)
|
||||
})
|
||||
OnPropertyChanged(name);
|
||||
CopyReferenceCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
/// <summary>Changement venu du service (session perdue pendant que la fenêtre est ouverte…).</summary>
|
||||
private void OnServiceChanged() => _ui.Post(() =>
|
||||
{
|
||||
if (_service.State == PlayerAccountState.SignedOut && Step is AccountStep.Account && !IsBusy)
|
||||
{
|
||||
GoTo(AccountStep.SignIn);
|
||||
ErrorMessage = _service.Notice ?? "Vous avez été déconnecté.";
|
||||
}
|
||||
else if (_service.Account is { } account && Step == AccountStep.Account)
|
||||
{
|
||||
SetAccount(account);
|
||||
}
|
||||
});
|
||||
|
||||
internal static string FormatDate(DateTimeOffset? date) =>
|
||||
date is { } value && value != default ? value.ToLocalTime().ToString("d MMMM yyyy", French) : "—";
|
||||
|
||||
internal static string FormatDateTime(DateTimeOffset date) =>
|
||||
date.ToLocalTime().ToString("d MMM yyyy 'à' HH:mm", French);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_service.Changed -= OnServiceChanged;
|
||||
_lifetime.Cancel(); // actions en cours abandonnées (la source n'est pas libérée : une action peut encore la lire)
|
||||
ForgetRequiredChange();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Un appareil connecté au compte (onglet « Appareils »).</summary>
|
||||
public sealed class PlayerDeviceItem
|
||||
{
|
||||
public PlayerDeviceItem(PlayerDeviceInfo device, string createdText, string lastUsedText, Func<PlayerDeviceItem, Task> remove)
|
||||
{
|
||||
Id = device.Id;
|
||||
Label = device.Label;
|
||||
IsCurrent = device.Current;
|
||||
Detail = $"Connecté le {createdText} · utilisé le {lastUsedText}";
|
||||
RemoveCommand = new AsyncRelayCommand(() => remove(this));
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
public string Label { get; }
|
||||
public string Detail { get; }
|
||||
|
||||
/// <summary>Appareil de la session en cours (« Cet appareil » ; se déconnecte avec « Se déconnecter »).</summary>
|
||||
public bool IsCurrent { get; }
|
||||
|
||||
public bool CanRemove => !IsCurrent;
|
||||
public AsyncRelayCommand RemoveCommand { get; }
|
||||
}
|
||||
|
||||
/// <summary>Une ligne de l'activité récente du compte.</summary>
|
||||
public sealed class PlayerActivityItem
|
||||
{
|
||||
private static readonly HashSet<string> Alerts = new(StringComparer.Ordinal)
|
||||
{
|
||||
"account.locked", "account.suspended", "session.reuse", "account.disabled"
|
||||
};
|
||||
|
||||
public PlayerActivityItem(PlayerActivityEntry entry, string dateText)
|
||||
{
|
||||
Title = PlayerActivityText.Describe(entry.Type, entry.Success);
|
||||
IsWarning = !entry.Success || Alerts.Contains(entry.Type);
|
||||
var where = entry.ThisDevice ? "cet appareil" : entry.Device ?? (entry.Ip is { } ip ? $"adresse {ip}" : null);
|
||||
Detail = where is null ? dateText : $"{dateText} · {where}";
|
||||
}
|
||||
|
||||
public string Title { get; }
|
||||
public string Detail { get; }
|
||||
|
||||
/// <summary>Échec ou alerte de sécurité (icône orange).</summary>
|
||||
public bool IsWarning { get; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Module « Compte » côté joueur (sans WPF) : connexion, création de compte, session mémorisée (jeton de renouvellement
|
||||
chiffré sur le PC, lié à la clé de l'appareil), « Mon compte » (appareils, activité, mot de passe, export des données,
|
||||
suppression du compte). Testable sans interface, contre la Launcher API démarrée en mémoire.
|
||||
Ce qui dépend de Windows est fourni par le launcher : clé d'appareil (puce TPM ou clé logicielle CNG, Platform\PlayerDeviceKeyStore.cs)
|
||||
et chiffrement de la session (DPAPI, Platform\DpapiProtector.cs) ; la fenêtre est Views\AccountWindow.xaml.
|
||||
Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.Modules.Accounts</RootNamespace>
|
||||
<AssemblyName>GameLauncher.Modules.Accounts</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="GameLauncher.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,103 @@
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle EN DIRECT d'un nouveau mot de passe (création de compte, changement) avec les mêmes règles que le serveur
|
||||
/// (<see cref="PasswordRules"/>) : problèmes restants, confirmation identique, jauge de solidité indicative. Le mot de passe
|
||||
/// n'est jamais gardé ici : seule la vue (PasswordBox) le détient ; elle appelle <see cref="Update"/> à chaque frappe.
|
||||
/// </summary>
|
||||
public sealed class PasswordRulesViewModel : ObservableObject
|
||||
{
|
||||
private IReadOnlyList<string> _problems = Array.Empty<string>();
|
||||
private bool _hasInput;
|
||||
private bool _hasConfirmation;
|
||||
private bool _confirmationMatches;
|
||||
private int _strength;
|
||||
|
||||
/// <summary>Rappel des règles, affiché tant que rien n'est saisi.</summary>
|
||||
public string RulesText =>
|
||||
$"Au moins {PasswordRules.MinLength} caractères mêlant au moins {PasswordRules.RequiredClasses} sortes parmi minuscules, majuscules, " +
|
||||
$"chiffres et symboles, ou une phrase de passe d'au moins {PasswordRules.MinLengthWithoutComposition} caractères. " +
|
||||
"Les mots de passe trop courants sont refusés.";
|
||||
|
||||
/// <summary>Problèmes restants (messages du contrôle commun au launcher et au serveur).</summary>
|
||||
public IReadOnlyList<string> Problems
|
||||
{
|
||||
get => _problems;
|
||||
private set => SetProperty(ref _problems, value);
|
||||
}
|
||||
|
||||
public bool HasInput
|
||||
{
|
||||
get => _hasInput;
|
||||
private set => SetProperty(ref _hasInput, value);
|
||||
}
|
||||
|
||||
public bool ShowRules => !HasInput;
|
||||
public bool HasProblems => HasInput && Problems.Count > 0;
|
||||
|
||||
/// <summary>Mot de passe conforme aux règles (indépendamment de la confirmation).</summary>
|
||||
public bool IsAccepted => HasInput && Problems.Count == 0;
|
||||
|
||||
public bool HasConfirmation
|
||||
{
|
||||
get => _hasConfirmation;
|
||||
private set => SetProperty(ref _hasConfirmation, value);
|
||||
}
|
||||
|
||||
public bool ConfirmationMatches
|
||||
{
|
||||
get => _confirmationMatches;
|
||||
private set => SetProperty(ref _confirmationMatches, value);
|
||||
}
|
||||
|
||||
public bool ShowConfirmationMismatch => HasConfirmation && !ConfirmationMatches;
|
||||
|
||||
/// <summary>Mot de passe accepté ET confirmation identique : le bouton d'envoi peut être utilisé.</summary>
|
||||
public bool IsValid => IsAccepted && ConfirmationMatches;
|
||||
|
||||
/// <summary>Jauge 0 à 4 (indicative : longueur et variété au-delà du minimum).</summary>
|
||||
public int Strength
|
||||
{
|
||||
get => _strength;
|
||||
private set => SetProperty(ref _strength, value);
|
||||
}
|
||||
|
||||
public string StrengthText => !HasInput ? "" : !IsAccepted ? "Refusé" : Strength switch
|
||||
{
|
||||
>= 4 => "Excellent",
|
||||
3 => "Très bon",
|
||||
_ => "Correct"
|
||||
};
|
||||
|
||||
/// <param name="email">Adresse du compte (création) : un mot de passe qui en est tiré est refusé.</param>
|
||||
public void Update(string password, string confirmation, string? email)
|
||||
{
|
||||
HasInput = !string.IsNullOrEmpty(password);
|
||||
Problems = HasInput ? PasswordRules.Check(password, email) : Array.Empty<string>();
|
||||
HasConfirmation = !string.IsNullOrEmpty(confirmation);
|
||||
ConfirmationMatches = HasInput && string.Equals(password, confirmation, StringComparison.Ordinal);
|
||||
Strength = !HasInput || Problems.Count > 0 ? 0 : Score(password);
|
||||
OnPropertyChanged(nameof(ShowRules));
|
||||
OnPropertyChanged(nameof(HasProblems));
|
||||
OnPropertyChanged(nameof(IsAccepted));
|
||||
OnPropertyChanged(nameof(ShowConfirmationMismatch));
|
||||
OnPropertyChanged(nameof(IsValid));
|
||||
OnPropertyChanged(nameof(StrengthText));
|
||||
}
|
||||
|
||||
public void Reset() => Update("", "", null);
|
||||
|
||||
private static int Score(string password)
|
||||
{
|
||||
var classes = (password.Any(char.IsLower) ? 1 : 0) + (password.Any(char.IsUpper) ? 1 : 0) +
|
||||
(password.Any(char.IsDigit) ? 1 : 0) + (password.Any(c => !char.IsLetterOrDigit(c)) ? 1 : 0);
|
||||
var length = password.Length;
|
||||
var score = 2;
|
||||
if (length >= 16) score++;
|
||||
if (length >= 20 || (length >= 14 && classes == 4)) score++;
|
||||
return Math.Min(4, score);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Modules.Accounts;
|
||||
|
||||
/// <summary>État du compte joueur dans le launcher.</summary>
|
||||
public enum PlayerAccountState
|
||||
{
|
||||
/// <summary>Aucun compte connecté.</summary>
|
||||
SignedOut,
|
||||
|
||||
/// <summary>Session mémorisée en cours de renouvellement (démarrage du launcher).</summary>
|
||||
Restoring,
|
||||
|
||||
SignedIn,
|
||||
|
||||
/// <summary>Session mémorisée, serveur injoignable : nouvel essai à la prochaine action.</summary>
|
||||
Offline
|
||||
}
|
||||
|
||||
/// <summary>Résultat d'une connexion : connecté, ou nouveau mot de passe exigé par un administrateur.</summary>
|
||||
/// <param name="PasswordChangeToken">Jeton limité (10 min) qui permet seulement de choisir le nouveau mot de passe.</param>
|
||||
public sealed record PlayerSignInResult(bool PasswordChangeRequired, string? PasswordChangeToken = null);
|
||||
|
||||
/// <summary>Compte joueur du launcher (implémenté par <see cref="PlayerAccountService"/>, simulé par les tests et UiSnapshot).</summary>
|
||||
public interface IPlayerAccountService
|
||||
{
|
||||
PlayerAccountState State { get; }
|
||||
|
||||
/// <summary>Compte connecté (ou mémorisé hors ligne : référence et adresse masquée seulement), sinon null.</summary>
|
||||
PlayerAccountInfo? Account { get; }
|
||||
|
||||
/// <summary>Raison de la dernière déconnexion imposée (session expirée, compte désactivé…), à afficher une fois.</summary>
|
||||
string? Notice { get; }
|
||||
|
||||
/// <summary>Changement d'état ou de compte (peut être levé depuis un autre fil).</summary>
|
||||
event Action? Changed;
|
||||
|
||||
/// <summary>État du service des comptes annoncé par le serveur (null : comptes non activés sur ce serveur).</summary>
|
||||
Task<PlayerAccountsInfo?> GetServiceInfoAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Reprend la session mémorisée (démarrage du launcher), sans jamais lever d'erreur.</summary>
|
||||
Task RestoreAsync(CancellationToken ct = default);
|
||||
|
||||
Task<PlayerSignInResult> SignInAsync(string email, string password, bool remember, CancellationToken ct = default);
|
||||
|
||||
Task RegisterAsync(string email, string password, string termsVersion, bool remember, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Nouveau mot de passe EXIGÉ (après <see cref="SignInAsync"/>) : la session s'ouvre ensuite.</summary>
|
||||
Task CompleteRequiredPasswordChangeAsync(string passwordChangeToken, string currentPassword, string newPassword, bool remember,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>Changement ordinaire : renvoie le nombre d'autres appareils déconnectés.</summary>
|
||||
Task<int> ChangePasswordAsync(string currentPassword, string newPassword, CancellationToken ct = default);
|
||||
|
||||
Task<PlayerMeResponse> GetMeAsync(CancellationToken ct = default);
|
||||
|
||||
Task<PlayerActivityResponse> GetActivityAsync(CancellationToken ct = default);
|
||||
|
||||
Task RemoveDeviceAsync(string deviceId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Déconnexion de ce PC (ou de tous les appareils) ; la session locale est effacée même si le serveur est injoignable.</summary>
|
||||
Task SignOutAsync(bool allDevices, CancellationToken ct = default);
|
||||
|
||||
Task<byte[]> ExportAsync(string password, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Suppression DÉFINITIVE du compte ; la session locale est effacée.</summary>
|
||||
Task DeleteAccountAsync(string password, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Session perdue (expirée, révoquée, compte désactivé…) : le joueur doit se reconnecter.</summary>
|
||||
public sealed class PlayerSessionLostException : Exception
|
||||
{
|
||||
public PlayerSessionLostException(string message, Exception? inner = null) : base(message, inner) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compte joueur du launcher. Le jeton d'accès (15 min) reste en mémoire ; le jeton de renouvellement est enregistré
|
||||
/// chiffré (<see cref="PlayerSessionFile"/>) si le joueur a choisi « Rester connecté », sinon gardé en mémoire et la session
|
||||
/// est fermée à la sortie du launcher (<see cref="Shutdown"/>). Chaque renouvellement est signé par la clé de l'appareil ;
|
||||
/// le nouveau jeton remplace aussitôt l'ancien sur le disque (l'ancien, présenté de nouveau, ferait déconnecter l'appareil
|
||||
/// par le serveur). Un renouvellement à la fois (verrou) ; jeton d'accès renouvelé une minute avant son échéance, ou
|
||||
/// aussitôt si le serveur l'a oublié (redémarrage).
|
||||
/// Le launcher ne contacte l'API des comptes que sur une action du joueur, ou pour reprendre une session mémorisée.
|
||||
/// </summary>
|
||||
public sealed class PlayerAccountService : IPlayerAccountService, IDisposable
|
||||
{
|
||||
private static readonly TimeSpan AccessMargin = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly PlayerAccountApi _api;
|
||||
private readonly IPlayerDeviceKeyStore _keys;
|
||||
private readonly PlayerSessionFile _file;
|
||||
private readonly string _deviceLabel;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly object _lock = new();
|
||||
|
||||
private IPlayerDeviceKey? _key;
|
||||
private string? _access;
|
||||
private DateTimeOffset _accessExpiresAt;
|
||||
private string? _refresh;
|
||||
private string? _deviceId;
|
||||
private DateTimeOffset _refreshExpiresAt;
|
||||
private bool _remember;
|
||||
private PlayerAccountInfo? _account;
|
||||
private PlayerAccountState _state = PlayerAccountState.SignedOut;
|
||||
private string? _notice;
|
||||
|
||||
public PlayerAccountService(PlayerAccountApi api, IPlayerDeviceKeyStore keys, PlayerSessionFile file, string deviceLabel,
|
||||
TimeProvider? time = null)
|
||||
{
|
||||
_api = api;
|
||||
_keys = keys;
|
||||
_file = file;
|
||||
_deviceLabel = deviceLabel;
|
||||
_time = time ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public event Action? Changed;
|
||||
|
||||
public PlayerAccountState State
|
||||
{
|
||||
get { lock (_lock) return _state; }
|
||||
}
|
||||
|
||||
public PlayerAccountInfo? Account
|
||||
{
|
||||
get { lock (_lock) return _account; }
|
||||
}
|
||||
|
||||
public string? Notice
|
||||
{
|
||||
get { lock (_lock) return _notice; }
|
||||
}
|
||||
|
||||
/// <summary>Session mémorisée sur ce PC (pour les tests et l'affichage).</summary>
|
||||
public bool IsRemembered
|
||||
{
|
||||
get { lock (_lock) return _remember && _refresh is not null; }
|
||||
}
|
||||
|
||||
public async Task<PlayerAccountsInfo?> GetServiceInfoAsync(CancellationToken ct = default) =>
|
||||
(await _api.Client.GetInfoAsync(ct).ConfigureAwait(false)).Accounts;
|
||||
|
||||
// ------------------------------------------------------------------ Session mémorisée
|
||||
|
||||
public async Task RestoreAsync(CancellationToken ct = default)
|
||||
{
|
||||
StoredPlayerSession? stored;
|
||||
try
|
||||
{
|
||||
stored = _file.Load();
|
||||
if (stored is null) return;
|
||||
if (!string.Equals(stored.Origin, _api.Client.Origin, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Log.Info("Compte : session mémorisée d'un autre serveur, ignorée.");
|
||||
_file.Delete();
|
||||
return;
|
||||
}
|
||||
var key = await Task.Run(() => _keys.TryOpen(), ct).ConfigureAwait(false);
|
||||
if (key is null || key.KeyId != stored.KeyId)
|
||||
{
|
||||
Log.Warn("Compte : clé de l'appareil absente ou remplacée, la session mémorisée est abandonnée.");
|
||||
_file.Delete();
|
||||
return;
|
||||
}
|
||||
lock (_lock)
|
||||
{
|
||||
_key = key;
|
||||
_refresh = stored.RefreshToken;
|
||||
_deviceId = stored.DeviceId;
|
||||
_refreshExpiresAt = stored.RefreshExpiresAt;
|
||||
_remember = true;
|
||||
_account = new PlayerAccountInfo { Reference = stored.Reference, EmailMasked = stored.EmailMasked };
|
||||
_state = PlayerAccountState.Restoring;
|
||||
}
|
||||
RaiseChanged();
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException or PlatformNotSupportedException)
|
||||
{
|
||||
Log.Error("Compte : reprise de la session mémorisée", ex);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await RenewAsync(force: true, ct).ConfigureAwait(false);
|
||||
Log.Info("Compte : session reprise.");
|
||||
}
|
||||
catch (PlayerSessionLostException ex)
|
||||
{
|
||||
Log.Info($"Compte : session mémorisée refusée ({ex.Message}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Compte : serveur injoignable, session mémorisée conservée ({ex.Message}).");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Connexion, création de compte
|
||||
|
||||
public async Task<PlayerSignInResult> SignInAsync(string email, string password, bool remember, CancellationToken ct = default)
|
||||
{
|
||||
var key = await OpenKeyAsync(ct).ConfigureAwait(false);
|
||||
var response = await _api.LoginAsync(email, password, key, _deviceLabel, ct).ConfigureAwait(false);
|
||||
if (response.Status == PlayerLoginStatus.PasswordChangeRequired && response.PasswordChangeToken is { } token)
|
||||
return new PlayerSignInResult(true, token);
|
||||
Adopt(response.Session ?? throw new ApiException(0, "invalid_response", "Réponse du serveur illisible."), key, remember);
|
||||
Log.Info("Compte : connecté.");
|
||||
return new PlayerSignInResult(false);
|
||||
}
|
||||
|
||||
public async Task RegisterAsync(string email, string password, string termsVersion, bool remember, CancellationToken ct = default)
|
||||
{
|
||||
var key = await OpenKeyAsync(ct).ConfigureAwait(false);
|
||||
var response = await _api.RegisterAsync(email, password, termsVersion, key, _deviceLabel, ct).ConfigureAwait(false);
|
||||
Adopt(response.Session ?? throw new ApiException(0, "invalid_response", "Réponse du serveur illisible."), key, remember);
|
||||
Log.Info("Compte : créé et connecté.");
|
||||
}
|
||||
|
||||
public async Task CompleteRequiredPasswordChangeAsync(string passwordChangeToken, string currentPassword, string newPassword, bool remember,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var key = await OpenKeyAsync(ct).ConfigureAwait(false);
|
||||
var response = await _api.ChangePasswordAsync(passwordChangeToken, currentPassword, newPassword, ct).ConfigureAwait(false);
|
||||
Adopt(response.Session ?? throw new ApiException(0, "invalid_response", "Réponse du serveur illisible."), key, remember);
|
||||
Log.Info("Compte : nouveau mot de passe exigé enregistré, connecté.");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Compte connecté
|
||||
|
||||
public async Task<int> ChangePasswordAsync(string currentPassword, string newPassword, CancellationToken ct = default) =>
|
||||
(await CallAsync(token => _api.ChangePasswordAsync(token, currentPassword, newPassword, ct), ct).ConfigureAwait(false)).DevicesSignedOut;
|
||||
|
||||
public async Task<PlayerMeResponse> GetMeAsync(CancellationToken ct = default)
|
||||
{
|
||||
var me = await CallAsync(token => _api.MeAsync(token, ct), ct).ConfigureAwait(false);
|
||||
lock (_lock) _account = me.Account;
|
||||
RaiseChanged();
|
||||
return me;
|
||||
}
|
||||
|
||||
public Task<PlayerActivityResponse> GetActivityAsync(CancellationToken ct = default) =>
|
||||
CallAsync(token => _api.ActivityAsync(token, ct), ct);
|
||||
|
||||
public async Task RemoveDeviceAsync(string deviceId, CancellationToken ct = default)
|
||||
{
|
||||
await CallAsync(async token =>
|
||||
{
|
||||
await _api.RemoveDeviceAsync(token, deviceId, ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}, ct).ConfigureAwait(false);
|
||||
string? current;
|
||||
lock (_lock) current = _deviceId;
|
||||
if (deviceId == current) Clear(null); // cet appareil lui-même
|
||||
}
|
||||
|
||||
public async Task SignOutAsync(bool allDevices, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CallAsync(async token =>
|
||||
{
|
||||
await _api.LogoutAsync(token, allDevices, ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (!allDevices && ex is ApiException or PlayerSessionLostException)
|
||||
{
|
||||
// Serveur injoignable ou session déjà fermée : ce PC est déconnecté quand même (le jeton est effacé ici).
|
||||
Log.Warn($"Compte : déconnexion locale seulement ({ex.Message}).");
|
||||
}
|
||||
Clear(null);
|
||||
Log.Info(allDevices ? "Compte : tous les appareils déconnectés." : "Compte : déconnecté.");
|
||||
}
|
||||
|
||||
public Task<byte[]> ExportAsync(string password, CancellationToken ct = default) =>
|
||||
CallAsync(token => _api.ExportAsync(token, password, ct), ct);
|
||||
|
||||
public async Task DeleteAccountAsync(string password, CancellationToken ct = default)
|
||||
{
|
||||
await CallAsync(async token =>
|
||||
{
|
||||
await _api.DeleteAccountAsync(token, password, ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}, ct).ConfigureAwait(false);
|
||||
Clear("Votre compte a été supprimé définitivement.");
|
||||
Log.Info("Compte : supprimé à la demande du joueur.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sortie du launcher : une session NON mémorisée est fermée sur le serveur (quelques secondes au plus), pour ne pas
|
||||
/// laisser un appareil connecté que le joueur n'a pas voulu garder.
|
||||
/// </summary>
|
||||
public void Shutdown(TimeSpan timeout)
|
||||
{
|
||||
bool close;
|
||||
lock (_lock) close = !_remember && _refresh is not null;
|
||||
if (!close) return;
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeout);
|
||||
Task.Run(() => SignOutAsync(false, cts.Token)).Wait(timeout);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Compte : fermeture de la session à la sortie impossible ({ex.Message}).");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _gate.Dispose();
|
||||
|
||||
// ------------------------------------------------------------------ Jetons
|
||||
|
||||
/// <summary>
|
||||
/// Appel authentifié : jeton d'accès valable (renouvelé si besoin), puis appel ; un refus 401 (jeton oublié par le
|
||||
/// serveur après un redémarrage) provoque UN renouvellement et un nouvel essai.
|
||||
/// </summary>
|
||||
private async Task<T> CallAsync<T>(Func<string, Task<T>> call, CancellationToken ct)
|
||||
{
|
||||
var token = await RenewAsync(force: false, ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return await call(token).ConfigureAwait(false);
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
|
||||
{
|
||||
token = await RenewAsync(force: true, ct, staleToken: token).ConfigureAwait(false);
|
||||
return await call(token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Jeton d'accès valable ; renouvellement signé par la clé de l'appareil si besoin (un seul à la fois).</summary>
|
||||
/// <param name="staleToken">Jeton refusé par le serveur : renouvelé même s'il semble encore valable.</param>
|
||||
private async Task<string> RenewAsync(bool force, CancellationToken ct, string? staleToken = null)
|
||||
{
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
string refresh;
|
||||
IPlayerDeviceKey key;
|
||||
lock (_lock)
|
||||
{
|
||||
var fresh = _access is not null && _time.GetUtcNow() + AccessMargin < _accessExpiresAt;
|
||||
if (fresh && (staleToken is null ? !force : _access != staleToken)) return _access!;
|
||||
if (_refresh is null || _key is null) throw new PlayerSessionLostException("Vous n'êtes pas connecté.");
|
||||
refresh = _refresh;
|
||||
key = _key;
|
||||
}
|
||||
|
||||
PlayerSession session;
|
||||
try
|
||||
{
|
||||
session = await _api.RefreshAsync(refresh, key, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (ApiException ex) when (IsSessionLost(ex))
|
||||
{
|
||||
Clear(PlayerAccountTexts.SessionLost(ex));
|
||||
throw new PlayerSessionLostException(PlayerAccountTexts.SessionLost(ex), ex);
|
||||
}
|
||||
catch (ApiException)
|
||||
{
|
||||
// Serveur injoignable, en erreur ou qui limite les essais : la session est gardée, nouvel essai plus tard.
|
||||
SetState(PlayerAccountState.Offline);
|
||||
throw;
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
// Clé de l'appareil devenue inutilisable (puce réinitialisée…) : la session ne peut plus être renouvelée.
|
||||
Clear("La clé de cet appareil est devenue inutilisable : reconnectez-vous.");
|
||||
throw new PlayerSessionLostException("La clé de cet appareil est devenue inutilisable : reconnectez-vous.", ex);
|
||||
}
|
||||
Adopt(session, key, remember: null);
|
||||
return session.AccessToken;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSessionLost(ApiException ex) =>
|
||||
ex.Status == 401 ||
|
||||
(ex.Status == 403 && ex.Code is ApiErrorCodes.AccountDisabled or ApiErrorCodes.AccountSuspended or ApiErrorCodes.PasswordChangeRequired);
|
||||
|
||||
private async Task<IPlayerDeviceKey> OpenKeyAsync(CancellationToken ct)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_key is not null) return _key;
|
||||
}
|
||||
var key = await Task.Run(() => _keys.OpenOrCreate(), ct).ConfigureAwait(false);
|
||||
lock (_lock) _key ??= key;
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <summary>Nouvelle session : jetons en mémoire, jeton de renouvellement enregistré (si mémorisée), état « connecté ».</summary>
|
||||
/// <param name="remember">Null : inchangé (renouvellement).</param>
|
||||
private void Adopt(PlayerSession session, IPlayerDeviceKey key, bool? remember)
|
||||
{
|
||||
bool save;
|
||||
lock (_lock)
|
||||
{
|
||||
_key = key;
|
||||
_access = session.AccessToken;
|
||||
_accessExpiresAt = session.AccessExpiresAt;
|
||||
_refresh = session.RefreshToken;
|
||||
_deviceId = session.DeviceId;
|
||||
_refreshExpiresAt = session.RefreshExpiresAt;
|
||||
_account = session.Account;
|
||||
_state = PlayerAccountState.SignedIn;
|
||||
_notice = null;
|
||||
if (remember is { } value) _remember = value;
|
||||
save = _remember;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (save)
|
||||
_file.Save(new StoredPlayerSession(_api.Client.Origin, session.RefreshToken, session.DeviceId, key.KeyId,
|
||||
session.Account.Reference, session.Account.EmailMasked, session.RefreshExpiresAt));
|
||||
else
|
||||
_file.Delete();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or CryptographicException)
|
||||
{
|
||||
// Session utilisable pendant cette exécution ; au prochain lancement, il faudra se reconnecter.
|
||||
Log.Error("Compte : enregistrement de la session mémorisée impossible", ex);
|
||||
}
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
/// <summary>Oublie la session (mémoire et disque) ; la clé de l'appareil est conservée.</summary>
|
||||
private void Clear(string? notice)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_access = null;
|
||||
_refresh = null;
|
||||
_deviceId = null;
|
||||
_account = null;
|
||||
_state = PlayerAccountState.SignedOut;
|
||||
_notice = notice;
|
||||
}
|
||||
_file.Delete();
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
private void SetState(PlayerAccountState state)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_state == state) return;
|
||||
_state = state;
|
||||
}
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
private void RaiseChanged()
|
||||
{
|
||||
try
|
||||
{
|
||||
Changed?.Invoke();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Compte : notification du changement d'état", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Messages français des erreurs du compte joueur.</summary>
|
||||
public static class PlayerAccountTexts
|
||||
{
|
||||
public static string Describe(Exception ex) => ex switch
|
||||
{
|
||||
PlayerSessionLostException lost => lost.Message,
|
||||
ApiException { Status: 0, Code: "unreachable" } => "Connexion au serveur impossible. Vérifiez votre connexion Internet, puis réessayez.",
|
||||
ApiException { Status: 0, Code: "timeout" } => "Le serveur ne répond pas. Réessayez dans un instant.",
|
||||
ApiException { Status: 0, Code: "origin_mismatch" } or ApiException { Code: "invalid_response" } =>
|
||||
"Réponse inattendue du serveur. Réessayez plus tard ; si le problème continue, mettez le launcher à jour.",
|
||||
ApiException { Code: ApiErrorCodes.NotConfigured } => "Les comptes joueurs ne sont pas encore ouverts.",
|
||||
ApiException { Status: 401, Code: ApiErrorCodes.Unauthorized } => "Session expirée : reconnectez-vous.",
|
||||
ApiException api => api.Message,
|
||||
CryptographicException crypto => $"Clé de cet appareil inutilisable : {crypto.Message}",
|
||||
IOException or UnauthorizedAccessException => $"Fichier inaccessible : {ex.Message}",
|
||||
OperationCanceledException => "Opération annulée.",
|
||||
_ => $"Erreur inattendue : {ex.Message}"
|
||||
};
|
||||
|
||||
/// <summary>Raison d'une session refusée au renouvellement.</summary>
|
||||
public static string SessionLost(ApiException ex) => ex.Code switch
|
||||
{
|
||||
ApiErrorCodes.AccountDisabled or ApiErrorCodes.AccountSuspended => ex.Message,
|
||||
ApiErrorCodes.PasswordChangeRequired => "Un nouveau mot de passe est exigé : reconnectez-vous pour le choisir.",
|
||||
ApiErrorCodes.DeviceProofInvalid => "Cet appareil n'a pas pu prouver son identité : reconnectez-vous.",
|
||||
_ => "Votre session a expiré ou a été fermée depuis un autre appareil : reconnectez-vous."
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
|
||||
namespace GameLauncher.Modules.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Clé d'appareil du joueur sur ce PC (une par compte Windows, partagée par les comptes joueurs utilisés sur ce PC).
|
||||
/// Implémentation Windows dans le launcher (Platform\PlayerDeviceKeyStore.cs) : puce de sécurité (TPM 2.0) SANS code PIN
|
||||
/// quand elle existe, sinon clé logicielle non exportable du fournisseur de Windows (protégée par la session Windows).
|
||||
/// Les méthodes peuvent être lentes (accès à la puce) : jamais appelées sur le fil de l'interface.
|
||||
/// </summary>
|
||||
public interface IPlayerDeviceKeyStore
|
||||
{
|
||||
/// <summary>Ouvre la clé existante, ou null si elle n'existe pas (encore) sur ce PC.</summary>
|
||||
IPlayerDeviceKey? TryOpen();
|
||||
|
||||
/// <summary>Ouvre la clé, en la créant si besoin.</summary>
|
||||
IPlayerDeviceKey OpenOrCreate();
|
||||
|
||||
/// <summary>Supprime la clé (sans erreur si elle n'existe pas).</summary>
|
||||
void Delete();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chiffrement local des secrets du joueur (jeton de renouvellement) : DPAPI de l'utilisateur Windows dans le launcher
|
||||
/// (Platform\DpapiProtector.cs) ; un autre compte Windows du PC, ou une copie du fichier sur un autre PC, ne peut pas le lire.
|
||||
/// </summary>
|
||||
public interface ISecretProtector
|
||||
{
|
||||
byte[] Protect(byte[] data);
|
||||
|
||||
/// <exception cref="CryptographicException">Données illisibles (autre utilisateur, autre PC, fichier modifié).</exception>
|
||||
byte[] Unprotect(byte[] data);
|
||||
}
|
||||
|
||||
/// <summary>Nom générique de l'appareil envoyé au serveur (« Windows 11 · GameLauncher 3.4.2 ») : jamais le nom Windows du PC.</summary>
|
||||
public static class PlayerDeviceLabel
|
||||
{
|
||||
public static string Create(string launcherVersion)
|
||||
{
|
||||
var os = OperatingSystem.IsWindows()
|
||||
? Environment.OSVersion.Version.Build >= 22000 ? "Windows 11" : "Windows 10"
|
||||
: "PC";
|
||||
var version = new string((launcherVersion ?? "").Where(c => char.IsAsciiDigit(c) || c == '.').Take(20).ToArray());
|
||||
return version.Length > 0 ? $"{os} · GameLauncher {version}" : $"{os} · GameLauncher";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Modules.Accounts;
|
||||
|
||||
/// <summary>Session mémorisée (« Rester connecté »), telle qu'enregistrée chiffrée sur le PC.</summary>
|
||||
/// <param name="Origin">Serveur qui a ouvert la session (« https://launcher.lanisys.fr ») : ignorée si l'adresse de l'API change.</param>
|
||||
/// <param name="KeyId">Clé d'appareil avec laquelle la session a été ouverte (sans elle, le jeton ne sert à rien).</param>
|
||||
/// <param name="Reference">Référence et adresse masquée du compte : affichées hors ligne.</param>
|
||||
public sealed record StoredPlayerSession(string Origin, string RefreshToken, string DeviceId, string KeyId, string Reference,
|
||||
string EmailMasked, DateTimeOffset RefreshExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Fichier de la session mémorisée : %AppData%\…\GameLauncher\account\session.dat, chiffré par <see cref="ISecretProtector"/>
|
||||
/// (DPAPI de l'utilisateur Windows). Le jeton de renouvellement qu'il contient est inutilisable sans la clé de l'appareil
|
||||
/// (non exportable) et change à chaque lancement. Écriture atomique (fichier temporaire puis remplacement) : une coupure
|
||||
/// pendant l'écriture ne laisse jamais un fichier à moitié écrit.
|
||||
/// </summary>
|
||||
public sealed class PlayerSessionFile
|
||||
{
|
||||
public const string FolderName = "account";
|
||||
public const string FileName = "session.dat";
|
||||
|
||||
/// <summary>En-tête du fichier (« GLS » + version du format).</summary>
|
||||
private static readonly byte[] Magic = "GLS1"u8.ToArray();
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly ISecretProtector _protector;
|
||||
|
||||
public PlayerSessionFile(string dataDirectory, ISecretProtector protector)
|
||||
{
|
||||
_protector = protector;
|
||||
Path = System.IO.Path.Combine(dataDirectory, FolderName, FileName);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
/// <summary>Session enregistrée, ou null (absente, illisible, d'un autre utilisateur ou PC : le fichier est alors supprimé).</summary>
|
||||
public StoredPlayerSession? Load()
|
||||
{
|
||||
byte[] content;
|
||||
try
|
||||
{
|
||||
if (!File.Exists(Path)) return null;
|
||||
content = File.ReadAllBytes(Path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"Compte : session mémorisée illisible ({ex.Message}).");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (content.Length <= Magic.Length || content.Length > 64 * 1024 || !content.AsSpan(0, Magic.Length).SequenceEqual(Magic))
|
||||
throw new InvalidDataException("en-tête inconnu");
|
||||
var json = _protector.Unprotect(content[Magic.Length..]);
|
||||
try
|
||||
{
|
||||
var session = JsonSerializer.Deserialize<StoredPlayerSession>(json, Json) ?? throw new InvalidDataException("vide");
|
||||
if (!PlayerTokens.TryParseRefresh(session.RefreshToken, out var deviceId, out _) || deviceId != session.DeviceId ||
|
||||
string.IsNullOrEmpty(session.Origin) || string.IsNullOrEmpty(session.KeyId))
|
||||
throw new InvalidDataException("contenu incohérent");
|
||||
return session;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(json);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or InvalidDataException or JsonException)
|
||||
{
|
||||
Log.Warn($"Compte : session mémorisée inutilisable ({ex.Message}) : elle est supprimée.");
|
||||
Delete();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(StoredPlayerSession session)
|
||||
{
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(session, Json);
|
||||
byte[] protectedBytes;
|
||||
try
|
||||
{
|
||||
protectedBytes = _protector.Protect(json);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(json);
|
||||
}
|
||||
|
||||
var directory = System.IO.Path.GetDirectoryName(Path)!;
|
||||
Directory.CreateDirectory(directory);
|
||||
var temp = Path + ".tmp";
|
||||
using (var stream = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
stream.Write(Magic);
|
||||
stream.Write(protectedBytes);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
File.Move(temp, Path, overwrite: true);
|
||||
}
|
||||
|
||||
public void Delete()
|
||||
{
|
||||
FileUtil.TryDeleteFile(Path);
|
||||
FileUtil.TryDeleteFile(Path + ".tmp");
|
||||
}
|
||||
|
||||
/// <summary>Texte d'en-tête utilisé pour lier le chiffrement à cet usage (entropie DPAPI).</summary>
|
||||
public static byte[] Purpose => Encoding.ASCII.GetBytes("GameLauncher/Player/Session/v1");
|
||||
}
|
||||
@@ -58,6 +58,7 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
|
||||
new("files.", "Fichiers du serveur"),
|
||||
new("keyring.", "Signature (trousseau)"),
|
||||
new("feedback.", "Demandes des joueurs"),
|
||||
new("maintenance.", "Mode maintenance"),
|
||||
new("audit.", "Journal d'audit (consultations, exports, purges)"),
|
||||
new("security", "Refus de sécurité")
|
||||
};
|
||||
@@ -504,6 +505,12 @@ public static class AuditTexts
|
||||
"feedback.delete" => "Demande de joueur supprimée",
|
||||
"feedback.purge" => "Anciennes demandes supprimées (conservation)",
|
||||
"feedback.forms" => "Formulaires des demandes publiés",
|
||||
"feedback.form_create" => "Type de demande ajouté",
|
||||
"feedback.form_update" => "Type de demande modifié",
|
||||
"feedback.form_delete" => "Type de demande supprimé",
|
||||
"maintenance.enable" => "Mode maintenance activé",
|
||||
"maintenance.disable" => "Mode maintenance terminé",
|
||||
"maintenance.update" => "Message de la maintenance modifié",
|
||||
"files.delete" => "Fichier du serveur supprimé",
|
||||
"files.prune" => "Dossier « objects » nettoyé",
|
||||
"request.signature" => "Requête signée refusée",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Maintenance » : page d'activation et de désactivation du mode maintenance du launcher (commun à tous les
|
||||
/// jeux), avec un message facultatif pour les joueurs. Permission maintenance.manage ; publication signée (autorisation
|
||||
/// « maintenance » du trousseau, vérifiée par les launchers). L'état est aussi rappelé au tableau de bord.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceAdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "maintenance";
|
||||
|
||||
public string DisplayName => "Maintenance";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
MaintenancePanelViewModel.PanelId,
|
||||
"Maintenance",
|
||||
"Mode maintenance de tous les launchers : thème rouge / orange, message au centre, tout est bloqué chez les joueurs " +
|
||||
"sauf la désinstallation des jeux et ce menu.",
|
||||
"TrafficCone",
|
||||
AdminPermissions.MaintenanceManage,
|
||||
Order: 5,
|
||||
context => new MaintenancePanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Modules.Admin.Accounts;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Maintenance » (maintenance.manage) : état du mode maintenance de tous les launchers, activation (message
|
||||
/// facultatif, confirmation), modification du message pendant la maintenance, désactivation (confirmation).
|
||||
/// • Chaque publication est signée par la clé de ce PC (autorisation « maintenance » du trousseau) : avertissement si
|
||||
/// les launchers des joueurs ne l'accepteront pas (<see cref="AdminPanelViewModel.ConfirmPlayerSigningAsync"/>).
|
||||
/// • Message contrôlé ici avec les règles partagées (le serveur et les launchers les réappliquent) : texte brut, sans lien.
|
||||
/// • Après une publication, le launcher de ce PC relit aussitôt le mode maintenance (<see cref="IAdminContext.NotifyMaintenanceChanged"/>).
|
||||
/// </summary>
|
||||
public sealed class MaintenancePanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "maintenance";
|
||||
|
||||
private MaintenanceState? _state;
|
||||
private string _message = "";
|
||||
private string? _messageError;
|
||||
private string? _statusMessage;
|
||||
|
||||
public MaintenancePanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(ReloadAsync)), () => !IsBusy);
|
||||
EnableCommand = new AsyncRelayCommand(ScrollingToTop(EnableAsync), () => !IsBusy && CanPublish && !IsActive);
|
||||
UpdateMessageCommand = new AsyncRelayCommand(ScrollingToTop(UpdateMessageAsync), () => !IsBusy && CanPublish && IsActive && IsMessageChanged);
|
||||
DisableCommand = new AsyncRelayCommand(ScrollingToTop(DisableAsync), () => !IsBusy && CanPublish && IsActive);
|
||||
}
|
||||
|
||||
public AsyncRelayCommand RefreshCommand { get; }
|
||||
public AsyncRelayCommand EnableCommand { get; }
|
||||
public AsyncRelayCommand UpdateMessageCommand { get; }
|
||||
public AsyncRelayCommand DisableCommand { get; }
|
||||
|
||||
// ------------------------------------------------------------------ État publié
|
||||
|
||||
public bool IsLoaded => _state is not null;
|
||||
|
||||
/// <summary>Maintenance en cours (état publié).</summary>
|
||||
public bool IsActive => _state?.Document.Enabled == true;
|
||||
|
||||
/// <summary>Publication possible : état chargé et fichier public configuré sur le serveur.</summary>
|
||||
public bool CanPublish => _state is { PublishingEnabled: true };
|
||||
|
||||
public bool ShowPublishingDisabled => _state is { PublishingEnabled: false };
|
||||
|
||||
public string? PublicUrl => _state?.PublicUrl;
|
||||
|
||||
public string StateTitle => _state is null ? "Chargement…" : IsActive ? "Maintenance en cours" : "Launchers accessibles";
|
||||
|
||||
public string StateText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_state is not { } state) return "";
|
||||
var document = state.Document;
|
||||
var by = state.UpdatedBy is { } name ? $" par {name}" : "";
|
||||
if (document.Revision == 0) return "Le mode maintenance n'a jamais été activé.";
|
||||
if (document.Enabled)
|
||||
return (document.Since is { } since ? $"Depuis le {AccountTexts.Date(since)}" : "En cours") + by +
|
||||
(document.UpdatedAt is { } updated && document.Since is { } start && updated - start > TimeSpan.FromMinutes(1)
|
||||
? $" · message modifié le {AccountTexts.Date(updated)}"
|
||||
: "") + ".";
|
||||
return (document.UpdatedAt is { } at ? $"Dernière maintenance terminée le {AccountTexts.Date(at)}" : "Maintenance terminée") + by + ".";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Message publié (pendant la maintenance), sinon null.</summary>
|
||||
public string? PublishedMessage => IsActive ? _state!.Document.Message : null;
|
||||
|
||||
// ------------------------------------------------------------------ Saisie
|
||||
|
||||
/// <summary>Précision affichée aux joueurs sous « Maintenance en cours — merci de revenir plus tard » (facultative).</summary>
|
||||
public string Message
|
||||
{
|
||||
get => _message;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _message, value ?? "")) return;
|
||||
MaintenanceRules.CleanMessage(_message, out var problem);
|
||||
MessageError = problem is null ? null : $"Message : {problem}";
|
||||
OnPropertyChanged(nameof(MessageCounter));
|
||||
OnPropertyChanged(nameof(PreviewMessage));
|
||||
OnPropertyChanged(nameof(IsMessageChanged));
|
||||
RefreshCommands();
|
||||
}
|
||||
}
|
||||
|
||||
public string? MessageError
|
||||
{
|
||||
get => _messageError;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _messageError, value)) OnPropertyChanged(nameof(HasMessageError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasMessageError => _messageError is not null;
|
||||
|
||||
public string MessageCounter => $"{_message.Trim().Length} / {MaintenanceRules.MaxMessageLength}";
|
||||
|
||||
public int MaxMessageLength => MaintenanceRules.MaxMessageLength;
|
||||
|
||||
/// <summary>Le message saisi diffère de celui publié (bouton « Modifier le message »).</summary>
|
||||
public bool IsMessageChanged
|
||||
{
|
||||
get
|
||||
{
|
||||
var clean = MaintenanceRules.CleanMessage(_message, out var problem);
|
||||
return problem is null && !string.Equals(clean, PublishedMessage, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Aperçu (textes affichés aux joueurs)
|
||||
|
||||
public string PreviewTitle => MaintenanceRules.Title;
|
||||
public string PreviewSubtitle => MaintenanceRules.Subtitle;
|
||||
|
||||
/// <summary>Message tel que les joueurs le verront (nettoyé), sinon null.</summary>
|
||||
public string? PreviewMessage => MaintenanceRules.CleanMessage(_message, out _);
|
||||
|
||||
// ------------------------------------------------------------------ Bandeau de résultat
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(_statusMessage);
|
||||
|
||||
// ------------------------------------------------------------------ Chargement
|
||||
|
||||
protected override void OnBusyChanged() => RefreshCommands();
|
||||
|
||||
private void RefreshCommands()
|
||||
{
|
||||
RefreshCommand.RaiseCanExecuteChanged();
|
||||
EnableCommand.RaiseCanExecuteChanged();
|
||||
UpdateMessageCommand.RaiseCanExecuteChanged();
|
||||
DisableCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => RunAsync(ReloadAsync);
|
||||
|
||||
private async Task ReloadAsync() => Apply(await Context.Session.GetMaintenanceAsync(), resetMessage: true);
|
||||
|
||||
private void Apply(MaintenanceState state, bool resetMessage)
|
||||
{
|
||||
_state = state;
|
||||
if (resetMessage) Message = state.Document.Enabled ? state.Document.Message ?? "" : _message;
|
||||
foreach (var name in new[]
|
||||
{
|
||||
nameof(IsLoaded), nameof(IsActive), nameof(CanPublish), nameof(ShowPublishingDisabled), nameof(PublicUrl),
|
||||
nameof(StateTitle), nameof(StateText), nameof(PublishedMessage), nameof(IsMessageChanged)
|
||||
})
|
||||
OnPropertyChanged(name);
|
||||
RefreshCommands();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Actions
|
||||
|
||||
/// <summary>Message nettoyé, ou null avec l'erreur affichée (sous le champ).</summary>
|
||||
private bool TryCleanMessage(out string? clean)
|
||||
{
|
||||
clean = MaintenanceRules.CleanMessage(_message, out var problem);
|
||||
MessageError = problem is null ? null : $"Message : {problem}";
|
||||
return problem is null;
|
||||
}
|
||||
|
||||
private async Task EnableAsync()
|
||||
{
|
||||
if (_state is not { } state) return;
|
||||
StatusMessage = null;
|
||||
if (!TryCleanMessage(out var message)) return;
|
||||
if (!Context.Dialogs.Confirm("Activer le mode maintenance ?",
|
||||
"Tous les launchers des joueurs passeront en mode maintenance au plus tard dans 2 minutes (ceux qui démarrent : " +
|
||||
"aussitôt), pour tous les jeux :\n\n" +
|
||||
"• installation, mises à jour, vérification, réparation et lancement des jeux bloqués ;\n" +
|
||||
"• paramètres du launcher et des jeux, bouton « Aide » et site officiel indisponibles ;\n" +
|
||||
"• seule la désinstallation reste possible, et ce menu d'administration.\n\n" +
|
||||
"Les téléchargements déjà commencés se terminent et les parties en cours ne sont pas fermées. Les mises à jour du " +
|
||||
"launcher lui-même continuent.",
|
||||
"Activer la maintenance", danger: true))
|
||||
return;
|
||||
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Activer quand même")) return;
|
||||
await PublishAsync(enabled: true, message, since: null, state.Document.Revision,
|
||||
"Mode maintenance activé : les launchers des joueurs l'affichent au plus tard dans 2 minutes.");
|
||||
}
|
||||
|
||||
private async Task UpdateMessageAsync()
|
||||
{
|
||||
if (_state is not { Document: { Enabled: true } document }) return;
|
||||
StatusMessage = null;
|
||||
if (!TryCleanMessage(out var message)) return;
|
||||
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Modifier quand même")) return;
|
||||
await PublishAsync(enabled: true, message, document.Since, document.Revision,
|
||||
"Message de la maintenance modifié : les launchers des joueurs l'affichent au plus tard dans une minute.");
|
||||
}
|
||||
|
||||
private async Task DisableAsync()
|
||||
{
|
||||
if (_state is not { Document: { Enabled: true } document }) return;
|
||||
StatusMessage = null;
|
||||
if (!Context.Dialogs.Confirm("Terminer la maintenance ?",
|
||||
"Les launchers des joueurs redeviendront utilisables au plus tard dans une minute (installation, mises à jour, " +
|
||||
"lancement des jeux) ; ils rechercheront aussitôt les mises à jour des jeux.",
|
||||
"Terminer la maintenance"))
|
||||
return;
|
||||
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Terminer quand même")) return;
|
||||
await PublishAsync(enabled: false, message: null, since: null, document.Revision,
|
||||
"Maintenance terminée : les launchers des joueurs redeviennent utilisables au plus tard dans une minute.");
|
||||
}
|
||||
|
||||
private async Task PublishAsync(bool enabled, string? message, DateTimeOffset? since, long baseRevision, string success)
|
||||
{
|
||||
MaintenanceState? published = null;
|
||||
var ok = await RunAsync(async () =>
|
||||
published = await Context.Session.PublishMaintenanceAsync(enabled, message, since, baseRevision));
|
||||
if (!ok)
|
||||
{
|
||||
// Publication par quelqu'un d'autre entre-temps (409) : état relu, la saisie est gardée.
|
||||
if (ErrorMessage is not null) await ReloadKeepingMessageAsync();
|
||||
return;
|
||||
}
|
||||
Apply(published!, resetMessage: true);
|
||||
if (!enabled) Message = "";
|
||||
StatusMessage = success;
|
||||
Context.NotifyMaintenanceChanged(); // ce launcher bascule tout de suite
|
||||
}
|
||||
|
||||
private async Task ReloadKeepingMessageAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Apply(await Context.Session.GetMaintenanceAsync(), resetMessage: false);
|
||||
}
|
||||
catch (Exception ex) when (ex is ApiException or HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
// L'erreur de la publication reste affichée ; « Recharger » permet de réessayer.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@ namespace GameLauncher.Modules.Admin.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Signature » (propriétaires) : trousseau publié (vérifié avec la clé racine intégrée à l'exe), trousseau
|
||||
/// proposé d'après les appareils actifs autorisés à publier, état des signatures du contenu en ligne.
|
||||
/// proposé d'après les appareils actifs autorisés à publier, état des signatures du contenu en ligne (versions en
|
||||
/// ligne, catalogue, formulaires des demandes, mode maintenance).
|
||||
/// La clé racine n'est JAMAIS ouverte par le launcher : la demande est exportée, signée hors ligne avec
|
||||
/// tools\sign-keyring.bat, puis le trousseau signé est publié ici.
|
||||
/// </summary>
|
||||
@@ -193,6 +194,29 @@ public sealed class SigningPanelViewModel : AdminPanelViewModel
|
||||
items.Add(new SignedContentItem($"Catalogue (révision {catalog.Revision})", ContentKinds.Catalog, ContentKinds.CatalogId,
|
||||
catalog.Digest, catalog.Signature, catalog.SignerName, state.Keyring, proposed,
|
||||
"Republiez-le depuis la page « Jeux » (il est signé à chaque publication)."));
|
||||
if (state.FeedbackForms is { } forms)
|
||||
items.Add(forms.Unreadable
|
||||
? SignedContentItem.Failed("Formulaires des demandes",
|
||||
"Fichier des formulaires illisible sur le serveur (voir ses journaux) : restaurez-le depuis feedback-forms-history/ " +
|
||||
"dans les données privées du serveur.")
|
||||
: new SignedContentItem(
|
||||
forms.Revision > 0 ? $"Formulaires des demandes (révision {forms.Revision})" : "Formulaires des demandes",
|
||||
ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, forms.Digest, forms.Signature, forms.SignerName, state.Keyring, proposed,
|
||||
"Republiez-les depuis la page « Formulaires des demandes » (ils sont signés à chaque publication).",
|
||||
"Formulaires d'origine, intégrés au launcher : rien à signer tant que rien n'est publié."));
|
||||
if (state.Maintenance is { } maintenance)
|
||||
items.Add(maintenance.Unreadable
|
||||
? SignedContentItem.Failed("Mode maintenance",
|
||||
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/ " +
|
||||
"dans les données privées du serveur.")
|
||||
: new SignedContentItem(
|
||||
maintenance.Revision > 0
|
||||
? $"Mode maintenance (révision {maintenance.Revision}, {(maintenance.Enabled ? "en cours" : "inactif")})"
|
||||
: "Mode maintenance",
|
||||
ContentKinds.Maintenance, ContentKinds.MaintenanceId, maintenance.Digest, maintenance.Signature, maintenance.SignerName,
|
||||
state.Keyring, proposed,
|
||||
"Republiez-le depuis la page « Maintenance » (il est signé à chaque activation, modification ou fin).",
|
||||
"Jamais activé : rien à signer tant que rien n'est publié."));
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -319,12 +343,12 @@ public sealed class KeyringKeyItem
|
||||
public sealed class SignedContentItem
|
||||
{
|
||||
internal SignedContentItem(string title, string kind, string id, string digest, ContentSignature? signature, string? signerName,
|
||||
KeyringDocument? published, KeyringDocument proposed, string howToSign)
|
||||
KeyringDocument? published, KeyringDocument proposed, string howToSign, string nothingPublished = "Rien n'est publié.")
|
||||
{
|
||||
Title = title;
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
{
|
||||
Text = "Rien n'est publié.";
|
||||
Text = nothingPublished;
|
||||
IsValid = true;
|
||||
return;
|
||||
}
|
||||
@@ -342,6 +366,15 @@ public sealed class SignedContentItem
|
||||
Text += $" Attention : sa clé ne sera plus dans le trousseau proposé. {howToSign}";
|
||||
}
|
||||
|
||||
private SignedContentItem(string title, string text)
|
||||
{
|
||||
Title = title;
|
||||
Text = text;
|
||||
}
|
||||
|
||||
/// <summary>Contenu dont l'état ne peut pas être lu (refusé, à corriger sur le serveur).</summary>
|
||||
internal static SignedContentItem Failed(string title, string text) => new(title, text);
|
||||
|
||||
public string Title { get; }
|
||||
public string Text { get; }
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ public sealed class AdminEnvironment
|
||||
/// </summary>
|
||||
public PlayerSigningCheck? PlayerSigning { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Appelé après une publication du mode maintenance (page « Maintenance ») : le launcher de ce PC le relit aussitôt.
|
||||
/// Null : rien à faire (tests).
|
||||
/// </summary>
|
||||
public Action? MaintenanceChanged { get; init; }
|
||||
|
||||
/// <summary>Puce de sécurité (TPM) de ce PC ; null : clés TPM non proposées.</summary>
|
||||
public IAdminTpm? Tpm { get; init; }
|
||||
|
||||
|
||||
@@ -127,6 +127,8 @@ public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, I
|
||||
public string LauncherVersion => _shell.Environment.LauncherVersion;
|
||||
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys => _shell.Environment.RootKeys;
|
||||
public PlayerSigningCheck? PlayerSigning => _shell.Environment.PlayerSigning;
|
||||
|
||||
public void NotifyMaintenanceChanged() => _shell.Environment.MaintenanceChanged?.Invoke();
|
||||
public IReadOnlyList<AdminPanelDescriptor> Panels { get; }
|
||||
|
||||
public IDisposable BeginLongOperation() => Guard.BeginLongOperation();
|
||||
|
||||
@@ -2,6 +2,7 @@ using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Modules.Admin.Accounts;
|
||||
using GameLauncher.Modules.Admin.Feedback;
|
||||
using GameLauncher.Modules.Admin.Maintenance;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
@@ -34,6 +35,45 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
|
||||
OpenAdminsCommand = new RelayCommand(() => Context.Navigate(AdminsPanelViewModel.PanelId), () => CanOpenAdmins);
|
||||
OpenDevicesCommand = new RelayCommand(() => Context.Navigate(MyDevicesPanelViewModel.PanelId), () => CanOpenDevices);
|
||||
OpenFeedbackCommand = new RelayCommand(() => Context.Navigate(FeedbackPanelViewModel.PanelId), () => CanReadFeedback);
|
||||
OpenMaintenanceCommand = new RelayCommand(() => Context.Navigate(MaintenancePanelViewModel.PanelId), () => CanOpenMaintenance);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Mode maintenance
|
||||
|
||||
/// <summary>Page « Maintenance » présente (permission maintenance.manage).</summary>
|
||||
public bool CanOpenMaintenance => Context.Panels.Any(p => p.Id == MaintenancePanelViewModel.PanelId);
|
||||
|
||||
public RelayCommand OpenMaintenanceCommand { get; }
|
||||
|
||||
private string? _maintenanceText;
|
||||
|
||||
/// <summary>Bandeau « Maintenance en cours depuis … » (null : pas de maintenance, ou serveur antérieur à la 3.3.0).</summary>
|
||||
public string? MaintenanceText
|
||||
{
|
||||
get => _maintenanceText;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _maintenanceText, value)) OnPropertyChanged(nameof(IsMaintenanceActive));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsMaintenanceActive => _maintenanceText is not null;
|
||||
|
||||
private async Task RefreshMaintenanceAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var state = await Context.Session.GetMaintenanceAsync();
|
||||
MaintenanceText = state.Document is { Enabled: true } document
|
||||
? "Maintenance en cours" + (document.Since is { } since ? $" depuis le {AccountTexts.Date(since)}" : "") +
|
||||
(state.UpdatedBy is { } by ? $" (activée par {by})" : "") +
|
||||
" : les joueurs ne peuvent ni installer, ni mettre à jour, ni lancer les jeux."
|
||||
: null;
|
||||
}
|
||||
catch (ApiException ex) when (!ApiErrorText.IsSessionLost(ex))
|
||||
{
|
||||
MaintenanceText = null; // serveur antérieur (404) ou fichier illisible : rien au tableau de bord
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Demandes des joueurs
|
||||
@@ -182,6 +222,7 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
|
||||
ActiveInvitations = invitations.Count;
|
||||
}
|
||||
if (CanReadFeedback) await RefreshFeedbackAsync();
|
||||
await RefreshMaintenanceAsync();
|
||||
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
|
||||
});
|
||||
}
|
||||
@@ -36,6 +36,12 @@ public interface IAdminContext
|
||||
/// </summary>
|
||||
PlayerSigningCheck? PlayerSigning => null;
|
||||
|
||||
/// <summary>
|
||||
/// Le mode maintenance vient d'être publié depuis le menu : le launcher de ce PC le relit aussitôt (sans attendre sa
|
||||
/// relecture périodique). Sans effet par défaut (tests).
|
||||
/// </summary>
|
||||
void NotifyMaintenanceChanged() { }
|
||||
|
||||
/// <summary>Pages visibles pour cet administrateur.</summary>
|
||||
IReadOnlyList<AdminPanelDescriptor> Panels { get; }
|
||||
|
||||
|
||||
@@ -42,7 +42,8 @@ public sealed class PlayerSigningCheck
|
||||
/// Texte de l'avertissement à afficher avant de publier un contenu de ce type signé par cette clé, ou null si les
|
||||
/// launchers des joueurs l'accepteront (ou ne vérifient pas les signatures). Ne lève pas d'exception (sauf annulation).
|
||||
/// </summary>
|
||||
/// <param name="kind"><see cref="ContentKinds.Catalog"/>, <see cref="ContentKinds.Manifest"/> ou <see cref="ContentKinds.FeedbackForms"/>.</param>
|
||||
/// <param name="kind"><see cref="ContentKinds.Catalog"/>, <see cref="ContentKinds.Manifest"/>, <see cref="ContentKinds.FeedbackForms"/>
|
||||
/// ou <see cref="ContentKinds.Maintenance"/>.</param>
|
||||
/// <param name="publicKey">Clé publique (SPKI) de ce PC : celle qui signera, lue localement.</param>
|
||||
public async Task<string?> WarningAsync(string kind, byte[] publicKey, CancellationToken ct = default)
|
||||
{
|
||||
@@ -111,6 +112,9 @@ public sealed class PlayerSigningCheck
|
||||
? kind == ContentKinds.FeedbackForms
|
||||
? "Les launchers des joueurs (signatures obligatoires) ignoreront ces formulaires : la fenêtre « Aide » proposera " +
|
||||
"les derniers formulaires acceptés, ou ceux d'origine."
|
||||
: kind == ContentKinds.Maintenance
|
||||
? "Les launchers des joueurs (signatures obligatoires) ignoreront ce changement : ils garderont l'état du mode " +
|
||||
"maintenance qu'ils connaissent (une maintenance ne s'afficherait pas, ou ne se terminerait qu'à leur redémarrage)."
|
||||
: catalog
|
||||
? "Les launchers des joueurs (signatures obligatoires) ignoreront ce catalogue : ils garderont le précédent " +
|
||||
"(celui intégré au launcher pour une installation neuve)."
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Core.Catalog;
|
||||
using GameLauncher.Core.Maintenance;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Services;
|
||||
@@ -20,6 +21,7 @@ using GameLauncher.Modules.Admin.Accounts;
|
||||
using GameLauncher.Modules.Admin.Audit;
|
||||
using GameLauncher.Modules.Admin.Feedback;
|
||||
using GameLauncher.Modules.Admin.Files;
|
||||
using GameLauncher.Modules.Admin.Maintenance;
|
||||
using GameLauncher.Modules.Admin.Publishing;
|
||||
using GameLauncher.Modules.Admin.Signing;
|
||||
using GameLauncher.Platform;
|
||||
@@ -57,6 +59,10 @@ public partial class App : Application, ILauncherRestartHost
|
||||
private HttpDownloader? _http;
|
||||
private LauncherUpdateService? _updates;
|
||||
private AdminWindowService? _admin;
|
||||
|
||||
/// <summary>Bouton « Compte » (comptes joueurs) et son client de l'API ; null si les comptes ne sont pas activés.</summary>
|
||||
private AccountWindowService? _accounts;
|
||||
private GameLauncher.ApiClient.LauncherApiClient? _accountsClient;
|
||||
private CatalogService? _catalogService;
|
||||
private ContentTrustService? _trust;
|
||||
|
||||
@@ -64,6 +70,13 @@ public partial class App : Application, ILauncherRestartHost
|
||||
private string? _keyringUrl;
|
||||
private PlayerSigningCheck? _playerSigning;
|
||||
private Task<CatalogDocument?>? _catalogFetch;
|
||||
|
||||
/// <summary>Mode maintenance publié depuis le menu d'administration (null : aucune adresse de trousseau ni de catalogue).</summary>
|
||||
private MaintenanceService? _maintenance;
|
||||
private Task<bool>? _maintenanceFetch;
|
||||
|
||||
/// <summary>Annulé à la fermeture du launcher (relecture périodique du mode maintenance).</summary>
|
||||
private readonly CancellationTokenSource _lifetime = new();
|
||||
private string _launcherPath = "";
|
||||
private Version _version = new(1, 0, 0);
|
||||
|
||||
@@ -172,6 +185,11 @@ public partial class App : Application, ILauncherRestartHost
|
||||
_trust = CreateContentTrust(catalog);
|
||||
if (_trust is not null && args.CaptureDirectory is null && !args.Uninstall) _ = _trust.GetKeyringAsync();
|
||||
|
||||
// Mode maintenance (publié depuis le menu d'administration) : lu dès maintenant, en parallèle du catalogue et de la
|
||||
// mise à jour du launcher, puis attendu quelques secondes au plus AVANT la recherche des mises à jour des jeux.
|
||||
var maintenanceStarted = DateTime.UtcNow;
|
||||
StartMaintenance(catalog, download: args.CaptureDirectory is null);
|
||||
|
||||
// Catalogue des jeux (informations modifiables publiées depuis le menu d'administration) :
|
||||
// dernier catalogue en cache appliqué tout de suite ; version à jour téléchargée pendant la
|
||||
// vérification de mise à jour du launcher (pas de téléchargement pour --capture et --uninstall).
|
||||
@@ -222,7 +240,8 @@ public partial class App : Application, ILauncherRestartHost
|
||||
else if (normalStart && _updates.IsEnabled && (!_updates.AutoUpdate || _updates.SkippedUpdate is not null))
|
||||
_ = NotifyLauncherUpdateAsync();
|
||||
|
||||
// 5. Détection des installations + mises à jour des jeux
|
||||
// 5. Détection des installations + mises à jour des jeux (après le mode maintenance, qui peut les bloquer)
|
||||
if (args.CaptureDirectory is null) await ApplyStartupMaintenanceAsync(maintenanceStarted, wait: !args.Uninstall);
|
||||
try
|
||||
{
|
||||
if (args.CaptureDirectory is not null)
|
||||
@@ -359,12 +378,21 @@ public partial class App : Application, ILauncherRestartHost
|
||||
launcher.SelectedGame = launcher.ResolveInitialGame(requestedGameId); // applique le thème du jeu
|
||||
launcher.AttachTransition(window);
|
||||
|
||||
// Mode maintenance : chaque changement (relecture périodique, publication depuis le menu de ce PC), puis l'état déjà connu.
|
||||
if (_maintenance is { } maintenance && !readOnly)
|
||||
{
|
||||
maintenance.Changed += state => Dispatcher.InvokeAsync(() => launcher.ApplyMaintenance(state));
|
||||
launcher.ApplyMaintenance(maintenance.Current);
|
||||
}
|
||||
|
||||
// Menu d'administration (accès discret : Ctrl+Maj+Alt+A ou 5 clics sur le logo). Pas en mode --capture.
|
||||
if (!readOnly)
|
||||
{
|
||||
var (apiUrl, apiUrlError) = ResolveAdminApiUrl(catalog);
|
||||
// Publication du mode maintenance depuis le menu : ce launcher le relit aussitôt (sans attendre la relecture périodique).
|
||||
_admin = new AdminWindowService(window, CreateModules(), catalog, LoadDeclaredCatalog(catalog), apiUrl, apiUrlError, version,
|
||||
ThemeManager.BuiltInThemes.ToList(), _playerSigning);
|
||||
ThemeManager.BuiltInThemes.ToList(), _playerSigning,
|
||||
maintenanceChanged: () => { if (_maintenance is { } m) _ = m.RefreshAsync(_lifetime.Token); });
|
||||
window.AdminRequested += _admin.Open;
|
||||
|
||||
// Bouton « Aide » (demandes des joueurs) : même adresse d'API, lue au même endroit (launcher.json intégré en Release).
|
||||
@@ -374,7 +402,15 @@ public partial class App : Application, ILauncherRestartHost
|
||||
: new GameLauncher.Modules.Feedback.FeedbackFormsService(_http.GetBytesAsync, formsUrl, catalog.DataDirectory, _trust);
|
||||
var feedback = new FeedbackWindowService(window, apiUrl, version, forms);
|
||||
launcher.IsHelpAvailable = feedback.IsAvailable;
|
||||
window.HelpRequested += game => feedback.Open(game.GameId, game.GameName, game.InstalledVersion);
|
||||
window.HelpRequested += game =>
|
||||
{
|
||||
if (launcher.IsMaintenance) return; // bouton masqué pendant une maintenance
|
||||
feedback.Open(game.GameId, game.GameName, game.InstalledVersion);
|
||||
};
|
||||
|
||||
// Bouton « Compte » (comptes joueurs) : « playerAccounts » ET la même adresse d'API, lus au même endroit. La session
|
||||
// mémorisée est reprise sans attendre (le launcher ne contacte l'API des comptes que s'il en existe une).
|
||||
StartPlayerAccounts(catalog, apiUrl, version, window, launcher);
|
||||
}
|
||||
|
||||
window.DataContext = launcher;
|
||||
@@ -383,6 +419,108 @@ public partial class App : Application, ILauncherRestartHost
|
||||
return window;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Comptes joueurs
|
||||
|
||||
private void StartPlayerAccounts(LauncherCatalog catalog, string? apiUrl, string version, Views.MainWindow window, LauncherViewModel launcher)
|
||||
{
|
||||
if (apiUrl is null || !ResolvePlayerAccounts(catalog) || !Uri.TryCreate(apiUrl, UriKind.Absolute, out var apiUri)) return;
|
||||
try
|
||||
{
|
||||
// Agent utilisateur générique (version du launcher seulement), comme le bouton « Aide ».
|
||||
_accountsClient = new GameLauncher.ApiClient.LauncherApiClient(apiUri, $"GameLauncher/{version}");
|
||||
var service = new GameLauncher.Modules.Accounts.PlayerAccountService(
|
||||
new GameLauncher.ApiClient.PlayerAccountApi(_accountsClient),
|
||||
new PlayerDeviceKeyStore(catalog.Publisher + catalog.DataFolderName),
|
||||
new GameLauncher.Modules.Accounts.PlayerSessionFile(catalog.DataDirectory,
|
||||
new DpapiProtector(GameLauncher.Modules.Accounts.PlayerSessionFile.Purpose)),
|
||||
GameLauncher.Modules.Accounts.PlayerDeviceLabel.Create(version));
|
||||
_accounts = new AccountWindowService(window, launcher, service, catalog.Publisher);
|
||||
window.AccountRequested += () =>
|
||||
{
|
||||
if (launcher.IsMaintenance) return; // bouton masqué pendant une maintenance
|
||||
_accounts?.Open();
|
||||
};
|
||||
_accounts.Start(_lifetime.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Comptes joueurs", ex);
|
||||
launcher.IsAccountAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// « playerAccounts ». Comme apiUrl : en Release, seulement celui du launcher.json intégré à l'exécutable (un fichier
|
||||
/// posé à côté ne peut pas l'activer).
|
||||
/// </summary>
|
||||
private static bool ResolvePlayerAccounts(LauncherCatalog catalog)
|
||||
{
|
||||
#if DEBUG
|
||||
return catalog.PlayerAccounts;
|
||||
#else
|
||||
try
|
||||
{
|
||||
using var embedded = Assembly.GetExecutingAssembly().GetManifestResourceStream(ConfigFileName);
|
||||
var integrated = embedded is null ? null : LauncherCatalog.Load(embedded);
|
||||
if (catalog.PlayerAccounts && integrated?.PlayerAccounts != true)
|
||||
Log.Warn("« playerAccounts » du launcher.json externe ignoré : seul celui intégré à l'exécutable est utilisé.");
|
||||
return integrated?.PlayerAccounts == true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Comptes joueurs (launcher.json intégré)", ex);
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Mode maintenance
|
||||
|
||||
/// <summary>Attente maximale du mode maintenance au démarrage (au-delà : appliqué à son arrivée).</summary>
|
||||
private static readonly TimeSpan MaintenanceStartupBudget = TimeSpan.FromSeconds(4);
|
||||
|
||||
/// <summary>
|
||||
/// Lance la lecture du mode maintenance (maintenance.json, à côté du trousseau ou du catalogue). Rien n'est gardé sur le
|
||||
/// disque : hors ligne, le launcher démarre normalement (le jeu installé reste jouable).
|
||||
/// </summary>
|
||||
private void StartMaintenance(LauncherCatalog catalog, bool download)
|
||||
{
|
||||
if (!download || _http is null) return;
|
||||
try
|
||||
{
|
||||
var url = MaintenanceService.PublishedUrl(_keyringUrl, ResolveCatalogUrl(catalog));
|
||||
if (url is null) return;
|
||||
_maintenance = new MaintenanceService(_http.GetBytesAsync, url, _trust);
|
||||
_maintenanceFetch = _maintenance.RefreshAsync(_lifetime.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Mode maintenance", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Avant la recherche des mises à jour des jeux : attend le mode maintenance (quelques secondes au plus), l'applique,
|
||||
/// puis lance sa relecture périodique. <paramref name="wait"/> faux (désinstallation depuis Windows) : pas d'attente.
|
||||
/// </summary>
|
||||
private async Task ApplyStartupMaintenanceAsync(DateTime started, bool wait)
|
||||
{
|
||||
if (_maintenance is not { } maintenance || _maintenanceFetch is not { } fetch) return;
|
||||
try
|
||||
{
|
||||
var remaining = MaintenanceStartupBudget - (DateTime.UtcNow - started);
|
||||
if (wait && !fetch.IsCompleted && remaining > TimeSpan.Zero) await Task.WhenAny(fetch, Task.Delay(remaining));
|
||||
if (wait && !fetch.IsCompleted)
|
||||
Log.Info("Mode maintenance : serveur lent, démarrage sans attendre (l'état sera appliqué à son arrivée).");
|
||||
_launcher?.ApplyMaintenance(maintenance.Current);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Mode maintenance", ex);
|
||||
}
|
||||
_ = maintenance.RunAsync(_lifetime.Token);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Catalogue des jeux
|
||||
|
||||
/// <summary>Attente maximale du catalogue à jour au démarrage (au-delà : cache, mise à jour au démarrage suivant).</summary>
|
||||
@@ -591,6 +729,7 @@ public partial class App : Application, ILauncherRestartHost
|
||||
new ChangelogAdminModule(),
|
||||
new PublishAdminModule(),
|
||||
new FilesAdminModule(),
|
||||
new MaintenanceAdminModule(),
|
||||
new AccountsAdminModule(),
|
||||
new SigningAdminModule(),
|
||||
new AuditAdminModule()
|
||||
@@ -676,10 +815,15 @@ public partial class App : Application, ILauncherRestartHost
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
_lifetime.Cancel(); // relecture du mode maintenance
|
||||
_instanceChannel?.Dispose();
|
||||
_instanceChannel = null;
|
||||
_admin?.Dispose(); // ferme le menu d'administration : clé effacée de la mémoire
|
||||
_admin = null;
|
||||
_accounts?.Dispose(); // session non mémorisée fermée sur le serveur (3 s au plus)
|
||||
_accounts = null;
|
||||
_accountsClient?.Dispose();
|
||||
_accountsClient = null;
|
||||
_trayIcon?.Dispose(); // sinon l'icône resterait affichée jusqu'au survol
|
||||
_trayIcon = null;
|
||||
_launcher?.Dispose();
|
||||
@@ -822,7 +966,8 @@ public partial class App : Application, ILauncherRestartHost
|
||||
|
||||
/// <summary>
|
||||
/// Nouvelle version non installée automatiquement (option désactivée, échec précédent,
|
||||
/// dossier protégé) : simple information dans le bandeau.
|
||||
/// dossier protégé, « Plus tard ») : simple information dans le bandeau, en avertissement pour une
|
||||
/// version prioritaire.
|
||||
/// </summary>
|
||||
private async Task NotifyLauncherUpdateAsync()
|
||||
{
|
||||
@@ -830,7 +975,13 @@ public partial class App : Application, ILauncherRestartHost
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(4));
|
||||
var check = _updates!.SkippedUpdate ?? await _updates.CheckAsync();
|
||||
if (check.IsAvailable)
|
||||
if (check.IsPriority)
|
||||
// Correctif critique non installé au démarrage (« Plus tard », échec précédent, dossier protégé…)
|
||||
_launcher?.SelectedGame?.ShowNotice("Mise à jour importante du launcher",
|
||||
$"La version {check.AvailableDisplay} corrige un problème important et n'est pas encore installée : " +
|
||||
"Paramètres du launcher (roue crantée) › Vérifier les mises à jour.",
|
||||
DialogKind.Warning);
|
||||
else if (check.IsAvailable)
|
||||
_launcher?.SelectedGame?.ShowNotice("Nouvelle version du launcher",
|
||||
$"La version {check.AvailableDisplay} est disponible : Paramètres du launcher (roue crantée) › Vérifier les mises à jour.",
|
||||
DialogKind.Info);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
(1.0.0 → 1.0.1 → 1.1.0…). Les launchers des joueurs ne se mettent à jour que vers
|
||||
une version STRICTEMENT supérieure à la leur.
|
||||
-->
|
||||
<Version>3.2.0</Version>
|
||||
<Version>3.4.2</Version>
|
||||
<Authors>Lanisys</Authors>
|
||||
<Company>LanisysGames</Company>
|
||||
<Copyright>© LanisysGames</Copyright>
|
||||
@@ -54,6 +54,9 @@
|
||||
<ProjectReference Include="..\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj" />
|
||||
<!-- Module « Aide » (demandes des joueurs) : formulaire sans WPF ; la fenêtre est ici (Views\FeedbackWindow.xaml). -->
|
||||
<ProjectReference Include="..\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj" />
|
||||
<!-- Module « Compte » (comptes joueurs) : service et ViewModels sans WPF ; la fenêtre est ici (Views\AccountWindow.xaml),
|
||||
ainsi que la clé d'appareil (Platform\PlayerDeviceKeyStore.cs) et le chiffrement DPAPI (Platform\DpapiProtector.cs). -->
|
||||
<ProjectReference Include="..\GameLauncher.Modules.Accounts\GameLauncher.Modules.Accounts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Modules.Accounts;
|
||||
|
||||
namespace GameLauncher.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Chiffrement local par DPAPI (CryptProtectData de Windows), portée « utilisateur Windows courant » : seul ce compte
|
||||
/// Windows, sur ce PC, peut relire les données. Entropie propre à l'usage (<see cref="PlayerSessionFile.Purpose"/>) : un
|
||||
/// autre programme qui utilise DPAPI pour le même utilisateur ne déchiffre pas ces données par hasard. Aucune fenêtre
|
||||
/// (CRYPTPROTECT_UI_FORBIDDEN). Appel direct de crypt32.dll : aucune dépendance supplémentaire.
|
||||
/// </summary>
|
||||
public sealed class DpapiProtector : ISecretProtector
|
||||
{
|
||||
private const int UiForbidden = 0x1;
|
||||
|
||||
private readonly byte[] _entropy;
|
||||
|
||||
public DpapiProtector(byte[] entropy) => _entropy = entropy.ToArray();
|
||||
|
||||
public byte[] Protect(byte[] data) => Run(data, protect: true);
|
||||
|
||||
public byte[] Unprotect(byte[] data) => Run(data, protect: false);
|
||||
|
||||
private byte[] Run(byte[] data, bool protect)
|
||||
{
|
||||
var input = Allocate(data);
|
||||
var entropy = Allocate(_entropy);
|
||||
var output = new DataBlob();
|
||||
try
|
||||
{
|
||||
var ok = protect
|
||||
? CryptProtectData(ref input, null, ref entropy, IntPtr.Zero, IntPtr.Zero, UiForbidden, ref output)
|
||||
: CryptUnprotectData(ref input, IntPtr.Zero, ref entropy, IntPtr.Zero, IntPtr.Zero, UiForbidden, ref output);
|
||||
if (!ok)
|
||||
throw new CryptographicException(protect ? "Chiffrement Windows (DPAPI) impossible." : "Données illisibles pour cet utilisateur Windows.",
|
||||
new Win32Exception(Marshal.GetLastWin32Error()));
|
||||
var result = new byte[output.Length];
|
||||
Marshal.Copy(output.Data, result, 0, output.Length);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Free(ref input);
|
||||
Free(ref entropy);
|
||||
if (output.Data != IntPtr.Zero)
|
||||
{
|
||||
Marshal.Copy(new byte[output.Length], 0, output.Data, output.Length); // effacé avant d'être rendu à Windows
|
||||
LocalFree(output.Data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static DataBlob Allocate(byte[] bytes)
|
||||
{
|
||||
var blob = new DataBlob { Length = bytes.Length, Data = Marshal.AllocHGlobal(Math.Max(1, bytes.Length)) };
|
||||
Marshal.Copy(bytes, 0, blob.Data, bytes.Length);
|
||||
return blob;
|
||||
}
|
||||
|
||||
private static void Free(ref DataBlob blob)
|
||||
{
|
||||
if (blob.Data == IntPtr.Zero) return;
|
||||
Marshal.Copy(new byte[blob.Length], 0, blob.Data, blob.Length);
|
||||
Marshal.FreeHGlobal(blob.Data);
|
||||
blob.Data = IntPtr.Zero;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct DataBlob
|
||||
{
|
||||
public int Length;
|
||||
public IntPtr Data;
|
||||
}
|
||||
|
||||
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CryptProtectData(ref DataBlob dataIn, string? description, ref DataBlob entropy, IntPtr reserved,
|
||||
IntPtr prompt, int flags, ref DataBlob dataOut);
|
||||
|
||||
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CryptUnprotectData(ref DataBlob dataIn, IntPtr description, ref DataBlob entropy, IntPtr reserved,
|
||||
IntPtr prompt, int flags, ref DataBlob dataOut);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr LocalFree(IntPtr memory);
|
||||
}
|
||||
@@ -128,12 +128,15 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
|
||||
/// <summary>
|
||||
/// Au démarrage, avant l'affichage de la fenêtre principale : recherche rapide et, si une
|
||||
/// nouvelle version existe et que la mise à jour automatique est activée, installation.
|
||||
/// Mise à jour automatique désactivée : la recherche a lieu quand même, et seule une version
|
||||
/// PRIORITAIRE (correctif critique, information signée) est installée. Le réglage du joueur
|
||||
/// (self-update.json) n'est jamais modifié : il reste désactivé pour les versions suivantes.
|
||||
/// Renvoie vrai si le launcher redémarre (l'appelant doit alors s'arrêter là).
|
||||
/// </summary>
|
||||
public async Task<bool> RunStartupCheckAsync()
|
||||
{
|
||||
ExecutableSwapper.CleanupLeftovers(TargetPath, CurrentVersionValue);
|
||||
if (_client is null || !AutoUpdate || !IsSingleFileBuild) return false;
|
||||
if (_client is null || !IsSingleFileBuild) return false;
|
||||
|
||||
LauncherUpdateCheck check;
|
||||
try
|
||||
@@ -148,6 +151,18 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
|
||||
}
|
||||
|
||||
if (!check.IsAvailable) return false;
|
||||
if (!AutoUpdate && !check.IsPriority)
|
||||
{
|
||||
// Option désactivée, version normale : simple information dans le bandeau (sans seconde recherche).
|
||||
SkippedUpdate = check;
|
||||
return false;
|
||||
}
|
||||
if (check.IsPriority)
|
||||
Log.Warn($"Launcher {check.AvailableDisplay} : mise à jour PRIORITAIRE (correctif publié en {check.Release!.PriorityVersion})" +
|
||||
(AutoUpdate ? "." : ", installée bien que la mise à jour automatique soit désactivée (réglage conservé)."));
|
||||
|
||||
// Échec précédent : même prioritaire, pas de nouvelle tentative automatique (une version qui ne démarre pas
|
||||
// sur ce PC imposerait 60 s d'attente et un retour arrière à chaque lancement). Bandeau + bouton manuel.
|
||||
if (string.Equals(_settings.FailedVersion, check.AvailableDisplay, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Log.Warn($"Launcher {check.AvailableDisplay} : installation automatique ignorée (échec précédent). " +
|
||||
@@ -167,7 +182,10 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
|
||||
SkippedUpdate = check;
|
||||
return false;
|
||||
}
|
||||
return await InstallAsync(check, automatic: true);
|
||||
if (await InstallAsync(check, automatic: true)) return true;
|
||||
// Version prioritaire non installée (« Plus tard », erreur) : rappel dans le bandeau une fois la fenêtre affichée.
|
||||
if (check.IsPriority) SkippedUpdate = check;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Installation
|
||||
@@ -265,10 +283,17 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
|
||||
Log.Error("Mise à jour du launcher impossible", ex);
|
||||
// Au démarrage, une erreur persistante (fichier absent du serveur, antivirus…) ne doit
|
||||
// pas réapparaître à chaque lancement : cette version ne sera plus installée automatiquement.
|
||||
if (automatic) RememberFailure(update);
|
||||
vm.SetError(FriendlyMessage(ex) + (automatic
|
||||
? "\n\nVous pourrez réessayer depuis Paramètres du launcher › Vérifier les mises à jour."
|
||||
: ""));
|
||||
// Exception : une version PRIORITAIRE (correctif critique) est retentée au prochain démarrage
|
||||
// (erreur de téléchargement le plus souvent passagère). Seul un échec de démarrage de la nouvelle
|
||||
// version (plus haut, après retour arrière) l'écarte des installations automatiques.
|
||||
var retryAtNextStart = automatic && update.IsPriority;
|
||||
if (automatic && !retryAtNextStart) RememberFailure(update);
|
||||
vm.SetError(FriendlyMessage(ex) + (retryAtNextStart
|
||||
? "\n\nCette mise à jour est importante : elle sera retentée au prochain démarrage du launcher " +
|
||||
"(ou dès maintenant depuis Paramètres du launcher › Vérifier les mises à jour)."
|
||||
: automatic
|
||||
? "\n\nVous pourrez réessayer depuis Paramètres du launcher › Vérifier les mises à jour."
|
||||
: ""));
|
||||
}
|
||||
|
||||
// Erreur : la fenêtre reste ouverte jusqu'à ce que l'utilisateur la ferme.
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Accounts;
|
||||
|
||||
namespace GameLauncher.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Clé d'appareil des comptes JOUEURS (ECDSA P-256), une par compte Windows, via les fournisseurs de clés de Windows (CNG) :
|
||||
/// 1. dans la puce de sécurité (TPM 2.0, « Microsoft Platform Crypto Provider ») quand elle existe : clé générée par la
|
||||
/// puce, jamais exportable, SANS code PIN (le joueur n'a rien à saisir ; une copie des fichiers de Windows est
|
||||
/// inutilisable sur un autre PC) ;
|
||||
/// 2. sinon clé logicielle du « Microsoft Software Key Storage Provider », marquée NON exportable, protégée par Windows
|
||||
/// (DPAPI de l'utilisateur).
|
||||
/// Aucune fenêtre Windows (mode silencieux). Totalement distincte des clés d'administrateur (autre nom, jamais de code PIN,
|
||||
/// jamais utilisée pour l'administration). Méthodes lentes avec la puce : appelées hors du fil de l'interface.
|
||||
/// </summary>
|
||||
public sealed class PlayerDeviceKeyStore : IPlayerDeviceKeyStore
|
||||
{
|
||||
private static readonly CngProvider[] Providers = { CngProvider.MicrosoftPlatformCryptoProvider, CngProvider.MicrosoftSoftwareKeyStorageProvider };
|
||||
|
||||
private readonly string _keyName;
|
||||
|
||||
/// <param name="scope">Éditeur et dossier de données du launcher : deux launchers différents n'ont pas la même clé.</param>
|
||||
public PlayerDeviceKeyStore(string scope)
|
||||
{
|
||||
var clean = new string((scope ?? "").Where(char.IsAsciiLetterOrDigit).Take(48).ToArray());
|
||||
_keyName = $"GameLauncher.PlayerDevice.{(clean.Length > 0 ? clean : "Default")}.v1";
|
||||
}
|
||||
|
||||
public IPlayerDeviceKey? TryOpen()
|
||||
{
|
||||
foreach (var provider in Providers)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!CngKey.Exists(_keyName, provider, CngKeyOpenOptions.Silent)) continue;
|
||||
return new Signer(CngKey.Open(_keyName, provider, CngKeyOpenOptions.Silent), provider == CngProvider.MicrosoftPlatformCryptoProvider);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
Log.Warn($"Compte : clé d'appareil illisible ({provider.Provider}) : {ex.Message}");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public IPlayerDeviceKey OpenOrCreate()
|
||||
{
|
||||
if (TryOpen() is { } existing) return existing;
|
||||
try
|
||||
{
|
||||
var tpm = Create(CngProvider.MicrosoftPlatformCryptoProvider);
|
||||
Log.Info("Compte : clé d'appareil créée dans la puce de sécurité (TPM).");
|
||||
return tpm;
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or PlatformNotSupportedException)
|
||||
{
|
||||
Log.Info($"Compte : puce de sécurité indisponible ({ex.Message}) : clé logicielle protégée par Windows.");
|
||||
TryDelete(CngProvider.MicrosoftPlatformCryptoProvider);
|
||||
}
|
||||
var software = Create(CngProvider.MicrosoftSoftwareKeyStorageProvider);
|
||||
Log.Info("Compte : clé d'appareil logicielle (non exportable) créée.");
|
||||
return software;
|
||||
}
|
||||
|
||||
public void Delete()
|
||||
{
|
||||
foreach (var provider in Providers) TryDelete(provider);
|
||||
}
|
||||
|
||||
/// <summary>Crée la clé (non exportable, de l'utilisateur Windows) puis vérifie qu'elle signe.</summary>
|
||||
private Signer Create(CngProvider provider)
|
||||
{
|
||||
var parameters = new CngKeyCreationParameters
|
||||
{
|
||||
Provider = provider,
|
||||
KeyCreationOptions = CngKeyCreationOptions.None // clé de l'utilisateur Windows (pas de la machine)
|
||||
};
|
||||
if (provider != CngProvider.MicrosoftPlatformCryptoProvider)
|
||||
{
|
||||
// Clé logicielle : jamais exportable, signature seulement (la puce, elle, n'exporte jamais une clé qu'elle a créée).
|
||||
parameters.ExportPolicy = CngExportPolicies.None;
|
||||
parameters.KeyUsage = CngKeyUsages.Signing;
|
||||
}
|
||||
var key = CngKey.Create(CngAlgorithm.ECDsaP256, _keyName, parameters);
|
||||
Signer? signer = null;
|
||||
try
|
||||
{
|
||||
signer = new Signer(key, provider == CngProvider.MicrosoftPlatformCryptoProvider);
|
||||
signer.SignNow(RandomNumberGenerator.GetBytes(32)); // la clé fonctionne vraiment
|
||||
return signer;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (signer is not null) signer.Dispose();
|
||||
else key.Dispose();
|
||||
TryDelete(provider);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDelete(CngProvider provider)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!CngKey.Exists(_keyName, provider, CngKeyOpenOptions.Silent)) return;
|
||||
using var key = CngKey.Open(_keyName, provider, CngKeyOpenOptions.Silent);
|
||||
key.Delete();
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or PlatformNotSupportedException)
|
||||
{
|
||||
Log.Warn($"Compte : clé d'appareil non supprimée ({provider.Provider}) : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clé ouverte : signe hors du fil de l'interface, une signature à la fois.</summary>
|
||||
private sealed class Signer : IPlayerDeviceKey, IDisposable
|
||||
{
|
||||
private readonly ECDsaCng _ecdsa;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
public Signer(CngKey key, bool inTpm)
|
||||
{
|
||||
try
|
||||
{
|
||||
PublicKey = TpmKeyStore.PublicKeyOf(key); // vérifie aussi qu'il s'agit bien d'une clé ECDSA P-256
|
||||
KeyId = AdminKeys.ComputeKeyId(PublicKey);
|
||||
InTpm = inTpm;
|
||||
_ecdsa = new ECDsaCng(key);
|
||||
}
|
||||
finally
|
||||
{
|
||||
key.Dispose(); // ECDsaCng garde sa propre référence sur la poignée
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] PublicKey { get; }
|
||||
public string KeyId { get; }
|
||||
public bool InTpm { get; }
|
||||
|
||||
public byte[] SignNow(byte[] message) => _ecdsa.SignData(message, HashAlgorithmName.SHA256); // IEEE P1363, 64 octets
|
||||
|
||||
public async ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default)
|
||||
{
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
ExceptionDispatchInfo? failure = null;
|
||||
var signature = await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return SignNow(message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failure = ExceptionDispatchInfo.Capture(ex);
|
||||
return null;
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
failure?.Throw();
|
||||
return signature!;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _ecdsa.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -345,6 +345,12 @@ public sealed class TpmKeyStore : IAdminTpm
|
||||
var code => $"Puce de sécurité (TPM) inutilisable : {(ex.InnerException ?? ex).Message} (code 0x{code:X8})."
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Clé publique d'une clé CNG ECDSA P-256 (puce ou logicielle), lue au format brut de Windows puis encodée en SPKI.
|
||||
/// Partagé avec la clé d'appareil des joueurs (<see cref="PlayerDeviceKeyStore"/>).
|
||||
/// </summary>
|
||||
internal static byte[] PublicKeyOf(CngKey key) => TpmSigner.ReadPublicKey(key);
|
||||
|
||||
// ------------------------------------------------------------------ Signataire
|
||||
|
||||
/// <summary>Clé ouverte dans la puce : elle signe (dans la puce), la poignée est fermée au verrouillage.</summary>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Windows;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Accounts;
|
||||
using GameLauncher.Platform;
|
||||
using GameLauncher.ViewModels;
|
||||
using GameLauncher.Views;
|
||||
|
||||
namespace GameLauncher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Bouton « Compte » de la barre de titre : fenêtre de connexion / création de compte / « Mon compte » (fenêtre modale), et
|
||||
/// état du compte affiché sur le bouton. Présent seulement si « playerAccounts » et « apiUrl » sont renseignés dans le
|
||||
/// launcher.json INTÉGRÉ (Release). Le launcher ne contacte l'API des comptes que si une session est mémorisée (reprise au
|
||||
/// démarrage, sans bloquer) ou quand le joueur ouvre la fenêtre.
|
||||
/// </summary>
|
||||
public sealed class AccountWindowService : IDisposable
|
||||
{
|
||||
/// <summary>Attente maximale, à la sortie du launcher, de la fermeture d'une session non mémorisée.</summary>
|
||||
private static readonly TimeSpan ShutdownBudget = TimeSpan.FromSeconds(3);
|
||||
|
||||
private readonly MainWindow _owner;
|
||||
private readonly LauncherViewModel _launcher;
|
||||
private readonly PlayerAccountService _service;
|
||||
private readonly string _publisher;
|
||||
private AccountWindow? _window;
|
||||
private bool _disposed;
|
||||
|
||||
public AccountWindowService(MainWindow owner, LauncherViewModel launcher, PlayerAccountService service, string publisher)
|
||||
{
|
||||
_owner = owner;
|
||||
_launcher = launcher;
|
||||
_service = service;
|
||||
_publisher = publisher;
|
||||
_service.Changed += OnChanged;
|
||||
_launcher.IsAccountAvailable = true;
|
||||
ApplyState();
|
||||
}
|
||||
|
||||
/// <summary>Reprend la session mémorisée (après l'affichage de la fenêtre principale, sans la bloquer).</summary>
|
||||
public void Start(CancellationToken ct) => _ = _service.RestoreAsync(ct);
|
||||
|
||||
public void Open()
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (_window is not null)
|
||||
{
|
||||
_window.Activate();
|
||||
return;
|
||||
}
|
||||
|
||||
AccountWindowViewModel? viewModel = null;
|
||||
try
|
||||
{
|
||||
AccountWindow? window = null;
|
||||
viewModel = new AccountWindowViewModel(_service, new ModuleDialogs(() => window), new WpfUiDispatcher(_owner.Dispatcher), _publisher);
|
||||
window = new AccountWindow(viewModel);
|
||||
if (_owner.IsVisible) window.Owner = _owner;
|
||||
else window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
_window = window;
|
||||
Log.Info("Ouverture de la fenêtre « Compte ».");
|
||||
_owner.SetDimmed(true);
|
||||
try
|
||||
{
|
||||
window.ShowDialog();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_owner.SetDimmed(false);
|
||||
_window = null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_window = null;
|
||||
viewModel?.Dispose();
|
||||
Log.Error("Ouverture de la fenêtre « Compte »", ex);
|
||||
ModernDialog.ShowMessage(_owner.IsVisible ? _owner : null, "Compte indisponible", ex.Message, DialogKind.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnChanged() => _owner.Dispatcher.InvokeAsync(ApplyState);
|
||||
|
||||
/// <summary>État affiché sur le bouton « Compte » (pastille, info-bulle).</summary>
|
||||
private void ApplyState()
|
||||
{
|
||||
if (_disposed) return;
|
||||
var account = _service.Account;
|
||||
_launcher.ApplyAccountState(_service.State switch
|
||||
{
|
||||
PlayerAccountState.SignedIn => AccountButtonState.SignedIn,
|
||||
PlayerAccountState.Restoring => AccountButtonState.Restoring,
|
||||
PlayerAccountState.Offline => AccountButtonState.Offline,
|
||||
_ => AccountButtonState.SignedOut
|
||||
}, account?.EmailMasked);
|
||||
}
|
||||
|
||||
/// <summary>Sortie du launcher : session non mémorisée fermée sur le serveur (quelques secondes au plus).</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_service.Changed -= OnChanged;
|
||||
_window?.Close();
|
||||
_service.Shutdown(ShutdownBudget);
|
||||
_service.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public sealed class AdminWindowService : IDisposable
|
||||
private readonly Uri? _apiUrl;
|
||||
private readonly string? _apiUrlError;
|
||||
private readonly PlayerSigningCheck? _playerSigning;
|
||||
private readonly Action? _maintenanceChanged;
|
||||
private LauncherApiClient? _client;
|
||||
private AdminWindow? _window;
|
||||
|
||||
@@ -39,9 +40,10 @@ public sealed class AdminWindowService : IDisposable
|
||||
/// <param name="playerSigning">
|
||||
/// Contrôle du trousseau des joueurs avant une publication signée (null : signatures non vérifiées, pas d'avertissement).
|
||||
/// </param>
|
||||
/// <param name="maintenanceChanged">Après une publication du mode maintenance : relecture immédiate par ce launcher.</param>
|
||||
public AdminWindowService(Window owner, ModuleCatalog modules, LauncherCatalog catalog, LauncherCatalog declared, string? apiUrl,
|
||||
string? apiUrlError, string launcherVersion, IReadOnlyList<string> themes,
|
||||
PlayerSigningCheck? playerSigning = null)
|
||||
PlayerSigningCheck? playerSigning = null, Action? maintenanceChanged = null)
|
||||
{
|
||||
_owner = owner;
|
||||
_modules = modules;
|
||||
@@ -52,6 +54,7 @@ public sealed class AdminWindowService : IDisposable
|
||||
_apiUrl = apiUrl is null ? null : new Uri(apiUrl, UriKind.Absolute);
|
||||
_apiUrlError = apiUrlError;
|
||||
_playerSigning = playerSigning;
|
||||
_maintenanceChanged = maintenanceChanged;
|
||||
}
|
||||
|
||||
public bool IsAvailable => _apiUrl is not null;
|
||||
@@ -93,7 +96,8 @@ public sealed class AdminWindowService : IDisposable
|
||||
SettingsPath = _settingsPath,
|
||||
LauncherVersion = _launcherVersion,
|
||||
RootKeys = LauncherUpdateService.LoadRootKeys(),
|
||||
PlayerSigning = _playerSigning
|
||||
PlayerSigning = _playerSigning,
|
||||
MaintenanceChanged = _maintenanceChanged
|
||||
});
|
||||
window = new AdminWindow(shell);
|
||||
if (_owner.IsVisible) window.Owner = _owner;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
xmlns:asig="clr-namespace:GameLauncher.Modules.Admin.Signing;assembly=GameLauncher.Modules.Admin"
|
||||
xmlns:afil="clr-namespace:GameLauncher.Modules.Admin.Files;assembly=GameLauncher.Modules.Admin"
|
||||
xmlns:afb="clr-namespace:GameLauncher.Modules.Admin.Feedback;assembly=GameLauncher.Modules.Admin"
|
||||
xmlns:amnt="clr-namespace:GameLauncher.Modules.Admin.Maintenance;assembly=GameLauncher.Modules.Admin"
|
||||
xmlns:conv="clr-namespace:GameLauncher.Converters">
|
||||
|
||||
<!--
|
||||
@@ -367,6 +368,19 @@
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Mode maintenance en cours (tous les administrateurs le voient ; la page « Maintenance » exige maintenance.manage) -->
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}" Background="#26FF8A1F"
|
||||
Visibility="{Binding IsMaintenanceActive, Converter={StaticResource BoolToVisibility}}">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Gérer" Margin="12,0,0,0"
|
||||
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.TrafficCone}" Command="{Binding OpenMaintenanceCommand}"
|
||||
Visibility="{Binding CanOpenMaintenance, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.TrafficCone}"
|
||||
VerticalAlignment="Top" Margin="0,1,0,0" />
|
||||
<TextBlock Text="{Binding MaintenanceText}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Compte connecté -->
|
||||
<Border Style="{StaticResource AdminCardStyle}">
|
||||
<Grid>
|
||||
@@ -2991,7 +3005,7 @@
|
||||
<StackPanel>
|
||||
<TextBlock Text="Clés autorisées à signer" Style="{StaticResource AdminCardTitleStyle}" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
|
||||
Text="D'après les appareils actifs des administrateurs autorisés à publier une version (propriétaires, administrateurs) ou le catalogue (éditeurs compris). Le trousseau est public : chacun peut y lire le nom des appareils (« Nom — appareil »)." />
|
||||
Text="D'après les appareils actifs des administrateurs autorisés à publier une version, le catalogue, les formulaires des demandes ou le mode maintenance (permissions de leur groupe). Le trousseau est public : chacun peut y lire le nom des appareils (« Nom — appareil »)." />
|
||||
<TextBlock Text="{Binding ChangesText}" FontSize="12.5" FontWeight="SemiBold" Margin="0,0,0,10" />
|
||||
<ItemsControl ItemsSource="{Binding Keys}" Focusable="False" />
|
||||
<DockPanel Margin="0,10,0,0">
|
||||
@@ -3014,13 +3028,130 @@
|
||||
<StackPanel>
|
||||
<TextBlock Text="Contenu en ligne" Style="{StaticResource AdminCardTitleStyle}" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
|
||||
Text="Versions et catalogue tels que les launchers les reçoivent, vérifiés avec le trousseau publié et avec le trousseau proposé." />
|
||||
Text="Versions, catalogue, formulaires des demandes et mode maintenance tels que les launchers les reçoivent, vérifiés avec le trousseau publié et avec le trousseau proposé." />
|
||||
<ItemsControl ItemsSource="{Binding Contents}" Focusable="False" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- ============================================================ Page « Maintenance » -->
|
||||
<DataTemplate DataType="{x:Type amnt:MaintenancePanelViewModel}">
|
||||
<StackPanel>
|
||||
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
|
||||
<DockPanel>
|
||||
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
|
||||
VerticalAlignment="Top" />
|
||||
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
|
||||
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
|
||||
<DockPanel>
|
||||
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
|
||||
VerticalAlignment="Top" Margin="0,1,0,0" />
|
||||
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
|
||||
Visibility="{Binding ShowPublishingDisabled, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock TextWrapping="Wrap" FontSize="12.5"
|
||||
Text="Publication du mode maintenance non configurée sur le serveur : renseignez LauncherApi:Catalog:PublicFile (le fichier est alors écrit à côté du catalogue) ou LauncherApi:Keyring:PublicFile (server/deploy/LISEZMOI.md, étape 3 decies)." />
|
||||
</Border>
|
||||
|
||||
<!-- État publié -->
|
||||
<Border x:Name="StateCard" Style="{StaticResource AdminCardStyle}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border x:Name="StateBadge" Style="{StaticResource AdminIconBadgeStyle}" Width="46" Height="46" CornerRadius="15">
|
||||
<Path x:Name="StateIcon" Style="{StaticResource AdminIconStyle}" Width="22" Height="22" Data="{StaticResource Icon.CheckCircle}"
|
||||
Fill="{DynamicResource SuccessBrush}" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Margin="14,0,12,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding StateTitle}" FontSize="17" FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding StateText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
|
||||
<TextBlock FontSize="12" Margin="0,6,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}"
|
||||
Visibility="{Binding PublicUrl, Converter={StaticResource StringToVisibility}}">
|
||||
<Run Text="Fichier lu par les launchers :" />
|
||||
<Run Text="{Binding PublicUrl, Mode=OneWay}" FontFamily="Cascadia Mono, Consolas, Courier New" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Message (actions : barre du bas, AdminFooter.MaintenancePanelViewModel) -->
|
||||
<Border Style="{StaticResource AdminCardStyle}" IsEnabled="{Binding IsLoaded}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Message affiché aux joueurs" Style="{StaticResource AdminCardTitleStyle}" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
|
||||
Text="Facultatif : une précision sous « Maintenance en cours — merci de revenir plus tard » (raison, heure de retour prévue…). Texte brut, 6 lignes au plus, aucun lien ni adresse de site." />
|
||||
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="80" MaxLength="400"
|
||||
Text="{Binding Message, UpdateSourceTrigger=PropertyChanged}" ToolTip="Précision affichée aux joueurs (facultative)" />
|
||||
<DockPanel Margin="0,6,0,0">
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding MessageCounter}" FontSize="11.5" Margin="12,0,0,0"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
<TextBlock Text="{Binding MessageError}" FontSize="12" TextWrapping="Wrap" Foreground="{DynamicResource DangerBrush}"
|
||||
Visibility="{Binding HasMessageError, Converter={StaticResource BoolToVisibility}}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Aperçu : mêmes textes que la carte du launcher -->
|
||||
<Border Margin="0,16,0,0" CornerRadius="16" Padding="22,18" BorderThickness="1" Background="#E61A0D09" BorderBrush="#80FF6A2B">
|
||||
<StackPanel>
|
||||
<DockPanel HorizontalAlignment="Center">
|
||||
<Border DockPanel.Dock="Left" Width="34" Height="34" CornerRadius="11" VerticalAlignment="Center">
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#FF3B30" Offset="0" />
|
||||
<GradientStop Color="#FF9500" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
<Path Data="{StaticResource Icon.TrafficCone}" Fill="White" Width="18" Height="18" Stretch="Uniform" />
|
||||
</Border>
|
||||
<StackPanel Margin="12,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding PreviewTitle}" FontSize="16" FontWeight="Bold" Foreground="#F4F6FB" />
|
||||
<TextBlock Text="{Binding PreviewSubtitle}" FontSize="12.5" Foreground="#C8B4AC" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
<TextBlock Text="{Binding PreviewMessage}" Margin="0,12,0,0" FontSize="13" TextWrapping="Wrap" TextAlignment="Center"
|
||||
Foreground="#F4F6FB" Visibility="{Binding PreviewMessage, Converter={StaticResource StringToVisibility}}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,8,0,0"
|
||||
Text="Aperçu : dans le launcher, la carte s'affiche au centre de la fenêtre, sur un fond rouge / orange." />
|
||||
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,12,0,0"
|
||||
Text="Publication signée par la clé de ce PC (autorisation « mode maintenance » du trousseau). Les launchers relisent l'état au démarrage, puis toutes les 1 à 2 minutes ; celui de ce PC bascule aussitôt." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Ce que voient les joueurs -->
|
||||
<Border Style="{StaticResource AdminCardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Pendant la maintenance, chez les joueurs" Style="{StaticResource AdminCardTitleStyle}" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,6,0,0"
|
||||
Text="• Bloqués : installation, mises à jour (automatiques ou non), vérification, réparation, localisation et lancement des jeux, raccourcis du Bureau, paramètres du launcher et des jeux, bouton « Aide », site officiel." />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
|
||||
Text="• Toujours possibles : désinstallation des jeux (menu « … » ou Windows), menu d'administration, réduction et fermeture du launcher." />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
|
||||
Text="• Non interrompus : téléchargements déjà commencés, parties en cours, mises à jour du launcher lui-même (signées par la clé racine)." />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
|
||||
Text="• Hors ligne au démarrage : pas de maintenance (le jeu installé reste jouable). C'est un affichage pour les joueurs, pas une protection : si les serveurs du jeu doivent refuser les connexions, c'est à eux de le faire." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding IsActive}" Value="True">
|
||||
<Setter TargetName="StateCard" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
|
||||
<Setter TargetName="StateBadge" Property="Background" Value="#33FF8A1F" />
|
||||
<Setter TargetName="StateIcon" Property="Data" Value="{DynamicResource Icon.TrafficCone}" />
|
||||
<Setter TargetName="StateIcon" Property="Fill" Value="{DynamicResource WarningBrush}" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- ============================================================ Page « Fichiers du serveur » -->
|
||||
<!-- Élément du fil d'Ariane (www › exovivalzombie › objects) -->
|
||||
<DataTemplate DataType="{x:Type afil:FolderCrumb}">
|
||||
@@ -3326,6 +3457,34 @@
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="AdminFooter.MaintenancePanelViewModel">
|
||||
<StackPanel>
|
||||
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
||||
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Recharger" Margin="0,0,10,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}"
|
||||
ToolTip="Afficher l'état actuellement publié" />
|
||||
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Modifier le message" Margin="0,0,10,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding UpdateMessageCommand}"
|
||||
ToolTip="Publie le nouveau message (requête signée par votre clé) ; la maintenance continue"
|
||||
Visibility="{Binding IsActive, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Button Style="{StaticResource AccentButtonStyle}" Content="Terminer la maintenance"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.LockOpenVariant}" Command="{Binding DisableCommand}"
|
||||
ToolTip="Les launchers des joueurs redeviennent utilisables (requête signée par votre clé)"
|
||||
Visibility="{Binding IsActive, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Button Style="{StaticResource DangerButtonStyle}" Content="Activer la maintenance"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.TrafficCone}" Command="{Binding EnableCommand}"
|
||||
ToolTip="Bloque tous les launchers des joueurs, sauf la désinstallation des jeux (requête signée par votre clé)"
|
||||
Visibility="{Binding IsActive, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding StateTitle}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="AdminFooter.MyDevicesPanelViewModel">
|
||||
<StackPanel>
|
||||
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
|
||||
|
||||
@@ -91,6 +91,8 @@
|
||||
|
||||
<!-- Publication d'une version (phase 5) -->
|
||||
<Geometry x:Key="Icon.CloudUpload">M14,13V17H10V13H7L12,8L17,13M19.35,10.03C18.67,6.59 15.64,4 12,4C9.11,4 6.6,5.64 5.35,8.03C2.34,8.36 0,10.9 0,14A6,6 0 0,0 6,20H19A5,5 0 0,0 24,15C24,12.36 21.95,10.22 19.35,10.03Z</Geometry>
|
||||
<!-- Cône de chantier (mode maintenance) : bandes du cône et socle -->
|
||||
<Geometry x:Key="Icon.TrafficCone">M9.6,2H14.4L15.2,5H8.8L9.6,2M8.3,7H15.7L16.7,11H7.3L8.3,7M6.8,13H17.2L18.2,17H5.8L6.8,13M3,19H21V22H3V19Z</Geometry>
|
||||
<Geometry x:Key="Icon.Pause">M14,19H18V5H14M6,19H10V5H6V19Z</Geometry>
|
||||
<Geometry x:Key="Icon.PackageVariant">M2,10.96C1.5,10.68 1.35,10.07 1.63,9.59L3.13,7C3.24,6.8 3.41,6.66 3.6,6.58L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.66,6.72 20.82,6.88 20.91,7.08L22.36,9.6C22.64,10.08 22.47,10.69 22,10.96L21,11.54V16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V10.96C2.7,11.13 2.32,11.14 2,10.96M12,4.15V4.15L12,10.85V10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V12.69L14,15.59C13.67,15.77 13.3,15.76 13,15.6V19.29L19,15.91M13.04,13.36L20.03,9.27L19.5,8.35L12.51,12.45L13.04,13.36Z</Geometry>
|
||||
<Geometry x:Key="Icon.Laptop">M4,6H20V16H4M20,18A2,2 0 0,0 22,16V6C22,4.89 21.1,4 20,4H4C2.89,4 2,4.89 2,6V16A2,2 0 0,0 4,18H0V20H24V18H20Z</Geometry>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!--
|
||||
Mode maintenance (publié depuis le menu d'administration) : palette rouge / orange appliquée par-dessus le thème du
|
||||
jeu, dans la fenêtre principale seulement (MainWindow l'ajoute à ses ressources pendant la maintenance, puis la
|
||||
retire). Mêmes clés que Themes/Default.xaml : les vues, en {DynamicResource …}, changent de couleur à chaud.
|
||||
Les clés « Maintenance* » ne servent qu'à la carte affichée au centre de la fenêtre.
|
||||
-->
|
||||
|
||||
<!-- ============================================================ Palette -->
|
||||
<Color x:Key="AccentColor">#FF3B30</Color>
|
||||
<Color x:Key="Accent2Color">#FF9F0A</Color>
|
||||
<Color x:Key="BackgroundColor">#140806</Color>
|
||||
|
||||
<SolidColorBrush x:Key="BackgroundBrush" Color="#140806" />
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#FF453A" />
|
||||
<SolidColorBrush x:Key="Accent2Brush" Color="#FF9F0A" />
|
||||
<SolidColorBrush x:Key="OnAccentBrush" Color="White" />
|
||||
|
||||
<LinearGradientBrush x:Key="AccentGradientBrush" StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#FF3B30" Offset="0" />
|
||||
<GradientStop Color="#FF9500" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
<SolidColorBrush x:Key="BorderBrush" Color="#2EFF7A45" />
|
||||
<SolidColorBrush x:Key="BorderStrongBrush" Color="#4DFF7A45" />
|
||||
<SolidColorBrush x:Key="ChipBrush" Color="#991C0C08" />
|
||||
<SolidColorBrush x:Key="ChipIconBoxBrush" Color="#33FF6A2B" />
|
||||
<SolidColorBrush x:Key="BadgeBrush" Color="#24FF6A2B" />
|
||||
<SolidColorBrush x:Key="MenuBrush" Color="#F21C0E0A" />
|
||||
<SolidColorBrush x:Key="ActionBarBrush" Color="#EB170B08" />
|
||||
<SolidColorBrush x:Key="WindowBorderBrush" Color="#66FF6A2B" />
|
||||
<SolidColorBrush x:Key="RailBrush" Color="#CC140806" />
|
||||
|
||||
<!-- ============================================================ Fond : illustration du jeu fortement teintée -->
|
||||
<LinearGradientBrush x:Key="HeroOverlayBrush" StartPoint="0,0" EndPoint="1,0">
|
||||
<GradientStop Color="#F2140806" Offset="0" />
|
||||
<GradientStop Color="#D9140806" Offset="0.5" />
|
||||
<GradientStop Color="#C7140806" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
<LinearGradientBrush x:Key="VignetteBrush" StartPoint="0,0" EndPoint="0,1">
|
||||
<GradientStop Color="#B3140806" Offset="0" />
|
||||
<GradientStop Color="#00140806" Offset="0.2" />
|
||||
<GradientStop Color="#00140806" Offset="0.6" />
|
||||
<GradientStop Color="#F2140806" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
<!-- Lueur rouge / orange derrière la carte de maintenance -->
|
||||
<RadialGradientBrush x:Key="AmbientGlowBrush" Center="0.55,0.45" GradientOrigin="0.55,0.45" RadiusX="0.7" RadiusY="0.8">
|
||||
<GradientStop Color="#66FF3B1F" Offset="0" />
|
||||
<GradientStop Color="#33FF8A00" Offset="0.45" />
|
||||
<GradientStop Color="#00FF8A00" Offset="1" />
|
||||
</RadialGradientBrush>
|
||||
|
||||
<!-- ============================================================ Carte « Maintenance en cours » -->
|
||||
<SolidColorBrush x:Key="MaintenanceCardBrush" Color="#E61A0D09" />
|
||||
<SolidColorBrush x:Key="MaintenanceCardBorderBrush" Color="#80FF6A2B" />
|
||||
|
||||
<!-- Bandes de chantier (diagonales orange / brun), répétées -->
|
||||
<DrawingBrush x:Key="MaintenanceStripesBrush" TileMode="Tile" Viewport="0,0,16,16" ViewportUnits="Absolute"
|
||||
Viewbox="0,0,16,16" ViewboxUnits="Absolute" Stretch="None">
|
||||
<DrawingBrush.Drawing>
|
||||
<DrawingGroup>
|
||||
<GeometryDrawing Brush="#FF2A130B" Geometry="M0,0H16V16H0Z" />
|
||||
<GeometryDrawing Brush="#FFFF8A1F" Geometry="M0,0L8,0L0,8Z M16,0L16,8L8,16L0,16Z" />
|
||||
</DrawingGroup>
|
||||
</DrawingBrush.Drawing>
|
||||
</DrawingBrush>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -4,6 +4,7 @@ using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Services;
|
||||
using GameLauncher.Core.Trust;
|
||||
@@ -65,6 +66,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
private string? _lastInstallDirectory;
|
||||
private int _bannerVersion;
|
||||
private bool _launchWhenReady;
|
||||
private bool _isMaintenance;
|
||||
|
||||
public GameViewModel(LauncherConfig config, LauncherSettings settings, string settingsPath,
|
||||
LauncherPreferences preferences, GameInstaller installer, ManifestClient manifestClient,
|
||||
@@ -88,8 +90,10 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
LauncherVersion = launcherVersion;
|
||||
ApplySettings();
|
||||
|
||||
PrimaryCommand = Command(ExecutePrimary, CanExecutePrimary);
|
||||
CancelCommand = Command(Cancel, () => State == LauncherState.Busy && _operationCancellable);
|
||||
// Mode maintenance : toutes les commandes sont désactivées, sauf la désinstallation, l'annulation d'une opération
|
||||
// déjà commencée et la fermeture du bandeau (Command(…, allowedInMaintenance: true)).
|
||||
PrimaryCommand = Command(ExecutePrimary, CanExecutePrimary, allowedInMaintenance: true);
|
||||
CancelCommand = Command(Cancel, () => State == LauncherState.Busy && _operationCancellable, allowedInMaintenance: true);
|
||||
CheckUpdatesCommand = Command(() => Fire(() => CheckForUpdatesAsync(userInitiated: true)),
|
||||
() => State is LauncherState.NotInstalled or LauncherState.Ready or LauncherState.UpdateAvailable
|
||||
or LauncherState.NeedsRepair or LauncherState.Broken);
|
||||
@@ -101,12 +105,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
() => State is LauncherState.NotInstalled or LauncherState.Broken);
|
||||
ForgetCommand = Command(Forget, () => State == LauncherState.Broken);
|
||||
UninstallCommand = Command(() => Fire(async () => await UninstallAsync()),
|
||||
() => State is LauncherState.Ready or LauncherState.UpdateAvailable or LauncherState.NeedsRepair or LauncherState.Broken);
|
||||
() => State is LauncherState.Ready or LauncherState.UpdateAvailable or LauncherState.NeedsRepair or LauncherState.Broken,
|
||||
allowedInMaintenance: true);
|
||||
OpenFolderCommand = Command(() => { if (InstallPath is not null) _window.OpenFolder(InstallPath); },
|
||||
() => InstallPath is not null && State != LauncherState.Broken && Directory.Exists(InstallPath));
|
||||
OpenSettingsCommand = Command(OpenSettings);
|
||||
OpenWebsiteCommand = Command(() => { if (HasWebsite) _window.OpenUrl(_config.WebsiteUrl!); }, () => HasWebsite);
|
||||
DismissBannerCommand = Command(HideBanner);
|
||||
DismissBannerCommand = Command(HideBanner, allowedInMaintenance: true);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Propriétés affichées
|
||||
@@ -184,6 +189,8 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
OnPropertyChanged(nameof(ShowInstallInfo));
|
||||
OnPropertyChanged(nameof(ShowRailBadge));
|
||||
OnPropertyChanged(nameof(RailToolTip));
|
||||
OnPropertyChanged(nameof(DisplayStatusTitle));
|
||||
OnPropertyChanged(nameof(DisplayStatusDetail));
|
||||
RefreshCommands();
|
||||
|
||||
// Lancement demandé par un raccourci pendant une vérification : traité à la fin de celle-ci.
|
||||
@@ -196,12 +203,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
}
|
||||
|
||||
public bool IsBusy => State == LauncherState.Busy;
|
||||
public bool ShowLocateHint => State == LauncherState.NotInstalled;
|
||||
public bool ShowLocateHint => State == LauncherState.NotInstalled && !_isMaintenance;
|
||||
public bool ShowBrokenActions => State == LauncherState.Broken;
|
||||
public bool ShowInstallInfo => InstallPath is not null && State != LauncherState.NotInstalled;
|
||||
|
||||
public string PrimaryText => State switch
|
||||
{
|
||||
not (LauncherState.Busy or LauncherState.Running) when _isMaintenance => "MAINTENANCE",
|
||||
LauncherState.Checking => "CHARGEMENT",
|
||||
LauncherState.NotInstalled => "INSTALLER",
|
||||
LauncherState.Ready => "JOUER",
|
||||
@@ -215,6 +223,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
/// <summary>Clé d'icône (ressource "Icon.<clé>" dans Themes/Icons.xaml).</summary>
|
||||
public string PrimaryIcon => State switch
|
||||
{
|
||||
not (LauncherState.Busy or LauncherState.Running) when _isMaintenance => "TrafficCone",
|
||||
LauncherState.Checking => "Refresh",
|
||||
LauncherState.NotInstalled => "Download",
|
||||
LauncherState.UpdateAvailable => "CloudDownload",
|
||||
@@ -244,10 +253,71 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
};
|
||||
|
||||
private string _statusTitle = "Chargement…";
|
||||
public string StatusTitle { get => _statusTitle; private set => SetProperty(ref _statusTitle, value); }
|
||||
public string StatusTitle
|
||||
{
|
||||
get => _statusTitle;
|
||||
private set { if (SetProperty(ref _statusTitle, value)) OnPropertyChanged(nameof(DisplayStatusTitle)); }
|
||||
}
|
||||
|
||||
private string _statusDetail = "";
|
||||
public string StatusDetail { get => _statusDetail; private set => SetProperty(ref _statusDetail, value); }
|
||||
public string StatusDetail
|
||||
{
|
||||
get => _statusDetail;
|
||||
private set { if (SetProperty(ref _statusDetail, value)) OnPropertyChanged(nameof(DisplayStatusDetail)); }
|
||||
}
|
||||
|
||||
/// <summary>Titre de la barre d'action : pendant une maintenance, celle-ci (sauf pendant une opération déjà commencée).</summary>
|
||||
public string DisplayStatusTitle => _isMaintenance && State != LauncherState.Busy ? MaintenanceRules.Title : StatusTitle;
|
||||
|
||||
public string DisplayStatusDetail => !_isMaintenance || State == LauncherState.Busy
|
||||
? StatusDetail
|
||||
: State == LauncherState.Running
|
||||
? "Le jeu déjà lancé n'est pas fermé. Seule la désinstallation des jeux reste possible."
|
||||
: "Seule la désinstallation des jeux reste possible (menu « … »).";
|
||||
|
||||
// ------------------------------------------------------------------ Mode maintenance
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance publié depuis le menu d'administration (commun à tous les jeux, posé par le launcher) : tout est
|
||||
/// bloqué sauf la désinstallation. Une opération déjà commencée se termine (elle reste annulable) ; un jeu déjà lancé
|
||||
/// n'est pas fermé. À la fin de la maintenance, les mises à jour sont recherchées de nouveau.
|
||||
/// </summary>
|
||||
public bool IsMaintenance
|
||||
{
|
||||
get => _isMaintenance;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _isMaintenance, value)) return;
|
||||
if (value) _launchWhenReady = false; // lancement demandé par un raccourci : abandonné
|
||||
OnPropertyChanged(nameof(PrimaryText));
|
||||
OnPropertyChanged(nameof(PrimaryIcon));
|
||||
OnPropertyChanged(nameof(DisplayStatusTitle));
|
||||
OnPropertyChanged(nameof(DisplayStatusDetail));
|
||||
OnPropertyChanged(nameof(ShowLocateHint));
|
||||
OnPropertyChanged(nameof(ShowWebsite));
|
||||
RefreshCommands();
|
||||
Log.Info($"[{GameId}] Mode maintenance {(value ? "activé" : "terminé")}.");
|
||||
|
||||
// Fin de la maintenance : état et mises à jour à jour (mise à jour automatique selon le réglage du jeu).
|
||||
if (!value && State is LauncherState.NotInstalled or LauncherState.Ready or LauncherState.UpdateAvailable
|
||||
or LauncherState.NeedsRepair or LauncherState.Broken)
|
||||
Fire(() => CheckForUpdatesAsync(userInitiated: false));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Bouton « Site officiel » de la barre de titre (masqué pendant une maintenance).</summary>
|
||||
public bool ShowWebsite => HasWebsite && !_isMaintenance;
|
||||
|
||||
/// <summary>Action refusée pendant une maintenance : bandeau d'explication. Vrai si l'action doit s'arrêter.</summary>
|
||||
private bool BlockedByMaintenance()
|
||||
{
|
||||
if (!_isMaintenance) return false;
|
||||
Log.Info($"[{GameId}] Action refusée : mode maintenance.");
|
||||
ShowBanner(MaintenanceRules.Title,
|
||||
"Cette action est indisponible pendant la maintenance. Merci de revenir plus tard : seule la désinstallation reste possible.",
|
||||
DialogKind.Warning);
|
||||
return true;
|
||||
}
|
||||
|
||||
private string _stepText = "";
|
||||
public string StepText { get => _stepText; private set => SetProperty(ref _stepText, value); }
|
||||
@@ -334,9 +404,10 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
public RelayCommand OpenWebsiteCommand { get; }
|
||||
public RelayCommand DismissBannerCommand { get; }
|
||||
|
||||
private RelayCommand Command(Action execute, Func<bool>? canExecute = null)
|
||||
private RelayCommand Command(Action execute, Func<bool>? canExecute = null, bool allowedInMaintenance = false)
|
||||
{
|
||||
var command = new RelayCommand(execute, canExecute);
|
||||
var command = new RelayCommand(execute,
|
||||
allowedInMaintenance ? canExecute : () => !_isMaintenance && (canExecute?.Invoke() ?? true));
|
||||
_commands.Add(command);
|
||||
return command;
|
||||
}
|
||||
@@ -348,6 +419,8 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
private bool CanExecutePrimary() => State switch
|
||||
{
|
||||
LauncherState.Busy when _isMaintenance => _operationCancellable, // seule l'annulation reste possible
|
||||
_ when _isMaintenance => false,
|
||||
LauncherState.Checking or LauncherState.Running => false,
|
||||
LauncherState.Busy => _operationCancellable,
|
||||
_ => true
|
||||
@@ -355,6 +428,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
private void ExecutePrimary()
|
||||
{
|
||||
if (State != LauncherState.Busy && BlockedByMaintenance()) return;
|
||||
switch (State)
|
||||
{
|
||||
case LauncherState.NotInstalled: Fire(InstallAsync); break;
|
||||
@@ -578,6 +652,12 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
private async Task CheckForUpdatesAsync(bool userInitiated, bool background = false)
|
||||
{
|
||||
if (State is LauncherState.Busy or LauncherState.Running) return;
|
||||
// Maintenance : ni recherche ni mise à jour automatique (reprises à la fin de la maintenance).
|
||||
if (_isMaintenance)
|
||||
{
|
||||
if (userInitiated) BlockedByMaintenance();
|
||||
return;
|
||||
}
|
||||
if (_launcherUpdates?.IsInstalling == true) return; // le launcher se met à jour puis redémarre
|
||||
|
||||
if (!background)
|
||||
@@ -599,6 +679,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
if (State is LauncherState.Busy or LauncherState.Running) return; // une opération ou le jeu a démarré entre-temps
|
||||
if (_launcherUpdates?.IsInstalling == true) return;
|
||||
RefreshInstallState();
|
||||
if (_isMaintenance) return; // maintenance commencée pendant la recherche : pas de mise à jour automatique
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
@@ -649,11 +730,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
/// Exécute une opération longue : état "Busy", barre de progression, annulation,
|
||||
/// gestion des erreurs, puis nouvelle détection de l'état.
|
||||
/// </summary>
|
||||
/// <param name="allowedInMaintenance">Vrai pour la désinstallation seulement : seule opération permise pendant une maintenance.</param>
|
||||
private async Task<bool> RunOperationAsync(string title,
|
||||
Func<IProgress<OperationProgress>, CancellationToken, Task> work,
|
||||
bool cancellable = true)
|
||||
bool cancellable = true, bool allowedInMaintenance = false)
|
||||
{
|
||||
if (State == LauncherState.Busy) return false;
|
||||
if (!allowedInMaintenance && BlockedByMaintenance()) return false;
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token);
|
||||
_operationCts = cts;
|
||||
@@ -725,6 +808,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
private async Task InstallAsync()
|
||||
{
|
||||
if (BlockedByMaintenance()) return;
|
||||
var manifest = await TryFetchManifestAsync();
|
||||
if (manifest is null)
|
||||
{
|
||||
@@ -756,6 +840,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
private async Task UpdateAsync()
|
||||
{
|
||||
if (BlockedByMaintenance()) return;
|
||||
var dir = InstallPath;
|
||||
if (dir is null) return;
|
||||
|
||||
@@ -783,6 +868,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
/// </param>
|
||||
private async Task RepairAsync(bool cacheIsFresh = false)
|
||||
{
|
||||
if (BlockedByMaintenance()) return;
|
||||
var dir = InstallPath;
|
||||
if (dir is null) return;
|
||||
|
||||
@@ -914,6 +1000,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
private async Task VerifyIntegrityAsync()
|
||||
{
|
||||
if (BlockedByMaintenance()) return;
|
||||
var dir = InstallPath;
|
||||
if (dir is null) return;
|
||||
|
||||
@@ -983,6 +1070,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
private async Task LocateAsync()
|
||||
{
|
||||
if (BlockedByMaintenance()) return;
|
||||
var initial = InstallPath is not null ? Path.GetDirectoryName(InstallPath) : null;
|
||||
var folder = _dialogs.PickFolder($"Où se trouve {GameName} ?", initial);
|
||||
if (folder is null) return;
|
||||
@@ -1046,6 +1134,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
private void Forget()
|
||||
{
|
||||
if (BlockedByMaintenance()) return;
|
||||
if (!_dialogs.Confirm("Oublier l'installation",
|
||||
$"Le launcher ne considérera plus {GameName} comme installé dans « {InstallPath} ».\nAucun fichier ne sera supprimé.",
|
||||
"Oublier", "Annuler", DialogKind.Question))
|
||||
@@ -1124,7 +1213,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
_registry.Delete();
|
||||
_shortcuts.RemoveDesktopShortcut();
|
||||
_needsRepair = false;
|
||||
}, cancellable: false);
|
||||
}, cancellable: false, allowedInMaintenance: true);
|
||||
|
||||
if (ok && result is not null)
|
||||
{
|
||||
@@ -1250,6 +1339,12 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
public void LaunchFromShortcut()
|
||||
{
|
||||
_launchWhenReady = false;
|
||||
if (_isMaintenance && State != LauncherState.Running)
|
||||
{
|
||||
ShowBanner(MaintenanceRules.Title,
|
||||
$"{GameName} ne peut pas être lancé pendant la maintenance. {MaintenanceRules.Subtitle}", DialogKind.Warning, autoHide: false);
|
||||
return;
|
||||
}
|
||||
switch (State)
|
||||
{
|
||||
case LauncherState.Ready:
|
||||
@@ -1283,6 +1378,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
private void Play()
|
||||
{
|
||||
if (BlockedByMaintenance()) return;
|
||||
var dir = InstallPath;
|
||||
if (dir is null) return;
|
||||
var exePath = GetExecutablePath(dir);
|
||||
@@ -1416,6 +1512,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
/// <summary>Paramètres propres à ce jeu (menu « … »). Les réglages communs sont dans les paramètres du launcher.</summary>
|
||||
private void OpenSettings()
|
||||
{
|
||||
if (BlockedByMaintenance()) return;
|
||||
var info = new GameSettingsInfo(GameName, InstallPath, InstalledVersionText, LatestVersionText, InstallSizeText,
|
||||
_config.GameLogsDirectory, _config.GameDataDirectory);
|
||||
var shortcut = new GameShortcutActions(
|
||||
|
||||
@@ -116,9 +116,19 @@ public sealed class LauncherSettingsViewModel : SettingsViewModelBase
|
||||
if (check.IsAvailable)
|
||||
{
|
||||
SetAvailable(check);
|
||||
LauncherStatusKind = DialogKind.Info;
|
||||
var title = string.IsNullOrWhiteSpace(check.Release?.Title) ? "" : $" — {check.Release!.Title}";
|
||||
LauncherStatus = $"Nouvelle version {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}).";
|
||||
if (check.IsPriority)
|
||||
{
|
||||
// Correctif critique non installé au démarrage (annulé, échec, dossier protégé…)
|
||||
LauncherStatusKind = DialogKind.Warning;
|
||||
LauncherStatus = $"Mise à jour importante {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}). " +
|
||||
"Installez-la dès que possible.";
|
||||
}
|
||||
else
|
||||
{
|
||||
LauncherStatusKind = DialogKind.Info;
|
||||
LauncherStatus = $"Nouvelle version {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}).";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -21,13 +21,19 @@ public sealed class LauncherUpdateViewModel : ObservableObject
|
||||
public LauncherUpdateViewModel(LauncherUpdateCheck update, bool automatic)
|
||||
{
|
||||
Automatic = automatic;
|
||||
IsPriority = update.IsPriority;
|
||||
FromVersion = update.CurrentDisplay;
|
||||
ToVersion = update.AvailableDisplay;
|
||||
// Notes de toutes les versions publiées depuis la version installée (format enrichi)
|
||||
Changelog = update.Release?.GetChangelogSince(update.CurrentVersion) ?? Array.Empty<ChangelogEntry>();
|
||||
Subtitle = automatic
|
||||
? "Une nouvelle version est disponible. Elle s'installe automatiquement, le launcher redémarrera tout seul."
|
||||
: "Installation de la nouvelle version. Le launcher redémarrera tout seul.";
|
||||
Subtitle = (automatic, IsPriority) switch
|
||||
{
|
||||
(true, true) => "Mise à jour importante : elle s'installe automatiquement, même si la mise à jour automatique " +
|
||||
"du launcher est désactivée (votre réglage ne change pas). Le launcher redémarrera tout seul.",
|
||||
(true, false) => "Une nouvelle version est disponible. Elle s'installe automatiquement, le launcher redémarrera tout seul.",
|
||||
(false, true) => "Installation d'une mise à jour importante. Le launcher redémarrera tout seul.",
|
||||
_ => "Installation de la nouvelle version. Le launcher redémarrera tout seul."
|
||||
};
|
||||
|
||||
Progress = new Progress<OperationProgress>(p =>
|
||||
{
|
||||
@@ -44,6 +50,9 @@ public sealed class LauncherUpdateViewModel : ObservableObject
|
||||
/// <summary>Recherche au démarrage (vrai) ou demandée dans les Paramètres du launcher (faux).</summary>
|
||||
public bool Automatic { get; }
|
||||
|
||||
/// <summary>Mise à jour prioritaire (correctif critique, information signée) : pastille « Prioritaire ».</summary>
|
||||
public bool IsPriority { get; }
|
||||
|
||||
public string FromVersion { get; }
|
||||
public string ToVersion { get; }
|
||||
public string Subtitle { get; }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Utils;
|
||||
@@ -9,6 +11,15 @@ using GameLauncher.Services;
|
||||
|
||||
namespace GameLauncher.ViewModels;
|
||||
|
||||
/// <summary>État du compte joueur affiché sur le bouton « Compte » de la barre de titre.</summary>
|
||||
public enum AccountButtonState
|
||||
{
|
||||
SignedOut,
|
||||
Restoring,
|
||||
SignedIn,
|
||||
Offline
|
||||
}
|
||||
|
||||
/// <summary>Effet visuel appliqué lors du changement de thème (implémenté par la fenêtre principale).</summary>
|
||||
public interface IThemeTransition
|
||||
{
|
||||
@@ -32,6 +43,9 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
|
||||
private readonly string _launcherVersion;
|
||||
private IThemeTransition? _transition;
|
||||
private GameViewModel? _selectedGame;
|
||||
private MaintenanceDocument? _maintenance;
|
||||
private AccountButtonState _accountState;
|
||||
private string? _accountEmail;
|
||||
|
||||
public LauncherViewModel(LauncherCatalog catalog, IWindowService window, IDialogService dialogs,
|
||||
LauncherPreferences preferences, string launcherVersion,
|
||||
@@ -52,7 +66,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
|
||||
if (CloseToTray) HideToTray(); // option « La croix réduit le launcher dans la zone de notification »
|
||||
else Quit();
|
||||
});
|
||||
OpenLauncherSettingsCommand = new RelayCommand(OpenLauncherSettings);
|
||||
OpenLauncherSettingsCommand = new RelayCommand(OpenLauncherSettings, () => !IsMaintenance);
|
||||
}
|
||||
|
||||
public ObservableCollection<GameViewModel> Games { get; } = new();
|
||||
@@ -65,9 +79,89 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
|
||||
|
||||
public bool IsExitConfirmed { get; set; }
|
||||
|
||||
/// <summary>Bouton « Aide » affiché (adresse de l'API présente dans launcher.json).</summary>
|
||||
/// <summary>Bouton « Aide » disponible (adresse de l'API présente dans launcher.json).</summary>
|
||||
public bool IsHelpAvailable { get; set; }
|
||||
|
||||
/// <summary>Bouton « Aide » affiché : disponible, et pas de maintenance en cours.</summary>
|
||||
public bool ShowHelpButton => IsHelpAvailable && !IsMaintenance;
|
||||
|
||||
// ------------------------------------------------------------------ Compte joueur (bouton « Compte »)
|
||||
|
||||
/// <summary>Comptes joueurs disponibles (« playerAccounts » et « apiUrl » dans le launcher.json intégré).</summary>
|
||||
public bool IsAccountAvailable { get; set; }
|
||||
|
||||
/// <summary>Bouton « Compte » affiché : disponible, et pas de maintenance en cours.</summary>
|
||||
public bool ShowAccountButton => IsAccountAvailable && !IsMaintenance;
|
||||
|
||||
public AccountButtonState AccountState => _accountState;
|
||||
|
||||
/// <summary>Pastille sur le bouton : connecté (verte) ou session mémorisée hors ligne / en reprise (orange).</summary>
|
||||
public bool IsAccountSignedIn => _accountState == AccountButtonState.SignedIn;
|
||||
public bool IsAccountPending => _accountState is AccountButtonState.Restoring or AccountButtonState.Offline;
|
||||
|
||||
public string AccountToolTip => _accountState switch
|
||||
{
|
||||
AccountButtonState.SignedIn => $"Mon compte · {_accountEmail}",
|
||||
AccountButtonState.Restoring => "Compte : reconnexion…",
|
||||
AccountButtonState.Offline => $"Mon compte · {_accountEmail} (serveur injoignable)",
|
||||
_ => "Compte : se connecter ou créer un compte"
|
||||
};
|
||||
|
||||
/// <summary>Nouvel état du compte (appelé sur le fil de l'interface).</summary>
|
||||
public void ApplyAccountState(AccountButtonState state, string? emailMasked)
|
||||
{
|
||||
_accountState = state;
|
||||
_accountEmail = string.IsNullOrEmpty(emailMasked) ? "connecté" : emailMasked;
|
||||
OnPropertyChanged(nameof(AccountState));
|
||||
OnPropertyChanged(nameof(IsAccountSignedIn));
|
||||
OnPropertyChanged(nameof(IsAccountPending));
|
||||
OnPropertyChanged(nameof(AccountToolTip));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Mode maintenance
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance publié depuis le menu d'administration (maintenance.json, vérifié par MaintenanceService) : thème
|
||||
/// rouge / orange, message au centre, tout bloqué sauf la désinstallation des jeux et le menu d'administration.
|
||||
/// </summary>
|
||||
public bool IsMaintenance => _maintenance is { Enabled: true };
|
||||
|
||||
public string MaintenanceTitle => MaintenanceRules.Title;
|
||||
public string MaintenanceSubtitle => MaintenanceRules.Subtitle;
|
||||
|
||||
/// <summary>Précision publiée par l'administrateur (texte brut, sans lien), sinon null.</summary>
|
||||
public string? MaintenanceMessage => IsMaintenance ? _maintenance!.Message : null;
|
||||
|
||||
/// <summary>« Depuis le mardi 10 novembre à 14:05 » (heure de ce PC).</summary>
|
||||
public string? MaintenanceSinceText => IsMaintenance && _maintenance!.Since is { } since
|
||||
? "Depuis le " + since.ToLocalTime().ToString("dddd d MMMM 'à' HH:mm", CultureInfo.GetCultureInfo("fr-FR"))
|
||||
: null;
|
||||
|
||||
/// <summary>Nouvel état du mode maintenance (null : rien de publié). Appelé sur le fil de l'interface.</summary>
|
||||
public void ApplyMaintenance(MaintenanceDocument? maintenance)
|
||||
{
|
||||
var wasActive = IsMaintenance;
|
||||
var next = maintenance is { Enabled: true } ? maintenance : null;
|
||||
|
||||
void Change()
|
||||
{
|
||||
_maintenance = next;
|
||||
var active = IsMaintenance;
|
||||
foreach (var game in Games) game.IsMaintenance = active;
|
||||
OnPropertyChanged(nameof(MaintenanceMessage));
|
||||
OnPropertyChanged(nameof(MaintenanceSinceText));
|
||||
if (wasActive == active) return;
|
||||
OnPropertyChanged(nameof(IsMaintenance));
|
||||
OnPropertyChanged(nameof(ShowHelpButton));
|
||||
OnPropertyChanged(nameof(ShowAccountButton));
|
||||
OpenLauncherSettingsCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
// Début ou fin de la maintenance : même fondu enchaîné qu'un changement de jeu (palette, carte au centre).
|
||||
if (wasActive != (next is not null) && _transition is not null) _transition.Run(Change);
|
||||
else Change();
|
||||
}
|
||||
|
||||
public RelayCommand MinimizeCommand { get; }
|
||||
public RelayCommand CloseCommand { get; }
|
||||
|
||||
@@ -124,6 +218,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
|
||||
|
||||
public void AddGame(GameViewModel game)
|
||||
{
|
||||
game.IsMaintenance = IsMaintenance;
|
||||
Games.Add(game);
|
||||
OnPropertyChanged(nameof(IsMultiGame));
|
||||
}
|
||||
@@ -253,6 +348,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
|
||||
|
||||
private void OpenLauncherSettings()
|
||||
{
|
||||
if (IsMaintenance) return; // bouton désactivé pendant une maintenance
|
||||
var vm = new LauncherSettingsViewModel(_preferences, LauncherName, _launcherVersion, _window, _launcherUpdates);
|
||||
if (!_dialogs.ShowSettings(vm)) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
<Window x:Class="GameLauncher.Views.AccountWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:GameLauncher.Controls"
|
||||
Title="Compte"
|
||||
Width="600"
|
||||
SizeToContent="Height"
|
||||
WindowStyle="None"
|
||||
AllowsTransparency="True"
|
||||
Background="Transparent"
|
||||
ResizeMode="NoResize"
|
||||
ShowInTaskbar="False"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
FontFamily="{DynamicResource UiFont}"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}"
|
||||
UseLayoutRounding="True"
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
ui:WheelAssist.IsEnabled="True"
|
||||
PreviewKeyDown="OnPreviewKeyDown">
|
||||
|
||||
<!--
|
||||
Bouton « Compte » de la fenêtre principale. ViewModel : AccountWindowViewModel (GameLauncher.Modules.Accounts).
|
||||
Écrans : connexion, création de compte, nouveau mot de passe exigé, « Mon compte » (onglets Profil, Appareils, Activité,
|
||||
Mes données). Les mots de passe restent dans les PasswordBox (jamais liés au ViewModel) : lus au moment de l'action,
|
||||
effacés ensuite (AccountWindow.xaml.cs). Tout texte venu du serveur est affiché tel quel (jamais interprété).
|
||||
-->
|
||||
<Window.Resources>
|
||||
<!-- Contrôle en direct du nouveau mot de passe (PasswordRulesViewModel) -->
|
||||
<DataTemplate x:Key="PasswordRulesTemplate">
|
||||
<StackPanel Margin="2,8,0,0">
|
||||
<TextBlock Text="{Binding RulesText}" FontSize="11.5" TextWrapping="Wrap" Foreground="{DynamicResource TextMutedBrush}"
|
||||
Visibility="{Binding ShowRules, Converter={StaticResource BoolToVisibility}}" />
|
||||
<ItemsControl ItemsSource="{Binding Problems}" Focusable="False"
|
||||
Visibility="{Binding HasProblems, Converter={StaticResource BoolToVisibility}}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<Path DockPanel.Dock="Left" Width="12" Height="12" Stretch="Uniform" VerticalAlignment="Top" Margin="0,2,8,0"
|
||||
Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.AlertCircle}" />
|
||||
<TextBlock Text="{Binding}" FontSize="12" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<DockPanel Visibility="{Binding IsAccepted, Converter={StaticResource BoolToVisibility}}">
|
||||
<Path DockPanel.Dock="Left" Width="13" Height="13" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,8,0"
|
||||
Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}" />
|
||||
<TextBlock DockPanel.Dock="Right" Text="{Binding StrengthText}" FontSize="12" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||
Margin="10,0,0,0" Foreground="{DynamicResource SuccessBrush}" />
|
||||
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="4" Maximum="4" Value="{Binding Strength, Mode=OneWay}"
|
||||
VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
<TextBlock Text="Les deux mots de passe ne sont pas identiques." FontSize="12" Margin="0,6,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource DangerBrush}"
|
||||
Visibility="{Binding ShowConfirmationMismatch, Converter={StaticResource BoolToVisibility}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Libellé d'un champ -->
|
||||
<Style x:Key="AccountLabelStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Margin" Value="0,0,0,6" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Carte d'une section de « Mon compte » -->
|
||||
<Style x:Key="AccountCardStyle" TargetType="Border">
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="Padding" Value="16,14" />
|
||||
<Setter Property="Margin" Value="0,0,0,12" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Background" Value="{DynamicResource InputBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}" />
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="20">
|
||||
<!-- Ombre -->
|
||||
<Border CornerRadius="20" Background="{DynamicResource BackgroundBrush}">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="32" ShadowDepth="8" Direction="270" Opacity="0.6" Color="Black" />
|
||||
</Border.Effect>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource DialogCardStyle}" Padding="28,26,14,22" MouseLeftButtonDown="OnDragMove">
|
||||
<StackPanel>
|
||||
<!-- ================================================================ En-tête -->
|
||||
<Grid Margin="0,0,14,18">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Width="48" Height="48" CornerRadius="14" Background="{DynamicResource AccentGradientBrush}">
|
||||
<Grid>
|
||||
<Path Data="{Binding HeaderIcon, Converter={StaticResource IconKey}}" Fill="{DynamicResource OnAccentBrush}"
|
||||
Width="24" Height="24" Stretch="Uniform"
|
||||
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
|
||||
<!-- Mon compte : initiale de l'adresse -->
|
||||
<TextBlock Text="{Binding AvatarText}" FontSize="20" FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource OnAccentBrush}"
|
||||
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Margin="16,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Title}" FontSize="19" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding Subtitle}" FontSize="12.5" Margin="0,3,0,0" TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Style="{StaticResource IconButtonStyle}" VerticalAlignment="Top"
|
||||
Width="32" Height="32" Padding="9" Margin="0,-6,-10,0" ToolTip="Fermer"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Close}" Command="{Binding CloseCommand}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Onglets de « Mon compte » -->
|
||||
<Border HorizontalAlignment="Left" CornerRadius="12" Padding="3" Margin="0,0,14,16" BorderThickness="1"
|
||||
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}"
|
||||
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal" IsEnabled="{Binding IsIdle}">
|
||||
<RadioButton Style="{StaticResource AdminSegmentStyle}" GroupName="AccountTabs" Content="Profil"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.BadgeAccountHorizontal}" IsChecked="{Binding IsProfileSection}" />
|
||||
<RadioButton Style="{StaticResource AdminSegmentStyle}" GroupName="AccountTabs" Content="Appareils"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Laptop}" IsChecked="{Binding IsDevicesSection}" />
|
||||
<RadioButton Style="{StaticResource AdminSegmentStyle}" GroupName="AccountTabs" Content="Activité"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.History}" IsChecked="{Binding IsActivitySection}" />
|
||||
<RadioButton Style="{StaticResource AdminSegmentStyle}" GroupName="AccountTabs" Content="Mes données"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.FileLockOutline}" IsChecked="{Binding IsDataSection}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer x:Name="BodyScroll" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
|
||||
Focusable="False" Padding="0,0,14,0">
|
||||
<StackPanel>
|
||||
<!-- Messages -->
|
||||
<Border CornerRadius="12" Padding="14,10" Margin="0,0,0,14" Background="#26E5484D"
|
||||
BorderBrush="{DynamicResource DangerBrush}" BorderThickness="1"
|
||||
Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
|
||||
<DockPanel>
|
||||
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Top"
|
||||
Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
|
||||
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<Border CornerRadius="12" Padding="14,10" Margin="0,0,0,14" Background="#262BD48B"
|
||||
BorderBrush="{DynamicResource SuccessBrush}" BorderThickness="1"
|
||||
Visibility="{Binding HasSuccess, Converter={StaticResource BoolToVisibility}}">
|
||||
<DockPanel>
|
||||
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Top"
|
||||
Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}" />
|
||||
<TextBlock Text="{Binding SuccessMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============================================ Chargement -->
|
||||
<StackPanel Orientation="Horizontal" Margin="2,10,0,20"
|
||||
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibility}}">
|
||||
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Width="90" Height="4" IsIndeterminate="True"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding BusyText}" FontSize="12.5" Margin="12,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============================================ Service indisponible -->
|
||||
<StackPanel Margin="0,6,0,10" Visibility="{Binding IsUnavailable, Converter={StaticResource BoolToVisibility}}">
|
||||
<Path Width="40" Height="40" Stretch="Uniform" HorizontalAlignment="Center" Fill="{DynamicResource TextMutedBrush}"
|
||||
Data="{StaticResource Icon.Server}" />
|
||||
<TextBlock Text="{Binding UnavailableText}" FontSize="13.5" Margin="0,14,0,16" HorizontalAlignment="Center"
|
||||
TextAlignment="Center" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Réessayer" HorizontalAlignment="Center"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RetryCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============================================ Connexion -->
|
||||
<StackPanel Visibility="{Binding IsSignIn, Converter={StaticResource BoolToVisibility}}" IsEnabled="{Binding IsIdle}">
|
||||
<TextBlock Text="Adresse e-mail" Style="{StaticResource AccountLabelStyle}" />
|
||||
<TextBox x:Name="SignInEmailBox" Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding EmailMaxLength}"
|
||||
ui:TextAssist.Placeholder="vous@exemple.fr" Margin="0,0,0,14"
|
||||
Text="{Binding Email, UpdateSourceTrigger=PropertyChanged}" KeyDown="OnSignInKeyDown" />
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource LinkButtonStyle}" Content="Mot de passe oublié ?" FontSize="12"
|
||||
Command="{Binding ToggleForgotCommand}" />
|
||||
<TextBlock Text="Mot de passe" Style="{StaticResource AccountLabelStyle}" Margin="0" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
<PasswordBox x:Name="SignInPasswordBox" Style="{StaticResource ModernPasswordBoxStyle}" MaxLength="{Binding PasswordMaxLength}"
|
||||
KeyDown="OnSignInKeyDown" />
|
||||
<Border CornerRadius="12" Padding="14,10" Margin="0,10,0,0" BorderThickness="1"
|
||||
Background="{DynamicResource ChipBrush}" BorderBrush="{DynamicResource BorderStrongBrush}"
|
||||
Visibility="{Binding ShowForgotHelp, Converter={StaticResource BoolToVisibility}}">
|
||||
<DockPanel>
|
||||
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" VerticalAlignment="Top" Margin="0,1,10,0"
|
||||
Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.Info}" />
|
||||
<TextBlock Text="{Binding ForgotHelpText}" FontSize="12" TextWrapping="Wrap" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<CheckBox Style="{StaticResource ToggleSwitchStyle}" Margin="0,14,0,0" IsChecked="{Binding RememberMe}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding RememberText}" FontSize="13" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Inutile de saisir votre mot de passe au prochain lancement. Décochez sur un PC partagé."
|
||||
FontSize="11.5" Margin="0,2,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</StackPanel>
|
||||
</CheckBox>
|
||||
|
||||
<Border Height="1" Background="{DynamicResource BorderBrush}" Margin="0,18,0,14" />
|
||||
<DockPanel Visibility="{Binding RegistrationOpen, Converter={StaticResource BoolToVisibility}}">
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Créer un compte"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Plus}" Command="{Binding ShowRegisterCommand}" />
|
||||
<TextBlock Text="Pas encore de compte ? C'est gratuit, et jamais obligatoire pour jouer." FontSize="12.5"
|
||||
VerticalAlignment="Center" TextWrapping="Wrap" Margin="0,0,12,0"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</DockPanel>
|
||||
<TextBlock Text="La création de compte est momentanément fermée." FontSize="12.5" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMutedBrush}"
|
||||
Visibility="{Binding RegistrationClosed, Converter={StaticResource BoolToVisibility}}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============================================ Création de compte -->
|
||||
<StackPanel Visibility="{Binding IsRegister, Converter={StaticResource BoolToVisibility}}" IsEnabled="{Binding IsIdle}">
|
||||
<TextBlock Text="Adresse e-mail" Style="{StaticResource AccountLabelStyle}" />
|
||||
<TextBox x:Name="RegisterEmailBox" Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding EmailMaxLength}"
|
||||
ui:TextAssist.Placeholder="vous@exemple.fr" Margin="0,0,0,14"
|
||||
Text="{Binding Email, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<TextBlock Text="Mot de passe" Style="{StaticResource AccountLabelStyle}" />
|
||||
<PasswordBox x:Name="RegisterPasswordBox" Style="{StaticResource ModernPasswordBoxStyle}"
|
||||
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
|
||||
<TextBlock Text="Confirmation du mot de passe" Style="{StaticResource AccountLabelStyle}" Margin="0,14,0,6" />
|
||||
<PasswordBox x:Name="RegisterConfirmBox" Style="{StaticResource ModernPasswordBoxStyle}"
|
||||
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
|
||||
<ContentControl Content="{Binding NewPassword}" ContentTemplate="{StaticResource PasswordRulesTemplate}" Focusable="False" />
|
||||
|
||||
<CheckBox Style="{StaticResource ToggleSwitchStyle}" Margin="0,16,0,0" IsChecked="{Binding RememberMe}">
|
||||
<TextBlock Text="{Binding RememberText}" FontSize="13" FontWeight="SemiBold" />
|
||||
</CheckBox>
|
||||
<CheckBox Style="{StaticResource FeedbackCheckBoxStyle}" Margin="0,12,0,0" IsChecked="{Binding AcceptTerms}">
|
||||
<TextBlock Text="{Binding TermsText}" FontSize="12.5" TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
<DockPanel Margin="0,14,0,0">
|
||||
<Path DockPanel.Dock="Left" Width="14" Height="14" Stretch="Uniform" VerticalAlignment="Top" Margin="0,1,0,0"
|
||||
Fill="{DynamicResource TextMutedBrush}" Data="{StaticResource Icon.ShieldCheck}" />
|
||||
<TextBlock Text="{Binding PrivacyText}" FontSize="11.5" Margin="8,0,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============================================ Nouveau mot de passe exigé -->
|
||||
<StackPanel Visibility="{Binding IsPasswordChange, Converter={StaticResource BoolToVisibility}}" IsEnabled="{Binding IsIdle}">
|
||||
<TextBlock Text="{Binding PasswordChangeText}" FontSize="13" Margin="0,0,0,16" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
<TextBlock Text="Nouveau mot de passe" Style="{StaticResource AccountLabelStyle}" />
|
||||
<PasswordBox x:Name="RequiredNewBox" Style="{StaticResource ModernPasswordBoxStyle}"
|
||||
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
|
||||
<TextBlock Text="Confirmation" Style="{StaticResource AccountLabelStyle}" Margin="0,14,0,6" />
|
||||
<PasswordBox x:Name="RequiredConfirmBox" Style="{StaticResource ModernPasswordBoxStyle}"
|
||||
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
|
||||
<ContentControl Content="{Binding NewPassword}" ContentTemplate="{StaticResource PasswordRulesTemplate}" Focusable="False" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============================================ Mon compte -->
|
||||
<StackPanel Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}">
|
||||
|
||||
<!-- ··· Profil -->
|
||||
<StackPanel Visibility="{Binding IsProfileSection, Converter={StaticResource BoolToVisibility}}">
|
||||
<Border CornerRadius="12" Padding="14,10" Margin="0,0,0,12" BorderThickness="1" Background="#26FFB547"
|
||||
BorderBrush="{DynamicResource WarningBrush}"
|
||||
Visibility="{Binding NewDevicesSuspended, Converter={StaticResource BoolToVisibility}}">
|
||||
<DockPanel>
|
||||
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Top" Margin="0,1,10,0"
|
||||
Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.ShieldAccount}" />
|
||||
<TextBlock Text="{Binding SuspendedText}" FontSize="12.5" TextWrapping="Wrap" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource AccountCardStyle}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Text="Adresse e-mail" Style="{StaticResource AdminLabelStyle}" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding EmailMasked}" Style="{StaticResource AdminValueStyle}" />
|
||||
<TextBlock Grid.Row="1" Text="Référence" Style="{StaticResource AdminLabelStyle}" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Margin="0,0,0,8">
|
||||
<TextBlock Text="{Binding Reference}" FontSize="12.5" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||
FontFamily="Cascadia Mono, Consolas, Courier New" />
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="28" Height="28" Padding="7" Margin="8,0,0,0"
|
||||
ToolTip="Copier la référence (à communiquer au support)"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}" Command="{Binding CopyReferenceCommand}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Row="2" Text="Compte créé le" Style="{StaticResource AdminLabelStyle}" />
|
||||
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding CreatedText}" Style="{StaticResource AdminValueStyle}" />
|
||||
<TextBlock Grid.Row="3" Text="Dernière connexion" Style="{StaticResource AdminLabelStyle}" Margin="0,0,16,0" />
|
||||
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding LastLoginText}" Style="{StaticResource AdminValueStyle}" Margin="0" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Mot de passe -->
|
||||
<Border Style="{StaticResource AccountCardStyle}">
|
||||
<StackPanel>
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Changer"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.KeyVariant}" Command="{Binding ShowChangePasswordCommand}"
|
||||
Visibility="{Binding IsChangingPassword, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Text="Mot de passe" FontSize="13.5" FontWeight="SemiBold" />
|
||||
<TextBlock FontSize="12" Margin="0,2,0,0" Foreground="{DynamicResource TextMutedBrush}"
|
||||
Text="{Binding PasswordChangedText, StringFormat='Modifié le {0}'}" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
<StackPanel Margin="0,14,0,0" IsEnabled="{Binding IsIdle}"
|
||||
Visibility="{Binding IsChangingPassword, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Mot de passe actuel" Style="{StaticResource AccountLabelStyle}" />
|
||||
<PasswordBox x:Name="CurrentPasswordBox" Style="{StaticResource ModernPasswordBoxStyle}"
|
||||
MaxLength="{Binding PasswordMaxLength}" />
|
||||
<TextBlock Text="Nouveau mot de passe" Style="{StaticResource AccountLabelStyle}" Margin="0,14,0,6" />
|
||||
<PasswordBox x:Name="ChangeNewBox" Style="{StaticResource ModernPasswordBoxStyle}"
|
||||
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
|
||||
<TextBlock Text="Confirmation" Style="{StaticResource AccountLabelStyle}" Margin="0,14,0,6" />
|
||||
<PasswordBox x:Name="ChangeConfirmBox" Style="{StaticResource ModernPasswordBoxStyle}"
|
||||
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
|
||||
<ContentControl Content="{Binding NewPassword}" ContentTemplate="{StaticResource PasswordRulesTemplate}"
|
||||
Focusable="False" />
|
||||
<TextBlock Text="Vos autres appareils seront déconnectés." FontSize="11.5" Margin="0,10,0,0"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Annuler" Margin="0,0,10,0"
|
||||
Command="{Binding CancelChangePasswordCommand}" />
|
||||
<Button Style="{StaticResource AccentButtonStyle}" Content="Enregistrer"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Check}" Click="OnChangePasswordClick" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Double authentification (étape suivante) -->
|
||||
<Border Style="{StaticResource AccountCardStyle}">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Right" CornerRadius="9" Padding="10,3" VerticalAlignment="Center"
|
||||
Background="{DynamicResource ChipBrush}">
|
||||
<TextBlock Text="Bientôt" FontSize="11.5" FontWeight="SemiBold" Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</Border>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Text="Double authentification" FontSize="13.5" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Un code à usage unique (application d'authentification) en plus du mot de passe."
|
||||
FontSize="12" Margin="0,2,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ··· Appareils -->
|
||||
<StackPanel Visibility="{Binding IsDevicesSection, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Appareils sur lesquels votre compte est connecté. Déconnectez ceux que vous ne reconnaissez pas, puis changez de mot de passe."
|
||||
FontSize="12.5" Margin="0,0,0,12" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
<ItemsControl ItemsSource="{Binding Devices}" Focusable="False" IsEnabled="{Binding IsIdle}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Style="{StaticResource AccountCardStyle}" Padding="14,12">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Left" Width="38" Height="38" CornerRadius="11" Margin="0,0,14,0"
|
||||
VerticalAlignment="Center" Background="{DynamicResource BadgeBrush}">
|
||||
<Path Width="18" Height="18" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
|
||||
Data="{StaticResource Icon.Laptop}" />
|
||||
</Border>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Déconnecter"
|
||||
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Logout}"
|
||||
Command="{Binding RemoveCommand}"
|
||||
Visibility="{Binding CanRemove, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Border DockPanel.Dock="Right" CornerRadius="9" Padding="10,3" VerticalAlignment="Center" Margin="10,0,0,0"
|
||||
Background="{DynamicResource AccentGradientBrush}"
|
||||
Visibility="{Binding IsCurrent, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Cet appareil" FontSize="11.5" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource OnAccentBrush}" />
|
||||
</Border>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Label}" FontSize="13.5" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding Detail}" FontSize="12" Margin="0,2,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Style="{StaticResource LinkButtonStyle}" HorizontalAlignment="Left" Margin="2,4,0,4" FontSize="12.5"
|
||||
Foreground="{DynamicResource DangerBrush}" Content="Déconnecter tous mes appareils (celui-ci compris)"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.LinkOff}" Command="{Binding SignOutAllCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- ··· Activité -->
|
||||
<StackPanel Visibility="{Binding IsActivitySection, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Dernières connexions et actions sur votre compte. Adresses IP masquées ; un essai de mot de passe que vous ne reconnaissez pas ? Changez de mot de passe."
|
||||
FontSize="12.5" Margin="0,0,0,12" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
<StackPanel Orientation="Horizontal" Margin="2,4,0,14"
|
||||
Visibility="{Binding IsActivityLoading, Converter={StaticResource BoolToVisibility}}">
|
||||
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Width="90" Height="4" IsIndeterminate="True"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="Chargement de l'activité…" FontSize="12.5" Margin="12,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="Aucune activité enregistrée." FontSize="12.5" Foreground="{DynamicResource TextMutedBrush}"
|
||||
Visibility="{Binding IsActivityEmpty, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Border Style="{StaticResource AccountCardStyle}" Padding="6,4"
|
||||
Visibility="{Binding IsActivityLoading, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}">
|
||||
<ItemsControl ItemsSource="{Binding Activity}" Focusable="False">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DockPanel Margin="8,8">
|
||||
<Grid DockPanel.Dock="Left" Width="16" Height="16" Margin="0,1,12,0" VerticalAlignment="Top">
|
||||
<Path Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.CheckCircle}"
|
||||
Visibility="{Binding IsWarning, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
|
||||
<Path Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
|
||||
Visibility="{Binding IsWarning, Converter={StaticResource BoolToVisibility}}" />
|
||||
</Grid>
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Title}" FontSize="13" TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding Detail}" FontSize="11.5" Margin="0,2,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ··· Mes données -->
|
||||
<StackPanel Visibility="{Binding IsDataSection, Converter={StaticResource BoolToVisibility}}" IsEnabled="{Binding IsIdle}">
|
||||
<Border Style="{StaticResource AccountCardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Exporter mes données" FontSize="13.5" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding ExportText}" FontSize="12" Margin="0,4,0,12" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
<TextBlock Text="Mot de passe" Style="{StaticResource AccountLabelStyle}" />
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource SecondaryButtonStyle}" Content="Exporter…" Margin="10,0,0,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Download}" Click="OnExportClick" />
|
||||
<PasswordBox x:Name="ExportPasswordBox" Style="{StaticResource ModernPasswordBoxStyle}"
|
||||
MaxLength="{Binding PasswordMaxLength}" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource AccountCardStyle}" BorderBrush="{DynamicResource DangerBrush}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Supprimer mon compte" FontSize="13.5" FontWeight="SemiBold" Foreground="{DynamicResource DangerBrush}" />
|
||||
<TextBlock Text="{Binding DeleteText}" FontSize="12" Margin="0,4,0,12" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
<CheckBox Style="{StaticResource FeedbackCheckBoxStyle}" Margin="0,0,0,12" IsChecked="{Binding DeleteConfirmed}">
|
||||
<TextBlock Text="Je comprends que la suppression est définitive." FontSize="12.5" TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
<TextBlock Text="Mot de passe" Style="{StaticResource AccountLabelStyle}" />
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource DangerButtonStyle}" Content="Supprimer" Margin="10,0,0,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.AccountRemove}" Click="OnDeleteClick" />
|
||||
<PasswordBox x:Name="DeletePasswordBox" Style="{StaticResource ModernPasswordBoxStyle}"
|
||||
MaxLength="{Binding PasswordMaxLength}" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ================================================================ Boutons -->
|
||||
<DockPanel Margin="0,16,14,0">
|
||||
<Button DockPanel.Dock="Left" Style="{StaticResource LinkButtonStyle}" Content="J'ai déjà un compte" FontSize="12.5"
|
||||
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.ArrowLeft}" Command="{Binding ShowSignInCommand}"
|
||||
Visibility="{Binding IsRegister, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Button DockPanel.Dock="Left" Style="{StaticResource LinkButtonStyle}" Content="Se déconnecter" FontSize="12.5"
|
||||
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Logout}" Command="{Binding SignOutCommand}"
|
||||
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Button DockPanel.Dock="Left" Style="{StaticResource LinkButtonStyle}" Content="Actualiser" FontSize="12.5" Margin="16,0,0,0"
|
||||
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}"
|
||||
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}" />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<!-- Action en cours (preuve anti-robot, signature, serveur) -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,14,0" VerticalAlignment="Center"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}">
|
||||
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Width="70" Height="4" IsIndeterminate="{Binding IsBusy}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Fermer" Command="{Binding CloseCommand}"
|
||||
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Annuler" Command="{Binding CloseCommand}"
|
||||
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
|
||||
<Button Style="{StaticResource AccentButtonStyle}" Content="Se connecter" Margin="10,0,0,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Login}" Click="OnSignInClick" IsEnabled="{Binding IsIdle}"
|
||||
Visibility="{Binding IsSignIn, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Button Style="{StaticResource AccentButtonStyle}" Content="Créer mon compte" Margin="10,0,0,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ArrowRight}" Click="OnRegisterClick" IsEnabled="{Binding IsIdle}"
|
||||
Visibility="{Binding IsRegister, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Button Style="{StaticResource AccentButtonStyle}" Content="Enregistrer et me connecter" Margin="10,0,0,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Check}" Click="OnRequiredChangeClick" IsEnabled="{Binding IsIdle}"
|
||||
Visibility="{Binding IsPasswordChange, Converter={StaticResource BoolToVisibility}}" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
<TextBlock Text="{Binding BusyText}" FontSize="11.5" Margin="0,8,14,0" HorizontalAlignment="Right" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMutedBrush}"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Threading;
|
||||
using GameLauncher.Modules.Accounts;
|
||||
|
||||
namespace GameLauncher.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Fenêtre « Compte » (voir <see cref="AccountWindowViewModel"/>). Les mots de passe restent dans les PasswordBox : lus au
|
||||
/// moment de l'action, passés au ViewModel, puis effacés (<see cref="AccountWindowViewModel.ClearPasswordsRequested"/>).
|
||||
/// Fermer la fenêtre abandonne une action en cours.
|
||||
/// </summary>
|
||||
public partial class AccountWindow : Window
|
||||
{
|
||||
/// <summary>Hauteur de base de la zone du contenu : au-delà, il défile au lieu d'agrandir la fenêtre.</summary>
|
||||
private const double BodyMaxHeight = 520;
|
||||
|
||||
private readonly AccountWindowViewModel _viewModel;
|
||||
private bool _shown;
|
||||
|
||||
public AccountWindow(AccountWindowViewModel viewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
_viewModel = viewModel;
|
||||
DataContext = viewModel;
|
||||
viewModel.CloseRequested += Close;
|
||||
viewModel.ClearPasswordsRequested += ClearPasswords;
|
||||
viewModel.PropertyChanged += OnViewModelChanged;
|
||||
SourceInitialized += (_, _) => FitToScreen();
|
||||
Loaded += async (_, _) => await viewModel.InitializeAsync();
|
||||
ContentRendered += (_, _) => _shown = true;
|
||||
SizeChanged += (_, e) =>
|
||||
{
|
||||
if (_shown && e.HeightChanged) KeepCentered(e.PreviousSize.Height);
|
||||
};
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
ClearPasswords();
|
||||
viewModel.CloseRequested -= Close;
|
||||
viewModel.ClearPasswordsRequested -= ClearPasswords;
|
||||
viewModel.PropertyChanged -= OnViewModelChanged;
|
||||
viewModel.Dispose();
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Actions (mots de passe lus ici, jamais liés)
|
||||
|
||||
private async void OnSignInClick(object sender, RoutedEventArgs e) => await _viewModel.SignInAsync(SignInPasswordBox.Password);
|
||||
|
||||
private async void OnSignInKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.Enter) return;
|
||||
e.Handled = true;
|
||||
if (string.IsNullOrEmpty(SignInPasswordBox.Password))
|
||||
{
|
||||
SignInPasswordBox.Focus();
|
||||
return;
|
||||
}
|
||||
await _viewModel.SignInAsync(SignInPasswordBox.Password);
|
||||
}
|
||||
|
||||
private async void OnRegisterClick(object sender, RoutedEventArgs e) =>
|
||||
await _viewModel.RegisterAsync(RegisterPasswordBox.Password, RegisterConfirmBox.Password);
|
||||
|
||||
private async void OnRequiredChangeClick(object sender, RoutedEventArgs e) =>
|
||||
await _viewModel.CompleteRequiredPasswordChangeAsync(RequiredNewBox.Password, RequiredConfirmBox.Password);
|
||||
|
||||
private async void OnChangePasswordClick(object sender, RoutedEventArgs e) =>
|
||||
await _viewModel.ChangePasswordAsync(CurrentPasswordBox.Password, ChangeNewBox.Password, ChangeConfirmBox.Password);
|
||||
|
||||
private async void OnExportClick(object sender, RoutedEventArgs e) => await _viewModel.ExportAsync(ExportPasswordBox.Password);
|
||||
|
||||
private async void OnDeleteClick(object sender, RoutedEventArgs e) => await _viewModel.DeleteAccountAsync(DeletePasswordBox.Password);
|
||||
|
||||
/// <summary>Contrôle en direct du nouveau mot de passe de l'écran affiché (création, exigé, changement).</summary>
|
||||
private void OnNewPasswordChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var (password, confirmation) = _viewModel.Step switch
|
||||
{
|
||||
AccountStep.Register => (RegisterPasswordBox, RegisterConfirmBox),
|
||||
AccountStep.PasswordChange => (RequiredNewBox, RequiredConfirmBox),
|
||||
_ => (ChangeNewBox, ChangeConfirmBox)
|
||||
};
|
||||
_viewModel.UpdateNewPassword(password.Password, confirmation.Password);
|
||||
}
|
||||
|
||||
private void ClearPasswords()
|
||||
{
|
||||
foreach (var box in new[]
|
||||
{
|
||||
SignInPasswordBox, RegisterPasswordBox, RegisterConfirmBox, RequiredNewBox, RequiredConfirmBox, CurrentPasswordBox,
|
||||
ChangeNewBox, ChangeConfirmBox, ExportPasswordBox, DeletePasswordBox
|
||||
})
|
||||
box.Clear();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Affichage
|
||||
|
||||
private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is not (nameof(AccountWindowViewModel.Step) or nameof(AccountWindowViewModel.Section))) return;
|
||||
BodyScroll.ScrollToTop();
|
||||
// Curseur dans le premier champ de l'écran affiché.
|
||||
Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
Control? target = _viewModel.Step switch
|
||||
{
|
||||
AccountStep.SignIn => string.IsNullOrEmpty(_viewModel.Email) ? SignInEmailBox : SignInPasswordBox,
|
||||
AccountStep.Register => string.IsNullOrEmpty(_viewModel.Email) ? RegisterEmailBox : RegisterPasswordBox,
|
||||
AccountStep.PasswordChange => RequiredNewBox,
|
||||
_ => null
|
||||
};
|
||||
target?.Focus();
|
||||
}, DispatcherPriority.Input);
|
||||
}
|
||||
|
||||
/// <summary>Taille de base raisonnable, jamais plus grande que la zone utile de l'écran : au-delà, le contenu défile.</summary>
|
||||
private void FitToScreen()
|
||||
{
|
||||
const double chrome = 250; // en-tête, onglets, boutons, marges et ombre
|
||||
var available = Controls.WindowModeAssist.WorkArea(Owner ?? (System.Windows.Media.Visual)this).Height - chrome;
|
||||
BodyScroll.MaxHeight = Math.Max(240, Math.Min(BodyMaxHeight, available));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// La hauteur change avec l'écran : la fenêtre grandit ou rétrécit autour de son centre et reste dans la zone utile de
|
||||
/// l'écran (même règle que la fenêtre « Aide »). Une fenêtre placée hors des écrans (tools\UiSnapshot) n'est pas déplacée.
|
||||
/// </summary>
|
||||
private void KeepCentered(double previousHeight)
|
||||
{
|
||||
if (previousHeight <= 0 || double.IsNaN(Top) || double.IsNaN(Left) || WindowState != WindowState.Normal) return;
|
||||
var work = Controls.WindowModeAssist.WorkArea(this);
|
||||
if (work.Width < 1 || work.Height < 1 || !work.IntersectsWith(new Rect(Left, Top, ActualWidth, previousHeight))) return;
|
||||
var top = Top + (previousHeight - ActualHeight) / 2;
|
||||
Top = Math.Max(work.Top, Math.Min(top, work.Bottom - ActualHeight));
|
||||
}
|
||||
|
||||
/// <summary>Échap ferme la fenêtre, sauf pendant une action en cours.</summary>
|
||||
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.Escape || _viewModel.IsBusy) return;
|
||||
Close();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnDragMove(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.ButtonState == MouseButtonState.Pressed && e.OriginalSource is not (TextBox or PasswordBox)) DragMove();
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,16 @@
|
||||
<TextBlock Text="{Binding ToVersion}" FontSize="13" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Mise à jour prioritaire (correctif critique) : installée même si l'option automatique est désactivée -->
|
||||
<Border Style="{StaticResource VersionChipStyle}" Margin="10,0,0,0" BorderBrush="{DynamicResource WarningBrush}"
|
||||
ToolTip="Correctif important : installé même si la mise à jour automatique du launcher est désactivée. Votre réglage ne change pas."
|
||||
Visibility="{Binding IsPriority, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Data="{StaticResource Icon.Alert}" Fill="{DynamicResource WarningBrush}"
|
||||
Width="14" Height="14" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,7,0" />
|
||||
<TextBlock Text="Prioritaire" FontSize="12.5" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Nouveautés : même présentation que les notes du jeu (Themes/Changelog.xaml) -->
|
||||
|
||||
@@ -271,14 +271,32 @@
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<!-- Aide : avis, demande d'évolution, bug… pour le jeu sélectionné (absent sans « apiUrl » dans launcher.json) -->
|
||||
<!-- Aide : avis, demande d'évolution, bug… pour le jeu sélectionné (absent sans « apiUrl » dans launcher.json,
|
||||
masqué pendant une maintenance) -->
|
||||
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Aide : avis, idée, bug…" Margin="0,0,4,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.MessageQuestion}" Click="OnHelpClick"
|
||||
Visibility="{Binding DataContext.IsHelpAvailable, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
|
||||
Visibility="{Binding DataContext.ShowHelpButton, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
|
||||
<!-- Compte joueur : connexion, création, « Mon compte » (absent sans « playerAccounts » et « apiUrl » dans le
|
||||
launcher.json intégré, masqué pendant une maintenance). Pastille : verte = connecté, orange = reconnexion
|
||||
ou serveur injoignable. -->
|
||||
<Grid Margin="0,0,4,0"
|
||||
Visibility="{Binding DataContext.ShowAccountButton, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}">
|
||||
<Button x:Name="AccountButton" Style="{StaticResource IconButtonStyle}" Click="OnAccountClick"
|
||||
ToolTip="{Binding DataContext.AccountToolTip, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.AccountCircle}" />
|
||||
<Ellipse Width="9" Height="9" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,6,6,0"
|
||||
IsHitTestVisible="False" Fill="{DynamicResource SuccessBrush}" Stroke="{DynamicResource SurfaceSolidBrush}"
|
||||
StrokeThickness="1.5"
|
||||
Visibility="{Binding DataContext.IsAccountSignedIn, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Ellipse Width="9" Height="9" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,6,6,0"
|
||||
IsHitTestVisible="False" Fill="{DynamicResource WarningBrush}" Stroke="{DynamicResource SurfaceSolidBrush}"
|
||||
StrokeThickness="1.5"
|
||||
Visibility="{Binding DataContext.IsAccountPending, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
|
||||
</Grid>
|
||||
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Site officiel"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Web}"
|
||||
Command="{Binding OpenWebsiteCommand}"
|
||||
Visibility="{Binding HasWebsite, Converter={StaticResource BoolToVisibility}}" />
|
||||
Visibility="{Binding ShowWebsite, Converter={StaticResource BoolToVisibility}}" />
|
||||
<!-- Paramètres du launcher : communs à tous les jeux -->
|
||||
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Paramètres du launcher" Margin="4,0,0,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Cog}"
|
||||
@@ -295,8 +313,9 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- ===================================================== Contenu -->
|
||||
<Grid Grid.Row="1" Margin="58,6,34,20">
|
||||
<!-- ===================================================== Contenu (masqué pendant une maintenance) -->
|
||||
<Grid Grid.Row="1" Margin="58,6,34,20"
|
||||
Visibility="{Binding IsMaintenance, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<!-- Notes de version : 370 en mode fenêtré, plus larges en plein écran (jusqu'à 560) -->
|
||||
@@ -489,6 +508,87 @@
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- ===================================================== Mode maintenance (publié depuis le menu d'administration) -->
|
||||
<!--
|
||||
Carte au centre de la fenêtre, à la place de la présentation et des notes de version. La palette rouge / orange
|
||||
(Themes/Maintenance.xaml) est ajoutée aux ressources de la fenêtre par MainWindow.xaml.cs pendant la maintenance.
|
||||
Textes : ceux du launcher (titre, sous-titre) et, au choix, la précision publiée par l'administrateur (texte brut).
|
||||
-->
|
||||
<Grid Grid.Row="1" Margin="58,6,34,20"
|
||||
Visibility="{Binding IsMaintenance, Converter={StaticResource BoolToVisibility}}">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Focusable="False">
|
||||
<Grid HorizontalAlignment="Center" VerticalAlignment="Center" MaxWidth="660" Margin="0,8">
|
||||
<!-- Halo de la carte (à part : l'ombre ne s'applique pas aux textes) -->
|
||||
<Border CornerRadius="28" Background="{DynamicResource MaintenanceCardBrush}">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="44" ShadowDepth="0" Opacity="0.5" Color="#FF3B1F" />
|
||||
</Border.Effect>
|
||||
</Border>
|
||||
<Border CornerRadius="28" Padding="44,26,44,34" BorderThickness="1"
|
||||
BorderBrush="{DynamicResource MaintenanceCardBorderBrush}">
|
||||
<StackPanel>
|
||||
<!-- Bandes de chantier -->
|
||||
<Border Width="132" Height="8" CornerRadius="4" Margin="0,0,0,26" HorizontalAlignment="Center"
|
||||
Background="{DynamicResource MaintenanceStripesBrush}" />
|
||||
|
||||
<Grid Width="92" Height="92" HorizontalAlignment="Center">
|
||||
<Border CornerRadius="30" Background="{DynamicResource AccentGradientBrush}" Opacity="0.7">
|
||||
<Border.Effect>
|
||||
<BlurEffect Radius="26" />
|
||||
</Border.Effect>
|
||||
</Border>
|
||||
<Border CornerRadius="30" Background="{DynamicResource AccentGradientBrush}">
|
||||
<Path Data="{StaticResource Icon.TrafficCone}" Fill="{DynamicResource OnAccentBrush}"
|
||||
Width="46" Height="46" Stretch="Uniform" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="{Binding DataContext.MaintenanceTitle, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
Margin="0,26,0,0" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
|
||||
FontFamily="{DynamicResource HeroTitleFont}" FontSize="38" FontWeight="{DynamicResource HeroTitleWeight}"
|
||||
FontStretch="{DynamicResource HeroTitleStretch}" />
|
||||
<TextBlock Text="{Binding DataContext.MaintenanceSubtitle, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
Margin="0,8,0,0" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
|
||||
FontSize="18" Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
|
||||
<Border Width="72" Height="3" CornerRadius="1.5" Margin="0,22,0,0" HorizontalAlignment="Center"
|
||||
Background="{DynamicResource AccentGradientBrush}" />
|
||||
|
||||
<!-- Précision publiée par l'administrateur : texte brut, sélectionnable, jamais interprété -->
|
||||
<TextBox Text="{Binding DataContext.MaintenanceMessage, RelativeSource={RelativeSource AncestorType=Window}, Mode=OneWay}"
|
||||
Margin="0,20,0,0" IsReadOnly="True" IsTabStop="False" BorderThickness="0" Background="Transparent"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" CaretBrush="Transparent" Cursor="Arrow"
|
||||
FontSize="15" TextAlignment="Center" TextWrapping="Wrap" HorizontalAlignment="Stretch"
|
||||
Visibility="{Binding DataContext.MaintenanceMessage, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource StringToVisibility}}">
|
||||
<TextBox.Template>
|
||||
<ControlTemplate TargetType="TextBox">
|
||||
<ScrollViewer x:Name="PART_ContentHost" Focusable="False" VerticalScrollBarVisibility="Disabled"
|
||||
HorizontalScrollBarVisibility="Disabled" />
|
||||
</ControlTemplate>
|
||||
</TextBox.Template>
|
||||
</TextBox>
|
||||
|
||||
<TextBlock Text="{Binding DataContext.MaintenanceSinceText, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
Margin="0,18,0,0" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
|
||||
FontSize="12.5" Foreground="{DynamicResource TextMutedBrush}"
|
||||
Visibility="{Binding DataContext.MaintenanceSinceText, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource StringToVisibility}}" />
|
||||
|
||||
<Border HorizontalAlignment="Center" Margin="0,18,0,0" CornerRadius="14" Padding="14,8"
|
||||
Background="{DynamicResource BadgeBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
|
||||
<DockPanel>
|
||||
<Path DockPanel.Dock="Left" Data="{StaticResource Icon.Lock}" Fill="{DynamicResource Accent2Brush}"
|
||||
Width="13" Height="13" Stretch="Uniform" VerticalAlignment="Center" />
|
||||
<TextBlock Margin="9,0,0,0" FontSize="12.5" TextWrapping="Wrap" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}"
|
||||
Text="Installation, mises à jour et lancement suspendus. La désinstallation reste possible (menu « … »)." />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<!-- ===================================================== Barre d'action -->
|
||||
<Border Grid.Row="2" Margin="24,0,24,24" Padding="28,20" CornerRadius="22"
|
||||
Background="{DynamicResource ActionBarBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
|
||||
@@ -500,7 +600,7 @@
|
||||
|
||||
<StackPanel VerticalAlignment="Center" Margin="0,0,32,0">
|
||||
<Grid>
|
||||
<TextBlock Text="{Binding StatusTitle}" FontSize="16" FontWeight="SemiBold"
|
||||
<TextBlock Text="{Binding DisplayStatusTitle}" FontSize="16" FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis" Margin="0,0,90,0" />
|
||||
<TextBlock Text="{Binding StepText}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
FontSize="12" Foreground="{DynamicResource TextMutedBrush}" />
|
||||
@@ -509,9 +609,9 @@
|
||||
Value="{Binding ProgressValue, Mode=OneWay}"
|
||||
IsIndeterminate="{Binding IsProgressIndeterminate, Mode=OneWay}"
|
||||
Visibility="{Binding ShowProgress, Converter={StaticResource BoolToVisibility}}" />
|
||||
<TextBlock Text="{Binding StatusDetail}" Margin="0,8,0,0" FontSize="12.5"
|
||||
<TextBlock Text="{Binding DisplayStatusDetail}" Margin="0,8,0,0" FontSize="12.5"
|
||||
TextTrimming="CharacterEllipsis" Foreground="{DynamicResource TextSecondaryBrush}"
|
||||
Visibility="{Binding StatusDetail, Converter={StaticResource StringToVisibility}}" />
|
||||
Visibility="{Binding DisplayStatusDetail, Converter={StaticResource StringToVisibility}}" />
|
||||
<StackPanel Orientation="Horizontal" Margin="0,10,0,0"
|
||||
Visibility="{Binding ShowLocateHint, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Le jeu est déjà sur votre PC ?" Margin="0,0,10,0" FontSize="12.5"
|
||||
@@ -533,6 +633,12 @@
|
||||
<DataTrigger Binding="{Binding State}" Value="Running">
|
||||
<Setter Property="Background" Value="{DynamicResource NeutralGradientBrush}" />
|
||||
</DataTrigger>
|
||||
<!-- Maintenance : bouton « MAINTENANCE » désactivé, aux couleurs de la maintenance -->
|
||||
<DataTrigger Binding="{Binding IsMaintenance}" Value="True">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentGradientBrush}" />
|
||||
<Setter Property="ToolTip" Value="Maintenance en cours : merci de revenir plus tard." />
|
||||
<Setter Property="ToolTipService.ShowOnDisabled" Value="True" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Button.Style>
|
||||
|
||||
@@ -22,10 +22,52 @@ public partial class MainWindow : Window, IThemeTransition
|
||||
|
||||
private readonly Queue<DateTime> _logoClicks = new();
|
||||
|
||||
/// <summary>Palette rouge / orange du mode maintenance (Themes/Maintenance.xaml), ajoutée aux ressources de la fenêtre.</summary>
|
||||
private static readonly Uri MaintenancePaletteUri = new("pack://application:,,,/GameLauncher;component/Themes/Maintenance.xaml");
|
||||
private ResourceDictionary? _maintenancePalette;
|
||||
private LauncherViewModel? _observed;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
PreviewKeyDown += OnPreviewKeyDown;
|
||||
DataContextChanged += (_, _) => ObserveLauncher(DataContext as LauncherViewModel);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Mode maintenance
|
||||
|
||||
private void ObserveLauncher(LauncherViewModel? launcher)
|
||||
{
|
||||
if (_observed is not null) _observed.PropertyChanged -= OnLauncherPropertyChanged;
|
||||
_observed = launcher;
|
||||
if (launcher is null) return;
|
||||
launcher.PropertyChanged += OnLauncherPropertyChanged;
|
||||
ApplyMaintenancePalette(launcher.IsMaintenance);
|
||||
}
|
||||
|
||||
private void OnLauncherPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(LauncherViewModel.IsMaintenance) && sender is LauncherViewModel launcher)
|
||||
ApplyMaintenancePalette(launcher.IsMaintenance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maintenance : la palette rouge / orange passe devant celle du thème du jeu, pour cette fenêtre seulement (les
|
||||
/// ressources de Root sont trouvées avant celles de l'application). Le fondu enchaîné est fait par LauncherViewModel
|
||||
/// (début et fin de la maintenance passent par <see cref="Run"/>, comme un changement de jeu).
|
||||
/// </summary>
|
||||
private void ApplyMaintenancePalette(bool active)
|
||||
{
|
||||
var merged = Root.Resources.MergedDictionaries;
|
||||
if (active)
|
||||
{
|
||||
_maintenancePalette ??= new ResourceDictionary { Source = MaintenancePaletteUri };
|
||||
if (!merged.Contains(_maintenancePalette)) merged.Add(_maintenancePalette);
|
||||
}
|
||||
else if (_maintenancePalette is not null)
|
||||
{
|
||||
merged.Remove(_maintenancePalette);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -42,6 +84,11 @@ public partial class MainWindow : Window, IThemeTransition
|
||||
if (sender is FrameworkElement { DataContext: GameViewModel game }) HelpRequested?.Invoke(game);
|
||||
}
|
||||
|
||||
/// <summary>Bouton « Compte » : connexion, création de compte, « Mon compte ».</summary>
|
||||
public event Action? AccountRequested;
|
||||
|
||||
private void OnAccountClick(object sender, RoutedEventArgs e) => AccountRequested?.Invoke();
|
||||
|
||||
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
// Avec Alt, WPF signale la touche dans SystemKey (Key.System).
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
// pour retirer le menu d'administration.
|
||||
"apiUrl": "https://launcher.lanisys.fr/api/",
|
||||
|
||||
// Comptes joueurs (bouton « Compte » : connexion, création de compte, « Mon compte ») avec la même API. Laisser à false
|
||||
// tant que les comptes ne sont pas ouverts sur le serveur (LauncherApi:Players:Enabled, étapes 8a à 8g). Comme apiUrl,
|
||||
// la version Release ne le lit QUE dans le launcher.json intégré à l'exécutable.
|
||||
"playerAccounts": false,
|
||||
|
||||
// Catalogue des jeux : informations modifiables depuis le menu d'administration (nom affiché,
|
||||
// accroche, présentation, liens, thème intégré, images, ordre…), lues au démarrage puis mises en
|
||||
// cache. Comme apiUrl, la version Release ne la lit que dans le launcher.json intégré à l'exe.
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Accounts;
|
||||
using GameLauncher.Tests.Admin;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
|
||||
namespace GameLauncher.Tests.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Fenêtre « Compte » sans interface (ViewModel réel, service réel, Launcher API en mémoire) : écrans, contrôles locaux avant
|
||||
/// tout envoi, messages, onglets de « Mon compte », export, suppression, service fermé.
|
||||
/// </summary>
|
||||
public class AccountWindowViewModelTests
|
||||
{
|
||||
private const string Email = "joueur@example.fr";
|
||||
private const string Password = PlayerTestServer.Password;
|
||||
|
||||
private static AccountWindowViewModel NewWindow(PlayerAccountService service, FakeDialogs dialogs) =>
|
||||
new(service, dialogs, ImmediateDispatcher.Instance, "LanisysGames");
|
||||
|
||||
[Fact]
|
||||
public async Task Registration_checks_everything_locally_before_creating_the_account()
|
||||
{
|
||||
await using var h = await AccountsHarness.StartAsync();
|
||||
using var service = h.NewService();
|
||||
var dialogs = new FakeDialogs();
|
||||
using var vm = NewWindow(service, dialogs);
|
||||
var clears = 0;
|
||||
vm.ClearPasswordsRequested += () => clears++;
|
||||
|
||||
await vm.InitializeAsync();
|
||||
Assert.Equal(AccountStep.SignIn, vm.Step);
|
||||
Assert.True(vm.RegistrationOpen);
|
||||
Assert.Contains("version 1", vm.TermsText);
|
||||
vm.ShowRegisterCommand.Execute(null);
|
||||
Assert.Equal(AccountStep.Register, vm.Step);
|
||||
Assert.Equal("Créer un compte", vm.Title);
|
||||
|
||||
vm.Email = "pas-une-adresse";
|
||||
await vm.RegisterAsync(Password, Password);
|
||||
Assert.Equal("Adresse e-mail invalide.", vm.ErrorMessage);
|
||||
|
||||
vm.Email = Email;
|
||||
vm.UpdateNewPassword("azerty123", "");
|
||||
Assert.True(vm.NewPassword.HasProblems);
|
||||
Assert.Equal("Refusé", vm.NewPassword.StrengthText);
|
||||
await vm.RegisterAsync("azerty123", "azerty123");
|
||||
Assert.True(vm.HasError);
|
||||
|
||||
vm.UpdateNewPassword(Password, "autre chose");
|
||||
Assert.True(vm.NewPassword.IsAccepted);
|
||||
Assert.True(vm.NewPassword.ShowConfirmationMismatch);
|
||||
await vm.RegisterAsync(Password, "autre chose");
|
||||
Assert.Equal("Les deux mots de passe ne sont pas identiques.", vm.ErrorMessage);
|
||||
|
||||
await vm.RegisterAsync(Password, Password);
|
||||
Assert.Contains("Acceptez les conditions", vm.ErrorMessage);
|
||||
Assert.Equal(0, h.Server.Store.CountAccounts()); // rien n'a été envoyé
|
||||
|
||||
vm.AcceptTerms = true;
|
||||
await vm.RegisterAsync(Password, Password);
|
||||
Assert.False(vm.HasError, vm.ErrorMessage);
|
||||
Assert.Equal(AccountStep.Account, vm.Step);
|
||||
Assert.Equal("Bienvenue ! Votre compte est créé.", vm.SuccessMessage);
|
||||
Assert.Equal("j***@e***.fr", vm.Subtitle);
|
||||
Assert.Matches("^PL-[0-9A-Z]{4}-[0-9A-Z]{4}$", vm.Reference);
|
||||
Assert.Single(vm.Devices);
|
||||
Assert.True(vm.Devices[0].IsCurrent);
|
||||
Assert.False(vm.Devices[0].CanRemove);
|
||||
Assert.True(clears > 0); // champs de mot de passe effacés après chaque action
|
||||
|
||||
vm.CopyReferenceCommand.Execute(null);
|
||||
Assert.Equal(vm.Reference, dialogs.Clipboard);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sign_in_errors_are_clear_and_the_account_tabs_load_on_demand()
|
||||
{
|
||||
await using var h = await AccountsHarness.StartAsync();
|
||||
using (var first = h.NewService()) await first.RegisterAsync(Email, Password, "1", remember: false);
|
||||
h.File.Delete();
|
||||
using var service = h.NewService();
|
||||
using var vm = NewWindow(service, new FakeDialogs());
|
||||
await vm.InitializeAsync();
|
||||
|
||||
vm.Email = Email;
|
||||
await vm.SignInAsync("");
|
||||
Assert.Equal("Saisissez votre mot de passe.", vm.ErrorMessage);
|
||||
await vm.SignInAsync("Mauvais-Mot-De-Passe-1");
|
||||
Assert.Equal("Adresse e-mail ou mot de passe incorrect.", vm.ErrorMessage);
|
||||
Assert.Equal(AccountStep.SignIn, vm.Step);
|
||||
|
||||
vm.ToggleForgotCommand.Execute(null);
|
||||
Assert.True(vm.ShowForgotHelp);
|
||||
Assert.Contains("mot de passe temporaire", vm.ForgotHelpText);
|
||||
|
||||
await vm.SignInAsync(Password);
|
||||
Assert.Equal(AccountStep.Account, vm.Step);
|
||||
Assert.True(vm.IsProfileSection);
|
||||
Assert.Empty(vm.Activity);
|
||||
|
||||
vm.IsActivitySection = true;
|
||||
await WaitUntil(() => !vm.IsActivityLoading && vm.Activity.Count > 0);
|
||||
Assert.Contains(vm.Activity, a => a.Title == "Mot de passe incorrect" && a.IsWarning);
|
||||
Assert.Contains(vm.Activity, a => a.Title == "Connexion" && a.Detail.EndsWith("cet appareil"));
|
||||
|
||||
await vm.SignOutCommand.ExecuteAsync();
|
||||
Assert.Equal(AccountStep.SignIn, vm.Step);
|
||||
Assert.Equal("Vous êtes déconnecté.", vm.SuccessMessage);
|
||||
Assert.Equal(PlayerAccountState.SignedOut, service.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Required_password_change_then_ordinary_password_change()
|
||||
{
|
||||
await using var h = await AccountsHarness.StartAsync();
|
||||
string reference;
|
||||
using (var first = h.NewService())
|
||||
{
|
||||
await first.RegisterAsync(Email, Password, "1", remember: false);
|
||||
reference = first.Account!.Reference;
|
||||
}
|
||||
h.Server.Store.SetMustChangePassword(h.Server.Store.FindByReference(reference)!.Id, true);
|
||||
using var service = h.NewService();
|
||||
using var vm = NewWindow(service, new FakeDialogs());
|
||||
await vm.InitializeAsync();
|
||||
vm.Email = Email;
|
||||
|
||||
await vm.SignInAsync(Password);
|
||||
Assert.Equal(AccountStep.PasswordChange, vm.Step);
|
||||
Assert.Equal("Nouveau mot de passe exigé", vm.Title);
|
||||
await vm.CompleteRequiredPasswordChangeAsync(Password, Password);
|
||||
Assert.Contains("différent", vm.ErrorMessage);
|
||||
await vm.CompleteRequiredPasswordChangeAsync(PlayerTestServer.OtherPassword, PlayerTestServer.OtherPassword);
|
||||
Assert.Equal(AccountStep.Account, vm.Step);
|
||||
Assert.Contains("Nouveau mot de passe enregistré", vm.SuccessMessage);
|
||||
|
||||
// Changement ordinaire depuis « Profil ».
|
||||
vm.ShowChangePasswordCommand.Execute(null);
|
||||
Assert.True(vm.IsChangingPassword);
|
||||
await vm.ChangePasswordAsync("Pas-Le-Bon-Mot-De-Passe", "Nouvelle-Phrase-De-Passe-42", "Nouvelle-Phrase-De-Passe-42");
|
||||
Assert.Equal("Mot de passe actuel incorrect.", vm.ErrorMessage);
|
||||
await vm.ChangePasswordAsync(PlayerTestServer.OtherPassword, "Nouvelle-Phrase-De-Passe-42", "Nouvelle-Phrase-De-Passe-42");
|
||||
Assert.Equal("Mot de passe modifié.", vm.SuccessMessage);
|
||||
Assert.False(vm.IsChangingPassword);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_writes_the_chosen_file_and_deletion_needs_two_confirmations()
|
||||
{
|
||||
await using var h = await AccountsHarness.StartAsync();
|
||||
using var service = h.NewService();
|
||||
await service.RegisterAsync(Email, Password, "1", remember: true);
|
||||
var dialogs = new FakeDialogs();
|
||||
using var vm = NewWindow(service, dialogs);
|
||||
await vm.InitializeAsync();
|
||||
Assert.Equal(AccountStep.Account, vm.Step);
|
||||
vm.IsDataSection = true;
|
||||
|
||||
// Export : emplacement refusé = rien n'est demandé au serveur.
|
||||
dialogs.NextSaveFile = null;
|
||||
await vm.ExportAsync(Password);
|
||||
Assert.False(vm.HasSuccess);
|
||||
dialogs.NextSaveFile = Path.Combine(h.DataDirectory, "export.json");
|
||||
await vm.ExportAsync(Password);
|
||||
Assert.Contains("export.json", vm.SuccessMessage);
|
||||
using (var json = JsonDocument.Parse(await File.ReadAllBytesAsync(dialogs.NextSaveFile)))
|
||||
Assert.Equal(PlayerDataExport.Format, json.RootElement.GetProperty("format").GetString());
|
||||
|
||||
// Suppression : case à cocher, puis confirmation, puis mot de passe vérifié par le serveur.
|
||||
await vm.DeleteAccountAsync(Password);
|
||||
Assert.Contains("Cochez la case", vm.ErrorMessage);
|
||||
vm.DeleteConfirmed = true;
|
||||
dialogs.ConfirmAnswer = false;
|
||||
await vm.DeleteAccountAsync(Password);
|
||||
Assert.Equal(PlayerAccountState.SignedIn, service.State);
|
||||
dialogs.ConfirmAnswer = true;
|
||||
await vm.DeleteAccountAsync(Password);
|
||||
Assert.Equal(AccountStep.SignIn, vm.Step);
|
||||
Assert.Equal("Votre compte a été supprimé définitivement.", vm.SuccessMessage);
|
||||
Assert.Equal(0, h.Server.Store.CountAccounts());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_lost_while_the_window_is_open_returns_to_sign_in()
|
||||
{
|
||||
await using var h = await AccountsHarness.StartAsync();
|
||||
using var service = h.NewService();
|
||||
await service.RegisterAsync(Email, Password, "1", remember: true);
|
||||
using var vm = NewWindow(service, new FakeDialogs());
|
||||
await vm.InitializeAsync();
|
||||
|
||||
// Déconnexion de tous les appareils depuis un autre PC, puis jeton d'accès échu.
|
||||
using var laptop = new TestDevice();
|
||||
var other = (await h.Server.LoginAsync(laptop, Email)).Session!;
|
||||
using (await h.Server.SendAsync(HttpMethod.Post, ApiRoutes.AccountLogout, other.AccessToken, new PlayerLogoutRequest { AllDevices = true })) { }
|
||||
h.Server.Api.Time.Advance(TimeSpan.FromMinutes(20));
|
||||
|
||||
await vm.RefreshCommand.ExecuteAsync();
|
||||
Assert.Equal(AccountStep.SignIn, vm.Step);
|
||||
Assert.Contains("reconnectez-vous", vm.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Closed_service_shows_a_friendly_message()
|
||||
{
|
||||
await using var h = await AccountsHarness.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Players:Enabled"] = "false" });
|
||||
using var service = h.NewService();
|
||||
using var vm = NewWindow(service, new FakeDialogs());
|
||||
await vm.InitializeAsync();
|
||||
Assert.Equal(AccountStep.Unavailable, vm.Step);
|
||||
Assert.Contains("pas encore ouverts", vm.UnavailableText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Closed_registration_hides_account_creation()
|
||||
{
|
||||
await using var h = await AccountsHarness.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Players:RegistrationEnabled"] = "false" });
|
||||
using var service = h.NewService();
|
||||
using var vm = NewWindow(service, new FakeDialogs());
|
||||
await vm.InitializeAsync();
|
||||
Assert.Equal(AccountStep.SignIn, vm.Step);
|
||||
Assert.True(vm.RegistrationClosed);
|
||||
Assert.False(vm.ShowRegisterCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Password_rules_follow_the_shared_checks()
|
||||
{
|
||||
var rules = new PasswordRulesViewModel();
|
||||
Assert.True(rules.ShowRules);
|
||||
rules.Update("Exovival2026!!", "", "joueur@example.fr");
|
||||
Assert.True(rules.HasProblems); // tiré du nom du jeu
|
||||
rules.Update("une phrase de passe assez longue", "une phrase de passe assez longue", null);
|
||||
Assert.True(rules.IsValid);
|
||||
Assert.Equal(4, rules.Strength);
|
||||
rules.Reset();
|
||||
Assert.False(rules.HasInput);
|
||||
Assert.False(rules.IsValid);
|
||||
}
|
||||
|
||||
private static async Task WaitUntil(Func<bool> condition)
|
||||
{
|
||||
for (var i = 0; i < 200 && !condition(); i++) await Task.Delay(25);
|
||||
Assert.True(condition());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Modules.Accounts;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
|
||||
namespace GameLauncher.Tests.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Compte joueur du launcher contre la Launcher API démarrée en mémoire : clé d'appareil logicielle (à la place de la puce
|
||||
/// TPM / clé CNG de Windows), chiffrement AES-GCM à clé aléatoire (à la place de DPAPI), dossier de données temporaire.
|
||||
/// </summary>
|
||||
public sealed class AccountsHarness : IAsyncDisposable
|
||||
{
|
||||
private readonly List<LauncherApiClient> _clients = new();
|
||||
|
||||
private AccountsHarness(PlayerTestServer server)
|
||||
{
|
||||
Server = server;
|
||||
DataDirectory = Path.Combine(Path.GetTempPath(), "launcher-accounts-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(DataDirectory);
|
||||
}
|
||||
|
||||
public PlayerTestServer Server { get; }
|
||||
public string DataDirectory { get; }
|
||||
public SoftwareKeyStore Keys { get; } = new();
|
||||
public TestProtector Protector { get; } = new();
|
||||
public PlayerSessionFile File => new(DataDirectory, Protector);
|
||||
|
||||
public static async Task<AccountsHarness> StartAsync(IDictionary<string, string?>? settings = null) =>
|
||||
new(await PlayerTestServer.StartAsync(settings));
|
||||
|
||||
/// <summary>Nouveau service (comme un nouveau lancement du launcher sur le même PC).</summary>
|
||||
public PlayerAccountService NewService(Uri? apiUrl = null, HttpMessageHandler? handler = null)
|
||||
{
|
||||
var client = new LauncherApiClient(apiUrl ?? Server.Api.BaseUrl, "GameLauncher.Tests", handler);
|
||||
_clients.Add(client);
|
||||
return new PlayerAccountService(new PlayerAccountApi(client), Keys, File, "Windows 11 · GameLauncher tests", Server.Api.Time);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var client in _clients) client.Dispose();
|
||||
await Server.DisposeAsync();
|
||||
try
|
||||
{
|
||||
Directory.Delete(DataDirectory, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clé d'appareil logicielle (une par « PC ») : même interface que la clé TPM / CNG du launcher.</summary>
|
||||
public sealed class SoftwareKeyStore : IPlayerDeviceKeyStore
|
||||
{
|
||||
private Key? _key;
|
||||
|
||||
public int Created { get; private set; }
|
||||
|
||||
public IPlayerDeviceKey? TryOpen() => _key;
|
||||
|
||||
public IPlayerDeviceKey OpenOrCreate()
|
||||
{
|
||||
if (_key is null)
|
||||
{
|
||||
_key = new Key();
|
||||
Created++;
|
||||
}
|
||||
return _key;
|
||||
}
|
||||
|
||||
public void Delete() => _key = null;
|
||||
|
||||
private sealed class Key : IPlayerDeviceKey
|
||||
{
|
||||
private readonly ECDsa _ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
|
||||
public Key()
|
||||
{
|
||||
PublicKey = _ecdsa.ExportSubjectPublicKeyInfo();
|
||||
KeyId = AdminKeys.ComputeKeyId(PublicKey);
|
||||
}
|
||||
|
||||
public byte[] PublicKey { get; }
|
||||
public string KeyId { get; }
|
||||
|
||||
public ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default) => ValueTask.FromResult(AdminKeys.Sign(_ecdsa, message));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Chiffrement authentifié à clé aléatoire (remplace DPAPI) : un autre « utilisateur » (autre instance) ne lit rien.</summary>
|
||||
public sealed class TestProtector : ISecretProtector
|
||||
{
|
||||
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
|
||||
|
||||
public byte[] Protect(byte[] data)
|
||||
{
|
||||
var nonce = RandomNumberGenerator.GetBytes(12);
|
||||
var cipher = new byte[data.Length];
|
||||
var tag = new byte[16];
|
||||
using var aes = new AesGcm(_key, 16);
|
||||
aes.Encrypt(nonce, data, cipher, tag);
|
||||
return nonce.Concat(tag).Concat(cipher).ToArray();
|
||||
}
|
||||
|
||||
public byte[] Unprotect(byte[] data)
|
||||
{
|
||||
if (data.Length < 28) throw new CryptographicException("trop court");
|
||||
var plain = new byte[data.Length - 28];
|
||||
using var aes = new AesGcm(_key, 16);
|
||||
aes.Decrypt(data.AsSpan(0, 12), data.AsSpan(28), data.AsSpan(12, 16), plain);
|
||||
return plain;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Accounts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Modules.Accounts;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GameLauncher.Tests.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Compte joueur côté launcher (étape 8b) contre la vraie Launcher API : session mémorisée chiffrée et reprise au lancement
|
||||
/// suivant, rotation du jeton de renouvellement, jeton d'accès renouvelé de lui-même, sessions perdues, hors ligne,
|
||||
/// nouveau mot de passe exigé, export et suppression.
|
||||
/// </summary>
|
||||
public class PlayerAccountServiceTests
|
||||
{
|
||||
private const string Email = "joueur@example.fr";
|
||||
private const string Password = PlayerTestServer.Password;
|
||||
|
||||
[Fact]
|
||||
public async Task Registered_session_is_saved_encrypted_and_resumed_at_next_launch_with_a_new_token()
|
||||
{
|
||||
await using var h = await AccountsHarness.StartAsync();
|
||||
using (var first = h.NewService())
|
||||
{
|
||||
await first.RegisterAsync(Email, Password, "1", remember: true);
|
||||
Assert.Equal(PlayerAccountState.SignedIn, first.State);
|
||||
Assert.Equal("j***@e***.fr", first.Account!.EmailMasked);
|
||||
Assert.True(first.IsRemembered);
|
||||
}
|
||||
|
||||
// Fichier chiffré : ni le jeton, ni l'adresse, ni la référence en clair.
|
||||
var saved = h.File.Load()!;
|
||||
var raw = await File.ReadAllBytesAsync(h.File.Path);
|
||||
var text = Encoding.UTF8.GetString(raw);
|
||||
Assert.DoesNotContain(saved.RefreshToken, text);
|
||||
Assert.DoesNotContain(saved.Reference, text);
|
||||
Assert.DoesNotContain("e***", text);
|
||||
|
||||
// Lancement suivant : session reprise, jeton de renouvellement remplacé sur le disque.
|
||||
using var second = h.NewService();
|
||||
var changes = 0;
|
||||
second.Changed += () => changes++;
|
||||
await second.RestoreAsync();
|
||||
Assert.Equal(PlayerAccountState.SignedIn, second.State);
|
||||
Assert.True(changes >= 2); // reprise puis connecté
|
||||
var rotated = h.File.Load()!;
|
||||
Assert.NotEqual(saved.RefreshToken, rotated.RefreshToken);
|
||||
Assert.Equal(saved.DeviceId, rotated.DeviceId);
|
||||
|
||||
var me = await second.GetMeAsync();
|
||||
Assert.Single(me.Devices);
|
||||
Assert.True(me.Devices[0].Current);
|
||||
Assert.Equal("Windows 11 · GameLauncher tests", me.Devices[0].Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_file_of_another_user_another_server_or_without_the_device_key_is_discarded()
|
||||
{
|
||||
await using var h = await AccountsHarness.StartAsync();
|
||||
using (var service = h.NewService()) await service.RegisterAsync(Email, Password, "1", remember: true);
|
||||
var saved = h.File.Load()!;
|
||||
|
||||
// Autre utilisateur Windows (autre clé DPAPI) : illisible, supprimé.
|
||||
var other = new PlayerSessionFile(h.DataDirectory, new TestProtector());
|
||||
Assert.Null(other.Load());
|
||||
Assert.False(File.Exists(h.File.Path));
|
||||
|
||||
// Autre serveur : ignorée.
|
||||
h.File.Save(saved with { Origin = "https://autre.exemple.fr" });
|
||||
using (var service = h.NewService())
|
||||
{
|
||||
await service.RestoreAsync();
|
||||
Assert.Equal(PlayerAccountState.SignedOut, service.State);
|
||||
}
|
||||
Assert.False(File.Exists(h.File.Path));
|
||||
|
||||
// Clé de l'appareil disparue (profil Windows réinitialisé) : abandonnée.
|
||||
h.File.Save(saved);
|
||||
h.Keys.Delete();
|
||||
using (var service = h.NewService())
|
||||
{
|
||||
await service.RestoreAsync();
|
||||
Assert.Equal(PlayerAccountState.SignedOut, service.State);
|
||||
}
|
||||
Assert.False(File.Exists(h.File.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_closed_from_another_device_is_dropped_with_an_explanation()
|
||||
{
|
||||
await using var h = await AccountsHarness.StartAsync();
|
||||
using (var service = h.NewService()) await service.RegisterAsync(Email, Password, "1", remember: true);
|
||||
|
||||
// Un autre appareil déconnecte tout.
|
||||
using var laptop = new TestDevice();
|
||||
var other = (await h.Server.LoginAsync(laptop, Email)).Session!;
|
||||
using (await h.Server.SendAsync(HttpMethod.Post, ApiRoutes.AccountLogout, other.AccessToken, new PlayerLogoutRequest { AllDevices = true })) { }
|
||||
|
||||
using var next = h.NewService();
|
||||
await next.RestoreAsync();
|
||||
Assert.Equal(PlayerAccountState.SignedOut, next.State);
|
||||
Assert.Contains("reconnectez-vous", next.Notice);
|
||||
Assert.False(File.Exists(h.File.Path));
|
||||
await Assert.ThrowsAsync<PlayerSessionLostException>(() => next.GetMeAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unreachable_server_keeps_the_remembered_session_offline()
|
||||
{
|
||||
await using var h = await AccountsHarness.StartAsync();
|
||||
using (var service = h.NewService()) await service.RegisterAsync(Email, Password, "1", remember: true);
|
||||
var saved = h.File.Load()!;
|
||||
|
||||
// Serveur injoignable (coupure réseau simulée).
|
||||
using var offline = h.NewService(handler: new UnreachableHandler());
|
||||
await offline.RestoreAsync();
|
||||
Assert.Equal(PlayerAccountState.Offline, offline.State);
|
||||
Assert.Equal(saved.Reference, offline.Account!.Reference);
|
||||
Assert.Equal(saved.RefreshToken, h.File.Load()!.RefreshToken); // rien de perdu
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Access_token_is_renewed_before_expiry_and_after_a_server_restart()
|
||||
{
|
||||
await using var h = await AccountsHarness.StartAsync();
|
||||
using var service = h.NewService();
|
||||
await service.RegisterAsync(Email, Password, "1", remember: true);
|
||||
var token1 = h.File.Load()!.RefreshToken;
|
||||
|
||||
// Échéance du jeton d'accès (15 min) : renouvelé de lui-même.
|
||||
h.Server.Api.Time.Advance(TimeSpan.FromMinutes(20));
|
||||
await service.GetMeAsync();
|
||||
var token2 = h.File.Load()!.RefreshToken;
|
||||
Assert.NotEqual(token1, token2);
|
||||
|
||||
// Serveur redémarré (sessions en mémoire perdues) : un renouvellement, puis l'appel réussit.
|
||||
h.Server.Api.Services.GetRequiredService<PlayerSessionStore>().RemoveWhere(_ => true);
|
||||
var me = await service.GetMeAsync();
|
||||
Assert.Single(me.Devices);
|
||||
Assert.NotEqual(token2, h.File.Load()!.RefreshToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_not_remembered_is_never_written_and_is_closed_when_the_launcher_exits()
|
||||
{
|
||||
await using var h = await AccountsHarness.StartAsync();
|
||||
var service = h.NewService();
|
||||
await service.RegisterAsync(Email, Password, "1", remember: false);
|
||||
Assert.Equal(PlayerAccountState.SignedIn, service.State);
|
||||
Assert.False(File.Exists(h.File.Path));
|
||||
var accountId = h.Server.Store.FindByReference(service.Account!.Reference)!.Id;
|
||||
Assert.Single(h.Server.Store.Devices(accountId));
|
||||
|
||||
service.Shutdown(TimeSpan.FromSeconds(10));
|
||||
Assert.Empty(h.Server.Store.Devices(accountId));
|
||||
Assert.Equal(PlayerAccountState.SignedOut, service.State);
|
||||
service.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_password_and_required_password_change_flow()
|
||||
{
|
||||
await using var h = await AccountsHarness.StartAsync();
|
||||
using (var service = h.NewService()) await service.RegisterAsync(Email, Password, "1", remember: true);
|
||||
h.Server.Store.SetMustChangePassword(h.Server.Store.FindByReference(h.File.Load()!.Reference)!.Id, true);
|
||||
h.File.Delete();
|
||||
|
||||
using var next = h.NewService();
|
||||
var wrong = await Assert.ThrowsAsync<ApiException>(() => next.SignInAsync(Email, "Pas-Le-Bon-Mot-2-Passe", true));
|
||||
Assert.Equal(ApiErrorCodes.LoginFailed, wrong.Code);
|
||||
Assert.Equal("Adresse e-mail ou mot de passe incorrect.", PlayerAccountTexts.Describe(wrong));
|
||||
|
||||
var result = await next.SignInAsync(Email, Password, remember: true);
|
||||
Assert.True(result.PasswordChangeRequired);
|
||||
Assert.Equal(PlayerAccountState.SignedOut, next.State);
|
||||
Assert.False(File.Exists(h.File.Path));
|
||||
|
||||
await next.CompleteRequiredPasswordChangeAsync(result.PasswordChangeToken!, Password, PlayerTestServer.OtherPassword, remember: true);
|
||||
Assert.Equal(PlayerAccountState.SignedIn, next.State);
|
||||
Assert.True(File.Exists(h.File.Path));
|
||||
Assert.Equal(1, h.Keys.Created); // même clé d'appareil depuis le début
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_then_deletion_from_the_launcher()
|
||||
{
|
||||
await using var h = await AccountsHarness.StartAsync();
|
||||
using var service = h.NewService();
|
||||
await service.RegisterAsync(Email, Password, "1", remember: true);
|
||||
var reference = service.Account!.Reference;
|
||||
|
||||
var export = await service.ExportAsync(Password);
|
||||
using (var json = JsonDocument.Parse(export))
|
||||
Assert.Equal(Email, json.RootElement.GetProperty("account").GetProperty("email").GetString());
|
||||
|
||||
await Assert.ThrowsAsync<ApiException>(() => service.DeleteAccountAsync("Mauvais-Mot-De-Passe-9"));
|
||||
Assert.Equal(PlayerAccountState.SignedIn, service.State);
|
||||
|
||||
await service.DeleteAccountAsync(Password);
|
||||
Assert.Equal(PlayerAccountState.SignedOut, service.State);
|
||||
Assert.False(File.Exists(h.File.Path));
|
||||
Assert.Null(h.Server.Store.FindByReference(reference));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signing_out_this_device_or_all_devices()
|
||||
{
|
||||
await using var h = await AccountsHarness.StartAsync();
|
||||
using var service = h.NewService();
|
||||
await service.RegisterAsync(Email, Password, "1", remember: true);
|
||||
var accountId = h.Server.Store.FindByReference(service.Account!.Reference)!.Id;
|
||||
using var laptop = new TestDevice();
|
||||
await h.Server.LoginAsync(laptop, Email);
|
||||
Assert.Equal(2, h.Server.Store.Devices(accountId).Count);
|
||||
|
||||
await service.SignOutAsync(allDevices: false);
|
||||
Assert.Equal(PlayerAccountState.SignedOut, service.State);
|
||||
Assert.False(File.Exists(h.File.Path));
|
||||
Assert.Single(h.Server.Store.Devices(accountId)); // l'autre appareil reste connecté
|
||||
|
||||
await service.SignInAsync(Email, Password, remember: true);
|
||||
await service.SignOutAsync(allDevices: true);
|
||||
Assert.Empty(h.Server.Store.Devices(accountId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Challenge_from_a_server_announcing_another_origin_is_refused()
|
||||
{
|
||||
await using var h = await AccountsHarness.StartAsync();
|
||||
// Même serveur, joint par un autre nom : l'origine annoncée ne correspond pas à l'adresse configurée.
|
||||
using var service = h.NewService(new Uri($"http://localhost:{h.Server.Api.BaseUrl.Port}/api/"));
|
||||
var error = await Assert.ThrowsAsync<ApiException>(() => service.RegisterAsync(Email, Password, "1", true));
|
||||
Assert.Equal("origin_mismatch", error.Code);
|
||||
Assert.Equal(0, h.Server.Store.CountAccounts());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_label_is_generic()
|
||||
{
|
||||
var label = PlayerDeviceLabel.Create("3.4.2");
|
||||
Assert.EndsWith("· GameLauncher 3.4.2", label);
|
||||
Assert.DoesNotContain(Environment.MachineName, label);
|
||||
Assert.Equal(label, PlayerDeviceLabel.Create("3.4.2<script>"));
|
||||
Assert.EndsWith("· GameLauncher", PlayerDeviceLabel.Create(""));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Coupure réseau : chaque requête échoue comme un serveur injoignable.</summary>
|
||||
internal sealed class UnreachableHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
|
||||
throw new HttpRequestException("Aucune connexion n'a pu être établie (test).");
|
||||
}
|
||||
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user