Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e887a91d9b | ||
|
|
c1ad053861 | ||
|
|
5195398943 | ||
|
|
fd90c436e7 | ||
|
|
ca194c2bf8 | ||
|
|
4b7b7bb83c | ||
|
|
270e311fb4 | ||
|
|
80d0eb93c5 | ||
|
|
6d4219228e | ||
|
|
20991ddc61 | ||
|
|
fa01962d03 | ||
|
|
53d87f3cc6 | ||
|
|
dc18e01540 | ||
|
|
723da51c65 | ||
|
|
cf3e3f5b57 | ||
|
|
7c7e12f694 | ||
|
|
950d6acc4d | ||
|
|
39dd8300e4 | ||
|
|
920d215fdd | ||
|
|
22468122e0 | ||
|
|
c2c67a63f2 | ||
|
|
d5aa875178 | ||
|
|
b9fea61e64 | ||
|
|
14a76927e1 | ||
|
|
5e68c4b16a | ||
|
|
e5b9ebf3ff | ||
|
|
fecf95ae3e | ||
|
|
3a788c18d2 | ||
|
|
dbe5d9051d | ||
|
|
ae2a5a4ab7 | ||
|
|
e8e08f792e | ||
|
|
c41bcda853 | ||
|
|
e41c3a977e | ||
|
|
f5e6c99aca | ||
|
|
35664045ce | ||
|
|
ad5d92ee8b | ||
|
|
047ec0d1db | ||
|
|
6243bd2574 | ||
|
|
7ee4d84dd2 | ||
|
|
f4ebb680ef | ||
|
|
756a76582d | ||
|
|
b5b922019c | ||
|
|
7ee8da3e67 | ||
|
|
0e8c918922 | ||
|
|
e2f2ae5817 | ||
|
|
9dda708c94 | ||
|
|
a17f0efcd7 |
No files matched your search
@@ -61,3 +61,10 @@
|
||||
#*.PDF diff=astextplain
|
||||
#*.rtf diff=astextplain
|
||||
#*.RTF diff=astextplain
|
||||
|
||||
###############################################################################
|
||||
# Fichiers executes sur le VPS (Linux) : fins de ligne LF obligatoires
|
||||
###############################################################################
|
||||
*.sh text eol=lf
|
||||
*.service text eol=lf
|
||||
server/deploy/*.conf text eol=lf
|
||||
@@ -412,3 +412,8 @@ FodyWeavers.xsd
|
||||
# Clé PRIVÉE de signature des mises à jour du launcher : ne jamais versionner
|
||||
launcher-signing.key
|
||||
*.download
|
||||
|
||||
# Cles privees (signature du launcher, administrateurs) : jamais dans Git
|
||||
*.key
|
||||
# Donnees locales de la Launcher API (F5)
|
||||
data-dev/
|
||||
@@ -28,9 +28,38 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
||||
server-example\launcher\publier-launcher.txt = server-example\launcher\publier-launcher.txt
|
||||
server-example\launcher\preparer-vps.sh = server-example\launcher\preparer-vps.sh
|
||||
README.md = README.md
|
||||
docs\ADMIN-ARCHITECTURE.md = docs\ADMIN-ARCHITECTURE.md
|
||||
tools\admin-key.bat = tools\admin-key.bat
|
||||
tools\publish-server.bat = tools\publish-server.bat
|
||||
server-example\web.config = server-example\web.config
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "server", "server", "{E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{1F9FBD8B-7A86-4F7B-A2ED-E18F5B014CDF}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
server\deploy\LISEZMOI.md = server\deploy\LISEZMOI.md
|
||||
server\deploy\installer-api.sh = server\deploy\installer-api.sh
|
||||
server\deploy\launcher-api.service = server\deploy\launcher-api.service
|
||||
server\deploy\nginx-api.conf = server\deploy\nginx-api.conf
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{266A4FF5-5752-46BA-803C-09E351720DB2}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Contracts", "src\GameLauncher.Contracts\GameLauncher.Contracts.csproj", "{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.ApiClient", "src\GameLauncher.ApiClient\GameLauncher.ApiClient.csproj", "{3BB233EF-1252-4D1D-9808-9C75EB393065}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Server", "server\GameLauncher.Server\GameLauncher.Server.csproj", "{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdminKeyTool", "tools\AdminKeyTool\AdminKeyTool.csproj", "{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Tests", "tests\GameLauncher.Tests\GameLauncher.Tests.csproj", "{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules", "src\GameLauncher.Modules\GameLauncher.Modules.csproj", "{9EBFA383-7928-46F6-8958-F4B38287E380}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Admin", "src\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj", "{E4FD522A-F44B-4401-9A18-542D42136E6B}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -71,6 +100,62 @@ Global
|
||||
{B6C1E2D3-4F5A-4B6C-9D7E-8F9A0B1C2D3E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B6C1E2D3-4F5A-4B6C-9D7E-8F9A0B1C2D3E}.Release|x64.ActiveCfg = Release|x64
|
||||
{B6C1E2D3-4F5A-4B6C-9D7E-8F9A0B1C2D3E}.Release|x64.Build.0 = Release|x64
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Debug|x64.Build.0 = Debug|x64
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Release|x64.ActiveCfg = Release|x64
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342}.Release|x64.Build.0 = Release|x64
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Debug|x64.Build.0 = Debug|x64
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Release|x64.ActiveCfg = Release|x64
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065}.Release|x64.Build.0 = Release|x64
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Debug|x64.Build.0 = Debug|x64
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Release|x64.ActiveCfg = Release|x64
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E}.Release|x64.Build.0 = Release|x64
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Debug|x64.Build.0 = Debug|x64
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Release|x64.ActiveCfg = Release|x64
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}.Release|x64.Build.0 = Release|x64
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Debug|x64.Build.0 = Debug|x64
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|x64.ActiveCfg = Release|x64
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}.Release|x64.Build.0 = Release|x64
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Debug|x64.Build.0 = Debug|x64
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|x64.ActiveCfg = Release|x64
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380}.Release|x64.Build.0 = Release|x64
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Debug|x64.Build.0 = Debug|x64
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.ActiveCfg = Release|x64
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.Build.0 = Release|x64
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -80,6 +165,14 @@ Global
|
||||
{7384678B-D7F7-4682-B99D-2DDCBC605BA4} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{35F7D646-FAC8-46A6-ACC8-0E22AE78B603} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
|
||||
{B6C1E2D3-4F5A-4B6C-9D7E-8F9A0B1C2D3E} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
|
||||
{99F6893A-AB86-4F7B-8F62-2D09CFCE8342} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{3BB233EF-1252-4D1D-9808-9C75EB393065} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{F4BAC0CC-DA96-481F-BD2A-35E7E156652E} = {E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}
|
||||
{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
|
||||
{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F} = {266A4FF5-5752-46BA-803C-09E351720DB2}
|
||||
{1F9FBD8B-7A86-4F7B-A2ED-E18F5B014CDF} = {E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}
|
||||
{9EBFA383-7928-46F6-8958-F4B38287E380} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
|
||||
|
||||
@@ -35,6 +35,13 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
|
||||
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation |
|
||||
| `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, rôles, messages signés (voir §8) |
|
||||
| `src/GameLauncher.ApiClient` | Client de la Launcher API : connexion par clé, requêtes signées, fichier de clé chiffré |
|
||||
| `src/GameLauncher.Modules` | Socle des modules du launcher (sans WPF) : contrat d'un module, pages du menu d'administration, MVVM commun |
|
||||
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord (vues dans `src\GameLauncher\Views\Admin`) |
|
||||
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
|
||||
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
|
||||
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
|
||||
|
||||
```
|
||||
src/GameLauncher/
|
||||
@@ -42,14 +49,16 @@ src/GameLauncher/
|
||||
├─ launcher.json Configuration éditeur : un jeu, ou plusieurs (voir launcher.multi-jeux.example.json)
|
||||
├─ 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)
|
||||
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog
|
||||
│ Changelog.xaml (modèles des notes de version), Admin.xaml (menu d'administration)
|
||||
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow
|
||||
│ └─ Admin/ AdminWindow, LoginView, EnrollView (menu d'administration ; ViewModels dans GameLauncher.Modules.Admin)
|
||||
├─ ViewModels/ LauncherViewModel (jeux, thème actif, paramètres du launcher),
|
||||
│ GameViewModel (machine à états d'un jeu),
|
||||
│ LauncherSettingsViewModel / GameSettingsViewModel (les deux fenêtres de paramètres)
|
||||
├─ Services/ ThemeManager, RegistryService, ShortcutService, InstanceChannel (raccourci → launcher
|
||||
│ déjà ouvert), GameScopedServices, interfaces
|
||||
└─ Platform/ DialogService, WindowService (implémentations WPF)
|
||||
│ déjà ouvert), GameScopedServices, AdminWindowService (menu d'administration), interfaces
|
||||
└─ Platform/ DialogService, WindowService (implémentations WPF), TrayIcon (zone de notification),
|
||||
ModuleDialogs, WpfUiDispatcher (services offerts aux modules)
|
||||
```
|
||||
|
||||
## 3. Publier une version du jeu (ManifestBuilder)
|
||||
@@ -161,6 +170,13 @@ Le raccourci créé à l'installation pointe vers **le launcher** (`GameLauncher
|
||||
pas vers le jeu : les mises à jour sont ainsi toujours vérifiées. Un raccourci « du jeu » est
|
||||
reconnu à ses arguments `--game <GameId>`, même si le joueur l'a renommé (Bureau de l'utilisateur).
|
||||
|
||||
* **Créer le raccourci** : à l'installation (case à cocher), ou à tout moment depuis le menu **« … »** ›
|
||||
*Paramètres du jeu* › bloc *Raccourci* (« Créer le raccourci », ou « Mettre à jour » s'il existe déjà).
|
||||
* **Icône** : celle de l'exécutable du jeu (dossier d'installation et exécutable lus dans le registre),
|
||||
ou celle du launcher si le jeu n'est pas installé ou introuvable. Elle suit le jeu s'il est déplacé
|
||||
(localisation, réparation) ; les raccourcis créés par une version précédente prennent l'icône du jeu
|
||||
au démarrage du launcher. Une icône choisie par le joueur n'est jamais remplacée.
|
||||
|
||||
| Situation | Comportement |
|
||||
|---|---|
|
||||
| Raccourci ouvert, launcher **fermé** | Le launcher s'ouvre sur ce jeu, vérifie les mises à jour (et les installe si la mise à jour automatique du jeu est activée), puis **lance le jeu** s'il est prêt, en appliquant le réglage *Lancement du jeu* (réduire / fermer le launcher). Sinon le bandeau explique pourquoi (mise à jour manuelle à faire, réparation, jeu non installé). Hors ligne, le jeu installé est lancé. |
|
||||
@@ -168,7 +184,8 @@ reconnu à ses arguments `--game <GameId>`, même si le joueur l'a renommé (Bur
|
||||
| Raccourci vers un **autre exemplaire** du launcher, encore présent (copie) | Rien d'automatique ; **Réparer** propose de le rediriger (confirmation) |
|
||||
| Icône personnalisée par le joueur | Conservée lors de la redirection |
|
||||
| Raccourci ouvert alors que le launcher est **déjà ouvert** | La demande est transmise au launcher ouvert : il revient au premier plan sur ce jeu et **lance le jeu** s'il est prêt et pas déjà en cours d'exécution (sinon le bandeau explique pourquoi : mise à jour, réparation, opération en cours…). Rien n'est lancé si une boîte de dialogue est ouverte. |
|
||||
| Launcher relancé **sans** `--game` alors qu'il est déjà ouvert | Message « Le launcher est déjà ouvert. » (inchangé) |
|
||||
| Launcher relancé **sans** `--game` alors qu'il est déjà ouvert | Message « Le launcher est déjà ouvert » (qui rappelle de regarder dans la zone de notification) |
|
||||
| Raccourci ouvert alors que le launcher est **réduit dans la zone de notification** | La fenêtre est réaffichée, puis même comportement que « déjà ouvert » |
|
||||
|
||||
Tant que le launcher n'a pas été relancé depuis son nouvel emplacement, un raccourci vers l'ancien
|
||||
emplacement ne fonctionne pas (Windows le signale, et le retrouve parfois tout seul). Le chemin du
|
||||
@@ -193,17 +210,51 @@ version, une mise à jour (différentielle) est proposée.
|
||||
### Désinstallation
|
||||
Depuis le menu « … » ou depuis **Paramètres Windows › Applications installées** (le launcher est
|
||||
relancé avec `--uninstall <GameId>`). Par défaut seuls les fichiers installés par le launcher sont
|
||||
supprimés ; une option permet de supprimer tout le dossier (refusé pour les dossiers sensibles :
|
||||
racine de disque, Program Files, Documents…).
|
||||
supprimés : les sauvegardes et réglages du jeu (`%APPDATA%\<Société>\<Produit>`) et les fichiers
|
||||
personnels du dossier d'installation sont conservés.
|
||||
|
||||
L'option **« Supprimer aussi les sauvegardes, les réglages, les journaux et tous les autres fichiers du jeu »**
|
||||
(proposée aussi quand les fichiers du jeu ont déjà disparu) supprime en plus :
|
||||
|
||||
| Élément | Emplacement |
|
||||
|---|---|
|
||||
| Tout le dossier d'installation | refusé pour les dossiers sensibles : racine de disque, Program Files, Documents… |
|
||||
| Sauvegardes et réglages | `%APPDATA%\LanisysGames\Exovival Zombie Reborn` |
|
||||
| Journaux de Unity (`Player.log`) | `%USERPROFILE%\AppData\LocalLow\LanisysGames\Exovival Zombie Reborn` |
|
||||
| Réglages d'affichage de Unity (résolution, plein écran, position de la fenêtre) | `HKCU\Software\LanisysGames\Exovival Zombie Reborn` |
|
||||
|
||||
Garde-fous : chemin « Société\Produit » complet uniquement (`gameDataFolder`, jamais le dossier de la
|
||||
société entier), jamais le dossier ni la clé du launcher (`GameLauncher`) ni le dossier d'installation ;
|
||||
dossier et clé de la société retirés seulement s'ils sont vides. Ce qui n'a pas pu être supprimé (fichier
|
||||
ouvert) est indiqué dans le bandeau et le journal.
|
||||
|
||||
**Launcher placé dans un dossier supprimé** — dossier du jeu, ou (avec l'option) dossier des sauvegardes dans
|
||||
Roaming ou des journaux dans LocalLow, à toute profondeur : la confirmation l'indique (« Le launcher se trouve
|
||||
dans ce dossier / dans le dossier des données du jeu : il sera conservé. ») et ses fichiers sont épargnés :
|
||||
son exécutable, `launcher.json`, les fichiers temporaires de sa mise à jour (`GameLauncher.exe.old`,
|
||||
`.download`…) et, en version de développement, les bibliothèques chargées depuis son dossier. Tout le reste
|
||||
est supprimé, les sous-dossiers vides sont retirés, et le bandeau final indique où le launcher a été conservé.
|
||||
Les trois dossiers suivent la même règle (`FileUtil.DeleteDirectoryContents`).
|
||||
|
||||
**Fichier bloqué** (jeu mal fermé, antivirus…) : la désinstallation ne s'interrompt plus. Les fichiers sont
|
||||
supprimés un par un ; ceux qui ne peuvent pas l'être sont signalés (bandeau + liste complète dans le journal),
|
||||
puis registre, raccourci et données du jeu sont traités normalement.
|
||||
|
||||
### Registre (HKEY_CURRENT_USER, aucun droit administrateur requis)
|
||||
```
|
||||
HKCU\Software\<Publisher>\<GameId>
|
||||
InstallPath, Version, Executable, InstallDate, LauncherPath
|
||||
HKCU\Software\<Publisher>\GameLauncher (ex. …\LanisysGames\GameLauncher)
|
||||
LauncherPath emplacement du launcher (une seule fois)
|
||||
HKCU\Software\<Publisher>\GameLauncher\Games\<GameId> (ex. …\GameLauncher\Games\EZR)
|
||||
InstallPath, Version, Executable, InstallDate
|
||||
HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\<Publisher>.<GameId>
|
||||
DisplayName, DisplayVersion, Publisher, InstallLocation, DisplayIcon,
|
||||
UninstallString ("GameLauncher.exe" --uninstall <GameId>), EstimatedSize…
|
||||
```
|
||||
* `LauncherPath` est écrit une seule fois pour tous les jeux, à l'installation d'un jeu et à chaque démarrage
|
||||
(s'il y a au moins un jeu installé), et supprimé à la désinstallation du dernier jeu. Seul le launcher
|
||||
réellement ouvert l'écrit : un second launcher, arrêté par « Le launcher est déjà ouvert » (ou qui transmet
|
||||
un raccourci au launcher ouvert), ne touche pas au registre. Il est informatif : la vérification des
|
||||
raccourcis compare la cible de chaque raccourci à l'exécutable en cours, pas au registre.
|
||||
|
||||
### Paramètres
|
||||
|
||||
@@ -211,19 +262,65 @@ Deux fenêtres distinctes :
|
||||
|
||||
| Accès | Contenu | Portée |
|
||||
|---|---|---|
|
||||
| **Roue crantée** (en haut à droite) › *Paramètres du launcher* | Mise à jour automatique du launcher, « Vérifier les mises à jour », bloc *Lancement du jeu* (réduire / fermer le launcher pendant la partie), dossier des journaux | Commun à tous les jeux |
|
||||
| Menu **« … »** › *Paramètres du jeu* | Mises à jour automatiques du jeu, bloc *Téléchargement* (téléchargements simultanés, vérification après installation), bloc *Informations* (versions, taille, dossier du jeu, journaux) | Jeu sélectionné uniquement |
|
||||
| **Roue crantée** (en haut à droite) › *Paramètres du launcher* | Mise à jour automatique du launcher, « Vérifier les mises à jour », bloc *Lancement du jeu* (réduire — dans la barre des tâches ou la zone de notification — ou fermer le launcher pendant la partie), bloc *Fermeture du launcher* (la croix ferme ou réduit dans la zone de notification), dossier des journaux | Commun à tous les jeux |
|
||||
| Menu **« … »** › *Paramètres du jeu* | Mises à jour automatiques du jeu, bloc *Téléchargement* (téléchargements simultanés, vérification après installation), bloc *Raccourci* (créer / mettre à jour le raccourci du Bureau), bloc *Informations* (versions, taille ; boutons *dossier du jeu*, *dossier des journaux* du jeu dans LocalLow, *dossier des données* du jeu dans Roaming) | Jeu sélectionné uniquement |
|
||||
|
||||
Au premier démarrage d'une version qui sépare les paramètres, les réglages *Lancement du jeu*
|
||||
sont repris de ceux du jeu affiché par défaut.
|
||||
|
||||
### Zone de notification (icônes cachées, près de l'horloge)
|
||||
|
||||
Deux options des *Paramètres du launcher* (désactivées par défaut) :
|
||||
|
||||
| Option | Effet |
|
||||
|---|---|
|
||||
| *Lancement du jeu* › Réduire le launcher pendant la partie › **Dans la zone de notification** | Pendant la partie, la fenêtre est masquée et une icône apparaît près de l'horloge ; la fenêtre revient à la fermeture du jeu |
|
||||
| *Fermeture du launcher* › **La croix réduit le launcher dans la zone de notification** | La croix (et Alt+F4) masque la fenêtre au lieu de quitter : téléchargements, mises à jour et surveillance du jeu continuent |
|
||||
|
||||
L'icône n'est affichée que lorsque la fenêtre est masquée. **Clic gauche** : réouvre le launcher.
|
||||
**Clic droit** : menu *Ouvrir* / *Quitter* (même style arrondi que les autres menus, couleurs du thème
|
||||
du jeu affiché). *Quitter* demande confirmation si une opération est en cours (la fenêtre est alors
|
||||
réaffichée). À la fermeture de session ou à l'arrêt de Windows, le launcher se ferme normalement.
|
||||
L'icône est gérée par l'API Windows (`Shell_NotifyIcon`, `Platform/TrayIcon.cs`), sans Windows Forms,
|
||||
et réapparaît si l'Explorateur Windows redémarre. Le menu est une petite fenêtre (`Views/TrayMenuWindow`)
|
||||
et non un `ContextMenu` WPF : launcher masqué, un `ContextMenu` ne peut pas capturer la souris et se
|
||||
referme aussitôt. Il se ferme dès qu'il perd le premier plan (clic ailleurs) ou avec Échap.
|
||||
|
||||
### Autres fichiers
|
||||
* Paramètres d'un jeu : `%AppData%\<Publisher>\<GameId>Launcher\settings.json`
|
||||
* Paramètres du launcher (communs) : `%AppData%\<Publisher>\<LauncherId>\launcher-settings.json`
|
||||
(en mono-jeu, `<LauncherId>` = `<GameId>Launcher` : même dossier que ci-dessus)
|
||||
* Journaux : `%AppData%\<Publisher>\<GameId>Launcher\logs\launcher.log` (accessibles depuis les deux fenêtres de paramètres) ;
|
||||
en multi-jeux : `%AppData%\<Publisher>\<launcherId>\logs\` (+ `launcher-state.json` : dernier jeu affiché)
|
||||
```
|
||||
%AppData%\<Publisher>\GameLauncher (ex. %AppData%\LanisysGames\GameLauncher)
|
||||
├─ launcher-settings.json paramètres du launcher (communs à tous les jeux)
|
||||
├─ self-update.json mise à jour du launcher (§7)
|
||||
├─ launcher-state.json dernier jeu affiché (plusieurs jeux)
|
||||
├─ logs\launcher.log journal du launcher (roue crantée › Ouvrir le dossier des journaux)
|
||||
└─ Games\<GameId>\settings.json paramètres de chaque jeu (menu « … » › Paramètres du jeu)
|
||||
```
|
||||
* État de l'installation : `<dossier du jeu>\.launcher\state.json` (dossier caché)
|
||||
* Nom du dossier et de la clé de registre : `launcher.json` → `"dataFolder"` (à la racine ; `"GameLauncher"`
|
||||
par défaut). Il est indépendant de `launcherId`, qui ne doit plus changer (§7).
|
||||
|
||||
**Versions 1.2.1 et antérieures** : les données étaient dans `%AppData%\<Publisher>\<LauncherId>` (ex.
|
||||
`EZRLauncher`, + `<GameId>Launcher` par jeu en multi-jeux) et dans `HKCU\Software\<Publisher>\<GameId>`.
|
||||
Au démarrage, le launcher les déplace (renommage du dossier, paramètres de chaque jeu vers `Games\<GameId>`,
|
||||
copie vérifiée puis suppression de l'ancienne clé de registre). Ce déplacement n'a lieu que si aucun autre
|
||||
launcher n'est ouvert, et jamais au redémarrage qui suit une mise à jour du launcher (l'ancienne version doit
|
||||
pouvoir être remise en place) : l'ancien emplacement est alors utilisé pour la session et le déplacement est
|
||||
fait au démarrage suivant. Les entrées *Applications installées* (`Uninstall\<Publisher>.<GameId>`) ne changent pas.
|
||||
|
||||
### Données du jeu (Unity)
|
||||
|
||||
| Contenu | Emplacement | Bouton (*Paramètres du jeu* › *Informations*) |
|
||||
|---|---|---|
|
||||
| Sauvegardes (`Data\SavedGame`) et réglages (`Data\Config`) | `%APPDATA%\LanisysGames\Exovival Zombie Reborn` (Roaming, à côté de `GameLauncher`) | *Ouvrir le dossier des données* |
|
||||
| Journaux de Unity (`Player.log`, `Player-prev.log`) | `%USERPROFILE%\AppData\LocalLow\LanisysGames\Exovival Zombie Reborn` | *Ouvrir le dossier des journaux* |
|
||||
|
||||
* « LanisysGames\Exovival Zombie Reborn » correspond à *Company Name* / *Product Name* des Player Settings
|
||||
de Unity ; côté launcher : `launcher.json` → `gameDataFolder` (par défaut `publisher\gameName`).
|
||||
* Le jeu choisit l'emplacement dans `SerializationAsset.cs` (UHFPS, classe `ExovivalDataLocation`). Au
|
||||
premier lancement d'une version qui l'intègre, le dossier `Data` de LocalLow est copié vers Roaming,
|
||||
vérifié (mêmes fichiers, mêmes tailles), puis supprimé de LocalLow. En cas d'échec, le jeu continue
|
||||
d'utiliser LocalLow et réessaie au lancement suivant. Dans l'éditeur Unity, rien ne change (`Assets/Data`).
|
||||
* `Player.log` reste dans LocalLow : son emplacement est fixé par Unity (`Application.persistentDataPath`).
|
||||
|
||||
## 5. Personnalisation
|
||||
|
||||
@@ -237,6 +334,7 @@ sont repris de ceux du jeu affiché par défaut.
|
||||
| Icônes de l'interface | `Themes/Icons.xaml` (tracés 24×24, compatibles [Material Design Icons](https://pictogrammers.com/library/mdi/)) |
|
||||
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` |
|
||||
| Fréquence des recherches de mises à jour | `launcher.json` → `updateCheckIntervalMinutes` |
|
||||
| Dossier des données du jeu Unity (« Société\Produit ») | `launcher.json` → `gameDataFolder` |
|
||||
|
||||
### Thèmes
|
||||
|
||||
@@ -277,7 +375,7 @@ liste des jeux apparaît alors à gauche de la fenêtre (masquée s'il n'y a qu'
|
||||
|---|---|
|
||||
| `--game <GameId>` | Ouvre le launcher sur ce jeu, vérifie les mises à jour puis lance le jeu s'il est prêt ; si le launcher est déjà ouvert, lui demande de lancer ce jeu (voir « Raccourcis du Bureau ») |
|
||||
| `--uninstall [GameId]` | Désinstallation (utilisée par Windows) |
|
||||
| `--config <fichier>` | Utilise un autre `launcher.json` (tests) |
|
||||
| `--config <fichier>` | Utilise un autre `launcher.json` (tests). En Release, son `apiUrl` est ignorée (seule celle intégrée à l'exe compte) |
|
||||
| `--capture <dossier>` | Enregistre une capture PNG par jeu (+ ses paramètres, + paramètres du launcher), sans rien installer, puis quitte |
|
||||
| `--updated <pid> <événement> <version>` | Interne : démarrage de la nouvelle version après une mise à jour du launcher (§7). Le jeu de `--game` n'est alors lancé que si `--launch` est aussi présent (mise à jour faite au démarrage depuis un raccourci), jamais après une mise à jour demandée depuis les paramètres |
|
||||
|
||||
@@ -367,5 +465,50 @@ launcher distribué : c'est l'identifiant de produit vérifié lors des mises à
|
||||
|
||||
### Fichiers
|
||||
|
||||
* Préférences : `%AppData%\<Publisher>\<LauncherId>\self-update.json` (`autoUpdate`, version en échec, dernière recherche)
|
||||
* Préférences : `%AppData%\<Publisher>\GameLauncher\self-update.json` (`autoUpdate`, version en échec, dernière recherche)
|
||||
* À côté de l'exe, temporairement : `GameLauncher.exe.<version>.download`, `GameLauncher.exe.old`, `GameLauncher.exe.replaced`, `GameLauncher.exe.failed`
|
||||
|
||||
## 8. Administration (Launcher API)
|
||||
|
||||
Chantier en cours, découpé en phases : architecture, sécurité et planning dans
|
||||
**`docs/ADMIN-ARCHITECTURE.md`**. Phase 1 livrée : le serveur d'administration et ses outils.
|
||||
Phase 2 livrée : le **menu d'administration** du launcher (connexion, invitation, tableau de bord) ;
|
||||
les pages de gestion arrivent dans les phases suivantes. Rien ne change pour les joueurs.
|
||||
|
||||
* **Ouvrir le menu** : `Ctrl+Maj+Alt+A` dans la fenêtre du launcher, ou **5 clics rapides** (en
|
||||
moins de 3 s) sur le logo en haut à gauche. Accès discret, pas secret : seule la connexion par clé,
|
||||
vérifiée par le serveur, protège l'administration.
|
||||
* **Adresse du serveur** : `"apiUrl"` dans `src\GameLauncher\launcher.json` (racine du fichier,
|
||||
https). En Release, seule la valeur **intégrée à l'exe** est utilisée ; en Debug, celle du
|
||||
`launcher.json` utilisé (ex. `--config` avec `http://127.0.0.1:5080/api/` pour un serveur local).
|
||||
Sans `apiUrl`, le menu indique qu'il n'est relié à aucun serveur.
|
||||
* **Se connecter** : choisir sa clé (dossier `%USERPROFILE%\.launcher-keys`, ou « Choisir un autre
|
||||
fichier de clé… »), saisir sa phrase de passe. Verrouillage automatique après 10 min sans activité,
|
||||
quand Windows est verrouillé, ou avec `Ctrl+L` ; fermer la fenêtre efface la clé de la mémoire.
|
||||
* **« J'ai une invitation »** : crée la clé sur ce PC et l'enregistre avec le code reçu, puis
|
||||
affiche l'**empreinte** à dicter à la personne qui approuve. Sans code : clé seule (premier
|
||||
propriétaire, à déclarer sur le VPS). Deux rangements au choix :
|
||||
* **fichier chiffré** (phrase de passe, 12 caractères min.) : copiable et sauvegardable ;
|
||||
* **puce de sécurité (TPM 2.0) de ce PC** (code PIN, 6 caractères min.) : la clé est créée dans la
|
||||
puce et ne peut pas être copiée. Proposé seulement après vérification réelle de la puce. Elle
|
||||
disparaît avec la puce (carte mère, réinitialisation, Windows réinstallé) : gardez aussi une
|
||||
clé fichier de secours. Sur le disque : `admin.tpmkey` (référence, sans secret) et `admin.pub`.
|
||||
Écran de connexion : « Supprimer cette clé de la puce » (puis révoquer la clé sur le serveur) ;
|
||||
après trop de codes PIN faux, un décompte indique quand la puce acceptera de nouveau un code.
|
||||
|
||||
* **Principe** : le launcher étant public, la sécurité est assurée par le **serveur**. Chaque
|
||||
administrateur possède une **clé privée** (sur son PC, chiffrée) ; le serveur ne connaît que les
|
||||
clés publiques. Connexion par défi signé, sessions courtes, et **toute modification signée** par
|
||||
la clé (un jeton volé ne permet que de lire). Journal d'audit de chaque action.
|
||||
* **Rôles** : propriétaire (tout, dont la gestion des administrateurs), administrateur (contenu et
|
||||
publication), éditeur (contenu).
|
||||
* **Ajouter un administrateur** : invitation (code à usage unique, 24 h) → la personne enregistre sa
|
||||
clé → un propriétaire approuve en saisissant l'**empreinte** que la personne lui communique.
|
||||
Un nouveau PC d'un administrateur existant est approuvé par lui-même, depuis un PC déjà enregistré.
|
||||
|
||||
| Pour… | Où |
|
||||
|---|---|
|
||||
| Installer le serveur sur le VPS, créer le premier propriétaire, récupération | `server/deploy/LISEZMOI.md` |
|
||||
| Publier le serveur | `tools\publish-server.bat` (Linux x64 autonome + empreinte SHA-256) |
|
||||
| Créer sa clé, tester la connexion, inviter/approuver | `tools\admin-key.bat` (`help` pour la liste) ; créer sa clé et se connecter : aussi depuis le launcher |
|
||||
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
|
||||
@@ -0,0 +1,400 @@
|
||||
# Module d'administration et socle modulaire — Architecture
|
||||
|
||||
> Document de référence du chantier « module d'administration ». Il fixe les décisions
|
||||
> d'architecture et de sécurité, puis découpe le travail en phases indépendantes.
|
||||
> Statut des phases : voir § 13.
|
||||
|
||||
## 1. Objectifs
|
||||
|
||||
* Un **menu d'administration intégré au launcher**, accessible par une entrée discrète, protégé par
|
||||
une **authentification par clé publique/privée**.
|
||||
* Depuis ce menu :
|
||||
* **éditer les notes de version existantes** avec un éditeur graphique qui respecte le format
|
||||
enrichi actuel (titre, sous-titre, introduction, sections, modifications, commentaires,
|
||||
étiquettes, sous-points, notes) ;
|
||||
* **publier une nouvelle version du jeu** (archive ZIP + `manifest.json` + dossier `objects/`)
|
||||
produite par l'outil `.bat` / ManifestBuilder, qui **reste l'outil de génération** ;
|
||||
* **modifier les informations des jeux** (nom affiché, accroche, description, liens, thème…),
|
||||
les jeux restant **déclarés dans `launcher.json`** (solution Visual Studio) ;
|
||||
* **gérer les administrateurs** (inviter, approuver, changer le rôle, révoquer).
|
||||
* Un **socle modulaire** : le menu d'administration est le premier module ; les fonctionnalités
|
||||
futures « à la Steam » (actualités, amis, succès, sauvegardes dans le cloud, boutique…) s'y
|
||||
brancheront de la même façon, côté launcher **et** côté serveur.
|
||||
|
||||
## 2. Principe de sécurité fondamental
|
||||
|
||||
Le launcher est distribué à tous les joueurs : **tout ce qu'il contient est public**, et toute
|
||||
vérification faite uniquement dans l'exécutable peut être contournée (exécutable modifié).
|
||||
|
||||
Conséquences :
|
||||
|
||||
1. **Le serveur est la seule barrière de sécurité.** Chaque action d'administration est vérifiée
|
||||
par le serveur (authentification, droits, validation du contenu). Le bouton caché n'est qu'un
|
||||
confort : le cacher ne protège rien.
|
||||
2. **Aucun secret dans l'exécutable.** L'exe ne contient que des éléments publics : adresses,
|
||||
clés *publiques*.
|
||||
3. **Le serveur ne stocke aucun secret utilisable** : uniquement des clés publiques. Une fuite
|
||||
complète de ses données ne permet pas de se connecter.
|
||||
4. **Toute modification est signée par la clé de l'administrateur** (en plus de la session) : un
|
||||
jeton de session volé ne permet que de lire.
|
||||
5. **Le serveur valide tout ce qu'il écrit** avec le même code que le launcher
|
||||
(`GameLauncher.Core`) et écrit de façon atomique (fichier temporaire puis renommage), avec
|
||||
sauvegarde de la version précédente et journal d'audit.
|
||||
6. Les éléments qui touchent à la sécurité ou au disque du joueur (URL des serveurs, clés de
|
||||
confiance, dossiers supprimés à la désinstallation…) **restent dans l'exe** et ne peuvent pas
|
||||
être modifiés depuis le serveur, ni par un `launcher.json` posé à côté de l'exe.
|
||||
|
||||
## 3. Vue d'ensemble
|
||||
|
||||
```
|
||||
PC de l'administrateur VPS (Debian 13)
|
||||
┌──────────────────────────────┐ HTTPS ┌─────────────────────────────────────────────┐
|
||||
│ GameLauncher.exe │──────────►│ nginx (TLS, limites, fichiers statiques) │
|
||||
│ ├─ Module « Administration »│ │ ├─ /exovivalzombie/… manifest, zip, objects│
|
||||
│ │ clé privée : fichier │ │ ├─ /launcher/… mise à jour launcher │
|
||||
│ │ chiffré, ou puce TPM │ │ ├─ /catalog/… infos des jeux (ph. 3)│
|
||||
│ │ du PC (non exportable) │ │ └─ /api/ ──► Launcher API (127.0.0.1:5080) │
|
||||
│ └─ GameLauncher.ApiClient │ │ ASP.NET Core, service systemd durci │
|
||||
└──────────────────────────────┘ │ données : /var/lib/launcher-api (700) │
|
||||
└─────────────────────────────────────────────┘
|
||||
PC d'un joueur : lit uniquement les fichiers statiques (aucun accès à l'API d'administration).
|
||||
```
|
||||
|
||||
## 4. Projets de la solution
|
||||
|
||||
| Projet | Cible | Rôle |
|
||||
|---|---|---|
|
||||
| `src/GameLauncher` | net8.0-windows (WPF) | Application ; hôte des modules ; **affichage** des modules (vues WPF du menu d'administration : `Views/Admin`, `Themes/Admin.xaml`) |
|
||||
| `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.Core` | net8.0 | Logique du jeu (inchangée) : manifest, téléchargement, installation… |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `tools/AdminKeyTool` | net8.0 | **Nouveau.** Crée une clé d'administrateur (fichier chiffré) et teste une connexion à l'API |
|
||||
| `tools/ManifestBuilder`, `tools/LauncherPublisher` | net8.0 | Inchangés (le `.bat` de génération est conservé) |
|
||||
| `tests/GameLauncher.Tests` | net8.0 (xUnit) | **Nouveau.** Tests unitaires + tests de bout en bout (serveur démarré en mémoire) |
|
||||
|
||||
Dépendances : `Contracts` ne dépend de rien ; `ApiClient` → `Contracts` ; `Server` → `Contracts`
|
||||
(+ `Core` à partir de la phase 3) ; `Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ;
|
||||
`GameLauncher` → `Core`, `Modules`, `Modules.Admin`.
|
||||
Aucun paquet NuGet n'est nécessaire en dehors du projet de tests (xUnit).
|
||||
|
||||
## 5. Modules
|
||||
|
||||
### 5.1 Côté serveur (phase 1)
|
||||
|
||||
```csharp
|
||||
public interface IApiModule
|
||||
{
|
||||
string Name { get; }
|
||||
void ConfigureServices(IServiceCollection services, LauncherApiOptions options);
|
||||
void MapEndpoints(IEndpointRouteBuilder api); // sous /api/v1
|
||||
}
|
||||
```
|
||||
|
||||
Modules livrés en phase 1 : `System` (infos, santé), `Auth` (défi, connexion, session, enrôlement),
|
||||
`Admins` (gestion des administrateurs), `Audit` (journal). Les phases suivantes ajoutent `Catalog`,
|
||||
`Changelog`, `Releases`, puis les modules « joueurs ». Chaque module déclare ses **permissions**
|
||||
(`AdminPermissions`), protège ses routes avec `.RequirePermission(...)` et ses modifications avec
|
||||
`.RequireSignedRequest()`.
|
||||
|
||||
### 5.2 Côté launcher (phase 2 ✅)
|
||||
|
||||
La logique d'un module (services, ViewModels) est une bibliothèque **net8.0 sans WPF**, testée sans
|
||||
interface ; le launcher WPF n'en fournit que l'**affichage** (un `DataTemplate` par type de page).
|
||||
|
||||
```csharp
|
||||
public interface ILauncherModule
|
||||
{
|
||||
string Id { get; } // "admin", puis "news", "friends"…
|
||||
string DisplayName { get; }
|
||||
void Register(IModuleRegistry registry);
|
||||
}
|
||||
|
||||
public interface IModuleRegistry
|
||||
{
|
||||
void AddAdminPanel(AdminPanelDescriptor panel); // page du menu d'administration
|
||||
// Plus tard : AddPage (section de la fenêtre principale), services partagés…
|
||||
}
|
||||
|
||||
public sealed record AdminPanelDescriptor(
|
||||
string Id, string Title, string Description, string IconKey,
|
||||
string? RequiredPermission, // null : toute personne connectée
|
||||
int Order, Func<IAdminContext, AdminPanelViewModel> CreateViewModel);
|
||||
```
|
||||
|
||||
* `ModuleCatalog` rassemble les modules (déclarés dans `App.CreateModules()`), vérifie l'unicité des
|
||||
identifiants et le format des permissions, et donne les pages **autorisées par les permissions**
|
||||
renvoyées par le serveur (le serveur reste seul juge : masquer une page n'est qu'un confort).
|
||||
* Le **menu d'administration** est une fenêtre « coquille » : à gauche l'administrateur connecté, les
|
||||
pages des modules, le temps restant avant verrouillage et le bouton « Verrouiller » ; à droite la
|
||||
page choisie.
|
||||
* `IAdminContext` donne à chaque page : la session (`AdminSession` : requêtes signées), le client de
|
||||
l'API, les jeux déclarés dans `launcher.json`, les boîtes de dialogue (`IModuleDialogs`), la
|
||||
navigation entre pages et le signalement d'une session perdue (retour à la connexion).
|
||||
`AdminPanelViewModel.RunAsync` gère « occupé », les messages d'erreur et la session expirée.
|
||||
* Ajouter une page à un module futur : une classe `AdminPanelViewModel` + son descripteur dans le
|
||||
module, et son `DataTemplate` dans `Themes/Admin.xaml` (ou un dictionnaire dédié fusionné dans
|
||||
`App.xaml`). La fenêtre d'administration elle-même ne change pas.
|
||||
|
||||
## 6. Authentification par clé publique/privée
|
||||
|
||||
### 6.1 Clés
|
||||
|
||||
* ECDSA **P-256 / SHA-256**, signature au format IEEE P1363 (64 octets) — même famille que la
|
||||
signature des mises à jour du launcher, entièrement fournie par .NET (aucune dépendance).
|
||||
* `keyId` = 16 premiers caractères hexadécimaux du SHA-256 de la clé publique (SPKI).
|
||||
* **Empreinte** affichée aux humains : 5 groupes de 4 caractères (`AB12 CD34 EF56 7890 1A2B`, 80 bits),
|
||||
calculée avec une séparation de domaine : elle ne se déduit pas du `keyId`, visible dans les listes.
|
||||
* Stockage de la clé privée sur le PC de l'administrateur :
|
||||
* **fichier chiffré** (phase 2 ✅) : PKCS#8, AES-256, PBKDF2-SHA256 600 000 itérations — comme la
|
||||
clé de signature du launcher — déverrouillé par une phrase de passe de 12 caractères minimum.
|
||||
Dossier proposé : `%USERPROFILE%\.launcher-keys` (celui de `tools\admin-key.bat`). La clé de
|
||||
signature du launcher (`launcher-signing*.key`), rangée dans le même dossier, n'est jamais
|
||||
proposée ni acceptée. Une clé n'écrase jamais un fichier existant ;
|
||||
* **puce de sécurité TPM 2.0** (phase 2b ✅, au choix lors de « J'ai une invitation ») : la clé
|
||||
ECDSA P-256 est **générée dans la puce** par le fournisseur Windows « Microsoft Platform Crypto
|
||||
Provider » et n'en sort jamais (non exportable) ; la puce signe elle-même. Clé de l'utilisateur
|
||||
Windows courant, protégée par un **code PIN** (6 caractères minimum, saisi dans le launcher,
|
||||
vérifié par la puce, qui se bloque temporairement après plusieurs codes faux). Sur le disque,
|
||||
seul un fichier de référence `*.tpmkey` (nom de la clé dans la puce + clé publique, aucun
|
||||
secret) et le `.pub` habituel. À la connexion, la clé publique de la puce est comparée à celle
|
||||
de la référence. Le choix n'est **dégrisé qu'après une vérification réelle de la puce**
|
||||
(création, signature et suppression d'une clé de test). Rien ne change côté serveur : même
|
||||
format de clé publique P-256, mêmes signatures. Limites : la clé ne peut pas être sauvegardée
|
||||
et disparaît avec la puce (carte mère, réinitialisation du TPM, Windows réinstallé) — garder
|
||||
une clé « fichier » de secours ; le serveur ne peut pas prouver qu'une clé est dans une puce
|
||||
(pas d'attestation) : c'est une protection pour l'administrateur, pas une règle du serveur ;
|
||||
le blocage anti-essais est commun à toute la puce (les codes faux comptent aussi pour le code
|
||||
PIN Windows Hello) ; avec un profil Windows itinérant, la clé n'est utilisable que sur le PC
|
||||
qui l'a créée. Le fichier `.tpmkey` ne désigne que des clés nommées `GameLauncher.Admin.*`
|
||||
du fournisseur de la puce, et sa clé publique est comparée à celle de la puce avant tout
|
||||
essai du code PIN.
|
||||
Écran de connexion : **« Supprimer cette clé de la puce »** (clés TPM uniquement, après
|
||||
confirmation ; efface la clé de la puce, le `.tpmkey` et le `.pub` — à révoquer aussi sur le
|
||||
serveur ; les clés fichier se suppriment à la main). Après un code faux, le nombre d'essais
|
||||
restants est affiché ; si la puce est bloquée, un **décompte** indique quand elle acceptera de
|
||||
nouveau un code (état lu dans la puce par TPM2_GetCapability via le service TBS : compteur,
|
||||
maximum, intervalle de « guérison » ; estimation, PC allumé). Si cet état n'est pas lisible sur
|
||||
le PC, une explication générale est affichée et un nouvel essai reste possible.
|
||||
* Windows Hello (visage, empreinte) : écarté pour l'instant, son API ne crée que des clés RSA,
|
||||
ce qui demanderait de faire évoluer le serveur.
|
||||
|
||||
### 6.2 Connexion (défi-réponse)
|
||||
|
||||
```
|
||||
launcher serveur
|
||||
│ POST /api/v1/auth/challenge {keyId} ───► défi SANS ÉTAT : clé + échéance (60 s) + nonce 256 bits,
|
||||
│ scellés par HMAC (secret en mémoire) : rien à stocker
|
||||
│ ◄─── {challengeId, nonce, serverId} (même réponse pour une clé inconnue)
|
||||
│ signe le message de connexion ─────────► POST /api/v1/auth/login
|
||||
│ vérifie : sceau, clé visée, échéance, signature,
|
||||
│ puis clé et compte actifs, défi jamais utilisé (rejeu)
|
||||
│ ◄─── jeton de session + droits jeton 256 bits (seule son empreinte est gardée)
|
||||
```
|
||||
|
||||
Message signé (séparation de domaine, une ligne par champ) :
|
||||
|
||||
```
|
||||
GameLauncher/Admin/Login/v1
|
||||
origin:https://launcher.lanisys.fr
|
||||
server:<serverId>
|
||||
challenge:<challengeId>
|
||||
nonce:<nonce>
|
||||
key:<keyId>
|
||||
```
|
||||
|
||||
`origin` (adresse publique de l'API) et `serverId` (identifiant aléatoire propre au serveur)
|
||||
empêchent de réutiliser une signature auprès d'un autre serveur.
|
||||
|
||||
### 6.3 Sessions
|
||||
|
||||
* En mémoire uniquement (un redémarrage du service déconnecte tout le monde) ; **20 min
|
||||
d'inactivité, 8 h au maximum**, 5 sessions par administrateur.
|
||||
* À chaque requête : session valide **et** administrateur actif **et** clé active (une révocation
|
||||
prend effet immédiatement, y compris depuis l'outil en ligne de commande).
|
||||
* **Côté launcher** (phase 2) : la clé déchiffrée n'existe en mémoire que pendant la session. Le menu
|
||||
se **verrouille** (session fermée, clé effacée, retour à la connexion) après **10 min sans activité**
|
||||
dans la fenêtre, à la fin des 8 h, quand la **session Windows est verrouillée** (Win+L, veille),
|
||||
sur `Ctrl+L` ou le bouton « Verrouiller », et si le serveur refuse la session (révocation). Tant
|
||||
que l'administrateur est actif, la session est prolongée sur le serveur toutes les 4 min. Fermer
|
||||
la fenêtre ou le launcher efface la clé, puis déconnecte la session. La phrase de passe n'est
|
||||
jamais conservée (champ effacé dès la tentative de connexion).
|
||||
* L'adresse de l'API (`apiUrl`) n'est lue, dans la version Release, que dans le `launcher.json`
|
||||
**intégré à l'exe** : un fichier posé à côté (ou `--config`) ne peut pas envoyer les
|
||||
administrateurs vers un autre serveur. Une valeur invalide est ignorée (le launcher des joueurs
|
||||
démarre toujours) et expliquée à l'ouverture du menu.
|
||||
|
||||
### 6.4 Requêtes signées
|
||||
|
||||
Toute requête qui **modifie** quelque chose porte, en plus du jeton, une signature de la clé de
|
||||
l'administrateur :
|
||||
|
||||
```
|
||||
GameLauncher/Admin/Request/v1
|
||||
origin:<origine>
|
||||
server:<serverId>
|
||||
session:<identifiant de session>
|
||||
method:POST
|
||||
path:/api/v1/admins/invitations
|
||||
body-sha256:<SHA-256 du corps>
|
||||
timestamp:<secondes Unix>
|
||||
nonce:<aléatoire>
|
||||
```
|
||||
|
||||
En-têtes `X-GL-Timestamp`, `X-GL-Nonce`, `X-GL-Signature`. Le serveur refuse une horloge décalée
|
||||
de plus de 2 minutes et tout nonce déjà vu (rejeu). Bénéfices : un jeton volé ne permet aucune
|
||||
modification ; le journal d'audit sait quelle clé a signé chaque modification.
|
||||
|
||||
### 6.5 Défenses complémentaires
|
||||
|
||||
| Menace | Défense |
|
||||
|---|---|
|
||||
| Force brute / saturation | Limites par IP (IPv6 : par /64 ; nginx + ASP.NET Core) ; défis sans état (aucune réserve à saturer) ; sessions en nombre borné |
|
||||
| Énumération des clés | Défi identique pour une clé connue ou inconnue ; erreurs génériques |
|
||||
| Rejeu | Défi à usage unique (60 s) ; nonce + horodatage des requêtes signées |
|
||||
| Afflux de tentatives dans l'audit | Échecs sans signature valide détaillés 10 fois par minute au plus, puis résumés ; échecs AVEC signature valide (clé révoquée ou en attente, rejeu) toujours détaillés |
|
||||
| Vol du jeton | Inutile pour modifier (requêtes signées) ; courte durée ; lié à la clé |
|
||||
| Fuite des données du serveur | Uniquement des clés publiques et des empreintes de codes d'invitation |
|
||||
| Compromission du compte `deploy` | Données de l'API hors du chroot SFTP (`/var/lib/launcher-api`) |
|
||||
| En-têtes falsifiés | `X-Forwarded-*` acceptés uniquement depuis 127.0.0.1 ; HTTPS exigé derrière nginx |
|
||||
| Usurpation par un autre administrateur | Un nouvel appareil n'est approuvé que par le titulaire du compte ; droits de l'auteur revérifiés au moment d'écrire |
|
||||
| Erreur de manipulation | Impossible de se révoquer soi-même, de changer son propre rôle, ou de supprimer le dernier propriétaire |
|
||||
| Perte de toutes les clés | Récupération par l'outil en ligne de commande, sur le VPS (accès SSH administrateur) |
|
||||
|
||||
## 7. Rôles et permissions
|
||||
|
||||
| Permission | Propriétaire | Administrateur | Éditeur | Utilisée par |
|
||||
|---|:-:|:-:|:-:|---|
|
||||
| `admins.manage` | ✔ | | | Gestion des administrateurs (phase 1 serveur, phase 6 interface) |
|
||||
| `audit.read` | ✔ | ✔ | | Journal d'audit |
|
||||
| `games.edit` | ✔ | ✔ | ✔ | Informations des jeux (phase 3) |
|
||||
| `changelog.edit` | ✔ | ✔ | ✔ | Notes de version (phase 4) |
|
||||
| `releases.publish` | ✔ | ✔ | | Publication d'une version (phase 5) |
|
||||
|
||||
Les permissions sont des chaînes « module.action » : un module futur déclare les siennes
|
||||
(`news.edit`, `community.moderate`…) et les rôles qui les reçoivent.
|
||||
|
||||
## 8. Ajouter un administrateur
|
||||
|
||||
1. Un **propriétaire** crée une invitation (nom, rôle) : le serveur renvoie **une seule fois** un
|
||||
code à usage unique (160 bits, `ABCD-EFGH-…`, valable 24 h ; seule son empreinte est stockée).
|
||||
2. Il transmet ce code au futur administrateur.
|
||||
3. Le futur administrateur ouvre le menu d'administration › « J'ai une invitation » : le launcher
|
||||
crée sa clé, l'envoie avec le code et une **preuve de possession** (signature), et affiche son
|
||||
**empreinte**.
|
||||
4. Il communique son empreinte au propriétaire **par un autre moyen** (téléphone, message).
|
||||
5. Le propriétaire approuve en **saisissant cette empreinte** (le serveur ne l'affiche pas pour les
|
||||
clés en attente : l'approbation prouve la vérification). Clé et compte deviennent actifs.
|
||||
(Phase 2 : étapes 3 et 4 dans le launcher ; création de l'invitation et approbation avec
|
||||
`tools\admin-key.bat` jusqu'à la page « Administrateurs » de la phase 6.)
|
||||
|
||||
**Ajouter un PC** à un compte existant (invitation « nouvel appareil ») : l'administrateur crée
|
||||
lui-même l'invitation, enrôle son nouveau PC, puis **approuve depuis un de ses PC déjà actifs**.
|
||||
Personne d'autre — pas même un propriétaire — ne peut ajouter une clé à un compte déjà actif
|
||||
(sinon un propriétaire pourrait révoquer les clés d'un autre puis y ajouter la sienne et agir sous
|
||||
son nom). Tous ses PC perdus : restauration sur le VPS (`add-key`, accès SSH), ou nouveau compte.
|
||||
Le **premier propriétaire** est créé sur le VPS avec l'outil en ligne de commande (preuve
|
||||
d'identité = accès SSH administrateur) :
|
||||
|
||||
```
|
||||
sudo -u deploy /opt/launcher-api/GameLauncher.Server add-owner --name "Nicolas" --public-key /tmp/owner.pub
|
||||
```
|
||||
|
||||
## 9. Informations fixes et informations modifiables des jeux (phase 3)
|
||||
|
||||
Les jeux sont **toujours déclarés dans `launcher.json`** : le serveur ne peut ni ajouter ni retirer
|
||||
un jeu, seulement modifier l'affichage des jeux déclarés. Comme `apiUrl`, la liste des jeux que le
|
||||
menu d'administration peut modifier sera lue, en Release, dans le `launcher.json` intégré à l'exe. Les champs actuels de `launcher.json` se
|
||||
répartissent ainsi :
|
||||
|
||||
| Champ | Reste dans l'exe | Modifiable (serveur) | Raison |
|
||||
|---|:-:|:-:|---|
|
||||
| `launcherId`, `dataFolder` | ✔ | | Identité du produit, dossiers et registre |
|
||||
| `publisher`, `gameId` | ✔ | | Identité, registre, raccourcis |
|
||||
| `manifestUrl`, `launcherUpdateUrl` | ✔ | | Sources de confiance |
|
||||
| `apiUrl`, `catalogUrl` (nouveaux) | ✔ | | Sources de confiance (jamais remplaçables par un `launcher.json` externe) |
|
||||
| `gameDataFolder` | ✔ | | Dossiers supprimés à la désinstallation : jamais piloté à distance |
|
||||
| `defaultInstallDirectory`, `gameExecutable`, `gameArguments` | ✔ | | Disque et exécution sur le PC du joueur |
|
||||
| `gameName`, `tagline` | valeur par défaut | ✔ | Affichage |
|
||||
| `description` (nouveau, texte long), `links` (Discord, YouTube… https) | | ✔ | Affichage |
|
||||
| `websiteUrl` | valeur par défaut | ✔ | https uniquement |
|
||||
| `theme` | valeur par défaut | ✔ | Uniquement un thème **intégré** (pas de XAML distant) |
|
||||
| `icon`, `background` | valeur par défaut | ✔ | Images https du même serveur, taille et dimensions bornées, mises en cache |
|
||||
| `updateCheckIntervalMinutes` | valeur par défaut | ✔ | Borné (0 ou 5 à 1440) |
|
||||
| `launcherName`, `defaultGame`, ordre et visibilité des jeux | valeur par défaut | ✔ | Affichage |
|
||||
|
||||
Fonctionnement côté joueur : `catalog.json` (statique, sur le serveur) est lu au démarrage,
|
||||
validé et borné comme les notes de version, puis **mis en cache** (`%AppData%\…\GameLauncher`).
|
||||
Sans réseau : dernier cache, sinon valeurs de `launcher.json`. Le serveur écrit `catalog.json`
|
||||
seulement via l'API (permission `games.edit`, requête signée).
|
||||
|
||||
## 10. Notes de version (phase 4)
|
||||
|
||||
* Éditeur graphique dans le menu admin : arborescence version › sections › éléments, édition des
|
||||
champs du format enrichi, **aperçu en direct avec les mêmes modèles que le launcher**
|
||||
(`Themes/Changelog.xaml`) et les mêmes bornes (`ChangelogLimits`).
|
||||
* On édite **les versions existantes** uniquement ; la création d'une version reste au `.bat`.
|
||||
* Enregistrement : le launcher envoie le `manifest.json` modifié avec l'empreinte de la version
|
||||
qu'il a lue (concurrence optimiste) ; le serveur vérifie que **seules les notes ont changé**
|
||||
(fichiers, archive, version, exécutable identiques), valide, sauvegarde l'ancien manifest puis
|
||||
remplace atomiquement.
|
||||
* **Cohérence avec ManifestBuilder** : aujourd'hui il reprend l'historique des notes depuis le
|
||||
`manifest.json` *local*, ce qui écraserait les corrections faites en ligne. ManifestBuilder
|
||||
recevra une option `--remote-manifest <url>` (ajoutée au `.bat`) : l'historique est repris du
|
||||
serveur, seule la nouvelle version vient de `notes.txt`.
|
||||
|
||||
## 11. Publication d'une version (phase 5)
|
||||
|
||||
Depuis le menu admin › « Publier une version » : choix du dossier produit par le `.bat`, contrôle
|
||||
local (manifest valide, archive et objets présents avec les bonnes empreintes), puis envoi :
|
||||
|
||||
1. `objects/` : seuls les objets **absents du serveur** (le serveur répond à une liste d'empreintes) ;
|
||||
2. l'archive ZIP, **par morceaux** (8 Mo, reprise possible, chaque morceau et le fichier final
|
||||
vérifiés par SHA-256) ;
|
||||
3. `manifest.json` **en dernier**, remplacé atomiquement (même règle qu'avec WinSCP).
|
||||
|
||||
Le serveur vérifie que chaque fichier référencé existe avec la bonne taille et la bonne empreinte
|
||||
avant d'accepter le manifest. L'historique des notes du serveur est conservé.
|
||||
|
||||
## 12. Signature de bout en bout pour les joueurs (phase 7, recommandée)
|
||||
|
||||
Aujourd'hui `manifest.json` n'est pas signé : une prise de contrôle du VPS permettrait de
|
||||
distribuer un faux jeu. La phase 7 ajoute :
|
||||
|
||||
* `keyring.json` : liste des clés publiques des administrateurs, **signée par la clé racine**
|
||||
(votre clé de signature du launcher, déjà intégrée à l'exe) ;
|
||||
* une **signature détachée** (`manifest.json.sig`, `catalog.json.sig`) produite par la clé de
|
||||
l'administrateur au moment de la publication ou de l'édition — les anciens launchers ignorent
|
||||
ces fichiers ;
|
||||
* côté joueur, vérification puis refus de tout contenu non signé par une clé du trousseau.
|
||||
|
||||
Le format des requêtes signées de la phase 1 est déjà compatible avec ce modèle.
|
||||
|
||||
## 13. Phases
|
||||
|
||||
| Phase | Contenu | Statut |
|
||||
|---|---|---|
|
||||
| **0** | Architecture (ce document) | ✅ |
|
||||
| **1** | Contrats partagés ; Launcher API (modules, authentification par clé, sessions, requêtes signées, invitations/enrôlement/approbation/révocation, audit, limites, CLI) ; client API ; outil `AdminKeyTool` ; tests ; fichiers de déploiement (systemd, nginx) | ✅ |
|
||||
| **2** | Launcher : socle des modules (`GameLauncher.Modules`), module Administration (`GameLauncher.Modules.Admin`), entrée discrète (Ctrl+Maj+Alt+A ou 5 clics sur le logo), fenêtre d'administration (navigation, session, verrouillage auto), clé en fichier chiffré, connexion, « J'ai une invitation », tableau de bord | ✅ |
|
||||
| **2b** | Clé dans la puce TPM 2.0 (non exportable, code PIN vérifié par la puce), au choix en plus du fichier chiffré | ✅ |
|
||||
| **3** | Séparation fixe/modifiable : `catalog.json`, lecture + cache côté joueur, module serveur `Catalog`, page admin « Jeux » | à faire |
|
||||
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | à faire |
|
||||
| **5** | Publication d'une version depuis le menu admin (envoi par morceaux, reprise, vérifications) | à faire |
|
||||
| **6** | Pages admin « Administrateurs » (invitations, approbation par empreinte, rôles, révocation, appareils) et « Journal d'audit » | à faire |
|
||||
| **7** | Signature de bout en bout (trousseau signé par la clé racine, signatures détachées, vérification côté joueur) | recommandée |
|
||||
|
||||
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.
|
||||
|
||||
## 14. Choix écartés
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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,257 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Outil en ligne de commande, à lancer SUR LE VPS sous le compte du service (accès SSH
|
||||
/// administrateur = preuve d'identité). Sert à créer le premier propriétaire et à reprendre la main
|
||||
/// si toutes les clés sont perdues. Le service n'a pas besoin d'être arrêté : il relit admins.json.
|
||||
///
|
||||
/// sudo -u deploy /opt/launcher-api/GameLauncher.Server add-owner --name "Nicolas" --public-key /tmp/owner.pub
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
public static int Run(string[] args, TextWriter? output = null, string? dataDirectoryOverride = null)
|
||||
{
|
||||
var o = output ?? Console.Out;
|
||||
try
|
||||
{
|
||||
var command = args[0].ToLowerInvariant();
|
||||
if (command == "help") return Help(o);
|
||||
var options = ReadOptions(args);
|
||||
|
||||
// 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.
|
||||
if (dataDirectoryOverride is null && !OperatingSystem.IsWindows() && Environment.UserName == "root")
|
||||
throw new InvalidOperationException(
|
||||
"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" &&
|
||||
!File.Exists(Path.Combine(dataDirectory, AdminStore.FileName)))
|
||||
throw new InvalidOperationException(
|
||||
$"aucun fichier {AdminStore.FileName} dans {dataDirectory} : mauvais dossier de données ? (option --data-dir)");
|
||||
if (output is null) Console.Error.WriteLine($"Données : {dataDirectory}");
|
||||
|
||||
var store = new AdminStore(dataDirectory);
|
||||
var audit = new AuditLog(dataDirectory, TimeProvider.System);
|
||||
var actor = $"cli ({Environment.UserName})";
|
||||
|
||||
switch (command)
|
||||
{
|
||||
case "add-owner": return AddOwner(store, audit, actor, options, o);
|
||||
case "add-key": return AddKey(store, audit, actor, options, o);
|
||||
case "list": return List(store, o);
|
||||
case "revoke-admin": return RevokeAdmin(store, audit, actor, options, o);
|
||||
case "revoke-key": return RevokeKey(store, audit, actor, options, o);
|
||||
case "server-id":
|
||||
o.WriteLine(ServerIdentity.LoadOrCreate(dataDirectory));
|
||||
return 0;
|
||||
default: return Help(o);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or InvalidDataException or IOException
|
||||
or UnauthorizedAccessException or CryptographicException or ApiRejection or StoreBusyException)
|
||||
{
|
||||
(output ?? Console.Error).WriteLine($"Erreur : {ex.Message}");
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private static int AddOwner(AdminStore store, AuditLog audit, string actor, Dictionary<string, string> options, TextWriter o)
|
||||
{
|
||||
var name = ApiText.CleanLine(Get(options, "name"), ApiText.MaxDisplayNameLength)
|
||||
?? throw new ArgumentException("--name obligatoire (64 caractères au plus).");
|
||||
var keyArg = Get(options, "public-key") ?? throw new ArgumentException("--public-key obligatoire (fichier .pub ou Base64).");
|
||||
var keyText = File.Exists(keyArg) ? File.ReadAllText(keyArg) : keyArg;
|
||||
var spki = AdminKeys.ParsePublicKey(keyText);
|
||||
var keyId = AdminKeys.ComputeKeyId(spki);
|
||||
var label = ApiText.CleanLine(Get(options, "label"), ApiText.MaxKeyLabelLength) ?? "Clé ajoutée sur le serveur";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var id = store.Update(db =>
|
||||
{
|
||||
if (db.FindKey(keyId) is not null) throw new InvalidOperationException($"La clé {keyId} est déjà enregistrée.");
|
||||
var admin = new AdminRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DisplayName = name,
|
||||
Role = AdminRoles.Owner,
|
||||
Status = AdminStatus.Active,
|
||||
CreatedAt = now,
|
||||
CreatedBy = actor,
|
||||
ApprovedAt = now,
|
||||
ApprovedBy = actor,
|
||||
Keys =
|
||||
{
|
||||
new KeyRecord
|
||||
{
|
||||
KeyId = keyId, PublicKey = Convert.ToBase64String(spki), Label = label,
|
||||
Status = AdminStatus.Active, AddedAt = now, ApprovedAt = now, ApprovedBy = actor
|
||||
}
|
||||
}
|
||||
};
|
||||
db.Admins.Add(admin);
|
||||
return admin.Id;
|
||||
});
|
||||
audit.Write("cli.add_owner", AuditResults.Ok, actorName: actor, keyId: keyId, target: name);
|
||||
o.WriteLine($"Propriétaire créé : {name} ({id:D})");
|
||||
o.WriteLine($" Clé : {keyId}");
|
||||
o.WriteLine($" Empreinte: {AdminKeys.ComputeFingerprint(spki)} ← doit être celle affichée à la création de la clé");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Restaure l'accès d'un administrateur qui a perdu tous ses appareils (clé active immédiatement).</summary>
|
||||
private static int AddKey(AdminStore store, AuditLog audit, string actor, Dictionary<string, string> options, TextWriter o)
|
||||
{
|
||||
if (!Guid.TryParse(Get(options, "admin"), out var adminId)) throw new ArgumentException("--admin <identifiant> obligatoire (voir « list »).");
|
||||
var keyArg = Get(options, "public-key") ?? throw new ArgumentException("--public-key obligatoire (fichier .pub ou Base64).");
|
||||
var spki = AdminKeys.ParsePublicKey(File.Exists(keyArg) ? File.ReadAllText(keyArg) : keyArg);
|
||||
var keyId = AdminKeys.ComputeKeyId(spki);
|
||||
var label = ApiText.CleanLine(Get(options, "label"), ApiText.MaxKeyLabelLength) ?? "Clé ajoutée sur le serveur";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
var admin = db.FindAdmin(adminId) ?? throw new InvalidOperationException("Administrateur introuvable.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw new InvalidOperationException("Cet administrateur est révoqué : créez un nouveau compte.");
|
||||
if (db.FindKey(keyId) is not null) throw new InvalidOperationException($"La clé {keyId} est déjà enregistrée.");
|
||||
admin.Keys.Add(new KeyRecord
|
||||
{
|
||||
KeyId = keyId, PublicKey = Convert.ToBase64String(spki), Label = label,
|
||||
Status = AdminStatus.Active, AddedAt = now, ApprovedAt = now, ApprovedBy = actor
|
||||
});
|
||||
if (admin.Status == AdminStatus.Pending)
|
||||
{
|
||||
admin.Status = AdminStatus.Active;
|
||||
admin.ApprovedAt = now;
|
||||
admin.ApprovedBy = actor;
|
||||
}
|
||||
return admin.DisplayName;
|
||||
});
|
||||
audit.Write("cli.add_key", AuditResults.Ok, actorName: actor, keyId: keyId, target: name);
|
||||
o.WriteLine($"Clé ajoutée à {name} : {keyId}");
|
||||
o.WriteLine($" Empreinte: {AdminKeys.ComputeFingerprint(spki)} ← doit être celle affichée à la création de la clé");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int List(AdminStore store, TextWriter o)
|
||||
{
|
||||
var db = store.Read();
|
||||
if (db.Admins.Count == 0) o.WriteLine("Aucun administrateur.");
|
||||
foreach (var admin in db.Admins.OrderBy(a => a.CreatedAt))
|
||||
{
|
||||
o.WriteLine($"{admin.DisplayName} [{AdminRoles.DisplayName(admin.Role)}, {admin.Status}] {admin.Id:D}");
|
||||
foreach (var key in admin.Keys)
|
||||
o.WriteLine($" clé {key.KeyId} {AdminKeys.ComputeFingerprint(key.PublicKeyBytes())} {key.Status,-8} {key.Label}" +
|
||||
(key.LastUsedAt is { } used ? $" (dernière connexion {used:yyyy-MM-dd HH:mm} UTC)" : ""));
|
||||
}
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var invitation in db.Invitations.Where(i => i.ExpiresAt > now))
|
||||
o.WriteLine($"Invitation {invitation.Id:D} : {invitation.DisplayName} ({invitation.Role}), expire {invitation.ExpiresAt:yyyy-MM-dd HH:mm} UTC");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int RevokeAdmin(AdminStore store, AuditLog audit, string actor, Dictionary<string, string> options, TextWriter o)
|
||||
{
|
||||
if (!Guid.TryParse(Get(options, "id"), out var id)) throw new ArgumentException("--id <identifiant> obligatoire (voir « list »).");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
var admin = db.FindAdmin(id) ?? throw new InvalidOperationException("Administrateur introuvable.");
|
||||
admin.Status = AdminStatus.Revoked;
|
||||
admin.RevokedAt = now;
|
||||
admin.RevokedBy = actor;
|
||||
foreach (var key in admin.Keys.Where(k => k.Status != AdminStatus.Revoked))
|
||||
{
|
||||
key.Status = AdminStatus.Revoked;
|
||||
key.RevokedAt = now;
|
||||
key.RevokedBy = actor;
|
||||
}
|
||||
db.Invitations.RemoveAll(i => i.ForAdminId == id);
|
||||
return admin.DisplayName;
|
||||
});
|
||||
audit.Write("cli.revoke_admin", AuditResults.Ok, actorName: actor, target: name);
|
||||
o.WriteLine($"Administrateur révoqué : {name}. Ses sessions sont fermées à sa prochaine requête.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int RevokeKey(AdminStore store, AuditLog audit, string actor, Dictionary<string, string> options, TextWriter o)
|
||||
{
|
||||
var keyId = Get(options, "key")?.ToLowerInvariant();
|
||||
if (!AdminKeys.IsValidKeyId(keyId)) throw new ArgumentException("--key <identifiant de clé> obligatoire (voir « list »).");
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
var (admin, key) = db.FindKey(keyId!) ?? throw new InvalidOperationException("Clé introuvable.");
|
||||
key.Status = AdminStatus.Revoked;
|
||||
key.RevokedAt = DateTimeOffset.UtcNow;
|
||||
key.RevokedBy = actor;
|
||||
return admin.DisplayName;
|
||||
});
|
||||
audit.Write("cli.revoke_key", AuditResults.Ok, actorName: actor, keyId: keyId, target: name);
|
||||
o.WriteLine($"Clé {keyId} de {name} révoquée.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int Help(TextWriter o)
|
||||
{
|
||||
o.WriteLine("""
|
||||
Launcher API — outil d'administration (à lancer sur le VPS, sous le compte du service)
|
||||
|
||||
add-owner --name <nom> --public-key <fichier.pub | Base64> [--label <poste>]
|
||||
Crée un propriétaire actif (premier administrateur, ou reprise de la main).
|
||||
add-key --admin <identifiant> --public-key <fichier.pub | Base64> [--label <poste>]
|
||||
Restaure l'accès d'un administrateur qui a perdu tous ses appareils.
|
||||
list Administrateurs, clés (empreintes) et invitations en cours.
|
||||
revoke-admin --id <identifiant> Révoque un administrateur et toutes ses clés.
|
||||
revoke-key --key <identifiant> Révoque une clé (poste perdu ou volé).
|
||||
server-id Affiche l'identifiant de ce serveur.
|
||||
|
||||
Option commune : --data-dir <dossier> (sinon LauncherApi:DataDirectory d'appsettings.json).
|
||||
""");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ReadOptions(string[] args)
|
||||
{
|
||||
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 1; i < args.Length; i++)
|
||||
{
|
||||
if (!args[i].StartsWith("--", StringComparison.Ordinal)) throw new ArgumentException($"Argument inattendu : {args[i]}");
|
||||
if (i + 1 >= args.Length) throw new ArgumentException($"Valeur manquante pour {args[i]}");
|
||||
options[args[i][2..]] = args[++i];
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Même configuration que le service : appsettings.json, puis appsettings.{environnement}.json
|
||||
/// (Production par défaut, comme le service systemd), puis variables d'environnement.
|
||||
/// </summary>
|
||||
private static string ResolveDataDirectory(Dictionary<string, string> options)
|
||||
{
|
||||
if (Get(options, "data-dir") is { Length: > 0 } explicitDirectory) return Path.GetFullPath(explicitDirectory);
|
||||
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
|
||||
?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production";
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddJsonFile($"appsettings.{environment}.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
var directory = configuration[$"{LauncherApiOptions.Section}:DataDirectory"] ?? new LauncherApiOptions().DataDirectory;
|
||||
return Path.GetFullPath(Path.IsPathRooted(directory) ? directory : Path.Combine(AppContext.BaseDirectory, directory));
|
||||
}
|
||||
|
||||
private static string? Get(Dictionary<string, string> options, string name) =>
|
||||
options.TryGetValue(name, out var value) ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<!--
|
||||
Launcher API : serveur d'administration (et, plus tard, des fonctionnalités « joueurs »).
|
||||
Déployé sur le VPS derrière nginx (https://<hote>/api/), service systemd durci.
|
||||
Voir docs\ADMIN-ARCHITECTURE.md et server\deploy\LISEZMOI.md.
|
||||
|
||||
Publication pour le VPS (autonome linux-x64 : aucun runtime .NET à installer sur le serveur) :
|
||||
tools\publish-server.bat
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.Server</RootNamespace>
|
||||
<AssemblyName>GameLauncher.Server</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
<Version>1.0.0</Version>
|
||||
<Product>Launcher API</Product>
|
||||
<!-- Pas de dépendance à libicu sur le VPS ; les textes restent en UTF-8. -->
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<!-- Petit VPS : ramasse-miettes « poste de travail », moins gourmand en mémoire. -->
|
||||
<ServerGarbageCollection>false</ServerGarbageCollection>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="GameLauncher.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,23 @@
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Refus d'une requête (levé n'importe où, y compris dans <see cref="Storage.AdminStore.Update{T}"/> :
|
||||
/// rien n'est alors écrit). Transformé en réponse <see cref="ApiError"/> par ServerApp.
|
||||
/// </summary>
|
||||
public sealed class ApiRejection : Exception
|
||||
{
|
||||
public ApiRejection(int status, string code, string message) : base(message)
|
||||
{
|
||||
Status = status;
|
||||
Code = code;
|
||||
}
|
||||
|
||||
public int Status { get; }
|
||||
public string Code { get; }
|
||||
|
||||
public static ApiRejection BadRequest(string message, string code = ApiErrorCodes.InvalidRequest) => new(400, code, message);
|
||||
public static ApiRejection NotFound(string message = "Élément introuvable.") => new(404, ApiErrorCodes.NotFound, message);
|
||||
public static ApiRejection Conflict(string message, string code = ApiErrorCodes.Conflict) => new(409, code, message);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
/// <summary>Réponses d'erreur uniformes (<see cref="ApiError"/>), sans détail interne.</summary>
|
||||
public static class ApiResults
|
||||
{
|
||||
public static IResult Error(int status, string code, string message) =>
|
||||
Results.Json(new ApiError { Status = status, Code = code, Message = message }, ApiJson.Options, statusCode: status);
|
||||
|
||||
public static IResult BadRequest(string message, string code = ApiErrorCodes.InvalidRequest) => Error(400, code, message);
|
||||
public static IResult Unauthorized(string message = "Authentification requise.", string code = ApiErrorCodes.Unauthorized) =>
|
||||
Error(401, code, message);
|
||||
public static IResult Forbidden(string message = "Action non autorisée.") => Error(403, ApiErrorCodes.Forbidden, message);
|
||||
public static IResult NotFound(string message = "Élément introuvable.") => Error(404, ApiErrorCodes.NotFound, message);
|
||||
public static IResult Conflict(string message, string code = ApiErrorCodes.Conflict) => Error(409, code, message);
|
||||
public static IResult Busy() => Error(503, ApiErrorCodes.Busy, "Serveur momentanément occupé. Réessayez dans un instant.");
|
||||
|
||||
/// <summary>Écrit une erreur directement dans la réponse (middlewares, authentification).</summary>
|
||||
public static Task WriteAsync(HttpContext context, int status, string code, string message)
|
||||
{
|
||||
context.Response.StatusCode = status;
|
||||
return context.Response.WriteAsJsonAsync(new ApiError { Status = status, Code = code, Message = message }, ApiJson.Options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
public static class HttpContextExtensions
|
||||
{
|
||||
/// <summary>Adresse IP du client (celle transmise par nginx, seul proxy de confiance).</summary>
|
||||
public static string? ClientIp(this HttpContext context) => context.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
/// <summary>
|
||||
/// Partition des limites de débit : adresse IPv4, ou préfixe /64 en IPv6 (un abonné dispose
|
||||
/// généralement de tout un /64 : sans regroupement, chaque adresse aurait sa propre limite).
|
||||
/// </summary>
|
||||
public static string RateLimitPartition(this HttpContext context)
|
||||
{
|
||||
var ip = context.Connection.RemoteIpAddress;
|
||||
if (ip is null) return "inconnue";
|
||||
if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4();
|
||||
if (ip.AddressFamily != AddressFamily.InterNetworkV6) return ip.ToString();
|
||||
var bytes = ip.GetAddressBytes();
|
||||
Array.Clear(bytes, 8, 8);
|
||||
return new IPAddress(bytes) + "/64";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Module du serveur : enregistre ses services et ses routes (sous « /api »). Les modules futurs
|
||||
/// (catalogue, notes de version, publication, puis fonctionnalités « joueurs ») s'ajoutent à
|
||||
/// <see cref="ApiModules.All"/> sans toucher au reste.
|
||||
///
|
||||
/// Règles : routes de lecture protégées par RequirePermission(…) (ou RequireAdmin()) ; routes qui
|
||||
/// modifient protégées en plus par RequireSignedRequest() ; chaque modification écrite dans l'audit.
|
||||
/// </summary>
|
||||
public interface IApiModule
|
||||
{
|
||||
string Name { get; }
|
||||
|
||||
void ConfigureServices(IServiceCollection services, LauncherApiOptions options);
|
||||
|
||||
void MapEndpoints(IEndpointRouteBuilder api);
|
||||
}
|
||||
|
||||
public static class ApiModules
|
||||
{
|
||||
/// <summary>Modules actifs, dans l'ordre d'enregistrement.</summary>
|
||||
public static IReadOnlyList<IApiModule> All { get; } = new IApiModule[]
|
||||
{
|
||||
new Modules.System.SystemModule(),
|
||||
new Modules.Auth.AuthModule(),
|
||||
new Modules.Admins.AdminsModule(),
|
||||
new Modules.Audit.AuditModule()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Noms des limites de débit (voir ServerApp).</summary>
|
||||
public static class RateLimitPolicies
|
||||
{
|
||||
public const string Auth = "auth";
|
||||
public const string Enrollment = "enrollment";
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Net;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
/// <summary>Réglages de la Launcher API (section « LauncherApi » d'appsettings.json ou variables d'environnement).</summary>
|
||||
public sealed class LauncherApiOptions
|
||||
{
|
||||
public const string Section = "LauncherApi";
|
||||
|
||||
/// <summary>
|
||||
/// Dossier des données privées (administrateurs, audit, identifiant du serveur). Jamais dans le
|
||||
/// dossier servi par nginx. Relatif : par rapport au dossier de l'application.
|
||||
/// </summary>
|
||||
public string DataDirectory { get; set; } = "/var/lib/launcher-api";
|
||||
|
||||
/// <summary>
|
||||
/// Adresse publique de l'API, sans chemin (ex. « https://launcher.lanisys.fr »). Incluse dans les
|
||||
/// messages signés : une signature produite pour un autre serveur est refusée.
|
||||
/// </summary>
|
||||
public string PublicOrigin { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Exige que les requêtes arrivent en HTTPS par le proxy (en-tête X-Forwarded-Proto de nginx).
|
||||
/// Seule la route de santé reste accessible en HTTP, et seulement depuis la machine elle-même.
|
||||
/// </summary>
|
||||
public bool RequireHttps { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Proxys autorisés à transmettre l'adresse et le protocole du client (X-Forwarded-For/Proto).
|
||||
/// Vide : nginx sur la même machine (127.0.0.1 et ::1). Pas de valeur par défaut dans le tableau :
|
||||
/// la configuration .NET AJOUTE les valeurs lues à celles d'un tableau déjà rempli, ce qui
|
||||
/// laisserait 127.0.0.1 de confiance même si la configuration en désigne d'autres.
|
||||
/// </summary>
|
||||
public string[] TrustedProxies { get; set; } = Array.Empty<string>();
|
||||
|
||||
private static readonly string[] DefaultTrustedProxies = { "127.0.0.1", "::1" };
|
||||
|
||||
public SessionSettings Sessions { get; set; } = new();
|
||||
|
||||
public LimitSettings Limits { get; set; } = new();
|
||||
|
||||
/// <summary>Durée de validité d'un défi de connexion.</summary>
|
||||
public int ChallengeLifetimeSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>Durée de validité d'un code d'invitation.</summary>
|
||||
public int InvitationLifetimeHours { get; set; } = 24;
|
||||
|
||||
public sealed class SessionSettings
|
||||
{
|
||||
public int IdleMinutes { get; set; } = 20;
|
||||
public int MaxHours { get; set; } = 8;
|
||||
public int MaxPerAdmin { get; set; } = 5;
|
||||
public int MaxTotal { get; set; } = 500;
|
||||
}
|
||||
|
||||
public sealed class LimitSettings
|
||||
{
|
||||
/// <summary>Requêtes par minute et par adresse IP, toutes routes confondues.</summary>
|
||||
public int RequestsPerMinute { get; set; } = 300;
|
||||
|
||||
/// <summary>Défis et connexions par minute et par adresse IP.</summary>
|
||||
public int AuthPerMinute { get; set; } = 20;
|
||||
|
||||
/// <summary>Enrôlements (codes d'invitation) par heure et par adresse IP.</summary>
|
||||
public int EnrollmentsPerHour { get; set; } = 10;
|
||||
|
||||
/// <summary>Invitations actives simultanément.</summary>
|
||||
public int MaxActiveInvitations { get; set; } = 20;
|
||||
|
||||
/// <summary>Taille maximale d'un corps de requête (octets).</summary>
|
||||
public long MaxRequestBodyBytes { get; set; } = 64 * 1024;
|
||||
}
|
||||
|
||||
/// <summary>Origine normalisée (« https://hote[:port] »), calculée par <see cref="Validate"/>.</summary>
|
||||
public string NormalizedOrigin { get; private set; } = "";
|
||||
|
||||
/// <summary>Dossier des données en chemin absolu, calculé par <see cref="Validate"/>.</summary>
|
||||
public string DataDirectoryFullPath { get; private set; } = "";
|
||||
|
||||
public IReadOnlyList<IPAddress> TrustedProxyAddresses { get; private set; } = Array.Empty<IPAddress>();
|
||||
|
||||
/// <summary>Vérifie les réglages au démarrage : un serveur mal configuré ne démarre pas.</summary>
|
||||
public void Validate(string contentRoot)
|
||||
{
|
||||
if (!Uri.TryCreate(PublicOrigin, UriKind.Absolute, out var origin) ||
|
||||
!string.IsNullOrEmpty(origin.Query) || origin.AbsolutePath != "/")
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:PublicOrigin doit être une adresse sans chemin, ex. « https://launcher.lanisys.fr ».");
|
||||
if (origin.Scheme != Uri.UriSchemeHttps && !(origin.Scheme == Uri.UriSchemeHttp && origin.IsLoopback && !RequireHttps))
|
||||
throw new InvalidOperationException($"{Section}:PublicOrigin doit être en https://.");
|
||||
NormalizedOrigin = Contracts.Security.SignedMessages.NormalizeOrigin(origin);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DataDirectory))
|
||||
throw new InvalidOperationException($"{Section}:DataDirectory est obligatoire.");
|
||||
DataDirectoryFullPath = Path.GetFullPath(Path.IsPathRooted(DataDirectory)
|
||||
? DataDirectory
|
||||
: Path.Combine(contentRoot, DataDirectory));
|
||||
|
||||
var proxies = new List<IPAddress>();
|
||||
foreach (var proxy in TrustedProxies is { Length: > 0 } configured ? configured : DefaultTrustedProxies)
|
||||
{
|
||||
if (!IPAddress.TryParse(proxy, out var address))
|
||||
throw new InvalidOperationException($"{Section}:TrustedProxies : adresse invalide « {proxy} ».");
|
||||
proxies.Add(address);
|
||||
}
|
||||
TrustedProxyAddresses = proxies;
|
||||
|
||||
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
|
||||
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
|
||||
Require(Sessions.MaxPerAdmin is >= 1 and <= 50, "Sessions:MaxPerAdmin (1 à 50)");
|
||||
Require(Sessions.MaxTotal is >= 1 and <= 100_000, "Sessions:MaxTotal");
|
||||
Require(ChallengeLifetimeSeconds is >= 10 and <= 600, "ChallengeLifetimeSeconds (10 à 600)");
|
||||
Require(InvitationLifetimeHours is >= 1 and <= 168, "InvitationLifetimeHours (1 à 168)");
|
||||
Require(Limits.RequestsPerMinute >= 1 && Limits.AuthPerMinute >= 1 && Limits.EnrollmentsPerHour >= 1, "Limits");
|
||||
Require(Limits.MaxActiveInvitations is >= 1 and <= 1000, "Limits:MaxActiveInvitations");
|
||||
Require(Limits.MaxRequestBodyBytes is >= 1024 and <= 16 * 1024 * 1024, "Limits:MaxRequestBodyBytes");
|
||||
}
|
||||
|
||||
private static void Require(bool condition, string name)
|
||||
{
|
||||
if (!condition) throw new InvalidOperationException($"{Section}:{name} : valeur hors limites.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Threading.RateLimiting;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Construction du serveur : réglages, sécurité (proxy, HTTPS, en-têtes, limites, authentification)
|
||||
/// puis modules. Utilisé par Program et par les tests (serveur démarré en mémoire).
|
||||
/// </summary>
|
||||
public static class ServerApp
|
||||
{
|
||||
public static WebApplication Build(string[] args, Action<WebApplicationBuilder>? configure = null)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
{
|
||||
Args = args,
|
||||
ContentRootPath = AppContext.BaseDirectory // appsettings.json à côté de l'exécutable, quel que soit le dossier courant
|
||||
});
|
||||
configure?.Invoke(builder);
|
||||
|
||||
var options = builder.Configuration.GetSection(LauncherApiOptions.Section).Get<LauncherApiOptions>() ?? new LauncherApiOptions();
|
||||
options.Validate(builder.Environment.ContentRootPath);
|
||||
builder.Services.AddSingleton(Options.Create(options));
|
||||
builder.Services.TryAddTimeProvider();
|
||||
|
||||
builder.WebHost.ConfigureKestrel(kestrel =>
|
||||
{
|
||||
kestrel.AddServerHeader = false;
|
||||
kestrel.Limits.MaxRequestBodySize = options.Limits.MaxRequestBodyBytes;
|
||||
kestrel.Limits.MaxRequestHeaderCount = 40;
|
||||
kestrel.Limits.MaxRequestHeadersTotalSize = 16 * 1024;
|
||||
kestrel.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(forwarded =>
|
||||
{
|
||||
forwarded.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
forwarded.ForwardLimit = 1;
|
||||
forwarded.KnownNetworks.Clear();
|
||||
forwarded.KnownProxies.Clear();
|
||||
foreach (var proxy in options.TrustedProxyAddresses) forwarded.KnownProxies.Add(proxy);
|
||||
});
|
||||
|
||||
builder.Services.ConfigureHttpJsonOptions(json =>
|
||||
{
|
||||
var source = ApiJson.Options;
|
||||
json.SerializerOptions.PropertyNamingPolicy = source.PropertyNamingPolicy;
|
||||
json.SerializerOptions.PropertyNameCaseInsensitive = source.PropertyNameCaseInsensitive;
|
||||
json.SerializerOptions.DefaultIgnoreCondition = source.DefaultIgnoreCondition;
|
||||
json.SerializerOptions.ReadCommentHandling = source.ReadCommentHandling;
|
||||
json.SerializerOptions.AllowTrailingCommas = source.AllowTrailingCommas;
|
||||
json.SerializerOptions.MaxDepth = source.MaxDepth;
|
||||
json.SerializerOptions.NumberHandling = source.NumberHandling;
|
||||
});
|
||||
// Corps JSON invalide : exception transformée en erreur 400 lisible (voir HandleErrors).
|
||||
builder.Services.Configure<Microsoft.AspNetCore.Routing.RouteHandlerOptions>(o => o.ThrowOnBadRequest = true);
|
||||
|
||||
builder.Services.AddRateLimiter(limiter =>
|
||||
{
|
||||
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
limiter.OnRejected = (context, _) => new ValueTask(ApiResults.WriteAsync(context.HttpContext, 429,
|
||||
ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute avant de réessayer."));
|
||||
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.RequestsPerMinute, TimeSpan.FromMinutes(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.Auth, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.AuthPerMinute, TimeSpan.FromMinutes(1))));
|
||||
limiter.AddPolicy(RateLimitPolicies.Enrollment, context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.EnrollmentsPerHour, TimeSpan.FromHours(1))));
|
||||
});
|
||||
|
||||
// Protection des données d'ASP.NET Core (cookies, jetons anti-falsification) : inutilisée par l'API,
|
||||
// mais initialisée par le framework. Ses clés restent dans le dossier privé des données (rien dans $HOME).
|
||||
var dataProtection = Path.Combine(options.DataDirectoryFullPath, "data-protection");
|
||||
SecureFiles.EnsureDirectory(options.DataDirectoryFullPath);
|
||||
SecureFiles.EnsureDirectory(dataProtection);
|
||||
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(dataProtection));
|
||||
builder.Logging.AddFilter("Microsoft.AspNetCore.DataProtection", LogLevel.Error);
|
||||
|
||||
builder.Services.AddSingleton<ServerIdentity>();
|
||||
builder.Services.AddSingleton<AdminStore>();
|
||||
builder.Services.AddAdminAuthentication();
|
||||
foreach (var module in ApiModules.All) module.ConfigureServices(builder.Services, options);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
app.Use(SecurityHeaders);
|
||||
app.Use(HandleErrors);
|
||||
app.Use((context, next) => RequireHttps(context, next, options));
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseRequestBodyCapture(); // après les limites et les droits : une requête refusée n'est pas lue
|
||||
|
||||
var api = app.MapGroup(ApiRoutes.Prefix);
|
||||
foreach (var module in ApiModules.All) module.MapEndpoints(api);
|
||||
app.MapFallback((HttpContext context) => ApiResults.NotFound("Adresse inconnue."));
|
||||
|
||||
// Identité et fichiers vérifiés dès le démarrage (un fichier illisible empêche le démarrage).
|
||||
var identity = app.Services.GetRequiredService<ServerIdentity>();
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
private static FixedWindowRateLimiterOptions Window(int permits, TimeSpan window) => new()
|
||||
{
|
||||
PermitLimit = permits,
|
||||
Window = window,
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
};
|
||||
|
||||
private static void TryAddTimeProvider(this IServiceCollection services)
|
||||
{
|
||||
if (services.All(s => s.ServiceType != typeof(TimeProvider))) services.AddSingleton(TimeProvider.System);
|
||||
}
|
||||
|
||||
/// <summary>En-têtes de sécurité sur toutes les réponses (l'API ne sert jamais de page web).</summary>
|
||||
private static Task SecurityHeaders(HttpContext context, Func<Task> next)
|
||||
{
|
||||
context.Response.OnStarting(() =>
|
||||
{
|
||||
var headers = context.Response.Headers;
|
||||
headers.CacheControl = "no-store";
|
||||
headers.Pragma = "no-cache";
|
||||
headers.XContentTypeOptions = "nosniff";
|
||||
headers.XFrameOptions = "DENY";
|
||||
headers.ContentSecurityPolicy = "default-src 'none'; frame-ancestors 'none'";
|
||||
headers["Referrer-Policy"] = "no-referrer";
|
||||
headers["Cross-Origin-Resource-Policy"] = "same-origin";
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
return next();
|
||||
}
|
||||
|
||||
/// <summary>Exceptions → réponses <see cref="ApiError"/> sans détail interne (le détail va dans journalctl).</summary>
|
||||
private static async Task HandleErrors(HttpContext context, Func<Task> next)
|
||||
{
|
||||
try
|
||||
{
|
||||
await next();
|
||||
}
|
||||
catch (ApiRejection rejection) when (!context.Response.HasStarted)
|
||||
{
|
||||
await ApiResults.WriteAsync(context, rejection.Status, rejection.Code, rejection.Message);
|
||||
}
|
||||
catch (StoreBusyException ex) when (!context.Response.HasStarted)
|
||||
{
|
||||
context.RequestServices.GetRequiredService<ILogger<WebApplication>>().LogWarning("{Message}", ex.Message);
|
||||
await ApiResults.WriteAsync(context, 503, ApiErrorCodes.Busy, "Serveur momentanément occupé. Réessayez dans un instant.");
|
||||
}
|
||||
catch (BadHttpRequestException ex) when (!context.Response.HasStarted)
|
||||
{
|
||||
var status = ex.StatusCode == StatusCodes.Status413PayloadTooLarge ? 413 : 400;
|
||||
await ApiResults.WriteAsync(context, status, ApiErrorCodes.InvalidRequest,
|
||||
status == 413 ? "Requête trop volumineuse." : "Requête invalide.");
|
||||
}
|
||||
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
// client parti
|
||||
}
|
||||
catch (Exception ex) when (!context.Response.HasStarted)
|
||||
{
|
||||
context.RequestServices.GetRequiredService<ILogger<WebApplication>>().LogError(ex, "Erreur sur {Path}", context.Request.Path);
|
||||
await ApiResults.WriteAsync(context, 500, ApiErrorCodes.Error, "Erreur interne du serveur.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTTPS exigé (via nginx). Exception : la route de santé, appelée en HTTP depuis la machine
|
||||
/// elle-même (surveillance), sans passer par nginx.
|
||||
/// </summary>
|
||||
private static Task RequireHttps(HttpContext context, Func<Task> next, LauncherApiOptions options)
|
||||
{
|
||||
if (!options.RequireHttps || context.Request.IsHttps) return next();
|
||||
var local = context.Connection.RemoteIpAddress is { } ip && System.Net.IPAddress.IsLoopback(ip) &&
|
||||
!context.Request.Headers.ContainsKey("X-Forwarded-For");
|
||||
if (local && context.Request.Path.Equals(ApiRoutes.Prefix + "/" + ApiRoutes.Health, StringComparison.OrdinalIgnoreCase))
|
||||
return next();
|
||||
return ApiResults.WriteAsync(context, 400, ApiErrorCodes.HttpsRequired, "HTTPS obligatoire.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Admins;
|
||||
|
||||
/// <summary>
|
||||
/// Gestion des administrateurs (permission admins.manage = propriétaires) : invitations,
|
||||
/// approbation des clés par empreinte, changement de rôle, révocation d'une clé ou d'un compte.
|
||||
/// Toutes les modifications sont des requêtes signées.
|
||||
///
|
||||
/// Nouvel appareil d'un compte actif : approuvé par le TITULAIRE du compte, depuis un de ses appareils
|
||||
/// déjà actifs. Personne d'autre ne peut ajouter une clé à son nom, pas même un propriétaire (qui
|
||||
/// pourrait sinon révoquer ses clés puis y ajouter la sienne). Compte sans aucun appareil actif :
|
||||
/// restauration sur le VPS (outil en ligne de commande, « add-key »).
|
||||
///
|
||||
/// Garde-fous : on ne change pas son propre rôle, on ne se révoque pas soi-même, on ne révoque pas
|
||||
/// la clé de sa propre session, et il reste toujours au moins un propriétaire capable de se connecter.
|
||||
/// </summary>
|
||||
public sealed class AdminsModule : IApiModule
|
||||
{
|
||||
public string Name => "Admins";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) { }
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
var manage = api.MapGroup("").RequirePermission(AdminPermissions.AdminsManage);
|
||||
|
||||
manage.MapGet(ApiRoutes.Admins, List);
|
||||
manage.MapGet(ApiRoutes.Invitations, ListInvitations);
|
||||
manage.MapDelete("v1/admins/invitations/{invitationId:guid}", DeleteInvitation).RequireSignedRequest();
|
||||
|
||||
// Ouvertes à tout administrateur connecté : chacun peut ajouter et approuver SES appareils ;
|
||||
// le reste exige admins.manage (contrôlé dans la modification elle-même, voir EnsureActor).
|
||||
api.MapPost(ApiRoutes.Invitations, CreateInvitation).RequireAdmin().RequireSignedRequest();
|
||||
api.MapPost("v1/admins/{adminId:guid}/keys/{keyId}/approve", ApproveKey).RequireAdmin().RequireSignedRequest();
|
||||
manage.MapPost("v1/admins/{adminId:guid}/keys/{keyId}/revoke", RevokeKey).RequireSignedRequest();
|
||||
manage.MapPost("v1/admins/{adminId:guid}/role", ChangeRole).RequireSignedRequest();
|
||||
manage.MapPost("v1/admins/{adminId:guid}/revoke", RevokeAdmin).RequireSignedRequest();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Lecture
|
||||
|
||||
private static IResult List(AdminStore store) =>
|
||||
Results.Json(store.Read().Admins.OrderBy(a => a.CreatedAt).Select(ToSummary).ToList(), ApiJson.Options);
|
||||
|
||||
private static IResult ListInvitations(AdminStore store, TimeProvider time)
|
||||
{
|
||||
var now = time.GetUtcNow();
|
||||
return Results.Json(store.Read().Invitations.Where(i => i.ExpiresAt > now).OrderBy(i => i.CreatedAt)
|
||||
.Select(ToSummary).ToList(), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Invitations
|
||||
|
||||
private static IResult CreateInvitation(CreateInvitationRequest request, HttpContext context, AdminStore store,
|
||||
AuditLog audit, TimeProvider time, IOptions<LauncherApiOptions> options)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
var code = InvitationCodes.Generate();
|
||||
var limits = options.Value;
|
||||
|
||||
var invitation = store.Update(db =>
|
||||
{
|
||||
if (request.ForAdminId == actor.Admin.Id) EnsureActor(db, actor, AdminPermission.None); // son propre appareil
|
||||
else EnsureActor(db, actor);
|
||||
db.Invitations.RemoveAll(i => i.ExpiresAt <= now);
|
||||
if (!actor.Has(AdminPermissions.AdminsManage) && db.Invitations.Count(i => i.CreatedById == actor.Admin.Id) >= 2)
|
||||
throw ApiRejection.Conflict("Vous avez déjà 2 invitations en cours : utilisez-les ou attendez leur expiration.");
|
||||
if (db.Invitations.Count >= limits.Limits.MaxActiveInvitations)
|
||||
throw ApiRejection.Conflict("Trop d'invitations en cours : supprimez-en avant d'en créer une autre.");
|
||||
|
||||
string displayName, role;
|
||||
if (request.ForAdminId is { } adminId)
|
||||
{
|
||||
var target = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
|
||||
if (target.Status == AdminStatus.Revoked)
|
||||
throw ApiRejection.Conflict("Cet administrateur est révoqué.");
|
||||
(displayName, role) = (target.DisplayName, target.Role);
|
||||
}
|
||||
else
|
||||
{
|
||||
displayName = ApiText.CleanLine(request.DisplayName, ApiText.MaxDisplayNameLength)
|
||||
?? throw ApiRejection.BadRequest($"Nom obligatoire ({ApiText.MaxDisplayNameLength} caractères au plus).");
|
||||
role = AdminRoles.IsValid(request.Role) ? request.Role! : throw ApiRejection.BadRequest("Rôle inconnu.");
|
||||
}
|
||||
|
||||
var record = new InvitationRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CodeHash = SignedMessages.HashInvitationCode(InvitationCodes.Normalize(code)!),
|
||||
DisplayName = displayName,
|
||||
Role = role,
|
||||
ForAdminId = request.ForAdminId,
|
||||
CreatedAt = now,
|
||||
CreatedBy = actor.Admin.DisplayName,
|
||||
CreatedById = actor.Admin.Id,
|
||||
ExpiresAt = now + TimeSpan.FromHours(limits.InvitationLifetimeHours)
|
||||
};
|
||||
db.Invitations.Add(record);
|
||||
return record;
|
||||
});
|
||||
|
||||
Audit(audit, context, "admins.invite", $"invitation {invitation.Id:D}",
|
||||
invitation.ForAdminId is null ? $"{invitation.DisplayName} ({invitation.Role})" : $"nouvel appareil pour {invitation.DisplayName}");
|
||||
return Results.Json(new InvitationCreated { Invitation = ToSummary(invitation), Code = code }, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult DeleteInvitation(Guid invitationId, HttpContext context, AdminStore store, AuditLog audit)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
store.Update(db =>
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
if (db.Invitations.RemoveAll(i => i.Id == invitationId) == 0) throw ApiRejection.NotFound("Invitation introuvable.");
|
||||
});
|
||||
Audit(audit, context, "admins.invite_deleted", $"invitation {invitationId:D}");
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Clés
|
||||
|
||||
/// <summary>Approuve une clé en attente : l'empreinte saisie doit être celle de la clé.</summary>
|
||||
private static IResult ApproveKey(Guid adminId, string keyId, ApproveKeyRequest request, HttpContext context,
|
||||
AdminStore store, AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
var now = time.GetUtcNow();
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
var (admin, key) = FindKey(db, adminId, keyId);
|
||||
// Nouvel appareil d'un compte déjà actif : seul son titulaire l'approuve (depuis un de ses
|
||||
// appareils actifs). S'il les a tous perdus : outil en ligne de commande sur le VPS (add-key).
|
||||
if (admin.Status == AdminStatus.Active && !admin.Keys.Any(k => k.Status == AdminStatus.Active && k.KeyId != key.KeyId))
|
||||
throw new ApiRejection(409, ApiErrorCodes.Conflict,
|
||||
$"{admin.DisplayName} n'a plus aucun appareil actif : restaurez son accès sur le VPS (« add-key »), " +
|
||||
"ou révoquez ce compte et invitez-le de nouveau.");
|
||||
if (admin.Status == AdminStatus.Active)
|
||||
{
|
||||
EnsureActor(db, actor, AdminPermission.None);
|
||||
if (actor.Admin.Id != admin.Id)
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden,
|
||||
$"Ce nouvel appareil doit être approuvé par {admin.DisplayName} lui-même, depuis un de ses appareils déjà actifs.");
|
||||
}
|
||||
else EnsureActor(db, actor);
|
||||
if (key.Status != AdminStatus.Pending) throw ApiRejection.Conflict("Cette clé n'est pas en attente d'approbation.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est révoqué.");
|
||||
if (!AdminKeys.FingerprintMatches(key.PublicKeyBytes(), request.Fingerprint))
|
||||
{
|
||||
Audit(audit, context, "admins.key_approve", $"{admin.DisplayName} / clé {keyId}", "empreinte différente", AuditResults.Denied);
|
||||
throw new ApiRejection(400, ApiErrorCodes.FingerprintMismatch,
|
||||
"L'empreinte saisie ne correspond pas à cette clé. Ne l'approuvez pas : vérifiez-la de nouveau avec la personne invitée.");
|
||||
}
|
||||
|
||||
key.Status = AdminStatus.Active;
|
||||
key.ApprovedAt = now;
|
||||
key.ApprovedBy = actor.Admin.DisplayName;
|
||||
if (admin.Status == AdminStatus.Pending)
|
||||
{
|
||||
admin.Status = AdminStatus.Active;
|
||||
admin.ApprovedAt = now;
|
||||
admin.ApprovedBy = actor.Admin.DisplayName;
|
||||
}
|
||||
return admin.DisplayName;
|
||||
});
|
||||
Audit(audit, context, "admins.key_approve", $"{name} / clé {keyId}");
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult RevokeKey(Guid adminId, string keyId, HttpContext context, AdminStore store, SessionStore sessions,
|
||||
AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
if (string.Equals(keyId, actor.Key.KeyId, StringComparison.Ordinal))
|
||||
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas révoquer la clé de votre session en cours.");
|
||||
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
var (admin, key) = FindKey(db, adminId, keyId);
|
||||
if (key.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cette clé est déjà révoquée.");
|
||||
key.Status = AdminStatus.Revoked;
|
||||
key.RevokedAt = time.GetUtcNow();
|
||||
key.RevokedBy = actor.Admin.DisplayName;
|
||||
EnsureUsableOwner(db);
|
||||
return admin.DisplayName;
|
||||
});
|
||||
sessions.RemoveWhere(s => s.KeyId == keyId);
|
||||
Audit(audit, context, "admins.key_revoke", $"{name} / clé {keyId}");
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Comptes
|
||||
|
||||
private static IResult ChangeRole(Guid adminId, ChangeRoleRequest request, HttpContext context, AdminStore store, AuditLog audit)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
if (adminId == actor.Admin.Id)
|
||||
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas modifier votre propre rôle.");
|
||||
if (!AdminRoles.IsValid(request.Role)) throw ApiRejection.BadRequest("Rôle inconnu.");
|
||||
|
||||
var (name, previous) = store.Update(db =>
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
var admin = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est révoqué.");
|
||||
var old = admin.Role;
|
||||
admin.Role = request.Role;
|
||||
EnsureUsableOwner(db);
|
||||
return (admin.DisplayName, old);
|
||||
});
|
||||
// Les droits sont relus à chaque requête : le nouveau rôle s'applique immédiatement.
|
||||
Audit(audit, context, "admins.role", name, $"{previous} → {request.Role}");
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult RevokeAdmin(Guid adminId, HttpContext context, AdminStore store, SessionStore sessions,
|
||||
AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
if (adminId == actor.Admin.Id)
|
||||
throw new ApiRejection(409, ApiErrorCodes.SelfAction, "Vous ne pouvez pas révoquer votre propre compte.");
|
||||
|
||||
var now = time.GetUtcNow();
|
||||
var name = store.Update(db =>
|
||||
{
|
||||
EnsureActor(db, actor);
|
||||
var admin = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
|
||||
if (admin.Status == AdminStatus.Revoked) throw ApiRejection.Conflict("Cet administrateur est déjà révoqué.");
|
||||
admin.Status = AdminStatus.Revoked;
|
||||
admin.RevokedAt = now;
|
||||
admin.RevokedBy = actor.Admin.DisplayName;
|
||||
foreach (var key in admin.Keys.Where(k => k.Status != AdminStatus.Revoked))
|
||||
{
|
||||
key.Status = AdminStatus.Revoked;
|
||||
key.RevokedAt = now;
|
||||
key.RevokedBy = actor.Admin.DisplayName;
|
||||
}
|
||||
db.Invitations.RemoveAll(i => i.ForAdminId == adminId);
|
||||
EnsureUsableOwner(db);
|
||||
return admin.DisplayName;
|
||||
});
|
||||
sessions.RemoveWhere(s => s.AdminId == adminId);
|
||||
Audit(audit, context, "admins.revoke", name);
|
||||
return Results.Json(ToSummary(store.Read().FindAdmin(adminId)!), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
private static (AdminRecord Admin, KeyRecord Key) FindKey(AdminDatabase db, Guid adminId, string keyId)
|
||||
{
|
||||
var admin = db.FindAdmin(adminId) ?? throw ApiRejection.NotFound("Administrateur introuvable.");
|
||||
var key = admin.Keys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal))
|
||||
?? throw ApiRejection.NotFound("Clé introuvable.");
|
||||
return (admin, key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revérifie l'auteur DANS la modification (sous verrou) : s'il a été révoqué ou rétrogradé entre
|
||||
/// l'authentification de sa requête et l'écriture (autre propriétaire, outil en ligne de commande),
|
||||
/// rien n'est écrit.
|
||||
/// </summary>
|
||||
private static void EnsureActor(AdminDatabase db, AuthenticatedAdmin actor, AdminPermission required = AdminPermission.Manage)
|
||||
{
|
||||
if (db.FindKey(actor.Key.KeyId) is not { } current || current.Admin.Id != actor.Admin.Id ||
|
||||
current.Admin.Status != AdminStatus.Active || current.Key.Status != AdminStatus.Active ||
|
||||
(required == AdminPermission.Manage && !AdminRoles.Has(current.Admin.Role, AdminPermissions.AdminsManage)))
|
||||
throw new ApiRejection(403, ApiErrorCodes.Forbidden,
|
||||
required == AdminPermission.Manage ? "Vos droits ne permettent pas cette action." : "Vos droits ont changé entre-temps : action refusée.");
|
||||
}
|
||||
|
||||
private enum AdminPermission { None, Manage }
|
||||
|
||||
/// <summary>Annule la modification s'il ne reste plus aucun propriétaire capable de se connecter.</summary>
|
||||
private static void EnsureUsableOwner(AdminDatabase db)
|
||||
{
|
||||
if (!db.UsableOwners().Any())
|
||||
throw new ApiRejection(409, ApiErrorCodes.LastOwner,
|
||||
"Impossible : il doit toujours rester au moins un propriétaire actif disposant d'une clé active.");
|
||||
}
|
||||
|
||||
private static void Audit(AuditLog audit, HttpContext context, string action, string target, string? details = null,
|
||||
string result = AuditResults.Ok)
|
||||
{
|
||||
var actor = context.Admin();
|
||||
audit.Write(action, result, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), target, details);
|
||||
}
|
||||
|
||||
internal static AdminSummary ToSummary(AdminRecord admin) => new()
|
||||
{
|
||||
Id = admin.Id,
|
||||
DisplayName = admin.DisplayName,
|
||||
Role = admin.Role,
|
||||
Status = admin.Status,
|
||||
CreatedAt = admin.CreatedAt,
|
||||
Keys = admin.Keys.Select(k => new AdminKeySummary
|
||||
{
|
||||
KeyId = k.KeyId,
|
||||
Label = k.Label,
|
||||
Status = k.Status,
|
||||
// Clé en attente : empreinte volontairement masquée (l'approbateur doit la recevoir de la personne invitée).
|
||||
Fingerprint = k.Status == AdminStatus.Pending ? null : AdminKeys.ComputeFingerprint(k.PublicKeyBytes()),
|
||||
AddedAt = k.AddedAt,
|
||||
LastUsedAt = k.LastUsedAt
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
private static InvitationSummary ToSummary(InvitationRecord invitation) => new()
|
||||
{
|
||||
Id = invitation.Id,
|
||||
DisplayName = invitation.DisplayName,
|
||||
Role = invitation.Role,
|
||||
ForAdminId = invitation.ForAdminId,
|
||||
CreatedAt = invitation.CreatedAt,
|
||||
CreatedBy = invitation.CreatedBy,
|
||||
ExpiresAt = invitation.ExpiresAt
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Audit;
|
||||
|
||||
/// <summary>Lecture du journal d'audit (permission audit.read), entrées les plus récentes d'abord.</summary>
|
||||
public sealed class AuditModule : IApiModule
|
||||
{
|
||||
public string Name => "Audit";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<AuditLog>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api) =>
|
||||
api.MapGet(ApiRoutes.Audit, (int? limit, string? cursor, AuditLog audit) =>
|
||||
{
|
||||
if (cursor is { Length: > 32 }) return ApiResults.BadRequest("Curseur invalide.");
|
||||
return Results.Json(audit.Read(limit ?? 50, cursor), ApiJson.Options);
|
||||
}).RequirePermission(AdminPermissions.AuditRead);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Authentification par clé : défi, connexion (signature du défi), session, déconnexion, et
|
||||
/// enrôlement d'une clé avec un code d'invitation (preuve de possession de la clé).
|
||||
/// </summary>
|
||||
public sealed class AuthModule : IApiModule
|
||||
{
|
||||
private const string LoginRefused = "Connexion refusée.";
|
||||
|
||||
public string Name => "Auth";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
|
||||
{
|
||||
services.AddSingleton<ChallengeStore>();
|
||||
services.AddSingleton<SessionStore>();
|
||||
services.AddSingleton<ReplayCache>();
|
||||
}
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapPost(ApiRoutes.AuthChallenge, Challenge).RequireRateLimiting(RateLimitPolicies.Auth);
|
||||
api.MapPost(ApiRoutes.AuthLogin, Login).RequireRateLimiting(RateLimitPolicies.Auth);
|
||||
api.MapPost(ApiRoutes.AuthLogout, Logout).RequireAdmin();
|
||||
api.MapGet(ApiRoutes.AuthMe, Me).RequireAdmin();
|
||||
api.MapPost(ApiRoutes.Enrollment, Enroll).RequireRateLimiting(RateLimitPolicies.Enrollment);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Défi de connexion. Même réponse que la clé soit connue ou non (pas d'énumération des clés) :
|
||||
/// la vérification a lieu à la connexion.
|
||||
/// </summary>
|
||||
private static IResult Challenge(ChallengeRequest request, ChallengeStore challenges, ServerIdentity server, TimeProvider time)
|
||||
{
|
||||
if (!AdminKeys.IsValidKeyId(request.KeyId)) return ApiResults.BadRequest("Identifiant de clé invalide.");
|
||||
var challenge = challenges.Create(request.KeyId);
|
||||
return Results.Json(new ChallengeResponse
|
||||
{
|
||||
ChallengeId = challenge.Id,
|
||||
Nonce = challenge.Nonce,
|
||||
ServerId = server.ServerId,
|
||||
Origin = server.Origin,
|
||||
ExpiresAt = challenge.ExpiresAt,
|
||||
ServerTime = time.GetUtcNow()
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult Login(LoginRequest request, HttpContext context, ChallengeStore challenges, SessionStore sessions,
|
||||
AdminStore store, ServerIdentity server, AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var ip = context.ClientIp();
|
||||
var signature = AdminKeys.DecodeSignature(request.Signature);
|
||||
if (!AdminKeys.IsValidKeyId(request.KeyId) || string.IsNullOrEmpty(request.ChallengeId) ||
|
||||
request.ChallengeId.Length > ChallengeStore.MaxIdLength || signature is null)
|
||||
return ApiResults.BadRequest("Demande de connexion incomplète.");
|
||||
|
||||
// 1. Échecs AVANT toute signature valide (défi, clé ou signature) : bruit possible, journal limité.
|
||||
AdminRecord? admin = null;
|
||||
KeyRecord? key = null;
|
||||
string? unsigned = null;
|
||||
if (!challenges.TryValidate(request.ChallengeId, request.KeyId, out var challenge))
|
||||
unsigned = "défi inconnu, expiré ou destiné à une autre clé";
|
||||
else if (store.Read().FindKey(request.KeyId) is not { } found)
|
||||
unsigned = "clé inconnue";
|
||||
else
|
||||
{
|
||||
(admin, key) = found;
|
||||
if (!AdminKeys.Verify(key.PublicKeyBytes(),
|
||||
SignedMessages.Login(server.Origin, server.ServerId, challenge.Id, challenge.Nonce, key.KeyId), signature))
|
||||
unsigned = "signature invalide";
|
||||
}
|
||||
if (unsigned is not null || admin is null || key is null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("auth.login", request.KeyId, ip, unsigned);
|
||||
return ApiResults.Unauthorized(LoginRefused);
|
||||
}
|
||||
|
||||
// 2. Signature VALIDE mais connexion refusée (clé révoquée ou en attente, défi rejoué) : signal fort,
|
||||
// toujours journalisé en détail (ex. clé volée puis révoquée encore utilisée).
|
||||
// (Le défi n'est marqué « utilisé » qu'une fois la connexion acceptée : une clé révoquée ou en
|
||||
// attente ne peut pas remplir la liste des défis utilisés.)
|
||||
string? refused = null;
|
||||
if (admin.Status != AdminStatus.Active) refused = $"administrateur {admin.Status}";
|
||||
else if (key.Status != AdminStatus.Active) refused = $"clé {key.Status}";
|
||||
else if (!challenges.TryMarkUsed(challenge)) refused = "défi déjà utilisé (rejeu)";
|
||||
if (refused is not null)
|
||||
{
|
||||
audit.WriteSignedRefusal("auth.login", admin.Id, admin.DisplayName, key.KeyId, ip, refused);
|
||||
return ApiResults.Unauthorized(LoginRefused);
|
||||
}
|
||||
|
||||
var clientName = ApiText.CleanLine(request.ClientName, ApiText.MaxClientNameLength);
|
||||
var (token, session) = sessions.Create(admin.Id, key.KeyId, ip, clientName);
|
||||
// Date de dernière utilisation : enregistrée au plus une fois par heure (évite de réécrire
|
||||
// admins.json à chaque connexion).
|
||||
var now = time.GetUtcNow();
|
||||
if (key.LastUsedAt is null || now - key.LastUsedAt > TimeSpan.FromHours(1))
|
||||
{
|
||||
try
|
||||
{
|
||||
store.Update(db =>
|
||||
{
|
||||
if (db.FindKey(key.KeyId) is { } current) current.Key.LastUsedAt = now;
|
||||
});
|
||||
}
|
||||
catch (StoreBusyException)
|
||||
{
|
||||
// sans conséquence
|
||||
}
|
||||
}
|
||||
|
||||
audit.Write("auth.login", AuditResults.Ok, admin.Id, admin.DisplayName, key.KeyId, ip, details: clientName);
|
||||
var authenticated = new AuthenticatedAdmin(session, admin, key, AdminRoles.PermissionsOf(admin.Role));
|
||||
return Results.Json(new LoginResponse
|
||||
{
|
||||
Token = token,
|
||||
SessionId = session.Id,
|
||||
ExpiresAt = session.ExpiresAt,
|
||||
IdleExpiresAt = sessions.IdleExpiresAt(session),
|
||||
Admin = authenticated.ToProfile()
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult Logout(HttpContext context, SessionStore sessions, AuditLog audit)
|
||||
{
|
||||
var admin = context.Admin();
|
||||
sessions.Remove(admin.Session);
|
||||
audit.Write("auth.logout", AuditResults.Ok, admin.Admin.Id, admin.Admin.DisplayName, admin.Key.KeyId, context.ClientIp());
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static IResult Me(HttpContext context, SessionStore sessions)
|
||||
{
|
||||
var admin = context.Admin();
|
||||
return Results.Json(new SessionInfo
|
||||
{
|
||||
SessionId = admin.Session.Id,
|
||||
ExpiresAt = admin.Session.ExpiresAt,
|
||||
IdleExpiresAt = sessions.IdleExpiresAt(admin.Session),
|
||||
Admin = admin.ToProfile()
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enrôlement d'une clé avec un code d'invitation. La clé (et, pour une nouvelle personne, le
|
||||
/// compte) reste EN ATTENTE jusqu'à l'approbation par un propriétaire, qui doit saisir l'empreinte
|
||||
/// communiquée par la personne invitée. Le code est consommé (usage unique).
|
||||
/// </summary>
|
||||
private static IResult Enroll(EnrollmentRequest request, HttpContext context, AdminStore store, ServerIdentity server,
|
||||
AuditLog audit, TimeProvider time)
|
||||
{
|
||||
var ip = context.ClientIp();
|
||||
var code = InvitationCodes.Normalize(request.InvitationCode);
|
||||
var proof = AdminKeys.DecodeSignature(request.Proof);
|
||||
if (code is null || proof is null) return Reject("code ou preuve mal formés");
|
||||
|
||||
byte[] spki;
|
||||
try
|
||||
{
|
||||
spki = AdminKeys.ParsePublicKey(request.PublicKey);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return ApiResults.BadRequest("Clé publique invalide : une clé ECDSA P-256 est attendue.");
|
||||
}
|
||||
|
||||
var keyId = AdminKeys.ComputeKeyId(spki);
|
||||
var codeHash = SignedMessages.HashInvitationCode(code);
|
||||
if (!AdminKeys.Verify(spki, SignedMessages.Enrollment(server.Origin, server.ServerId, codeHash, keyId), proof))
|
||||
return Reject("preuve de possession invalide", keyId);
|
||||
|
||||
var label = ApiText.CleanLine(request.KeyLabel, ApiText.MaxKeyLabelLength);
|
||||
if (request.KeyLabel is not null && label is null)
|
||||
return ApiResults.BadRequest($"Nom du poste invalide ({ApiText.MaxKeyLabelLength} caractères au plus).");
|
||||
|
||||
var now = time.GetUtcNow();
|
||||
var result = store.Update(db =>
|
||||
{
|
||||
db.Invitations.RemoveAll(i => i.ExpiresAt <= now);
|
||||
var invitation = db.Invitations.FirstOrDefault(i => string.Equals(i.CodeHash, codeHash, StringComparison.Ordinal));
|
||||
if (invitation is null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("admins.enroll", keyId, ip, "code inconnu, expiré ou déjà utilisé");
|
||||
throw new ApiRejection(400, ApiErrorCodes.InvitationInvalid, "Code d'invitation invalide, expiré ou déjà utilisé.");
|
||||
}
|
||||
if (db.FindKey(keyId) is not null)
|
||||
throw ApiRejection.Conflict("Cette clé est déjà enregistrée.");
|
||||
|
||||
var key = new KeyRecord
|
||||
{
|
||||
KeyId = keyId,
|
||||
PublicKey = Convert.ToBase64String(spki),
|
||||
Label = label,
|
||||
Status = AdminStatus.Pending,
|
||||
AddedAt = now
|
||||
};
|
||||
|
||||
AdminRecord admin;
|
||||
if (invitation.ForAdminId is { } existingId)
|
||||
{
|
||||
admin = db.FindAdmin(existingId) ?? throw new ApiRejection(400, ApiErrorCodes.InvitationInvalid,
|
||||
"Code d'invitation invalide, expiré ou déjà utilisé.");
|
||||
if (admin.Status == AdminStatus.Revoked)
|
||||
throw new ApiRejection(400, ApiErrorCodes.InvitationInvalid, "Code d'invitation invalide, expiré ou déjà utilisé.");
|
||||
}
|
||||
else
|
||||
{
|
||||
admin = new AdminRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DisplayName = invitation.DisplayName,
|
||||
Role = invitation.Role,
|
||||
Status = AdminStatus.Pending,
|
||||
CreatedAt = now,
|
||||
CreatedBy = invitation.CreatedBy
|
||||
};
|
||||
db.Admins.Add(admin);
|
||||
}
|
||||
admin.Keys.Add(key);
|
||||
db.Invitations.Remove(invitation); // usage unique
|
||||
return (admin.Id, admin.DisplayName, admin.Status, InvitationId: invitation.Id);
|
||||
});
|
||||
|
||||
audit.Write("admins.enroll", AuditResults.Ok, result.Id, result.DisplayName, keyId, ip,
|
||||
target: $"invitation {result.InvitationId:D}", details: label);
|
||||
return Results.Json(new EnrollmentResponse
|
||||
{
|
||||
AdminId = result.Id,
|
||||
DisplayName = result.DisplayName,
|
||||
KeyId = keyId,
|
||||
Fingerprint = AdminKeys.ComputeFingerprint(spki),
|
||||
Status = AdminStatus.Pending
|
||||
}, ApiJson.Options);
|
||||
|
||||
IResult Reject(string reason, string? key = null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("admins.enroll", key, ip, reason);
|
||||
return ApiResults.Error(400, ApiErrorCodes.InvitationInvalid, "Code d'invitation invalide, expiré ou déjà utilisé.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Reflection;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.System;
|
||||
|
||||
/// <summary>Informations publiques du serveur et état de santé.</summary>
|
||||
public sealed class SystemModule : IApiModule
|
||||
{
|
||||
public string Name => "System";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) { }
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
var version = typeof(SystemModule).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? "?";
|
||||
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
|
||||
{
|
||||
ServerId = server.ServerId,
|
||||
Origin = server.Origin,
|
||||
ApiVersion = ApiRoutes.Version,
|
||||
ServerVersion = version,
|
||||
KeyAlgorithms = new[] { AdminKeys.Algorithm }
|
||||
}, ApiJson.Options));
|
||||
|
||||
api.MapGet(ApiRoutes.Health, () => Results.Json(new HealthStatus(), ApiJson.Options));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using GameLauncher.Server.Cli;
|
||||
using GameLauncher.Server.Hosting;
|
||||
|
||||
namespace GameLauncher.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Sans commande : démarre la Launcher API (service systemd).
|
||||
/// Avec une commande (add-owner, list, revoke-admin, revoke-key, server-id, help) : outil
|
||||
/// d'administration en ligne de commande, à lancer SUR LE VPS sous le compte du service.
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
if (AdminCli.IsCommand(args)) return AdminCli.Run(args);
|
||||
|
||||
var app = ServerApp.Build(args);
|
||||
await app.RunAsync();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"GameLauncher.Server": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://127.0.0.1:5080",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Administrateur authentifié pour la requête en cours.</summary>
|
||||
public sealed record AuthenticatedAdmin(AdminSessionState Session, AdminRecord Admin, KeyRecord Key, IReadOnlyList<string> Permissions)
|
||||
{
|
||||
public bool Has(string permission) => Permissions.Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
public AdminProfile ToProfile() => new()
|
||||
{
|
||||
Id = Admin.Id,
|
||||
DisplayName = Admin.DisplayName,
|
||||
Role = Admin.Role,
|
||||
Permissions = Permissions,
|
||||
KeyId = Key.KeyId,
|
||||
KeyLabel = Key.Label
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schéma « AdminSession » : jeton de session (Authorization: Bearer …). À chaque requête, la
|
||||
/// session doit être valide ET l'administrateur actif ET la clé de la session active : une
|
||||
/// révocation (même depuis l'outil en ligne de commande) prend effet immédiatement. Le rôle est relu
|
||||
/// à chaque requête : un changement de rôle s'applique aussitôt.
|
||||
/// </summary>
|
||||
public sealed class AdminSessionHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "AdminSession";
|
||||
public const string PermissionClaim = "perm";
|
||||
private const string ContextKey = "GameLauncher.Admin";
|
||||
|
||||
private readonly SessionStore _sessions;
|
||||
private readonly AdminStore _store;
|
||||
|
||||
public AdminSessionHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder,
|
||||
SessionStore sessions, AdminStore store)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_sessions = sessions;
|
||||
_store = store;
|
||||
}
|
||||
|
||||
public static AuthenticatedAdmin? Get(HttpContext context) =>
|
||||
context.Items.TryGetValue(ContextKey, out var value) ? value as AuthenticatedAdmin : null;
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var header = Request.Headers.Authorization.ToString();
|
||||
if (string.IsNullOrEmpty(header)) return Task.FromResult(AuthenticateResult.NoResult());
|
||||
if (!header.StartsWith("Bearer ", StringComparison.Ordinal))
|
||||
return Task.FromResult(AuthenticateResult.Fail("Schéma d'authentification inattendu."));
|
||||
|
||||
var session = _sessions.Find(header["Bearer ".Length..].Trim());
|
||||
if (session is null) return Task.FromResult(AuthenticateResult.Fail("Session absente ou expirée."));
|
||||
|
||||
var match = _store.Read().FindKey(session.KeyId);
|
||||
if (match is not { } found || found.Admin.Id != session.AdminId ||
|
||||
found.Admin.Status != AdminStatus.Active || found.Key.Status != AdminStatus.Active)
|
||||
{
|
||||
_sessions.Remove(session);
|
||||
return Task.FromResult(AuthenticateResult.Fail("Administrateur ou clé révoqué."));
|
||||
}
|
||||
|
||||
var permissions = AdminRoles.PermissionsOf(found.Admin.Role);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, found.Admin.Id.ToString("D")),
|
||||
new(ClaimTypes.Name, found.Admin.DisplayName),
|
||||
new(ClaimTypes.Role, found.Admin.Role),
|
||||
new("key", found.Key.KeyId),
|
||||
new("sid", session.Id)
|
||||
};
|
||||
claims.AddRange(permissions.Select(p => new Claim(PermissionClaim, p)));
|
||||
|
||||
Context.Items[ContextKey] = new AuthenticatedAdmin(session, found.Admin, found.Key, permissions);
|
||||
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, "Authentification requise (session absente ou expirée).");
|
||||
|
||||
protected override Task HandleForbiddenAsync(AuthenticationProperties properties) =>
|
||||
ApiResults.WriteAsync(Context, 403, ApiErrorCodes.Forbidden, "Vos droits ne permettent pas cette action.");
|
||||
}
|
||||
|
||||
public static class AdminAuthorization
|
||||
{
|
||||
public static string PolicyName(string permission) => "perm:" + permission;
|
||||
|
||||
/// <summary>Route réservée aux administrateurs disposant de cette permission.</summary>
|
||||
public static TBuilder RequirePermission<TBuilder>(this TBuilder builder, string permission)
|
||||
where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.RequireAuthorization(PolicyName(permission));
|
||||
|
||||
/// <summary>Route réservée à un administrateur connecté (quelles que soient ses permissions).</summary>
|
||||
public static TBuilder RequireAdmin<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.RequireAuthorization(AdminSessionHandler.SchemeName);
|
||||
|
||||
public static IServiceCollection AddAdminAuthentication(this IServiceCollection services)
|
||||
{
|
||||
services.AddAuthentication(AdminSessionHandler.SchemeName)
|
||||
.AddScheme<AuthenticationSchemeOptions, AdminSessionHandler>(AdminSessionHandler.SchemeName, null);
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(AdminSessionHandler.SchemeName, p => p.RequireAuthenticatedUser());
|
||||
foreach (var permission in AdminPermissions.All)
|
||||
options.AddPolicy(PolicyName(permission),
|
||||
p => p.RequireAuthenticatedUser().RequireClaim(AdminSessionHandler.PermissionClaim, permission));
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Administrateur authentifié de la requête (les routes protégées en ont toujours un).</summary>
|
||||
public static AuthenticatedAdmin Admin(this HttpContext context) =>
|
||||
AdminSessionHandler.Get(context) ?? throw new InvalidOperationException("Route non protégée par RequireAdmin/RequirePermission.");
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Défi de connexion : à signer avec la clé, valable quelques secondes, utilisable une seule fois.</summary>
|
||||
public sealed record LoginChallenge(string Id, string Nonce, string KeyId, DateTimeOffset ExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Défis de connexion SANS ÉTAT : l'identifiant du défi contient la clé visée, l'échéance et le
|
||||
/// nonce, scellés par un HMAC (secret aléatoire, en mémoire). Il n'existe donc aucune réserve
|
||||
/// partagée qu'un attaquant pourrait remplir ou vider : demander des défis ne coûte rien au serveur.
|
||||
/// Seuls les défis UTILISÉS avec succès sont mémorisés (jusqu'à leur échéance) pour refuser le rejeu ;
|
||||
/// on n'y entre qu'avec une signature valide.
|
||||
/// </summary>
|
||||
public sealed class ChallengeStore
|
||||
{
|
||||
private const int NonceLength = 32;
|
||||
private const int MacLength = 16;
|
||||
private const int PayloadLength = 8 + 8 + NonceLength; // clé (8 octets) + échéance + nonce
|
||||
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Admin/Challenge/v1\n");
|
||||
|
||||
private readonly byte[] _secret = RandomNumberGenerator.GetBytes(32);
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _used = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _time;
|
||||
private readonly TimeSpan _lifetime;
|
||||
private readonly int _maxUsed;
|
||||
|
||||
public ChallengeStore(IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
{
|
||||
_time = time;
|
||||
_lifetime = TimeSpan.FromSeconds(options.Value.ChallengeLifetimeSeconds);
|
||||
_maxUsed = 100_000;
|
||||
}
|
||||
|
||||
/// <summary>Longueur maximale d'un identifiant de défi (contrôle des entrées).</summary>
|
||||
public const int MaxIdLength = 96;
|
||||
|
||||
public LoginChallenge Create(string keyId)
|
||||
{
|
||||
var expires = _time.GetUtcNow() + _lifetime;
|
||||
var payload = new byte[PayloadLength];
|
||||
Convert.FromHexString(keyId).CopyTo(payload, 0);
|
||||
BinaryPrimitives.WriteInt64BigEndian(payload.AsSpan(8), expires.ToUnixTimeSeconds());
|
||||
RandomNumberGenerator.Fill(payload.AsSpan(16));
|
||||
|
||||
var id = new byte[PayloadLength + MacLength];
|
||||
payload.CopyTo(id, 0);
|
||||
Mac(payload).CopyTo(id, PayloadLength);
|
||||
return new LoginChallenge(Base64Url.Encode(id), Base64Url.Encode(payload.AsSpan(16)), keyId,
|
||||
DateTimeOffset.FromUnixTimeSeconds(expires.ToUnixTimeSeconds()));
|
||||
}
|
||||
|
||||
/// <summary>Vérifie le sceau, la clé visée et l'échéance (sans consommer le défi).</summary>
|
||||
public bool TryValidate(string challengeId, string keyId, out LoginChallenge challenge)
|
||||
{
|
||||
challenge = null!;
|
||||
if (challengeId.Length > MaxIdLength || !Base64Url.TryDecode(challengeId, out var id) ||
|
||||
id.Length != PayloadLength + MacLength || !AdminKeysHex(keyId, out var keyBytes))
|
||||
return false;
|
||||
|
||||
var payload = id.AsSpan(0, PayloadLength);
|
||||
if (!CryptographicOperations.FixedTimeEquals(Mac(payload), id.AsSpan(PayloadLength))) return false;
|
||||
if (!payload[..8].SequenceEqual(keyBytes)) return false;
|
||||
var expires = DateTimeOffset.FromUnixTimeSeconds(BinaryPrimitives.ReadInt64BigEndian(payload[8..16]));
|
||||
if (expires <= _time.GetUtcNow() || expires > _time.GetUtcNow() + _lifetime + TimeSpan.FromSeconds(1)) return false;
|
||||
|
||||
challenge = new LoginChallenge(challengeId, Base64Url.Encode(payload[16..]), keyId, expires);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Marque le défi comme utilisé (après vérification de la signature). Faux s'il l'était déjà (rejeu).</summary>
|
||||
public bool TryMarkUsed(LoginChallenge challenge)
|
||||
{
|
||||
if (_used.Count >= _maxUsed)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
foreach (var (id, expires) in _used)
|
||||
if (expires <= now) _used.TryRemove(id, out _);
|
||||
if (_used.Count >= _maxUsed) throw new StoreBusyException("Trop de connexions récentes.");
|
||||
}
|
||||
return _used.TryAdd(challenge.Id, challenge.ExpiresAt);
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
private static bool AdminKeysHex(string keyId, out byte[] bytes)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
if (!Contracts.Security.AdminKeys.IsValidKeyId(keyId)) return false;
|
||||
bytes = Convert.FromHexString(keyId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Collections.Concurrent;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Nonces des requêtes signées déjà vus (pendant la fenêtre de tolérance d'horloge) : refus des rejeux.</summary>
|
||||
public sealed class ReplayCache
|
||||
{
|
||||
private const int Max = 100_000;
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _seen = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public ReplayCache(TimeProvider time) => _time = time;
|
||||
|
||||
/// <summary>Faux si ce nonce a déjà été utilisé.</summary>
|
||||
public bool TryRegister(string key, TimeSpan keepFor)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
if (_seen.Count >= Max)
|
||||
{
|
||||
foreach (var (k, expires) in _seen)
|
||||
if (expires <= now) _seen.TryRemove(k, out _);
|
||||
if (_seen.Count >= Max) throw new StoreBusyException("Trop de requêtes signées récentes.");
|
||||
}
|
||||
|
||||
if (_seen.TryAdd(key, now + keepFor)) return true;
|
||||
// Entrée expirée encore présente : elle peut être remplacée.
|
||||
if (_seen.TryGetValue(key, out var existing) && existing <= now && _seen.TryUpdate(key, now + keepFor, existing))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>Session d'un administrateur (en mémoire uniquement).</summary>
|
||||
public sealed class AdminSessionState
|
||||
{
|
||||
/// <summary>Identifiant public de la session (inclus dans les requêtes signées).</summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>SHA-256 du jeton : le jeton lui-même n'est jamais conservé.</summary>
|
||||
public required string TokenHash { get; init; }
|
||||
|
||||
public required Guid AdminId { get; init; }
|
||||
public required string KeyId { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
public string? Ip { get; init; }
|
||||
public string? ClientName { get; init; }
|
||||
|
||||
private long _lastSeenTicks;
|
||||
|
||||
public DateTimeOffset LastSeenAt
|
||||
{
|
||||
get => new(Interlocked.Read(ref _lastSeenTicks), TimeSpan.Zero);
|
||||
set => Interlocked.Exchange(ref _lastSeenTicks, value.UtcTicks);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sessions en mémoire : un redémarrage du service déconnecte tout le monde. Durée d'inactivité
|
||||
/// et durée maximale bornées ; nombre de sessions borné par administrateur et au total.
|
||||
/// </summary>
|
||||
public sealed class SessionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AdminSessionState> _byTokenHash = new(StringComparer.Ordinal);
|
||||
private readonly object _createLock = new();
|
||||
private readonly TimeProvider _time;
|
||||
private readonly LauncherApiOptions.SessionSettings _settings;
|
||||
|
||||
public SessionStore(IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
{
|
||||
_time = time;
|
||||
_settings = options.Value.Sessions;
|
||||
}
|
||||
|
||||
public TimeSpan IdleTimeout => TimeSpan.FromMinutes(_settings.IdleMinutes);
|
||||
|
||||
public int Count => _byTokenHash.Count;
|
||||
|
||||
/// <summary>Crée une session ; renvoie le jeton (à transmettre une seule fois au client).</summary>
|
||||
public (string Token, AdminSessionState Session) Create(Guid adminId, string keyId, string? ip, string? clientName)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
var token = Base64Url.Encode(RandomNumberGenerator.GetBytes(32));
|
||||
var session = new AdminSessionState
|
||||
{
|
||||
Id = Base64Url.Encode(RandomNumberGenerator.GetBytes(16)),
|
||||
TokenHash = Hash(token),
|
||||
AdminId = adminId,
|
||||
KeyId = keyId,
|
||||
CreatedAt = now,
|
||||
ExpiresAt = now + TimeSpan.FromHours(_settings.MaxHours),
|
||||
Ip = ip,
|
||||
ClientName = clientName,
|
||||
LastSeenAt = now
|
||||
};
|
||||
|
||||
lock (_createLock)
|
||||
{
|
||||
RemoveExpired();
|
||||
// Au-delà du nombre autorisé pour cet administrateur : les plus anciennes sont fermées.
|
||||
var own = _byTokenHash.Values.Where(s => s.AdminId == adminId).OrderBy(s => s.CreatedAt).ToList();
|
||||
foreach (var old in own.Take(Math.Max(0, own.Count - _settings.MaxPerAdmin + 1)))
|
||||
_byTokenHash.TryRemove(old.TokenHash, out _);
|
||||
if (_byTokenHash.Count >= _settings.MaxTotal) throw new StoreBusyException("Trop de sessions ouvertes.");
|
||||
_byTokenHash[session.TokenHash] = session;
|
||||
}
|
||||
return (token, session);
|
||||
}
|
||||
|
||||
/// <summary>Session correspondant au jeton, si elle est encore valide ; l'activité est enregistrée.</summary>
|
||||
public AdminSessionState? Find(string? token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token) || token.Length > 256) return null;
|
||||
var hash = Hash(token);
|
||||
if (!_byTokenHash.TryGetValue(hash, out var session)) return null;
|
||||
|
||||
var now = _time.GetUtcNow();
|
||||
if (IsExpired(session, now))
|
||||
{
|
||||
_byTokenHash.TryRemove(hash, out _);
|
||||
return null;
|
||||
}
|
||||
session.LastSeenAt = now;
|
||||
return session;
|
||||
}
|
||||
|
||||
public DateTimeOffset IdleExpiresAt(AdminSessionState session)
|
||||
{
|
||||
var idle = session.LastSeenAt + IdleTimeout;
|
||||
return idle < session.ExpiresAt ? idle : session.ExpiresAt;
|
||||
}
|
||||
|
||||
public void Remove(AdminSessionState session) => _byTokenHash.TryRemove(session.TokenHash, out _);
|
||||
|
||||
public int RemoveWhere(Func<AdminSessionState, bool> predicate)
|
||||
{
|
||||
var removed = 0;
|
||||
foreach (var (hash, session) in _byTokenHash)
|
||||
if (predicate(session) && _byTokenHash.TryRemove(hash, out _)) removed++;
|
||||
return removed;
|
||||
}
|
||||
|
||||
public void RemoveExpired()
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
RemoveWhere(s => IsExpired(s, now));
|
||||
}
|
||||
|
||||
private bool IsExpired(AdminSessionState session, DateTimeOffset now) =>
|
||||
now >= session.ExpiresAt || now - session.LastSeenAt >= IdleTimeout;
|
||||
|
||||
private static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Requêtes signées : toute route qui modifie quelque chose exige, en plus de la session, une
|
||||
/// signature de la clé de l'administrateur sur la méthode, le chemin, le corps, l'heure et un nonce
|
||||
/// (<see cref="SignedMessages.Request"/>). Un jeton de session volé ne permet donc aucune modification.
|
||||
/// </summary>
|
||||
public static class SignedRequests
|
||||
{
|
||||
private const string BodyKey = "GameLauncher.Body";
|
||||
|
||||
/// <summary>
|
||||
/// Middleware : copie en mémoire le corps des requêtes qui modifient (taille bornée par Kestrel),
|
||||
/// pour pouvoir le signer après la lecture JSON par la route.
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseRequestBodyCapture(this IApplicationBuilder app) => app.Use(async (context, next) =>
|
||||
{
|
||||
if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method) &&
|
||||
!HttpMethods.IsOptions(context.Request.Method))
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(buffer, context.RequestAborted);
|
||||
var body = buffer.ToArray();
|
||||
context.Items[BodyKey] = body;
|
||||
context.Request.Body = new MemoryStream(body, writable: false);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
/// <summary>La route exige une requête signée par la clé de la session (à placer après RequireAdmin/RequirePermission).</summary>
|
||||
public static TBuilder RequireSignedRequest<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
|
||||
builder.AddEndpointFilter(async (invocation, next) =>
|
||||
{
|
||||
var context = invocation.HttpContext;
|
||||
var services = context.RequestServices;
|
||||
var failure = Verify(context, services.GetRequiredService<ServerIdentity>(), services.GetRequiredService<ReplayCache>(),
|
||||
services.GetRequiredService<TimeProvider>());
|
||||
if (failure is null) return await next(invocation);
|
||||
|
||||
var admin = AdminSessionHandler.Get(context);
|
||||
services.GetRequiredService<AuditLog>().Write("request.signature", AuditResults.Denied, admin?.Admin.Id,
|
||||
admin?.Admin.DisplayName, admin?.Key.KeyId, context.ClientIp(), $"{context.Request.Method} {context.Request.Path}",
|
||||
failure.Value.Message);
|
||||
return ApiResults.Unauthorized(failure.Value.Message, failure.Value.Code);
|
||||
});
|
||||
|
||||
private static (string Code, string Message)? Verify(HttpContext context, ServerIdentity server, ReplayCache replays, TimeProvider time)
|
||||
{
|
||||
var admin = AdminSessionHandler.Get(context);
|
||||
if (admin is null) return (ApiErrorCodes.Unauthorized, "Authentification requise.");
|
||||
|
||||
var headers = context.Request.Headers;
|
||||
var timestampText = headers[SignedRequestHeaders.Timestamp].ToString();
|
||||
var nonce = headers[SignedRequestHeaders.Nonce].ToString();
|
||||
var signature = AdminKeys.DecodeSignature(headers[SignedRequestHeaders.Signature].ToString());
|
||||
if (string.IsNullOrEmpty(timestampText) || string.IsNullOrEmpty(nonce) || signature is null)
|
||||
return (ApiErrorCodes.SignatureRequired, "Cette action doit être signée par votre clé.");
|
||||
|
||||
if (!long.TryParse(timestampText, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var timestamp))
|
||||
return (ApiErrorCodes.SignatureInvalid, "Horodatage de signature invalide.");
|
||||
if (nonce.Length is < 16 or > 64 || !Base64Url.TryDecode(nonce, out _))
|
||||
return (ApiErrorCodes.SignatureInvalid, "Nonce de signature invalide.");
|
||||
|
||||
var now = time.GetUtcNow();
|
||||
var skew = SignedRequestHeaders.MaxClockSkew;
|
||||
DateTimeOffset signedAt;
|
||||
try
|
||||
{
|
||||
signedAt = DateTimeOffset.FromUnixTimeSeconds(timestamp);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return (ApiErrorCodes.SignatureInvalid, "Horodatage de signature invalide.");
|
||||
}
|
||||
if (signedAt < now - skew || signedAt > now + skew)
|
||||
return (ApiErrorCodes.ClockSkew, "L'horloge de votre PC est décalée de plus de 2 minutes : corrigez l'heure de Windows.");
|
||||
|
||||
var body = context.Items.TryGetValue(BodyKey, out var captured) && captured is byte[] bytes ? bytes : Array.Empty<byte>();
|
||||
var request = context.Request;
|
||||
var message = SignedMessages.Request(server.Origin, server.ServerId, admin.Session.Id, request.Method,
|
||||
request.PathBase.Add(request.Path).ToUriComponent() + request.QueryString.ToUriComponent(), body, timestamp, nonce);
|
||||
if (!AdminKeys.Verify(admin.Key.PublicKeyBytes(), message, signature))
|
||||
return (ApiErrorCodes.SignatureInvalid, "Signature de la requête invalide.");
|
||||
|
||||
// Nonce enregistré seulement une fois la signature vérifiée.
|
||||
if (!replays.TryRegister(admin.Session.Id + ":" + nonce, skew + skew + TimeSpan.FromMinutes(1)))
|
||||
return (ApiErrorCodes.Replay, "Requête déjà reçue (rejeu refusé).");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Contenu de admins.json. Ne contient AUCUN secret : clés publiques et empreintes de codes
|
||||
/// d'invitation uniquement.
|
||||
/// </summary>
|
||||
public sealed class AdminDatabase
|
||||
{
|
||||
public int FormatVersion { get; set; } = 1;
|
||||
public List<AdminRecord> Admins { get; set; } = new();
|
||||
public List<InvitationRecord> Invitations { get; set; } = new();
|
||||
|
||||
public AdminRecord? FindAdmin(Guid id) => Admins.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
/// <summary>Administrateur possédant cette clé (quel que soit l'état de la clé).</summary>
|
||||
public (AdminRecord Admin, KeyRecord Key)? FindKey(string keyId)
|
||||
{
|
||||
foreach (var admin in Admins)
|
||||
foreach (var key in admin.Keys)
|
||||
if (string.Equals(key.KeyId, keyId, StringComparison.Ordinal)) return (admin, key);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Propriétaires actifs disposant d'au moins une clé active (ceux qui peuvent encore se connecter).</summary>
|
||||
public IEnumerable<AdminRecord> UsableOwners() =>
|
||||
Admins.Where(a => a.Role == AdminRoles.Owner && a.Status == AdminStatus.Active &&
|
||||
a.Keys.Any(k => k.Status == AdminStatus.Active));
|
||||
}
|
||||
|
||||
public sealed class AdminRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string Role { get; set; } = AdminRoles.Editor;
|
||||
public string Status { get; set; } = AdminStatus.Pending;
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTimeOffset? ApprovedAt { get; set; }
|
||||
public string? ApprovedBy { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public string? RevokedBy { get; set; }
|
||||
public List<KeyRecord> Keys { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class KeyRecord
|
||||
{
|
||||
public string KeyId { get; set; } = "";
|
||||
|
||||
/// <summary>Clé publique : SPKI en Base64.</summary>
|
||||
public string PublicKey { get; set; } = "";
|
||||
|
||||
public string? Label { get; set; }
|
||||
public string Status { get; set; } = AdminStatus.Pending;
|
||||
public DateTimeOffset AddedAt { get; set; }
|
||||
public DateTimeOffset? LastUsedAt { get; set; }
|
||||
public DateTimeOffset? ApprovedAt { get; set; }
|
||||
public string? ApprovedBy { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public string? RevokedBy { get; set; }
|
||||
|
||||
public byte[] PublicKeyBytes() => Convert.FromBase64String(PublicKey);
|
||||
}
|
||||
|
||||
public sealed class InvitationRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Empreinte du code (<see cref="SignedMessages.HashInvitationCode"/>) ; le code n'est jamais stocké.</summary>
|
||||
public string CodeHash { get; set; } = "";
|
||||
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string Role { get; set; } = AdminRoles.Editor;
|
||||
|
||||
/// <summary>Invitation « nouvel appareil » pour un administrateur existant.</summary>
|
||||
public Guid? ForAdminId { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public Guid? CreatedById { get; set; }
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Administrateurs et invitations (admins.json dans le dossier des données).
|
||||
/// • Lecture : copie en cache, relue automatiquement si le fichier a changé (outil en ligne de
|
||||
/// commande) — une révocation faite sur le VPS prend effet sans redémarrer le service.
|
||||
/// • Écriture : <see cref="Update{T}"/> relit le fichier sous verrou inter-processus, applique la
|
||||
/// modification sur une copie, puis remplace le fichier atomiquement (ancienne version en .bak).
|
||||
/// • Fichier illisible : le serveur refuse tout (aucune reprise silencieuse d'une sauvegarde, qui
|
||||
/// pourrait réactiver un administrateur révoqué).
|
||||
/// </summary>
|
||||
public sealed class AdminStore
|
||||
{
|
||||
public const string FileName = "admins.json";
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new(Contracts.ApiJson.Options) { WriteIndented = true };
|
||||
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string _path;
|
||||
private readonly string _lockPath;
|
||||
private readonly object _cacheLock = new(); // court : échange du cache
|
||||
private readonly object _writeLock = new(); // un seul écrivain dans ce processus
|
||||
private AdminDatabase? _cache;
|
||||
private (DateTime WriteTime, long Length) _cacheStamp;
|
||||
|
||||
public AdminStore(IOptions<LauncherApiOptions> options) : this(options.Value.DataDirectoryFullPath) { }
|
||||
|
||||
public AdminStore(string dataDirectory)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
_path = Path.Combine(dataDirectory, FileName);
|
||||
_lockPath = _path + ".lock";
|
||||
}
|
||||
|
||||
public string FilePath => _path;
|
||||
|
||||
/// <summary>
|
||||
/// Instantané en lecture seule (ne pas le modifier : utiliser <see cref="Update{T}"/>).
|
||||
/// </summary>
|
||||
public AdminDatabase Read()
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
var stamp = Stamp();
|
||||
if (_cache is not null && stamp == _cacheStamp) return _cache;
|
||||
_cache = Load();
|
||||
_cacheStamp = stamp;
|
||||
return _cache;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Modification atomique. <paramref name="change"/> travaille sur une copie fraîchement relue ;
|
||||
/// s'il lève une exception, rien n'est écrit.
|
||||
/// </summary>
|
||||
public T Update<T>(Func<AdminDatabase, T> change)
|
||||
{
|
||||
// Les lectures ne sont jamais bloquées par une écriture en attente du verrou fichier
|
||||
// (le renommage atomique leur donne l'ancienne ou la nouvelle version, jamais un mélange).
|
||||
lock (_writeLock)
|
||||
{
|
||||
using var fileLock = SecureFiles.AcquireLock(_lockPath, LockTimeout);
|
||||
var database = Load();
|
||||
var result = change(database);
|
||||
SecureFiles.WriteAllTextAtomic(_path, JsonSerializer.Serialize(database, Json), keepBackup: true);
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_cache = database;
|
||||
_cacheStamp = Stamp();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public void Update(Action<AdminDatabase> change) => Update(db =>
|
||||
{
|
||||
change(db);
|
||||
return true;
|
||||
});
|
||||
|
||||
private AdminDatabase Load()
|
||||
{
|
||||
if (!File.Exists(_path)) return new AdminDatabase();
|
||||
try
|
||||
{
|
||||
var database = JsonSerializer.Deserialize<AdminDatabase>(File.ReadAllText(_path), Json)
|
||||
?? throw new InvalidDataException("fichier vide");
|
||||
if (database.FormatVersion != 1) throw new InvalidDataException($"format {database.FormatVersion} inconnu");
|
||||
database.Admins ??= new();
|
||||
database.Invitations ??= new();
|
||||
foreach (var admin in database.Admins) admin.Keys ??= new();
|
||||
return database;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidDataException)
|
||||
{
|
||||
throw new InvalidDataException($"{_path} est illisible ({ex.Message}). Restaurez {FileName}.bak APRÈS l'avoir vérifié : " +
|
||||
"c'est la version précédant la dernière modification (une révocation récente y serait annulée).", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private (DateTime, long) Stamp()
|
||||
{
|
||||
var info = new FileInfo(_path);
|
||||
return info.Exists ? (info.LastWriteTimeUtc, info.Length) : (DateTime.MinValue, -1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Journal d'audit : un fichier JSON Lines par mois (audit/audit-2026-10.jsonl), en ajout seul.
|
||||
/// Chaque action d'administration y figure (réussie ou refusée), avec l'auteur, la clé et l'adresse IP.
|
||||
/// Les entrées sont aussi écrites dans le journal du service (journalctl).
|
||||
/// </summary>
|
||||
public sealed class AuditLog
|
||||
{
|
||||
public const int MaxPageSize = 200;
|
||||
private const int MaxFieldLength = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Échecs SANS authentification (connexion refusée, code d'invitation faux) détaillés au plus
|
||||
/// ce nombre de fois par minute ; au-delà, ils sont comptés et résumés en une ligne : une
|
||||
/// avalanche de tentatives ne peut ni noyer les vraies entrées ni remplir le disque.
|
||||
/// </summary>
|
||||
public const int MaxAnonymousFailuresPerMinute = 10;
|
||||
|
||||
private readonly object _throttleLock = new();
|
||||
private DateTimeOffset _throttleWindow;
|
||||
private int _throttleCount;
|
||||
private int _suppressed;
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new(Contracts.ApiJson.Options) { WriteIndented = false };
|
||||
|
||||
private readonly string _directory;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger<AuditLog>? _logger;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
public AuditLog(IOptions<LauncherApiOptions> options, TimeProvider time, ILogger<AuditLog> logger)
|
||||
: this(options.Value.DataDirectoryFullPath, time, logger) { }
|
||||
|
||||
public AuditLog(string dataDirectory, TimeProvider time, ILogger<AuditLog>? logger = null)
|
||||
{
|
||||
_directory = Path.Combine(dataDirectory, "audit");
|
||||
_time = time;
|
||||
_logger = logger;
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
SecureFiles.EnsureDirectory(_directory);
|
||||
}
|
||||
|
||||
public void Write(string action, string result, Guid? actorId = null, string? actorName = null, string? keyId = null,
|
||||
string? ip = null, string? target = null, string? details = null)
|
||||
{
|
||||
var entry = new AuditEntry
|
||||
{
|
||||
Timestamp = _time.GetUtcNow(),
|
||||
Action = Clean(action) ?? "?",
|
||||
Result = Clean(result) ?? "?",
|
||||
ActorId = actorId,
|
||||
ActorName = Clean(actorName),
|
||||
KeyId = Clean(keyId),
|
||||
Ip = Clean(ip),
|
||||
Target = Clean(target),
|
||||
Details = Clean(details)
|
||||
};
|
||||
|
||||
_logger?.LogInformation("AUDIT {Action} {Result} acteur={Actor} clé={KeyId} ip={Ip} cible={Target} {Details}",
|
||||
entry.Action, entry.Result, entry.ActorName ?? entry.ActorId?.ToString(), entry.KeyId, entry.Ip, entry.Target, entry.Details);
|
||||
|
||||
var line = JsonSerializer.Serialize(entry, Json) + "\n";
|
||||
var path = Path.Combine(_directory, $"audit-{entry.Timestamp.UtcDateTime:yyyy-MM}.jsonl");
|
||||
try
|
||||
{
|
||||
lock (_writeLock)
|
||||
{
|
||||
using var fileLock = SecureFiles.AcquireLock(Path.Combine(_directory, "audit.lock"), TimeSpan.FromSeconds(5));
|
||||
using var stream = SecureFiles.OpenAppend(path);
|
||||
stream.Write(Encoding.UTF8.GetBytes(line));
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or StoreBusyException)
|
||||
{
|
||||
// L'action elle-même n'est pas annulée, mais l'échec est visible dans journalctl.
|
||||
if (_logger is not null) _logger.LogError(ex, "Écriture du journal d'audit impossible : {Line}", line.TrimEnd());
|
||||
else Console.Error.WriteLine($"ATTENTION : journal d'audit non écrit ({ex.Message}).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Échec d'une requête non authentifiée (voir <see cref="MaxAnonymousFailuresPerMinute"/>).</summary>
|
||||
public void WriteAnonymousFailure(string action, string? keyId, string? ip, string? details)
|
||||
{
|
||||
int suppressed;
|
||||
lock (_throttleLock)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
if (now - _throttleWindow >= TimeSpan.FromMinutes(1))
|
||||
{
|
||||
_throttleWindow = now;
|
||||
_throttleCount = 0;
|
||||
}
|
||||
if (_throttleCount >= MaxAnonymousFailuresPerMinute)
|
||||
{
|
||||
if (_suppressed++ == 0)
|
||||
_logger?.LogWarning("AUDIT afflux de tentatives refusées sans authentification (dernière : {Action} ip={Ip}) : " +
|
||||
"détail suspendu, résumé à la minute suivante.", action, ip);
|
||||
return;
|
||||
}
|
||||
_throttleCount++;
|
||||
suppressed = _suppressed;
|
||||
_suppressed = 0;
|
||||
}
|
||||
if (suppressed > 0)
|
||||
Write("audit.suppressed", Contracts.Dtos.AuditResults.Denied,
|
||||
details: $"{suppressed} tentative(s) refusée(s) sans authentification non détaillée(s) (afflux de requêtes)");
|
||||
Write(action, Contracts.Dtos.AuditResults.Denied, keyId: keyId, ip: ip, details: details);
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, (DateTimeOffset Window, int Suppressed)> _perKey = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Refus d'une requête correctement SIGNÉE (clé révoquée ou en attente, rejeu) : toujours
|
||||
/// détaillé, au plus une ligne par clé et par minute (les suivantes sont comptées et signalées
|
||||
/// sur la ligne suivante). Seules les clés enregistrées produisent des signatures valides : la
|
||||
/// table reste petite.
|
||||
/// </summary>
|
||||
public void WriteSignedRefusal(string action, Guid? actorId, string? actorName, string keyId, string? ip, string details)
|
||||
{
|
||||
int suppressed;
|
||||
lock (_throttleLock)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
if (_perKey.TryGetValue(keyId, out var state) && now - state.Window < TimeSpan.FromMinutes(1))
|
||||
{
|
||||
_perKey[keyId] = (state.Window, state.Suppressed + 1);
|
||||
return;
|
||||
}
|
||||
suppressed = state.Suppressed;
|
||||
_perKey[keyId] = (now, 0);
|
||||
if (_perKey.Count > 10_000) _perKey.Clear();
|
||||
}
|
||||
Write(action, Contracts.Dtos.AuditResults.Denied, actorId, actorName, keyId, ip,
|
||||
details: suppressed > 0 ? $"{details} (+{suppressed} refus identique(s) la minute précédente)" : details);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entrées les plus récentes d'abord. <paramref name="cursor"/> : valeur NextCursor de la page
|
||||
/// précédente (« aaaa-MM:position »). Les fichiers sont lus depuis la fin, par blocs : la mémoire
|
||||
/// utilisée ne dépend pas de leur taille.
|
||||
/// </summary>
|
||||
public AuditPage Read(int limit, string? cursor)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, MaxPageSize);
|
||||
var months = Directory.Exists(_directory)
|
||||
? Directory.GetFiles(_directory, "audit-*.jsonl").Select(Path.GetFileName).OfType<string>()
|
||||
.Select(name => name["audit-".Length..^".jsonl".Length])
|
||||
.Where(month => DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
|
||||
.OrderByDescending(month => month, StringComparer.Ordinal).ToList()
|
||||
: new List<string>();
|
||||
|
||||
string? startMonth = null;
|
||||
var startOffset = long.MaxValue;
|
||||
if (!string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
var parts = cursor.Split(':');
|
||||
if (parts.Length != 2 || !months.Contains(parts[0]) ||
|
||||
!long.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out startOffset))
|
||||
return new AuditPage();
|
||||
startMonth = parts[0];
|
||||
}
|
||||
|
||||
var entries = new List<AuditEntry>(limit);
|
||||
for (var m = 0; m < months.Count; m++)
|
||||
{
|
||||
var month = months[m];
|
||||
if (startMonth is not null && string.CompareOrdinal(month, startMonth) > 0) continue;
|
||||
var end = month == startMonth ? startOffset : long.MaxValue;
|
||||
// Lignes illisibles (écriture interrompue, modification manuelle) ignorées sans rien masquer :
|
||||
// la lecture continue jusqu'à obtenir « limit » entrées lisibles.
|
||||
foreach (var (start, line) in ReadBackward(Path.Combine(_directory, $"audit-{month}.jsonl"), end))
|
||||
{
|
||||
if (TryParse(line) is not { } entry) continue;
|
||||
entries.Add(entry);
|
||||
if (entries.Count == limit)
|
||||
{
|
||||
var more = start > 0 || m < months.Count - 1;
|
||||
return new AuditPage { Entries = entries, NextCursor = more ? $"{month}:{start}" : null };
|
||||
}
|
||||
}
|
||||
}
|
||||
return new AuditPage { Entries = entries };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lignes situées avant <paramref name="end"/> (octets), de la dernière à la première, avec leur
|
||||
/// position, lues par blocs depuis la fin. Une ligne démesurée (plus de 1 Mo) est sautée.
|
||||
/// </summary>
|
||||
private static IEnumerable<(long Start, string Line)> ReadBackward(string path, long end)
|
||||
{
|
||||
if (!File.Exists(path)) yield break;
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
var position = Math.Min(end, stream.Length);
|
||||
var carry = Array.Empty<byte>();
|
||||
var skipping = false; // fin de ligne démesurée abandonnée : on cherche son début
|
||||
const int blockSize = 64 * 1024;
|
||||
const int maxLine = 1024 * 1024;
|
||||
|
||||
while (position > 0)
|
||||
{
|
||||
var size = (int)Math.Min(blockSize, position);
|
||||
position -= size;
|
||||
var data = new byte[size + carry.Length];
|
||||
stream.Position = position;
|
||||
stream.ReadExactly(data, 0, size);
|
||||
carry.CopyTo(data, size);
|
||||
|
||||
var lineEnd = data.Length;
|
||||
for (var i = data.Length - 1; i >= 0; i--)
|
||||
{
|
||||
if (data[i] != (byte)'\n') continue;
|
||||
if (!skipping && lineEnd > i + 1) yield return (position + i + 1, Encoding.UTF8.GetString(data, i + 1, lineEnd - i - 1));
|
||||
skipping = false;
|
||||
lineEnd = i;
|
||||
}
|
||||
carry = data[..lineEnd];
|
||||
if (carry.Length > maxLine)
|
||||
{
|
||||
carry = Array.Empty<byte>();
|
||||
skipping = true;
|
||||
}
|
||||
}
|
||||
if (!skipping && carry.Length > 0) yield return (0, Encoding.UTF8.GetString(carry));
|
||||
}
|
||||
|
||||
private static AuditEntry? TryParse(string line)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<AuditEntry>(line, Json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Clean(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var sb = new StringBuilder(Math.Min(value.Length, MaxFieldLength));
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (sb.Length >= MaxFieldLength) break;
|
||||
sb.Append(char.IsControl(c) ? ' ' : c);
|
||||
}
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Fichiers privés du serveur : dossier 0700 et fichiers 0600 sous Linux (le service tourne sous un
|
||||
/// compte dédié), écriture atomique (fichier temporaire puis renommage) et verrou inter-processus
|
||||
/// (le service et l'outil en ligne de commande peuvent écrire en même temps).
|
||||
/// </summary>
|
||||
internal static class SecureFiles
|
||||
{
|
||||
private const UnixFileMode DirectoryMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
|
||||
private const UnixFileMode FileMode600 = UnixFileMode.UserRead | UnixFileMode.UserWrite;
|
||||
|
||||
public static void EnsureDirectory(string path)
|
||||
{
|
||||
if (Directory.Exists(path)) return;
|
||||
if (OperatingSystem.IsWindows()) Directory.CreateDirectory(path);
|
||||
else Directory.CreateDirectory(path, DirectoryMode);
|
||||
}
|
||||
|
||||
/// <summary>Remplace le contenu d'un fichier de façon atomique ; l'ancienne version est copiée en « .bak ».</summary>
|
||||
public static void WriteAllTextAtomic(string path, string content, bool keepBackup)
|
||||
{
|
||||
var temp = path + ".tmp";
|
||||
var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content);
|
||||
using (var stream = new FileStream(temp, CreateOptions(System.IO.FileMode.Create)))
|
||||
{
|
||||
stream.Write(bytes);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
if (keepBackup && File.Exists(path)) File.Copy(path, path + ".bak", overwrite: true);
|
||||
File.Move(temp, path, overwrite: true); // renommage : atomique sur le même système de fichiers
|
||||
}
|
||||
|
||||
/// <summary>Crée un fichier uniquement s'il n'existe pas encore (faux s'il existait).</summary>
|
||||
public static bool TryCreateNew(string path, string content)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(path, CreateOptions(System.IO.FileMode.CreateNew));
|
||||
stream.Write(Encoding.UTF8.GetBytes(content));
|
||||
stream.Flush(flushToDisk: true);
|
||||
return true;
|
||||
}
|
||||
catch (IOException) when (File.Exists(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ouvre un fichier en ajout (journal d'audit), créé en 0600.</summary>
|
||||
public static FileStream OpenAppend(string path) =>
|
||||
new(path, Private(new FileStreamOptions { Mode = System.IO.FileMode.Append, Access = FileAccess.Write, Share = FileShare.Read }));
|
||||
|
||||
/// <summary>
|
||||
/// Verrou exclusif inter-processus (fichier « .lock »). Réessaie pendant <paramref name="timeout"/>,
|
||||
/// puis lève <see cref="StoreBusyException"/>.
|
||||
/// </summary>
|
||||
public static FileStream AcquireLock(string lockPath, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new FileStream(lockPath, Private(new FileStreamOptions
|
||||
{
|
||||
Mode = System.IO.FileMode.OpenOrCreate,
|
||||
Access = FileAccess.ReadWrite,
|
||||
Share = FileShare.None
|
||||
}));
|
||||
}
|
||||
catch (IOException) when (DateTime.UtcNow < deadline)
|
||||
{
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
throw new StoreBusyException($"Fichier verrouillé : {Path.GetFileName(lockPath)}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static FileStreamOptions CreateOptions(System.IO.FileMode mode) =>
|
||||
Private(new FileStreamOptions { Mode = mode, Access = FileAccess.Write, Share = FileShare.None });
|
||||
|
||||
/// <summary>Fichier créé en 0600 sous Linux (lecture/écriture pour le compte du service uniquement).</summary>
|
||||
private static FileStreamOptions Private(FileStreamOptions options)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = FileMode600;
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Données momentanément inaccessibles (verrou tenu par un autre processus) : réponse 503.</summary>
|
||||
public sealed class StoreBusyException : Exception
|
||||
{
|
||||
public StoreBusyException(string message, Exception? inner = null) : base(message, inner) { }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Identifiant aléatoire et permanent de ce serveur (fichier « server-id »), inclus dans les messages
|
||||
/// signés : une signature destinée à un autre serveur (test, ancien VPS…) est refusée.
|
||||
/// </summary>
|
||||
public sealed class ServerIdentity
|
||||
{
|
||||
public const string FileName = "server-id";
|
||||
|
||||
public ServerIdentity(IOptions<LauncherApiOptions> options) : this(options.Value) { }
|
||||
|
||||
public ServerIdentity(LauncherApiOptions options)
|
||||
{
|
||||
Origin = options.NormalizedOrigin;
|
||||
ServerId = LoadOrCreate(options.DataDirectoryFullPath);
|
||||
}
|
||||
|
||||
public string ServerId { get; }
|
||||
|
||||
/// <summary>Adresse publique de l'API (« https://hote »).</summary>
|
||||
public string Origin { get; }
|
||||
|
||||
public static string LoadOrCreate(string dataDirectory)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
var path = Path.Combine(dataDirectory, FileName);
|
||||
if (!File.Exists(path))
|
||||
SecureFiles.TryCreateNew(path, Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant());
|
||||
|
||||
var id = File.ReadAllText(path).Trim();
|
||||
if (id.Length != 32 || !id.All(Uri.IsHexDigit))
|
||||
throw new InvalidDataException($"{path} : identifiant de serveur invalide.");
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
// Développement (F5 dans Visual Studio) : HTTP local, données dans le dossier de compilation.
|
||||
"LauncherApi": {
|
||||
"PublicOrigin": "http://127.0.0.1:5080",
|
||||
"DataDirectory": "data-dev",
|
||||
"RequireHttps": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
// Écoute locale uniquement : seul nginx (même machine) joint l'API.
|
||||
"Urls": "http://127.0.0.1:5080",
|
||||
|
||||
"LauncherApi": {
|
||||
// Adresse publique de l'API, SANS chemin : elle est incluse dans les messages signés.
|
||||
"PublicOrigin": "https://launcher.lanisys.fr",
|
||||
// Données privées (administrateurs, audit) : JAMAIS dans le dossier servi par nginx.
|
||||
"DataDirectory": "/var/lib/launcher-api",
|
||||
"RequireHttps": true,
|
||||
"TrustedProxies": [ "127.0.0.1", "::1" ],
|
||||
"Sessions": {
|
||||
"IdleMinutes": 20,
|
||||
"MaxHours": 8,
|
||||
"MaxPerAdmin": 5
|
||||
},
|
||||
"ChallengeLifetimeSeconds": 60,
|
||||
"InvitationLifetimeHours": 24,
|
||||
"Limits": {
|
||||
"RequestsPerMinute": 300,
|
||||
"AuthPerMinute": 20,
|
||||
"EnrollmentsPerHour": 10,
|
||||
"MaxActiveInvitations": 20,
|
||||
"MaxRequestBodyBytes": 65536
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
# Launcher API — installation sur le VPS
|
||||
|
||||
La Launcher API est le serveur d'administration du launcher (voir `docs/ADMIN-ARCHITECTURE.md`).
|
||||
Elle tourne sur le VPS de téléchargement, derrière nginx, à l'adresse
|
||||
`https://launcher.lanisys.fr/api/`. Ce guide suppose le VPS déjà préparé avec
|
||||
« Serveur de téléchargement Exovival – VPS Debian 13 » (compte `admin` en SSH, compte `deploy`,
|
||||
nginx + HTTPS).
|
||||
|
||||
Compter 20 minutes la première fois. Les commandes `PS>` se tapent dans PowerShell sur ton PC,
|
||||
les autres sur le VPS.
|
||||
|
||||
## 1. Publier le serveur (PC)
|
||||
|
||||
Double-clique `tools\publish-server.bat`. Il produit dans `Publish\Server\` :
|
||||
|
||||
* `launcher-api.tar.gz` : le serveur pour Linux x64, **autonome** (aucun .NET à installer sur le VPS) ;
|
||||
* `installer-api.sh` : le script d'installation ;
|
||||
* `launcher-api.tar.gz.sha256` : l'empreinte de l'archive, aussi affichée à la fin du script.
|
||||
|
||||
## 2. Envoyer et installer (PC puis VPS)
|
||||
|
||||
```
|
||||
PS> scp "Publish\Server\launcher-api.tar.gz" "Publish\Server\installer-api.sh" admin@launcher.lanisys.fr:/tmp/
|
||||
```
|
||||
|
||||
```
|
||||
sudo sh /tmp/installer-api.sh /tmp/launcher-api.tar.gz <empreinte affichée par publish-server.bat>
|
||||
rm /tmp/launcher-api.tar.gz /tmp/installer-api.sh
|
||||
```
|
||||
|
||||
L'empreinte garantit que l'archive installée (en root) est exactement celle produite sur ton PC :
|
||||
une archive altérée pendant le transfert ou dans `/tmp` est refusée sans rien modifier.
|
||||
|
||||
Le script installe les binaires dans `/opt/launcher-api` (propriétaire root), le service systemd
|
||||
`launcher-api` (compte `deploy`, durci, aucune connexion sortante), le démarre et vérifie qu'il répond. Il doit finir par
|
||||
`{"status":"ok"}`. Les données privées sont dans `/var/lib/launcher-api` (droits 700).
|
||||
|
||||
> Réglages propres au VPS (autre domaine, durées de session…) : crée
|
||||
> `/opt/launcher-api/appsettings.Production.json` avec uniquement les valeurs à changer, par ex.
|
||||
> `{ "LauncherApi": { "PublicOrigin": "https://dl.autredomaine.fr" } }`, puis
|
||||
> `sudo systemctl restart launcher-api`. Ce fichier est conservé lors des mises à jour.
|
||||
|
||||
## 3. Brancher nginx (VPS)
|
||||
|
||||
1. Limite de requêtes : ajoute cette ligne dans `/etc/nginx/conf.d/securite.conf`
|
||||
(à côté de `limit_conn_zone`) :
|
||||
|
||||
```
|
||||
limit_req_zone $binary_remote_addr zone=api_par_ip:10m rate=5r/s;
|
||||
```
|
||||
2. Ouvre `/etc/nginx/sites-available/dl-exovival` et colle le bloc `location ^~ /api/ { … }` de
|
||||
`server/deploy/nginx-api.conf` dans le bloc **HTTPS** (`listen 443 ssl`), avant `location / {`.
|
||||
3. Vérifie et recharge :
|
||||
|
||||
```
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
4. Teste depuis ton PC :
|
||||
|
||||
```
|
||||
PS> curl.exe -s https://launcher.lanisys.fr/api/v1/info
|
||||
```
|
||||
|
||||
Réponse attendue : `{"serverId":"…","origin":"https://launcher.lanisys.fr",…}`. Si `origin` est
|
||||
différente de l'adresse utilisée, corrige `PublicOrigin` (étape 2) : sinon toutes les
|
||||
signatures seront refusées.
|
||||
|
||||
## 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 :
|
||||
|
||||
| Fichier | Contenu |
|
||||
|---|---|
|
||||
| `%USERPROFILE%\.launcher-keys\admin.key` | Clé **privée**, chiffrée par ta phrase de passe. **Sauvegarde-la** (clé USB, gestionnaire de mots de passe) ; jamais dans Git, jamais sur le serveur. |
|
||||
| `%USERPROFILE%\.launcher-keys\admin.pub` | Clé publique : c'est elle qu'on donne au serveur. |
|
||||
|
||||
Note l'**empreinte** affichée (`AB12 CD34 EF56 7890 1A2B`).
|
||||
|
||||
> Depuis la phase 2, le launcher sait aussi créer la clé : menu d'administration (Ctrl+Maj+Alt+A,
|
||||
> ou 5 clics rapides sur le logo) › « J'ai une invitation », **sans code** → clé seule, même dossier,
|
||||
> même format. Une clé créée par `admin-key.bat` est reconnue par le launcher, et inversement.
|
||||
> Le launcher peut aussi créer la clé **dans la puce de sécurité (TPM) du PC** (non copiable,
|
||||
> code PIN) : il écrit alors `admin.tpmkey` (référence, sans secret) et `admin.pub` ; c'est ce
|
||||
> `.pub` qu'on déclare ci-dessous. Une clé TPM ne fonctionne que dans le launcher, sur ce PC
|
||||
> (`admin-key.bat` n'utilise que des clés fichier) : garde une clé fichier de secours (§ 9, `add-key`).
|
||||
|
||||
## 5. Te déclarer propriétaire (PC puis VPS, une seule fois)
|
||||
|
||||
```
|
||||
PS> scp "$env:USERPROFILE\.launcher-keys\admin.pub" admin@launcher.lanisys.fr:/tmp/owner.pub
|
||||
```
|
||||
|
||||
```
|
||||
sudo -u deploy /opt/launcher-api/GameLauncher.Server add-owner --name "Nicolas" --public-key /tmp/owner.pub --label "PC bureau"
|
||||
rm /tmp/owner.pub
|
||||
```
|
||||
|
||||
**Vérifie que l'empreinte affichée est celle de l'étape 4.** L'accès SSH administrateur est la
|
||||
preuve d'identité : c'est pour cela que le premier propriétaire se crée sur le VPS.
|
||||
|
||||
## 6. Tester la connexion (PC)
|
||||
|
||||
```
|
||||
PS> tools\admin-key.bat login-test --api https://launcher.lanisys.fr/api/
|
||||
```
|
||||
|
||||
Résultat attendu : `Connexion réussie : Nicolas (Propriétaire)` et la liste des permissions.
|
||||
|
||||
## 7. Ajouter un administrateur
|
||||
|
||||
Depuis la phase 2, la personne invitée peut créer et enregistrer sa clé **dans le launcher** (menu
|
||||
d'administration › « J'ai une invitation », code collé) : l'étape 2 ci-dessous se fait alors sans
|
||||
ligne de commande, et le launcher affiche l'empreinte à te dicter. Inviter et approuver se font avec
|
||||
`tools\admin-key.bat` jusqu'à la page « Administrateurs » du launcher (phase 6) :
|
||||
|
||||
1. **Toi** : `admin-key.bat invite --api https://launcher.lanisys.fr/api/ --name "Paul" --role editor`
|
||||
(rôles : `owner`, `admin`, `editor`, voir `docs/ADMIN-ARCHITECTURE.md` § 7). Transmets le code affiché (valable 24 h, usage unique).
|
||||
2. **Paul** : launcher › menu d'administration › « J'ai une invitation » (ou `admin-key.bat`, puis
|
||||
`admin-key.bat enroll --api https://launcher.lanisys.fr/api/ --code <code>`) ; il te communique
|
||||
**par téléphone ou message** l'empreinte affichée.
|
||||
3. **Toi** : `admin-key.bat admins --api …` (repère l'identifiant de Paul et de sa clé), puis
|
||||
`admin-key.bat approve --api … --admin <id> --key-id <clé> --fingerprint "<empreinte dictée par Paul>"`.
|
||||
|
||||
Le serveur n'affiche pas l'empreinte des clés en attente : l'approbation n'est possible qu'avec
|
||||
celle que Paul te donne. Si elle ne correspond pas, **n'insiste pas** : quelqu'un d'autre a pu
|
||||
utiliser le code. Révoque (`revoke-admin`) et recommence.
|
||||
|
||||
Nouveau PC pour un administrateur existant : **lui-même** lance `invite --api … --for <son id>`
|
||||
depuis un PC déjà enregistré, `enroll` sur le nouveau PC, puis `approve` depuis l'ancien. Personne
|
||||
d'autre ne peut approuver une clé à son nom. S'il a perdu tous ses PC : voir § 9 (`add-key`).
|
||||
|
||||
## 8. Mettre à jour le serveur
|
||||
|
||||
Refais les étapes 1 et 2. Les administrateurs, l'audit et `appsettings.Production.json` sont
|
||||
conservés ; les sessions ouvertes sont fermées (reconnexion). Si la nouvelle version ne démarre
|
||||
pas, le script remet la précédente en place.
|
||||
|
||||
## 9. Récupération (VPS)
|
||||
|
||||
| Situation | Commande |
|
||||
|---|---|
|
||||
| Voir les administrateurs, clés et empreintes | `sudo -u deploy /opt/launcher-api/GameLauncher.Server list` |
|
||||
| PC perdu ou volé | `sudo -u deploy /opt/launcher-api/GameLauncher.Server revoke-key --key <identifiant>` |
|
||||
| Clé TPM supprimée dans le launcher (« Supprimer cette clé de la puce ») | Même commande `revoke-key`, avec l'identifiant affiché par le launcher |
|
||||
| Retirer un administrateur | `sudo -u deploy /opt/launcher-api/GameLauncher.Server revoke-admin --id <identifiant>` |
|
||||
| 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 |
|
||||
|
||||
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
|
||||
mêmes réglages que le service (`appsettings.json` puis `appsettings.Production.json`).
|
||||
|
||||
`admins.json` illisible : le service refuse de démarrer. `admins.json.bak` est la version qui
|
||||
précède la **dernière** modification : vérifie-la avant de la restaurer (si cette dernière
|
||||
modification était une révocation, la restaurer réactiverait la clé révoquée ; refais alors la
|
||||
révocation).
|
||||
|
||||
Le service n'a pas besoin d'être redémarré : une révocation ferme immédiatement les sessions concernées.
|
||||
|
||||
## 10. Surveillance
|
||||
|
||||
| Pour… | Commande |
|
||||
|---|---|
|
||||
| Journal du service (connexions, refus, erreurs) | `journalctl -u launcher-api -f` |
|
||||
| Journal d'audit (une ligne JSON par action) | `sudo tail -n 20 /var/lib/launcher-api/audit/audit-$(date +%Y-%m).jsonl` |
|
||||
| Niveau de durcissement du service | `systemd-analyze security launcher-api` |
|
||||
| État | `systemctl status launcher-api` |
|
||||
|
||||
**Sauvegarde** : `/var/lib/launcher-api/admins.json` (une copie `.bak` de la version précédente est
|
||||
gardée à côté). Il ne contient aucun secret (clés publiques uniquement), mais sa perte oblige à
|
||||
recréer les administrateurs.
|
||||
|
||||
## Fichiers sur le VPS
|
||||
|
||||
```
|
||||
/opt/launcher-api/ binaires (root, lecture seule pour le service)
|
||||
├─ GameLauncher.Server serveur + outil en ligne de commande
|
||||
├─ appsettings.json réglages livrés (remplacés à chaque mise à jour)
|
||||
└─ appsettings.Production.json TES réglages (facultatif, conservé)
|
||||
/var/lib/launcher-api/ données privées (deploy, 700)
|
||||
├─ admins.json (+ .bak) administrateurs, clés publiques, invitations (empreintes)
|
||||
├─ server-id identifiant aléatoire du serveur
|
||||
├─ audit/audit-AAAA-MM.jsonl journal d'audit
|
||||
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
|
||||
/etc/systemd/system/launcher-api.service
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/bin/sh
|
||||
# =============================================================================
|
||||
# Installe ou met à jour la Launcher API sur le VPS (compte admin, avec sudo) :
|
||||
# sudo sh installer-api.sh /tmp/launcher-api.tar.gz <empreinte SHA-256>
|
||||
# L'empreinte est affichée par tools\publish-server.bat (fichier launcher-api.tar.gz.sha256) :
|
||||
# une archive modifiée pendant le transfert ou dans /tmp est refusée.
|
||||
#
|
||||
# • binaires dans /opt/launcher-api (propriétaire root : le service ne peut pas les modifier) ;
|
||||
# • service systemd « launcher-api » (compte deploy, voir launcher-api.service) ;
|
||||
# • appsettings.Production.json (réglages propres au VPS) conservé d'une version à l'autre ;
|
||||
# • en cas d'échec du démarrage, la version précédente est remise en place.
|
||||
# =============================================================================
|
||||
set -eu
|
||||
|
||||
ARCHIVE="${1:-}"
|
||||
EXPECTED_SHA256=$(printf '%s' "${2:-}" | tr 'A-F' 'a-f')
|
||||
INSTALL_DIR=/opt/launcher-api
|
||||
SERVICE=launcher-api
|
||||
SERVICE_USER=deploy
|
||||
|
||||
fail() { echo "ERREUR : $*" >&2; exit 1; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || fail "lancez ce script avec sudo."
|
||||
[ -n "$ARCHIVE" ] && [ -f "$ARCHIVE" ] || fail "utilisation : sudo sh installer-api.sh /tmp/launcher-api.tar.gz <empreinte SHA-256>"
|
||||
printf '%s' "$EXPECTED_SHA256" | grep -Eq '^[0-9a-f]{64}$' || fail "empreinte SHA-256 manquante ou invalide (voir launcher-api.tar.gz.sha256)."
|
||||
id "$SERVICE_USER" >/dev/null 2>&1 || fail "le compte $SERVICE_USER n'existe pas (guide du VPS, étape 9)."
|
||||
command -v curl >/dev/null 2>&1 || fail "curl est nécessaire (sudo apt install -y curl)."
|
||||
|
||||
TMPROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPROOT"' EXIT
|
||||
# Copie privée de l'archive (dossier 700), PUIS vérification : plus rien ne peut la modifier entre-temps.
|
||||
cp "$ARCHIVE" "$TMPROOT/archive.tar.gz"
|
||||
ACTUAL_SHA256=$(sha256sum "$TMPROOT/archive.tar.gz" | cut -d' ' -f1)
|
||||
[ "$ACTUAL_SHA256" = "$EXPECTED_SHA256" ] || fail "empreinte différente : archive altérée ou incomplète. Rien n'a été modifié."
|
||||
WORK="$TMPROOT/files"
|
||||
mkdir "$WORK"
|
||||
tar -xzf "$TMPROOT/archive.tar.gz" -C "$WORK" --no-same-owner
|
||||
[ -f "$WORK/GameLauncher.Server" ] || fail "archive invalide : GameLauncher.Server absent."
|
||||
[ -f "$WORK/deploy/launcher-api.service" ] || fail "archive invalide : deploy/launcher-api.service absent."
|
||||
|
||||
echo "== Préparation de la nouvelle version"
|
||||
rm -rf "$INSTALL_DIR.new"
|
||||
mkdir -p "$INSTALL_DIR.new"
|
||||
cp -a "$WORK/." "$INSTALL_DIR.new/"
|
||||
rm -rf "$INSTALL_DIR.new/deploy"
|
||||
if [ -f "$INSTALL_DIR/appsettings.Production.json" ]; then
|
||||
cp -a "$INSTALL_DIR/appsettings.Production.json" "$INSTALL_DIR.new/"
|
||||
fi
|
||||
chown -R root:root "$INSTALL_DIR.new"
|
||||
find "$INSTALL_DIR.new" -type d -exec chmod 755 {} +
|
||||
find "$INSTALL_DIR.new" -type f -exec chmod 644 {} +
|
||||
chmod 755 "$INSTALL_DIR.new/GameLauncher.Server"
|
||||
|
||||
echo "== Installation du service"
|
||||
UNIT=/etc/systemd/system/$SERVICE.service
|
||||
rm -f "$UNIT.old"
|
||||
[ -f "$UNIT" ] && cp -a "$UNIT" "$UNIT.old"
|
||||
systemctl stop $SERVICE 2>/dev/null || true
|
||||
install -m 644 -o root -g root "$WORK/deploy/launcher-api.service" "$UNIT"
|
||||
systemctl daemon-reload
|
||||
|
||||
rm -rf "$INSTALL_DIR.old"
|
||||
[ -d "$INSTALL_DIR" ] && mv "$INSTALL_DIR" "$INSTALL_DIR.old"
|
||||
mv "$INSTALL_DIR.new" "$INSTALL_DIR"
|
||||
systemctl enable --now $SERVICE
|
||||
|
||||
echo "== Vérification"
|
||||
OK=0
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if curl -fsS http://127.0.0.1:5080/api/v1/health >/dev/null 2>&1; then OK=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$OK" -ne 1 ]; then
|
||||
echo "La nouvelle version ne répond pas. Dernières lignes du journal :" >&2
|
||||
journalctl -u $SERVICE -n 30 --no-pager >&2 || true
|
||||
if [ -d "$INSTALL_DIR.old" ]; then
|
||||
echo "Retour à la version précédente." >&2
|
||||
systemctl stop $SERVICE || true
|
||||
rm -rf "$INSTALL_DIR.failed"
|
||||
mv "$INSTALL_DIR" "$INSTALL_DIR.failed"
|
||||
mv "$INSTALL_DIR.old" "$INSTALL_DIR"
|
||||
[ -f "$UNIT.old" ] && cp -a "$UNIT.old" "$UNIT"
|
||||
systemctl daemon-reload
|
||||
systemctl start $SERVICE || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Launcher API opérationnelle :"
|
||||
curl -fsS http://127.0.0.1:5080/api/v1/health; echo
|
||||
echo "Journal : journalctl -u $SERVICE -f"
|
||||
echo "Premier propriétaire (une seule fois) : voir LISEZMOI.md, étape 5."
|
||||
@@ -0,0 +1,72 @@
|
||||
# =============================================================================
|
||||
# Launcher API : service systemd (installé par installer-api.sh).
|
||||
# /etc/systemd/system/launcher-api.service
|
||||
#
|
||||
# Le service tourne sous le compte « deploy » (déjà propriétaire des fichiers du jeu, sans
|
||||
# terminal), enfermé par systemd : système en lecture seule, pas d'accès à /home, aucun privilège.
|
||||
# Ses binaires (/opt/launcher-api) appartiennent à root : il ne peut pas se modifier lui-même.
|
||||
# Données privées : /var/lib/launcher-api (créé par systemd, droits 700).
|
||||
#
|
||||
# Vérifier le durcissement : systemd-analyze security launcher-api
|
||||
# Journal : journalctl -u launcher-api -f
|
||||
# =============================================================================
|
||||
[Unit]
|
||||
Description=Launcher API (administration du launcher)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=deploy
|
||||
Group=deploy
|
||||
WorkingDirectory=/opt/launcher-api
|
||||
ExecStart=/opt/launcher-api/GameLauncher.Server
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=20
|
||||
|
||||
Environment=ASPNETCORE_ENVIRONMENT=Production
|
||||
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||
Environment=DOTNET_NOLOGO=1
|
||||
|
||||
# Données privées : /var/lib/launcher-api (administrateurs, audit, identifiant du serveur)
|
||||
StateDirectory=launcher-api
|
||||
StateDirectoryMode=0700
|
||||
UMask=0077
|
||||
|
||||
# --- Durcissement ------------------------------------------------------------
|
||||
NoNewPrivileges=yes
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectClock=yes
|
||||
ProtectHostname=yes
|
||||
ProtectProc=invisible
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
# Réseau : uniquement la machine elle-même (nginx). Aucune connexion sortante possible, même en cas de compromission.
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=localhost
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RemoveIPC=yes
|
||||
SystemCallArchitectures=native
|
||||
# MemoryDenyWriteExecute n'est PAS activé : le compilateur JIT de .NET a besoin de mémoire exécutable.
|
||||
# SystemCallFilter n'est pas activé : .NET utilise des appels système variés selon les versions
|
||||
# (un filtre trop strict provoquerait des arrêts difficiles à diagnostiquer).
|
||||
|
||||
# --- Ressources (petit VPS) ------------------------------------------------------
|
||||
MemoryMax=512M
|
||||
TasksMax=256
|
||||
LimitNOFILE=8192
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,34 @@
|
||||
# =============================================================================
|
||||
# Launcher API derrière nginx — DEUX ajouts (voir LISEZMOI.md, étape 3) :
|
||||
#
|
||||
# 1) Dans /etc/nginx/conf.d/securite.conf (contexte « http », à côté de limit_conn_zone) :
|
||||
#
|
||||
# limit_req_zone $binary_remote_addr zone=api_par_ip:10m rate=5r/s;
|
||||
#
|
||||
# 2) Dans le bloc HTTPS (port 443, créé par certbot) du site
|
||||
# /etc/nginx/sites-available/dl-exovival, AVANT « location / { … } » :
|
||||
# (le bloc ci-dessous)
|
||||
#
|
||||
# Puis : sudo nginx -t && sudo systemctl reload nginx
|
||||
# =============================================================================
|
||||
|
||||
# « ^~ » : aucune autre règle (manifest, objects, .zip…) ne s'applique sous /api/.
|
||||
location ^~ /api/ {
|
||||
limit_req zone=api_par_ip burst=20 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
# Requêtes d'administration : petites. (La publication de versions, en phase 5, aura sa propre règle.)
|
||||
client_max_body_size 64k;
|
||||
|
||||
proxy_pass http://127.0.0.1:5080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
# REMPLACE l'en-tête (au lieu d'ajouter à celui du client) : l'adresse IP transmise ne peut pas être falsifiée.
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
/// <summary>
|
||||
/// Clé d'administrateur dans un fichier chiffré par une phrase de passe : PKCS#8 chiffré (AES-256,
|
||||
/// PBKDF2-SHA256, 600 000 itérations), comme la clé de signature des mises à jour du launcher.
|
||||
/// Le fichier seul ne sert à rien sans la phrase de passe.
|
||||
/// </summary>
|
||||
public static class AdminKeyFile
|
||||
{
|
||||
public const int MinPassphraseLength = 12;
|
||||
public const string PrivateKeyExtension = ".key";
|
||||
public const string PublicKeyExtension = ".pub";
|
||||
|
||||
private static readonly PbeParameters Encryption = new(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 600_000);
|
||||
|
||||
public static ECDsa Generate() => ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
|
||||
/// <summary>
|
||||
/// Enregistre la clé chiffrée (refuse d'écraser un fichier existant) et sa clé publique à côté
|
||||
/// (même nom, extension .pub). Renvoie le chemin de la clé publique.
|
||||
/// </summary>
|
||||
public static string Save(ECDsa key, string path, ReadOnlySpan<char> passphrase)
|
||||
{
|
||||
if (passphrase.Length < MinPassphraseLength)
|
||||
throw new ArgumentException($"La phrase de passe doit contenir au moins {MinPassphraseLength} caractères.");
|
||||
if (File.Exists(path)) throw new IOException($"Le fichier existe déjà : {path}");
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
|
||||
var pem = key.ExportEncryptedPkcs8PrivateKeyPem(passphrase, Encryption);
|
||||
using (var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None))
|
||||
using (var writer = new StreamWriter(stream))
|
||||
writer.Write(pem);
|
||||
|
||||
// Clé publique : jamais écrasée non plus ; en cas d'échec, la clé privée qui vient d'être créée est retirée.
|
||||
var publicPath = Path.ChangeExtension(path, PublicKeyExtension);
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(publicPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
||||
using var writer = new StreamWriter(stream);
|
||||
writer.Write(AdminKeys.ToPem(key.ExportSubjectPublicKeyInfo()));
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// clé chiffrée laissée en place : sans danger
|
||||
}
|
||||
throw;
|
||||
}
|
||||
return publicPath;
|
||||
}
|
||||
|
||||
/// <summary>Déchiffre la clé. <see cref="CryptographicException"/> si la phrase de passe est fausse.</summary>
|
||||
public static ECDsa Load(string path, ReadOnlySpan<char> passphrase)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists) throw new FileNotFoundException($"Clé introuvable : {path}", path);
|
||||
if (info.Length > 16 * 1024) throw new CryptographicException("Fichier de clé inattendu (trop volumineux).");
|
||||
|
||||
var pem = File.ReadAllText(path);
|
||||
if (!pem.Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----", StringComparison.Ordinal))
|
||||
throw new CryptographicException("Ce fichier n'est pas une clé privée chiffrée.");
|
||||
|
||||
var key = ECDsa.Create();
|
||||
try
|
||||
{
|
||||
key.ImportFromEncryptedPem(pem, passphrase);
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
|
||||
{
|
||||
key.Dispose();
|
||||
throw new CryptographicException("Phrase de passe incorrecte (ou fichier de clé endommagé).", ex);
|
||||
}
|
||||
try
|
||||
{
|
||||
AdminKeys.ParsePublicKey(Convert.ToBase64String(key.ExportSubjectPublicKeyInfo())); // clé P-256 uniquement
|
||||
return key;
|
||||
}
|
||||
catch
|
||||
{
|
||||
key.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
/// <summary>
|
||||
/// Session d'administration ouverte. Le jeton reste en mémoire (jamais écrit sur le disque) ;
|
||||
/// chaque requête qui modifie quelque chose est signée par la clé (<see cref="SignedMessages.Request"/>).
|
||||
/// Les modules du launcher ajoutent leurs propres appels avec <see cref="GetAsync{T}"/> et
|
||||
/// <see cref="SendSignedAsync{T}"/>.
|
||||
/// </summary>
|
||||
public sealed class AdminSession : IAsyncDisposable
|
||||
{
|
||||
private readonly LauncherApiClient _client;
|
||||
private readonly IAdminSigner _signer;
|
||||
private readonly TimeSpan _clockOffset;
|
||||
private string? _token;
|
||||
|
||||
internal AdminSession(LauncherApiClient client, IAdminSigner signer, string serverId, LoginResponse login, TimeSpan clockOffset)
|
||||
{
|
||||
_client = client;
|
||||
_signer = signer;
|
||||
_clockOffset = clockOffset;
|
||||
_token = login.Token;
|
||||
ServerId = serverId;
|
||||
SessionId = login.SessionId;
|
||||
Profile = login.Admin;
|
||||
ExpiresAt = login.ExpiresAt;
|
||||
IdleExpiresAt = login.IdleExpiresAt;
|
||||
}
|
||||
|
||||
public string ServerId { get; }
|
||||
|
||||
/// <summary>Écart entre l'horloge du serveur et celle du PC (heure serveur = heure du PC + écart), mesuré à la connexion.</summary>
|
||||
public TimeSpan ClockOffset => _clockOffset;
|
||||
public string SessionId { get; }
|
||||
public AdminProfile Profile { get; private set; }
|
||||
public DateTimeOffset ExpiresAt { get; }
|
||||
public DateTimeOffset IdleExpiresAt { get; private set; }
|
||||
public bool IsOpen => _token is not null;
|
||||
|
||||
public bool Has(string permission) => Profile.Permissions.Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
// ------------------------------------------------------------------ Session
|
||||
|
||||
/// <summary>Relit la session (droits à jour) et la prolonge.</summary>
|
||||
public async Task<SessionInfo> RefreshAsync(CancellationToken ct = default)
|
||||
{
|
||||
var info = await GetAsync<SessionInfo>(ApiRoutes.AuthMe, ct).ConfigureAwait(false);
|
||||
Profile = info.Admin;
|
||||
IdleExpiresAt = info.IdleExpiresAt;
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>Ferme la session sur le serveur (sans erreur si elle avait déjà expiré).</summary>
|
||||
public async Task LogoutAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_token is null) return;
|
||||
try
|
||||
{
|
||||
await SendCoreAsync<NoContent>(HttpMethod.Post, ApiRoutes.AuthLogout, null, signed: false, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (ApiException)
|
||||
{
|
||||
// session déjà expirée, serveur injoignable : le jeton est oublié de toute façon
|
||||
}
|
||||
finally
|
||||
{
|
||||
_token = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await LogoutAsync().ConfigureAwait(false);
|
||||
|
||||
// ------------------------------------------------------------------ Administrateurs
|
||||
|
||||
public Task<List<AdminSummary>> ListAdminsAsync(CancellationToken ct = default) =>
|
||||
GetAsync<List<AdminSummary>>(ApiRoutes.Admins, ct);
|
||||
|
||||
public Task<List<InvitationSummary>> ListInvitationsAsync(CancellationToken ct = default) =>
|
||||
GetAsync<List<InvitationSummary>>(ApiRoutes.Invitations, ct);
|
||||
|
||||
public Task<InvitationCreated> CreateInvitationAsync(CreateInvitationRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<InvitationCreated>(HttpMethod.Post, ApiRoutes.Invitations, request, ct);
|
||||
|
||||
public Task DeleteInvitationAsync(Guid invitationId, CancellationToken ct = default) =>
|
||||
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.Invitation(invitationId), (object?)null, ct);
|
||||
|
||||
public Task<AdminSummary> ApproveKeyAsync(Guid adminId, string keyId, string fingerprint, CancellationToken ct = default) =>
|
||||
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.KeyApprove(adminId, keyId), new ApproveKeyRequest { Fingerprint = fingerprint }, ct);
|
||||
|
||||
public Task<AdminSummary> RevokeKeyAsync(Guid adminId, string keyId, CancellationToken ct = default) =>
|
||||
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.KeyRevoke(adminId, keyId), (object?)null, ct);
|
||||
|
||||
public Task<AdminSummary> ChangeRoleAsync(Guid adminId, string role, CancellationToken ct = default) =>
|
||||
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.AdminRole(adminId), new ChangeRoleRequest { Role = role }, ct);
|
||||
|
||||
public Task<AdminSummary> RevokeAdminAsync(Guid adminId, CancellationToken ct = default) =>
|
||||
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.AdminRevoke(adminId), (object?)null, ct);
|
||||
|
||||
public Task<AuditPage> GetAuditAsync(int limit = 50, string? cursor = null, CancellationToken ct = default)
|
||||
{
|
||||
var route = $"{ApiRoutes.Audit}?limit={limit}";
|
||||
if (!string.IsNullOrEmpty(cursor)) route += "&cursor=" + Uri.EscapeDataString(cursor);
|
||||
return GetAsync<AuditPage>(route, ct);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Requêtes génériques (modules)
|
||||
|
||||
public Task<T> GetAsync<T>(string route, CancellationToken ct = default) =>
|
||||
SendCoreAsync<T>(HttpMethod.Get, route, null, signed: false, ct);
|
||||
|
||||
/// <summary>Requête qui modifie : corps JSON (ou aucun corps si null), signée par la clé.</summary>
|
||||
public Task<T> SendSignedAsync<T>(HttpMethod method, string route, object? body, CancellationToken ct = default) =>
|
||||
SendCoreAsync<T>(method, route, body is null ? null : LauncherApiClient.Serialize(body), signed: true, ct);
|
||||
|
||||
private async Task<T> SendCoreAsync<T>(HttpMethod method, string route, byte[]? body, bool signed, CancellationToken ct)
|
||||
{
|
||||
var token = _token ?? throw new ApiException(401, ApiErrorCodes.Unauthorized, "Session fermée : reconnectez-vous.");
|
||||
|
||||
(long Timestamp, string Nonce, byte[] Signature)? signature = null;
|
||||
if (signed)
|
||||
{
|
||||
var timestamp = (DateTimeOffset.UtcNow + _clockOffset).ToUnixTimeSeconds();
|
||||
var nonce = Base64Url.Encode(RandomNumberGenerator.GetBytes(16));
|
||||
var path = _client.Resolve(route).PathAndQuery;
|
||||
var message = SignedMessages.Request(_client.Origin, ServerId, SessionId, method.Method, path,
|
||||
body ?? Array.Empty<byte>(), timestamp, nonce);
|
||||
signature = (timestamp, nonce, await _signer.SignAsync(message, ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _client.SendAsync<T>(method, route, body, request =>
|
||||
{
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
if (signature is { } s)
|
||||
{
|
||||
request.Headers.Add(SignedRequestHeaders.Timestamp, s.Timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
request.Headers.Add(SignedRequestHeaders.Nonce, s.Nonce);
|
||||
request.Headers.Add(SignedRequestHeaders.Signature, Convert.ToBase64String(s.Signature));
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
|
||||
{
|
||||
_token = null; // session expirée ou révoquée : il faudra se reconnecter
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
/// <summary>Erreur renvoyée par la Launcher API (ou problème de communication).</summary>
|
||||
public sealed class ApiException : Exception
|
||||
{
|
||||
public ApiException(int status, string code, string message, Exception? inner = null) : base(message, inner)
|
||||
{
|
||||
Status = status;
|
||||
Code = code;
|
||||
}
|
||||
|
||||
/// <summary>Code HTTP (0 : serveur injoignable).</summary>
|
||||
public int Status { get; }
|
||||
|
||||
/// <summary>Code stable (<see cref="ApiErrorCodes"/>).</summary>
|
||||
public string Code { get; }
|
||||
|
||||
public bool IsUnauthorized => Status == 401;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Client de la Launcher API (sans WPF) : connexion par défi signé, requêtes signées, appels typés,
|
||||
fichier de clé d'administrateur chiffré. Utilisé par le launcher (module Administration),
|
||||
par tools\AdminKeyTool et par les tests. Voir docs\ADMIN-ARCHITECTURE.md.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.ApiClient</RootNamespace>
|
||||
<AssemblyName>GameLauncher.ApiClient</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
/// <summary>
|
||||
/// Clé privée d'un administrateur, vue par le client : elle signe, elle ne sort jamais.
|
||||
/// Implémentations : <see cref="EcdsaAdminSigner"/> (clé en mémoire, déverrouillée depuis un fichier
|
||||
/// chiffré) ; clé de la puce TPM dans le launcher (Platform\TpmKeyStore, phase 2b).
|
||||
/// </summary>
|
||||
public interface IAdminSigner
|
||||
{
|
||||
/// <summary>Clé publique (SPKI DER).</summary>
|
||||
byte[] PublicKey { get; }
|
||||
|
||||
string KeyId { get; }
|
||||
|
||||
/// <summary>Signature ECDSA P-256/SHA-256 (IEEE P1363, 64 octets). Peut demander une confirmation à l'utilisateur.</summary>
|
||||
ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Clé ECDSA P-256 en mémoire.</summary>
|
||||
public sealed class EcdsaAdminSigner : IAdminSigner, IDisposable
|
||||
{
|
||||
private readonly ECDsa _key;
|
||||
|
||||
/// <param name="key">Clé privée P-256 ; elle appartient désormais à ce signataire (libérée par <see cref="Dispose"/>).</param>
|
||||
public EcdsaAdminSigner(ECDsa key)
|
||||
{
|
||||
_key = key;
|
||||
PublicKey = AdminKeys.ParsePublicKey(Convert.ToBase64String(key.ExportSubjectPublicKeyInfo())); // vérifie P-256
|
||||
KeyId = AdminKeys.ComputeKeyId(PublicKey);
|
||||
}
|
||||
|
||||
public byte[] PublicKey { get; }
|
||||
public string KeyId { get; }
|
||||
public string Fingerprint => AdminKeys.ComputeFingerprint(PublicKey);
|
||||
|
||||
public ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default) =>
|
||||
ValueTask.FromResult(AdminKeys.Sign(_key, message));
|
||||
|
||||
public void Dispose() => _key.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
/// <summary>
|
||||
/// Client de la Launcher API. L'adresse vient de la configuration INTÉGRÉE à l'exécutable (jamais
|
||||
/// d'un fichier modifiable) ; HTTPS obligatoire (HTTP accepté uniquement en local, pour le
|
||||
/// développement). Les redirections ne sont jamais suivies (le jeton ne part que vers cette adresse).
|
||||
/// </summary>
|
||||
public sealed class LauncherApiClient : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly bool _ownsHttp;
|
||||
|
||||
/// <param name="apiBaseUrl">Adresse de base, ex. « https://launcher.lanisys.fr/api/ ».</param>
|
||||
/// <param name="handler">Pour les tests ; par défaut un gestionnaire sans redirection automatique.</param>
|
||||
public LauncherApiClient(Uri apiBaseUrl, string userAgent, HttpMessageHandler? handler = null)
|
||||
{
|
||||
if (!apiBaseUrl.IsAbsoluteUri ||
|
||||
!(apiBaseUrl.Scheme == Uri.UriSchemeHttps || (apiBaseUrl.Scheme == Uri.UriSchemeHttp && apiBaseUrl.IsLoopback)))
|
||||
throw new ArgumentException("L'adresse de l'API doit être en https://.", nameof(apiBaseUrl));
|
||||
if (!string.IsNullOrEmpty(apiBaseUrl.Query) || !string.IsNullOrEmpty(apiBaseUrl.Fragment))
|
||||
throw new ArgumentException("L'adresse de l'API ne doit contenir ni paramètres ni fragment.", nameof(apiBaseUrl));
|
||||
|
||||
BaseUrl = apiBaseUrl.AbsoluteUri.EndsWith('/') ? apiBaseUrl : new Uri(apiBaseUrl.AbsoluteUri + "/");
|
||||
Origin = SignedMessages.NormalizeOrigin(BaseUrl);
|
||||
|
||||
_ownsHttp = true;
|
||||
_http = new HttpClient(handler ?? new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(15),
|
||||
AutomaticDecompression = DecompressionMethods.None,
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
|
||||
}, disposeHandler: handler is null)
|
||||
{
|
||||
Timeout = RequestTimeout
|
||||
};
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
}
|
||||
|
||||
public Uri BaseUrl { get; }
|
||||
|
||||
/// <summary>Origine (« https://hote ») incluse dans les messages signés : celle de l'adresse configurée.</summary>
|
||||
public string Origin { get; }
|
||||
|
||||
public Task<ServerInfo> GetInfoAsync(CancellationToken ct = default) =>
|
||||
SendAsync<ServerInfo>(HttpMethod.Get, ApiRoutes.Info, null, null, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Connexion : défi, signature par la clé, ouverture de la session. L'heure du serveur est
|
||||
/// relevée au passage (les requêtes signées restent valides même si l'heure du PC est décalée).
|
||||
/// </summary>
|
||||
public async Task<AdminSession> LoginAsync(IAdminSigner signer, string? clientName = null, CancellationToken ct = default)
|
||||
{
|
||||
var challenge = await SendAsync<ChallengeResponse>(HttpMethod.Post, ApiRoutes.AuthChallenge,
|
||||
Serialize(new ChallengeRequest { KeyId = signer.KeyId }), null, ct).ConfigureAwait(false);
|
||||
var clockOffset = challenge.ServerTime == default ? TimeSpan.Zero : challenge.ServerTime - DateTimeOffset.UtcNow;
|
||||
if (clockOffset.Duration() > MaxClockOffset)
|
||||
throw new ApiException(0, "clock_skew",
|
||||
$"L'horloge de votre PC diffère de plus d'une heure de celle du serveur ({clockOffset.TotalHours:+0.0;-0.0} h) : corrigez la date et l'heure de Windows.");
|
||||
EnsureSameOrigin(challenge.Origin);
|
||||
|
||||
var message = SignedMessages.Login(Origin, challenge.ServerId, challenge.ChallengeId, challenge.Nonce, signer.KeyId);
|
||||
var signature = await signer.SignAsync(message, ct).ConfigureAwait(false);
|
||||
var login = await SendAsync<LoginResponse>(HttpMethod.Post, ApiRoutes.AuthLogin, Serialize(new LoginRequest
|
||||
{
|
||||
ChallengeId = challenge.ChallengeId,
|
||||
KeyId = signer.KeyId,
|
||||
Signature = Convert.ToBase64String(signature),
|
||||
ClientName = clientName
|
||||
}), null, ct).ConfigureAwait(false);
|
||||
|
||||
return new AdminSession(this, signer, challenge.ServerId, login, clockOffset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enrôle la clé avec un code d'invitation. Renvoie l'empreinte à communiquer au propriétaire,
|
||||
/// qui doit la saisir pour approuver la clé.
|
||||
/// </summary>
|
||||
public async Task<EnrollmentResponse> EnrollAsync(string invitationCode, IAdminSigner signer, string? keyLabel,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var code = InvitationCodes.Normalize(invitationCode)
|
||||
?? throw new ApiException(400, ApiErrorCodes.InvitationInvalid, "Code d'invitation mal saisi.");
|
||||
var info = await GetInfoAsync(ct).ConfigureAwait(false);
|
||||
EnsureSameOrigin(info.Origin);
|
||||
|
||||
var proof = await signer.SignAsync(
|
||||
SignedMessages.Enrollment(Origin, info.ServerId, SignedMessages.HashInvitationCode(code), signer.KeyId), ct).ConfigureAwait(false);
|
||||
return await SendAsync<EnrollmentResponse>(HttpMethod.Post, ApiRoutes.Enrollment, Serialize(new EnrollmentRequest
|
||||
{
|
||||
InvitationCode = code,
|
||||
PublicKey = Convert.ToBase64String(signer.PublicKey),
|
||||
KeyLabel = keyLabel,
|
||||
Proof = Convert.ToBase64String(proof)
|
||||
}), null, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Transport
|
||||
|
||||
internal static byte[] Serialize<T>(T value) => JsonSerializer.SerializeToUtf8Bytes(value, ApiJson.Options);
|
||||
|
||||
/// <summary>
|
||||
/// Adresse d'une route, OBLIGATOIREMENT sous l'adresse de base : une route absolue ou
|
||||
/// « //autre-hote/… » (construite par erreur à partir d'une donnée reçue) ne doit jamais
|
||||
/// emporter le jeton ailleurs.
|
||||
/// </summary>
|
||||
internal Uri Resolve(string route)
|
||||
{
|
||||
if (string.IsNullOrEmpty(route) || route.StartsWith('/') || route.StartsWith('\\') || route.Contains("://", StringComparison.Ordinal))
|
||||
throw new ArgumentException($"Route invalide : « {route} ».", nameof(route));
|
||||
var uri = new Uri(BaseUrl, route);
|
||||
if (uri.Scheme != BaseUrl.Scheme || uri.Host != BaseUrl.Host || uri.Port != BaseUrl.Port ||
|
||||
!uri.AbsolutePath.StartsWith(BaseUrl.AbsolutePath, StringComparison.Ordinal))
|
||||
throw new ArgumentException($"Route hors de l'API : « {route} ».", nameof(route));
|
||||
return uri;
|
||||
}
|
||||
|
||||
/// <summary>Écart d'horloge maximal accepté avec le serveur (au-delà : l'heure du PC est à corriger).</summary>
|
||||
public static readonly TimeSpan MaxClockOffset = TimeSpan.FromHours(1);
|
||||
|
||||
internal async Task<T> SendAsync<T>(HttpMethod method, string route, byte[]? body,
|
||||
Action<HttpRequestMessage>? prepare, CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, Resolve(route));
|
||||
if (body is not null)
|
||||
{
|
||||
request.Content = new ByteArrayContent(body);
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
|
||||
}
|
||||
prepare?.Invoke(request);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new ApiException(0, "unreachable", $"Serveur d'administration injoignable ({ex.Message}).", ex);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new ApiException(0, "timeout", "Le serveur d'administration ne répond pas.", ex);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
var bytes = await ReadBoundedAsync(response, ct).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode) throw ToException(response, bytes);
|
||||
if (typeof(T) == typeof(NoContent)) return (T)(object)NoContent.Value;
|
||||
try
|
||||
{
|
||||
var value = JsonSerializer.Deserialize<T>(bytes, ApiJson.Options)
|
||||
?? throw new JsonException("réponse vide");
|
||||
return value;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur illisible.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadBoundedAsync(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
if (response.Content.Headers.ContentLength > ApiJson.MaxResponseBytes)
|
||||
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur trop volumineuse.");
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[16 * 1024];
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(chunk, ct).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
if (buffer.Length + read > ApiJson.MaxResponseBytes)
|
||||
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur trop volumineuse.");
|
||||
buffer.Write(chunk, 0, read);
|
||||
}
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private static ApiException ToException(HttpResponseMessage response, byte[] body)
|
||||
{
|
||||
var status = (int)response.StatusCode;
|
||||
try
|
||||
{
|
||||
if (body.Length > 0 && JsonSerializer.Deserialize<ApiError>(body, ApiJson.Options) is { Message.Length: > 0 } error)
|
||||
return new ApiException(status, error.Code, error.Message);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// réponse d'erreur non JSON (nginx, proxy…)
|
||||
}
|
||||
return status switch
|
||||
{
|
||||
401 => new ApiException(status, ApiErrorCodes.Unauthorized, "Session expirée : reconnectez-vous."),
|
||||
403 => new ApiException(status, ApiErrorCodes.Forbidden, "Action non autorisée."),
|
||||
404 => new ApiException(status, ApiErrorCodes.NotFound, "Adresse inconnue sur le serveur (version de l'API ?)."),
|
||||
413 => new ApiException(status, ApiErrorCodes.InvalidRequest, "Requête trop volumineuse."),
|
||||
429 => new ApiException(status, ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute."),
|
||||
>= 500 => new ApiException(status, ApiErrorCodes.Error, $"Erreur du serveur ({status})."),
|
||||
_ => new ApiException(status, ApiErrorCodes.Error, $"Réponse inattendue du serveur ({status}).")
|
||||
};
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (!string.Equals(serverOrigin, Origin, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ApiException(0, "origin_mismatch",
|
||||
$"Le serveur se présente comme « {serverOrigin} » au lieu de « {Origin} » : configuration à vérifier (PublicOrigin).");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttp) _http.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Réponse sans contenu (204).</summary>
|
||||
public sealed class NoContent
|
||||
{
|
||||
public static readonly NoContent Value = new();
|
||||
private NoContent() { }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace GameLauncher.Contracts;
|
||||
|
||||
/// <summary>Options JSON communes au launcher et au serveur (strictes : ni commentaires, ni virgules finales).</summary>
|
||||
public static class ApiJson
|
||||
{
|
||||
public static readonly JsonSerializerOptions Options = Create();
|
||||
|
||||
/// <summary>Taille maximale d'une réponse JSON lue par le client (hors transferts de fichiers).</summary>
|
||||
public const int MaxResponseBytes = 1024 * 1024;
|
||||
|
||||
public static JsonSerializerOptions Create() => new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
ReadCommentHandling = JsonCommentHandling.Disallow,
|
||||
AllowTrailingCommas = false,
|
||||
MaxDepth = 32,
|
||||
NumberHandling = JsonNumberHandling.Strict
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace GameLauncher.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Routes de la Launcher API, relatives à l'adresse de base de l'API
|
||||
/// (ex. « https://launcher.lanisys.fr/api/ »). Le serveur les publie sous « /api/ ».
|
||||
/// </summary>
|
||||
public static class ApiRoutes
|
||||
{
|
||||
/// <summary>Préfixe des routes côté serveur (nginx transmet « /api/… » sans le modifier).</summary>
|
||||
public const string Prefix = "/api";
|
||||
|
||||
public const string Version = "v1";
|
||||
|
||||
// ------------------------------------------------------------------ Système
|
||||
public const string Info = "v1/info";
|
||||
public const string Health = "v1/health";
|
||||
|
||||
// ------------------------------------------------------------------ Authentification
|
||||
public const string AuthChallenge = "v1/auth/challenge";
|
||||
public const string AuthLogin = "v1/auth/login";
|
||||
public const string AuthLogout = "v1/auth/logout";
|
||||
public const string AuthMe = "v1/auth/me";
|
||||
|
||||
/// <summary>Enrôlement d'une clé avec un code d'invitation (sans session).</summary>
|
||||
public const string Enrollment = "v1/enrollment";
|
||||
|
||||
// ------------------------------------------------------------------ Administrateurs
|
||||
public const string Admins = "v1/admins";
|
||||
public const string Invitations = "v1/admins/invitations";
|
||||
|
||||
public static string Invitation(Guid invitationId) => $"v1/admins/invitations/{invitationId:D}";
|
||||
public static string AdminRole(Guid adminId) => $"v1/admins/{adminId:D}/role";
|
||||
public static string AdminRevoke(Guid adminId) => $"v1/admins/{adminId:D}/revoke";
|
||||
public static string KeyApprove(Guid adminId, string keyId) => $"v1/admins/{adminId:D}/keys/{keyId}/approve";
|
||||
public static string KeyRevoke(Guid adminId, string keyId) => $"v1/admins/{adminId:D}/keys/{keyId}/revoke";
|
||||
|
||||
// ------------------------------------------------------------------ Audit
|
||||
public const string Audit = "v1/audit";
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Contracts;
|
||||
|
||||
/// <summary>Contrôle des textes courts saisis dans le menu d'administration (noms, libellés).</summary>
|
||||
public static class ApiText
|
||||
{
|
||||
public const int MaxDisplayNameLength = 64;
|
||||
public const int MaxKeyLabelLength = 64;
|
||||
public const int MaxClientNameLength = 96;
|
||||
|
||||
/// <summary>
|
||||
/// Texte d'une ligne : espaces normalisés, sans caractère de contrôle ni de mise en forme
|
||||
/// bidirectionnelle. Null si vide ou trop long (après nettoyage).
|
||||
/// </summary>
|
||||
public static string? CleanLine(string? value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var sb = new StringBuilder(value.Length);
|
||||
var previousSpace = false;
|
||||
foreach (var c in value.Trim())
|
||||
{
|
||||
if (char.IsWhiteSpace(c))
|
||||
{
|
||||
if (!previousSpace) sb.Append(' ');
|
||||
previousSpace = true;
|
||||
continue;
|
||||
}
|
||||
if (char.IsControl(c) || IsFormatting(c)) return null;
|
||||
sb.Append(c);
|
||||
previousSpace = false;
|
||||
}
|
||||
var text = sb.ToString();
|
||||
return text.Length == 0 || text.Length > maxLength ? null : text;
|
||||
}
|
||||
|
||||
/// <summary>Caractères invisibles de mise en forme (bidirectionnels, largeur nulle…).</summary>
|
||||
private static bool IsFormatting(char c) =>
|
||||
char.GetUnicodeCategory(c) == System.Globalization.UnicodeCategory.Format;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace GameLauncher.Contracts;
|
||||
|
||||
/// <summary>Base64 « URL » sans remplissage (nonces, jetons).</summary>
|
||||
public static class Base64Url
|
||||
{
|
||||
public static string Encode(ReadOnlySpan<byte> data) =>
|
||||
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
public static bool TryDecode(string? text, out byte[] data)
|
||||
{
|
||||
data = Array.Empty<byte>();
|
||||
if (string.IsNullOrEmpty(text) || text.Length > 4096) return false;
|
||||
foreach (var c in text)
|
||||
if (!(c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '-' or '_')) return false;
|
||||
|
||||
var s = text.Replace('-', '+').Replace('_', '/');
|
||||
switch (s.Length % 4)
|
||||
{
|
||||
case 2: s += "=="; break;
|
||||
case 3: s += "="; break;
|
||||
case 1: return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
data = Convert.FromBase64String(s);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace GameLauncher.Contracts.Dtos;
|
||||
|
||||
public sealed record AdminKeySummary
|
||||
{
|
||||
public string KeyId { get; init; } = "";
|
||||
public string? Label { get; init; }
|
||||
public string Status { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Empreinte des clés actives ou révoquées. Volontairement absente pour une clé EN ATTENTE :
|
||||
/// l'approbateur doit saisir celle que lui communique le nouvel administrateur.
|
||||
/// </summary>
|
||||
public string? Fingerprint { get; init; }
|
||||
|
||||
public DateTimeOffset AddedAt { get; init; }
|
||||
public DateTimeOffset? LastUsedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdminSummary
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
public string Role { get; init; } = "";
|
||||
public string Status { get; init; } = "";
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public IReadOnlyList<AdminKeySummary> Keys { get; init; } = Array.Empty<AdminKeySummary>();
|
||||
}
|
||||
|
||||
public sealed record CreateInvitationRequest
|
||||
{
|
||||
/// <summary>Nom du nouvel administrateur (ignoré pour un nouvel appareil).</summary>
|
||||
public string? DisplayName { get; init; }
|
||||
|
||||
/// <summary>Rôle du nouvel administrateur (ignoré pour un nouvel appareil).</summary>
|
||||
public string? Role { get; init; }
|
||||
|
||||
/// <summary>Renseigné : invitation « nouvel appareil » pour cet administrateur existant.</summary>
|
||||
public Guid? ForAdminId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record InvitationSummary
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
public string Role { get; init; } = "";
|
||||
public Guid? ForAdminId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record InvitationCreated
|
||||
{
|
||||
public InvitationSummary Invitation { get; init; } = new();
|
||||
|
||||
/// <summary>Code à transmettre au futur administrateur. Affiché UNE SEULE FOIS (le serveur n'en garde que l'empreinte).</summary>
|
||||
public string Code { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record ApproveKeyRequest
|
||||
{
|
||||
/// <summary>Empreinte communiquée par le nouvel administrateur (« AB12 CD34 EF56 7890 1A2B »).</summary>
|
||||
public string Fingerprint { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record ChangeRoleRequest
|
||||
{
|
||||
public string Role { get; init; } = "";
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace GameLauncher.Contracts.Dtos;
|
||||
|
||||
public sealed record AuditEntry
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>Action, ex. « auth.login », « admins.invite ».</summary>
|
||||
public string Action { get; init; } = "";
|
||||
|
||||
/// <summary>« ok », « denied » ou « failed ».</summary>
|
||||
public string Result { get; init; } = "";
|
||||
|
||||
public Guid? ActorId { get; init; }
|
||||
public string? ActorName { get; init; }
|
||||
public string? KeyId { get; init; }
|
||||
public string? Ip { get; init; }
|
||||
public string? Target { get; init; }
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AuditPage
|
||||
{
|
||||
public IReadOnlyList<AuditEntry> Entries { get; init; } = Array.Empty<AuditEntry>();
|
||||
|
||||
/// <summary>Curseur à passer dans « cursor » pour la page suivante (entrées plus anciennes), ou null.</summary>
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
public static class AuditResults
|
||||
{
|
||||
public const string Ok = "ok";
|
||||
public const string Denied = "denied";
|
||||
public const string Failed = "failed";
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace GameLauncher.Contracts.Dtos;
|
||||
|
||||
public sealed record ChallengeRequest
|
||||
{
|
||||
public string KeyId { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record ChallengeResponse
|
||||
{
|
||||
public string ChallengeId { get; init; } = "";
|
||||
public string Nonce { get; init; } = "";
|
||||
public string ServerId { get; init; } = "";
|
||||
public string Origin { get; init; } = "";
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>Heure du serveur : le launcher s'y cale pour horodater ses requêtes signées (PC mal réglé).</summary>
|
||||
public DateTimeOffset ServerTime { get; init; }
|
||||
}
|
||||
|
||||
public sealed record LoginRequest
|
||||
{
|
||||
public string ChallengeId { get; init; } = "";
|
||||
public string KeyId { get; init; } = "";
|
||||
|
||||
/// <summary>Signature (Base64) de <see cref="Security.SignedMessages.Login"/>.</summary>
|
||||
public string Signature { get; init; } = "";
|
||||
|
||||
/// <summary>Description du poste, pour le journal (ex. « GameLauncher 1.4.0 »). Facultatif.</summary>
|
||||
public string? ClientName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Administrateur connecté et ses droits.</summary>
|
||||
public sealed record AdminProfile
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
public string Role { get; init; } = "";
|
||||
public IReadOnlyList<string> Permissions { get; init; } = Array.Empty<string>();
|
||||
public string KeyId { get; init; } = "";
|
||||
public string? KeyLabel { get; init; }
|
||||
}
|
||||
|
||||
public sealed record LoginResponse
|
||||
{
|
||||
/// <summary>Jeton de session (en-tête « Authorization: Bearer … »). À garder en mémoire uniquement.</summary>
|
||||
public string Token { get; init; } = "";
|
||||
|
||||
/// <summary>Identifiant public de la session, inclus dans les requêtes signées.</summary>
|
||||
public string SessionId { get; init; } = "";
|
||||
|
||||
/// <summary>Fin de session au plus tard (durée maximale).</summary>
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>Fin de session sans activité.</summary>
|
||||
public DateTimeOffset IdleExpiresAt { get; init; }
|
||||
|
||||
public AdminProfile Admin { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Session en cours (GET v1/auth/me) : prolonge la session (activité).</summary>
|
||||
public sealed record SessionInfo
|
||||
{
|
||||
public string SessionId { get; init; } = "";
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
public DateTimeOffset IdleExpiresAt { get; init; }
|
||||
public AdminProfile Admin { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Enrôlement d'une clé avec un code d'invitation.</summary>
|
||||
public sealed record EnrollmentRequest
|
||||
{
|
||||
public string InvitationCode { get; init; } = "";
|
||||
|
||||
/// <summary>Clé publique (SPKI en Base64).</summary>
|
||||
public string PublicKey { get; init; } = "";
|
||||
|
||||
/// <summary>Nom du poste (ex. « PC bureau »).</summary>
|
||||
public string? KeyLabel { get; init; }
|
||||
|
||||
/// <summary>Signature (Base64) de <see cref="Security.SignedMessages.Enrollment"/> : preuve de possession.</summary>
|
||||
public string Proof { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record EnrollmentResponse
|
||||
{
|
||||
public Guid AdminId { get; init; }
|
||||
public string DisplayName { get; init; } = "";
|
||||
public string KeyId { get; init; } = "";
|
||||
|
||||
/// <summary>Empreinte à communiquer au propriétaire, qui la saisit pour approuver.</summary>
|
||||
public string Fingerprint { get; init; } = "";
|
||||
|
||||
public string Status { get; init; } = "";
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace GameLauncher.Contracts.Dtos;
|
||||
|
||||
/// <summary>Informations publiques du serveur (GET v1/info).</summary>
|
||||
public sealed record ServerInfo
|
||||
{
|
||||
/// <summary>Identifiant aléatoire propre à ce serveur, inclus dans les messages signés.</summary>
|
||||
public string ServerId { get; init; } = "";
|
||||
|
||||
/// <summary>Adresse publique de l'API (« https://hote »), incluse dans les messages signés.</summary>
|
||||
public string Origin { get; init; } = "";
|
||||
|
||||
public string ApiVersion { get; init; } = ApiRoutes.Version;
|
||||
|
||||
public string ServerVersion { get; init; } = "";
|
||||
|
||||
/// <summary>Algorithmes de clé acceptés (« ECDSA-P256-SHA256 »).</summary>
|
||||
public IReadOnlyList<string> KeyAlgorithms { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed record HealthStatus
|
||||
{
|
||||
public string Status { get; init; } = "ok";
|
||||
}
|
||||
|
||||
/// <summary>Erreur renvoyée par l'API (corps des réponses 4xx/5xx).</summary>
|
||||
public sealed record ApiError
|
||||
{
|
||||
public int Status { get; init; }
|
||||
|
||||
/// <summary>Code stable, voir <see cref="ApiErrorCodes"/>.</summary>
|
||||
public string Code { get; init; } = ApiErrorCodes.Error;
|
||||
|
||||
/// <summary>Message destiné à l'administrateur (français).</summary>
|
||||
public string Message { get; init; } = "";
|
||||
}
|
||||
|
||||
public static class ApiErrorCodes
|
||||
{
|
||||
public const string Error = "error";
|
||||
public const string InvalidRequest = "invalid_request";
|
||||
public const string Unauthorized = "unauthorized";
|
||||
public const string Forbidden = "forbidden";
|
||||
public const string NotFound = "not_found";
|
||||
public const string Conflict = "conflict";
|
||||
public const string RateLimited = "rate_limited";
|
||||
public const string SignatureRequired = "signature_required";
|
||||
public const string SignatureInvalid = "signature_invalid";
|
||||
public const string ClockSkew = "clock_skew";
|
||||
public const string Replay = "replay";
|
||||
public const string InvitationInvalid = "invitation_invalid";
|
||||
public const string FingerprintMismatch = "fingerprint_mismatch";
|
||||
public const string LastOwner = "last_owner";
|
||||
public const string SelfAction = "self_action";
|
||||
public const string Busy = "busy";
|
||||
public const string HttpsRequired = "https_required";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Protocole partagé entre le launcher et la Launcher API (serveur) : routes, objets échangés,
|
||||
rôles et permissions, messages signés et contrôle des clés publiques.
|
||||
Aucune dépendance (ni WPF, ni ASP.NET Core) : référencé par le launcher, le serveur et les outils.
|
||||
Voir docs\ADMIN-ARCHITECTURE.md.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.Contracts</RootNamespace>
|
||||
<AssemblyName>GameLauncher.Contracts</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Clés des administrateurs : ECDSA P-256, signatures SHA-256 au format IEEE P1363 (64 octets).
|
||||
/// Même famille que la signature des mises à jour du launcher ; tout est fourni par .NET.
|
||||
/// </summary>
|
||||
public static class AdminKeys
|
||||
{
|
||||
public const string Algorithm = "ECDSA-P256-SHA256";
|
||||
public const int SignatureLength = 64;
|
||||
|
||||
/// <summary>Taille maximale d'une clé publique SPKI encodée (une clé P-256 fait 91 octets).</summary>
|
||||
private const int MaxSpkiLength = 512;
|
||||
|
||||
private const DSASignatureFormat Format = DSASignatureFormat.IeeeP1363FixedFieldConcatenation;
|
||||
private const string P256Oid = "1.2.840.10045.3.1.7";
|
||||
|
||||
/// <summary>Identifiant d'une clé : 16 premiers caractères hexadécimaux (minuscules) du SHA-256 de la clé publique.</summary>
|
||||
public static string ComputeKeyId(ReadOnlySpan<byte> subjectPublicKeyInfo) =>
|
||||
Convert.ToHexString(SHA256.HashData(subjectPublicKeyInfo))[..16].ToLowerInvariant();
|
||||
|
||||
/// <summary>Nombre de caractères hexadécimaux de l'empreinte (80 bits).</summary>
|
||||
public const int FingerprintLength = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Empreinte à comparer entre humains (téléphone, message) : « AB12 CD34 EF56 7890 1A2B » (80 bits).
|
||||
/// Calculée avec une séparation de domaine, donc SANS RAPPORT avec <see cref="ComputeKeyId"/> :
|
||||
/// connaître l'identifiant d'une clé (visible dans les listes et les adresses) ne donne pas son
|
||||
/// empreinte. Seule la personne qui détient la clé peut la communiquer.
|
||||
/// </summary>
|
||||
public static string ComputeFingerprint(ReadOnlySpan<byte> subjectPublicKeyInfo)
|
||||
{
|
||||
var prefix = System.Text.Encoding.ASCII.GetBytes("GameLauncher/Admin/Fingerprint/v1\n");
|
||||
var data = new byte[prefix.Length + subjectPublicKeyInfo.Length];
|
||||
prefix.CopyTo(data, 0);
|
||||
subjectPublicKeyInfo.CopyTo(data.AsSpan(prefix.Length));
|
||||
var hex = Convert.ToHexString(SHA256.HashData(data))[..FingerprintLength];
|
||||
return string.Join(' ', Enumerable.Range(0, FingerprintLength / 4).Select(i => hex.Substring(i * 4, 4)));
|
||||
}
|
||||
|
||||
/// <summary>Empreinte saisie par un humain → 20 caractères hexadécimaux majuscules, ou null.</summary>
|
||||
public static string? NormalizeFingerprint(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text) || text.Length > 64) return null;
|
||||
var hex = new string(text.Where(c => !char.IsWhiteSpace(c) && c != '-' && c != ':').ToArray()).ToUpperInvariant();
|
||||
return hex.Length == FingerprintLength && hex.All(Uri.IsHexDigit) ? hex : null;
|
||||
}
|
||||
|
||||
/// <summary>Compare une empreinte saisie avec celle d'une clé (en temps constant).</summary>
|
||||
public static bool FingerprintMatches(ReadOnlySpan<byte> subjectPublicKeyInfo, string? typed)
|
||||
{
|
||||
var normalized = NormalizeFingerprint(typed);
|
||||
if (normalized is null) return false;
|
||||
var expected = ComputeFingerprint(subjectPublicKeyInfo).Replace(" ", "");
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
System.Text.Encoding.ASCII.GetBytes(expected), System.Text.Encoding.ASCII.GetBytes(normalized));
|
||||
}
|
||||
|
||||
public static bool IsValidKeyId(string? keyId) =>
|
||||
keyId is { Length: 16 } && keyId.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f');
|
||||
|
||||
/// <summary>
|
||||
/// Lit une clé publique (SPKI en Base64, ou PEM « BEGIN PUBLIC KEY ») et vérifie qu'il s'agit
|
||||
/// d'une clé ECDSA P-256 valide. Renvoie le SPKI ré-encodé (forme canonique).
|
||||
/// </summary>
|
||||
/// <exception cref="CryptographicException">Clé illisible ou d'un autre type.</exception>
|
||||
public static byte[] ParsePublicKey(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text) || text.Length > 4096)
|
||||
throw new CryptographicException("Clé publique absente ou trop longue.");
|
||||
|
||||
using var ec = ECDsa.Create();
|
||||
try
|
||||
{
|
||||
if (text.Contains("-----BEGIN", StringComparison.Ordinal))
|
||||
{
|
||||
if (!text.Contains("-----BEGIN PUBLIC KEY-----", StringComparison.Ordinal))
|
||||
throw new CryptographicException("Une clé PUBLIQUE est attendue (jamais la clé privée).");
|
||||
ec.ImportFromPem(text);
|
||||
}
|
||||
else
|
||||
{
|
||||
var der = Convert.FromBase64String(text.Trim());
|
||||
if (der.Length > MaxSpkiLength) throw new CryptographicException("Clé publique trop longue.");
|
||||
ec.ImportSubjectPublicKeyInfo(der, out var read);
|
||||
if (read != der.Length) throw new CryptographicException("Clé publique mal formée.");
|
||||
}
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new CryptographicException("Clé publique illisible.", ex);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
throw new CryptographicException("Clé publique illisible.", ex);
|
||||
}
|
||||
|
||||
EnsureP256(ec);
|
||||
return ec.ExportSubjectPublicKeyInfo();
|
||||
}
|
||||
|
||||
/// <summary>Clé publique au format PEM (export, outil en ligne de commande).</summary>
|
||||
public static string ToPem(ReadOnlySpan<byte> subjectPublicKeyInfo) =>
|
||||
PemEncoding.WriteString("PUBLIC KEY", subjectPublicKeyInfo);
|
||||
|
||||
/// <summary>Signe un message (côté launcher).</summary>
|
||||
public static byte[] Sign(ECDsa privateKey, ReadOnlySpan<byte> message)
|
||||
{
|
||||
EnsureP256(privateKey);
|
||||
return privateKey.SignData(message, HashAlgorithmName.SHA256, Format);
|
||||
}
|
||||
|
||||
/// <summary>Vérifie une signature (côté serveur). Ne lève jamais d'exception : faux si quoi que ce soit ne va pas.</summary>
|
||||
public static bool Verify(ReadOnlySpan<byte> subjectPublicKeyInfo, ReadOnlySpan<byte> message, ReadOnlySpan<byte> signature)
|
||||
{
|
||||
if (signature.Length != SignatureLength || subjectPublicKeyInfo.Length is 0 or > MaxSpkiLength) return false;
|
||||
try
|
||||
{
|
||||
using var ec = ECDsa.Create();
|
||||
ec.ImportSubjectPublicKeyInfo(subjectPublicKeyInfo, out _);
|
||||
if (!IsP256(ec)) return false;
|
||||
return ec.VerifyData(message, signature, HashAlgorithmName.SHA256, Format);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Signature encodée en Base64 → octets, ou null si l'encodage ou la taille ne conviennent pas.</summary>
|
||||
public static byte[]? DecodeSignature(string? base64)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64) || base64.Length > 128) return null;
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(base64);
|
||||
return bytes.Length == SignatureLength ? bytes : null;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureP256(ECDsa ec)
|
||||
{
|
||||
if (!IsP256(ec)) throw new CryptographicException("Une clé ECDSA P-256 est attendue.");
|
||||
}
|
||||
|
||||
private static bool IsP256(ECDsa ec)
|
||||
{
|
||||
if (ec.KeySize != 256) return false;
|
||||
var curve = ec.ExportParameters(false).Curve;
|
||||
return curve.IsNamed && (curve.Oid.Value == P256Oid || curve.Oid.FriendlyName is "nistP256" or "ECDSA_P256");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Permissions d'administration, au format « module.action ». Un module futur ajoute les siennes
|
||||
/// ici (ou dans son propre fichier de contrats) et les attribue aux rôles dans <see cref="AdminRoles"/>.
|
||||
/// Le serveur est seul juge : côté launcher, elles servent uniquement à masquer les pages inutiles.
|
||||
/// </summary>
|
||||
public static class AdminPermissions
|
||||
{
|
||||
/// <summary>Gestion des administrateurs : invitations, approbation, rôles, révocation.</summary>
|
||||
public const string AdminsManage = "admins.manage";
|
||||
|
||||
/// <summary>Lecture du journal d'audit.</summary>
|
||||
public const string AuditRead = "audit.read";
|
||||
|
||||
/// <summary>Informations modifiables des jeux (nom affiché, accroche, liens…) — phase 3.</summary>
|
||||
public const string GamesEdit = "games.edit";
|
||||
|
||||
/// <summary>Édition des notes de version existantes — phase 4.</summary>
|
||||
public const string ChangelogEdit = "changelog.edit";
|
||||
|
||||
/// <summary>Publication d'une nouvelle version du jeu — phase 5.</summary>
|
||||
public const string ReleasesPublish = "releases.publish";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[]
|
||||
{
|
||||
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>Rôles des administrateurs et permissions associées.</summary>
|
||||
public static class AdminRoles
|
||||
{
|
||||
/// <summary>Tous les droits, dont la gestion des administrateurs.</summary>
|
||||
public const string Owner = "owner";
|
||||
|
||||
/// <summary>Contenu et publication, sans gestion des administrateurs.</summary>
|
||||
public const string Admin = "admin";
|
||||
|
||||
/// <summary>Contenu uniquement (informations des jeux, notes de version).</summary>
|
||||
public const string Editor = "editor";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { Owner, Admin, Editor };
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, IReadOnlyList<string>> Map =
|
||||
new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal)
|
||||
{
|
||||
[Owner] = AdminPermissions.All,
|
||||
[Admin] = new[]
|
||||
{
|
||||
AdminPermissions.AuditRead, AdminPermissions.GamesEdit,
|
||||
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish
|
||||
},
|
||||
[Editor] = new[] { AdminPermissions.GamesEdit, AdminPermissions.ChangelogEdit }
|
||||
};
|
||||
|
||||
public static bool IsValid(string? role) => role is not null && Map.ContainsKey(role);
|
||||
|
||||
/// <summary>Permissions d'un rôle (aucune pour un rôle inconnu).</summary>
|
||||
public static IReadOnlyList<string> PermissionsOf(string? role) =>
|
||||
role is not null && Map.TryGetValue(role, out var permissions) ? permissions : Array.Empty<string>();
|
||||
|
||||
public static bool Has(string? role, string permission) => PermissionsOf(role).Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
public static string DisplayName(string? role) => role switch
|
||||
{
|
||||
Owner => "Propriétaire",
|
||||
Admin => "Administrateur",
|
||||
Editor => "Éditeur",
|
||||
_ => "Inconnu"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>États d'un administrateur ou d'une clé.</summary>
|
||||
public static class AdminStatus
|
||||
{
|
||||
/// <summary>Enrôlé avec une invitation, en attente d'approbation (empreinte vérifiée par un propriétaire).</summary>
|
||||
public const string Pending = "pending";
|
||||
public const string Active = "active";
|
||||
public const string Revoked = "revoked";
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Codes d'invitation : 160 bits aléatoires, en Base32 (sans 0/1/8/9 ambigus), groupés par 4 :
|
||||
/// « ABCD-EFGH-IJKL-MNOP-QRST-UVWX-YZ23-4567 ». Le serveur ne garde que leur empreinte.
|
||||
/// </summary>
|
||||
public static class InvitationCodes
|
||||
{
|
||||
private const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
private const int Length = 32; // 32 × 5 bits = 160 bits
|
||||
|
||||
public static string Generate()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(Length * 5 / 8);
|
||||
var chars = new char[Length];
|
||||
int buffer = 0, bits = 0, index = 0;
|
||||
foreach (var b in bytes)
|
||||
{
|
||||
buffer = (buffer << 8) | b;
|
||||
bits += 8;
|
||||
while (bits >= 5)
|
||||
{
|
||||
chars[index++] = Alphabet[(buffer >> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < Length; i++)
|
||||
{
|
||||
if (i > 0 && i % 4 == 0) sb.Append('-');
|
||||
sb.Append(chars[i]);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Code saisi → 32 caractères majuscules sans séparateurs, ou null s'il est mal formé.</summary>
|
||||
public static string? Normalize(string? typed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(typed) || typed.Length > 80) return null;
|
||||
var sb = new StringBuilder(Length);
|
||||
foreach (var c in typed)
|
||||
{
|
||||
if (c is '-' or ' ') continue;
|
||||
var u = char.ToUpperInvariant(c);
|
||||
if (Alphabet.IndexOf(u) < 0) return null;
|
||||
sb.Append(u);
|
||||
}
|
||||
return sb.Length == Length ? sb.ToString() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Contracts.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Messages signés par la clé d'un administrateur. Chaque type de message commence par un en-tête
|
||||
/// propre (séparation de domaine) : une signature produite pour un usage ne vaut jamais pour un
|
||||
/// autre. Une ligne par champ, « nom:valeur », valeurs sans retour à la ligne, UTF-8, « \n ».
|
||||
/// Le launcher et le serveur construisent le message avec ce même code.
|
||||
/// </summary>
|
||||
public static class SignedMessages
|
||||
{
|
||||
public const string LoginHeader = "GameLauncher/Admin/Login/v1";
|
||||
public const string EnrollmentHeader = "GameLauncher/Admin/Enrollment/v1";
|
||||
public const string RequestHeader = "GameLauncher/Admin/Request/v1";
|
||||
|
||||
/// <summary>Message signé pour se connecter (réponse au défi).</summary>
|
||||
public static byte[] Login(string origin, string serverId, string challengeId, string nonce, string keyId) =>
|
||||
Build(LoginHeader,
|
||||
("origin", origin), ("server", serverId), ("challenge", challengeId), ("nonce", nonce), ("key", keyId));
|
||||
|
||||
/// <summary>
|
||||
/// Preuve de possession de la clé lors d'un enrôlement : seule l'empreinte du code d'invitation est
|
||||
/// signée (le code lui-même n'apparaît pas dans la signature).
|
||||
/// </summary>
|
||||
public static byte[] Enrollment(string origin, string serverId, string invitationCodeHash, string keyId) =>
|
||||
Build(EnrollmentHeader,
|
||||
("origin", origin), ("server", serverId), ("invitation", invitationCodeHash), ("key", keyId));
|
||||
|
||||
/// <summary>Message signé d'une requête qui modifie quelque chose (en plus du jeton de session).</summary>
|
||||
/// <param name="pathAndQuery">Chemin tel que reçu par le serveur, ex. « /api/v1/admins/invitations ».</param>
|
||||
public static byte[] Request(string origin, string serverId, string sessionId, string method, string pathAndQuery,
|
||||
ReadOnlySpan<byte> body, long timestamp, string nonce) =>
|
||||
Build(RequestHeader,
|
||||
("origin", origin), ("server", serverId), ("session", sessionId),
|
||||
("method", method.ToUpperInvariant()), ("path", pathAndQuery),
|
||||
("body-sha256", Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant()),
|
||||
("timestamp", timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture)), ("nonce", nonce));
|
||||
|
||||
/// <summary>Empreinte d'un code d'invitation (normalisé) : seule valeur conservée par le serveur.</summary>
|
||||
public static string HashInvitationCode(string normalizedCode) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("GameLauncher/Admin/Invitation/v1\n" + normalizedCode)))
|
||||
.ToLowerInvariant();
|
||||
|
||||
/// <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)
|
||||
{
|
||||
var sb = new StringBuilder(header).Append('\n');
|
||||
foreach (var (name, value) in fields)
|
||||
{
|
||||
if (value is null || value.IndexOfAny(new[] { '\n', '\r' }) >= 0 || value.Length > 2048)
|
||||
throw new ArgumentException($"Valeur invalide pour le champ signé « {name} ».");
|
||||
sb.Append(name).Append(':').Append(value).Append('\n');
|
||||
}
|
||||
return Encoding.UTF8.GetBytes(sb.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>En-têtes HTTP des requêtes signées et tolérances associées.</summary>
|
||||
public static class SignedRequestHeaders
|
||||
{
|
||||
public const string Timestamp = "X-GL-Timestamp";
|
||||
public const string Nonce = "X-GL-Nonce";
|
||||
public const string Signature = "X-GL-Signature";
|
||||
|
||||
/// <summary>Décalage d'horloge maximal accepté entre le launcher et le serveur.</summary>
|
||||
public static readonly TimeSpan MaxClockSkew = TimeSpan.FromMinutes(2);
|
||||
}
|
||||
@@ -15,7 +15,16 @@ namespace GameLauncher.Core.Models;
|
||||
/// </summary>
|
||||
public sealed class LauncherCatalog
|
||||
{
|
||||
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl" };
|
||||
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl" };
|
||||
|
||||
/// <summary>Nom par défaut du dossier des données du launcher (%AppData%\<Publisher>\GameLauncher).</summary>
|
||||
public const string DefaultDataFolderName = "GameLauncher";
|
||||
|
||||
private const string GamesFolderName = "Games";
|
||||
private const string GameSettingsFileName = "settings.json";
|
||||
|
||||
private string? _dataDirectory;
|
||||
private readonly Dictionary<string, string> _gameSettingsPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private LauncherCatalog(string launcherId, string launcherName, string publisher, string? defaultGameId,
|
||||
IReadOnlyList<LauncherConfig> games, bool isMultiGameFormat)
|
||||
@@ -54,11 +63,151 @@ public sealed class LauncherCatalog
|
||||
public string? LauncherUpdateUrl { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dossier des données du launcher (journaux, dernier jeu sélectionné).
|
||||
/// Avec un seul jeu, c'est le même dossier qu'auparavant : %AppData%\Publisher\GameIdLauncher.
|
||||
/// Adresse de la Launcher API (menu d'administration), ex. « https://launcher.lanisys.fr/api/ ».
|
||||
/// Facultatif : sans cette valeur, le menu d'administration est désactivé. Le launcher publié ne
|
||||
/// lit cette valeur que dans le launcher.json INTÉGRÉ à l'exe (voir App) : un fichier posé à côté
|
||||
/// ne peut pas rediriger les administrateurs vers un autre serveur.
|
||||
/// Une valeur invalide ne bloque jamais le launcher des joueurs : elle est ignorée
|
||||
/// (<see cref="ApiUrl"/> null) et expliquée dans <see cref="ApiUrlError"/>.
|
||||
/// </summary>
|
||||
public string DataDirectory =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Publisher, LauncherId);
|
||||
public string? ApiUrl { get; private set; }
|
||||
|
||||
/// <summary>Raison pour laquelle « apiUrl » a été ignorée (valeur invalide), sinon null.</summary>
|
||||
public string? ApiUrlError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Nom du dossier des données du launcher et de sa clé de registre (« dataFolder » dans
|
||||
/// launcher.json, « GameLauncher » par défaut). Indépendant de <see cref="LauncherId"/>, qui
|
||||
/// identifie le produit lors des mises à jour et ne doit plus changer.
|
||||
/// </summary>
|
||||
public string DataFolderName { get; private set; } = DefaultDataFolderName;
|
||||
|
||||
/// <summary>%AppData%\<Publisher></summary>
|
||||
public string PublisherDirectory =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Publisher);
|
||||
|
||||
/// <summary>
|
||||
/// Dossier des données du launcher : %AppData%\<Publisher>\GameLauncher (paramètres du
|
||||
/// launcher, mise à jour du launcher, journaux, dernier jeu affiché ; paramètres de chaque jeu
|
||||
/// dans Games\<GameId>). Voir <see cref="PrepareStorage"/>.
|
||||
/// </summary>
|
||||
public string DataDirectory => _dataDirectory ?? Path.Combine(PublisherDirectory, DataFolderName);
|
||||
|
||||
/// <summary>
|
||||
/// Ancien dossier des données (launcher 1.2.1 et antérieurs) : %AppData%\<Publisher>\<LauncherId>
|
||||
/// (ex. EZRLauncher avec un seul jeu).
|
||||
/// </summary>
|
||||
public string LegacyDataDirectory => Path.Combine(PublisherDirectory, LauncherId);
|
||||
|
||||
/// <summary>Vrai si l'ancien dossier est encore utilisé pour cette session (déplacement différé ou impossible).</summary>
|
||||
public bool UsesLegacyStorage { get; private set; }
|
||||
|
||||
/// <summary>Paramètres propres à un jeu : <DataDirectory>\Games\<GameId>\settings.json.</summary>
|
||||
public string GetGameSettingsPath(LauncherConfig game) =>
|
||||
_gameSettingsPaths.TryGetValue(game.GameId, out var path)
|
||||
? path
|
||||
: Path.Combine(DataDirectory, GamesFolderName, game.GameId, GameSettingsFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Détermine les dossiers à utiliser et, si <paramref name="allowMigration"/>, déplace les données
|
||||
/// des versions précédentes :
|
||||
/// • %AppData%\<Publisher>\<LauncherId> (ex. EZRLauncher) → %AppData%\<Publisher>\GameLauncher
|
||||
/// (renommage du dossier, instantané) ;
|
||||
/// • paramètres de chaque jeu (settings.json à la racine de l'ancien dossier, ou dans
|
||||
/// %AppData%\<Publisher>\<GameId>Launcher) → GameLauncher\Games\<GameId>\settings.json.
|
||||
/// Sans déplacement (autre instance ouverte, démarrage après une mise à jour du launcher) ou en cas
|
||||
/// d'échec, l'ancien dossier reste utilisé pour cette session : rien n'est perdu, le déplacement est
|
||||
/// retenté au démarrage suivant.
|
||||
/// </summary>
|
||||
/// <returns>Messages à écrire dans le journal (le journal n'est pas encore ouvert) ; « ! » = avertissement.</returns>
|
||||
public IReadOnlyList<string> PrepareStorage(bool allowMigration)
|
||||
{
|
||||
var notes = new List<string>();
|
||||
var target = Path.Combine(PublisherDirectory, DataFolderName);
|
||||
var legacy = LegacyDataDirectory;
|
||||
_dataDirectory = target;
|
||||
UsesLegacyStorage = false;
|
||||
_gameSettingsPaths.Clear();
|
||||
|
||||
if (!SamePath(target, legacy) && !Directory.Exists(target) && Directory.Exists(legacy))
|
||||
{
|
||||
if (!allowMigration)
|
||||
{
|
||||
UseLegacy();
|
||||
notes.Add($"Dossier des données : {legacy} utilisé pour cette session (déplacement vers {target} au prochain démarrage).");
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Move(legacy, target); // même dossier parent : simple renommage
|
||||
notes.Add($"Données du launcher déplacées : {legacy} → {target}");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
UseLegacy();
|
||||
notes.Add($"! Déplacement de {legacy} vers {target} impossible ({ex.Message}) : l'ancien dossier reste utilisé.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var game in Games)
|
||||
{
|
||||
var legacyGameDirectory = Path.Combine(PublisherDirectory, game.GameId + "Launcher");
|
||||
if (UsesLegacyStorage)
|
||||
{
|
||||
_gameSettingsPaths[game.GameId] = Path.Combine(legacyGameDirectory, GameSettingsFileName);
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = Path.Combine(target, GamesFolderName, game.GameId, GameSettingsFileName);
|
||||
_gameSettingsPaths[game.GameId] = path;
|
||||
if (File.Exists(path)) continue;
|
||||
|
||||
// Ancien emplacement : à la racine du dossier déplacé (un seul jeu : même dossier que le
|
||||
// launcher) ou dans le dossier propre au jeu (plusieurs jeux).
|
||||
var candidates = new List<string>();
|
||||
if (SamePath(legacyGameDirectory, legacy)) candidates.Add(Path.Combine(target, GameSettingsFileName));
|
||||
candidates.Add(Path.Combine(legacyGameDirectory, GameSettingsFileName));
|
||||
var source = candidates.FirstOrDefault(File.Exists);
|
||||
if (source is null) continue;
|
||||
|
||||
if (!allowMigration)
|
||||
{
|
||||
_gameSettingsPaths[game.GameId] = source;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.Move(source, path);
|
||||
notes.Add($"[{game.GameId}] Paramètres du jeu déplacés : {source} → {path}");
|
||||
|
||||
// Ancien dossier propre au jeu, désormais vide : supprimé.
|
||||
var sourceDirectory = Path.GetDirectoryName(source)!;
|
||||
if (!SamePath(sourceDirectory, target) && Directory.Exists(sourceDirectory) &&
|
||||
!Directory.EnumerateFileSystemEntries(sourceDirectory).Any())
|
||||
Directory.Delete(sourceDirectory);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_gameSettingsPaths[game.GameId] = source;
|
||||
notes.Add($"! [{game.GameId}] Déplacement des paramètres du jeu impossible ({ex.Message}) : {source} reste utilisé.");
|
||||
}
|
||||
}
|
||||
return notes;
|
||||
|
||||
void UseLegacy()
|
||||
{
|
||||
_dataDirectory = legacy;
|
||||
UsesLegacyStorage = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SamePath(string a, string b) =>
|
||||
string.Equals(Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar),
|
||||
Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Nom du mutex garantissant une seule instance du launcher.</summary>
|
||||
public string SingleInstanceName =>
|
||||
@@ -103,7 +252,10 @@ public sealed class LauncherCatalog
|
||||
return new LauncherCatalog(single.GameId + "Launcher", single.GameName, single.Publisher, single.GameId,
|
||||
new[] { single }, isMultiGameFormat: false)
|
||||
{
|
||||
LauncherUpdateUrl = ReadUpdateUrl(root)
|
||||
LauncherUpdateUrl = ReadUpdateUrl(root),
|
||||
ApiUrl = ReadApiUrl(root, out var singleApiUrlError),
|
||||
ApiUrlError = singleApiUrlError,
|
||||
DataFolderName = ReadDataFolder(root)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,10 +305,23 @@ public sealed class LauncherCatalog
|
||||
var defaultGame = Get(root, "defaultGame")?.GetValue<string>();
|
||||
return new LauncherCatalog(launcherId, launcherName, publisher, defaultGame, games, isMultiGameFormat: true)
|
||||
{
|
||||
LauncherUpdateUrl = ReadUpdateUrl(root)
|
||||
LauncherUpdateUrl = ReadUpdateUrl(root),
|
||||
ApiUrl = ReadApiUrl(root, out var apiUrlError),
|
||||
ApiUrlError = apiUrlError,
|
||||
DataFolderName = ReadDataFolder(root)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>« dataFolder » : nom simple de dossier (« GameLauncher » par défaut).</summary>
|
||||
private static string ReadDataFolder(JsonObject root)
|
||||
{
|
||||
var name = Get(root, "dataFolder")?.GetValue<string>()?.Trim();
|
||||
if (string.IsNullOrEmpty(name)) return DefaultDataFolderName;
|
||||
if (name is "." or ".." || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || name.IndexOfAny(new[] { '\\', '/', ':' }) >= 0)
|
||||
throw new InvalidDataException("launcher.json : dataFolder doit être un simple nom de dossier (ex. \"GameLauncher\").");
|
||||
return name;
|
||||
}
|
||||
|
||||
private static string? ReadUpdateUrl(JsonObject root)
|
||||
{
|
||||
var url = Get(root, "launcherUpdateUrl")?.GetValue<string>();
|
||||
@@ -167,6 +332,24 @@ public sealed class LauncherCatalog
|
||||
return uri.ToString();
|
||||
}
|
||||
|
||||
/// <summary>« apiUrl » : adresse https absolue, sans paramètres (http accepté seulement en local, pour les tests).</summary>
|
||||
private static string? ReadApiUrl(JsonObject root, out string? error)
|
||||
{
|
||||
error = null;
|
||||
var node = Get(root, "apiUrl");
|
||||
if (node is null) return null;
|
||||
var url = node is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;
|
||||
if (url is not null && string.IsNullOrWhiteSpace(url)) return null;
|
||||
if (url is null || !Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) ||
|
||||
!(uri.Scheme == Uri.UriSchemeHttps || (uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)) ||
|
||||
!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || !string.IsNullOrEmpty(uri.UserInfo))
|
||||
{
|
||||
error = "launcher.json : apiUrl ignorée, elle doit être une adresse https:// absolue sans paramètres, ex. \"https://launcher.exemple.fr/api/\".";
|
||||
return null;
|
||||
}
|
||||
return uri.AbsoluteUri.EndsWith('/') ? uri.AbsoluteUri : uri.AbsoluteUri + "/";
|
||||
}
|
||||
|
||||
/// <summary>Lecture d'une propriété sans tenir compte de la casse (gameId / GameId…).</summary>
|
||||
private static JsonNode? Get(JsonObject obj, string name)
|
||||
{
|
||||
|
||||
@@ -47,6 +47,38 @@ public sealed class LauncherConfig
|
||||
/// <summary>Image de fond remplaçant celle du thème (image, chemin relatif au launcher). Facultatif.</summary>
|
||||
public string? Background { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dossier des données du jeu Unity : « Société\Produit » des Player Settings de Unity
|
||||
/// (ex. "LanisysGames\\Exovival Zombie Reborn"). Sert à ouvrir, depuis les paramètres du jeu,
|
||||
/// le dossier des données (%APPDATA%\Société\Produit : sauvegardes, réglages) et celui des
|
||||
/// journaux de Unity (%USERPROFILE%\AppData\LocalLow\Société\Produit : Player.log).
|
||||
/// Facultatif : Publisher\GameName par défaut.
|
||||
/// </summary>
|
||||
public string? GameDataFolder { get; set; }
|
||||
|
||||
/// <summary>Chemin relatif « Société\Produit » (voir <see cref="GameDataFolder"/>).</summary>
|
||||
public string GameDataRelativePath =>
|
||||
(string.IsNullOrWhiteSpace(GameDataFolder) ? Path.Combine(Publisher, GameName) : GameDataFolder.Trim())
|
||||
.Replace('/', '\\').Trim('\\');
|
||||
|
||||
/// <summary>Sauvegardes et réglages du jeu : %APPDATA%\Société\Produit (Roaming).</summary>
|
||||
public string GameDataDirectory =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), GameDataRelativePath);
|
||||
|
||||
/// <summary>
|
||||
/// Journaux de Unity (Player.log) : %USERPROFILE%\AppData\LocalLow\Société\Produit. Emplacement fixé
|
||||
/// par Unity (Application.persistentDataPath) : LocalLow est le dossier voisin de %LOCALAPPDATA%.
|
||||
/// </summary>
|
||||
public string GameLogsDirectory
|
||||
{
|
||||
get
|
||||
{
|
||||
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var appData = Path.GetDirectoryName(local.TrimEnd(Path.DirectorySeparatorChar)) ?? local;
|
||||
return Path.Combine(appData, "LocalLow", GameDataRelativePath);
|
||||
}
|
||||
}
|
||||
|
||||
public string ExpandedDefaultInstallDirectory =>
|
||||
Environment.ExpandEnvironmentVariables(DefaultInstallDirectory ?? "");
|
||||
|
||||
@@ -81,5 +113,17 @@ public sealed class LauncherConfig
|
||||
throw new InvalidDataException("launcher.json : GameName est obligatoire.");
|
||||
if (!Uri.TryCreate(ManifestUrl, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
throw new InvalidDataException("launcher.json : ManifestUrl doit être une URL http(s) absolue.");
|
||||
if (!IsSafeRelativeFolder(GameDataRelativePath))
|
||||
throw new InvalidDataException(
|
||||
"launcher.json : gameDataFolder doit être un chemin relatif « Société\\Produit » (sans lecteur, sans « .. »).");
|
||||
}
|
||||
|
||||
/// <summary>Chemin relatif simple : pas de lecteur, pas de « .. », pas de caractère interdit.</summary>
|
||||
private static bool IsSafeRelativeFolder(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path) || path.Contains(':')) return false;
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
return path.Split('\\').All(part => part.Length > 0 && part != "." && part != ".." &&
|
||||
part.Trim() == part && part.IndexOfAny(invalid) < 0);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace GameLauncher.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Préférences du launcher, communes à tous les jeux (roue crantée › Paramètres du launcher) :
|
||||
/// %AppData%\<Publisher>\<LauncherId>\launcher-settings.json.
|
||||
/// %AppData%\<Publisher>\GameLauncher\launcher-settings.json (voir LauncherCatalog.DataDirectory).
|
||||
/// (La mise à jour automatique du launcher lui-même est dans self-update.json.)
|
||||
/// </summary>
|
||||
public sealed class LauncherPreferences
|
||||
@@ -16,6 +16,18 @@ public sealed class LauncherPreferences
|
||||
/// <summary>Fermer le launcher au lancement du jeu.</summary>
|
||||
public bool CloseOnLaunch { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Avec <see cref="MinimizeOnLaunch"/> : pendant la partie, le launcher est réduit dans la zone de
|
||||
/// notification (icône près de l'horloge) au lieu de la barre des tâches.
|
||||
/// </summary>
|
||||
public bool MinimizeToTray { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// La croix de la fenêtre réduit le launcher dans la zone de notification au lieu de le quitter
|
||||
/// (il se quitte alors par le menu de son icône › Quitter).
|
||||
/// </summary>
|
||||
public bool CloseToTray { get; set; }
|
||||
|
||||
public static string GetPath(string dataDirectory) => Path.Combine(dataDirectory, "launcher-settings.json");
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace GameLauncher.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Préférences propres à UN jeu (menu « … » › Paramètres du jeu) :
|
||||
/// %AppData%\{Publisher}\{GameId}Launcher\settings.json.
|
||||
/// %AppData%\{Publisher}\GameLauncher\Games\{GameId}\settings.json (voir LauncherCatalog.GetGameSettingsPath ;
|
||||
/// launcher 1.2.1 et antérieurs : %AppData%\{Publisher}\{GameId}Launcher\settings.json).
|
||||
/// Les préférences communes à tous les jeux sont dans <see cref="LauncherPreferences"/>.
|
||||
/// </summary>
|
||||
public sealed class LauncherSettings
|
||||
@@ -26,13 +27,6 @@ public sealed class LauncherSettings
|
||||
|
||||
public LauncherSettings Clone() => (LauncherSettings)MemberwiseClone();
|
||||
|
||||
public static string GetDefaultPath(LauncherConfig config) =>
|
||||
Path.Combine(GetDataDirectory(config), "settings.json");
|
||||
|
||||
/// <summary>%AppData%\{Publisher}\{GameId}Launcher</summary>
|
||||
public static string GetDataDirectory(LauncherConfig config) =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
config.Publisher, config.GameId + "Launcher");
|
||||
|
||||
public static LauncherSettings Load(string path)
|
||||
{
|
||||
|
||||
@@ -24,4 +24,14 @@ public sealed record VerifyResult(
|
||||
}
|
||||
|
||||
/// <summary>Résultat d'une désinstallation.</summary>
|
||||
public sealed record UninstallResult(int DeletedFiles, int RemainingFiles, bool FolderRemoved);
|
||||
/// <param name="DeletedFiles">Fichiers supprimés.</param>
|
||||
/// <param name="RemainingFiles">Fichiers personnels conservés (hors fichiers bloqués et fichiers du launcher).</param>
|
||||
/// <param name="FolderRemoved">Le dossier du jeu a disparu.</param>
|
||||
public sealed record UninstallResult(int DeletedFiles, int RemainingFiles, bool FolderRemoved)
|
||||
{
|
||||
/// <summary>Fichiers qui n'ont pas pu être supprimés (fichier ouvert, antivirus…).</summary>
|
||||
public IReadOnlyList<string> LockedFiles { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>Fichiers du launcher conservés (launcher placé dans le dossier du jeu).</summary>
|
||||
public int LauncherFilesKept { get; init; }
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace GameLauncher.Core.SelfUpdate;
|
||||
|
||||
/// <summary>
|
||||
/// Préférences de mise à jour du launcher lui-même, communes à tous les jeux :
|
||||
/// %AppData%\<Publisher>\<LauncherId>\self-update.json
|
||||
/// %AppData%\<Publisher>\GameLauncher\self-update.json (voir LauncherCatalog.DataDirectory)
|
||||
/// </summary>
|
||||
public sealed class SelfUpdateSettings
|
||||
{
|
||||
|
||||
@@ -428,10 +428,15 @@ public sealed class GameInstaller
|
||||
/// <summary>
|
||||
/// Désinstalle le jeu. Par défaut, seuls les fichiers installés par le launcher sont supprimés
|
||||
/// (les sauvegardes/configurations créées par le jeu sont conservées).
|
||||
/// Avec <paramref name="deleteEverything"/>, le dossier entier est supprimé.
|
||||
/// Avec <paramref name="deleteEverything"/>, tout le contenu du dossier est supprimé.
|
||||
///
|
||||
/// Les fichiers de <paramref name="keepFiles"/> (le launcher, s'il est dans le dossier du jeu) ne
|
||||
/// sont jamais supprimés. Un fichier bloqué (jeu mal fermé, antivirus…) n'interrompt pas la
|
||||
/// désinstallation : tout ce qui peut l'être est supprimé, le reste est signalé (LockedFiles).
|
||||
/// </summary>
|
||||
public Task<UninstallResult> UninstallAsync(string installDir, GameManifest? manifest, bool deleteEverything,
|
||||
IProgress<OperationProgress>? progress, CancellationToken ct)
|
||||
IProgress<OperationProgress>? progress, CancellationToken ct,
|
||||
IReadOnlyCollection<string>? keepFiles = null)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
@@ -439,15 +444,30 @@ public sealed class GameInstaller
|
||||
Log.Info($"Désinstallation de {installDir} (tout supprimer : {deleteEverything})");
|
||||
if (!Directory.Exists(installDir)) return new UninstallResult(0, 0, true);
|
||||
|
||||
var keep = new HashSet<string>(
|
||||
(keepFiles ?? Array.Empty<string>()).Where(f => LauncherSelfFiles.IsInside(f, installDir)).Select(Path.GetFullPath),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
if (keep.Count > 0) Log.Info($"Fichiers du launcher conservés dans le dossier du jeu : {string.Join(", ", keep.Where(File.Exists))}");
|
||||
|
||||
if (deleteEverything)
|
||||
{
|
||||
if (PathHelper.IsDangerousDirectory(installDir))
|
||||
throw new InvalidOperationException($"Le dossier « {installDir} » est protégé : suppression complète refusée.");
|
||||
|
||||
progress?.Report(new OperationProgress("Suppression du dossier du jeu", installDir, 0, true));
|
||||
var count = Directory.EnumerateFiles(installDir, "*", SearchOption.AllDirectories).Count();
|
||||
FileUtil.DeleteDirectoryRecursive(installDir);
|
||||
return new UninstallResult(count, 0, true);
|
||||
// Fichier par fichier : le launcher est épargné et un fichier bloqué n'arrête pas le reste.
|
||||
var deletion = FileUtil.DeleteDirectoryContents(installDir, keep, (index, total) =>
|
||||
{
|
||||
if (index % 25 == 1 || index == total)
|
||||
progress?.Report(new OperationProgress("Suppression du dossier du jeu",
|
||||
$"{index}/{total} fichiers", index * 100.0 / total));
|
||||
}, ct);
|
||||
Log.Info($"Désinstallation : {deletion.DeletedFiles} fichier(s) supprimé(s), {deletion.LockedFiles.Count} bloqué(s), " +
|
||||
$"{deletion.KeptFiles} fichier(s) du launcher conservé(s).");
|
||||
return new UninstallResult(deletion.DeletedFiles, 0, deletion.FolderRemoved)
|
||||
{
|
||||
LockedFiles = deletion.LockedFiles,
|
||||
LauncherFilesKept = deletion.KeptFiles
|
||||
};
|
||||
}
|
||||
|
||||
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -457,6 +477,7 @@ public sealed class GameInstaller
|
||||
|
||||
var deleted = 0;
|
||||
var index = 0;
|
||||
var locked = new List<string>();
|
||||
foreach (var relative in paths)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
@@ -468,28 +489,29 @@ public sealed class GameInstaller
|
||||
}
|
||||
|
||||
string full;
|
||||
try { full = PathHelper.ResolveSafe(installDir, relative); }
|
||||
try { full = Path.GetFullPath(PathHelper.ResolveSafe(installDir, relative)); }
|
||||
catch { continue; }
|
||||
if (File.Exists(full) && FileUtil.TryDeleteFile(full)) deleted++;
|
||||
if (!File.Exists(full) || keep.Contains(full)) continue;
|
||||
if (FileUtil.TryDeleteFile(full)) deleted++;
|
||||
else locked.Add(full);
|
||||
}
|
||||
|
||||
try { FileUtil.DeleteDirectoryRecursive(InstallStateStore.GetMetaDirectory(installDir)); }
|
||||
catch (Exception ex) { Log.Warn($"Suppression de .launcher impossible : {ex.Message}"); }
|
||||
|
||||
FileUtil.DeleteEmptyDirectories(installDir);
|
||||
var remaining = Directory.EnumerateFiles(installDir, "*", SearchOption.AllDirectories).Count();
|
||||
var removed = false;
|
||||
if (PathHelper.IsDirectoryEmpty(installDir))
|
||||
{
|
||||
try { Directory.Delete(installDir); removed = true; }
|
||||
catch (Exception ex) { Log.Warn($"Suppression du dossier impossible : {ex.Message}"); }
|
||||
}
|
||||
var remainingFiles = Directory.EnumerateFiles(installDir, "*", FileUtil.AllFiles).Select(Path.GetFullPath).ToList();
|
||||
var launcherKept = remainingFiles.Count(keep.Contains);
|
||||
var personal = remainingFiles.Count - launcherKept - locked.Count(remainingFiles.Contains);
|
||||
var removed = FileUtil.TryRemoveEmptyDirectory(installDir);
|
||||
|
||||
Log.Info($"Désinstallation : {deleted} fichier(s) supprimé(s), {remaining} conservé(s).");
|
||||
return new UninstallResult(deleted, remaining, removed);
|
||||
Log.Info($"Désinstallation : {deleted} fichier(s) supprimé(s), {personal} fichier(s) personnel(s) conservé(s), " +
|
||||
$"{locked.Count} bloqué(s), {launcherKept} fichier(s) du launcher conservé(s).");
|
||||
return new UninstallResult(deleted, Math.Max(0, personal), removed) { LockedFiles = locked, LauncherFilesKept = launcherKept };
|
||||
}, ct);
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------------------------------------------ Utilitaires
|
||||
|
||||
private static void RemoveObsoleteFiles(string installDir, IEnumerable<string> oldPaths, GameManifest manifest, InstallState state)
|
||||
|
||||
@@ -1,7 +1,76 @@
|
||||
namespace GameLauncher.Core.Utils;
|
||||
|
||||
/// <summary>Résultat de <see cref="FileUtil.DeleteDirectoryContents"/>.</summary>
|
||||
/// <param name="DeletedFiles">Fichiers supprimés.</param>
|
||||
/// <param name="LockedFiles">Fichiers qui n'ont pas pu être supprimés (fichier ouvert, antivirus…).</param>
|
||||
/// <param name="KeptFiles">Fichiers conservés volontairement (fichiers du launcher).</param>
|
||||
/// <param name="FolderRemoved">Le dossier a disparu (il était vide à la fin).</param>
|
||||
public sealed record DirectoryDeletion(int DeletedFiles, IReadOnlyList<string> LockedFiles, int KeptFiles, bool FolderRemoved);
|
||||
|
||||
public static class FileUtil
|
||||
{
|
||||
/// <summary>Tous les fichiers, y compris cachés (ex. dossier .launcher) ; dossiers inaccessibles ignorés.</summary>
|
||||
public static readonly EnumerationOptions AllFiles = new()
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = 0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Supprime le contenu d'un dossier fichier par fichier, puis les sous-dossiers vides, puis le
|
||||
/// dossier lui-même s'il est vide. Utilisé par la désinstallation (dossier du jeu, sauvegardes,
|
||||
/// journaux) :
|
||||
/// • les fichiers de <paramref name="keepFiles"/> (le launcher, s'il se trouve dans ce dossier)
|
||||
/// ne sont jamais supprimés ;
|
||||
/// • un fichier bloqué n'interrompt pas la suppression : il est seulement signalé.
|
||||
/// </summary>
|
||||
/// <param name="onProgress">(index, total) : avancement, facultatif.</param>
|
||||
public static DirectoryDeletion DeleteDirectoryContents(string directory, IReadOnlyCollection<string>? keepFiles = null,
|
||||
Action<int, int>? onProgress = null, CancellationToken ct = default)
|
||||
{
|
||||
directory = Path.GetFullPath(directory);
|
||||
if (!Directory.Exists(directory)) return new DirectoryDeletion(0, Array.Empty<string>(), 0, true);
|
||||
|
||||
var keep = new HashSet<string>(
|
||||
(keepFiles ?? Array.Empty<string>()).Where(f => LauncherSelfFiles.IsInside(f, directory)).Select(Path.GetFullPath),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var files = Directory.EnumerateFiles(directory, "*", AllFiles).ToList();
|
||||
var locked = new List<string>();
|
||||
int deleted = 0, kept = 0;
|
||||
for (var i = 0; i < files.Count; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
onProgress?.Invoke(i + 1, files.Count);
|
||||
|
||||
var file = Path.GetFullPath(files[i]);
|
||||
if (keep.Contains(file)) { kept++; continue; }
|
||||
if (TryDeleteFile(file)) deleted++;
|
||||
else locked.Add(file);
|
||||
}
|
||||
|
||||
DeleteEmptyDirectories(directory);
|
||||
return new DirectoryDeletion(deleted, locked, kept, TryRemoveEmptyDirectory(directory));
|
||||
}
|
||||
|
||||
/// <summary>Supprime le dossier s'il est vide. Vrai s'il n'existe plus.</summary>
|
||||
public static bool TryRemoveEmptyDirectory(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(directory)) return true;
|
||||
if (Directory.EnumerateFileSystemEntries(directory).Any()) return false;
|
||||
Directory.Delete(directory);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"Suppression du dossier impossible ({directory}) : {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Écrit un fichier de façon atomique (fichier temporaire puis remplacement).</summary>
|
||||
public static void WriteAllTextAtomic(string path, string content)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace GameLauncher.Core.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Fichiers du launcher lui-même, à conserver quand il se trouve dans le dossier d'un jeu que l'on
|
||||
/// désinstalle (option « Supprimer aussi… ») : un exécutable en cours d'exécution ne peut de toute
|
||||
/// façon pas être supprimé, et il sert encore (autres jeux, réinstallation).
|
||||
/// </summary>
|
||||
public static class LauncherSelfFiles
|
||||
{
|
||||
/// <summary>
|
||||
/// Exécutable du launcher, launcher.json à côté, fichiers temporaires de sa mise à jour
|
||||
/// (GameLauncher.exe.old / .download / .replaced / .failed), et en version de développement
|
||||
/// (plusieurs fichiers) les bibliothèques chargées depuis son dossier.
|
||||
/// </summary>
|
||||
/// <returns>Chemins complets (comparaison sans tenir compte de la casse).</returns>
|
||||
public static IReadOnlyCollection<string> Get(string launcherExePath)
|
||||
{
|
||||
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
string exe;
|
||||
try { exe = Path.GetFullPath(launcherExePath); }
|
||||
catch { return files; }
|
||||
var directory = Path.GetDirectoryName(exe);
|
||||
if (string.IsNullOrEmpty(directory)) return files;
|
||||
|
||||
files.Add(exe);
|
||||
files.Add(Path.Combine(directory, "launcher.json"));
|
||||
|
||||
// GameLauncher.exe.old, GameLauncher.exe.1.2.2.download… et, en développement, GameLauncher.dll,
|
||||
// GameLauncher.Core.dll, GameLauncher.deps.json, GameLauncher.runtimeconfig.json, .pdb…
|
||||
var entryName = Assembly.GetEntryAssembly()?.GetName().Name ?? Path.GetFileNameWithoutExtension(exe);
|
||||
foreach (var pattern in new[] { Path.GetFileName(exe) + ".*", entryName + ".*" })
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(directory, pattern)) files.Add(Path.GetFullPath(file));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"Liste des fichiers du launcher incomplète : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Version de développement : bibliothèques réellement chargées depuis le dossier du launcher.
|
||||
// (Version publiée « fichier unique » : Location est vide, rien à ajouter.)
|
||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
if (assembly.IsDynamic) continue;
|
||||
string location;
|
||||
try { location = assembly.Location; }
|
||||
catch { continue; }
|
||||
if (string.IsNullOrEmpty(location) || !IsInside(location, directory)) continue;
|
||||
var full = Path.GetFullPath(location);
|
||||
files.Add(full);
|
||||
files.Add(Path.ChangeExtension(full, ".pdb"));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/// <summary>Vrai si <paramref name="path"/> est <paramref name="directory"/> ou se trouve dedans (à toute profondeur).</summary>
|
||||
public static bool IsInside(string path, string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var full = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var root = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
return string.Equals(full, root, StringComparison.OrdinalIgnoreCase) ||
|
||||
full.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
namespace GameLauncher.Modules.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Administration » : la fenêtre d'administration elle-même (connexion, invitation,
|
||||
/// verrouillage) et sa page d'accueil. Les autres pages (Jeux, Notes de version, Publication,
|
||||
/// Administrateurs, Audit) seront déclarées par leurs propres modules.
|
||||
/// </summary>
|
||||
public sealed class AdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "admin";
|
||||
|
||||
public string DisplayName => "Administration";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
DashboardPanelViewModel.PanelId,
|
||||
"Tableau de bord",
|
||||
"Votre compte, la session en cours et l'état du serveur.",
|
||||
"ViewDashboard",
|
||||
RequiredPermission: null,
|
||||
Order: 0,
|
||||
context => new DashboardPanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Module « Administration » du launcher (sans WPF) : clés de l'administrateur, connexion,
|
||||
invitation, verrouillage de session, tableau de bord. Les écrans correspondants sont dans le
|
||||
launcher (Views\Admin, Themes\Admin.xaml). Voir docs\ADMIN-ARCHITECTURE.md.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.Modules.Admin</RootNamespace>
|
||||
<AssemblyName>GameLauncher.Modules.Admin</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,60 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Connexion ouverte : session + clé déverrouillée (en mémoire, ou ouverte dans la puce TPM). La fermer
|
||||
/// déconnecte la session sur le serveur et libère la clé : il faudra de nouveau la phrase de passe (ou le PIN).
|
||||
/// </summary>
|
||||
public sealed class AdminConnection : IAsyncDisposable
|
||||
{
|
||||
private static readonly TimeSpan LogoutTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly IAdminSigner _signer;
|
||||
private readonly IDisposable _key;
|
||||
private int _disposed;
|
||||
|
||||
/// <param name="key">Ce qui retient la clé (clé en mémoire, poignée de la clé TPM) : libéré à la fermeture.</param>
|
||||
internal AdminConnection(LauncherApiClient client, AdminSession session, IAdminSigner signer, IDisposable key, string keyPath,
|
||||
bool isTpm = false)
|
||||
{
|
||||
Client = client;
|
||||
Session = session;
|
||||
_signer = signer;
|
||||
_key = key;
|
||||
KeyPath = keyPath;
|
||||
IsTpm = isTpm;
|
||||
}
|
||||
|
||||
public LauncherApiClient Client { get; }
|
||||
public AdminSession Session { get; }
|
||||
public string KeyPath { get; }
|
||||
public string Fingerprint => AdminKeys.ComputeFingerprint(_signer.PublicKey);
|
||||
|
||||
/// <summary>Clé conservée dans la puce de sécurité (TPM) de ce PC.</summary>
|
||||
public bool IsTpm { get; }
|
||||
|
||||
/// <summary>Fin de session (durée maximale), à l'heure du PC.</summary>
|
||||
public DateTimeOffset LocalExpiresAt => Session.ExpiresAt - Session.ClockOffset;
|
||||
|
||||
/// <summary>
|
||||
/// Libère d'abord la clé (la déconnexion n'est pas signée : elle n'en a pas besoin), puis ferme la
|
||||
/// session sur le serveur — au mieux, sans bloquer : injoignable, elle expirera d'elle-même.
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
_key.Dispose();
|
||||
using var timeout = new CancellationTokenSource(LogoutTimeout);
|
||||
try
|
||||
{
|
||||
await Session.LogoutAsync(timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Administration : déconnexion du serveur impossible ({ex.GetType().Name}) ; la session expirera d'elle-même.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>Résultat de la création d'une clé (et de son enregistrement auprès du serveur avec une invitation).</summary>
|
||||
/// <param name="Enrollment">Réponse du serveur ; null si aucune invitation n'a été utilisée (clé seulement) ou sans réponse.</param>
|
||||
/// <param name="EnrollmentUnconfirmed">
|
||||
/// Invitation envoyée mais réponse du serveur perdue (délai dépassé, coupure, erreur 5xx) : le serveur a
|
||||
/// peut-être enregistré la clé et consommé l'invitation. La clé est donc CONSERVÉE (message expliquant quoi faire).
|
||||
/// </param>
|
||||
/// <param name="IsTpm">Clé créée dans la puce de sécurité (TPM) de ce PC ; KeyPath est alors son fichier de référence.</param>
|
||||
public sealed record AdminKeyCreation(string KeyPath, string PublicKeyPath, string KeyId, string Fingerprint,
|
||||
EnrollmentResponse? Enrollment, string? EnrollmentUnconfirmed = null, bool IsTpm = false);
|
||||
|
||||
/// <summary>Clé fichier en mémoire, vue comme une clé « libérable » (même contrat que la clé TPM).</summary>
|
||||
internal sealed class FileKeySigner : IDisposableAdminSigner
|
||||
{
|
||||
private readonly EcdsaAdminSigner _inner;
|
||||
|
||||
public FileKeySigner(EcdsaAdminSigner inner) => _inner = inner;
|
||||
|
||||
public byte[] PublicKey => _inner.PublicKey;
|
||||
public string KeyId => _inner.KeyId;
|
||||
public ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default) => _inner.SignAsync(message, ct);
|
||||
public void Dispose() => _inner.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Création d'une clé d'administrateur sur ce PC — fichier chiffré par une phrase de passe, ou clé
|
||||
/// non exportable dans la puce TPM protégée par un code PIN — et, avec un code d'invitation,
|
||||
/// enregistrement auprès du serveur (preuve de possession). Si le serveur REFUSE (erreur 4xx), la clé
|
||||
/// est supprimée (fichiers, et clé de la puce) ; sans réponse sûre, elle est conservée.
|
||||
/// </summary>
|
||||
public sealed class AdminEnrollmentService
|
||||
{
|
||||
private readonly LauncherApiClient _client;
|
||||
private readonly IAdminTpm? _tpm;
|
||||
|
||||
/// <param name="tpm">Puce de sécurité de ce PC (null : clés TPM indisponibles).</param>
|
||||
public AdminEnrollmentService(LauncherApiClient client, IAdminTpm? tpm = null)
|
||||
{
|
||||
_client = client;
|
||||
_tpm = tpm;
|
||||
}
|
||||
|
||||
/// <param name="keyPath">Fichier .key (clé chiffrée) ou, avec <paramref name="useTpm"/>, fichier de référence .tpmkey.</param>
|
||||
/// <param name="secret">Phrase de passe du fichier (12 caractères au moins) ou code PIN de la clé TPM (6 au moins).</param>
|
||||
/// <param name="useTpm">Vrai : clé créée dans la puce de sécurité (TPM) de ce PC.</param>
|
||||
public async Task<AdminKeyCreation> CreateKeyAsync(string keyPath, string secret, string? invitationCode, string? keyLabel,
|
||||
CancellationToken ct = default, bool useTpm = false)
|
||||
{
|
||||
var minLength = useTpm ? AdminTpmKeyFile.MinPinLength : AdminKeyFile.MinPassphraseLength;
|
||||
if (secret.Length < minLength)
|
||||
throw new ArgumentException(useTpm
|
||||
? $"Le code PIN doit contenir au moins {minLength} caractères."
|
||||
: $"La phrase de passe doit contenir au moins {minLength} caractères.");
|
||||
string? code = null;
|
||||
if (!string.IsNullOrWhiteSpace(invitationCode))
|
||||
code = InvitationCodes.Normalize(invitationCode)
|
||||
?? throw new ArgumentException("Code d'invitation mal saisi : 32 caractères (lettres A à Z, chiffres 2 à 7), tirets facultatifs.");
|
||||
var label = string.IsNullOrWhiteSpace(keyLabel) ? null : ApiText.CleanLine(keyLabel, ApiText.MaxKeyLabelLength)
|
||||
?? throw new ArgumentException($"Nom du poste invalide ({ApiText.MaxKeyLabelLength} caractères au plus, sans caractère spécial invisible).");
|
||||
|
||||
var fullPath = Path.GetFullPath(keyPath);
|
||||
if (AdminKeyLocator.IsReserved(fullPath))
|
||||
throw new InvalidOperationException($"« {AdminKeyLocator.ReservedFileName} » est réservé à la clé de signature du launcher.");
|
||||
var extension = useTpm ? AdminTpmKeyFile.Extension : AdminKeyFile.PrivateKeyExtension;
|
||||
if (!fullPath.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException($"Le fichier de clé doit avoir l'extension {extension}.");
|
||||
var publicPath = Path.ChangeExtension(fullPath, AdminKeyFile.PublicKeyExtension);
|
||||
// Les deux types de clés partagent le même .pub : aucun des trois fichiers ne doit exister.
|
||||
var otherPath = Path.ChangeExtension(fullPath, useTpm ? AdminKeyFile.PrivateKeyExtension : AdminTpmKeyFile.Extension);
|
||||
if (File.Exists(fullPath) || File.Exists(publicPath) || File.Exists(otherPath))
|
||||
throw new IOException($"Une clé existe déjà à cet emplacement : {Path.ChangeExtension(fullPath, null)}. Choisissez un autre nom.");
|
||||
var tpm = useTpm ? _tpm ?? throw new InvalidOperationException("La puce de sécurité (TPM) n'est pas utilisable sur ce PC.") : null;
|
||||
|
||||
// Génération (et chiffrement, ou accès à la puce : lents) hors du fil de l'interface.
|
||||
string? tpmKeyName = null;
|
||||
IDisposableAdminSigner signer;
|
||||
if (tpm is not null)
|
||||
{
|
||||
var keyName = AdminTpmKeyFile.NewKeyName();
|
||||
signer = await BackgroundWork.RunAsync(() =>
|
||||
{
|
||||
var created = tpm.CreateKey(keyName, secret);
|
||||
try
|
||||
{
|
||||
AdminTpmKeyFile.Save(fullPath, keyName, created.PublicKey);
|
||||
return created;
|
||||
}
|
||||
catch
|
||||
{
|
||||
created.Dispose();
|
||||
DeleteTpmKey(tpm, keyName);
|
||||
throw;
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
tpmKeyName = keyName;
|
||||
}
|
||||
else
|
||||
{
|
||||
signer = await BackgroundWork.RunAsync<IDisposableAdminSigner>(() =>
|
||||
{
|
||||
var key = AdminKeyFile.Generate();
|
||||
try
|
||||
{
|
||||
AdminKeyFile.Save(key, fullPath, secret);
|
||||
return new FileKeySigner(new EcdsaAdminSigner(key));
|
||||
}
|
||||
catch
|
||||
{
|
||||
key.Dispose();
|
||||
throw;
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EnrollmentResponse? enrollment = null;
|
||||
string? unconfirmed = null;
|
||||
if (code is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
enrollment = await _client.EnrollAsync(code, signer, label, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (IsDefinitiveRefusal(ex) || ex is CryptographicException or ObjectDisposedException)
|
||||
{
|
||||
// Refus certain (invitation invalide, expirée, déjà utilisée…), ou signature de la preuve
|
||||
// impossible (puce bloquée…) avant tout envoi : rien n'a été enregistré.
|
||||
signer.Dispose();
|
||||
if (tpmKeyName is not null)
|
||||
{
|
||||
AdminTpmKeyFile.DeleteFiles(fullPath);
|
||||
DeleteTpmKey(tpm!, tpmKeyName);
|
||||
}
|
||||
else
|
||||
{
|
||||
TryDelete(fullPath);
|
||||
TryDelete(publicPath);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is ApiException or OperationCanceledException or HttpRequestException or IOException)
|
||||
{
|
||||
unconfirmed = $"Le serveur n'a pas confirmé l'enregistrement ({ApiErrorText.Describe(ex)}). " +
|
||||
"Votre clé est conservée : communiquez son empreinte à la personne qui vous a invité. " +
|
||||
"Si elle ne voit aucune clé en attente à votre nom, demandez-lui une nouvelle invitation.";
|
||||
}
|
||||
}
|
||||
var publicKey = signer.PublicKey;
|
||||
return new AdminKeyCreation(fullPath, publicPath, AdminKeys.ComputeKeyId(publicKey), AdminKeys.ComputeFingerprint(publicKey),
|
||||
enrollment, unconfirmed, IsTpm: tpmKeyName is not null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
signer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteTpmKey(IAdminTpm tpm, string keyName)
|
||||
{
|
||||
try
|
||||
{
|
||||
tpm.DeleteKey(keyName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Core.Utils.Log.Warn($"Administration : clé {keyName} non supprimée de la puce ({ex.Message}).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Le serveur a répondu par un refus (4xx), ou la requête n'a pas pu partir (horloge) : rien d'enregistré.</summary>
|
||||
private static bool IsDefinitiveRefusal(Exception ex) => ex is ApiException api &&
|
||||
((api.Status is >= 400 and < 500 && api.Code != "invalid_response") || api.Code == "clock_skew");
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// laissé en place : sans danger (clé chiffrée, jamais enregistrée sur le serveur)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>Clé d'administrateur trouvée sur ce PC.</summary>
|
||||
/// <param name="Path">Chemin complet du fichier .key (clé chiffrée) ou .tpmkey (référence d'une clé TPM).</param>
|
||||
/// <param name="Fingerprint">Empreinte (null si inconnue : fichier .pub absent).</param>
|
||||
/// <param name="IsTpm">Clé conservée dans la puce de sécurité (TPM) de ce PC : déverrouillée par un code PIN.</param>
|
||||
public sealed record AdminKeyEntry(string Path, string? KeyId, string? Fingerprint, bool IsTpm = false)
|
||||
{
|
||||
public string Name => System.IO.Path.GetFileNameWithoutExtension(Path);
|
||||
|
||||
public string Detail => Fingerprint is null ? "Empreinte inconnue (fichier .pub absent)" : $"Empreinte {Fingerprint}";
|
||||
|
||||
/// <summary>Libellé du secret demandé à la connexion.</summary>
|
||||
public string SecretLabel => IsTpm ? "Code PIN de la clé (puce TPM)" : "Phrase de passe de la clé";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recherche des clés d'administrateur : dossier %USERPROFILE%\.launcher-keys (celui de
|
||||
/// tools\admin-key.bat) et fichiers déjà utilisés. La clé de SIGNATURE DU LAUNCHER, rangée dans le
|
||||
/// même dossier, n'est jamais proposée : elle sert à autre chose et ne doit pas circuler.
|
||||
/// </summary>
|
||||
public static class AdminKeyLocator
|
||||
{
|
||||
/// <summary>Clé de signature des mises à jour du launcher (tools\keygen-launcher.bat).</summary>
|
||||
public const string ReservedFileName = "launcher-signing.key";
|
||||
|
||||
private const long MaxKeyFileBytes = 16 * 1024;
|
||||
|
||||
public static string DefaultDirectory =>
|
||||
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".launcher-keys");
|
||||
|
||||
/// <summary>
|
||||
/// Clé de signature du launcher, copies comprises (« launcher-signing - Copie.key »…) : ne doit jamais
|
||||
/// être déverrouillée par le menu d'administration. Chemin normalisé (« launcher-signing.key. »…).
|
||||
/// </summary>
|
||||
public static bool IsReserved(string path)
|
||||
{
|
||||
string name;
|
||||
try
|
||||
{
|
||||
name = System.IO.Path.GetFileName(System.IO.Path.GetFullPath(path)).TrimEnd('.', ' ');
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
return true; // chemin invalide : refusé
|
||||
}
|
||||
return name.StartsWith(System.IO.Path.GetFileNameWithoutExtension(ReservedFileName), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Clés du dossier <paramref name="directory"/> puis des chemins mémorisés (sans doublon).</summary>
|
||||
public static IReadOnlyList<AdminKeyEntry> Find(string directory, IEnumerable<string>? rememberedPaths = null)
|
||||
{
|
||||
var paths = new List<string>();
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
paths.AddRange(Directory.EnumerateFiles(directory, "*" + AdminKeyFile.PrivateKeyExtension, SearchOption.TopDirectoryOnly)
|
||||
.Concat(Directory.EnumerateFiles(directory, "*" + AdminTpmKeyFile.Extension, SearchOption.TopDirectoryOnly))
|
||||
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// dossier illisible : seulement les chemins mémorisés
|
||||
}
|
||||
paths.AddRange(rememberedPaths ?? Enumerable.Empty<string>());
|
||||
|
||||
var result = new List<AdminKeyEntry>();
|
||||
foreach (var path in paths)
|
||||
{
|
||||
// Describe normalise le chemin et ignore les chemins invalides (admin.json modifié à la main…).
|
||||
if (Describe(path) is not { } entry) continue;
|
||||
if (result.Any(e => string.Equals(e.Path, entry.Path, StringComparison.OrdinalIgnoreCase))) continue;
|
||||
result.Add(entry);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Décrit un fichier de clé chiffré ; null s'il n'en est pas un (ou s'il est réservé).</summary>
|
||||
public static AdminKeyEntry? Describe(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var full = System.IO.Path.GetFullPath(path);
|
||||
var info = new FileInfo(full);
|
||||
if (!info.Exists || info.Length > MaxKeyFileBytes || IsReserved(full)) return null;
|
||||
|
||||
// Référence d'une clé TPM : empreinte calculée depuis la clé publique qu'elle contient
|
||||
// (comparée à celle de la puce à la connexion).
|
||||
if (AdminTpmKeyFile.IsTpmKeyPath(full))
|
||||
{
|
||||
try
|
||||
{
|
||||
var reference = AdminTpmKeyFile.Load(full);
|
||||
return new AdminKeyEntry(full, AdminKeys.ComputeKeyId(reference.PublicKey),
|
||||
AdminKeys.ComputeFingerprint(reference.PublicKey), IsTpm: true);
|
||||
}
|
||||
catch (System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Clé fichier : reconnue à son contenu (extension .key conseillée, mais pas exigée : admin-key.bat --out).
|
||||
if (!File.ReadAllText(full).Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----", StringComparison.Ordinal)) return null;
|
||||
|
||||
string? keyId = null, fingerprint = null;
|
||||
var publicPath = System.IO.Path.ChangeExtension(full, AdminKeyFile.PublicKeyExtension);
|
||||
if (File.Exists(publicPath) && new FileInfo(publicPath).Length <= MaxKeyFileBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var spki = AdminKeys.ParsePublicKey(File.ReadAllText(publicPath));
|
||||
keyId = AdminKeys.ComputeKeyId(spki);
|
||||
fingerprint = AdminKeys.ComputeFingerprint(spki);
|
||||
}
|
||||
catch (System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
// .pub illisible : empreinte inconnue (la clé reste utilisable)
|
||||
}
|
||||
}
|
||||
return new AdminKeyEntry(full, keyId, fingerprint);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emplacement libre pour une nouvelle clé : admin, sinon admin-2, admin-3… Le nom est libre pour
|
||||
/// les deux types de clés (.key, .tpmkey) et pour le .pub qu'ils partageraient.
|
||||
/// </summary>
|
||||
/// <param name="tpm">Vrai : chemin d'un fichier de référence de clé TPM (.tpmkey).</param>
|
||||
public static string SuggestNewKeyPath(string directory, bool tpm = false)
|
||||
{
|
||||
var extension = tpm ? AdminTpmKeyFile.Extension : AdminKeyFile.PrivateKeyExtension;
|
||||
for (var i = 1; i < 1000; i++)
|
||||
{
|
||||
var name = System.IO.Path.Combine(directory, i == 1 ? "admin" : $"admin-{i}");
|
||||
if (!File.Exists(name + AdminKeyFile.PrivateKeyExtension) && !File.Exists(name + AdminTpmKeyFile.Extension) &&
|
||||
!File.Exists(name + AdminKeyFile.PublicKeyExtension))
|
||||
return name + extension;
|
||||
}
|
||||
return System.IO.Path.Combine(directory, $"admin-{Guid.NewGuid():N}{extension}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Connexion : ouvre la clé (fichier chiffré déverrouillé en arrière-plan, ou clé de la puce TPM avec
|
||||
/// son code PIN), puis défi signé auprès du serveur.
|
||||
/// </summary>
|
||||
public sealed class AdminLoginService
|
||||
{
|
||||
private readonly LauncherApiClient _client;
|
||||
private readonly string _clientName;
|
||||
private readonly IAdminTpm? _tpm;
|
||||
|
||||
/// <param name="clientName">Description du poste pour le journal d'audit, ex. « GameLauncher 1.4.0 (PC-BUREAU) ».</param>
|
||||
/// <param name="tpm">Puce de sécurité de ce PC (null : clés TPM indisponibles).</param>
|
||||
public AdminLoginService(LauncherApiClient client, string clientName, IAdminTpm? tpm = null)
|
||||
{
|
||||
_client = client;
|
||||
_clientName = clientName;
|
||||
_tpm = tpm;
|
||||
}
|
||||
|
||||
/// <param name="keyPath">Fichier .key (clé chiffrée) ou .tpmkey (référence d'une clé TPM).</param>
|
||||
/// <param name="secret">Phrase de passe du fichier, ou code PIN de la clé TPM.</param>
|
||||
public async Task<AdminConnection> LoginAsync(string keyPath, string secret, CancellationToken ct = default)
|
||||
{
|
||||
if (AdminKeyLocator.IsReserved(keyPath))
|
||||
throw new InvalidOperationException("Ce fichier est la clé de signature du launcher : utilisez votre clé d'administrateur.");
|
||||
keyPath = Path.GetFullPath(keyPath);
|
||||
var isTpm = AdminTpmKeyFile.IsTpmKeyPath(keyPath);
|
||||
if (string.IsNullOrEmpty(secret))
|
||||
throw new ArgumentException(isTpm ? "Saisissez le code PIN de la clé." : "Saisissez la phrase de passe de la clé.");
|
||||
|
||||
IAdminSigner signer;
|
||||
IDisposable key;
|
||||
if (isTpm)
|
||||
{
|
||||
var tpm = _tpm ?? throw new InvalidOperationException("La puce de sécurité (TPM) n'est pas utilisable sur ce PC.");
|
||||
var reference = AdminTpmKeyFile.Load(keyPath);
|
||||
var opened = await BackgroundWork.RunAsync(() => tpm.OpenKey(reference.KeyName, reference.PublicKey, secret), ct)
|
||||
.ConfigureAwait(false);
|
||||
// Le fichier de référence ne doit pas pouvoir faire utiliser une AUTRE clé que celle annoncée
|
||||
// (déjà vérifié par la puce avant le code PIN ; contrôlé de nouveau ici).
|
||||
if (!opened.PublicKey.AsSpan().SequenceEqual(reference.PublicKey))
|
||||
{
|
||||
opened.Dispose();
|
||||
throw new CryptographicException("La clé de la puce ne correspond pas à ce fichier de référence : fichier modifié ?");
|
||||
}
|
||||
signer = opened;
|
||||
key = opened;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Déchiffrement volontairement lent (PBKDF2, 600 000 itérations) : hors du fil de l'interface.
|
||||
var opened = await BackgroundWork.RunAsync(() => new EcdsaAdminSigner(AdminKeyFile.Load(keyPath, secret)), ct).ConfigureAwait(false);
|
||||
signer = opened;
|
||||
key = opened;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var session = await _client.LoginAsync(signer, _clientName, ct).ConfigureAwait(false);
|
||||
return new AdminConnection(_client, session, signer, key, keyPath, isTpm);
|
||||
}
|
||||
catch
|
||||
{
|
||||
key.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Surveille la session ouverte :
|
||||
/// • verrouillage après <see cref="LockAfter"/> sans activité dans la fenêtre d'administration ;
|
||||
/// • verrouillage à la fin de la durée maximale de la session (8 h) ;
|
||||
/// • tant que l'administrateur est actif, la session est prolongée sur le serveur (toutes les
|
||||
/// <see cref="KeepAliveEvery"/>) ; si le serveur la refuse (expirée, révoquée), verrouillage.
|
||||
/// </summary>
|
||||
public sealed class AdminSessionGuard : IDisposable
|
||||
{
|
||||
public static readonly TimeSpan DefaultLockAfter = TimeSpan.FromMinutes(10);
|
||||
public static readonly TimeSpan KeepAliveEvery = TimeSpan.FromMinutes(4);
|
||||
private static readonly TimeSpan TickEvery = TimeSpan.FromSeconds(15);
|
||||
|
||||
private readonly AdminConnection _connection;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ITimer? _timer;
|
||||
private DateTimeOffset _lastActivity;
|
||||
private DateTimeOffset _lastKeepAlive;
|
||||
private bool _refreshing;
|
||||
private bool _locked;
|
||||
private bool _disposed;
|
||||
|
||||
public AdminSessionGuard(AdminConnection connection, TimeProvider time, IUiDispatcher ui, TimeSpan? lockAfter = null,
|
||||
bool startTimer = true)
|
||||
{
|
||||
_connection = connection;
|
||||
_time = time;
|
||||
LockAfter = lockAfter ?? DefaultLockAfter;
|
||||
_lastActivity = _lastKeepAlive = time.GetUtcNow();
|
||||
if (startTimer) _timer = time.CreateTimer(_ => ui.Post(() => _ = TickAsync()), null, TickEvery, TickEvery);
|
||||
}
|
||||
|
||||
public TimeSpan LockAfter { get; }
|
||||
|
||||
/// <summary>Verrouillage demandé (le message explique pourquoi) ; attendu par <see cref="TickAsync"/>.</summary>
|
||||
public Func<string, Task>? LockRequested { get; set; }
|
||||
|
||||
/// <summary>Le temps restant a changé (affichage).</summary>
|
||||
public event Action? Changed;
|
||||
|
||||
/// <summary>Moment du verrouillage prévu (inactivité ou fin de session).</summary>
|
||||
public DateTimeOffset LockAt
|
||||
{
|
||||
get
|
||||
{
|
||||
var idle = _lastActivity + LockAfter;
|
||||
return idle < _connection.LocalExpiresAt ? idle : _connection.LocalExpiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
public TimeSpan Remaining
|
||||
{
|
||||
get
|
||||
{
|
||||
var remaining = LockAt - _time.GetUtcNow();
|
||||
return remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Activité de l'administrateur (souris, clavier) dans la fenêtre d'administration.</summary>
|
||||
public void NotifyActivity()
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
if (now - _lastActivity < TimeSpan.FromSeconds(1)) return;
|
||||
_lastActivity = now;
|
||||
}
|
||||
|
||||
/// <summary>Vérification périodique (minuterie) ; publique pour les tests.</summary>
|
||||
public async Task TickAsync()
|
||||
{
|
||||
if (_locked || _disposed) return;
|
||||
var now = _time.GetUtcNow();
|
||||
if (now >= _connection.LocalExpiresAt)
|
||||
{
|
||||
await LockAsync("La session a atteint sa durée maximale : reconnectez-vous.");
|
||||
return;
|
||||
}
|
||||
if (now - _lastActivity >= LockAfter)
|
||||
{
|
||||
await LockAsync($"Verrouillé après {LockAfter.TotalMinutes:0} minutes d'inactivité.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_refreshing && now - _lastKeepAlive >= KeepAliveEvery && _lastActivity > _lastKeepAlive)
|
||||
{
|
||||
_refreshing = true;
|
||||
try
|
||||
{
|
||||
await _connection.Session.RefreshAsync();
|
||||
_lastKeepAlive = _time.GetUtcNow();
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
|
||||
{
|
||||
await LockAsync("Session expirée ou révoquée sur le serveur : reconnectez-vous.");
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Serveur momentanément injoignable, délai dépassé… : nouvel essai au prochain passage.
|
||||
// L'inactivité et la durée maximale restent contrôlées localement.
|
||||
Log.Warn($"Administration : prolongation de la session impossible ({ex.GetType().Name}).");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
}
|
||||
}
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
private async Task LockAsync(string reason)
|
||||
{
|
||||
if (_locked) return;
|
||||
_locked = true;
|
||||
if (LockRequested is { } handler) await handler(reason);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
_timer?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Préférences du menu d'administration sur ce PC (%AppData%\…\GameLauncher\admin.json) : chemins
|
||||
/// des fichiers de clé utilisés. Aucun secret (les clés restent chiffrées dans leurs fichiers).
|
||||
/// </summary>
|
||||
public sealed class AdminSettings
|
||||
{
|
||||
public const string FileName = "admin.json";
|
||||
private const int MaxRememberedKeys = 10;
|
||||
|
||||
public string? LastKeyPath { get; set; }
|
||||
|
||||
public List<string> KeyPaths { get; set; } = new();
|
||||
|
||||
public static AdminSettings Load(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path) || new FileInfo(path).Length > 64 * 1024) return new AdminSettings();
|
||||
var settings = JsonSerializer.Deserialize<AdminSettings>(File.ReadAllText(path), JsonDefaults.Options) ?? new AdminSettings();
|
||||
settings.KeyPaths = (settings.KeyPaths ?? new()).Where(p => !string.IsNullOrWhiteSpace(p)).Take(MaxRememberedKeys).ToList();
|
||||
return settings;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
Log.Warn($"Préférences d'administration illisibles ({path}) : {ex.Message}");
|
||||
return new AdminSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Oublie une clé (supprimée).</summary>
|
||||
public void Forget(string keyPath)
|
||||
{
|
||||
KeyPaths.RemoveAll(p => string.Equals(p, keyPath, StringComparison.OrdinalIgnoreCase));
|
||||
if (string.Equals(LastKeyPath, keyPath, StringComparison.OrdinalIgnoreCase)) LastKeyPath = null;
|
||||
}
|
||||
|
||||
/// <summary>Mémorise la clé utilisée (en tête de liste).</summary>
|
||||
public void Remember(string keyPath)
|
||||
{
|
||||
var full = Path.GetFullPath(keyPath);
|
||||
LastKeyPath = full;
|
||||
KeyPaths.RemoveAll(p => string.Equals(p, full, StringComparison.OrdinalIgnoreCase));
|
||||
KeyPaths.Insert(0, full);
|
||||
if (KeyPaths.Count > MaxRememberedKeys) KeyPaths.RemoveRange(MaxRememberedKeys, KeyPaths.Count - MaxRememberedKeys);
|
||||
}
|
||||
|
||||
public void Save(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
|
||||
FileUtil.WriteAllTextAtomic(path, JsonSerializer.Serialize(this, JsonDefaults.Options));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"Préférences d'administration non enregistrées ({path}) : {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>Libellés affichés (français).</summary>
|
||||
public static class AdminTexts
|
||||
{
|
||||
public static string Permission(string permission) => permission switch
|
||||
{
|
||||
AdminPermissions.AdminsManage => "Gestion des administrateurs",
|
||||
AdminPermissions.AuditRead => "Journal d'audit",
|
||||
AdminPermissions.GamesEdit => "Informations des jeux",
|
||||
AdminPermissions.ChangelogEdit => "Notes de version",
|
||||
AdminPermissions.ReleasesPublish => "Publication des versions",
|
||||
_ => permission
|
||||
};
|
||||
|
||||
public static string Role(string? role) => AdminRoles.DisplayName(role);
|
||||
|
||||
/// <summary>Initiales pour l'avatar : « Nicolas Deville » → « ND ».</summary>
|
||||
public static string Initials(string? name)
|
||||
{
|
||||
var parts = (name ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var initials = string.Concat(parts.Take(2).Select(p => char.ToUpperInvariant(p[0])));
|
||||
return initials.Length == 0 ? "?" : initials;
|
||||
}
|
||||
|
||||
/// <summary>Durée lisible : « 9 min », « moins d'une minute », « 2 h 05 ».</summary>
|
||||
public static string Duration(TimeSpan duration)
|
||||
{
|
||||
if (duration < TimeSpan.FromMinutes(1)) return "moins d'une minute";
|
||||
if (duration < TimeSpan.FromHours(1)) return $"{(int)duration.TotalMinutes} min";
|
||||
return $"{(int)duration.TotalHours} h {duration.Minutes:00}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>Clé conservée dans la puce TPM, telle que décrite par son fichier de référence.</summary>
|
||||
/// <param name="KeyName">Nom de la clé dans la puce de ce PC.</param>
|
||||
/// <param name="PublicKey">Clé publique (SPKI DER) attendue : comparée à celle de la puce à l'ouverture.</param>
|
||||
public sealed record AdminTpmKeyReference(string KeyName, byte[] PublicKey);
|
||||
|
||||
/// <summary>
|
||||
/// Fichier de référence d'une clé TPM (« admin.tpmkey ») : il ne contient AUCUN secret, seulement le
|
||||
/// nom de la clé dans la puce et sa clé publique. Copié sur un autre PC, il ne sert à rien. Un fichier
|
||||
/// .pub est écrit à côté, comme pour une clé fichier (déclaration du premier propriétaire : add-owner).
|
||||
/// </summary>
|
||||
public static partial class AdminTpmKeyFile
|
||||
{
|
||||
public const string Extension = ".tpmkey";
|
||||
|
||||
/// <summary>
|
||||
/// Longueur minimale du code PIN. Plus courte que la phrase de passe d'un fichier (12) : le code
|
||||
/// ne peut être essayé que sur la puce elle-même, qui se bloque après quelques erreurs.
|
||||
/// </summary>
|
||||
public const int MinPinLength = 6;
|
||||
|
||||
/// <summary>Préfixe des noms de clés créées par le launcher (seules ces clés peuvent être ouvertes).</summary>
|
||||
public const string KeyNamePrefix = "GameLauncher.Admin.";
|
||||
|
||||
private const string Format = "gamelauncher-admin-tpm-key";
|
||||
private const int MaxFileBytes = 16 * 1024;
|
||||
|
||||
[GeneratedRegex(@"\AGameLauncher\.Admin\.[0-9a-f]{32}\z")]
|
||||
private static partial Regex KeyNameRegex();
|
||||
|
||||
public static string NewKeyName() => KeyNamePrefix + Guid.NewGuid().ToString("N");
|
||||
|
||||
public static bool IsValidKeyName(string? keyName) => keyName is not null && KeyNameRegex().IsMatch(keyName);
|
||||
|
||||
public static bool IsTpmKeyPath(string path) => path.EndsWith(Extension, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Écrit le fichier de référence et le .pub, sans jamais écraser un fichier existant.</summary>
|
||||
/// <returns>Chemin du fichier .pub.</returns>
|
||||
public static string Save(string path, string keyName, byte[] publicKey)
|
||||
{
|
||||
if (!IsValidKeyName(keyName)) throw new ArgumentException("Nom de clé TPM invalide.", nameof(keyName));
|
||||
AdminKeys.ParsePublicKey(Convert.ToBase64String(publicKey)); // P-256 attendue
|
||||
if (!IsTpmKeyPath(path)) throw new ArgumentException($"Le fichier de référence doit avoir l'extension {Extension}.");
|
||||
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
|
||||
var json = new JsonObject
|
||||
{
|
||||
["format"] = Format,
|
||||
["version"] = 1,
|
||||
["provider"] = "Microsoft Platform Crypto Provider",
|
||||
["keyName"] = keyName,
|
||||
["publicKey"] = Convert.ToBase64String(publicKey)
|
||||
}.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||
WriteNew(path, json);
|
||||
|
||||
var publicPath = Path.ChangeExtension(path, AdminKeyFile.PublicKeyExtension);
|
||||
try
|
||||
{
|
||||
WriteNew(publicPath, AdminKeys.ToPem(publicKey));
|
||||
}
|
||||
catch
|
||||
{
|
||||
TryDelete(path);
|
||||
throw;
|
||||
}
|
||||
return publicPath;
|
||||
}
|
||||
|
||||
/// <summary>Lit et contrôle un fichier de référence (<see cref="CryptographicException"/> s'il est invalide).</summary>
|
||||
public static AdminTpmKeyReference Load(string path)
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists) throw new FileNotFoundException($"Clé introuvable : {path}", path);
|
||||
if (info.Length > MaxFileBytes) throw new CryptographicException("Fichier de clé TPM inattendu (trop volumineux).");
|
||||
|
||||
try
|
||||
{
|
||||
var root = JsonNode.Parse(File.ReadAllText(path)) as JsonObject
|
||||
?? throw new CryptographicException("Fichier de clé TPM illisible.");
|
||||
if (root["format"]?.GetValue<string>() != Format || root["version"]?.GetValue<int>() != 1)
|
||||
throw new CryptographicException("Ce fichier n'est pas une clé TPM du launcher.");
|
||||
var keyName = root["keyName"]?.GetValue<string>();
|
||||
if (!IsValidKeyName(keyName)) throw new CryptographicException("Fichier de clé TPM invalide (nom de clé).");
|
||||
var publicKey = AdminKeys.ParsePublicKey(root["publicKey"]?.GetValue<string>() ?? "");
|
||||
return new AdminTpmKeyReference(keyName!, publicKey);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException or ArgumentException)
|
||||
{
|
||||
throw new CryptographicException("Fichier de clé TPM illisible.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Supprime le fichier de référence et son .pub (clé refusée, création annulée, suppression demandée).</summary>
|
||||
/// <returns>Les fichiers qui n'ont pas pu être supprimés (à supprimer à la main).</returns>
|
||||
internal static IReadOnlyList<string> DeleteFiles(string path)
|
||||
{
|
||||
var left = new List<string>();
|
||||
foreach (var file in new[] { path, Path.ChangeExtension(path, AdminKeyFile.PublicKeyExtension) })
|
||||
if (!TryDelete(file)) left.Add(file);
|
||||
return left;
|
||||
}
|
||||
|
||||
private static void WriteNew(string path, string text)
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
||||
using var writer = new StreamWriter(stream);
|
||||
writer.Write(text);
|
||||
}
|
||||
|
||||
private static bool TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return false; // laissé en place : sans danger (aucun secret dans ces fichiers)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Runtime.ExceptionServices;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Travail lent (chiffrement de clé) hors du fil de l'interface. L'erreur éventuelle est capturée
|
||||
/// DANS le travail puis relancée ici, dans la méthode asynchrone appelante : une erreur attendue
|
||||
/// (phrase de passe incorrecte…) ne traverse pas Task.Run, ce qui évite que le débogueur de Visual
|
||||
/// Studio ne s'arrête sur une « exception non gérée par l'utilisateur » alors qu'elle est bien gérée
|
||||
/// (message affiché dans le menu d'administration).
|
||||
/// </summary>
|
||||
internal static class BackgroundWork
|
||||
{
|
||||
public static async Task<T> RunAsync<T>(Func<T> work, CancellationToken ct = default)
|
||||
{
|
||||
ExceptionDispatchInfo? failure = null;
|
||||
var result = await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return work();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failure = ExceptionDispatchInfo.Capture(ex);
|
||||
return default;
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
failure?.Throw();
|
||||
return result!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Services;
|
||||
|
||||
/// <summary>Résultat de la vérification de la puce de sécurité (TPM) de ce PC.</summary>
|
||||
/// <param name="IsAvailable">Vrai si une clé ECDSA P-256 a pu être créée, utilisée puis supprimée dans la puce.</param>
|
||||
/// <param name="Message">Explication affichée (disponible, ou raison de l'indisponibilité).</param>
|
||||
public sealed record TpmAvailability(bool IsAvailable, string Message)
|
||||
{
|
||||
public static readonly TpmAvailability Unsupported =
|
||||
new(false, "Puce de sécurité (TPM) non prise en charge par ce launcher sur ce système.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Protection anti-essais de la puce (compteur COMMUN à toute la puce : codes PIN du launcher, de
|
||||
/// Windows Hello…). Au-delà de <see cref="MaxTries"/> erreurs, la puce refuse tout code ; elle
|
||||
/// « oublie » une erreur à chaque <see cref="RecoveryInterval"/> écoulé, PC allumé.
|
||||
/// </summary>
|
||||
public sealed record TpmLockoutInfo(int Failures, int MaxTries, TimeSpan RecoveryInterval, bool InLockout)
|
||||
{
|
||||
public bool IsLockedOut => InLockout || (MaxTries > 0 && Failures >= MaxTries);
|
||||
|
||||
/// <summary>Codes faux encore permis avant le blocage.</summary>
|
||||
public int RemainingTries => Math.Max(0, MaxTries - Failures);
|
||||
|
||||
/// <summary>Faux : la puce ne se débloque pas seule (un administrateur du PC doit la réinitialiser).</summary>
|
||||
public bool HealsWithTime => RecoveryInterval > TimeSpan.Zero;
|
||||
|
||||
/// <summary>Attente MAXIMALE avant que la puce accepte de nouveau un code (estimation, PC allumé).</summary>
|
||||
public TimeSpan MaxWait => !IsLockedOut || !HealsWithTime
|
||||
? TimeSpan.Zero
|
||||
: RecoveryInterval * Math.Max(1, Failures - MaxTries + 1);
|
||||
}
|
||||
|
||||
/// <summary>Code PIN refusé par la puce, ou puce bloquée par sa protection anti-essais.</summary>
|
||||
public sealed class TpmPinException : CryptographicException
|
||||
{
|
||||
public TpmPinException(string message, bool isLockedOut, Exception? inner = null) : base(message, inner) =>
|
||||
IsLockedOut = isLockedOut;
|
||||
|
||||
public bool IsLockedOut { get; }
|
||||
}
|
||||
|
||||
/// <summary>Clé ouverte (dans la puce ou en mémoire) : elle signe, et se libère à la fermeture de la session.</summary>
|
||||
public interface IDisposableAdminSigner : IAdminSigner, IDisposable
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clés d'administrateur conservées dans la puce de sécurité (TPM 2.0) de ce PC : la clé privée est
|
||||
/// CRÉÉE dans la puce et n'en sort jamais (non exportable) ; la puce signe elle-même. Chaque usage
|
||||
/// exige le code PIN de la clé, vérifié par la puce (blocage temporaire après plusieurs erreurs).
|
||||
/// Implémentation Windows dans le launcher (Microsoft Platform Crypto Provider) ; les méthodes sont
|
||||
/// lentes (accès à la puce) et sont appelées hors du fil de l'interface.
|
||||
/// </summary>
|
||||
public interface IAdminTpm
|
||||
{
|
||||
/// <summary>Vérifie RÉELLEMENT la puce : création, signature et suppression d'une clé de test.</summary>
|
||||
TpmAvailability Check();
|
||||
|
||||
/// <summary>Crée une clé (non exportable) protégée par <paramref name="pin"/> et l'ouvre.</summary>
|
||||
/// <param name="keyName">Nom de la clé dans la puce (<see cref="AdminTpmKeyFile.NewKeyName"/>).</param>
|
||||
IDisposableAdminSigner CreateKey(string keyName, string pin);
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre une clé existante, vérifie qu'elle a bien la clé publique attendue (AVANT tout essai du
|
||||
/// code, qui consommerait un essai de la protection anti-essais), puis vérifie le code PIN
|
||||
/// (signature de test). <see cref="System.Security.Cryptography.CryptographicException"/> avec un
|
||||
/// message clair si la clé diffère, si le code est faux, si la puce est bloquée ou si la clé
|
||||
/// n'existe pas sur ce PC.
|
||||
/// </summary>
|
||||
IDisposableAdminSigner OpenKey(string keyName, byte[] expectedPublicKey, string pin);
|
||||
|
||||
/// <summary>Supprime la clé de la puce (sans erreur si elle n'existe plus).</summary>
|
||||
void DeleteKey(string keyName);
|
||||
|
||||
/// <summary>
|
||||
/// État de la protection anti-essais (lu dans la puce sans rien modifier), ou null s'il n'est pas
|
||||
/// lisible sur ce PC (Windows peut réserver cette lecture aux administrateurs).
|
||||
/// </summary>
|
||||
TpmLockoutInfo? GetLockoutInfo();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>Dépendances de la fenêtre d'administration (fournies par le launcher).</summary>
|
||||
public sealed class AdminEnvironment
|
||||
{
|
||||
/// <summary>Client de l'API (adresse intégrée à l'exe).</summary>
|
||||
public required LauncherApiClient Client { get; init; }
|
||||
|
||||
public required ModuleCatalog Modules { get; init; }
|
||||
|
||||
public required IReadOnlyList<LauncherConfig> Games { get; init; }
|
||||
|
||||
public required IModuleDialogs Dialogs { get; init; }
|
||||
|
||||
public required IUiDispatcher Ui { get; init; }
|
||||
|
||||
/// <summary>Fichier des préférences d'administration (%AppData%\…\GameLauncher\admin.json).</summary>
|
||||
public required string SettingsPath { get; init; }
|
||||
|
||||
public required string LauncherVersion { get; init; }
|
||||
|
||||
public TimeProvider Time { get; init; } = TimeProvider.System;
|
||||
|
||||
/// <summary>Puce de sécurité (TPM) de ce PC ; null : clés TPM non proposées.</summary>
|
||||
public IAdminTpm? Tpm { get; init; }
|
||||
|
||||
/// <summary>Dossier des clés proposé par défaut (%USERPROFILE%\.launcher-keys).</summary>
|
||||
public string KeysDirectory { get; init; } = AdminKeyLocator.DefaultDirectory;
|
||||
|
||||
/// <summary>Verrouillage après inactivité (10 min par défaut).</summary>
|
||||
public TimeSpan? LockAfter { get; init; }
|
||||
|
||||
/// <summary>Faux dans les tests : la surveillance de session est déclenchée à la main.</summary>
|
||||
public bool StartTimers { get; init; } = true;
|
||||
|
||||
/// <summary>Poste, pour le journal d'audit du serveur.</summary>
|
||||
public string ClientName => $"GameLauncher {LauncherVersion} ({System.Environment.MachineName})";
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Fenêtre d'administration : un seul écran à la fois — connexion (<see cref="LoginViewModel"/>),
|
||||
/// création de clé / invitation (<see cref="EnrollViewModel"/>) ou espace d'administration
|
||||
/// (<see cref="AdminWorkspaceViewModel"/>). Verrouiller ferme la session et libère la clé.
|
||||
/// </summary>
|
||||
public sealed class AdminShellViewModel : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
private object _screen;
|
||||
private AdminWorkspaceViewModel? _workspace;
|
||||
private bool _disposed;
|
||||
private Task<TpmAvailability>? _tpmCheck;
|
||||
|
||||
public AdminShellViewModel(AdminEnvironment environment)
|
||||
{
|
||||
Environment = environment;
|
||||
Settings = AdminSettings.Load(environment.SettingsPath);
|
||||
_screen = new LoginViewModel(this, null, null);
|
||||
}
|
||||
|
||||
public AdminEnvironment Environment { get; }
|
||||
|
||||
internal AdminSettings Settings { get; }
|
||||
|
||||
/// <summary>Écran affiché (le launcher choisit la vue selon son type).</summary>
|
||||
public object Screen
|
||||
{
|
||||
get => _screen;
|
||||
private set
|
||||
{
|
||||
var previous = _screen;
|
||||
if (!SetProperty(ref _screen, value)) return;
|
||||
OnPropertyChanged(nameof(IsLoggedIn));
|
||||
if (previous is LoginViewModel login) login.Dispose(); // arrêt de ses minuteries (décompte de la puce)
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsLoggedIn => Screen is AdminWorkspaceViewModel;
|
||||
|
||||
public string ServerHost => Environment.Client.BaseUrl.Host;
|
||||
|
||||
public string WindowTitle => $"Administration — {ServerHost}";
|
||||
|
||||
/// <summary>Souris ou clavier dans la fenêtre : repousse le verrouillage automatique.</summary>
|
||||
public void NotifyActivity() => _workspace?.Guard.NotifyActivity();
|
||||
|
||||
public void ShowLogin(string? status = null, string? selectKeyPath = null)
|
||||
{
|
||||
if (_disposed) return;
|
||||
Screen = new LoginViewModel(this, status, selectKeyPath);
|
||||
}
|
||||
|
||||
/// <summary>Vérification de la puce TPM (une fois par ouverture de la fenêtre, hors du fil de l'interface).</summary>
|
||||
internal Task<TpmAvailability> CheckTpmAsync() => _tpmCheck ??= RunTpmCheckAsync();
|
||||
|
||||
private async Task<TpmAvailability> RunTpmCheckAsync()
|
||||
{
|
||||
if (Environment.Tpm is not { } tpm) return TpmAvailability.Unsupported;
|
||||
try
|
||||
{
|
||||
var availability = await BackgroundWork.RunAsync(tpm.Check);
|
||||
Log.Info($"Administration : puce TPM {(availability.IsAvailable ? "disponible" : "indisponible")} ({availability.Message})");
|
||||
return availability;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Administration : vérification de la puce TPM impossible ({ex.Message}).");
|
||||
return new TpmAvailability(false, $"Puce de sécurité (TPM) inutilisable : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void ShowEnroll()
|
||||
{
|
||||
if (_disposed) return;
|
||||
Screen = new EnrollViewModel(this);
|
||||
}
|
||||
|
||||
internal async Task OpenWorkspaceAsync(AdminConnection connection)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
return;
|
||||
}
|
||||
AdminWorkspaceViewModel workspace;
|
||||
try
|
||||
{
|
||||
workspace = new AdminWorkspaceViewModel(this, connection);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await connection.DisposeAsync(); // clé effacée, session fermée
|
||||
throw;
|
||||
}
|
||||
_workspace = workspace;
|
||||
Screen = workspace;
|
||||
await workspace.StartAsync();
|
||||
}
|
||||
|
||||
/// <summary>Ferme la session (serveur + clé en mémoire) et revient à l'écran de connexion.</summary>
|
||||
public Task LockAsync(string? reason) => _workspace is { } workspace ? LockAsync(workspace, reason) : Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Verrouillage demandé par un espace donné (minuterie, session perdue) : sans effet s'il n'est plus
|
||||
/// l'espace courant — une requête tardive d'une session fermée ne doit pas verrouiller la suivante.
|
||||
/// </summary>
|
||||
internal async Task LockAsync(AdminWorkspaceViewModel source, string? reason)
|
||||
{
|
||||
if (!ReferenceEquals(_workspace, source)) return;
|
||||
_workspace = null;
|
||||
try
|
||||
{
|
||||
if (!_disposed) ShowLogin(reason, source.KeyPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await source.DisposeAsync(); // dans tous les cas : clé effacée, session fermée
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fermeture de la fenêtre : déconnexion.</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
(_screen as LoginViewModel)?.Dispose();
|
||||
var workspace = _workspace;
|
||||
_workspace = null;
|
||||
if (workspace is not null) await workspace.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>Page du menu d'administration dans la navigation (ViewModel créé à la première ouverture).</summary>
|
||||
public sealed class AdminPanelItem : ObservableObject
|
||||
{
|
||||
private AdminPanelViewModel? _viewModel;
|
||||
|
||||
public AdminPanelItem(AdminPanelDescriptor descriptor) => Descriptor = descriptor;
|
||||
|
||||
public AdminPanelDescriptor Descriptor { get; }
|
||||
public string Title => Descriptor.Title;
|
||||
public string Description => Descriptor.Description;
|
||||
public string IconKey => Descriptor.IconKey;
|
||||
|
||||
public AdminPanelViewModel? ViewModel
|
||||
{
|
||||
get => _viewModel;
|
||||
internal set => SetProperty(ref _viewModel, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Espace d'administration (connecté) : navigation entre les pages des modules autorisées par les
|
||||
/// permissions, identité, temps restant avant verrouillage, bouton « Verrouiller ».
|
||||
/// </summary>
|
||||
public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, IAsyncDisposable
|
||||
{
|
||||
private readonly AdminShellViewModel _shell;
|
||||
private readonly AdminConnection _connection;
|
||||
private AdminPanelItem? _selectedPanel;
|
||||
private Task _activation = Task.CompletedTask;
|
||||
private bool _disposed;
|
||||
|
||||
internal AdminWorkspaceViewModel(AdminShellViewModel shell, AdminConnection connection)
|
||||
{
|
||||
_shell = shell;
|
||||
_connection = connection;
|
||||
var environment = shell.Environment;
|
||||
|
||||
Guard = new AdminSessionGuard(connection, environment.Time, environment.Ui, environment.LockAfter, environment.StartTimers);
|
||||
Guard.LockRequested = reason => shell.LockAsync(this, reason);
|
||||
Guard.Changed += () => OnPropertyChanged(nameof(SessionText));
|
||||
|
||||
Panels = environment.Modules.AdminPanelsFor(Session.Profile.Permissions);
|
||||
PanelItems = new ObservableCollection<AdminPanelItem>(Panels.Select(p => new AdminPanelItem(p)));
|
||||
LockCommand = new AsyncRelayCommand(() => shell.LockAsync(this, "Session verrouillée."));
|
||||
}
|
||||
|
||||
internal AdminSessionGuard Guard { get; }
|
||||
|
||||
internal string KeyPath => _connection.KeyPath;
|
||||
|
||||
// ------------------------------------------------------------------ Affichage
|
||||
|
||||
public ObservableCollection<AdminPanelItem> PanelItems { get; }
|
||||
|
||||
public AdminPanelItem? SelectedPanel
|
||||
{
|
||||
get => _selectedPanel;
|
||||
set
|
||||
{
|
||||
if (value is null || ReferenceEquals(value, _selectedPanel)) return;
|
||||
_selectedPanel = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(PanelTitle));
|
||||
OnPropertyChanged(nameof(PanelDescription));
|
||||
_activation = ActivateAsync(value);
|
||||
}
|
||||
}
|
||||
|
||||
public string PanelTitle => SelectedPanel?.Title ?? "";
|
||||
public string PanelDescription => SelectedPanel?.Description ?? "";
|
||||
|
||||
public string DisplayName => Session.Profile.DisplayName;
|
||||
public string Initials => AdminTexts.Initials(Session.Profile.DisplayName);
|
||||
public string RoleText => AdminTexts.Role(Session.Profile.Role);
|
||||
public string KeyLabel => (Session.Profile.KeyLabel ?? Path.GetFileNameWithoutExtension(_connection.KeyPath)) +
|
||||
(_connection.IsTpm ? " · puce TPM" : "");
|
||||
public string ServerHost => _shell.ServerHost;
|
||||
|
||||
/// <summary>« Verrouillage dans 9 min ».</summary>
|
||||
public string SessionText => $"Verrouillage dans {AdminTexts.Duration(Guard.Remaining)}";
|
||||
|
||||
public AsyncRelayCommand LockCommand { get; }
|
||||
|
||||
/// <summary>Ouvre la première page et attend son chargement initial.</summary>
|
||||
internal Task StartAsync()
|
||||
{
|
||||
SelectedPanel = PanelItems.FirstOrDefault();
|
||||
return _activation;
|
||||
}
|
||||
|
||||
/// <summary>Chargement de la page affichée (tests).</summary>
|
||||
internal Task Activation => _activation;
|
||||
|
||||
private async Task ActivateAsync(AdminPanelItem item)
|
||||
{
|
||||
if (_disposed) return;
|
||||
try
|
||||
{
|
||||
item.ViewModel ??= item.Descriptor.CreateViewModel(this);
|
||||
await item.ViewModel.ActivateAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"Administration : ouverture de la page {item.Descriptor.Id}", ex);
|
||||
Dialogs.ShowMessage("Page indisponible", ApiErrorText.Describe(ex), ModuleMessageKind.Error);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ IAdminContext
|
||||
|
||||
public AdminSession Session => _connection.Session;
|
||||
public LauncherApiClient Client => _connection.Client;
|
||||
public IReadOnlyList<LauncherConfig> Games => _shell.Environment.Games;
|
||||
public IModuleDialogs Dialogs => _shell.Environment.Dialogs;
|
||||
public string LauncherVersion => _shell.Environment.LauncherVersion;
|
||||
public IReadOnlyList<AdminPanelDescriptor> Panels { get; }
|
||||
|
||||
public void Navigate(string panelId)
|
||||
{
|
||||
if (PanelItems.FirstOrDefault(p => string.Equals(p.Descriptor.Id, panelId, StringComparison.OrdinalIgnoreCase)) is { } item)
|
||||
SelectedPanel = item;
|
||||
}
|
||||
|
||||
public void ReportSessionLost(string reason) => _ = _shell.LockAsync(this, reason);
|
||||
|
||||
// ------------------------------------------------------------------ Fermeture
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
Guard.Dispose();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>Jeu déclaré dans launcher.json, tel qu'affiché au tableau de bord.</summary>
|
||||
public sealed record DashboardGame(string Name, string GameId, string ManifestHost);
|
||||
|
||||
/// <summary>
|
||||
/// Page d'accueil : compte connecté et permissions, session, serveur, jeux déclarés et, pour les
|
||||
/// propriétaires, les clés en attente d'approbation.
|
||||
/// </summary>
|
||||
public sealed class DashboardPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "admin.dashboard";
|
||||
|
||||
private string _serverVersion = "…";
|
||||
private string _serverId = "…";
|
||||
private int _pendingApprovals;
|
||||
private int _activeInvitations;
|
||||
private string? _lastRefresh;
|
||||
|
||||
public DashboardPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
var profile = context.Session.Profile;
|
||||
Permissions = profile.Permissions.Select(AdminTexts.Permission).ToList();
|
||||
Games = context.Games.Select(g => new DashboardGame(g.GameName, g.GameId,
|
||||
Uri.TryCreate(g.ManifestUrl, UriKind.Absolute, out var uri) ? uri.Host : g.ManifestUrl)).ToList();
|
||||
RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsBusy);
|
||||
}
|
||||
|
||||
public string DisplayName => Context.Session.Profile.DisplayName;
|
||||
public string Initials => AdminTexts.Initials(DisplayName);
|
||||
public string RoleText => AdminTexts.Role(Context.Session.Profile.Role);
|
||||
public IReadOnlyList<string> Permissions { get; }
|
||||
|
||||
public string KeyText => $"{Context.Session.Profile.KeyLabel ?? "Clé"} · {Context.Session.Profile.KeyId}";
|
||||
|
||||
/// <summary>« Session ouverte jusqu'à 23:14 au plus tard » (heure du PC).</summary>
|
||||
public string SessionText =>
|
||||
$"Ouverte au plus tard jusqu'à {(Context.Session.ExpiresAt - Context.Session.ClockOffset).ToLocalTime():HH:mm}";
|
||||
|
||||
public string ServerOrigin => Context.Client.Origin;
|
||||
|
||||
public string ServerVersion
|
||||
{
|
||||
get => _serverVersion;
|
||||
private set => SetProperty(ref _serverVersion, value);
|
||||
}
|
||||
|
||||
/// <summary>Identifiant du serveur (début), pour le support.</summary>
|
||||
public string ServerId
|
||||
{
|
||||
get => _serverId;
|
||||
private set => SetProperty(ref _serverId, value);
|
||||
}
|
||||
|
||||
public string LauncherVersion => Context.LauncherVersion;
|
||||
|
||||
public IReadOnlyList<DashboardGame> Games { get; }
|
||||
|
||||
public bool CanManageAdmins => Context.Session.Has(AdminPermissions.AdminsManage);
|
||||
|
||||
public int PendingApprovals
|
||||
{
|
||||
get => _pendingApprovals;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _pendingApprovals, value))
|
||||
{
|
||||
OnPropertyChanged(nameof(HasPendingApprovals));
|
||||
OnPropertyChanged(nameof(PendingText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int ActiveInvitations
|
||||
{
|
||||
get => _activeInvitations;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _activeInvitations, value)) OnPropertyChanged(nameof(PendingText));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasPendingApprovals => PendingApprovals > 0;
|
||||
|
||||
public string PendingText => PendingApprovals switch
|
||||
{
|
||||
0 => ActiveInvitations == 0 ? "Aucune clé en attente, aucune invitation en cours." : $"Aucune clé en attente · {ActiveInvitations} invitation(s) en cours.",
|
||||
1 => $"1 clé attend votre approbation · {ActiveInvitations} invitation(s) en cours.",
|
||||
var n => $"{n} clés attendent votre approbation · {ActiveInvitations} invitation(s) en cours."
|
||||
};
|
||||
|
||||
public string? LastRefresh
|
||||
{
|
||||
get => _lastRefresh;
|
||||
private set => SetProperty(ref _lastRefresh, value);
|
||||
}
|
||||
|
||||
public AsyncRelayCommand RefreshCommand { get; }
|
||||
|
||||
protected override void OnBusyChanged() => RefreshCommand.RaiseCanExecuteChanged();
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RefreshAsync() : Task.CompletedTask;
|
||||
|
||||
public Task RefreshAsync() => RunAsync(async () =>
|
||||
{
|
||||
var info = await Context.Client.GetInfoAsync();
|
||||
ServerVersion = info.ServerVersion;
|
||||
ServerId = info.ServerId.Length > 8 ? info.ServerId[..8] : info.ServerId;
|
||||
|
||||
if (CanManageAdmins)
|
||||
{
|
||||
var admins = await Context.Session.ListAdminsAsync();
|
||||
var invitations = await Context.Session.ListInvitationsAsync();
|
||||
PendingApprovals = admins.Where(a => a.Status != AdminStatus.Revoked)
|
||||
.SelectMany(a => a.Keys).Count(k => k.Status == AdminStatus.Pending);
|
||||
ActiveInvitations = invitations.Count;
|
||||
}
|
||||
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// « J'ai une invitation » : création d'une clé sur ce PC et, avec un code d'invitation,
|
||||
/// enregistrement auprès du serveur. Deux rangements au choix :
|
||||
/// • fichier chiffré par une phrase de passe (copiable, sauvegardable) — par défaut ;
|
||||
/// • puce de sécurité (TPM) de ce PC, protégée par un code PIN (non copiable) — proposé seulement
|
||||
/// une fois la puce RÉELLEMENT vérifiée (création, signature et suppression d'une clé de test).
|
||||
/// Affiche ensuite l'EMPREINTE à communiquer à la personne qui approuve. Sans code : clé seule
|
||||
/// (premier propriétaire, à déclarer sur le VPS).
|
||||
/// </summary>
|
||||
public sealed class EnrollViewModel : ObservableObject
|
||||
{
|
||||
private readonly AdminShellViewModel _shell;
|
||||
private string _invitationCode = "";
|
||||
private string _keyLabel;
|
||||
private string _keyPath;
|
||||
private bool _useTpm;
|
||||
private bool _isBusy;
|
||||
private string? _errorMessage;
|
||||
private AdminKeyCreation? _result;
|
||||
private TpmAvailability? _tpm;
|
||||
|
||||
public EnrollViewModel(AdminShellViewModel shell)
|
||||
{
|
||||
_shell = shell;
|
||||
_keyLabel = System.Environment.MachineName;
|
||||
_keyPath = AdminKeyLocator.SuggestNewKeyPath(shell.Environment.KeysDirectory);
|
||||
BackCommand = new RelayCommand(() => shell.ShowLogin(), () => !IsBusy);
|
||||
ChangePathCommand = new RelayCommand(ChangePath, () => !IsBusy && !IsDone);
|
||||
CopyFingerprintCommand = new RelayCommand(() =>
|
||||
{
|
||||
if (_result is not null) shell.Environment.Dialogs.CopyToClipboard(_result.Fingerprint);
|
||||
});
|
||||
OpenKeyFolderCommand = new RelayCommand(() =>
|
||||
{
|
||||
if (Path.GetDirectoryName(_result?.KeyPath ?? KeyPath) is { } folder && Directory.Exists(folder))
|
||||
shell.Environment.Dialogs.OpenFolder(folder);
|
||||
});
|
||||
ContinueCommand = new RelayCommand(() => shell.ShowLogin(
|
||||
_result is null or { Enrollment: null, EnrollmentUnconfirmed: null } ? null : "Connectez-vous dès que votre clé a été approuvée.",
|
||||
_result?.KeyPath));
|
||||
TpmCheck = CheckTpmAsync();
|
||||
}
|
||||
|
||||
public string InvitationCode
|
||||
{
|
||||
get => _invitationCode;
|
||||
set => SetProperty(ref _invitationCode, value ?? "");
|
||||
}
|
||||
|
||||
/// <summary>Nom de ce PC, visible par les propriétaires (ex. « PC bureau »).</summary>
|
||||
public string KeyLabel
|
||||
{
|
||||
get => _keyLabel;
|
||||
set => SetProperty(ref _keyLabel, value ?? "");
|
||||
}
|
||||
|
||||
public string KeyPath
|
||||
{
|
||||
get => _keyPath;
|
||||
private set => SetProperty(ref _keyPath, value);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Rangement de la clé
|
||||
|
||||
/// <summary>Vérification de la puce en cours (le choix « puce » reste grisé).</summary>
|
||||
public bool IsCheckingTpm => _tpm is null;
|
||||
|
||||
/// <summary>Puce vérifiée et utilisable.</summary>
|
||||
public bool IsTpmAvailable => _tpm?.IsAvailable == true;
|
||||
|
||||
/// <summary>« Vérification de la puce de sécurité… », « Puce TPM 2.0 disponible… » ou la raison de l'indisponibilité.</summary>
|
||||
public string TpmStatus => _tpm?.Message ?? "Vérification de la puce de sécurité (TPM) de ce PC…";
|
||||
|
||||
/// <summary>Le choix « puce de sécurité » n'est dégrisé qu'après une vérification réussie.</summary>
|
||||
public bool CanChooseTpm => IsTpmAvailable && !IsBusy && !IsDone;
|
||||
|
||||
public bool CanChooseFile => !IsBusy && !IsDone;
|
||||
|
||||
/// <summary>Faux : fichier chiffré (par défaut). Vrai : puce de sécurité (TPM) de ce PC.</summary>
|
||||
public bool UseTpm
|
||||
{
|
||||
get => _useTpm;
|
||||
set
|
||||
{
|
||||
if ((value && !IsTpmAvailable) || IsBusy || IsDone || value == _useTpm)
|
||||
{
|
||||
// Refusé (jamais la puce sans vérification réussie) : l'affichage revient à l'état réel.
|
||||
OnPropertyChanged(nameof(UseTpm));
|
||||
OnPropertyChanged(nameof(UseFile));
|
||||
return;
|
||||
}
|
||||
_useTpm = value;
|
||||
ErrorMessage = null;
|
||||
KeyPath = AdminKeyLocator.SuggestNewKeyPath(Path.GetDirectoryName(KeyPath) ?? _shell.Environment.KeysDirectory, value);
|
||||
foreach (var name in new[] { nameof(UseTpm), nameof(UseFile), nameof(MinSecretLength), nameof(SecretLabel),
|
||||
nameof(ConfirmationLabel), nameof(SecretHint), nameof(KeyFileLabel) })
|
||||
OnPropertyChanged(name);
|
||||
}
|
||||
}
|
||||
|
||||
public bool UseFile
|
||||
{
|
||||
get => !_useTpm;
|
||||
set
|
||||
{
|
||||
if (value) UseTpm = false;
|
||||
}
|
||||
}
|
||||
|
||||
public int MinSecretLength => UseTpm ? AdminTpmKeyFile.MinPinLength : AdminKeyFile.MinPassphraseLength;
|
||||
|
||||
public string SecretLabel => UseTpm
|
||||
? $"Code PIN de la clé (au moins {AdminTpmKeyFile.MinPinLength} caractères)"
|
||||
: $"Phrase de passe (au moins {AdminKeyFile.MinPassphraseLength} caractères)";
|
||||
|
||||
public string ConfirmationLabel => UseTpm ? "Confirmation du code PIN" : "Confirmation";
|
||||
|
||||
public string SecretHint => UseTpm
|
||||
? "Le code PIN est vérifié par la puce elle-même, qui se bloque temporairement après plusieurs erreurs " +
|
||||
"(ces erreurs comptent aussi pour les autres usages de la puce, comme le code PIN Windows Hello). " +
|
||||
"Cette clé ne peut être ni copiée ni sauvegardée : gardez aussi une clé « fichier » de secours."
|
||||
: "Une phrase de plusieurs mots est plus sûre et plus facile à retenir. Elle ne peut pas être récupérée : " +
|
||||
"en cas d'oubli, il faudra une nouvelle invitation.";
|
||||
|
||||
public string KeyFileLabel => UseTpm
|
||||
? "Fichier de référence (aucun secret : la clé reste dans la puce)"
|
||||
: "Fichier de la clé";
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isBusy, value)) return;
|
||||
BackCommand.RaiseCanExecuteChanged();
|
||||
ChangePathCommand.RaiseCanExecuteChanged();
|
||||
OnPropertyChanged(nameof(CanChooseTpm));
|
||||
OnPropertyChanged(nameof(CanChooseFile));
|
||||
}
|
||||
}
|
||||
|
||||
public string? ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
|
||||
|
||||
public bool IsDone => _result is not null;
|
||||
public bool IsForm => _result is null;
|
||||
|
||||
/// <summary>Clé enregistrée auprès du serveur (sinon : clé seule).</summary>
|
||||
public bool IsEnrolled => _result?.Enrollment is not null;
|
||||
public bool IsKeyOnly => _result is { Enrollment: null, EnrollmentUnconfirmed: null };
|
||||
|
||||
/// <summary>Invitation envoyée sans réponse sûre du serveur : clé conservée, marche à suivre affichée.</summary>
|
||||
public bool IsUnconfirmed => _result?.EnrollmentUnconfirmed is not null;
|
||||
public string UnconfirmedText => _result?.EnrollmentUnconfirmed ?? "";
|
||||
|
||||
/// <summary>Clé créée dans la puce de sécurité (TPM).</summary>
|
||||
public bool IsTpmKey => _result?.IsTpm == true;
|
||||
|
||||
public string Fingerprint => _result?.Fingerprint ?? "";
|
||||
public string CreatedKeyPath => _result?.KeyPath ?? "";
|
||||
public string PublicKeyPath => _result?.PublicKeyPath ?? "";
|
||||
public string AccountName => _result?.Enrollment?.DisplayName ?? "";
|
||||
|
||||
public RelayCommand BackCommand { get; }
|
||||
public RelayCommand ChangePathCommand { get; }
|
||||
public RelayCommand CopyFingerprintCommand { get; }
|
||||
public RelayCommand OpenKeyFolderCommand { get; }
|
||||
public RelayCommand ContinueCommand { get; }
|
||||
|
||||
/// <summary>Vérification de la puce lancée à l'ouverture de l'écran (tests).</summary>
|
||||
internal Task TpmCheck { get; }
|
||||
|
||||
/// <summary>Crée la clé (et l'enregistre si un code est saisi). La vue efface les secrets après l'appel.</summary>
|
||||
public async Task SubmitAsync(string secret, string confirmation)
|
||||
{
|
||||
if (IsBusy || IsDone) return;
|
||||
if (UseTpm && !IsTpmAvailable)
|
||||
{
|
||||
ErrorMessage = "La puce de sécurité (TPM) n'est pas utilisable sur ce PC : choisissez « Fichier chiffré ».";
|
||||
return;
|
||||
}
|
||||
if (secret.Length < MinSecretLength)
|
||||
{
|
||||
ErrorMessage = UseTpm
|
||||
? $"Le code PIN doit contenir au moins {MinSecretLength} caractères."
|
||||
: $"La phrase de passe doit contenir au moins {MinSecretLength} caractères.";
|
||||
return;
|
||||
}
|
||||
if (!string.Equals(secret, confirmation, StringComparison.Ordinal))
|
||||
{
|
||||
ErrorMessage = UseTpm ? "Les deux codes PIN sont différents." : "Les deux phrases de passe sont différentes.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
ErrorMessage = null;
|
||||
try
|
||||
{
|
||||
var result = await new AdminEnrollmentService(_shell.Environment.Client, _shell.Environment.Tpm)
|
||||
.CreateKeyAsync(KeyPath, secret, InvitationCode, KeyLabel, useTpm: UseTpm);
|
||||
_shell.Settings.Remember(result.KeyPath);
|
||||
_shell.Settings.Save(_shell.Environment.SettingsPath);
|
||||
var storage = result.IsTpm ? "puce TPM" : "fichier";
|
||||
Log.Info(result switch
|
||||
{
|
||||
{ Enrollment: { } enrollment } =>
|
||||
$"Administration : clé {result.KeyId} ({storage}) enregistrée pour {enrollment.DisplayName}, en attente d'approbation.",
|
||||
{ EnrollmentUnconfirmed: not null } => $"Administration : clé {result.KeyId} ({storage}) créée, enregistrement non confirmé par le serveur.",
|
||||
_ => $"Administration : clé créée ({result.KeyId}, {storage})."
|
||||
});
|
||||
_result = result;
|
||||
foreach (var name in new[] { nameof(IsDone), nameof(IsForm), nameof(IsEnrolled), nameof(IsKeyOnly), nameof(IsUnconfirmed),
|
||||
nameof(UnconfirmedText), nameof(IsTpmKey), nameof(Fingerprint), nameof(CreatedKeyPath),
|
||||
nameof(PublicKeyPath), nameof(AccountName), nameof(CanChooseTpm), nameof(CanChooseFile) })
|
||||
OnPropertyChanged(name);
|
||||
ChangePathCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ApiErrorText.Describe(ex);
|
||||
if (ex is ApiException api) Log.Warn($"Administration : enregistrement refusé ({api.Status} {api.Code}).");
|
||||
else if (UseTpm) Log.Warn($"Administration : création de la clé TPM impossible ({ex.Message}).");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckTpmAsync()
|
||||
{
|
||||
var availability = await _shell.CheckTpmAsync();
|
||||
_tpm = availability;
|
||||
foreach (var name in new[] { nameof(IsCheckingTpm), nameof(IsTpmAvailable), nameof(TpmStatus), nameof(CanChooseTpm) })
|
||||
OnPropertyChanged(name);
|
||||
}
|
||||
|
||||
private void ChangePath()
|
||||
{
|
||||
var directory = Path.GetDirectoryName(KeyPath);
|
||||
var extension = UseTpm ? AdminTpmKeyFile.Extension : AdminKeyFile.PrivateKeyExtension;
|
||||
var filter = UseTpm ? "Référence de clé TPM (*.tpmkey)|*.tpmkey" : "Clé d'administrateur (*.key)|*.key";
|
||||
var path = _shell.Environment.Dialogs.PickSaveFile("Emplacement de la nouvelle clé", filter,
|
||||
Path.GetFileName(KeyPath), directory is not null && Directory.Exists(directory) ? directory : null);
|
||||
if (path is null) return;
|
||||
if (!path.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) path += extension;
|
||||
KeyPath = path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Écran de connexion : choix de la clé, phrase de passe ou code PIN (transmis par la vue, jamais
|
||||
/// stocké dans le ViewModel), connexion par défi signé. Pour les clés de la puce TPM : suppression
|
||||
/// de la clé, et aide quand la puce est bloquée par sa protection anti-essais (temps d'attente).
|
||||
/// </summary>
|
||||
public sealed class LoginViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private static readonly TimeSpan LockoutTick = TimeSpan.FromSeconds(1);
|
||||
|
||||
private readonly AdminShellViewModel _shell;
|
||||
private AdminKeyEntry? _selectedKey;
|
||||
private bool _isBusy;
|
||||
private string? _errorMessage;
|
||||
private string? _statusMessage;
|
||||
|
||||
// Protection anti-essais de la puce (commune à toutes les clés TPM de ce PC).
|
||||
private bool _isTpmLocked;
|
||||
private DateTimeOffset? _lockoutUntil;
|
||||
private TimeSpan _lockoutInterval;
|
||||
private bool _lockoutHeals = true;
|
||||
private string? _lockoutText;
|
||||
private ITimer? _lockoutTimer;
|
||||
private bool _disposed;
|
||||
|
||||
public LoginViewModel(AdminShellViewModel shell, string? statusMessage, string? selectKeyPath)
|
||||
{
|
||||
_shell = shell;
|
||||
_statusMessage = statusMessage;
|
||||
BrowseKeyCommand = new RelayCommand(BrowseKey, () => !IsBusy);
|
||||
ShowEnrollCommand = new RelayCommand(shell.ShowEnroll, () => !IsBusy);
|
||||
RefreshCommand = new RelayCommand(() => LoadKeys(SelectedKey?.Path), () => !IsBusy);
|
||||
OpenKeysFolderCommand = new RelayCommand(OpenKeysFolder);
|
||||
DeleteTpmKeyCommand = new AsyncRelayCommand(DeleteTpmKeyAsync, () => IsTpmKey && !IsBusy);
|
||||
RecheckLockoutCommand = new AsyncRelayCommand(() => CheckLockoutAsync(knownLocked: false), () => !IsBusy);
|
||||
LoadKeys(selectKeyPath ?? shell.Settings.LastKeyPath);
|
||||
}
|
||||
|
||||
public ObservableCollection<AdminKeyEntry> Keys { get; } = new();
|
||||
|
||||
public AdminKeyEntry? SelectedKey
|
||||
{
|
||||
get => _selectedKey;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _selectedKey, value)) return;
|
||||
OnPropertyChanged(nameof(CanLogin));
|
||||
OnPropertyChanged(nameof(SecretLabel));
|
||||
OnPropertyChanged(nameof(IsTpmKey));
|
||||
OnPropertyChanged(nameof(ShowTpmLockout));
|
||||
DeleteTpmKeyCommand.RaiseCanExecuteChanged();
|
||||
// Clé de la puce : la puce est-elle bloquée ? (affiché avant même de saisir le code)
|
||||
if (value?.IsTpm == true && !_isTpmLocked) _ = CheckLockoutAsync(knownLocked: false, quiet: true);
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasKeys => Keys.Count > 0;
|
||||
|
||||
/// <summary>« Phrase de passe de la clé » ou « Code PIN de la clé (puce TPM) », selon la clé choisie.</summary>
|
||||
public string SecretLabel => SelectedKey?.SecretLabel ?? "Phrase de passe de la clé";
|
||||
|
||||
public bool IsTpmKey => SelectedKey?.IsTpm == true;
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isBusy, value)) return;
|
||||
OnPropertyChanged(nameof(CanLogin));
|
||||
BrowseKeyCommand.RaiseCanExecuteChanged();
|
||||
ShowEnrollCommand.RaiseCanExecuteChanged();
|
||||
RefreshCommand.RaiseCanExecuteChanged();
|
||||
DeleteTpmKeyCommand.RaiseCanExecuteChanged();
|
||||
RecheckLockoutCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connexion possible. Avec une clé de la puce pendant un blocage dont la fin est connue : non,
|
||||
/// jusqu'à la fin du décompte (un essai serait refusé de toute façon).
|
||||
/// </summary>
|
||||
public bool CanLogin => SelectedKey is not null && !IsBusy && !(IsTpmKey && LockoutBlocksLogin);
|
||||
|
||||
public string? ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
|
||||
|
||||
/// <summary>Information (ex. « Verrouillé après 10 minutes d'inactivité », clé supprimée…).</summary>
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
|
||||
|
||||
public string ServerHost => _shell.ServerHost;
|
||||
|
||||
public string KeysDirectory => _shell.Environment.KeysDirectory;
|
||||
|
||||
// ------------------------------------------------------------------ Blocage de la puce
|
||||
|
||||
/// <summary>La puce refuse les codes PIN (trop d'erreurs) : bandeau d'aide affiché pour les clés TPM.</summary>
|
||||
public bool IsTpmLocked
|
||||
{
|
||||
get => _isTpmLocked;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isTpmLocked, value)) return;
|
||||
OnPropertyChanged(nameof(ShowTpmLockout));
|
||||
OnPropertyChanged(nameof(CanLogin));
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShowTpmLockout => IsTpmLocked && IsTpmKey;
|
||||
|
||||
/// <summary>« De nouveau utilisable dans 9 min 30 s au plus… », ou l'explication si la durée est inconnue.</summary>
|
||||
public string? LockoutText
|
||||
{
|
||||
get => _lockoutText;
|
||||
private set => SetProperty(ref _lockoutText, value);
|
||||
}
|
||||
|
||||
private bool LockoutBlocksLogin => _isTpmLocked && _lockoutUntil is { } until && until > _shell.Environment.Time.GetUtcNow();
|
||||
|
||||
public RelayCommand BrowseKeyCommand { get; }
|
||||
public RelayCommand ShowEnrollCommand { get; }
|
||||
public RelayCommand RefreshCommand { get; }
|
||||
public RelayCommand OpenKeysFolderCommand { get; }
|
||||
|
||||
/// <summary>Supprime définitivement la clé de la puce sélectionnée (clés TPM uniquement).</summary>
|
||||
public AsyncRelayCommand DeleteTpmKeyCommand { get; }
|
||||
|
||||
/// <summary>Relit l'état de la protection anti-essais de la puce.</summary>
|
||||
public AsyncRelayCommand RecheckLockoutCommand { get; }
|
||||
|
||||
/// <summary>Dernière lecture de l'état de la puce (tests).</summary>
|
||||
internal Task LockoutCheck { get; private set; } = Task.CompletedTask;
|
||||
|
||||
// ------------------------------------------------------------------ Connexion
|
||||
|
||||
/// <summary>Connexion avec la clé choisie. La vue efface la phrase de passe (ou le code) juste après l'appel.</summary>
|
||||
public async Task LoginAsync(string passphrase)
|
||||
{
|
||||
if (SelectedKey is not { } key || IsBusy) return;
|
||||
if (string.IsNullOrEmpty(passphrase))
|
||||
{
|
||||
ErrorMessage = key.IsTpm ? "Saisissez le code PIN de la clé." : "Saisissez la phrase de passe de la clé.";
|
||||
return;
|
||||
}
|
||||
if (!CanLogin) return;
|
||||
|
||||
IsBusy = true;
|
||||
ErrorMessage = null;
|
||||
AdminConnection? connection = null;
|
||||
TpmPinException? pinError = null;
|
||||
try
|
||||
{
|
||||
var environment = _shell.Environment;
|
||||
connection = await new AdminLoginService(environment.Client, environment.ClientName, environment.Tpm).LoginAsync(key.Path, passphrase);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ApiErrorText.Describe(ex, duringLogin: true);
|
||||
if (ex is ApiException api) Log.Warn($"Administration : connexion refusée ({api.Status} {api.Code}).");
|
||||
pinError = ex as TpmPinException;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
|
||||
if (connection is null)
|
||||
{
|
||||
if (pinError is not null) await OnTpmPinRefusedAsync(pinError);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Info($"Administration : connexion de {connection.Session.Profile.DisplayName} ({connection.Session.Profile.Role}).");
|
||||
_shell.Settings.Remember(key.Path); // chemin déjà normalisé (AdminKeyLocator)
|
||||
_shell.Settings.Save(_shell.Environment.SettingsPath); // erreurs d'écriture journalisées, sans effet
|
||||
try
|
||||
{
|
||||
await _shell.OpenWorkspaceAsync(connection);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Administration : ouverture de l'espace d'administration", ex);
|
||||
ErrorMessage = ApiErrorText.Describe(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Code refusé : essais restants, ou décompte jusqu'à la fin du blocage.</summary>
|
||||
private async Task OnTpmPinRefusedAsync(TpmPinException error)
|
||||
{
|
||||
var info = await CheckLockoutAsync(error.IsLockedOut);
|
||||
if (_isTpmLocked)
|
||||
{
|
||||
ErrorMessage = null; // le bandeau du blocage explique tout
|
||||
return;
|
||||
}
|
||||
if (info is { MaxTries: > 0 })
|
||||
ErrorMessage = $"Code PIN incorrect. Encore {info.RemainingTries} essai(s) avant le blocage temporaire de la puce " +
|
||||
"(compteur commun à la puce : les codes faux de Windows Hello comptent aussi).";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Protection anti-essais
|
||||
|
||||
/// <summary>
|
||||
/// Lit l'état de la puce et met à jour le bandeau : bloquée avec une fin estimée (décompte),
|
||||
/// bloquée sans estimation possible, ou débloquée.
|
||||
/// </summary>
|
||||
/// <param name="knownLocked">La puce vient de répondre « bloquée » (même si l'état n'est pas lisible).</param>
|
||||
/// <param name="quiet">Vérification de fond : rien n'est affiché si la puce n'est pas bloquée.</param>
|
||||
private Task<TpmLockoutInfo?> CheckLockoutAsync(bool knownLocked, bool quiet = false)
|
||||
{
|
||||
// Une lecture à la fois, dans l'ordre des demandes (une lecture de fond ne masque pas un blocage signalé).
|
||||
var task = CheckAfterAsync(LockoutCheck, knownLocked, quiet);
|
||||
LockoutCheck = task;
|
||||
return task;
|
||||
}
|
||||
|
||||
private async Task<TpmLockoutInfo?> CheckAfterAsync(Task previous, bool knownLocked, bool quiet)
|
||||
{
|
||||
try
|
||||
{
|
||||
await previous;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// erreur déjà journalisée par la lecture précédente
|
||||
}
|
||||
return await CheckCoreAsync(knownLocked, quiet);
|
||||
}
|
||||
|
||||
private async Task<TpmLockoutInfo?> CheckCoreAsync(bool knownLocked, bool quiet)
|
||||
{
|
||||
if (_shell.Environment.Tpm is not { } tpm || _disposed) return null;
|
||||
TpmLockoutInfo? info = null;
|
||||
try
|
||||
{
|
||||
info = await BackgroundWork.RunAsync(tpm.GetLockoutInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Administration : état de la protection anti-essais de la puce illisible ({ex.Message}).");
|
||||
}
|
||||
if (_disposed) return info;
|
||||
|
||||
var wasLocked = _isTpmLocked;
|
||||
var locked = info?.IsLockedOut ?? knownLocked;
|
||||
if (!locked)
|
||||
{
|
||||
StopLockout();
|
||||
if (wasLocked && !quiet) StatusMessage = "La puce de sécurité accepte de nouveau les codes PIN : vous pouvez réessayer.";
|
||||
return info;
|
||||
}
|
||||
|
||||
var time = _shell.Environment.Time;
|
||||
_lockoutHeals = info?.HealsWithTime ?? true;
|
||||
_lockoutInterval = info?.RecoveryInterval ?? TimeSpan.Zero;
|
||||
_lockoutUntil = info is { HealsWithTime: true } ? time.GetUtcNow() + info.MaxWait : null;
|
||||
IsTpmLocked = true;
|
||||
if (_lockoutUntil is not null && _lockoutTimer is null && _shell.Environment.StartTimers)
|
||||
_lockoutTimer = time.CreateTimer(_ => _shell.Environment.Ui.Post(() => _ = TickLockoutAsync()), null, LockoutTick, LockoutTick);
|
||||
UpdateLockoutText();
|
||||
Log.Info(info is null
|
||||
? "Administration : puce TPM bloquée (protection anti-essais), durée inconnue."
|
||||
: $"Administration : puce TPM bloquée ({info.Failures}/{info.MaxTries} erreurs, une erreur oubliée toutes les {info.RecoveryInterval}).");
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>Décompte (chaque seconde ; appelé directement par les tests).</summary>
|
||||
internal async Task TickLockoutAsync()
|
||||
{
|
||||
if (!_isTpmLocked || _disposed || !LockoutCheck.IsCompleted) return;
|
||||
UpdateLockoutText();
|
||||
OnPropertyChanged(nameof(CanLogin));
|
||||
// Fin estimée atteinte : la puce le confirme (sinon, nouvelle estimation).
|
||||
if (_lockoutUntil is { } until && _shell.Environment.Time.GetUtcNow() >= until) await CheckLockoutAsync(knownLocked: false);
|
||||
}
|
||||
|
||||
private void UpdateLockoutText()
|
||||
{
|
||||
if (!_lockoutHeals)
|
||||
{
|
||||
LockoutText = "La puce de sécurité est bloquée après trop de codes PIN faux, et ne se débloque pas d'elle-même sur ce PC : " +
|
||||
"un administrateur de ce PC doit réinitialiser sa protection anti-essais. En attendant, utilisez une clé « fichier ».";
|
||||
return;
|
||||
}
|
||||
if (_lockoutUntil is not { } until)
|
||||
{
|
||||
LockoutText = "La puce de sécurité est temporairement bloquée après trop de codes PIN faux. Elle se débloque d'elle-même " +
|
||||
"au bout d'un moment (de quelques minutes à quelques heures selon le PC), uniquement PC allumé. " +
|
||||
"Réessayez plus tard, ou utilisez une clé « fichier ».";
|
||||
return;
|
||||
}
|
||||
var remaining = until - _shell.Environment.Time.GetUtcNow();
|
||||
LockoutText = remaining > TimeSpan.Zero
|
||||
? $"La puce de sécurité est bloquée après trop de codes PIN faux. Elle sera de nouveau utilisable dans " +
|
||||
$"{FormatWait(remaining)} au plus (une erreur est oubliée toutes les {FormatWait(_lockoutInterval)}, PC allumé)."
|
||||
: "Vérification de la puce…";
|
||||
}
|
||||
|
||||
private void StopLockout()
|
||||
{
|
||||
_lockoutTimer?.Dispose();
|
||||
_lockoutTimer = null;
|
||||
_lockoutUntil = null;
|
||||
LockoutText = null;
|
||||
IsTpmLocked = false;
|
||||
}
|
||||
|
||||
/// <summary>« 9 min 05 s », « 45 s », « 1 h 30 min ».</summary>
|
||||
internal static string FormatWait(TimeSpan wait)
|
||||
{
|
||||
if (wait < TimeSpan.Zero) wait = TimeSpan.Zero;
|
||||
var seconds = (long)Math.Ceiling(wait.TotalSeconds);
|
||||
if (seconds < 60) return $"{seconds} s";
|
||||
if (seconds < 3600) return seconds % 60 == 0 ? $"{seconds / 60} min" : $"{seconds / 60} min {seconds % 60:00} s";
|
||||
var minutes = (seconds + 59) / 60;
|
||||
return minutes % 60 == 0 ? $"{minutes / 60} h" : $"{minutes / 60} h {minutes % 60:00} min";
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Suppression d'une clé TPM
|
||||
|
||||
private async Task DeleteTpmKeyAsync()
|
||||
{
|
||||
if (SelectedKey is not { IsTpm: true } key || IsBusy) return;
|
||||
if (_shell.Environment.Tpm is not { } tpm)
|
||||
{
|
||||
ErrorMessage = "La puce de sécurité (TPM) n'est pas accessible sur ce PC : la clé ne peut pas être supprimée ici.";
|
||||
return;
|
||||
}
|
||||
|
||||
var dialogs = _shell.Environment.Dialogs;
|
||||
if (!dialogs.Confirm("Supprimer la clé de la puce ?",
|
||||
$"La clé « {key.Name} » ({key.Detail}) sera effacée DÉFINITIVEMENT de la puce de sécurité de ce PC, " +
|
||||
$"avec ses fichiers {Path.GetFileName(key.Path)} et {Path.GetFileNameWithoutExtension(key.Path)}.pub.\n\n" +
|
||||
"Elle ne pourra plus jamais servir à se connecter, sur aucun PC. Si c'était votre seule clé, il faudra une " +
|
||||
"nouvelle invitation (ou la commande add-key sur le serveur).\n\n" +
|
||||
$"Pensez aussi à la révoquer sur le serveur (identifiant de clé {key.KeyId ?? "inconnu"}).",
|
||||
"Supprimer définitivement", "Annuler", danger: true))
|
||||
return;
|
||||
|
||||
IsBusy = true;
|
||||
ErrorMessage = null;
|
||||
try
|
||||
{
|
||||
var reference = AdminTpmKeyFile.Load(key.Path);
|
||||
await BackgroundWork.RunAsync(() =>
|
||||
{
|
||||
tpm.DeleteKey(reference.KeyName);
|
||||
return true;
|
||||
});
|
||||
var left = AdminTpmKeyFile.DeleteFiles(key.Path);
|
||||
_shell.Settings.Forget(key.Path);
|
||||
_shell.Settings.Save(_shell.Environment.SettingsPath);
|
||||
Log.Info($"Administration : clé TPM {key.KeyId} supprimée de la puce ({key.Path}).");
|
||||
|
||||
StatusMessage = $"Clé « {key.Name} » supprimée de la puce de ce PC. Révoquez-la aussi sur le serveur " +
|
||||
$"(identifiant de clé {key.KeyId ?? "inconnu"} : commande revoke-key sur le VPS)." +
|
||||
(left.Count == 0 ? "" : $" Fichier(s) à supprimer à la main : {string.Join(", ", left)}.");
|
||||
LoadKeys(null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Administration : suppression de la clé TPM impossible ({ex.Message}).");
|
||||
ErrorMessage = $"Suppression impossible : {ApiErrorText.Describe(ex)}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Clés
|
||||
|
||||
private void LoadKeys(string? select)
|
||||
{
|
||||
Keys.Clear();
|
||||
foreach (var entry in AdminKeyLocator.Find(KeysDirectory, _shell.Settings.KeyPaths)) Keys.Add(entry);
|
||||
SelectedKey = Keys.FirstOrDefault(k => string.Equals(k.Path, select, StringComparison.OrdinalIgnoreCase)) ?? Keys.FirstOrDefault();
|
||||
OnPropertyChanged(nameof(HasKeys));
|
||||
}
|
||||
|
||||
private void BrowseKey()
|
||||
{
|
||||
var path = _shell.Environment.Dialogs.PickOpenFile("Choisir votre clé d'administrateur",
|
||||
"Clé d'administrateur (*.key, *.tpmkey)|*.key;*.tpmkey", Directory.Exists(KeysDirectory) ? KeysDirectory : null);
|
||||
if (path is null) return;
|
||||
|
||||
if (AdminKeyLocator.IsReserved(path))
|
||||
{
|
||||
ErrorMessage = "Ce fichier est la clé de signature du launcher : choisissez votre clé d'administrateur.";
|
||||
return;
|
||||
}
|
||||
if (AdminKeyLocator.Describe(path) is not { } entry)
|
||||
{
|
||||
ErrorMessage = "Ce fichier n'est pas une clé d'administrateur (fichier .key chiffré créé par le launcher ou tools\\admin-key.bat, " +
|
||||
"ou référence .tpmkey d'une clé de la puce de ce PC).";
|
||||
return;
|
||||
}
|
||||
ErrorMessage = null;
|
||||
_shell.Settings.Remember(entry.Path);
|
||||
_shell.Settings.Save(_shell.Environment.SettingsPath);
|
||||
LoadKeys(entry.Path);
|
||||
}
|
||||
|
||||
private void OpenKeysFolder()
|
||||
{
|
||||
Directory.CreateDirectory(KeysDirectory);
|
||||
_shell.Environment.Dialogs.OpenFolder(KeysDirectory);
|
||||
}
|
||||
|
||||
/// <summary>Écran quitté : arrêt du décompte.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_lockoutTimer?.Dispose();
|
||||
_lockoutTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace GameLauncher.Modules.Admin;
|
||||
|
||||
/// <summary>Page du menu d'administration déclarée par un module.</summary>
|
||||
/// <param name="Id">Identifiant unique, ex. « admin.dashboard », « catalog.games ».</param>
|
||||
/// <param name="Title">Titre affiché dans la navigation.</param>
|
||||
/// <param name="Description">Sous-titre affiché en haut de la page.</param>
|
||||
/// <param name="IconKey">Icône du launcher (ressource « Icon.<clé> » de Themes/Icons.xaml).</param>
|
||||
/// <param name="RequiredPermission">Permission nécessaire (null : tout administrateur connecté).</param>
|
||||
/// <param name="Order">Position dans la navigation (croissante).</param>
|
||||
/// <param name="CreateViewModel">Crée le ViewModel de la page (son affichage est un DataTemplate du launcher).</param>
|
||||
public sealed record AdminPanelDescriptor(
|
||||
string Id,
|
||||
string Title,
|
||||
string Description,
|
||||
string IconKey,
|
||||
string? RequiredPermission,
|
||||
int Order,
|
||||
Func<IAdminContext, AdminPanelViewModel> CreateViewModel);
|
||||
@@ -0,0 +1,78 @@
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin;
|
||||
|
||||
/// <summary>Base des pages d'administration : état « occupé », message d'erreur, gestion de la session perdue.</summary>
|
||||
public abstract class AdminPanelViewModel : ObservableObject
|
||||
{
|
||||
private bool _isBusy;
|
||||
private string? _errorMessage;
|
||||
private bool _activated;
|
||||
|
||||
protected AdminPanelViewModel(IAdminContext context) => Context = context;
|
||||
|
||||
public IAdminContext Context { get; }
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
protected set
|
||||
{
|
||||
if (SetProperty(ref _isBusy, value)) OnBusyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>« Occupé » a changé : réévaluer les commandes de la page (RaiseCanExecuteChanged).</summary>
|
||||
protected virtual void OnBusyChanged()
|
||||
{
|
||||
}
|
||||
|
||||
public string? ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
protected set
|
||||
{
|
||||
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
|
||||
|
||||
/// <summary>Appelé à chaque affichage de la page ; <paramref name="firstTime"/> au premier.</summary>
|
||||
public Task ActivateAsync()
|
||||
{
|
||||
var first = !_activated;
|
||||
_activated = true;
|
||||
return OnActivatedAsync(first);
|
||||
}
|
||||
|
||||
protected virtual Task OnActivatedAsync(bool firstTime) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Exécute une action réseau : « occupé » pendant l'action, erreur affichée en cas d'échec,
|
||||
/// retour à la connexion si la session a expiré. Vrai si l'action a réussi.
|
||||
/// </summary>
|
||||
protected async Task<bool> RunAsync(Func<Task> action)
|
||||
{
|
||||
if (IsBusy) return false;
|
||||
IsBusy = true;
|
||||
ErrorMessage = null;
|
||||
try
|
||||
{
|
||||
await action();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ApiErrorText.IsSessionLost(ex))
|
||||
Context.ReportSessionLost("Session expirée ou révoquée : reconnectez-vous.");
|
||||
else
|
||||
ErrorMessage = ApiErrorText.Describe(ex);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Models;
|
||||
|
||||
namespace GameLauncher.Modules.Admin;
|
||||
|
||||
/// <summary>Ce qu'une page d'administration reçoit : session, jeux déclarés, dialogues, navigation.</summary>
|
||||
public interface IAdminContext
|
||||
{
|
||||
/// <summary>Session ouverte : lectures avec GetAsync, modifications signées avec SendSignedAsync.</summary>
|
||||
AdminSession Session { get; }
|
||||
|
||||
LauncherApiClient Client { get; }
|
||||
|
||||
/// <summary>Jeux déclarés dans launcher.json (les seuls que l'administration peut modifier).</summary>
|
||||
IReadOnlyList<LauncherConfig> Games { get; }
|
||||
|
||||
IModuleDialogs Dialogs { get; }
|
||||
|
||||
string LauncherVersion { get; }
|
||||
|
||||
/// <summary>Pages visibles pour cet administrateur.</summary>
|
||||
IReadOnlyList<AdminPanelDescriptor> Panels { get; }
|
||||
|
||||
void Navigate(string panelId);
|
||||
|
||||
/// <summary>La session n'est plus valable : retour à l'écran de connexion avec ce message.</summary>
|
||||
void ReportSessionLost(string reason);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
|
||||
namespace GameLauncher.Modules;
|
||||
|
||||
/// <summary>Message clair (français) pour une erreur survenue dans un module d'administration.</summary>
|
||||
public static class ApiErrorText
|
||||
{
|
||||
/// <param name="duringLogin">Vrai pendant la connexion : un refus vise la clé, pas la session.</param>
|
||||
public static string Describe(Exception ex, bool duringLogin = false) => ex switch
|
||||
{
|
||||
ApiException { Status: 0, Code: "unreachable" } =>
|
||||
"Serveur d'administration injoignable. Vérifiez votre connexion Internet, puis réessayez.",
|
||||
ApiException { Status: 0, Code: "timeout" } => "Le serveur d'administration ne répond pas. Réessayez dans un instant.",
|
||||
ApiException { Status: 401 } api when duringLogin && api.Code == ApiErrorCodes.Unauthorized =>
|
||||
"Connexion refusée : cette clé n'est pas reconnue par le serveur, est encore en attente d'approbation, ou a été révoquée.",
|
||||
ApiException { Status: 401, Code: ApiErrorCodes.Unauthorized } => "Session expirée : reconnectez-vous.",
|
||||
ApiException api => api.Message,
|
||||
CryptographicException crypto => crypto.Message,
|
||||
FileNotFoundException missing => $"Fichier introuvable : {missing.FileName ?? missing.Message}",
|
||||
IOException or UnauthorizedAccessException => $"Fichier inaccessible : {ex.Message}",
|
||||
ArgumentException or InvalidOperationException => ex.Message,
|
||||
OperationCanceledException => "Opération annulée.",
|
||||
_ => $"Erreur inattendue : {ex.Message}"
|
||||
};
|
||||
|
||||
/// <summary>La session n'est plus valable (expirée, révoquée) : il faut se reconnecter.</summary>
|
||||
public static bool IsSessionLost(Exception ex) =>
|
||||
ex is ApiException { Status: 401, Code: ApiErrorCodes.Unauthorized };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Socle des modules du launcher (sans WPF) : contrat d'un module, registre, pages du menu
|
||||
d'administration, boîtes de dialogue abstraites, MVVM commun. Chaque module (Administration,
|
||||
puis Actualités, Amis…) est une bibliothèque qui référence ce projet : sa logique et ses
|
||||
ViewModels sont testables sans interface ; le launcher (WPF) n'en fournit que l'affichage.
|
||||
Voir docs\ADMIN-ARCHITECTURE.md, § 5.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>GameLauncher.Modules</RootNamespace>
|
||||
<AssemblyName>GameLauncher.Modules</AssemblyName>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameLauncher.Core\GameLauncher.Core.csproj" />
|
||||
<ProjectReference Include="..\GameLauncher.ApiClient\GameLauncher.ApiClient.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,74 @@
|
||||
using GameLauncher.Modules.Admin;
|
||||
|
||||
namespace GameLauncher.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// Module du launcher. Il déclare ce qu'il apporte au registre ; il ne connaît ni les fenêtres ni
|
||||
/// les autres modules. Les modules actifs sont listés dans App (launcher WPF).
|
||||
/// </summary>
|
||||
public interface ILauncherModule
|
||||
{
|
||||
/// <summary>Identifiant stable, ex. « admin », « news ».</summary>
|
||||
string Id { get; }
|
||||
|
||||
string DisplayName { get; }
|
||||
|
||||
void Register(IModuleRegistry registry);
|
||||
}
|
||||
|
||||
/// <summary>Ce qu'un module peut ajouter au launcher.</summary>
|
||||
public interface IModuleRegistry
|
||||
{
|
||||
/// <summary>Page du menu d'administration (affichée seulement aux administrateurs ayant la permission requise).</summary>
|
||||
void AddAdminPanel(AdminPanelDescriptor panel);
|
||||
}
|
||||
|
||||
/// <summary>Modules actifs et ce qu'ils ont déclaré.</summary>
|
||||
public sealed class ModuleCatalog : IModuleRegistry
|
||||
{
|
||||
private readonly List<AdminPanelDescriptor> _adminPanels = new();
|
||||
private ILauncherModule? _registering;
|
||||
|
||||
public ModuleCatalog(IEnumerable<ILauncherModule> modules)
|
||||
{
|
||||
var list = new List<ILauncherModule>();
|
||||
foreach (var module in modules)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(module.Id) || list.Any(m => string.Equals(m.Id, module.Id, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException($"Identifiant de module invalide ou en double : « {module.Id} ».");
|
||||
list.Add(module);
|
||||
_registering = module;
|
||||
try
|
||||
{
|
||||
module.Register(this);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_registering = null;
|
||||
}
|
||||
}
|
||||
Modules = list;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ILauncherModule> Modules { get; }
|
||||
|
||||
/// <summary>Toutes les pages d'administration, dans l'ordre d'affichage.</summary>
|
||||
public IReadOnlyList<AdminPanelDescriptor> AdminPanels =>
|
||||
_adminPanels.OrderBy(p => p.Order).ThenBy(p => p.Title, StringComparer.CurrentCulture).ToList();
|
||||
|
||||
/// <summary>Pages visibles pour un administrateur (selon ses permissions ; le serveur reste seul juge).</summary>
|
||||
public IReadOnlyList<AdminPanelDescriptor> AdminPanelsFor(IReadOnlyCollection<string> permissions) =>
|
||||
AdminPanels.Where(p => p.RequiredPermission is null || permissions.Contains(p.RequiredPermission)).ToList();
|
||||
|
||||
void IModuleRegistry.AddAdminPanel(AdminPanelDescriptor panel)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(panel);
|
||||
if (_registering is null) throw new InvalidOperationException("Les pages se déclarent dans ILauncherModule.Register.");
|
||||
if (string.IsNullOrWhiteSpace(panel.Id) || _adminPanels.Any(p => string.Equals(p.Id, panel.Id, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException($"Page d'administration invalide ou en double : « {panel.Id} » (module {_registering.Id}).");
|
||||
if (panel.RequiredPermission is { } permission &&
|
||||
(permission.Length > 64 || permission.Count(c => c == '.') != 1 || permission.StartsWith('.') || permission.EndsWith('.')))
|
||||
throw new InvalidOperationException($"Permission invalide pour la page « {panel.Id} » : « {permission} » (format module.action).");
|
||||
_adminPanels.Add(panel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace GameLauncher.Modules;
|
||||
|
||||
public enum ModuleMessageKind
|
||||
{
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>Boîtes de dialogue et actions système offertes aux modules (implémentées par le launcher WPF).</summary>
|
||||
public interface IModuleDialogs
|
||||
{
|
||||
void ShowMessage(string title, string message, ModuleMessageKind kind = ModuleMessageKind.Info);
|
||||
|
||||
bool Confirm(string title, string message, string confirmText, string cancelText = "Annuler", bool danger = false);
|
||||
|
||||
/// <param name="filter">Filtre au format Windows, ex. « Clé d'administrateur (*.key)|*.key ».</param>
|
||||
string? PickOpenFile(string title, string filter, string? initialDirectory);
|
||||
|
||||
string? PickSaveFile(string title, string filter, string defaultFileName, string? initialDirectory);
|
||||
|
||||
void CopyToClipboard(string text);
|
||||
|
||||
void OpenFolder(string path);
|
||||
}
|
||||
|
||||
/// <summary>Exécution sur le fil de l'interface (minuteries, notifications venues d'un autre fil).</summary>
|
||||
public interface IUiDispatcher
|
||||
{
|
||||
void Post(Action action);
|
||||
}
|
||||
|
||||
/// <summary>Exécution immédiate (tests, outils en ligne de commande).</summary>
|
||||
public sealed class ImmediateDispatcher : IUiDispatcher
|
||||
{
|
||||
public static readonly ImmediateDispatcher Instance = new();
|
||||
|
||||
public void Post(Action action) => action();
|
||||
}
|
||||
File renamed without changes.
+5
-2
@@ -43,9 +43,12 @@ public sealed class AsyncRelayCommand : ICommand
|
||||
|
||||
public bool CanExecute(object? parameter) => !_isRunning && (_canExecute?.Invoke() ?? true);
|
||||
|
||||
public async void Execute(object? parameter)
|
||||
public async void Execute(object? parameter) => await ExecuteAsync();
|
||||
|
||||
/// <summary>Exécute la commande et attend la fin (tests, enchaînements).</summary>
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
if (!CanExecute(parameter)) return;
|
||||
if (!CanExecute(null)) return;
|
||||
_isRunning = true;
|
||||
RaiseCanExecuteChanged();
|
||||
try
|
||||
@@ -11,6 +11,8 @@
|
||||
<ResourceDictionary Source="Themes/Controls.xaml" />
|
||||
<!-- Notes de version (format enrichi) : modèles partagés par la fenêtre principale et la fenêtre agrandie -->
|
||||
<ResourceDictionary Source="Themes/Changelog.xaml" />
|
||||
<!-- Menu d'administration : styles et modèles des pages des modules (Views/Admin) -->
|
||||
<ResourceDictionary Source="Themes/Admin.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
+137
-11
@@ -9,6 +9,8 @@ using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Services;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Admin;
|
||||
using GameLauncher.Platform;
|
||||
using GameLauncher.Services;
|
||||
using GameLauncher.ViewModels;
|
||||
@@ -34,12 +36,16 @@ public partial class App : Application, ILauncherRestartHost
|
||||
private bool _ownsMutex;
|
||||
private string? _mutexName;
|
||||
private InstanceChannel? _instanceChannel;
|
||||
private TrayIcon? _trayIcon;
|
||||
private bool _migrateLegacyStorage;
|
||||
private WindowService? _windowService;
|
||||
private LauncherCatalog? _catalog;
|
||||
private CommandLine? _args;
|
||||
private LauncherViewModel? _launcher;
|
||||
private Views.MainWindow? _mainWindow;
|
||||
private HttpDownloader? _http;
|
||||
private LauncherUpdateService? _updates;
|
||||
private AdminWindowService? _admin;
|
||||
private string _launcherPath = "";
|
||||
private Version _version = new(1, 0, 0);
|
||||
|
||||
@@ -51,6 +57,13 @@ public partial class App : Application, ILauncherRestartHost
|
||||
SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(Dispatcher));
|
||||
|
||||
DispatcherUnhandledException += OnDispatcherUnhandledException;
|
||||
|
||||
// Fermeture de session / arrêt de Windows : le launcher se ferme vraiment
|
||||
// (pas de réduction dans la zone de notification, pas de confirmation bloquante).
|
||||
SessionEnding += (_, _) =>
|
||||
{
|
||||
if (_launcher is not null) _launcher.IsExitConfirmed = true;
|
||||
};
|
||||
TaskScheduler.UnobservedTaskException += (_, args) =>
|
||||
{
|
||||
Log.Error("Tâche non observée", args.Exception);
|
||||
@@ -75,7 +88,20 @@ public partial class App : Application, ILauncherRestartHost
|
||||
return;
|
||||
}
|
||||
|
||||
// Données du launcher : %AppData%\<Publisher>\GameLauncher et HKCU\Software\<Publisher>\GameLauncher.
|
||||
// Les emplacements des versions précédentes (EZRLauncher, HKCU\Software\<Publisher>\<GameId>) y sont
|
||||
// déplacés, seulement si aucun autre launcher n'est ouvert : jamais pendant le démarrage qui suit
|
||||
// une mise à jour du launcher (l'ancienne version, encore ouverte, peut devoir être remise en place).
|
||||
_migrateLegacyStorage = args.CaptureDirectory is null && args.UpdatedFromPid is null &&
|
||||
!IsInstanceRunning(catalog.SingleInstanceName);
|
||||
var storageNotes = catalog.PrepareStorage(_migrateLegacyStorage);
|
||||
|
||||
Log.Init(Path.Combine(catalog.DataDirectory, "logs"));
|
||||
foreach (var note in storageNotes)
|
||||
{
|
||||
if (note.StartsWith("! ", StringComparison.Ordinal)) Log.Warn(note[2..]);
|
||||
else Log.Info(note);
|
||||
}
|
||||
_catalog = catalog;
|
||||
|
||||
_launcherPath = Environment.ProcessPath ?? Path.Combine(AppContext.BaseDirectory, "GameLauncher.exe");
|
||||
@@ -116,7 +142,9 @@ public partial class App : Application, ILauncherRestartHost
|
||||
return;
|
||||
}
|
||||
|
||||
ModernDialog.ShowMessage(null, catalog.LauncherName, "Le launcher est déjà ouvert.", DialogKind.Info);
|
||||
ModernDialog.ShowMessage(null, catalog.LauncherName,
|
||||
"Le launcher est déjà ouvert.\n\nS'il n'est pas visible, il est peut-être réduit dans la zone de notification : " +
|
||||
"cliquez sur son icône près de l'horloge (flèche « Afficher les icônes cachées »).", DialogKind.Info);
|
||||
Shutdown(0);
|
||||
return;
|
||||
}
|
||||
@@ -204,8 +232,34 @@ public partial class App : Application, ILauncherRestartHost
|
||||
var window = new Views.MainWindow();
|
||||
MainWindow = window;
|
||||
_mainWindow = window;
|
||||
var windowService = new WindowService(window);
|
||||
|
||||
// Icône de la zone de notification : affichée seulement quand la fenêtre est masquée
|
||||
// (croix avec l'option correspondante, ou pendant la partie). Pas en mode --capture.
|
||||
TrayIcon? tray = null;
|
||||
if (!readOnly)
|
||||
{
|
||||
try
|
||||
{
|
||||
tray = new TrayIcon(launcherPath, catalog.LauncherName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Icône de la zone de notification indisponible", ex);
|
||||
}
|
||||
}
|
||||
_trayIcon = tray;
|
||||
var windowService = new WindowService(window, tray);
|
||||
_windowService = windowService;
|
||||
var dialogService = new DialogService(window);
|
||||
if (tray is not null)
|
||||
{
|
||||
tray.OpenRequested += windowService.Restore;
|
||||
tray.ExitRequested += () => _launcher?.Quit();
|
||||
window.IsVisibleChanged += (_, e) =>
|
||||
{
|
||||
if (e.NewValue is true) tray.Hide(); // fenêtre réaffichée : l'icône n'a plus lieu d'être
|
||||
};
|
||||
}
|
||||
|
||||
// Préférences communes à tous les jeux (roue crantée). Premier démarrage après la séparation
|
||||
// des paramètres : les réglages de lancement sont repris de ceux du jeu affiché par défaut.
|
||||
@@ -213,24 +267,27 @@ public partial class App : Application, ILauncherRestartHost
|
||||
var migrationSource = catalog.Find(requestedGameId) ?? catalog.Find(catalog.DefaultGameId) ?? catalog.Games[0];
|
||||
var preferences = readOnly && !File.Exists(preferencesPath)
|
||||
? new LauncherPreferences() // --capture : aucune écriture
|
||||
: LauncherPreferences.Load(preferencesPath, LauncherSettings.Load(LauncherSettings.GetDefaultPath(migrationSource)));
|
||||
: LauncherPreferences.Load(preferencesPath, LauncherSettings.Load(catalog.GetGameSettingsPath(migrationSource)));
|
||||
|
||||
var launcher = new LauncherViewModel(catalog, windowService, dialogService, preferences, version, _updates);
|
||||
_launcher = launcher;
|
||||
|
||||
foreach (var config in catalog.Games)
|
||||
{
|
||||
var settingsPath = LauncherSettings.GetDefaultPath(config);
|
||||
var settingsPath = catalog.GetGameSettingsPath(config); // GameLauncher\Games\<GameId>\settings.json
|
||||
var settings = LauncherSettings.Load(settingsPath);
|
||||
var installer = new GameInstaller(_http!);
|
||||
var manifestClient = new ManifestClient(_http!, new Uri(config.ManifestUrl));
|
||||
|
||||
// Si le launcher a été déplacé depuis l'installation du jeu, le bouton « Désinstaller »
|
||||
// de Windows doit pointer vers son nouvel emplacement. (Pas en mode --capture.)
|
||||
var registry = new RegistryService(config, launcherPath);
|
||||
var shortcuts = new ShortcutService(config, launcherPath);
|
||||
if (!readOnly)
|
||||
// Seul le launcher réellement ouvert (verrou d'instance unique obtenu) écrit dans le registre :
|
||||
// un second launcher s'arrête avant d'arriver ici (« Le launcher est déjà ouvert »).
|
||||
var registry = new RegistryService(config, launcherPath, catalog.DataFolderName, ownsLauncherInstance: _ownsMutex);
|
||||
var shortcuts = new ShortcutService(config, launcherPath, registry); // icône : exécutable du jeu (registre)
|
||||
if (!readOnly && _ownsMutex)
|
||||
{
|
||||
if (_migrateLegacyStorage) registry.MigrateLegacyKey(); // HKCU\Software\<Publisher>\<GameId> → GameLauncher\Games\<GameId>
|
||||
registry.RefreshLauncherPath();
|
||||
shortcuts.RefreshDesktopShortcut(); // ex. après une mise à jour qui a renommé l'exe en GameLauncher.exe
|
||||
}
|
||||
@@ -252,7 +309,8 @@ public partial class App : Application, ILauncherRestartHost
|
||||
gameDialogs,
|
||||
gameWindow,
|
||||
version,
|
||||
_updates)
|
||||
_updates,
|
||||
catalog.DataDirectory) // jamais supprimé par « Supprimer aussi les sauvegardes… »
|
||||
{
|
||||
IconImage = ThemeManager.LoadImage(config.Icon, 160),
|
||||
TileImage = ThemeManager.Peek(theme, "BackgroundImage", config.Background) as ImageSource,
|
||||
@@ -268,12 +326,56 @@ public partial class App : Application, ILauncherRestartHost
|
||||
launcher.SelectedGame = launcher.ResolveInitialGame(requestedGameId); // applique le thème du jeu
|
||||
launcher.AttachTransition(window);
|
||||
|
||||
// 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);
|
||||
_admin = new AdminWindowService(window, CreateModules(), catalog, apiUrl, apiUrlError, version);
|
||||
window.AdminRequested += _admin.Open;
|
||||
}
|
||||
|
||||
window.DataContext = launcher;
|
||||
window.Closed += (_, _) => Shutdown();
|
||||
window.Show();
|
||||
return window;
|
||||
}
|
||||
|
||||
/// <summary>Modules du launcher (pages du menu d'administration…). Les futurs modules s'ajoutent ici.</summary>
|
||||
private static ModuleCatalog CreateModules() => new(new ILauncherModule[]
|
||||
{
|
||||
new AdminModule()
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Adresse de l'API d'administration (« apiUrl »). En Release, elle n'est lue QUE dans le
|
||||
/// launcher.json intégré à l'exécutable : un launcher.json posé à côté de l'exe (ou --config) ne
|
||||
/// peut pas rediriger les administrateurs vers un autre serveur. En Debug, la configuration
|
||||
/// utilisée est suivie (serveur local http://127.0.0.1:5080/api/ pour les tests).
|
||||
/// </summary>
|
||||
/// <returns>L'adresse (ou null) et, si elle a été ignorée car invalide, la raison.</returns>
|
||||
private static (string? ApiUrl, string? Error) ResolveAdminApiUrl(LauncherCatalog catalog)
|
||||
{
|
||||
#if DEBUG
|
||||
if (catalog.ApiUrlError is not null) Log.Warn(catalog.ApiUrlError);
|
||||
return (catalog.ApiUrl, catalog.ApiUrlError);
|
||||
#else
|
||||
try
|
||||
{
|
||||
using var embedded = Assembly.GetExecutingAssembly().GetManifestResourceStream(ConfigFileName);
|
||||
var integrated = embedded is null ? null : LauncherCatalog.Load(embedded);
|
||||
if (catalog.ApiUrl is not null && !string.Equals(catalog.ApiUrl, integrated?.ApiUrl, StringComparison.Ordinal))
|
||||
Log.Warn("« apiUrl » du launcher.json externe ignorée : seule celle intégrée à l'exécutable est utilisée.");
|
||||
if (integrated?.ApiUrlError is { } error) Log.Warn(error);
|
||||
return (integrated?.ApiUrl, integrated?.ApiUrlError);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Adresse de l'API d'administration (launcher.json intégré)", ex);
|
||||
return (null, "launcher.json intégré illisible.");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>Option --capture : une capture PNG par jeu (état réel, sans action automatique), puis fermeture.</summary>
|
||||
private async Task CaptureAsync(Views.MainWindow window, string directory)
|
||||
{
|
||||
@@ -290,7 +392,7 @@ public partial class App : Application, ILauncherRestartHost
|
||||
|
||||
// Paramètres du jeu, dans le thème du jeu (vérifie aussi les boîtes de dialogue).
|
||||
var info = new GameSettingsInfo(game.GameName, game.InstallPath, game.InstalledVersionText,
|
||||
game.LatestVersionText, game.InstallSizeText);
|
||||
game.LatestVersionText, game.InstallSizeText, game.Config.GameLogsDirectory, game.Config.GameDataDirectory);
|
||||
await CaptureSettingsAsync(window, new GameSettingsViewModel(new LauncherSettings(), info, new WindowService(window)),
|
||||
Path.Combine(directory, $"{game.GameId}-parametres.png"));
|
||||
}
|
||||
@@ -326,6 +428,10 @@ public partial class App : Application, ILauncherRestartHost
|
||||
{
|
||||
_instanceChannel?.Dispose();
|
||||
_instanceChannel = null;
|
||||
_admin?.Dispose(); // ferme le menu d'administration : clé effacée de la mémoire
|
||||
_admin = null;
|
||||
_trayIcon?.Dispose(); // sinon l'icône resterait affichée jusqu'au survol
|
||||
_trayIcon = null;
|
||||
_launcher?.Dispose();
|
||||
_http?.Dispose();
|
||||
ReleaseSingleInstance();
|
||||
@@ -335,6 +441,21 @@ public partial class App : Application, ILauncherRestartHost
|
||||
|
||||
// ------------------------------------------------------------------ Instance unique
|
||||
|
||||
/// <summary>Vrai si un autre launcher (même configuration) est ouvert : son verrou d'instance unique existe.</summary>
|
||||
private static bool IsInstanceRunning(string mutexName)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Mutex.TryOpenExisting(mutexName, out var existing)) return false;
|
||||
existing.Dispose();
|
||||
return true;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return true; // verrou existant, mais inaccessible : par prudence, considéré comme ouvert
|
||||
}
|
||||
}
|
||||
|
||||
private bool AcquireSingleInstance(TimeSpan wait)
|
||||
{
|
||||
if (_mutexName is null) return true;
|
||||
@@ -373,6 +494,9 @@ public partial class App : Application, ILauncherRestartHost
|
||||
if (_launcher is null || _mainWindow is null) return;
|
||||
if (_updates?.IsInstalling == true) return; // le launcher redémarre dans sa nouvelle version
|
||||
|
||||
// Launcher masqué dans la zone de notification : la fenêtre est d'abord réaffichée.
|
||||
if (!_mainWindow.IsVisible) _windowService?.Restore();
|
||||
|
||||
// Une boîte de dialogue est ouverte (confirmation, choix de dossier…) : on ne lance rien
|
||||
// derrière elle, la fenêtre revient simplement au premier plan.
|
||||
if (!IsMainWindowInteractive())
|
||||
@@ -529,8 +653,10 @@ public partial class App : Application, ILauncherRestartHost
|
||||
}
|
||||
|
||||
// Sans fenêtre principale visible, le processus resterait ouvert en arrière-plan.
|
||||
// (Sauf pendant la mise à jour du launcher : la fenêtre est masquée volontairement.)
|
||||
if (MainWindow is not { IsVisible: true } && _updates?.IsInstalling != true) Shutdown(1);
|
||||
// (Sauf pendant la mise à jour du launcher, ou réduit dans la zone de notification :
|
||||
// la fenêtre est alors masquée volontairement.)
|
||||
if (MainWindow is not { IsVisible: true } && _updates?.IsInstalling != true && _trayIcon?.IsVisible != true)
|
||||
Shutdown(1);
|
||||
}
|
||||
|
||||
/// <summary>Lecture des arguments de la ligne de commande.</summary>
|
||||
|
||||
@@ -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>1.2.0</Version>
|
||||
<Version>1.3.0</Version>
|
||||
<Authors>Lanisys</Authors>
|
||||
<Company>LanisysGames</Company>
|
||||
<Copyright>© LanisysGames</Copyright>
|
||||
@@ -49,6 +49,9 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameLauncher.Core\GameLauncher.Core.csproj" />
|
||||
<!-- Modules (SDK commun + menu d'administration) : logique et ViewModels, sans WPF ; les vues sont ici (Views\Admin, Themes\Admin.xaml). -->
|
||||
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
|
||||
<ProjectReference Include="..\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Services;
|
||||
using GameLauncher.Views;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace GameLauncher.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Boîtes de dialogue offertes aux modules (menu d'administration…) : mêmes fenêtres que le reste
|
||||
/// du launcher (ModernDialog), au-dessus de la fenêtre du module.
|
||||
/// </summary>
|
||||
public sealed class ModuleDialogs : IModuleDialogs
|
||||
{
|
||||
private readonly Func<Window?> _owner;
|
||||
|
||||
/// <param name="owner">Fenêtre propriétaire du moment (null : centrée sur l'écran).</param>
|
||||
public ModuleDialogs(Func<Window?> owner) => _owner = owner;
|
||||
|
||||
private Window? Owner => _owner() is { IsLoaded: true, IsVisible: true } window ? window : null;
|
||||
|
||||
public void ShowMessage(string title, string message, ModuleMessageKind kind = ModuleMessageKind.Info) =>
|
||||
ModernDialog.ShowMessage(Owner, title, message, kind switch
|
||||
{
|
||||
ModuleMessageKind.Success => DialogKind.Success,
|
||||
ModuleMessageKind.Warning => DialogKind.Warning,
|
||||
ModuleMessageKind.Error => DialogKind.Error,
|
||||
_ => DialogKind.Info
|
||||
});
|
||||
|
||||
public bool Confirm(string title, string message, string confirmText, string cancelText = "Annuler", bool danger = false) =>
|
||||
ModernDialog.ShowConfirm(Owner, title, message, confirmText, cancelText,
|
||||
danger ? DialogKind.Danger : DialogKind.Question, null).Confirmed;
|
||||
|
||||
public string? PickOpenFile(string title, string filter, string? initialDirectory)
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = title,
|
||||
Filter = filter,
|
||||
Multiselect = false,
|
||||
CheckFileExists = true,
|
||||
DereferenceLinks = true
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(initialDirectory) && Directory.Exists(initialDirectory))
|
||||
dialog.InitialDirectory = initialDirectory;
|
||||
var owner = Owner;
|
||||
var result = owner is null ? dialog.ShowDialog() : dialog.ShowDialog(owner);
|
||||
return result == true ? dialog.FileName : null;
|
||||
}
|
||||
|
||||
public string? PickSaveFile(string title, string filter, string defaultFileName, string? initialDirectory)
|
||||
{
|
||||
var dialog = new SaveFileDialog
|
||||
{
|
||||
Title = title,
|
||||
Filter = filter,
|
||||
FileName = defaultFileName,
|
||||
OverwritePrompt = false, // les services refusent d'écraser un fichier existant (ex. une clé)
|
||||
AddExtension = true
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(initialDirectory) && Directory.Exists(initialDirectory))
|
||||
dialog.InitialDirectory = initialDirectory;
|
||||
var owner = Owner;
|
||||
var result = owner is null ? dialog.ShowDialog() : dialog.ShowDialog(owner);
|
||||
return result == true ? dialog.FileName : null;
|
||||
}
|
||||
|
||||
public void CopyToClipboard(string text)
|
||||
{
|
||||
// Le presse-papiers peut être momentanément occupé par une autre application.
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
Clipboard.SetText(text);
|
||||
return;
|
||||
}
|
||||
catch (COMException ex) when (attempt < 3)
|
||||
{
|
||||
Log.Warn($"Presse-papiers occupé ({ex.ErrorCode:X8}), nouvel essai.");
|
||||
Thread.Sleep(60);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Copie dans le presse-papiers impossible", ex);
|
||||
ShowMessage("Copie impossible", "Le presse-papiers est utilisé par une autre application : réessayez.", ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Dossier LOCAL existant uniquement (jamais une adresse web ni un partage réseau \\serveur\…).</summary>
|
||||
public void OpenFolder(string path)
|
||||
{
|
||||
if (!Path.IsPathFullyQualified(path) || path.StartsWith(@"\\", StringComparison.Ordinal) ||
|
||||
path.StartsWith("//", StringComparison.Ordinal) || !Directory.Exists(path))
|
||||
{
|
||||
Log.Warn($"Ouverture de dossier refusée (chemin local attendu) : {path}");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{path}\"") { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error($"Ouverture du dossier impossible : {path}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Services;
|
||||
|
||||
namespace GameLauncher.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Clés d'administrateur dans la puce de sécurité (TPM 2.0) de ce PC, via le fournisseur Windows
|
||||
/// « Microsoft Platform Crypto Provider » (CNG) :
|
||||
/// • la clé ECDSA P-256 est GÉNÉRÉE par la puce ; ce fournisseur ne permet jamais d'exporter la clé
|
||||
/// privée (Windows n'en garde qu'une copie chiffrée par la puce elle-même, inutilisable ailleurs) ;
|
||||
/// • clé de l'utilisateur Windows courant (un autre compte du PC ne peut pas l'ouvrir) ;
|
||||
/// • chaque usage exige le code PIN (« usage authorization » de la clé), vérifié par la puce, qui se
|
||||
/// bloque temporairement après plusieurs codes faux (protection anti-essais du TPM) ;
|
||||
/// • aucune fenêtre Windows : le code est saisi dans le launcher puis effacé ; la clé est ouverte
|
||||
/// en mode silencieux.
|
||||
/// </summary>
|
||||
public sealed class TpmKeyStore : IAdminTpm
|
||||
{
|
||||
/// <summary>Autorisation d'usage de la clé (NCRYPT_PCP_USAGEAUTH_PROPERTY) : SHA-1 du code PIN en UTF-16.</summary>
|
||||
private const string UsageAuthProperty = "PCP_USAGEAUTH";
|
||||
|
||||
private static CngProvider Provider => CngProvider.MicrosoftPlatformCryptoProvider;
|
||||
|
||||
/// <summary>Préfixe des clés de test de <see cref="Check"/> : distinct de celui des vraies clés.</summary>
|
||||
private const string CheckKeyPrefix = "GameLauncher.AdminCheck.";
|
||||
|
||||
public TpmAvailability Check()
|
||||
{
|
||||
if (!OperatingSystem.IsWindowsVersionAtLeast(10))
|
||||
return new TpmAvailability(false, "Clé dans la puce de sécurité : Windows 10 ou plus récent est nécessaire.");
|
||||
|
||||
// Vérification RÉELLE : création d'une clé de test dans la puce, signature, contrôle, suppression.
|
||||
var keyName = CheckKeyPrefix + Guid.NewGuid().ToString("N");
|
||||
var created = false;
|
||||
try
|
||||
{
|
||||
using var key = CreateCore(keyName, Convert.ToHexString(RandomNumberGenerator.GetBytes(16)));
|
||||
created = true;
|
||||
using var signer = new TpmSigner(key);
|
||||
var message = RandomNumberGenerator.GetBytes(32);
|
||||
var signature = signer.SignNow(message);
|
||||
if (!AdminKeys.Verify(signer.PublicKey, message, signature))
|
||||
return new TpmAvailability(false, "La puce de sécurité (TPM) de ce PC a produit une signature invalide.");
|
||||
return new TpmAvailability(true, "Puce de sécurité (TPM 2.0) détectée et vérifiée sur ce PC.");
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
Log.Info($"Puce TPM indisponible : {ex.Message} (code 0x{CodeOf(ex):X8})");
|
||||
return new TpmAvailability(false, DescribeUnavailable(ex));
|
||||
}
|
||||
catch (Exception ex) when (ex is PlatformNotSupportedException or NotSupportedException or ArgumentException)
|
||||
{
|
||||
Log.Info($"Puce TPM indisponible : {ex.GetType().Name} {ex.Message}");
|
||||
return new TpmAvailability(false, "Aucune puce de sécurité (TPM 2.0) utilisable sur ce PC.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (created) TryDelete(keyName);
|
||||
}
|
||||
}
|
||||
|
||||
public IDisposableAdminSigner CreateKey(string keyName, string pin)
|
||||
{
|
||||
EnsureKeyName(keyName);
|
||||
CngKey key;
|
||||
try
|
||||
{
|
||||
key = CreateCore(keyName, pin);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
throw Wrap(DescribeUnavailable(ex), ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return new TpmSigner(key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
key.Dispose();
|
||||
TryDelete(keyName); // jamais de clé orpheline dans la puce
|
||||
if (ex is CryptographicException crypto) throw Describe(crypto);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public IDisposableAdminSigner OpenKey(string keyName, byte[] expectedPublicKey, string pin)
|
||||
{
|
||||
EnsureKeyName(keyName);
|
||||
CngKey key;
|
||||
try
|
||||
{
|
||||
key = CngKey.Open(keyName, Provider, CngKeyOpenOptions.Silent);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
throw Describe(ex);
|
||||
}
|
||||
|
||||
TpmSigner? signer = null;
|
||||
try
|
||||
{
|
||||
// Clé publique comparée AVANT tout essai du code : une référence trompeuse ne consomme
|
||||
// aucun essai de la protection anti-essais de la puce.
|
||||
if (!TpmSigner.ReadPublicKey(key).AsSpan().SequenceEqual(expectedPublicKey))
|
||||
throw new CryptographicException("La clé de la puce ne correspond pas à ce fichier de référence : fichier modifié ?");
|
||||
SetUsageAuth(key, pin);
|
||||
signer = new TpmSigner(key);
|
||||
// Le code PIN n'est vérifié par la puce qu'à l'usage : signature de test AVANT de contacter le serveur.
|
||||
signer.SignNow(RandomNumberGenerator.GetBytes(32));
|
||||
return signer;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (signer is not null) signer.Dispose();
|
||||
else key.Dispose();
|
||||
if (ex is CryptographicException crypto) throw Describe(crypto);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteKey(string keyName)
|
||||
{
|
||||
EnsureKeyName(keyName);
|
||||
DeleteCore(keyName);
|
||||
}
|
||||
|
||||
/// <summary>Crée la clé (code PIN fixé avant la finalisation) et fournit le code pour la poignée ouverte.</summary>
|
||||
private static CngKey CreateCore(string keyName, string pin)
|
||||
{
|
||||
var parameters = new CngKeyCreationParameters
|
||||
{
|
||||
Provider = Provider,
|
||||
KeyCreationOptions = CngKeyCreationOptions.None // clé de l'utilisateur Windows (pas de la machine)
|
||||
};
|
||||
var auth = UsageAuth(pin);
|
||||
try
|
||||
{
|
||||
parameters.Parameters.Add(new CngProperty(UsageAuthProperty, auth, CngPropertyOptions.None));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(auth);
|
||||
}
|
||||
|
||||
var key = CngKey.Create(CngAlgorithm.ECDsaP256, keyName, parameters);
|
||||
try
|
||||
{
|
||||
SetUsageAuth(key, pin); // une fois finalisée, la clé exige son code pour cette poignée aussi
|
||||
return key;
|
||||
}
|
||||
catch
|
||||
{
|
||||
key.Dispose();
|
||||
TryDelete(keyName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetUsageAuth(CngKey key, string pin)
|
||||
{
|
||||
var auth = UsageAuth(pin);
|
||||
try
|
||||
{
|
||||
key.SetProperty(new CngProperty(UsageAuthProperty, auth, CngPropertyOptions.None));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(auth);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteCore(string keyName)
|
||||
{
|
||||
if (!CngKey.Exists(keyName, Provider, CngKeyOpenOptions.Silent)) return;
|
||||
using var key = CngKey.Open(keyName, Provider, CngKeyOpenOptions.Silent);
|
||||
key.Delete(); // supprime et libère
|
||||
}
|
||||
|
||||
private static void TryDelete(string keyName)
|
||||
{
|
||||
try
|
||||
{
|
||||
DeleteCore(keyName);
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or PlatformNotSupportedException)
|
||||
{
|
||||
Log.Warn($"Clé {keyName} non supprimée de la puce ({ex.Message}).");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureKeyName(string keyName)
|
||||
{
|
||||
// Seules les clés créées par le launcher peuvent être ouvertes ou supprimées.
|
||||
if (!AdminTpmKeyFile.IsValidKeyName(keyName)) throw new ArgumentException("Nom de clé TPM invalide.", nameof(keyName));
|
||||
}
|
||||
|
||||
/// <summary>SHA-1 du code en UTF-16 (sans zéro final) : format attendu par le fournisseur pour l'autorisation d'usage.</summary>
|
||||
private static byte[] UsageAuth(string pin)
|
||||
{
|
||||
var bytes = Encoding.Unicode.GetBytes(pin);
|
||||
try
|
||||
{
|
||||
return SHA1.HashData(bytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Protection anti-essais
|
||||
|
||||
/// <summary>
|
||||
/// Lit l'état de la protection anti-essais par la commande TPM2_GetCapability (lecture seule,
|
||||
/// sans autorisation), envoyée par le service TPM de Windows (TBS). Null si la lecture est
|
||||
/// impossible (pas de puce, commande réservée aux administrateurs sur ce PC…).
|
||||
/// </summary>
|
||||
public TpmLockoutInfo? GetLockoutInfo()
|
||||
{
|
||||
if (!OperatingSystem.IsWindowsVersionAtLeast(10)) return null;
|
||||
var context = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
var parameters = new TbsContextParams2 { Version = 2, Flags = 1u << 2 }; // TBS_CONTEXT_VERSION_TWO, includeTpm20
|
||||
if (Tbsi_Context_Create(ref parameters, out context) != 0) return null;
|
||||
|
||||
// TPM2_GetCapability(TPM_CAP_TPM_PROPERTIES, TPM_PT_PERMANENT…TPM_PT_LOCKOUT_RECOVERY)
|
||||
const uint FirstProperty = 0x200, PropertyCount = 0x12;
|
||||
var command = new byte[22];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(command.AsSpan(0), 0x8001); // TPM_ST_NO_SESSIONS
|
||||
BinaryPrimitives.WriteUInt32BigEndian(command.AsSpan(2), 22); // taille
|
||||
BinaryPrimitives.WriteUInt32BigEndian(command.AsSpan(6), 0x0000017A); // TPM_CC_GetCapability
|
||||
BinaryPrimitives.WriteUInt32BigEndian(command.AsSpan(10), 6); // TPM_CAP_TPM_PROPERTIES
|
||||
BinaryPrimitives.WriteUInt32BigEndian(command.AsSpan(14), FirstProperty);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(command.AsSpan(18), PropertyCount);
|
||||
|
||||
var response = new byte[512];
|
||||
var length = (uint)response.Length;
|
||||
if (Tbsip_Submit_Command(context, 0, 200, command, (uint)command.Length, response, ref length) != 0) return null;
|
||||
var data = response.AsSpan(0, (int)Math.Min(length, (uint)response.Length));
|
||||
if (data.Length < 19 || BinaryPrimitives.ReadUInt32BigEndian(data[6..]) != 0) return null; // code retour TPM
|
||||
if (BinaryPrimitives.ReadUInt32BigEndian(data[11..]) != 6) return null;
|
||||
var count = BinaryPrimitives.ReadUInt32BigEndian(data[15..]);
|
||||
|
||||
uint? permanent = null, counter = null, maxTries = null, interval = null;
|
||||
for (var i = 0; i < count && 19 + (i + 1) * 8 <= data.Length; i++)
|
||||
{
|
||||
var entry = data.Slice(19 + i * 8, 8);
|
||||
var value = BinaryPrimitives.ReadUInt32BigEndian(entry[4..]);
|
||||
switch (BinaryPrimitives.ReadUInt32BigEndian(entry))
|
||||
{
|
||||
case 0x200: permanent = value; break; // TPM_PT_PERMANENT
|
||||
case 0x20E: counter = value; break; // TPM_PT_LOCKOUT_COUNTER
|
||||
case 0x20F: maxTries = value; break; // TPM_PT_MAX_AUTH_FAIL
|
||||
case 0x210: interval = value; break; // TPM_PT_LOCKOUT_INTERVAL (secondes)
|
||||
}
|
||||
}
|
||||
if (counter is null || maxTries is null || interval is null) return null;
|
||||
var inLockout = permanent is { } attributes && (attributes & (1u << 2)) != 0; // TPMA_PERMANENT.inLockout
|
||||
return new TpmLockoutInfo((int)Math.Min(counter.Value, int.MaxValue), (int)Math.Min(maxTries.Value, int.MaxValue),
|
||||
TimeSpan.FromSeconds(interval.Value), inLockout);
|
||||
}
|
||||
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or ArgumentOutOfRangeException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (context != IntPtr.Zero) Tbsip_Context_Close(context);
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct TbsContextParams2
|
||||
{
|
||||
public uint Version;
|
||||
public uint Flags;
|
||||
}
|
||||
|
||||
[DllImport("tbs.dll", ExactSpelling = true)]
|
||||
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
|
||||
private static extern uint Tbsi_Context_Create(ref TbsContextParams2 parameters, out IntPtr context);
|
||||
|
||||
[DllImport("tbs.dll", ExactSpelling = true)]
|
||||
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
|
||||
private static extern uint Tbsip_Submit_Command(IntPtr context, uint locality, uint priority, byte[] command, uint commandLength,
|
||||
byte[] result, ref uint resultLength);
|
||||
|
||||
[DllImport("tbs.dll", ExactSpelling = true)]
|
||||
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
|
||||
private static extern uint Tbsip_Context_Close(IntPtr context);
|
||||
|
||||
// ------------------------------------------------------------------ Messages
|
||||
|
||||
private static class Codes
|
||||
{
|
||||
// Code PIN refusé par la puce (codes TPM normalisés, voir Normalize) ou par le fournisseur.
|
||||
public const uint TpmAuthFail = 0x8028008E, TpmBadAuth = 0x802800A2, Tpm12AuthFail = 0x80280001, Tpm12Auth2Fail = 0x8028001D,
|
||||
PcpAuthenticationFailed = 0x80290408;
|
||||
// Protection anti-essais active.
|
||||
public const uint TpmLockout = 0x80280921, TpmDefendLockRunning = 0x80280803;
|
||||
// Clé absente, ou plus chargeable (puce réinitialisée, profil Windows itinérant…).
|
||||
public const uint BadKeyset = 0x80090016, NoKey = 0x8009000D, NotFound = 0x80090011,
|
||||
PcpWrongParent = 0x8029040E, PcpKeyNotLoaded = 0x8029040F;
|
||||
// Puce absente, désactivée ou pas prête.
|
||||
public const uint TbsTpmNotFound = 0x8028400F, TbsServiceNotRunning = 0x80284008, TbsServiceDisabled = 0x80284010,
|
||||
PcpDeviceNotReady = 0x80290401, DeviceNotReady = 0x80090030, DeviceNotFound = 0x80090035,
|
||||
ProviderNotFound = 0x8009001F, ProviderDllNotFound = 0x8009001D;
|
||||
// Algorithme non géré (TPM 1.2 : pas de courbes elliptiques).
|
||||
public const uint NotSupported = 0x80090029, BadAlgId = 0x80090008, TpmCurve = 0x802800A6;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Code Windows d'origine (les exceptions décrites ci-dessous enveloppent l'erreur réelle).
|
||||
/// Codes TPM 2.0 « format 1 » (0x8028_0xxx avec le bit 0x080) : les bits qui désignent la session
|
||||
/// ou le paramètre en cause sont retirés (ex. 0x8028098E → 0x8028008E, code PIN refusé).
|
||||
/// </summary>
|
||||
private static uint CodeOf(Exception ex)
|
||||
{
|
||||
for (Exception? current = ex; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is not CryptographicException || (uint)current.HResult >> 16 == 0x8013) continue; // code .NET générique
|
||||
var hr = (uint)current.HResult;
|
||||
if ((hr & 0xFFFFF000) == 0x80280000 && (hr & 0x80) != 0) hr = 0x80280000 | 0x80 | (hr & 0x3F);
|
||||
return hr;
|
||||
}
|
||||
return (uint)ex.HResult;
|
||||
}
|
||||
|
||||
/// <summary>Message français, en conservant l'erreur d'origine (et son code) comme cause.</summary>
|
||||
private static CryptographicException Wrap(string message, CryptographicException ex) => new(message, ex);
|
||||
|
||||
private static CryptographicException Describe(CryptographicException ex) => CodeOf(ex) switch
|
||||
{
|
||||
Codes.TpmAuthFail or Codes.TpmBadAuth or Codes.Tpm12AuthFail or Codes.Tpm12Auth2Fail or Codes.PcpAuthenticationFailed =>
|
||||
new TpmPinException("Code PIN incorrect. Après plusieurs erreurs, la puce se bloque temporairement (protection anti-essais).",
|
||||
isLockedOut: false, ex),
|
||||
Codes.TpmLockout or Codes.TpmDefendLockRunning =>
|
||||
new TpmPinException("Trop de codes PIN faux : la puce de sécurité est temporairement bloquée.", isLockedOut: true, ex),
|
||||
Codes.BadKeyset or Codes.NoKey or Codes.NotFound or Codes.PcpWrongParent or Codes.PcpKeyNotLoaded =>
|
||||
Wrap("Clé introuvable ou inutilisable dans la puce de ce PC (autre PC, autre compte Windows, puce réinitialisée ou " +
|
||||
"Windows réinstallé ?). Utilisez une autre clé, ou demandez une nouvelle invitation.", ex),
|
||||
_ when ex.InnerException is null && (uint)ex.HResult >> 16 == 0x8013 => ex, // message déjà rédigé ici (ex. clé différente)
|
||||
_ => Wrap(DescribeUnavailable(ex), ex)
|
||||
};
|
||||
|
||||
private static string DescribeUnavailable(CryptographicException ex) => CodeOf(ex) switch
|
||||
{
|
||||
Codes.TbsTpmNotFound or Codes.DeviceNotFound or Codes.ProviderNotFound or Codes.ProviderDllNotFound =>
|
||||
"Aucune puce de sécurité (TPM) détectée sur ce PC.",
|
||||
Codes.TbsServiceNotRunning or Codes.TbsServiceDisabled or Codes.PcpDeviceNotReady or Codes.DeviceNotReady =>
|
||||
"La puce de sécurité (TPM) de ce PC n'est pas prête (désactivée dans le BIOS/UEFI ou service arrêté).",
|
||||
Codes.NotSupported or Codes.BadAlgId or Codes.TpmCurve =>
|
||||
"La puce de sécurité de ce PC ne gère pas les clés ECDSA P-256 (TPM 2.0 nécessaire).",
|
||||
Codes.TpmLockout or Codes.TpmDefendLockRunning =>
|
||||
"La puce de sécurité est temporairement bloquée (protection anti-essais). Réessayez plus tard.",
|
||||
var code => $"Puce de sécurité (TPM) inutilisable : {(ex.InnerException ?? ex).Message} (code 0x{code:X8})."
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ Signataire
|
||||
|
||||
/// <summary>Clé ouverte dans la puce : elle signe (dans la puce), la poignée est fermée au verrouillage.</summary>
|
||||
private sealed class TpmSigner : IDisposableAdminSigner
|
||||
{
|
||||
private readonly ECDsaCng _ecdsa;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private int _disposed;
|
||||
|
||||
public TpmSigner(CngKey key)
|
||||
{
|
||||
PublicKey = ReadPublicKey(key);
|
||||
KeyId = AdminKeys.ComputeKeyId(PublicKey);
|
||||
_ecdsa = new ECDsaCng(key);
|
||||
key.Dispose(); // ECDsaCng garde sa propre référence sur la même poignée (code PIN compris)
|
||||
}
|
||||
|
||||
public byte[] PublicKey { get; }
|
||||
public string KeyId { get; }
|
||||
|
||||
/// <summary>Signature immédiate (appelant déjà hors du fil de l'interface).</summary>
|
||||
public byte[] SignNow(byte[] message)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
return _ecdsa.SignData(message, HashAlgorithmName.SHA256); // IEEE P1363, 64 octets
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature dans la puce (lente) : hors du fil de l'interface, une à la fois. L'erreur éventuelle
|
||||
/// est relancée ici, pas dans Task.Run (pas d'arrêt du débogueur sur une erreur gérée).
|
||||
/// </summary>
|
||||
public async ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
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 is CryptographicException crypto ? Describe(crypto) : ex);
|
||||
return null;
|
||||
}
|
||||
}, ct).ConfigureAwait(false);
|
||||
failure?.Throw();
|
||||
return signature!;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clé publique lue au format brut de Windows (BCRYPT_ECCKEY_BLOB : « ECS1 », 32, X, Y), puis
|
||||
/// encodée en SPKI : ne dépend pas des propriétés facultatives du fournisseur de la puce.
|
||||
/// </summary>
|
||||
internal static byte[] ReadPublicKey(CngKey key)
|
||||
{
|
||||
const uint EcdsaP256PublicMagic = 0x31534345; // « ECS1 »
|
||||
const uint EcdsaGenericPublicMagic = 0x50444345; // « ECDP »
|
||||
var blob = key.Export(CngKeyBlobFormat.EccPublicBlob);
|
||||
var magic = blob.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(blob) : 0;
|
||||
if (blob.Length != 8 + 64 || magic is not (EcdsaP256PublicMagic or EcdsaGenericPublicMagic) ||
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(blob.AsSpan(4)) != 32)
|
||||
throw new CryptographicException("La puce n'a pas renvoyé une clé publique ECDSA P-256.");
|
||||
using var ec = ECDsa.Create(new ECParameters
|
||||
{
|
||||
Curve = ECCurve.NamedCurves.nistP256,
|
||||
Q = new ECPoint { X = blob[8..40], Y = blob[40..72] }
|
||||
});
|
||||
return AdminKeys.ParsePublicKey(Convert.ToBase64String(ec.ExportSubjectPublicKeyInfo()));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
_ecdsa.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Views;
|
||||
|
||||
namespace GameLauncher.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Icône du launcher dans la zone de notification de Windows (près de l'horloge), affichée
|
||||
/// pendant que la fenêtre est masquée :
|
||||
/// • clic gauche : réouvre le launcher ;
|
||||
/// • clic droit : menu « Ouvrir » / « Quitter » (<see cref="TrayMenuWindow"/>), dans le style des
|
||||
/// menus du launcher.
|
||||
///
|
||||
/// API Windows (Shell_NotifyIcon) appelée directement : pas de dépendance à Windows Forms.
|
||||
/// L'icône est recréée si l'Explorateur Windows redémarre (message « TaskbarCreated »).
|
||||
/// </summary>
|
||||
public sealed class TrayIcon : IDisposable
|
||||
{
|
||||
private const int CallbackMessage = 0x8000 + 0x42; // WM_APP + 0x42
|
||||
private const int IconId = 1;
|
||||
|
||||
private readonly HwndSource _source;
|
||||
private readonly int _taskbarCreatedMessage;
|
||||
private readonly IntPtr _icon;
|
||||
private readonly bool _ownsIcon;
|
||||
private TrayMenuWindow? _menu;
|
||||
private string _toolTip;
|
||||
private bool _isVisible;
|
||||
private bool _disposed;
|
||||
|
||||
/// <param name="executablePath">Exécutable dont l'icône est affichée (le launcher).</param>
|
||||
/// <param name="toolTip">Texte affiché au survol de l'icône.</param>
|
||||
public TrayIcon(string executablePath, string toolTip)
|
||||
{
|
||||
_toolTip = toolTip;
|
||||
|
||||
// Fenêtre invisible qui reçoit les clics sur l'icône. Fenêtre de premier niveau (et non
|
||||
// « message-only ») : elle doit recevoir le message diffusé quand l'Explorateur redémarre.
|
||||
_source = new HwndSource(new HwndSourceParameters("GameLauncher.TrayIcon")
|
||||
{
|
||||
Width = 0,
|
||||
Height = 0,
|
||||
WindowStyle = 0, // ni WS_VISIBLE, ni bordure
|
||||
ExtendedWindowStyle = 0x80 // WS_EX_TOOLWINDOW : absente de la barre des tâches et d'Alt+Tab
|
||||
});
|
||||
_source.AddHook(WndProc);
|
||||
_taskbarCreatedMessage = RegisterWindowMessage("TaskbarCreated");
|
||||
|
||||
(_icon, _ownsIcon) = LoadIcon(executablePath);
|
||||
}
|
||||
|
||||
/// <summary>Clic gauche sur l'icône, ou « Ouvrir » dans son menu.</summary>
|
||||
public event Action? OpenRequested;
|
||||
|
||||
/// <summary>« Quitter » dans le menu de l'icône.</summary>
|
||||
public event Action? ExitRequested;
|
||||
|
||||
public bool IsVisible => _isVisible;
|
||||
|
||||
/// <summary>Texte affiché au survol (127 caractères au plus).</summary>
|
||||
public string ToolTip
|
||||
{
|
||||
get => _toolTip;
|
||||
set
|
||||
{
|
||||
_toolTip = value;
|
||||
if (_isVisible) Notify(NIM_MODIFY);
|
||||
}
|
||||
}
|
||||
|
||||
public void Show()
|
||||
{
|
||||
if (_disposed || _isVisible) return;
|
||||
_isVisible = Notify(NIM_ADD);
|
||||
if (!_isVisible) Log.Warn("Icône de la zone de notification : ajout impossible.");
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
CloseMenu();
|
||||
if (!_isVisible) return;
|
||||
Notify(NIM_DELETE);
|
||||
_isVisible = false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
Hide();
|
||||
_disposed = true;
|
||||
_source.RemoveHook(WndProc);
|
||||
_source.Dispose();
|
||||
if (_ownsIcon && _icon != IntPtr.Zero) DestroyIcon(_icon);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Messages de l'icône
|
||||
|
||||
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||
{
|
||||
if (msg == CallbackMessage)
|
||||
{
|
||||
switch (unchecked((int)lParam.ToInt64()) & 0xFFFF)
|
||||
{
|
||||
case WM_LBUTTONUP:
|
||||
CloseMenu();
|
||||
OpenRequested?.Invoke();
|
||||
handled = true;
|
||||
break;
|
||||
case WM_RBUTTONUP:
|
||||
case WM_CONTEXTMENU:
|
||||
ShowMenu();
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (msg == _taskbarCreatedMessage && _taskbarCreatedMessage != 0 && _isVisible)
|
||||
{
|
||||
// L'Explorateur a redémarré : la zone de notification est vide, on se réinscrit.
|
||||
_isVisible = Notify(NIM_ADD);
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre le menu près du curseur (un second clic droit le referme). Le menu est une petite
|
||||
/// fenêtre et non un ContextMenu WPF : launcher masqué, un ContextMenu ne peut pas capturer la
|
||||
/// souris et se referme aussitôt ouvert.
|
||||
/// </summary>
|
||||
private void ShowMenu()
|
||||
{
|
||||
if (_menu is not null)
|
||||
{
|
||||
CloseMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Position du curseur (pixels) convertie en unités WPF.
|
||||
GetCursorPos(out var cursor);
|
||||
var point = new Point(cursor.X, cursor.Y);
|
||||
if (_source.CompositionTarget is { } target) point = target.TransformFromDevice.Transform(point);
|
||||
|
||||
var menu = new TrayMenuWindow();
|
||||
menu.OpenClicked += () => OpenRequested?.Invoke();
|
||||
menu.ExitClicked += () => ExitRequested?.Invoke();
|
||||
menu.Closed += (_, _) =>
|
||||
{
|
||||
if (ReferenceEquals(_menu, menu)) _menu = null;
|
||||
};
|
||||
_menu = menu;
|
||||
menu.ShowAt(point);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Menu de l'icône de la zone de notification", ex);
|
||||
CloseMenu();
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseMenu()
|
||||
{
|
||||
var menu = _menu;
|
||||
_menu = null;
|
||||
menu?.CloseMenu();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Shell_NotifyIcon
|
||||
|
||||
private bool Notify(int message)
|
||||
{
|
||||
var data = new NOTIFYICONDATAW
|
||||
{
|
||||
cbSize = Marshal.SizeOf<NOTIFYICONDATAW>(),
|
||||
hWnd = _source.Handle,
|
||||
uID = IconId,
|
||||
uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP,
|
||||
uCallbackMessage = CallbackMessage,
|
||||
hIcon = _icon,
|
||||
szTip = _toolTip.Length > 127 ? _toolTip[..127] : _toolTip
|
||||
};
|
||||
return Shell_NotifyIconW(message, ref data);
|
||||
}
|
||||
|
||||
/// <summary>Icône de l'exécutable à la taille des petites icônes (selon la mise à l'échelle de l'écran).</summary>
|
||||
private static (IntPtr Icon, bool Owned) LoadIcon(string executablePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var size = Math.Max(16, GetSystemMetrics(SM_CXSMICON));
|
||||
var sizes = (uint)((size << 16) | size);
|
||||
if (SHDefExtractIconW(executablePath, 0, 0, out var icon, IntPtr.Zero, sizes) == 0 && icon != IntPtr.Zero)
|
||||
return (icon, true);
|
||||
}
|
||||
catch (Exception ex) when (ex is Win32Exception or DllNotFoundException or EntryPointNotFoundException)
|
||||
{
|
||||
Log.Warn($"Icône du launcher illisible : {ex.Message}");
|
||||
}
|
||||
return (LoadIconW(IntPtr.Zero, new IntPtr(IDI_APPLICATION)), false); // icône système partagée
|
||||
}
|
||||
|
||||
private const int NIM_ADD = 0, NIM_MODIFY = 1, NIM_DELETE = 2;
|
||||
private const int NIF_MESSAGE = 0x1, NIF_ICON = 0x2, NIF_TIP = 0x4;
|
||||
private const int WM_CONTEXTMENU = 0x007B, WM_LBUTTONUP = 0x0202, WM_RBUTTONUP = 0x0205;
|
||||
private const int SM_CXSMICON = 49;
|
||||
private const int IDI_APPLICATION = 32512;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct NOTIFYICONDATAW
|
||||
{
|
||||
public int cbSize;
|
||||
public IntPtr hWnd;
|
||||
public int uID;
|
||||
public int uFlags;
|
||||
public int uCallbackMessage;
|
||||
public IntPtr hIcon;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string szTip;
|
||||
public int dwState;
|
||||
public int dwStateMask;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string szInfo;
|
||||
public int uTimeoutOrVersion;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string szInfoTitle;
|
||||
public int dwInfoFlags;
|
||||
public Guid guidItem;
|
||||
public IntPtr hBalloonIcon;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct POINT
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool Shell_NotifyIconW(int message, ref NOTIFYICONDATAW data);
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int SHDefExtractIconW(string iconFile, int index, uint flags, out IntPtr large, IntPtr small, uint iconSize);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr LoadIconW(IntPtr instance, IntPtr iconName);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DestroyIcon(IntPtr icon);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern int RegisterWindowMessage(string message);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetCursorPos(out POINT point);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int GetSystemMetrics(int index);
|
||||
}
|
||||
@@ -9,13 +9,37 @@ namespace GameLauncher.Platform;
|
||||
public sealed class WindowService : IWindowService
|
||||
{
|
||||
private readonly Window _window;
|
||||
private readonly TrayIcon? _tray;
|
||||
|
||||
public WindowService(Window window) => _window = window;
|
||||
/// <param name="tray">Icône de la zone de notification (absente : « HideToTray » réduit simplement la fenêtre).</param>
|
||||
public WindowService(Window window, TrayIcon? tray = null)
|
||||
{
|
||||
_window = window;
|
||||
_tray = tray;
|
||||
}
|
||||
|
||||
public void Minimize() => _window.WindowState = WindowState.Minimized;
|
||||
|
||||
public void HideToTray()
|
||||
{
|
||||
if (_tray is null)
|
||||
{
|
||||
Minimize();
|
||||
return;
|
||||
}
|
||||
_tray.ToolTip = string.IsNullOrWhiteSpace(_window.Title) ? "Launcher" : _window.Title;
|
||||
_tray.Show();
|
||||
if (!_tray.IsVisible)
|
||||
{
|
||||
Minimize(); // icône refusée par Windows : on ne masque pas une fenêtre qu'on ne pourrait plus rouvrir
|
||||
return;
|
||||
}
|
||||
_window.Hide();
|
||||
}
|
||||
|
||||
public void Restore()
|
||||
{
|
||||
if (!_window.IsVisible) _window.Show(); // l'icône de la zone de notification disparaît (voir App)
|
||||
if (_window.WindowState == WindowState.Minimized) _window.WindowState = WindowState.Normal;
|
||||
_window.Activate();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Windows.Threading;
|
||||
using GameLauncher.Modules;
|
||||
|
||||
namespace GameLauncher.Platform;
|
||||
|
||||
/// <summary>Exécution sur le fil de l'interface WPF.</summary>
|
||||
public sealed class WpfUiDispatcher : IUiDispatcher
|
||||
{
|
||||
private readonly Dispatcher _dispatcher;
|
||||
|
||||
public WpfUiDispatcher(Dispatcher dispatcher) => _dispatcher = dispatcher;
|
||||
|
||||
public void Post(Action action) => _dispatcher.InvokeAsync(action);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Platform;
|
||||
using GameLauncher.Views;
|
||||
using GameLauncher.Views.Admin;
|
||||
|
||||
namespace GameLauncher.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Ouverture du menu d'administration (accès discret depuis la fenêtre principale).
|
||||
/// Une seule fenêtre à la fois ; la fermer déconnecte la session et efface la clé de la mémoire.
|
||||
/// Sans adresse d'API dans launcher.json (« apiUrl »), le menu n'existe pas.
|
||||
/// </summary>
|
||||
public sealed class AdminWindowService : IDisposable
|
||||
{
|
||||
private readonly Window _owner;
|
||||
private readonly ModuleCatalog _modules;
|
||||
private readonly IReadOnlyList<LauncherConfig> _games;
|
||||
private readonly string _settingsPath;
|
||||
private readonly string _launcherVersion;
|
||||
private readonly Uri? _apiUrl;
|
||||
private readonly string? _apiUrlError;
|
||||
private LauncherApiClient? _client;
|
||||
private AdminWindow? _window;
|
||||
|
||||
/// <param name="apiUrl">Adresse de l'API (null : menu indisponible).</param>
|
||||
/// <param name="apiUrlError">Si l'adresse configurée a été ignorée (invalide), la raison, affichée à l'ouverture.</param>
|
||||
public AdminWindowService(Window owner, ModuleCatalog modules, LauncherCatalog catalog, string? apiUrl, string? apiUrlError,
|
||||
string launcherVersion)
|
||||
{
|
||||
_owner = owner;
|
||||
_modules = modules;
|
||||
_games = catalog.Games;
|
||||
_settingsPath = Path.Combine(catalog.DataDirectory, "admin.json");
|
||||
_launcherVersion = launcherVersion;
|
||||
_apiUrl = apiUrl is null ? null : new Uri(apiUrl, UriKind.Absolute);
|
||||
_apiUrlError = apiUrlError;
|
||||
}
|
||||
|
||||
public bool IsAvailable => _apiUrl is not null;
|
||||
|
||||
/// <summary>Ouvre la fenêtre (ou la ramène au premier plan si elle est déjà ouverte).</summary>
|
||||
public void Open()
|
||||
{
|
||||
if (_window is not null)
|
||||
{
|
||||
if (_window.WindowState == WindowState.Minimized) _window.WindowState = WindowState.Normal;
|
||||
_window.Activate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_apiUrl is null)
|
||||
{
|
||||
Log.Info("Administration demandée, mais aucune adresse d'API (apiUrl) dans launcher.json.");
|
||||
ModernDialog.ShowMessage(_owner.IsVisible ? _owner : null, "Administration indisponible",
|
||||
_apiUrlError ?? "Ce launcher n'est relié à aucun serveur d'administration (« apiUrl » absent de launcher.json).",
|
||||
_apiUrlError is null ? DialogKind.Info : DialogKind.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_client ??= new LauncherApiClient(_apiUrl, $"GameLauncher/{_launcherVersion}");
|
||||
AdminWindow? window = null;
|
||||
var shell = new AdminShellViewModel(new AdminEnvironment
|
||||
{
|
||||
Client = _client,
|
||||
Modules = _modules,
|
||||
Games = _games,
|
||||
Dialogs = new ModuleDialogs(() => window),
|
||||
Ui = new WpfUiDispatcher(_owner.Dispatcher),
|
||||
Tpm = new TpmKeyStore(), // clés dans la puce de sécurité : proposées après vérification réelle de la puce
|
||||
SettingsPath = _settingsPath,
|
||||
LauncherVersion = _launcherVersion
|
||||
});
|
||||
window = new AdminWindow(shell);
|
||||
if (_owner.IsVisible) window.Owner = _owner;
|
||||
else window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
window.Closed += (_, _) => _window = null;
|
||||
_window = window;
|
||||
Log.Info("Ouverture du menu d'administration.");
|
||||
window.Show();
|
||||
window.Activate();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_window = null;
|
||||
Log.Error("Ouverture du menu d'administration", ex);
|
||||
ModernDialog.ShowMessage(_owner.IsVisible ? _owner : null, "Administration indisponible", ex.Message, DialogKind.Error);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fermeture du launcher : la fenêtre se ferme (déconnexion, clé effacée).</summary>
|
||||
public void Close() => _window?.Close();
|
||||
|
||||
/// <remarks>
|
||||
/// Le client HTTP n'est pas libéré ici : la déconnexion du serveur, lancée par la fermeture de la
|
||||
/// fenêtre, a ainsi une chance de partir avant la fin du processus (la clé, elle, est déjà effacée).
|
||||
/// </remarks>
|
||||
public void Dispose() => Close();
|
||||
}
|
||||
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user