Compare commits

...
47 changed files with 4805 additions and 34 deletions

No files matched your search

+11
View File
@@ -71,6 +71,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Admin"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Feedback", "src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj", "{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Accounts", "src\GameLauncher.Modules.Accounts\GameLauncher.Modules.Accounts.csproj", "{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "website", "website", "{8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Website", "website\GameLauncher.Website\GameLauncher.Website.csproj", "{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}"
@@ -188,6 +190,14 @@ Global
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|Any CPU.Build.0 = Release|Any CPU
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|x64.ActiveCfg = Release|x64
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|x64.Build.0 = Release|x64
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Debug|x64.ActiveCfg = Debug|x64
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Debug|x64.Build.0 = Debug|x64
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Release|Any CPU.Build.0 = Release|Any CPU
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Release|x64.ActiveCfg = Release|x64
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53}.Release|x64.Build.0 = Release|x64
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|x64.ActiveCfg = Debug|x64
@@ -222,6 +232,7 @@ Global
{9EBFA383-7928-46F6-8958-F4B38287E380} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{B7E4D2A9-5C3F-4E81-9A6D-2F8C1B4E7D53} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
+1
View File
@@ -1058,6 +1058,7 @@ trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; re
| **7e** | Formulaires des demandes modifiables depuis le menu (types, champs, icônes importées), signés (autorisation `forms`) | ✅ |
| **7f** | Mode maintenance du launcher : page « Maintenance », module serveur `Maintenance`, document signé (autorisation `maintenance`), blocage côté joueur sauf la désinstallation, thème rouge / orange | ✅ |
| **8a** | Comptes joueurs — socle serveur (module `Accounts`, schéma d'authentification `PlayerSession` totalement séparé des administrateurs, base SQLite chiffrée, Argon2id + poivre, session mémorisée liée à une clé d'appareil, anti-force brute, journal des comptes, outil en ligne de commande) ; désactivé par défaut. Suite (8b à 8h) : `docs/PLAYER-ACCOUNTS-ARCHITECTURE.md`, § 12 | ✅ |
| **8b** | Comptes joueurs — launcher : bouton « Compte », connexion / création de compte / nouveau mot de passe exigé, session mémorisée (clé d'appareil dans la puce TPM ou clé CNG non exportable, jeton chiffré DPAPI), « Mon compte » (profil, appareils, activité, export JSON, suppression) ; serveur : activité, export, suppression, `IPersonalDataProvider`, comptes inactifs (sommeil après 24 mois, suppression 12 mois plus tard). Bouton absent tant que `playerAccounts` vaut `false` dans le launcher.json intégré | ✅ |
Chaque phase compile, est testée et laisse le launcher utilisable. Pour les joueurs, rien ne
change : le menu d'administration est discret et ne contacte le serveur qu'une fois ouvert.
+61 -8
View File
@@ -40,8 +40,10 @@
### 3.1 Clé d'appareil
Le launcher crée une clé ECDSA P-256 **par PC** (étape 8b : dans la puce TPM si elle existe, sans code PIN, non
exportable ; sinon clé logicielle protégée par DPAPI). La création de compte, la connexion et chaque renouvellement de
Le launcher crée une clé ECDSA P-256 **par PC et par compte Windows** (`Platform\PlayerDeviceKeyStore.cs`) : dans la puce
TPM 2.0 (« Microsoft Platform Crypto Provider ») si elle existe, **sans code PIN**, jamais exportable ; sinon clé logicielle
du « Microsoft Software Key Storage Provider », marquée **non exportable** et protégée par Windows. Nom de clé propre aux
joueurs (`GameLauncher.PlayerDevice.<éditeur><dossier>.v1`), distinct des clés d'administrateur. La création de compte, la connexion et chaque renouvellement de
session sont **signés** par cette clé (`PlayerMessages.Device`) :
```
@@ -78,9 +80,12 @@ renouvellement. Un défi d'un usage ne sert pas à un autre.
| `GET v1/account/me` | session | Compte (adresse **masquée**) et appareils connectés |
| `POST v1/account/password` | session ou jeton « changement exigé » | Nouveau mot de passe |
| `DELETE v1/account/devices/{deviceId}` | session | Déconnecter un appareil du compte |
| `GET v1/account/activity` | session | 50 derniers événements du compte : type, résultat, appareil s'il est toujours connecté, IP **masquée** (`203.0.113.x`, IPv6 en /48), aucun détail interne |
| `POST v1/account/export` | session + **mot de passe** | Export JSON de toutes ses données (`GameLauncher/PlayerData/v1`, § 10) |
| `POST v1/account/delete` | session + **mot de passe** | Suppression définitive du compte (§ 10) |
Limite de débit propre (`account`, `Players:AuthRequestsPerMinute`, 20/min par IP, IPv6 par /64) sur le défi, la
création, la connexion, le renouvellement et le changement de mot de passe, en plus de la limite générale. `GET v1/info`
création, la connexion, le renouvellement, le changement de mot de passe, l'export et la suppression, en plus de la limite générale. `GET v1/info`
annonce `accounts` (activés, création ouverte, version des conditions, version des règles des mots de passe) ; absent
si les comptes sont désactivés.
@@ -89,7 +94,7 @@ si les comptes sont désactivés.
| Jeton | Format | Durée | Où |
|---|---|---|---|
| Accès | `glpa_` + 256 bits | 15 min | mémoire du launcher ; serveur : empreinte en mémoire |
| Renouvellement | `glpr_` + identifiant d'appareil + `.` + 256 bits | 90 jours sans utilisation, 1 an au plus | PC (chiffré DPAPI, étape 8b) ; serveur : empreinte en base |
| Renouvellement | `glpr_` + identifiant d'appareil + `.` + 256 bits | 90 jours sans utilisation, 1 an au plus | PC (`account\session.dat`, chiffré DPAPI) si « Rester connecté » ; serveur : empreinte en base |
| Changement exigé | `glpc_` + 256 bits | 10 min, usage unique | mémoire |
Renouvellement : jeton **remplacé à chaque fois** (l'ancien devient « précédent ») ; un jeton précédent présenté de
@@ -101,6 +106,33 @@ launcher renouvelle sa session sans rien demander au joueur.
**et** aucun changement de mot de passe exigé. Une désactivation, une déconnexion ou un changement de mot de passe
prennent effet immédiatement.
### 3.5 Launcher (étape 8b)
* **Bouton « Compte »** dans la barre de titre (icône de compte, pastille verte = connecté, orange = reconnexion ou
serveur injoignable), présent seulement si `"playerAccounts": true` **et** `apiUrl` sont dans le `launcher.json`
**intégré** (Release) ; masqué pendant une maintenance. Le launcher ne contacte l'API des comptes que si une session est
mémorisée (reprise au démarrage, sans bloquer l'affichage) ou quand le joueur ouvre la fenêtre.
* **Fenêtre « Compte »** (`Views\AccountWindow.xaml`, ViewModel `AccountWindowViewModel` du projet
`GameLauncher.Modules.Accounts`, sans WPF, testé) : connexion (« Rester connecté sur ce PC », aide « mot de passe
oublié »), création de compte (règles du mot de passe vérifiées **en direct** avec `PasswordRules`, confirmation,
acceptation des conditions), nouveau mot de passe exigé, puis **Mon compte** : Profil (adresse masquée, référence à
copier, dates, changement de mot de passe), Appareils (déconnecter un appareil, tous les appareils), Activité (adresses
IP masquées, essais refusés signalés), Mes données (export JSON, suppression définitive : case à cocher + confirmation
+ mot de passe).
* **Mots de passe** : jamais liés au ViewModel ; lus dans les `PasswordBox` au moment de l'action, puis effacés. Seul le
mot de passe tapé à la connexion est gardé en mémoire quelques minutes quand un administrateur exige d'en changer (le
serveur le redemande).
* **Session** (`PlayerAccountService`) : jeton d'accès en mémoire, renouvelé une minute avant son échéance ou aussitôt
après un redémarrage du serveur (un seul renouvellement à la fois) ; jeton de renouvellement enregistré **chiffré DPAPI**
(`Platform\DpapiProtector.cs`, utilisateur Windows, entropie `GameLauncher/Player/Session/v1`, écriture atomique) et
remplacé sur le disque à chaque renouvellement. Session ignorée si elle vient d'un autre serveur ou d'une autre clé
d'appareil ; illisible (autre utilisateur, autre PC) = supprimée. Sans « Rester connecté », rien n'est écrit et la
session est fermée sur le serveur à la sortie du launcher (3 s au plus). Session refusée au renouvellement (expirée,
déconnectée ailleurs, compte désactivé) : effacée, raison affichée à l'ouverture de la fenêtre ; serveur injoignable :
conservée (« hors ligne »), nouvel essai à la prochaine action.
* **Nom de l'appareil** envoyé au serveur : générique (« Windows 11 · GameLauncher 3.5.0 »), jamais le nom Windows du PC.
* Captures : `tools\ui-snapshot.bat --filter "account-*,main-account"`.
## 4. Mots de passe et adresses
* **Règles** (`PasswordRules`, identiques launcher / serveur) : 12 caractères au moins avec 3 types parmi minuscules,
@@ -178,7 +210,11 @@ conversations et blocages), unicité stricte des adresses sous transaction, volu
l'identifiant de la clé d'appareil, **jamais l'adresse e-mail ni le mot de passe**. Les échecs sur une adresse sans
compte sont inscrits sans compte.
* **Actions des administrateurs et du système** → journal d'audit existant, par **référence** de compte : `players.suspend`
(suspension automatique), `cli.players_unlock`, `cli.players_enable`, `cli.players_disable`, `players.events_purge`.
(suspension automatique), `cli.players_unlock`, `cli.players_enable`, `cli.players_disable`, `players.events_purge`,
`players.self_delete` (compte supprimé par son titulaire), `players.inactive` (bilan quotidien des comptes inactifs),
`players.inactive_delete` (compte inactif supprimé).
* Nouveaux événements du journal des comptes (8b) : `account.export`, `account.delete` (essai refusé), `account.inactive`,
`account.reactivated`. Libellés français communs au launcher et au futur menu : `PlayerActivityText` (Contracts).
Les libellés lisibles et la catégorie « Comptes joueurs » du journal du menu arrivent avec l'étape 8d.
* **Conservation** (`RetentionService`, au démarrage puis chaque jour) : journal des comptes 12 mois
(`Players:EventsRetentionMonths`), adresses IP effacées au-delà de 6 mois (`Players:EventsIpRetentionMonths`).
@@ -214,13 +250,30 @@ Un compte est désigné par sa **référence** ; l'adresse e-mail n'est jamais a
| `AccessTokenMinutes`, `RefreshIdleDays`, `RefreshMaxDays` | 15, 90, 365 | Sessions |
| `MaxDevicesPerAccount` | 20 | Au-delà, le moins récemment utilisé est déconnecté |
| `EventsRetentionMonths`, `EventsIpRetentionMonths` | 12, 6 | Conservation du journal des comptes |
| `InactiveDisableMonths`, `InactiveDeleteMonths` | 24, 12 | Comptes inactifs : mis en sommeil, puis supprimés (0 : désactivé) |
## 10. RGPD / CNIL
* Base légale : exécution du contrat (compte, échanges) ; intérêt légitime (journaux de sécurité, anti-force brute,
blocages). Aucun traitement par consentement.
* Étape 8b : export JSON de ses données et suppression du compte depuis le launcher ; interface commune
`IPersonalDataProvider` ; comptes inactifs (désactivés après 2 ans, supprimés 1 an plus tard).
* **Droits d'accès et de portabilité** (étape 8b) : « Mon compte › Mes données › Exporter » (`POST v1/account/export`,
mot de passe exigé car le document contient l'adresse complète) : compte (adresse en clair, dates, état, conditions
acceptées), appareils (nom, identifiant de clé, dates), activité (1000 lignes au plus, avec IP et détails), sections des
autres modules, notes. Jamais l'empreinte du mot de passe, les jetons ni les clés publiques complètes. Export inscrit au
journal du compte.
* **Droit à l'effacement** (étape 8b) : « Supprimer mon compte » (`POST v1/account/delete`, mot de passe exigé) :
données des autres modules d'abord (`IPersonalDataProvider.Erase`), puis compte, appareils et journal du compte
(cascade SQLite, `secure_delete`), sessions fermées. Seule trace : une ligne `players.self_delete` du journal d'audit
(référence, IP, 12 mois). L'adresse redevient libre. Le jeu et ses sauvegardes sur le PC ne sont pas touchés.
* **`IPersonalDataProvider`** (serveur, `Modules\Accounts\PlayerPersonalData.cs`) : chaque futur module qui garde des
données liées à un compte (demandes connectées 8f, amis…) déclare son export et son effacement ; l'export et la
suppression du compte les couvrent sans modifier le module des comptes.
* **Comptes inactifs** (CNIL) : un compte **actif** sans aucune activité (connexion, renouvellement, création) depuis
`InactiveDisableMonths` (24 mois) est **mis en sommeil** (`inactive`, appareils déconnectés) ; une connexion avec le bon
mot de passe le **réveille**. Après `InactiveDeleteMonths` (12 mois) de sommeil, il est **supprimé** comme ci-dessus
(`players.inactive_delete`). Appliqué au démarrage puis chaque jour (`RetentionService`, ligne `players.inactive`). Les
comptes désactivés ou suspendus par un administrateur ne sont pas concernés. Le joueur ne peut pas encore être prévenu
avant l'échéance (module e-mail, étape 8h).
* Étape 8g : registre des traitements, politique de confidentialité et CGU, brouillon d'AIPD (mineurs, identifiants
matériels, messagerie).
@@ -236,7 +289,7 @@ Un compte est désigné par sa **référence** ; l'adresse e-mail n'est jamais a
| Étape | Contenu | Statut |
|---|---|---|
| **8a** | Socle serveur : contrats (`Contracts/Accounts`), module `Accounts`, schéma `PlayerSession`, base SQLite chiffrée, Argon2id + poivre, création / connexion / renouvellement / déconnexion / mot de passe / appareils, anti-force brute, journal des comptes, purge, outil en ligne de commande, tests (dont l'étanchéité sur toutes les routes d'administration) | ✅ |
| 8b | Launcher : bouton Compte, connexion, création, session mémorisée (clé TPM / DPAPI), « Mon compte », export et suppression | à venir |
| **8b** | Launcher : bouton Compte, fenêtre de connexion / création / nouveau mot de passe exigé, session mémorisée (clé d'appareil TPM ou CNG non exportable, jeton chiffré DPAPI), « Mon compte » (profil, appareils, activité, export, suppression) ; serveur : activité, export, suppression, `IPersonalDataProvider`, comptes inactifs ; captures UiSnapshot ; tests | ✅ |
| 8c | OTP (TOTP, codes de secours, QR maison) | à venir |
| 8d | Menu d'administration « Comptes joueurs » | à venir |
| 8e | Blocages par IP et par identifiants matériels (un blocage depuis une fiche vise les deux) | à venir |
@@ -250,6 +250,13 @@ public sealed class LauncherApiOptions
/// <summary>Journal des comptes : conservation (mois), et effacement des adresses IP au-delà de N mois.</summary>
public int EventsRetentionMonths { get; set; } = 12;
public int EventsIpRetentionMonths { get; set; } = 6;
/// <summary>
/// Comptes inactifs (CNIL) : mis en sommeil après N mois sans aucune activité (appareils déconnectés ; une connexion
/// avec le bon mot de passe le réveille), puis supprimés définitivement après M mois de sommeil. 0 : désactivé.
/// </summary>
public int InactiveDisableMonths { get; set; } = 24;
public int InactiveDeleteMonths { get; set; } = 12;
}
/// <summary>Un jeu publié sur ce serveur.</summary>
@@ -421,6 +428,10 @@ public sealed class LauncherApiOptions
Require(players.EventsRetentionMonths is >= 1 and <= 120, "Players:EventsRetentionMonths (1 à 120)");
Require(players.EventsIpRetentionMonths is >= 1 && players.EventsIpRetentionMonths <= players.EventsRetentionMonths,
"Players:EventsIpRetentionMonths (1 à EventsRetentionMonths)");
// Au moins un an (au-delà de la durée maximale d'une session mémorisée) : un joueur régulier n'est jamais mis en sommeil.
Require(players.InactiveDisableMonths == 0 || players.InactiveDisableMonths is >= 12 and <= 120,
"Players:InactiveDisableMonths (0, ou 12 à 120)");
Require(players.InactiveDeleteMonths is >= 0 and <= 120, "Players:InactiveDeleteMonths (0 à 120)");
if (Contracts.ApiText.CleanLine(players.TermsVersion, 32) is null)
throw new InvalidOperationException($"{Section}:Players:TermsVersion : une version (ex. « 2026-11 ») est obligatoire.");
if (!players.Enabled) return;
@@ -11,7 +11,9 @@ namespace GameLauncher.Server.Hosting;
/// • demandes des joueurs : closes depuis <c>Feedback:RetentionClosedMonths</c> (12), et toutes au-delà de
/// <c>Feedback:RetentionMaxMonths</c> (24) ;
/// • journal des comptes joueurs (si activés) : lignes au-delà de <c>Players:EventsRetentionMonths</c> (12), adresses IP
/// effacées au-delà de <c>Players:EventsIpRetentionMonths</c> (6).
/// effacées au-delà de <c>Players:EventsIpRetentionMonths</c> (6) ;
/// • comptes joueurs inactifs : mis en sommeil après <c>Players:InactiveDisableMonths</c> (24) sans activité, supprimés
/// après <c>Players:InactiveDeleteMonths</c> (12) de sommeil (« players.inactive »).
/// Chaque purge qui supprime quelque chose est inscrite au journal (« audit.purge », « feedback.purge »). Une erreur est
/// écrite dans journalctl sans arrêter le service ; la purge suivante réessaie.
/// </summary>
@@ -96,5 +98,18 @@ public sealed class RetentionService : BackgroundService
{
_logger.LogError(ex, "Purge du journal des comptes joueurs impossible");
}
try
{
var (dormant, deleted) = _services.GetRequiredService<Modules.Accounts.PlayerPersonalData>().ApplyInactivity(_time.GetUtcNow());
if (dormant > 0 || deleted > 0)
_audit.Write("players.inactive", AuditResults.Ok,
details: $"comptes joueurs inactifs : {dormant} mis en sommeil (aucune activité depuis {_options.Players.InactiveDisableMonths} mois), " +
$"{deleted} supprimé(s) (en sommeil depuis {_options.Players.InactiveDeleteMonths} mois)");
}
catch (Exception ex)
{
_logger.LogError(ex, "Politique des comptes joueurs inactifs impossible");
}
}
}
@@ -1,4 +1,5 @@
using System.Globalization;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
@@ -51,6 +52,7 @@ public sealed class AccountsModule : IApiModule
services.AddSingleton(sp => new PlayerPasswordHasher(sp.GetRequiredService<PlayerSecrets>(),
sp.GetRequiredService<IOptions<LauncherApiOptions>>().Value.Players));
services.AddSingleton<PlayerStore>();
services.AddSingleton<PlayerPersonalData>();
services.AddPlayerAuthentication();
}
@@ -64,6 +66,9 @@ public sealed class AccountsModule : IApiModule
api.MapGet(ApiRoutes.AccountMe, Me).RequirePlayer();
api.MapPost(ApiRoutes.AccountPassword, ChangePassword).RequirePlayerOrPasswordChange().RequireRateLimiting(RateLimitPolicies.Account);
api.MapDelete(ApiRoutes.AccountDevicePattern, DeleteDevice).RequirePlayer();
api.MapGet(ApiRoutes.AccountActivity, Activity).RequirePlayer();
api.MapPost(ApiRoutes.AccountExport, Export).RequirePlayer().RequireRateLimiting(RateLimitPolicies.Account);
api.MapPost(ApiRoutes.AccountDelete, DeleteAccount).RequirePlayer().RequireRateLimiting(RateLimitPolicies.Account);
}
private static IResult Disabled() => ApiResults.Error(503, ApiErrorCodes.NotConfigured,
@@ -295,6 +300,11 @@ public sealed class AccountsModule : IApiModule
return null;
case PlayerStatus.Suspended when trustedDevice:
return null;
case PlayerStatus.Inactive:
// Compte en sommeil (longue inactivité) : la connexion avec le bon mot de passe le réveille.
store.SetStatus(account.Id, PlayerStatus.Active, null, null, "système (connexion après inactivité)", now);
store.AddEvent(account.Id, PlayerEvents.Reactivated, AuditResults.Ok, null, null, "connexion après une longue inactivité");
return null;
case PlayerStatus.Disabled when account.DisabledUntil is { } end && end <= now:
store.SetStatus(account.Id, PlayerStatus.Active, null, null, "système (fin de la désactivation)", now);
store.AddEvent(account.Id, PlayerEvents.Enabled, AuditResults.Ok, null, null, "fin de la désactivation temporaire");
@@ -454,19 +464,9 @@ public sealed class AccountsModule : IApiModule
request.NewPassword is not { Length: > 0 and <= MaxSubmittedPasswordLength })
return ApiResults.BadRequest("Mot de passe actuel et nouveau mot de passe obligatoires.");
var (valid, _) = await hasher.VerifyAsync(request.CurrentPassword, account.PasswordHash, context.RequestAborted);
if (!valid)
{
// Session volée qui tente de deviner le mot de passe : blocage puis fermeture des sessions du compte.
var outcome = store.RecordFailure(account.Id, trusted: true, now, settings);
store.AddEvent(account.Id, PlayerEvents.PasswordChange, AuditResults.Denied, ip, deviceKey, "mot de passe actuel incorrect");
if (outcome.LockedUntil is { } locked)
{
sessions.RemoveWhere(s => s.AccountId == account.Id);
return TooManyAttempts(context, locked, now);
}
return ApiResults.Error(400, ApiErrorCodes.LoginFailed, "Mot de passe actuel incorrect.");
}
if (await CheckCurrentPasswordAsync(request.CurrentPassword, account, PlayerEvents.PasswordChange, deviceKey, context, store, hasher,
sessions, settings, now) is { } refused)
return refused;
var email = secrets.DecryptEmail(account.Id, account.EmailEncrypted);
var errors = PasswordRules.Check(request.NewPassword, email).ToList();
@@ -518,6 +518,107 @@ public sealed class AccountsModule : IApiModule
return Results.NoContent();
}
// ------------------------------------------------------------------ Activité, export et suppression (RGPD)
/// <summary>Activité récente du compte (50 dernières lignes), sans détail interne, adresses IP masquées.</summary>
private static IResult Activity(HttpContext context, IServiceProvider services)
{
var player = context.Player();
var store = services.GetRequiredService<PlayerStore>();
var labels = store.Devices(player.Account.Id).ToDictionary(d => d.KeyId, d => d.Label, StringComparer.Ordinal);
var currentKey = player.Device?.KeyId;
return Results.Json(new PlayerActivityResponse
{
Entries = store.Events(player.Account.Id, 50).Select(e => new PlayerActivityEntry
{
At = e.At,
Type = e.Type,
Success = e.Result == AuditResults.Ok,
Device = e.DeviceKeyId is { } key && labels.TryGetValue(key, out var label) ? label : null,
ThisDevice = currentKey is not null && e.DeviceKeyId == currentKey,
Ip = MaskIp(e.Ip)
}).ToList()
}, ApiJson.Options);
}
/// <summary>
/// Export de toutes les données du compte (droits d'accès et de portabilité), mot de passe exigé : le document contient
/// l'adresse e-mail complète, que l'API ne renvoie jamais autrement.
/// </summary>
private static async Task<IResult> Export(PlayerPasswordConfirmation request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerSessionStore sessions, ServerIdentity server, TimeProvider time)
{
var player = context.Player();
var store = services.GetRequiredService<PlayerStore>();
var now = time.GetUtcNow();
if (await ConfirmAsync(request, player, PlayerEvents.Export, context, options.Value.Players, services, sessions, now) is { } refused)
return refused;
var account = store.FindById(player.Account.Id) ?? player.Account;
var document = services.GetRequiredService<PlayerPersonalData>().Export(account, server.Origin, now);
store.AddEvent(account.Id, PlayerEvents.Export, AuditResults.Ok, context.ClientIp(), player.Device?.KeyId, null);
return Results.Bytes(document, "application/json; charset=utf-8");
}
/// <summary>Suppression définitive du compte par son titulaire (droit à l'effacement), mot de passe exigé.</summary>
private static async Task<IResult> DeleteAccount(PlayerPasswordConfirmation request, HttpContext context, IOptions<LauncherApiOptions> options,
IServiceProvider services, PlayerSessionStore sessions, TimeProvider time)
{
var player = context.Player();
var now = time.GetUtcNow();
if (await ConfirmAsync(request, player, PlayerEvents.Delete, context, options.Value.Players, services, sessions, now) is { } refused)
return refused;
services.GetRequiredService<PlayerPersonalData>().Erase(player.Account, byPlayer: true, context.ClientIp());
return Results.NoContent();
}
/// <summary>Mot de passe actuel d'une action sensible (export, suppression) : présence, blocage en cours, vérification.</summary>
private static async Task<IResult?> ConfirmAsync(PlayerPasswordConfirmation? request, AuthenticatedPlayer player, string eventType,
HttpContext context, LauncherApiOptions.PlayersSettings settings, IServiceProvider services, PlayerSessionStore sessions, DateTimeOffset now)
{
var account = player.Account;
if (account.TrustedLockedUntil is { } until && until > now) return TooManyAttempts(context, until, now);
if (request?.Password is not { Length: > 0 and <= MaxSubmittedPasswordLength } password)
return ApiResults.BadRequest("Mot de passe obligatoire.");
return await CheckCurrentPasswordAsync(password, account, eventType, player.Device?.KeyId, context,
services.GetRequiredService<PlayerStore>(), services.GetRequiredService<PlayerPasswordHasher>(), sessions, settings, now);
}
/// <summary>
/// Vérifie le mot de passe actuel d'un joueur DÉJÀ connecté. Un échec compte comme un échec de connexion depuis un appareil
/// de confiance : une session volée qui tente de deviner le mot de passe est bloquée, puis toutes les sessions du compte
/// sont fermées. Null si le mot de passe est correct.
/// </summary>
private static async Task<IResult?> CheckCurrentPasswordAsync(string password, PlayerAccountRecord account, string eventType, string? deviceKey,
HttpContext context, PlayerStore store, PlayerPasswordHasher hasher, PlayerSessionStore sessions,
LauncherApiOptions.PlayersSettings settings, DateTimeOffset now)
{
var (valid, _) = await hasher.VerifyAsync(password, account.PasswordHash, context.RequestAborted);
if (valid) return null;
var outcome = store.RecordFailure(account.Id, trusted: true, now, settings);
store.AddEvent(account.Id, eventType, AuditResults.Denied, context.ClientIp(), deviceKey, "mot de passe actuel incorrect");
if (outcome.LockedUntil is { } locked)
{
sessions.RemoveWhere(s => s.AccountId == account.Id);
return TooManyAttempts(context, locked, now);
}
return ApiResults.Error(400, ApiErrorCodes.LoginFailed, "Mot de passe actuel incorrect.");
}
/// <summary>
/// Adresse IP masquée pour l'affichage au joueur : IPv4 « 203.0.113.x », IPv6 réduite à son préfixe /48
/// (« 2001:db8:85a3::/48 »). Elle peut être celle d'un tiers (essai de mot de passe) : jamais affichée en entier.
/// </summary>
internal static string? MaskIp(string? ip)
{
if (string.IsNullOrWhiteSpace(ip) || !IPAddress.TryParse(ip, out var address)) return null;
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
var bytes = address.GetAddressBytes();
if (bytes.Length == 4) return $"{bytes[0]}.{bytes[1]}.{bytes[2]}.x";
var prefix = new byte[16];
Array.Copy(bytes, prefix, 6);
return new IPAddress(prefix) + "/48";
}
// ------------------------------------------------------------------ Outils
/// <summary>Clé d'appareil vérifiée : clé publique (Base64 canonique), identifiant, nom générique.</summary>
@@ -0,0 +1,163 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using GameLauncher.Server.Storage.Players;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Accounts;
/// <summary>
/// Données d'un joueur détenues par un module (RGPD : droits d'accès, de portabilité et d'effacement). Chaque module qui
/// conserve des données liées à un compte joueur (demandes connectées et échanges à l'étape 8f, amis, sauvegardes…) déclare
/// une implémentation (<c>services.AddSingleton&lt;IPersonalDataProvider, …&gt;()</c>) : l'export et la suppression du compte
/// la couvrent alors automatiquement, sans modifier le module des comptes.
/// </summary>
public interface IPersonalDataProvider
{
/// <summary>Nom de la section dans l'export (« feedback »…), en minuscules.</summary>
string Name { get; }
/// <summary>Données du joueur, lisibles par machine (null : rien pour ce compte).</summary>
JsonNode? Export(PlayerAccountRecord account);
/// <summary>Efface (ou détache du compte) les données du joueur. Appelé AVANT la suppression du compte lui-même.</summary>
void Erase(PlayerAccountRecord account);
}
/// <summary>
/// Export et effacement des données d'un compte joueur, à la demande du joueur ou par la politique des comptes inactifs
/// (CNIL : mise en sommeil après <c>Players:InactiveDisableMonths</c> sans activité, suppression après
/// <c>Players:InactiveDeleteMonths</c> de sommeil). L'export contient TOUT ce que le serveur sait du compte, y compris
/// l'adresse e-mail complète ; jamais l'empreinte du mot de passe ni les secrets des sessions.
/// </summary>
public sealed class PlayerPersonalData
{
private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true };
private readonly PlayerStore _store;
private readonly PlayerSecrets _secrets;
private readonly PlayerSessionStore _sessions;
private readonly IReadOnlyList<IPersonalDataProvider> _providers;
private readonly AuditLog _audit;
private readonly LauncherApiOptions.PlayersSettings _settings;
public PlayerPersonalData(PlayerStore store, PlayerSecrets secrets, PlayerSessionStore sessions, IEnumerable<IPersonalDataProvider> providers,
AuditLog audit, IOptions<LauncherApiOptions> options)
{
_store = store;
_secrets = secrets;
_sessions = sessions;
_providers = providers.ToList();
_audit = audit;
_settings = options.Value.Players;
var duplicate = _providers.GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase).FirstOrDefault(g => g.Count() > 1);
if (duplicate is not null) throw new InvalidOperationException($"Données personnelles : section « {duplicate.Key} » déclarée deux fois.");
}
/// <summary>Document d'export (JSON indenté, UTF-8).</summary>
public byte[] Export(PlayerAccountRecord account, string origin, DateTimeOffset now)
{
var devices = _store.Devices(account.Id);
var document = new JsonObject
{
["format"] = PlayerDataExport.Format,
["exportedAt"] = now,
["service"] = origin,
["account"] = new JsonObject
{
["reference"] = account.Reference,
["email"] = _secrets.DecryptEmail(account.Id, account.EmailEncrypted),
["emailVerified"] = account.EmailVerified,
["createdAt"] = account.CreatedAt,
["lastLoginAt"] = account.LastLoginAt,
["passwordChangedAt"] = account.PasswordChangedAt,
["termsVersionAccepted"] = account.TermsVersion,
["status"] = account.Status,
["disabledUntil"] = account.DisabledUntil,
["twoFactorEnabled"] = account.OtpEnabled
},
["devices"] = new JsonArray(devices.Select(d => (JsonNode)new JsonObject
{
["label"] = d.Label,
["deviceKeyId"] = d.KeyId,
["connectedAt"] = d.CreatedAt,
["lastUsedAt"] = d.LastUsedAt,
["rememberedUntil"] = d.RefreshExpiresAt
}).ToArray()),
["activity"] = new JsonArray(_store.Events(account.Id, 1000).Select(e => (JsonNode)new JsonObject
{
["at"] = e.At,
["type"] = e.Type,
["description"] = PlayerActivityText.Describe(e.Type, e.Result == AuditResults.Ok),
["result"] = e.Result,
["ip"] = e.Ip,
["deviceKeyId"] = e.DeviceKeyId,
["details"] = e.Details
}).ToArray())
};
var modules = new JsonObject();
foreach (var provider in _providers)
{
if (provider.Export(account) is { } data) modules[provider.Name] = data;
}
document["modules"] = modules;
document["notes"] = new JsonArray(
"Le mot de passe n'est jamais conservé : seule une empreinte irréversible existe sur le serveur, elle n'est pas exportée.",
$"Activité : {_settings.EventsRetentionMonths} mois au plus ; adresses IP effacées au bout de {_settings.EventsIpRetentionMonths} mois.",
"Les clés d'appareil privées ne quittent jamais votre PC : seul leur identifiant public figure ici.");
return JsonSerializer.SerializeToUtf8Bytes(document, Indented);
}
/// <summary>
/// Supprime DÉFINITIVEMENT le compte : données des modules, appareils, journal du compte, sessions ouvertes. Une ligne
/// du journal d'audit (référence seulement) garde la trace de la suppression.
/// </summary>
/// <param name="byPlayer">Vrai : à la demande du joueur ; faux : politique des comptes inactifs.</param>
public bool Erase(PlayerAccountRecord account, bool byPlayer, string? ip)
{
foreach (var provider in _providers) provider.Erase(account);
_sessions.RemoveWhere(s => s.AccountId == account.Id);
if (!_store.DeleteAccount(account.Id)) return false;
_sessions.RemoveWhere(s => s.AccountId == account.Id); // session ouverte pendant la suppression
_audit.Write(byPlayer ? "players.self_delete" : "players.inactive_delete", AuditResults.Ok,
actorName: byPlayer ? "joueur" : "système", ip: ip, target: account.Reference,
details: byPlayer
? "compte joueur supprimé définitivement à la demande de son titulaire"
: $"compte joueur supprimé définitivement après {_settings.InactiveDeleteMonths} mois de sommeil (inactivité)");
return true;
}
/// <summary>
/// Politique des comptes inactifs (au démarrage puis chaque jour) : mise en sommeil des comptes actifs sans activité
/// depuis <c>InactiveDisableMonths</c>, suppression de ceux en sommeil depuis <c>InactiveDeleteMonths</c>.
/// Renvoie (mis en sommeil, supprimés).
/// </summary>
public (int Dormant, int Deleted) ApplyInactivity(DateTimeOffset now)
{
if (_settings.InactiveDisableMonths <= 0) return (0, 0);
var dormant = 0;
foreach (var account in _store.FindInactive(now.AddMonths(-_settings.InactiveDisableMonths)))
{
if (!_store.MarkInactive(account.Id, now)) continue;
_sessions.RemoveWhere(s => s.AccountId == account.Id);
_store.AddEvent(account.Id, PlayerEvents.Inactive, AuditResults.Ok, null, null,
$"aucune activité depuis {_settings.InactiveDisableMonths} mois : appareils déconnectés");
dormant++;
}
var deleted = 0;
if (_settings.InactiveDeleteMonths > 0)
{
foreach (var account in _store.FindDormantSince(now.AddMonths(-_settings.InactiveDeleteMonths)))
{
if (Erase(account, byPlayer: false, ip: null)) deleted++;
}
}
return (dormant, deleted);
}
}
@@ -13,6 +13,12 @@ public static class PlayerStatus
/// refusées jusqu'à ce qu'un administrateur lève la suspension ; les appareils déjà connectés continuent de fonctionner.
/// </summary>
public const string Suspended = "suspended";
/// <summary>
/// En sommeil après une longue inactivité (<c>Players:InactiveDisableMonths</c>) : appareils déconnectés ; une connexion
/// avec le bon mot de passe le réveille. Supprimé au bout de <c>Players:InactiveDeleteMonths</c> de plus.
/// </summary>
public const string Inactive = "inactive";
}
/// <summary>Compte joueur tel qu'enregistré (adresse chiffrée ; jamais renvoyé tel quel par l'API).</summary>
@@ -91,4 +97,8 @@ public static class PlayerEvents
public const string Refresh = "session.refresh";
public const string RefreshReuse = "session.reuse";
public const string DeviceRemoved = "device.removed";
public const string Export = "account.export";
public const string Delete = "account.delete";
public const string Inactive = "account.inactive";
public const string Reactivated = "account.reactivated";
}
@@ -308,6 +308,47 @@ public sealed class PlayerStore : IDisposable
""", ("$s", status), ("$u", Db(until)), ("$r", (object?)reasonEncrypted ?? DBNull.Value), ("$now", now.ToUnixTimeSeconds()),
("$by", changedBy), ("$id", accountId)) == 1);
/// <summary>
/// Supprime DÉFINITIVEMENT le compte : ses appareils et son journal partent avec lui (clés étrangères en cascade,
/// lignes écrasées sur le disque : secure_delete). Faux si le compte n'existe plus.
/// </summary>
public bool DeleteAccount(string accountId) => Locked(() => Execute(null, "DELETE FROM accounts WHERE id = $id;", ("$id", accountId)) == 1);
/// <summary>
/// Comptes ACTIFS sans aucune activité (création, connexion, utilisation d'un appareil) depuis <paramref name="before"/>.
/// Les comptes désactivés ou suspendus par un administrateur ne sont pas concernés (leur sort relève de l'administration).
/// </summary>
public IReadOnlyList<PlayerAccountRecord> FindInactive(DateTimeOffset before, int limit = 1000) => Locked(() =>
Query(null, $"""
SELECT {AccountColumns} FROM accounts a
WHERE status = 'active'
AND MAX(created_at, COALESCE(last_login_at, 0),
COALESCE((SELECT MAX(last_used_at) FROM devices d WHERE d.account_id = a.id), 0)) < $t
ORDER BY created_at LIMIT $l;
""", ReadAccount, ("$t", before.ToUnixTimeSeconds()), ("$l", Math.Clamp(limit, 1, 10_000))));
/// <summary>Met le compte en sommeil (inactivité) et déconnecte ses appareils, si son état n'a pas changé entre-temps.</summary>
public bool MarkInactive(string accountId, DateTimeOffset now)
{
lock (_lock)
{
using var tx = _db.BeginTransaction();
var changed = Execute(tx, """
UPDATE accounts SET status = 'inactive', disabled_until = NULL, status_changed_at = $now,
status_changed_by = 'système (inactivité)'
WHERE id = $id AND status = 'active';
""", ("$now", now.ToUnixTimeSeconds()), ("$id", accountId)) == 1;
if (changed) Execute(tx, "DELETE FROM devices WHERE account_id = $a;", ("$a", accountId));
tx.Commit();
return changed;
}
}
/// <summary>Comptes en sommeil depuis avant <paramref name="before"/> (à supprimer).</summary>
public IReadOnlyList<PlayerAccountRecord> FindDormantSince(DateTimeOffset before, int limit = 1000) => Locked(() =>
Query(null, $"SELECT {AccountColumns} FROM accounts WHERE status = 'inactive' AND status_changed_at < $t ORDER BY status_changed_at LIMIT $l;",
ReadAccount, ("$t", before.ToUnixTimeSeconds()), ("$l", Math.Clamp(limit, 1, 10_000))));
// ------------------------------------------------------------------ Appareils
private const string DeviceColumns =
+4 -1
View File
@@ -87,7 +87,10 @@
"RefreshIdleDays": 90,
"RefreshMaxDays": 365,
"EventsRetentionMonths": 12,
"EventsIpRetentionMonths": 6
"EventsIpRetentionMonths": 6,
// Comptes inactifs : mis en sommeil après 24 mois sans activité, supprimés 12 mois plus tard (0 : désactivé).
"InactiveDisableMonths": 24,
"InactiveDeleteMonths": 12
},
"Limits": {
"RequestsPerMinute": 300,
+9
View File
@@ -530,6 +530,15 @@ Les comptes joueurs (adresse e-mail + mot de passe) sont stockés dans `/var/lib
doit apparaître. Clé absente, rangée dans le dossier des données ou lisible par d'autres comptes : le service refuse
de démarrer et l'explique dans le journal.
5. **Launcher** (étape 8b) : le bouton « Compte » n'apparaît que dans un launcher publié avec `"playerAccounts": true`
dans `src\GameLauncher\launcher.json` (lu dans le fichier intégré à l'exe, comme `apiUrl`). Pour un essai, un launcher
**Debug** pointé sur un serveur de test suffit ; ne publie pas de launcher avec `true` avant l'ouverture des comptes.
Comptes inactifs : sans activité pendant `Players:InactiveDisableMonths` (24 mois), un compte est mis en sommeil (une
connexion le réveille), puis supprimé après `Players:InactiveDeleteMonths` (12 mois) ; `0` désactive la règle. Chaque
jour, le bilan est inscrit au journal d'audit (`players.inactive`). Les joueurs exportent leurs données et suppriment leur
compte eux-mêmes depuis le launcher (« Mon compte › Mes données ») : `players.self_delete` dans le journal.
Sauvegarde : `players.db` contient des données personnelles chiffrées ; une sauvegarde cohérente se fait service arrêté
(ou, service en marche, avec `sqlite3 players.db ".backup /chemin"`, paquet `sqlite3`), et doit elle-même être chiffrée (étape 8g). Commandes de secours :
étape 9.
@@ -304,7 +304,7 @@ public sealed class LauncherApiClient : IDisposable
}
/// <summary>Le serveur doit annoncer la même origine que celle configurée (sinon les signatures seraient refusées).</summary>
private void EnsureSameOrigin(string serverOrigin)
internal void EnsureSameOrigin(string serverOrigin)
{
if (!string.Equals(serverOrigin, Origin, StringComparison.OrdinalIgnoreCase))
throw new ApiException(0, "origin_mismatch",
@@ -0,0 +1,202 @@
using System.Net.Http.Headers;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Security;
namespace GameLauncher.ApiClient;
/// <summary>
/// Clé d'appareil d'un joueur (ECDSA P-256, une par PC et par compte Windows) : créée dans la puce de sécurité (TPM) quand
/// elle existe, sinon clé logicielle non exportable protégée par Windows. La clé privée ne quitte jamais le PC.
/// Totalement distincte des clés d'administrateur (<see cref="IAdminSigner"/>) : jamais l'une pour l'autre.
/// </summary>
public interface IPlayerDeviceKey
{
/// <summary>Clé publique (SubjectPublicKeyInfo DER).</summary>
byte[] PublicKey { get; }
/// <summary>Identifiant de la clé (<see cref="AdminKeys.ComputeKeyId"/>).</summary>
string KeyId { get; }
/// <summary>Signature ECDSA P-256 / SHA-256 au format IEEE P1363 (64 octets).</summary>
ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default);
}
/// <summary>
/// Routes des comptes joueurs de la Launcher API (« v1/account/… »). Chaque création de compte, connexion et
/// renouvellement : défi du serveur (même origine que l'adresse configurée), preuve de travail résolue hors du fil de
/// l'interface, signature par la clé d'appareil. Les jetons sont passés par l'appelant (jeton d'accès gardé en mémoire,
/// jeton de renouvellement chiffré sur le PC) et ne partent que vers l'adresse de l'API (<see cref="LauncherApiClient.Resolve"/>).
/// </summary>
public sealed class PlayerAccountApi
{
/// <summary>Taille maximale acceptée pour l'export des données (le serveur borne l'activité à 1000 lignes).</summary>
public const int MaxExportBytes = 4 * 1024 * 1024;
private readonly LauncherApiClient _client;
public PlayerAccountApi(LauncherApiClient client) => _client = client;
public LauncherApiClient Client => _client;
public Task<PlayerLoginResponse> RegisterAsync(string email, string password, string termsVersion, IPlayerDeviceKey key,
string deviceLabel, CancellationToken ct = default) =>
SendWithDeviceAsync<PlayerLoginResponse>(PlayerChallengePurposes.Register, ApiRoutes.AccountRegister, key, deviceLabel,
(challenge, solution, proof) => new PlayerRegisterRequest
{
Email = email,
Password = password,
ChallengeId = challenge.ChallengeId,
Solution = solution,
AcceptedTermsVersion = termsVersion,
Device = proof
}, ct);
public Task<PlayerLoginResponse> LoginAsync(string email, string password, IPlayerDeviceKey key, string deviceLabel,
CancellationToken ct = default) =>
SendWithDeviceAsync<PlayerLoginResponse>(PlayerChallengePurposes.Login, ApiRoutes.AccountLogin, key, deviceLabel,
(challenge, solution, proof) => new PlayerLoginRequest
{
Email = email,
Password = password,
ChallengeId = challenge.ChallengeId,
Solution = solution,
Device = proof
}, ct);
/// <summary>Renouvellement de la session mémorisée : un nouveau jeton de renouvellement remplace celui présenté.</summary>
public async Task<PlayerSession> RefreshAsync(string refreshToken, IPlayerDeviceKey key, CancellationToken ct = default)
{
if (!PlayerTokens.TryParseRefresh(refreshToken, out _, out _))
throw new ArgumentException("Jeton de renouvellement mal formé.", nameof(refreshToken));
var (challenge, _) = await ChallengeAsync(PlayerChallengePurposes.Refresh, ct).ConfigureAwait(false);
var signature = await SignAsync(key, PlayerChallengePurposes.Refresh, challenge, ct).ConfigureAwait(false);
return Check(await _client.SendAsync<PlayerSession>(HttpMethod.Post, ApiRoutes.AccountRefresh,
LauncherApiClient.Serialize(new PlayerRefreshRequest
{
RefreshToken = refreshToken,
ChallengeId = challenge.ChallengeId,
Signature = signature
}), null, ct).ConfigureAwait(false));
}
public Task<PlayerMeResponse> MeAsync(string accessToken, CancellationToken ct = default) =>
_client.SendAsync<PlayerMeResponse>(HttpMethod.Get, ApiRoutes.AccountMe, null, Bearer(accessToken), ct);
public Task<PlayerActivityResponse> ActivityAsync(string accessToken, CancellationToken ct = default) =>
_client.SendAsync<PlayerActivityResponse>(HttpMethod.Get, ApiRoutes.AccountActivity, null, Bearer(accessToken), ct);
/// <param name="token">Jeton d'accès, ou jeton limité « changement de mot de passe exigé ».</param>
public async Task<PlayerChangePasswordResponse> ChangePasswordAsync(string token, string currentPassword, string newPassword,
CancellationToken ct = default)
{
var response = await _client.SendAsync<PlayerChangePasswordResponse>(HttpMethod.Post, ApiRoutes.AccountPassword,
LauncherApiClient.Serialize(new PlayerChangePasswordRequest { CurrentPassword = currentPassword, NewPassword = newPassword }),
Bearer(token), ct).ConfigureAwait(false);
if (response.Session is { } session) Check(session);
return response;
}
public async Task LogoutAsync(string accessToken, bool allDevices, CancellationToken ct = default) =>
await _client.SendAsync<NoContent>(HttpMethod.Post, ApiRoutes.AccountLogout,
LauncherApiClient.Serialize(new PlayerLogoutRequest { AllDevices = allDevices }), Bearer(accessToken), ct).ConfigureAwait(false);
public async Task RemoveDeviceAsync(string accessToken, string deviceId, CancellationToken ct = default)
{
if (!PlayerIds.IsValidId(deviceId)) throw new ArgumentException("Identifiant d'appareil invalide.", nameof(deviceId));
await _client.SendAsync<NoContent>(HttpMethod.Delete, ApiRoutes.AccountDevice(deviceId), null, Bearer(accessToken), ct)
.ConfigureAwait(false);
}
/// <summary>Export de toutes ses données (document JSON, vérifié avant d'être rendu), mot de passe exigé.</summary>
public async Task<byte[]> ExportAsync(string accessToken, string password, CancellationToken ct = default)
{
var bytes = await _client.SendAsync<byte[]>(HttpMethod.Post, ApiRoutes.AccountExport,
LauncherApiClient.Serialize(new PlayerPasswordConfirmation { Password = password }), Bearer(accessToken), ct,
timeout: TimeSpan.FromMinutes(1), maxResponseBytes: MaxExportBytes).ConfigureAwait(false);
try
{
using var document = JsonDocument.Parse(bytes);
if (document.RootElement.ValueKind != JsonValueKind.Object ||
!document.RootElement.TryGetProperty("format", out var format) || format.GetString() != PlayerDataExport.Format)
throw new JsonException("format inattendu");
}
catch (JsonException ex)
{
throw new ApiException(200, "invalid_response", "Le serveur a renvoyé un export illisible.", ex);
}
return bytes;
}
/// <summary>Suppression DÉFINITIVE du compte (mot de passe exigé).</summary>
public async Task DeleteAccountAsync(string accessToken, string password, CancellationToken ct = default) =>
await _client.SendAsync<NoContent>(HttpMethod.Post, ApiRoutes.AccountDelete,
LauncherApiClient.Serialize(new PlayerPasswordConfirmation { Password = password }), Bearer(accessToken), ct).ConfigureAwait(false);
// ------------------------------------------------------------------ Défi, preuve de travail, clé d'appareil
private async Task<T> SendWithDeviceAsync<T>(string purpose, string route, IPlayerDeviceKey key, string deviceLabel,
Func<PlayerChallengeResponse, string, PlayerDeviceProof, object> build, CancellationToken ct)
{
var (challenge, solution) = await ChallengeAsync(purpose, ct).ConfigureAwait(false);
var proof = new PlayerDeviceProof
{
PublicKey = Convert.ToBase64String(key.PublicKey),
Signature = await SignAsync(key, purpose, challenge, ct).ConfigureAwait(false),
Label = deviceLabel
};
var request = build(challenge, solution, proof);
var response = await _client.SendAsync<T>(HttpMethod.Post, route, JsonSerializer.SerializeToUtf8Bytes(request, request.GetType(), ApiJson.Options),
null, ct, timeout: TimeSpan.FromMinutes(1)).ConfigureAwait(false);
if (response is PlayerLoginResponse { Session: { } session }) Check(session);
return response;
}
/// <summary>
/// Défi du serveur, contrôlé (usage, origine, difficulté, valeurs bornées), puis preuve de travail résolue hors du fil
/// de l'interface (« » si la difficulté est nulle : renouvellement).
/// </summary>
private async Task<(PlayerChallengeResponse Challenge, string Solution)> ChallengeAsync(string purpose, CancellationToken ct)
{
var challenge = await _client.SendAsync<PlayerChallengeResponse>(HttpMethod.Post, ApiRoutes.AccountChallenge,
LauncherApiClient.Serialize(new PlayerChallengeRequest { Purpose = purpose }), null, ct).ConfigureAwait(false);
if (challenge.Purpose != purpose || challenge.Difficulty is < 0 or > FeedbackProof.MaxDifficulty ||
!IsPlainToken(challenge.ChallengeId, 512) || !IsPlainToken(challenge.ServerId, 128) ||
!Base64Url.TryDecode(challenge.Nonce, out var nonce) || nonce.Length is 0 or > 64)
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible.");
_client.EnsureSameOrigin(challenge.Origin);
var solution = challenge.Difficulty == 0
? ""
: await Task.Run(() => FeedbackProof.Solve(nonce, challenge.Difficulty, ct), ct).ConfigureAwait(false);
return (challenge, solution);
}
private async Task<string> SignAsync(IPlayerDeviceKey key, string purpose, PlayerChallengeResponse challenge, CancellationToken ct)
{
var message = PlayerMessages.Device(purpose, challenge.Origin, challenge.ServerId, challenge.ChallengeId, challenge.Nonce, key.KeyId);
return Convert.ToBase64String(await key.SignAsync(message, ct).ConfigureAwait(false));
}
/// <summary>Session reçue : jetons bien formés (ils seront renvoyés tels quels dans un en-tête puis enregistrés).</summary>
private static PlayerSession Check(PlayerSession session)
{
if (!session.AccessToken.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) || !IsPlainToken(session.AccessToken, PlayerTokens.MaxLength) ||
!PlayerTokens.TryParseRefresh(session.RefreshToken, out var deviceId, out _) || deviceId != session.DeviceId)
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible (session).");
return session;
}
private static Action<HttpRequestMessage> Bearer(string token)
{
if (!IsPlainToken(token, PlayerTokens.MaxLength) ||
!(token.StartsWith(PlayerTokens.AccessPrefix, StringComparison.Ordinal) || token.StartsWith(PlayerTokens.PasswordChangePrefix, StringComparison.Ordinal)))
throw new ArgumentException("Jeton de session invalide.", nameof(token));
return request => request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
/// <summary>Jeton ou identifiant « simple » : caractères ASCII visibles uniquement, longueur bornée.</summary>
private static bool IsPlainToken(string? value, int maxLength) =>
value is { Length: > 0 } && value.Length <= maxLength && value.All(c => c is > ' ' and < '\u007f');
}
@@ -194,3 +194,54 @@ public sealed record PlayerLogoutRequest
/// <summary>Vrai : tous les appareils du compte (« Déconnecter tous mes appareils »).</summary>
public bool AllDevices { get; init; }
}
/// <summary>Mot de passe actuel, exigé pour les actions sensibles : export des données (POST v1/account/export), suppression du
/// compte (POST v1/account/delete).</summary>
public sealed record PlayerPasswordConfirmation
{
public string Password { get; init; } = "";
}
/// <summary>GET v1/account/activity : activité récente du compte, la plus récente d'abord.</summary>
public sealed record PlayerActivityResponse
{
public IReadOnlyList<PlayerActivityEntry> Entries { get; init; } = Array.Empty<PlayerActivityEntry>();
}
/// <summary>
/// Une ligne de l'activité du compte. Le libellé est calculé par le launcher (<see cref="PlayerActivityText"/>) ; aucun détail
/// interne n'est renvoyé, et l'adresse IP est MASQUÉE (« 203.0.113.x ») : elle peut être celle d'un tiers (essai de mot de passe).
/// </summary>
public sealed record PlayerActivityEntry
{
public DateTimeOffset At { get; init; }
/// <summary>Type d'événement (« account.login »…), voir <see cref="PlayerActivityText"/>.</summary>
public string Type { get; init; } = "";
/// <summary>Faux : tentative refusée (mot de passe incorrect, connexion bloquée…).</summary>
public bool Success { get; init; }
/// <summary>Nom générique de l'appareil s'il est toujours connecté au compte, sinon null.</summary>
public string? Device { get; init; }
/// <summary>Événement venu de l'appareil de la session en cours.</summary>
public bool ThisDevice { get; init; }
/// <summary>Adresse IP masquée, ou null (effacée après quelques mois, ou action du système).</summary>
public string? Ip { get; init; }
}
/// <summary>Document d'export des données d'un joueur (POST v1/account/export), lisible par machine (JSON).</summary>
public static class PlayerDataExport
{
/// <summary>Valeur de la propriété « format » du document.</summary>
public const string Format = "GameLauncher/PlayerData/v1";
/// <summary>Nom de fichier proposé à l'enregistrement (« donnees-compte-PL-7KQ2-M9XD-2026-11-11.json »).</summary>
public static string SuggestedFileName(string reference, DateTimeOffset date)
{
var safe = new string((reference ?? "").Where(c => char.IsAsciiLetterOrDigit(c) || c == '-').ToArray());
return $"donnees-compte-{(safe.Length > 0 ? safe : "joueur")}-{date:yyyy-MM-dd}.json";
}
}
@@ -0,0 +1,35 @@
namespace GameLauncher.Contracts.Accounts;
/// <summary>
/// Libellés français des événements du journal des comptes joueurs (« Activité récente » du launcher, export des données,
/// et plus tard le menu d'administration). Un type inconnu (serveur plus récent) est affiché tel quel.
/// </summary>
public static class PlayerActivityText
{
public static string Describe(string type, bool success) => type switch
{
"account.register" => "Création du compte",
"account.login" => success ? "Connexion" : "Connexion refusée (compte désactivé ou suspendu)",
"account.login_failed" => "Mot de passe incorrect",
"account.locked" => "Connexion bloquée temporairement (trop d'essais)",
"account.suspended" => "Nouveaux appareils suspendus (trop d'essais)",
"account.unlocked" => "Suspension levée",
"account.disabled" => "Compte désactivé",
"account.enabled" => "Compte réactivé",
"account.inactive" => "Compte mis en sommeil (inactivité)",
"account.reactivated" => "Compte réveillé (connexion après une longue inactivité)",
"account.logout" => "Déconnexion",
"account.logout_all" => "Déconnexion de tous les appareils",
"account.password_change" => success ? "Mot de passe modifié" : "Changement de mot de passe refusé (mot de passe actuel incorrect)",
"account.password_change_required" => "Nouveau mot de passe exigé",
"account.export" => success ? "Export de vos données" : "Export refusé (mot de passe incorrect)",
"account.delete" => success ? "Suppression du compte" : "Suppression du compte refusée (mot de passe incorrect)",
"session.refresh" => success ? "Reconnexion automatique" : "Reconnexion automatique refusée",
"session.reuse" => "Jeton de connexion réutilisé : appareil déconnecté par précaution",
"device.removed" => "Appareil déconnecté",
_ => type
};
/// <summary>Vrai si le type a un libellé (sinon <see cref="Describe"/> renvoie le type tel quel).</summary>
public static bool IsKnown(string type) => Describe(type, true) != type;
}
+9
View File
@@ -180,6 +180,15 @@ public static class ApiRoutes
/// <summary>Un appareil du compte (DELETE : le déconnecter). Identifiant : 32 caractères hexadécimaux.</summary>
public static string AccountDevice(string deviceId) => $"v1/account/devices/{deviceId}";
/// <summary>Activité récente du compte (connexions, échecs, changements…), adresses IP masquées.</summary>
public const string AccountActivity = "v1/account/activity";
/// <summary>Export de toutes ses données (RGPD : accès et portabilité), mot de passe exigé. Document JSON.</summary>
public const string AccountExport = "v1/account/export";
/// <summary>Suppression définitive du compte par son titulaire (RGPD : effacement), mot de passe exigé.</summary>
public const string AccountDelete = "v1/account/delete";
// ------------------------------------------------------------------ Publication d'une version
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
public const string PublicationPattern = "v1/publications/{id}";
@@ -15,7 +15,7 @@ namespace GameLauncher.Core.Models;
/// </summary>
public sealed class LauncherCatalog
{
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl", "catalogUrl", "keyringUrl", "contentSigning", "keyringMinSequence" };
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl", "catalogUrl", "keyringUrl", "contentSigning", "keyringMinSequence", "playerAccounts" };
/// <summary>Nom par défaut du dossier des données du launcher (%AppData%\&lt;Publisher&gt;\GameLauncher).</summary>
public const string DefaultDataFolderName = "GameLauncher";
@@ -126,6 +126,13 @@ public sealed class LauncherCatalog
/// </summary>
public long KeyringMinSequence { get; private set; }
/// <summary>
/// « playerAccounts » : bouton « Compte » (comptes joueurs : connexion, création, « Mon compte »), avec l'API de
/// <see cref="ApiUrl"/>. Faux par défaut : à activer quand les comptes sont ouverts sur le serveur. Comme apiUrl, le
/// launcher publié ne le lit que dans le launcher.json intégré à l'exe.
/// </summary>
public bool PlayerAccounts { get; private set; }
/// <summary>Affichage modifié par le catalogue (voir Catalog.CatalogMerger).</summary>
internal void ApplyDisplay(string launcherName, string? defaultGameId, IReadOnlyList<LauncherConfig> games)
{
@@ -317,7 +324,8 @@ public sealed class LauncherCatalog
ApiUrlError = singleApiUrlError,
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var singleCatalogError),
CatalogUrlError = singleCatalogError,
DataFolderName = ReadDataFolder(root)
DataFolderName = ReadDataFolder(root),
PlayerAccounts = ReadFlag(root, "playerAccounts")
}.WithContentSigning(root);
}
@@ -372,7 +380,8 @@ public sealed class LauncherCatalog
ApiUrlError = apiUrlError,
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var catalogError),
CatalogUrlError = catalogError,
DataFolderName = ReadDataFolder(root)
DataFolderName = ReadDataFolder(root),
PlayerAccounts = ReadFlag(root, "playerAccounts")
}.WithContentSigning(root);
}
@@ -406,6 +415,10 @@ public sealed class LauncherCatalog
return this;
}
/// <summary>Booléen facultatif (faux si absent ou mal écrit).</summary>
private static bool ReadFlag(JsonObject root, string name) =>
Get(root, name) is JsonValue value && value.TryGetValue<bool>(out var flag) && flag;
/// <summary>« dataFolder » : nom simple de dossier (« GameLauncher » par défaut).</summary>
private static string ReadDataFolder(JsonObject root)
{
@@ -0,0 +1,890 @@
using System.Collections.ObjectModel;
using System.Globalization;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Core.Utils;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Accounts;
/// <summary>Écrans de la fenêtre « Compte ».</summary>
public enum AccountStep
{
Loading,
/// <summary>Comptes non ouverts sur le serveur, ou serveur injoignable (bouton « Réessayer »).</summary>
Unavailable,
SignIn,
Register,
/// <summary>Nouveau mot de passe exigé par un administrateur (après une connexion réussie).</summary>
PasswordChange,
/// <summary>« Mon compte » (onglets <see cref="AccountSection"/>).</summary>
Account
}
/// <summary>Onglets de « Mon compte ».</summary>
public enum AccountSection
{
Profile,
Devices,
Activity,
Data
}
/// <summary>
/// Fenêtre « Compte » (bouton de la barre de titre) : connexion, création de compte, nouveau mot de passe exigé, puis
/// « Mon compte » (profil et mot de passe, appareils connectés, activité récente, export et suppression des données).
/// Les mots de passe ne sont JAMAIS gardés par ce ViewModel : la vue (PasswordBox) les passe aux méthodes au moment de
/// l'action puis efface ses champs (<see cref="ClearPasswordsRequested"/>) ; seul le mot de passe tapé à la connexion est
/// conservé quelques minutes quand un administrateur exige d'en changer (il est demandé par le serveur).
/// </summary>
public sealed class AccountWindowViewModel : ObservableObject, IDisposable
{
private static readonly CultureInfo French = CultureInfo.GetCultureInfo("fr-FR");
/// <summary>Durée de validité du jeton « changement exigé » (serveur : 10 minutes).</summary>
private static readonly TimeSpan PasswordChangeWindow = TimeSpan.FromMinutes(9);
private readonly IPlayerAccountService _service;
private readonly IModuleDialogs _dialogs;
private readonly IUiDispatcher _ui;
private readonly string _publisher;
private readonly CancellationTokenSource _lifetime = new();
private AccountStep _step = AccountStep.Loading;
private AccountSection _section = AccountSection.Profile;
private bool _isBusy;
private string _busyText = "";
private string? _errorMessage;
private string? _successMessage;
private string? _unavailableText;
private string _email = "";
private bool _rememberMe = true;
private bool _showForgotHelp;
private bool _acceptTerms;
private bool _registrationOpen;
private string _termsVersion = "";
private bool _isChangingPassword;
private bool _deleteConfirmed;
private bool _isActivityLoading;
private bool _activityLoaded;
private string? _changeToken;
private string? _changeCurrentPassword;
private DateTime _changeStarted;
private PlayerAccountInfo? _account;
public AccountWindowViewModel(IPlayerAccountService service, IModuleDialogs dialogs, IUiDispatcher ui, string publisher)
{
_service = service;
_dialogs = dialogs;
_ui = ui;
_publisher = string.IsNullOrWhiteSpace(publisher) ? "LanisysGames" : publisher.Trim();
CloseCommand = new RelayCommand(() => CloseRequested?.Invoke(), () => !IsBusy);
RetryCommand = new AsyncRelayCommand(InitializeAsync, () => !IsBusy);
ShowRegisterCommand = new RelayCommand(() => GoTo(AccountStep.Register), () => !IsBusy && RegistrationOpen);
ShowSignInCommand = new RelayCommand(() => GoTo(AccountStep.SignIn), () => !IsBusy);
ToggleForgotCommand = new RelayCommand(() => ShowForgotHelp = !ShowForgotHelp);
ShowChangePasswordCommand = new RelayCommand(() =>
{
ClearMessages();
IsChangingPassword = true;
}, () => !IsBusy);
CancelChangePasswordCommand = new RelayCommand(() => IsChangingPassword = false, () => !IsBusy);
CopyReferenceCommand = new RelayCommand(CopyReference, () => !string.IsNullOrEmpty(Reference));
SignOutCommand = new AsyncRelayCommand(() => SignOutAsync(allDevices: false), () => !IsBusy);
SignOutAllCommand = new AsyncRelayCommand(() => SignOutAsync(allDevices: true), () => !IsBusy);
RefreshCommand = new AsyncRelayCommand(RefreshAccountAsync, () => !IsBusy);
_service.Changed += OnServiceChanged;
}
/// <summary>La vue efface ses champs de mot de passe (changement d'écran, action terminée).</summary>
public event Action? ClearPasswordsRequested;
public event Action? CloseRequested;
public PasswordRulesViewModel NewPassword { get; } = new();
public ObservableCollection<PlayerDeviceItem> Devices { get; } = new();
public ObservableCollection<PlayerActivityItem> Activity { get; } = new();
public RelayCommand CloseCommand { get; }
public AsyncRelayCommand RetryCommand { get; }
public RelayCommand ShowRegisterCommand { get; }
public RelayCommand ShowSignInCommand { get; }
public RelayCommand ToggleForgotCommand { get; }
public RelayCommand ShowChangePasswordCommand { get; }
public RelayCommand CancelChangePasswordCommand { get; }
public RelayCommand CopyReferenceCommand { get; }
public AsyncRelayCommand SignOutCommand { get; }
public AsyncRelayCommand SignOutAllCommand { get; }
public AsyncRelayCommand RefreshCommand { get; }
// ------------------------------------------------------------------ Écran
public AccountStep Step
{
get => _step;
private set
{
if (!SetProperty(ref _step, value)) return;
OnPropertyChanged(nameof(IsLoading));
OnPropertyChanged(nameof(IsUnavailable));
OnPropertyChanged(nameof(IsSignIn));
OnPropertyChanged(nameof(IsRegister));
OnPropertyChanged(nameof(IsPasswordChange));
OnPropertyChanged(nameof(IsAccount));
OnPropertyChanged(nameof(Title));
OnPropertyChanged(nameof(Subtitle));
OnPropertyChanged(nameof(HeaderIcon));
ShowRegisterCommand.RaiseCanExecuteChanged();
}
}
public bool IsLoading => Step == AccountStep.Loading;
public bool IsUnavailable => Step == AccountStep.Unavailable;
public bool IsSignIn => Step == AccountStep.SignIn;
public bool IsRegister => Step == AccountStep.Register;
public bool IsPasswordChange => Step == AccountStep.PasswordChange;
public bool IsAccount => Step == AccountStep.Account;
public string Title => Step switch
{
AccountStep.SignIn => "Se connecter",
AccountStep.Register => "Créer un compte",
AccountStep.PasswordChange => "Nouveau mot de passe exigé",
AccountStep.Account => "Mon compte",
_ => "Compte"
};
public string Subtitle => Step switch
{
AccountStep.SignIn => $"Avec votre compte {_publisher}",
AccountStep.Register => "Gratuit : une adresse e-mail et un mot de passe suffisent",
AccountStep.PasswordChange => $"Demandé par l'équipe {_publisher}",
AccountStep.Account => EmailMasked,
AccountStep.Unavailable => "Service indisponible",
_ => "Connexion au serveur…"
};
/// <summary>Clé de l'icône d'en-tête (Themes\Icons.xaml).</summary>
public string HeaderIcon => Step switch
{
AccountStep.Register => "AccountKey",
AccountStep.PasswordChange => "ShieldKey",
_ => "AccountCircle"
};
public bool IsBusy
{
get => _isBusy;
private set
{
if (!SetProperty(ref _isBusy, value)) return;
OnPropertyChanged(nameof(IsIdle));
CloseCommand.RaiseCanExecuteChanged();
RetryCommand.RaiseCanExecuteChanged();
ShowRegisterCommand.RaiseCanExecuteChanged();
ShowSignInCommand.RaiseCanExecuteChanged();
ShowChangePasswordCommand.RaiseCanExecuteChanged();
CancelChangePasswordCommand.RaiseCanExecuteChanged();
SignOutCommand.RaiseCanExecuteChanged();
SignOutAllCommand.RaiseCanExecuteChanged();
RefreshCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Saisie et boutons utilisables (aucune action en cours).</summary>
public bool IsIdle => !IsBusy;
public string BusyText
{
get => _busyText;
private set => SetProperty(ref _busyText, value);
}
public string? ErrorMessage
{
get => _errorMessage;
private set
{
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
}
}
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
public string? SuccessMessage
{
get => _successMessage;
private set
{
if (SetProperty(ref _successMessage, value)) OnPropertyChanged(nameof(HasSuccess));
}
}
public bool HasSuccess => !string.IsNullOrEmpty(SuccessMessage);
public string? UnavailableText
{
get => _unavailableText;
private set => SetProperty(ref _unavailableText, value);
}
// ------------------------------------------------------------------ Connexion et création
/// <summary>Adresse e-mail saisie (connexion et création), gardée d'un écran à l'autre.</summary>
public string Email
{
get => _email;
set => SetProperty(ref _email, value ?? "");
}
public int EmailMaxLength => PlayerEmail.MaxLength;
public int PasswordMaxLength => PasswordRules.MaxLength;
/// <summary>« Rester connecté sur ce PC » (session mémorisée, chiffrée par Windows).</summary>
public bool RememberMe
{
get => _rememberMe;
set => SetProperty(ref _rememberMe, value);
}
public bool ShowForgotHelp
{
get => _showForgotHelp;
private set => SetProperty(ref _showForgotHelp, value);
}
public string ForgotHelpText =>
$"Contactez l'équipe {_publisher} (Discord, e-mail…) en indiquant l'adresse de votre compte : un administrateur pourra " +
"vous donner un mot de passe temporaire. La réinitialisation par e-mail arrivera dans une prochaine version.";
public bool RegistrationOpen
{
get => _registrationOpen;
private set
{
if (!SetProperty(ref _registrationOpen, value)) return;
OnPropertyChanged(nameof(RegistrationClosed));
ShowRegisterCommand.RaiseCanExecuteChanged();
}
}
public bool RegistrationClosed => !RegistrationOpen;
public bool AcceptTerms
{
get => _acceptTerms;
set => SetProperty(ref _acceptTerms, value);
}
public string TermsText => string.IsNullOrEmpty(_termsVersion)
? "J'accepte les conditions d'utilisation et la politique de confidentialité."
: $"J'accepte les conditions d'utilisation et la politique de confidentialité (version {_termsVersion}).";
public string PrivacyText =>
"Conservés : votre adresse e-mail (chiffrée), une empreinte irréversible du mot de passe, les appareils connectés et " +
"l'activité de connexion (12 mois, adresses IP 6 mois). Export et suppression à tout moment depuis « Mon compte ».";
public string RememberText => "Rester connecté sur ce PC";
/// <summary>Saisie de la vue : contrôle en direct du nouveau mot de passe (création, changement).</summary>
public void UpdateNewPassword(string password, string confirmation) =>
NewPassword.Update(password, confirmation, Step == AccountStep.Register ? Email : null);
public async Task SignInAsync(string password)
{
if (IsBusy) return;
ClearMessages();
if (PlayerEmail.Normalize(Email) is null)
{
ErrorMessage = "Adresse e-mail invalide.";
return;
}
if (string.IsNullOrEmpty(password))
{
ErrorMessage = "Saisissez votre mot de passe.";
return;
}
await RunAsync("Vérification anti-robot puis connexion…", async () =>
{
var result = await _service.SignInAsync(Email, password, RememberMe, _lifetime.Token);
if (result.PasswordChangeRequired)
{
_changeToken = result.PasswordChangeToken;
_changeCurrentPassword = password; // exigé par le serveur pour le changement ; oublié ensuite
_changeStarted = DateTime.UtcNow;
GoTo(AccountStep.PasswordChange);
return;
}
await EnterAccountAsync();
});
}
public async Task RegisterAsync(string password, string confirmation)
{
if (IsBusy) return;
ClearMessages();
if (PlayerEmail.Normalize(Email) is null)
{
ErrorMessage = "Adresse e-mail invalide.";
return;
}
NewPassword.Update(password, confirmation, Email);
if (!NewPassword.IsAccepted)
{
ErrorMessage = NewPassword.Problems.FirstOrDefault() ?? "Mot de passe refusé.";
return;
}
if (!NewPassword.ConfirmationMatches)
{
ErrorMessage = "Les deux mots de passe ne sont pas identiques.";
return;
}
if (!AcceptTerms)
{
ErrorMessage = "Acceptez les conditions d'utilisation et la politique de confidentialité pour créer votre compte.";
return;
}
await RunAsync("Vérification anti-robot puis création du compte (quelques secondes)…", async () =>
{
await _service.RegisterAsync(Email, password, _termsVersion, RememberMe, _lifetime.Token);
await EnterAccountAsync();
SuccessMessage = "Bienvenue ! Votre compte est créé.";
});
}
// ------------------------------------------------------------------ Nouveau mot de passe exigé
public string PasswordChangeText =>
$"Pour la sécurité de votre compte, l'équipe {_publisher} vous demande de choisir un nouveau mot de passe. " +
"Vos autres appareils seront déconnectés.";
public async Task CompleteRequiredPasswordChangeAsync(string password, string confirmation)
{
if (IsBusy) return;
ClearMessages();
if (_changeToken is null || _changeCurrentPassword is null || DateTime.UtcNow - _changeStarted > PasswordChangeWindow)
{
ForgetRequiredChange();
GoTo(AccountStep.SignIn);
ErrorMessage = "Délai dépassé : reconnectez-vous pour choisir votre nouveau mot de passe.";
return;
}
NewPassword.Update(password, confirmation, null);
if (!NewPassword.IsValid)
{
ErrorMessage = NewPassword.IsAccepted ? "Les deux mots de passe ne sont pas identiques." : NewPassword.Problems.FirstOrDefault();
return;
}
await RunAsync("Enregistrement du nouveau mot de passe…", async () =>
{
await _service.CompleteRequiredPasswordChangeAsync(_changeToken, _changeCurrentPassword, password, RememberMe, _lifetime.Token);
ForgetRequiredChange();
await EnterAccountAsync();
SuccessMessage = "Nouveau mot de passe enregistré : vous êtes connecté.";
});
}
private void ForgetRequiredChange()
{
_changeToken = null;
_changeCurrentPassword = null;
}
// ------------------------------------------------------------------ Mon compte
public AccountSection Section
{
get => _section;
private set
{
if (!SetProperty(ref _section, value)) return;
OnPropertyChanged(nameof(IsProfileSection));
OnPropertyChanged(nameof(IsDevicesSection));
OnPropertyChanged(nameof(IsActivitySection));
OnPropertyChanged(nameof(IsDataSection));
ClearMessages();
IsChangingPassword = false;
DeleteConfirmed = false;
ClearPasswordsRequested?.Invoke();
if (value == AccountSection.Activity && !_activityLoaded) _ = LoadActivityAsync();
}
}
// Onglets (RadioButton : IsChecked lié dans les deux sens).
public bool IsProfileSection
{
get => Section == AccountSection.Profile;
set { if (value) Section = AccountSection.Profile; }
}
public bool IsDevicesSection
{
get => Section == AccountSection.Devices;
set { if (value) Section = AccountSection.Devices; }
}
public bool IsActivitySection
{
get => Section == AccountSection.Activity;
set { if (value) Section = AccountSection.Activity; }
}
public bool IsDataSection
{
get => Section == AccountSection.Data;
set { if (value) Section = AccountSection.Data; }
}
public string EmailMasked => _account?.EmailMasked ?? "";
public string Reference => _account?.Reference ?? "";
public string CreatedText => FormatDate(_account?.CreatedAt);
public string LastLoginText => FormatDate(_account?.LastLoginAt);
public string PasswordChangedText => FormatDate(_account?.PasswordChangedAt);
public bool NewDevicesSuspended => _account?.NewDevicesSuspended == true;
public string AvatarText => EmailMasked.Length > 0 ? EmailMasked[..1].ToUpperInvariant() : "?";
public string SuspendedText =>
"Par sécurité, les connexions depuis un NOUVEL appareil sont suspendues sur ce compte après de nombreux essais de mot de " +
$"passe. Cet appareil continue de fonctionner. Contactez l'équipe {_publisher} avec la référence {Reference} pour lever la suspension.";
public bool IsChangingPassword
{
get => _isChangingPassword;
private set
{
if (!SetProperty(ref _isChangingPassword, value)) return;
NewPassword.Reset();
ClearPasswordsRequested?.Invoke();
}
}
public async Task ChangePasswordAsync(string currentPassword, string password, string confirmation)
{
if (IsBusy) return;
ClearMessages();
if (string.IsNullOrEmpty(currentPassword))
{
ErrorMessage = "Saisissez votre mot de passe actuel.";
return;
}
NewPassword.Update(password, confirmation, null);
if (!NewPassword.IsValid)
{
ErrorMessage = NewPassword.IsAccepted ? "Les deux mots de passe ne sont pas identiques." : NewPassword.Problems.FirstOrDefault();
return;
}
await RunAsync("Enregistrement du nouveau mot de passe…", async () =>
{
var others = await _service.ChangePasswordAsync(currentPassword, password, _lifetime.Token);
IsChangingPassword = false;
await LoadMeAsync();
SuccessMessage = others switch
{
0 => "Mot de passe modifié.",
1 => "Mot de passe modifié. Votre autre appareil a été déconnecté.",
_ => $"Mot de passe modifié. Vos {others} autres appareils ont été déconnectés."
};
});
}
public bool IsActivityLoading
{
get => _isActivityLoading;
private set
{
if (SetProperty(ref _isActivityLoading, value)) OnPropertyChanged(nameof(IsActivityEmpty));
}
}
public bool IsActivityEmpty => !IsActivityLoading && _activityLoaded && Activity.Count == 0;
public bool DeleteConfirmed
{
get => _deleteConfirmed;
set => SetProperty(ref _deleteConfirmed, value);
}
public string ExportText =>
"Téléchargez toutes les données de votre compte (adresse e-mail, appareils, activité…) dans un fichier JSON lisible " +
"par machine. Votre mot de passe est demandé, car le fichier contient votre adresse complète.";
public string DeleteText =>
"Votre compte, vos appareils connectés et l'activité de votre compte sont effacés DÉFINITIVEMENT. Le jeu et ses " +
"sauvegardes sur ce PC ne sont pas touchés. Une simple trace de la suppression (référence du compte, date) est gardée " +
"12 mois dans le journal de sécurité.";
/// <summary>Export des données : emplacement choisi d'abord (annulable sans rien envoyer), puis téléchargement.</summary>
public async Task ExportAsync(string password)
{
if (IsBusy) return;
ClearMessages();
if (string.IsNullOrEmpty(password))
{
ErrorMessage = "Saisissez votre mot de passe pour exporter vos données.";
return;
}
var path = _dialogs.PickSaveFile("Exporter mes données", "Fichier JSON (*.json)|*.json",
PlayerDataExport.SuggestedFileName(Reference, DateTimeOffset.Now), Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments));
if (path is null) return;
await RunAsync("Préparation de l'export…", async () =>
{
var bytes = await _service.ExportAsync(password, _lifetime.Token);
await File.WriteAllBytesAsync(path, bytes, _lifetime.Token);
ClearPasswordsRequested?.Invoke();
SuccessMessage = $"Vos données ont été enregistrées dans {Path.GetFileName(path)}.";
Log.Info("Compte : données exportées.");
});
}
public async Task DeleteAccountAsync(string password)
{
if (IsBusy) return;
ClearMessages();
if (!DeleteConfirmed)
{
ErrorMessage = "Cochez la case pour confirmer que vous comprenez que la suppression est définitive.";
return;
}
if (string.IsNullOrEmpty(password))
{
ErrorMessage = "Saisissez votre mot de passe pour supprimer votre compte.";
return;
}
if (!_dialogs.Confirm("Supprimer définitivement le compte ?",
$"Le compte {EmailMasked} ({Reference}) sera supprimé définitivement. Cette action est irréversible.",
"Supprimer mon compte", "Annuler", danger: true))
return;
await RunAsync("Suppression du compte…", async () =>
{
await _service.DeleteAccountAsync(password, _lifetime.Token);
Email = "";
GoTo(AccountStep.SignIn);
SuccessMessage = "Votre compte a été supprimé définitivement.";
});
}
// ------------------------------------------------------------------ Chargement
/// <summary>Ouverture de la fenêtre (et « Réessayer ») : compte connecté, sinon état du service puis connexion.</summary>
public async Task InitializeAsync()
{
ClearMessages();
_ = Task.Run(() => PasswordRules.Check("préchargement-du-dictionnaire")); // dictionnaire prêt avant la première frappe
if (_service.State is PlayerAccountState.SignedIn or PlayerAccountState.Restoring or PlayerAccountState.Offline)
{
await RunAsync("Chargement du compte…", EnterAccountAsync, Step == AccountStep.Account ? null : AccountStep.Loading);
return;
}
Step = AccountStep.Loading;
IsBusy = true;
BusyText = "Connexion au serveur…";
try
{
var info = await _service.GetServiceInfoAsync(_lifetime.Token);
if (info is not { Enabled: true })
{
UnavailableText = "Les comptes joueurs ne sont pas encore ouverts. Revenez bientôt !";
Step = AccountStep.Unavailable;
return;
}
RegistrationOpen = info.RegistrationOpen;
_termsVersion = info.TermsVersion;
OnPropertyChanged(nameof(TermsText));
Step = AccountStep.SignIn;
if (_service.Notice is { } notice) ErrorMessage = notice;
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
{
// fenêtre fermée
}
catch (Exception ex)
{
UnavailableText = PlayerAccountTexts.Describe(ex);
Step = AccountStep.Unavailable;
}
finally
{
IsBusy = false;
}
}
private async Task EnterAccountAsync()
{
Section = AccountSection.Profile;
_activityLoaded = false;
Activity.Clear();
await LoadMeAsync();
GoTo(AccountStep.Account, keepMessages: true);
}
private async Task RefreshAccountAsync()
{
ClearMessages();
await RunAsync("Actualisation…", async () =>
{
await LoadMeAsync();
if (Section == AccountSection.Activity) await LoadActivityCoreAsync();
});
}
private async Task LoadMeAsync()
{
var me = await _service.GetMeAsync(_lifetime.Token);
SetAccount(me.Account);
Devices.Clear();
foreach (var device in me.Devices.OrderByDescending(d => d.Current).ThenByDescending(d => d.LastUsedAt))
Devices.Add(new PlayerDeviceItem(device, FormatDate(device.CreatedAt), FormatDate(device.LastUsedAt), RemoveDeviceAsync));
}
private async Task LoadActivityAsync()
{
if (IsBusy) return;
try
{
await LoadActivityCoreAsync();
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
{
// fenêtre fermée
}
catch (Exception ex)
{
ShowError(ex);
}
}
private async Task LoadActivityCoreAsync()
{
IsActivityLoading = true;
try
{
var activity = await _service.GetActivityAsync(_lifetime.Token);
Activity.Clear();
foreach (var entry in activity.Entries) Activity.Add(new PlayerActivityItem(entry, FormatDateTime(entry.At)));
_activityLoaded = true;
}
finally
{
IsActivityLoading = false;
OnPropertyChanged(nameof(IsActivityEmpty));
}
}
private async Task RemoveDeviceAsync(PlayerDeviceItem device)
{
if (IsBusy) return;
ClearMessages();
if (!_dialogs.Confirm("Déconnecter cet appareil ?",
$"« {device.Label} » devra se reconnecter avec le mot de passe pour accéder au compte.", "Déconnecter", "Annuler"))
return;
await RunAsync("Déconnexion de l'appareil…", async () =>
{
await _service.RemoveDeviceAsync(device.Id, _lifetime.Token);
if (_service.State == PlayerAccountState.SignedOut) return; // c'était cet appareil
await LoadMeAsync();
SuccessMessage = $"« {device.Label} » a été déconnecté.";
});
}
private async Task SignOutAsync(bool allDevices)
{
if (IsBusy) return;
ClearMessages();
if (allDevices && !_dialogs.Confirm("Déconnecter tous vos appareils ?",
"Tous les appareils connectés à votre compte, y compris celui-ci, devront se reconnecter avec le mot de passe.",
"Tout déconnecter", "Annuler", danger: true))
return;
await RunAsync(allDevices ? "Déconnexion de tous les appareils…" : "Déconnexion…", async () =>
{
await _service.SignOutAsync(allDevices, _lifetime.Token);
GoTo(AccountStep.SignIn);
SuccessMessage = allDevices ? "Tous vos appareils ont été déconnectés." : "Vous êtes déconnecté.";
});
}
private void CopyReference()
{
if (string.IsNullOrEmpty(Reference)) return;
_dialogs.CopyToClipboard(Reference);
ClearMessages();
SuccessMessage = $"Référence {Reference} copiée.";
}
// ------------------------------------------------------------------ Outils
/// <summary>Action longue : saisie bloquée, erreurs affichées en clair ; session perdue → écran de connexion.</summary>
private async Task RunAsync(string busyText, Func<Task> work, AccountStep? stepWhileBusy = null)
{
IsBusy = true;
BusyText = busyText;
if (stepWhileBusy is { } step) Step = step;
try
{
await work();
}
catch (PlayerSessionLostException ex)
{
GoTo(AccountStep.SignIn);
await EnsureServiceInfoAsync();
ErrorMessage = ex.Message;
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
{
// fenêtre fermée pendant l'action
}
catch (Exception ex)
{
if (Step == AccountStep.Loading) Step = _service.State == PlayerAccountState.SignedOut ? AccountStep.SignIn : AccountStep.Account;
ShowError(ex);
}
finally
{
IsBusy = false;
ClearPasswordsRequested?.Invoke();
}
}
/// <summary>Informations du service (création ouverte, conditions) si l'écran de connexion est atteint sans elles.</summary>
private async Task EnsureServiceInfoAsync()
{
if (!string.IsNullOrEmpty(_termsVersion)) return;
try
{
if (await _service.GetServiceInfoAsync(_lifetime.Token) is { } info)
{
RegistrationOpen = info.Enabled && info.RegistrationOpen;
_termsVersion = info.TermsVersion;
OnPropertyChanged(nameof(TermsText));
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Log.Warn($"Compte : état du service illisible ({ex.Message}).");
}
}
private void ShowError(Exception ex)
{
ErrorMessage = PlayerAccountTexts.Describe(ex);
Log.Warn($"Compte : {ex.GetType().Name} : {ex.Message}");
}
private void GoTo(AccountStep step, bool keepMessages = false)
{
if (!keepMessages) ClearMessages();
if (step != AccountStep.PasswordChange) ForgetRequiredChange();
if (step is AccountStep.SignIn or AccountStep.Register)
{
AcceptTerms = false;
ShowForgotHelp = false;
if (step == AccountStep.Register && !RegistrationOpen) step = AccountStep.SignIn;
}
IsChangingPassword = false;
DeleteConfirmed = false;
NewPassword.Reset();
Step = step;
ClearPasswordsRequested?.Invoke();
}
private void ClearMessages()
{
ErrorMessage = null;
SuccessMessage = null;
}
private void SetAccount(PlayerAccountInfo? account)
{
_account = account;
foreach (var name in new[]
{
nameof(EmailMasked), nameof(Reference), nameof(CreatedText), nameof(LastLoginText), nameof(PasswordChangedText),
nameof(NewDevicesSuspended), nameof(AvatarText), nameof(SuspendedText), nameof(Subtitle)
})
OnPropertyChanged(name);
CopyReferenceCommand.RaiseCanExecuteChanged();
}
/// <summary>Changement venu du service (session perdue pendant que la fenêtre est ouverte…).</summary>
private void OnServiceChanged() => _ui.Post(() =>
{
if (_service.State == PlayerAccountState.SignedOut && Step is AccountStep.Account && !IsBusy)
{
GoTo(AccountStep.SignIn);
ErrorMessage = _service.Notice ?? "Vous avez été déconnecté.";
}
else if (_service.Account is { } account && Step == AccountStep.Account)
{
SetAccount(account);
}
});
internal static string FormatDate(DateTimeOffset? date) =>
date is { } value && value != default ? value.ToLocalTime().ToString("d MMMM yyyy", French) : "—";
internal static string FormatDateTime(DateTimeOffset date) =>
date.ToLocalTime().ToString("d MMM yyyy 'à' HH:mm", French);
public void Dispose()
{
_service.Changed -= OnServiceChanged;
_lifetime.Cancel(); // actions en cours abandonnées (la source n'est pas libérée : une action peut encore la lire)
ForgetRequiredChange();
}
}
/// <summary>Un appareil connecté au compte (onglet « Appareils »).</summary>
public sealed class PlayerDeviceItem
{
public PlayerDeviceItem(PlayerDeviceInfo device, string createdText, string lastUsedText, Func<PlayerDeviceItem, Task> remove)
{
Id = device.Id;
Label = device.Label;
IsCurrent = device.Current;
Detail = $"Connecté le {createdText} · utilisé le {lastUsedText}";
RemoveCommand = new AsyncRelayCommand(() => remove(this));
}
public string Id { get; }
public string Label { get; }
public string Detail { get; }
/// <summary>Appareil de la session en cours (« Cet appareil » ; se déconnecte avec « Se déconnecter »).</summary>
public bool IsCurrent { get; }
public bool CanRemove => !IsCurrent;
public AsyncRelayCommand RemoveCommand { get; }
}
/// <summary>Une ligne de l'activité récente du compte.</summary>
public sealed class PlayerActivityItem
{
private static readonly HashSet<string> Alerts = new(StringComparer.Ordinal)
{
"account.locked", "account.suspended", "session.reuse", "account.disabled"
};
public PlayerActivityItem(PlayerActivityEntry entry, string dateText)
{
Title = PlayerActivityText.Describe(entry.Type, entry.Success);
IsWarning = !entry.Success || Alerts.Contains(entry.Type);
var where = entry.ThisDevice ? "cet appareil" : entry.Device ?? (entry.Ip is { } ip ? $"adresse {ip}" : null);
Detail = where is null ? dateText : $"{dateText} · {where}";
}
public string Title { get; }
public string Detail { get; }
/// <summary>Échec ou alerte de sécurité (icône orange).</summary>
public bool IsWarning { get; }
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Module « Compte » côté joueur (sans WPF) : connexion, création de compte, session mémorisée (jeton de renouvellement
chiffré sur le PC, lié à la clé de l'appareil), « Mon compte » (appareils, activité, mot de passe, export des données,
suppression du compte). Testable sans interface, contre la Launcher API démarrée en mémoire.
Ce qui dépend de Windows est fourni par le launcher : clé d'appareil (puce TPM ou clé logicielle CNG, Platform\PlayerDeviceKeyStore.cs)
et chiffrement de la session (DPAPI, Platform\DpapiProtector.cs) ; la fenêtre est Views\AccountWindow.xaml.
Voir docs\PLAYER-ACCOUNTS-ARCHITECTURE.md.
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>GameLauncher.Modules.Accounts</RootNamespace>
<AssemblyName>GameLauncher.Modules.Accounts</AssemblyName>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="GameLauncher.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,103 @@
using GameLauncher.Contracts.Accounts;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Accounts;
/// <summary>
/// Contrôle EN DIRECT d'un nouveau mot de passe (création de compte, changement) avec les mêmes règles que le serveur
/// (<see cref="PasswordRules"/>) : problèmes restants, confirmation identique, jauge de solidité indicative. Le mot de passe
/// n'est jamais gardé ici : seule la vue (PasswordBox) le détient ; elle appelle <see cref="Update"/> à chaque frappe.
/// </summary>
public sealed class PasswordRulesViewModel : ObservableObject
{
private IReadOnlyList<string> _problems = Array.Empty<string>();
private bool _hasInput;
private bool _hasConfirmation;
private bool _confirmationMatches;
private int _strength;
/// <summary>Rappel des règles, affiché tant que rien n'est saisi.</summary>
public string RulesText =>
$"Au moins {PasswordRules.MinLength} caractères mêlant au moins {PasswordRules.RequiredClasses} sortes parmi minuscules, majuscules, " +
$"chiffres et symboles, ou une phrase de passe d'au moins {PasswordRules.MinLengthWithoutComposition} caractères. " +
"Les mots de passe trop courants sont refusés.";
/// <summary>Problèmes restants (messages du contrôle commun au launcher et au serveur).</summary>
public IReadOnlyList<string> Problems
{
get => _problems;
private set => SetProperty(ref _problems, value);
}
public bool HasInput
{
get => _hasInput;
private set => SetProperty(ref _hasInput, value);
}
public bool ShowRules => !HasInput;
public bool HasProblems => HasInput && Problems.Count > 0;
/// <summary>Mot de passe conforme aux règles (indépendamment de la confirmation).</summary>
public bool IsAccepted => HasInput && Problems.Count == 0;
public bool HasConfirmation
{
get => _hasConfirmation;
private set => SetProperty(ref _hasConfirmation, value);
}
public bool ConfirmationMatches
{
get => _confirmationMatches;
private set => SetProperty(ref _confirmationMatches, value);
}
public bool ShowConfirmationMismatch => HasConfirmation && !ConfirmationMatches;
/// <summary>Mot de passe accepté ET confirmation identique : le bouton d'envoi peut être utilisé.</summary>
public bool IsValid => IsAccepted && ConfirmationMatches;
/// <summary>Jauge 0 à 4 (indicative : longueur et variété au-delà du minimum).</summary>
public int Strength
{
get => _strength;
private set => SetProperty(ref _strength, value);
}
public string StrengthText => !HasInput ? "" : !IsAccepted ? "Refusé" : Strength switch
{
>= 4 => "Excellent",
3 => "Très bon",
_ => "Correct"
};
/// <param name="email">Adresse du compte (création) : un mot de passe qui en est tiré est refusé.</param>
public void Update(string password, string confirmation, string? email)
{
HasInput = !string.IsNullOrEmpty(password);
Problems = HasInput ? PasswordRules.Check(password, email) : Array.Empty<string>();
HasConfirmation = !string.IsNullOrEmpty(confirmation);
ConfirmationMatches = HasInput && string.Equals(password, confirmation, StringComparison.Ordinal);
Strength = !HasInput || Problems.Count > 0 ? 0 : Score(password);
OnPropertyChanged(nameof(ShowRules));
OnPropertyChanged(nameof(HasProblems));
OnPropertyChanged(nameof(IsAccepted));
OnPropertyChanged(nameof(ShowConfirmationMismatch));
OnPropertyChanged(nameof(IsValid));
OnPropertyChanged(nameof(StrengthText));
}
public void Reset() => Update("", "", null);
private static int Score(string password)
{
var classes = (password.Any(char.IsLower) ? 1 : 0) + (password.Any(char.IsUpper) ? 1 : 0) +
(password.Any(char.IsDigit) ? 1 : 0) + (password.Any(c => !char.IsLetterOrDigit(c)) ? 1 : 0);
var length = password.Length;
var score = 2;
if (length >= 16) score++;
if (length >= 20 || (length >= 14 && classes == 4)) score++;
return Math.Min(4, score);
}
}
@@ -0,0 +1,502 @@
using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Core.Utils;
namespace GameLauncher.Modules.Accounts;
/// <summary>État du compte joueur dans le launcher.</summary>
public enum PlayerAccountState
{
/// <summary>Aucun compte connecté.</summary>
SignedOut,
/// <summary>Session mémorisée en cours de renouvellement (démarrage du launcher).</summary>
Restoring,
SignedIn,
/// <summary>Session mémorisée, serveur injoignable : nouvel essai à la prochaine action.</summary>
Offline
}
/// <summary>Résultat d'une connexion : connecté, ou nouveau mot de passe exigé par un administrateur.</summary>
/// <param name="PasswordChangeToken">Jeton limité (10 min) qui permet seulement de choisir le nouveau mot de passe.</param>
public sealed record PlayerSignInResult(bool PasswordChangeRequired, string? PasswordChangeToken = null);
/// <summary>Compte joueur du launcher (implémenté par <see cref="PlayerAccountService"/>, simulé par les tests et UiSnapshot).</summary>
public interface IPlayerAccountService
{
PlayerAccountState State { get; }
/// <summary>Compte connecté (ou mémorisé hors ligne : référence et adresse masquée seulement), sinon null.</summary>
PlayerAccountInfo? Account { get; }
/// <summary>Raison de la dernière déconnexion imposée (session expirée, compte désactivé…), à afficher une fois.</summary>
string? Notice { get; }
/// <summary>Changement d'état ou de compte (peut être levé depuis un autre fil).</summary>
event Action? Changed;
/// <summary>État du service des comptes annoncé par le serveur (null : comptes non activés sur ce serveur).</summary>
Task<PlayerAccountsInfo?> GetServiceInfoAsync(CancellationToken ct = default);
/// <summary>Reprend la session mémorisée (démarrage du launcher), sans jamais lever d'erreur.</summary>
Task RestoreAsync(CancellationToken ct = default);
Task<PlayerSignInResult> SignInAsync(string email, string password, bool remember, CancellationToken ct = default);
Task RegisterAsync(string email, string password, string termsVersion, bool remember, CancellationToken ct = default);
/// <summary>Nouveau mot de passe EXIGÉ (après <see cref="SignInAsync"/>) : la session s'ouvre ensuite.</summary>
Task CompleteRequiredPasswordChangeAsync(string passwordChangeToken, string currentPassword, string newPassword, bool remember,
CancellationToken ct = default);
/// <summary>Changement ordinaire : renvoie le nombre d'autres appareils déconnectés.</summary>
Task<int> ChangePasswordAsync(string currentPassword, string newPassword, CancellationToken ct = default);
Task<PlayerMeResponse> GetMeAsync(CancellationToken ct = default);
Task<PlayerActivityResponse> GetActivityAsync(CancellationToken ct = default);
Task RemoveDeviceAsync(string deviceId, CancellationToken ct = default);
/// <summary>Déconnexion de ce PC (ou de tous les appareils) ; la session locale est effacée même si le serveur est injoignable.</summary>
Task SignOutAsync(bool allDevices, CancellationToken ct = default);
Task<byte[]> ExportAsync(string password, CancellationToken ct = default);
/// <summary>Suppression DÉFINITIVE du compte ; la session locale est effacée.</summary>
Task DeleteAccountAsync(string password, CancellationToken ct = default);
}
/// <summary>Session perdue (expirée, révoquée, compte désactivé…) : le joueur doit se reconnecter.</summary>
public sealed class PlayerSessionLostException : Exception
{
public PlayerSessionLostException(string message, Exception? inner = null) : base(message, inner) { }
}
/// <summary>
/// Compte joueur du launcher. Le jeton d'accès (15 min) reste en mémoire ; le jeton de renouvellement est enregistré
/// chiffré (<see cref="PlayerSessionFile"/>) si le joueur a choisi « Rester connecté », sinon gardé en mémoire et la session
/// est fermée à la sortie du launcher (<see cref="Shutdown"/>). Chaque renouvellement est signé par la clé de l'appareil ;
/// le nouveau jeton remplace aussitôt l'ancien sur le disque (l'ancien, présenté de nouveau, ferait déconnecter l'appareil
/// par le serveur). Un renouvellement à la fois (verrou) ; jeton d'accès renouvelé une minute avant son échéance, ou
/// aussitôt si le serveur l'a oublié (redémarrage).
/// Le launcher ne contacte l'API des comptes que sur une action du joueur, ou pour reprendre une session mémorisée.
/// </summary>
public sealed class PlayerAccountService : IPlayerAccountService, IDisposable
{
private static readonly TimeSpan AccessMargin = TimeSpan.FromMinutes(1);
private readonly PlayerAccountApi _api;
private readonly IPlayerDeviceKeyStore _keys;
private readonly PlayerSessionFile _file;
private readonly string _deviceLabel;
private readonly TimeProvider _time;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly object _lock = new();
private IPlayerDeviceKey? _key;
private string? _access;
private DateTimeOffset _accessExpiresAt;
private string? _refresh;
private string? _deviceId;
private DateTimeOffset _refreshExpiresAt;
private bool _remember;
private PlayerAccountInfo? _account;
private PlayerAccountState _state = PlayerAccountState.SignedOut;
private string? _notice;
public PlayerAccountService(PlayerAccountApi api, IPlayerDeviceKeyStore keys, PlayerSessionFile file, string deviceLabel,
TimeProvider? time = null)
{
_api = api;
_keys = keys;
_file = file;
_deviceLabel = deviceLabel;
_time = time ?? TimeProvider.System;
}
public event Action? Changed;
public PlayerAccountState State
{
get { lock (_lock) return _state; }
}
public PlayerAccountInfo? Account
{
get { lock (_lock) return _account; }
}
public string? Notice
{
get { lock (_lock) return _notice; }
}
/// <summary>Session mémorisée sur ce PC (pour les tests et l'affichage).</summary>
public bool IsRemembered
{
get { lock (_lock) return _remember && _refresh is not null; }
}
public async Task<PlayerAccountsInfo?> GetServiceInfoAsync(CancellationToken ct = default) =>
(await _api.Client.GetInfoAsync(ct).ConfigureAwait(false)).Accounts;
// ------------------------------------------------------------------ Session mémorisée
public async Task RestoreAsync(CancellationToken ct = default)
{
StoredPlayerSession? stored;
try
{
stored = _file.Load();
if (stored is null) return;
if (!string.Equals(stored.Origin, _api.Client.Origin, StringComparison.OrdinalIgnoreCase))
{
Log.Info("Compte : session mémorisée d'un autre serveur, ignorée.");
_file.Delete();
return;
}
var key = await Task.Run(() => _keys.TryOpen(), ct).ConfigureAwait(false);
if (key is null || key.KeyId != stored.KeyId)
{
Log.Warn("Compte : clé de l'appareil absente ou remplacée, la session mémorisée est abandonnée.");
_file.Delete();
return;
}
lock (_lock)
{
_key = key;
_refresh = stored.RefreshToken;
_deviceId = stored.DeviceId;
_refreshExpiresAt = stored.RefreshExpiresAt;
_remember = true;
_account = new PlayerAccountInfo { Reference = stored.Reference, EmailMasked = stored.EmailMasked };
_state = PlayerAccountState.Restoring;
}
RaiseChanged();
}
catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException or PlatformNotSupportedException)
{
Log.Error("Compte : reprise de la session mémorisée", ex);
return;
}
try
{
await RenewAsync(force: true, ct).ConfigureAwait(false);
Log.Info("Compte : session reprise.");
}
catch (PlayerSessionLostException ex)
{
Log.Info($"Compte : session mémorisée refusée ({ex.Message}).");
}
catch (Exception ex)
{
Log.Warn($"Compte : serveur injoignable, session mémorisée conservée ({ex.Message}).");
}
}
// ------------------------------------------------------------------ Connexion, création de compte
public async Task<PlayerSignInResult> SignInAsync(string email, string password, bool remember, CancellationToken ct = default)
{
var key = await OpenKeyAsync(ct).ConfigureAwait(false);
var response = await _api.LoginAsync(email, password, key, _deviceLabel, ct).ConfigureAwait(false);
if (response.Status == PlayerLoginStatus.PasswordChangeRequired && response.PasswordChangeToken is { } token)
return new PlayerSignInResult(true, token);
Adopt(response.Session ?? throw new ApiException(0, "invalid_response", "Réponse du serveur illisible."), key, remember);
Log.Info("Compte : connecté.");
return new PlayerSignInResult(false);
}
public async Task RegisterAsync(string email, string password, string termsVersion, bool remember, CancellationToken ct = default)
{
var key = await OpenKeyAsync(ct).ConfigureAwait(false);
var response = await _api.RegisterAsync(email, password, termsVersion, key, _deviceLabel, ct).ConfigureAwait(false);
Adopt(response.Session ?? throw new ApiException(0, "invalid_response", "Réponse du serveur illisible."), key, remember);
Log.Info("Compte : créé et connecté.");
}
public async Task CompleteRequiredPasswordChangeAsync(string passwordChangeToken, string currentPassword, string newPassword, bool remember,
CancellationToken ct = default)
{
var key = await OpenKeyAsync(ct).ConfigureAwait(false);
var response = await _api.ChangePasswordAsync(passwordChangeToken, currentPassword, newPassword, ct).ConfigureAwait(false);
Adopt(response.Session ?? throw new ApiException(0, "invalid_response", "Réponse du serveur illisible."), key, remember);
Log.Info("Compte : nouveau mot de passe exigé enregistré, connecté.");
}
// ------------------------------------------------------------------ Compte connecté
public async Task<int> ChangePasswordAsync(string currentPassword, string newPassword, CancellationToken ct = default) =>
(await CallAsync(token => _api.ChangePasswordAsync(token, currentPassword, newPassword, ct), ct).ConfigureAwait(false)).DevicesSignedOut;
public async Task<PlayerMeResponse> GetMeAsync(CancellationToken ct = default)
{
var me = await CallAsync(token => _api.MeAsync(token, ct), ct).ConfigureAwait(false);
lock (_lock) _account = me.Account;
RaiseChanged();
return me;
}
public Task<PlayerActivityResponse> GetActivityAsync(CancellationToken ct = default) =>
CallAsync(token => _api.ActivityAsync(token, ct), ct);
public async Task RemoveDeviceAsync(string deviceId, CancellationToken ct = default)
{
await CallAsync(async token =>
{
await _api.RemoveDeviceAsync(token, deviceId, ct).ConfigureAwait(false);
return true;
}, ct).ConfigureAwait(false);
string? current;
lock (_lock) current = _deviceId;
if (deviceId == current) Clear(null); // cet appareil lui-même
}
public async Task SignOutAsync(bool allDevices, CancellationToken ct = default)
{
try
{
await CallAsync(async token =>
{
await _api.LogoutAsync(token, allDevices, ct).ConfigureAwait(false);
return true;
}, ct).ConfigureAwait(false);
}
catch (Exception ex) when (!allDevices && ex is ApiException or PlayerSessionLostException)
{
// Serveur injoignable ou session déjà fermée : ce PC est déconnecté quand même (le jeton est effacé ici).
Log.Warn($"Compte : déconnexion locale seulement ({ex.Message}).");
}
Clear(null);
Log.Info(allDevices ? "Compte : tous les appareils déconnectés." : "Compte : déconnecté.");
}
public Task<byte[]> ExportAsync(string password, CancellationToken ct = default) =>
CallAsync(token => _api.ExportAsync(token, password, ct), ct);
public async Task DeleteAccountAsync(string password, CancellationToken ct = default)
{
await CallAsync(async token =>
{
await _api.DeleteAccountAsync(token, password, ct).ConfigureAwait(false);
return true;
}, ct).ConfigureAwait(false);
Clear("Votre compte a été supprimé définitivement.");
Log.Info("Compte : supprimé à la demande du joueur.");
}
/// <summary>
/// Sortie du launcher : une session NON mémorisée est fermée sur le serveur (quelques secondes au plus), pour ne pas
/// laisser un appareil connecté que le joueur n'a pas voulu garder.
/// </summary>
public void Shutdown(TimeSpan timeout)
{
bool close;
lock (_lock) close = !_remember && _refresh is not null;
if (!close) return;
try
{
using var cts = new CancellationTokenSource(timeout);
Task.Run(() => SignOutAsync(false, cts.Token)).Wait(timeout);
}
catch (Exception ex)
{
Log.Warn($"Compte : fermeture de la session à la sortie impossible ({ex.Message}).");
}
}
public void Dispose() => _gate.Dispose();
// ------------------------------------------------------------------ Jetons
/// <summary>
/// Appel authentifié : jeton d'accès valable (renouvelé si besoin), puis appel ; un refus 401 (jeton oublié par le
/// serveur après un redémarrage) provoque UN renouvellement et un nouvel essai.
/// </summary>
private async Task<T> CallAsync<T>(Func<string, Task<T>> call, CancellationToken ct)
{
var token = await RenewAsync(force: false, ct).ConfigureAwait(false);
try
{
return await call(token).ConfigureAwait(false);
}
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
{
token = await RenewAsync(force: true, ct, staleToken: token).ConfigureAwait(false);
return await call(token).ConfigureAwait(false);
}
}
/// <summary>Jeton d'accès valable ; renouvellement signé par la clé de l'appareil si besoin (un seul à la fois).</summary>
/// <param name="staleToken">Jeton refusé par le serveur : renouvelé même s'il semble encore valable.</param>
private async Task<string> RenewAsync(bool force, CancellationToken ct, string? staleToken = null)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
string refresh;
IPlayerDeviceKey key;
lock (_lock)
{
var fresh = _access is not null && _time.GetUtcNow() + AccessMargin < _accessExpiresAt;
if (fresh && (staleToken is null ? !force : _access != staleToken)) return _access!;
if (_refresh is null || _key is null) throw new PlayerSessionLostException("Vous n'êtes pas connecté.");
refresh = _refresh;
key = _key;
}
PlayerSession session;
try
{
session = await _api.RefreshAsync(refresh, key, ct).ConfigureAwait(false);
}
catch (ApiException ex) when (IsSessionLost(ex))
{
Clear(PlayerAccountTexts.SessionLost(ex));
throw new PlayerSessionLostException(PlayerAccountTexts.SessionLost(ex), ex);
}
catch (ApiException)
{
// Serveur injoignable, en erreur ou qui limite les essais : la session est gardée, nouvel essai plus tard.
SetState(PlayerAccountState.Offline);
throw;
}
catch (CryptographicException ex)
{
// Clé de l'appareil devenue inutilisable (puce réinitialisée…) : la session ne peut plus être renouvelée.
Clear("La clé de cet appareil est devenue inutilisable : reconnectez-vous.");
throw new PlayerSessionLostException("La clé de cet appareil est devenue inutilisable : reconnectez-vous.", ex);
}
Adopt(session, key, remember: null);
return session.AccessToken;
}
finally
{
_gate.Release();
}
}
private static bool IsSessionLost(ApiException ex) =>
ex.Status == 401 ||
(ex.Status == 403 && ex.Code is ApiErrorCodes.AccountDisabled or ApiErrorCodes.AccountSuspended or ApiErrorCodes.PasswordChangeRequired);
private async Task<IPlayerDeviceKey> OpenKeyAsync(CancellationToken ct)
{
lock (_lock)
{
if (_key is not null) return _key;
}
var key = await Task.Run(() => _keys.OpenOrCreate(), ct).ConfigureAwait(false);
lock (_lock) _key ??= key;
return key;
}
/// <summary>Nouvelle session : jetons en mémoire, jeton de renouvellement enregistré (si mémorisée), état « connecté ».</summary>
/// <param name="remember">Null : inchangé (renouvellement).</param>
private void Adopt(PlayerSession session, IPlayerDeviceKey key, bool? remember)
{
bool save;
lock (_lock)
{
_key = key;
_access = session.AccessToken;
_accessExpiresAt = session.AccessExpiresAt;
_refresh = session.RefreshToken;
_deviceId = session.DeviceId;
_refreshExpiresAt = session.RefreshExpiresAt;
_account = session.Account;
_state = PlayerAccountState.SignedIn;
_notice = null;
if (remember is { } value) _remember = value;
save = _remember;
}
try
{
if (save)
_file.Save(new StoredPlayerSession(_api.Client.Origin, session.RefreshToken, session.DeviceId, key.KeyId,
session.Account.Reference, session.Account.EmailMasked, session.RefreshExpiresAt));
else
_file.Delete();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or CryptographicException)
{
// Session utilisable pendant cette exécution ; au prochain lancement, il faudra se reconnecter.
Log.Error("Compte : enregistrement de la session mémorisée impossible", ex);
}
RaiseChanged();
}
/// <summary>Oublie la session (mémoire et disque) ; la clé de l'appareil est conservée.</summary>
private void Clear(string? notice)
{
lock (_lock)
{
_access = null;
_refresh = null;
_deviceId = null;
_account = null;
_state = PlayerAccountState.SignedOut;
_notice = notice;
}
_file.Delete();
RaiseChanged();
}
private void SetState(PlayerAccountState state)
{
lock (_lock)
{
if (_state == state) return;
_state = state;
}
RaiseChanged();
}
private void RaiseChanged()
{
try
{
Changed?.Invoke();
}
catch (Exception ex)
{
Log.Error("Compte : notification du changement d'état", ex);
}
}
}
/// <summary>Messages français des erreurs du compte joueur.</summary>
public static class PlayerAccountTexts
{
public static string Describe(Exception ex) => ex switch
{
PlayerSessionLostException lost => lost.Message,
ApiException { Status: 0, Code: "unreachable" } => "Connexion au serveur impossible. Vérifiez votre connexion Internet, puis réessayez.",
ApiException { Status: 0, Code: "timeout" } => "Le serveur ne répond pas. Réessayez dans un instant.",
ApiException { Status: 0, Code: "origin_mismatch" } or ApiException { Code: "invalid_response" } =>
"Réponse inattendue du serveur. Réessayez plus tard ; si le problème continue, mettez le launcher à jour.",
ApiException { Code: ApiErrorCodes.NotConfigured } => "Les comptes joueurs ne sont pas encore ouverts.",
ApiException { Status: 401, Code: ApiErrorCodes.Unauthorized } => "Session expirée : reconnectez-vous.",
ApiException api => api.Message,
CryptographicException crypto => $"Clé de cet appareil inutilisable : {crypto.Message}",
IOException or UnauthorizedAccessException => $"Fichier inaccessible : {ex.Message}",
OperationCanceledException => "Opération annulée.",
_ => $"Erreur inattendue : {ex.Message}"
};
/// <summary>Raison d'une session refusée au renouvellement.</summary>
public static string SessionLost(ApiException ex) => ex.Code switch
{
ApiErrorCodes.AccountDisabled or ApiErrorCodes.AccountSuspended => ex.Message,
ApiErrorCodes.PasswordChangeRequired => "Un nouveau mot de passe est exigé : reconnectez-vous pour le choisir.",
ApiErrorCodes.DeviceProofInvalid => "Cet appareil n'a pas pu prouver son identité : reconnectez-vous.",
_ => "Votre session a expiré ou a été fermée depuis un autre appareil : reconnectez-vous."
};
}
@@ -0,0 +1,47 @@
using System.Security.Cryptography;
using GameLauncher.ApiClient;
namespace GameLauncher.Modules.Accounts;
/// <summary>
/// Clé d'appareil du joueur sur ce PC (une par compte Windows, partagée par les comptes joueurs utilisés sur ce PC).
/// Implémentation Windows dans le launcher (Platform\PlayerDeviceKeyStore.cs) : puce de sécurité (TPM 2.0) SANS code PIN
/// quand elle existe, sinon clé logicielle non exportable du fournisseur de Windows (protégée par la session Windows).
/// Les méthodes peuvent être lentes (accès à la puce) : jamais appelées sur le fil de l'interface.
/// </summary>
public interface IPlayerDeviceKeyStore
{
/// <summary>Ouvre la clé existante, ou null si elle n'existe pas (encore) sur ce PC.</summary>
IPlayerDeviceKey? TryOpen();
/// <summary>Ouvre la clé, en la créant si besoin.</summary>
IPlayerDeviceKey OpenOrCreate();
/// <summary>Supprime la clé (sans erreur si elle n'existe pas).</summary>
void Delete();
}
/// <summary>
/// Chiffrement local des secrets du joueur (jeton de renouvellement) : DPAPI de l'utilisateur Windows dans le launcher
/// (Platform\DpapiProtector.cs) ; un autre compte Windows du PC, ou une copie du fichier sur un autre PC, ne peut pas le lire.
/// </summary>
public interface ISecretProtector
{
byte[] Protect(byte[] data);
/// <exception cref="CryptographicException">Données illisibles (autre utilisateur, autre PC, fichier modifié).</exception>
byte[] Unprotect(byte[] data);
}
/// <summary>Nom générique de l'appareil envoyé au serveur (« Windows 11 · GameLauncher 3.4.2 ») : jamais le nom Windows du PC.</summary>
public static class PlayerDeviceLabel
{
public static string Create(string launcherVersion)
{
var os = OperatingSystem.IsWindows()
? Environment.OSVersion.Version.Build >= 22000 ? "Windows 11" : "Windows 10"
: "PC";
var version = new string((launcherVersion ?? "").Where(c => char.IsAsciiDigit(c) || c == '.').Take(20).ToArray());
return version.Length > 0 ? $"{os} · GameLauncher {version}" : $"{os} · GameLauncher";
}
}
@@ -0,0 +1,116 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Core.Utils;
namespace GameLauncher.Modules.Accounts;
/// <summary>Session mémorisée (« Rester connecté »), telle qu'enregistrée chiffrée sur le PC.</summary>
/// <param name="Origin">Serveur qui a ouvert la session (« https://launcher.lanisys.fr ») : ignorée si l'adresse de l'API change.</param>
/// <param name="KeyId">Clé d'appareil avec laquelle la session a été ouverte (sans elle, le jeton ne sert à rien).</param>
/// <param name="Reference">Référence et adresse masquée du compte : affichées hors ligne.</param>
public sealed record StoredPlayerSession(string Origin, string RefreshToken, string DeviceId, string KeyId, string Reference,
string EmailMasked, DateTimeOffset RefreshExpiresAt);
/// <summary>
/// Fichier de la session mémorisée : %AppData%\…\GameLauncher\account\session.dat, chiffré par <see cref="ISecretProtector"/>
/// (DPAPI de l'utilisateur Windows). Le jeton de renouvellement qu'il contient est inutilisable sans la clé de l'appareil
/// (non exportable) et change à chaque lancement. Écriture atomique (fichier temporaire puis remplacement) : une coupure
/// pendant l'écriture ne laisse jamais un fichier à moitié écrit.
/// </summary>
public sealed class PlayerSessionFile
{
public const string FolderName = "account";
public const string FileName = "session.dat";
/// <summary>En-tête du fichier (« GLS » + version du format).</summary>
private static readonly byte[] Magic = "GLS1"u8.ToArray();
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private readonly ISecretProtector _protector;
public PlayerSessionFile(string dataDirectory, ISecretProtector protector)
{
_protector = protector;
Path = System.IO.Path.Combine(dataDirectory, FolderName, FileName);
}
public string Path { get; }
/// <summary>Session enregistrée, ou null (absente, illisible, d'un autre utilisateur ou PC : le fichier est alors supprimé).</summary>
public StoredPlayerSession? Load()
{
byte[] content;
try
{
if (!File.Exists(Path)) return null;
content = File.ReadAllBytes(Path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"Compte : session mémorisée illisible ({ex.Message}).");
return null;
}
try
{
if (content.Length <= Magic.Length || content.Length > 64 * 1024 || !content.AsSpan(0, Magic.Length).SequenceEqual(Magic))
throw new InvalidDataException("en-tête inconnu");
var json = _protector.Unprotect(content[Magic.Length..]);
try
{
var session = JsonSerializer.Deserialize<StoredPlayerSession>(json, Json) ?? throw new InvalidDataException("vide");
if (!PlayerTokens.TryParseRefresh(session.RefreshToken, out var deviceId, out _) || deviceId != session.DeviceId ||
string.IsNullOrEmpty(session.Origin) || string.IsNullOrEmpty(session.KeyId))
throw new InvalidDataException("contenu incohérent");
return session;
}
finally
{
CryptographicOperations.ZeroMemory(json);
}
}
catch (Exception ex) when (ex is CryptographicException or InvalidDataException or JsonException)
{
Log.Warn($"Compte : session mémorisée inutilisable ({ex.Message}) : elle est supprimée.");
Delete();
return null;
}
}
public void Save(StoredPlayerSession session)
{
var json = JsonSerializer.SerializeToUtf8Bytes(session, Json);
byte[] protectedBytes;
try
{
protectedBytes = _protector.Protect(json);
}
finally
{
CryptographicOperations.ZeroMemory(json);
}
var directory = System.IO.Path.GetDirectoryName(Path)!;
Directory.CreateDirectory(directory);
var temp = Path + ".tmp";
using (var stream = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None))
{
stream.Write(Magic);
stream.Write(protectedBytes);
stream.Flush(flushToDisk: true);
}
File.Move(temp, Path, overwrite: true);
}
public void Delete()
{
FileUtil.TryDeleteFile(Path);
FileUtil.TryDeleteFile(Path + ".tmp");
}
/// <summary>Texte d'en-tête utilisé pour lier le chiffrement à cet usage (entropie DPAPI).</summary>
public static byte[] Purpose => Encoding.ASCII.GetBytes("GameLauncher/Player/Session/v1");
}
+67
View File
@@ -59,6 +59,10 @@ public partial class App : Application, ILauncherRestartHost
private HttpDownloader? _http;
private LauncherUpdateService? _updates;
private AdminWindowService? _admin;
/// <summary>Bouton « Compte » (comptes joueurs) et son client de l'API ; null si les comptes ne sont pas activés.</summary>
private AccountWindowService? _accounts;
private GameLauncher.ApiClient.LauncherApiClient? _accountsClient;
private CatalogService? _catalogService;
private ContentTrustService? _trust;
@@ -403,6 +407,10 @@ public partial class App : Application, ILauncherRestartHost
if (launcher.IsMaintenance) return; // bouton masqué pendant une maintenance
feedback.Open(game.GameId, game.GameName, game.InstalledVersion);
};
// Bouton « Compte » (comptes joueurs) : « playerAccounts » ET la même adresse d'API, lus au même endroit. La session
// mémorisée est reprise sans attendre (le launcher ne contacte l'API des comptes que s'il en existe une).
StartPlayerAccounts(catalog, apiUrl, version, window, launcher);
}
window.DataContext = launcher;
@@ -411,6 +419,61 @@ public partial class App : Application, ILauncherRestartHost
return window;
}
// ------------------------------------------------------------------ Comptes joueurs
private void StartPlayerAccounts(LauncherCatalog catalog, string? apiUrl, string version, Views.MainWindow window, LauncherViewModel launcher)
{
if (apiUrl is null || !ResolvePlayerAccounts(catalog) || !Uri.TryCreate(apiUrl, UriKind.Absolute, out var apiUri)) return;
try
{
// Agent utilisateur générique (version du launcher seulement), comme le bouton « Aide ».
_accountsClient = new GameLauncher.ApiClient.LauncherApiClient(apiUri, $"GameLauncher/{version}");
var service = new GameLauncher.Modules.Accounts.PlayerAccountService(
new GameLauncher.ApiClient.PlayerAccountApi(_accountsClient),
new PlayerDeviceKeyStore(catalog.Publisher + catalog.DataFolderName),
new GameLauncher.Modules.Accounts.PlayerSessionFile(catalog.DataDirectory,
new DpapiProtector(GameLauncher.Modules.Accounts.PlayerSessionFile.Purpose)),
GameLauncher.Modules.Accounts.PlayerDeviceLabel.Create(version));
_accounts = new AccountWindowService(window, launcher, service, catalog.Publisher);
window.AccountRequested += () =>
{
if (launcher.IsMaintenance) return; // bouton masqué pendant une maintenance
_accounts?.Open();
};
_accounts.Start(_lifetime.Token);
}
catch (Exception ex)
{
Log.Error("Comptes joueurs", ex);
launcher.IsAccountAvailable = false;
}
}
/// <summary>
/// « playerAccounts ». Comme apiUrl : en Release, seulement celui du launcher.json intégré à l'exécutable (un fichier
/// posé à côté ne peut pas l'activer).
/// </summary>
private static bool ResolvePlayerAccounts(LauncherCatalog catalog)
{
#if DEBUG
return catalog.PlayerAccounts;
#else
try
{
using var embedded = Assembly.GetExecutingAssembly().GetManifestResourceStream(ConfigFileName);
var integrated = embedded is null ? null : LauncherCatalog.Load(embedded);
if (catalog.PlayerAccounts && integrated?.PlayerAccounts != true)
Log.Warn("« playerAccounts » du launcher.json externe ignoré : seul celui intégré à l'exécutable est utilisé.");
return integrated?.PlayerAccounts == true;
}
catch (Exception ex)
{
Log.Error("Comptes joueurs (launcher.json intégré)", ex);
return false;
}
#endif
}
// ------------------------------------------------------------------ Mode maintenance
/// <summary>Attente maximale du mode maintenance au démarrage (au-delà : appliqué à son arrivée).</summary>
@@ -757,6 +820,10 @@ public partial class App : Application, ILauncherRestartHost
_instanceChannel = null;
_admin?.Dispose(); // ferme le menu d'administration : clé effacée de la mémoire
_admin = null;
_accounts?.Dispose(); // session non mémorisée fermée sur le serveur (3 s au plus)
_accounts = null;
_accountsClient?.Dispose();
_accountsClient = null;
_trayIcon?.Dispose(); // sinon l'icône resterait affichée jusqu'au survol
_trayIcon = null;
_launcher?.Dispose();
+3
View File
@@ -54,6 +54,9 @@
<ProjectReference Include="..\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj" />
<!-- Module « Aide » (demandes des joueurs) : formulaire sans WPF ; la fenêtre est ici (Views\FeedbackWindow.xaml). -->
<ProjectReference Include="..\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj" />
<!-- Module « Compte » (comptes joueurs) : service et ViewModels sans WPF ; la fenêtre est ici (Views\AccountWindow.xaml),
ainsi que la clé d'appareil (Platform\PlayerDeviceKeyStore.cs) et le chiffrement DPAPI (Platform\DpapiProtector.cs). -->
<ProjectReference Include="..\GameLauncher.Modules.Accounts\GameLauncher.Modules.Accounts.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,89 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using GameLauncher.Modules.Accounts;
namespace GameLauncher.Platform;
/// <summary>
/// Chiffrement local par DPAPI (CryptProtectData de Windows), portée « utilisateur Windows courant » : seul ce compte
/// Windows, sur ce PC, peut relire les données. Entropie propre à l'usage (<see cref="PlayerSessionFile.Purpose"/>) : un
/// autre programme qui utilise DPAPI pour le même utilisateur ne déchiffre pas ces données par hasard. Aucune fenêtre
/// (CRYPTPROTECT_UI_FORBIDDEN). Appel direct de crypt32.dll : aucune dépendance supplémentaire.
/// </summary>
public sealed class DpapiProtector : ISecretProtector
{
private const int UiForbidden = 0x1;
private readonly byte[] _entropy;
public DpapiProtector(byte[] entropy) => _entropy = entropy.ToArray();
public byte[] Protect(byte[] data) => Run(data, protect: true);
public byte[] Unprotect(byte[] data) => Run(data, protect: false);
private byte[] Run(byte[] data, bool protect)
{
var input = Allocate(data);
var entropy = Allocate(_entropy);
var output = new DataBlob();
try
{
var ok = protect
? CryptProtectData(ref input, null, ref entropy, IntPtr.Zero, IntPtr.Zero, UiForbidden, ref output)
: CryptUnprotectData(ref input, IntPtr.Zero, ref entropy, IntPtr.Zero, IntPtr.Zero, UiForbidden, ref output);
if (!ok)
throw new CryptographicException(protect ? "Chiffrement Windows (DPAPI) impossible." : "Données illisibles pour cet utilisateur Windows.",
new Win32Exception(Marshal.GetLastWin32Error()));
var result = new byte[output.Length];
Marshal.Copy(output.Data, result, 0, output.Length);
return result;
}
finally
{
Free(ref input);
Free(ref entropy);
if (output.Data != IntPtr.Zero)
{
Marshal.Copy(new byte[output.Length], 0, output.Data, output.Length); // effacé avant d'être rendu à Windows
LocalFree(output.Data);
}
}
}
private static DataBlob Allocate(byte[] bytes)
{
var blob = new DataBlob { Length = bytes.Length, Data = Marshal.AllocHGlobal(Math.Max(1, bytes.Length)) };
Marshal.Copy(bytes, 0, blob.Data, bytes.Length);
return blob;
}
private static void Free(ref DataBlob blob)
{
if (blob.Data == IntPtr.Zero) return;
Marshal.Copy(new byte[blob.Length], 0, blob.Data, blob.Length);
Marshal.FreeHGlobal(blob.Data);
blob.Data = IntPtr.Zero;
}
[StructLayout(LayoutKind.Sequential)]
private struct DataBlob
{
public int Length;
public IntPtr Data;
}
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CryptProtectData(ref DataBlob dataIn, string? description, ref DataBlob entropy, IntPtr reserved,
IntPtr prompt, int flags, ref DataBlob dataOut);
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CryptUnprotectData(ref DataBlob dataIn, IntPtr description, ref DataBlob entropy, IntPtr reserved,
IntPtr prompt, int flags, ref DataBlob dataOut);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr LocalFree(IntPtr memory);
}
@@ -0,0 +1,175 @@
using System.Runtime.ExceptionServices;
using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Security;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Accounts;
namespace GameLauncher.Platform;
/// <summary>
/// Clé d'appareil des comptes JOUEURS (ECDSA P-256), une par compte Windows, via les fournisseurs de clés de Windows (CNG) :
/// 1. dans la puce de sécurité (TPM 2.0, « Microsoft Platform Crypto Provider ») quand elle existe : clé générée par la
/// puce, jamais exportable, SANS code PIN (le joueur n'a rien à saisir ; une copie des fichiers de Windows est
/// inutilisable sur un autre PC) ;
/// 2. sinon clé logicielle du « Microsoft Software Key Storage Provider », marquée NON exportable, protégée par Windows
/// (DPAPI de l'utilisateur).
/// Aucune fenêtre Windows (mode silencieux). Totalement distincte des clés d'administrateur (autre nom, jamais de code PIN,
/// jamais utilisée pour l'administration). Méthodes lentes avec la puce : appelées hors du fil de l'interface.
/// </summary>
public sealed class PlayerDeviceKeyStore : IPlayerDeviceKeyStore
{
private static readonly CngProvider[] Providers = { CngProvider.MicrosoftPlatformCryptoProvider, CngProvider.MicrosoftSoftwareKeyStorageProvider };
private readonly string _keyName;
/// <param name="scope">Éditeur et dossier de données du launcher : deux launchers différents n'ont pas la même clé.</param>
public PlayerDeviceKeyStore(string scope)
{
var clean = new string((scope ?? "").Where(char.IsAsciiLetterOrDigit).Take(48).ToArray());
_keyName = $"GameLauncher.PlayerDevice.{(clean.Length > 0 ? clean : "Default")}.v1";
}
public IPlayerDeviceKey? TryOpen()
{
foreach (var provider in Providers)
{
try
{
if (!CngKey.Exists(_keyName, provider, CngKeyOpenOptions.Silent)) continue;
return new Signer(CngKey.Open(_keyName, provider, CngKeyOpenOptions.Silent), provider == CngProvider.MicrosoftPlatformCryptoProvider);
}
catch (CryptographicException ex)
{
Log.Warn($"Compte : clé d'appareil illisible ({provider.Provider}) : {ex.Message}");
}
}
return null;
}
public IPlayerDeviceKey OpenOrCreate()
{
if (TryOpen() is { } existing) return existing;
try
{
var tpm = Create(CngProvider.MicrosoftPlatformCryptoProvider);
Log.Info("Compte : clé d'appareil créée dans la puce de sécurité (TPM).");
return tpm;
}
catch (Exception ex) when (ex is CryptographicException or PlatformNotSupportedException)
{
Log.Info($"Compte : puce de sécurité indisponible ({ex.Message}) : clé logicielle protégée par Windows.");
TryDelete(CngProvider.MicrosoftPlatformCryptoProvider);
}
var software = Create(CngProvider.MicrosoftSoftwareKeyStorageProvider);
Log.Info("Compte : clé d'appareil logicielle (non exportable) créée.");
return software;
}
public void Delete()
{
foreach (var provider in Providers) TryDelete(provider);
}
/// <summary>Crée la clé (non exportable, de l'utilisateur Windows) puis vérifie qu'elle signe.</summary>
private Signer Create(CngProvider provider)
{
var parameters = new CngKeyCreationParameters
{
Provider = provider,
KeyCreationOptions = CngKeyCreationOptions.None // clé de l'utilisateur Windows (pas de la machine)
};
if (provider != CngProvider.MicrosoftPlatformCryptoProvider)
{
// Clé logicielle : jamais exportable, signature seulement (la puce, elle, n'exporte jamais une clé qu'elle a créée).
parameters.ExportPolicy = CngExportPolicies.None;
parameters.KeyUsage = CngKeyUsages.Signing;
}
var key = CngKey.Create(CngAlgorithm.ECDsaP256, _keyName, parameters);
Signer? signer = null;
try
{
signer = new Signer(key, provider == CngProvider.MicrosoftPlatformCryptoProvider);
signer.SignNow(RandomNumberGenerator.GetBytes(32)); // la clé fonctionne vraiment
return signer;
}
catch
{
if (signer is not null) signer.Dispose();
else key.Dispose();
TryDelete(provider);
throw;
}
}
private void TryDelete(CngProvider provider)
{
try
{
if (!CngKey.Exists(_keyName, provider, CngKeyOpenOptions.Silent)) return;
using var key = CngKey.Open(_keyName, provider, CngKeyOpenOptions.Silent);
key.Delete();
}
catch (Exception ex) when (ex is CryptographicException or PlatformNotSupportedException)
{
Log.Warn($"Compte : clé d'appareil non supprimée ({provider.Provider}) : {ex.Message}");
}
}
/// <summary>Clé ouverte : signe hors du fil de l'interface, une signature à la fois.</summary>
private sealed class Signer : IPlayerDeviceKey, IDisposable
{
private readonly ECDsaCng _ecdsa;
private readonly SemaphoreSlim _gate = new(1, 1);
public Signer(CngKey key, bool inTpm)
{
try
{
PublicKey = TpmKeyStore.PublicKeyOf(key); // vérifie aussi qu'il s'agit bien d'une clé ECDSA P-256
KeyId = AdminKeys.ComputeKeyId(PublicKey);
InTpm = inTpm;
_ecdsa = new ECDsaCng(key);
}
finally
{
key.Dispose(); // ECDsaCng garde sa propre référence sur la poignée
}
}
public byte[] PublicKey { get; }
public string KeyId { get; }
public bool InTpm { get; }
public byte[] SignNow(byte[] message) => _ecdsa.SignData(message, HashAlgorithmName.SHA256); // IEEE P1363, 64 octets
public async ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
ExceptionDispatchInfo? failure = null;
var signature = await Task.Run(() =>
{
try
{
return SignNow(message);
}
catch (Exception ex)
{
failure = ExceptionDispatchInfo.Capture(ex);
return null;
}
}, ct).ConfigureAwait(false);
failure?.Throw();
return signature!;
}
finally
{
_gate.Release();
}
}
public void Dispose() => _ecdsa.Dispose();
}
}
+6
View File
@@ -345,6 +345,12 @@ public sealed class TpmKeyStore : IAdminTpm
var code => $"Puce de sécurité (TPM) inutilisable : {(ex.InnerException ?? ex).Message} (code 0x{code:X8})."
};
/// <summary>
/// Clé publique d'une clé CNG ECDSA P-256 (puce ou logicielle), lue au format brut de Windows puis encodée en SPKI.
/// Partagé avec la clé d'appareil des joueurs (<see cref="PlayerDeviceKeyStore"/>).
/// </summary>
internal static byte[] PublicKeyOf(CngKey key) => TpmSigner.ReadPublicKey(key);
// ------------------------------------------------------------------ Signataire
/// <summary>Clé ouverte dans la puce : elle signe (dans la puce), la poignée est fermée au verrouillage.</summary>
@@ -0,0 +1,107 @@
using System.Windows;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Accounts;
using GameLauncher.Platform;
using GameLauncher.ViewModels;
using GameLauncher.Views;
namespace GameLauncher.Services;
/// <summary>
/// Bouton « Compte » de la barre de titre : fenêtre de connexion / création de compte / « Mon compte » (fenêtre modale), et
/// état du compte affiché sur le bouton. Présent seulement si « playerAccounts » et « apiUrl » sont renseignés dans le
/// launcher.json INTÉGRÉ (Release). Le launcher ne contacte l'API des comptes que si une session est mémorisée (reprise au
/// démarrage, sans bloquer) ou quand le joueur ouvre la fenêtre.
/// </summary>
public sealed class AccountWindowService : IDisposable
{
/// <summary>Attente maximale, à la sortie du launcher, de la fermeture d'une session non mémorisée.</summary>
private static readonly TimeSpan ShutdownBudget = TimeSpan.FromSeconds(3);
private readonly MainWindow _owner;
private readonly LauncherViewModel _launcher;
private readonly PlayerAccountService _service;
private readonly string _publisher;
private AccountWindow? _window;
private bool _disposed;
public AccountWindowService(MainWindow owner, LauncherViewModel launcher, PlayerAccountService service, string publisher)
{
_owner = owner;
_launcher = launcher;
_service = service;
_publisher = publisher;
_service.Changed += OnChanged;
_launcher.IsAccountAvailable = true;
ApplyState();
}
/// <summary>Reprend la session mémorisée (après l'affichage de la fenêtre principale, sans la bloquer).</summary>
public void Start(CancellationToken ct) => _ = _service.RestoreAsync(ct);
public void Open()
{
if (_disposed) return;
if (_window is not null)
{
_window.Activate();
return;
}
AccountWindowViewModel? viewModel = null;
try
{
AccountWindow? window = null;
viewModel = new AccountWindowViewModel(_service, new ModuleDialogs(() => window), new WpfUiDispatcher(_owner.Dispatcher), _publisher);
window = new AccountWindow(viewModel);
if (_owner.IsVisible) window.Owner = _owner;
else window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
_window = window;
Log.Info("Ouverture de la fenêtre « Compte ».");
_owner.SetDimmed(true);
try
{
window.ShowDialog();
}
finally
{
_owner.SetDimmed(false);
_window = null;
}
}
catch (Exception ex)
{
_window = null;
viewModel?.Dispose();
Log.Error("Ouverture de la fenêtre « Compte »", ex);
ModernDialog.ShowMessage(_owner.IsVisible ? _owner : null, "Compte indisponible", ex.Message, DialogKind.Error);
}
}
private void OnChanged() => _owner.Dispatcher.InvokeAsync(ApplyState);
/// <summary>État affiché sur le bouton « Compte » (pastille, info-bulle).</summary>
private void ApplyState()
{
if (_disposed) return;
var account = _service.Account;
_launcher.ApplyAccountState(_service.State switch
{
PlayerAccountState.SignedIn => AccountButtonState.SignedIn,
PlayerAccountState.Restoring => AccountButtonState.Restoring,
PlayerAccountState.Offline => AccountButtonState.Offline,
_ => AccountButtonState.SignedOut
}, account?.EmailMasked);
}
/// <summary>Sortie du launcher : session non mémorisée fermée sur le serveur (quelques secondes au plus).</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_service.Changed -= OnChanged;
_window?.Close();
_service.Shutdown(ShutdownBudget);
_service.Dispose();
}
}
@@ -11,6 +11,15 @@ using GameLauncher.Services;
namespace GameLauncher.ViewModels;
/// <summary>État du compte joueur affiché sur le bouton « Compte » de la barre de titre.</summary>
public enum AccountButtonState
{
SignedOut,
Restoring,
SignedIn,
Offline
}
/// <summary>Effet visuel appliqué lors du changement de thème (implémenté par la fenêtre principale).</summary>
public interface IThemeTransition
{
@@ -35,6 +44,8 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
private IThemeTransition? _transition;
private GameViewModel? _selectedGame;
private MaintenanceDocument? _maintenance;
private AccountButtonState _accountState;
private string? _accountEmail;
public LauncherViewModel(LauncherCatalog catalog, IWindowService window, IDialogService dialogs,
LauncherPreferences preferences, string launcherVersion,
@@ -74,6 +85,39 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
/// <summary>Bouton « Aide » affiché : disponible, et pas de maintenance en cours.</summary>
public bool ShowHelpButton => IsHelpAvailable && !IsMaintenance;
// ------------------------------------------------------------------ Compte joueur (bouton « Compte »)
/// <summary>Comptes joueurs disponibles (« playerAccounts » et « apiUrl » dans le launcher.json intégré).</summary>
public bool IsAccountAvailable { get; set; }
/// <summary>Bouton « Compte » affiché : disponible, et pas de maintenance en cours.</summary>
public bool ShowAccountButton => IsAccountAvailable && !IsMaintenance;
public AccountButtonState AccountState => _accountState;
/// <summary>Pastille sur le bouton : connecté (verte) ou session mémorisée hors ligne / en reprise (orange).</summary>
public bool IsAccountSignedIn => _accountState == AccountButtonState.SignedIn;
public bool IsAccountPending => _accountState is AccountButtonState.Restoring or AccountButtonState.Offline;
public string AccountToolTip => _accountState switch
{
AccountButtonState.SignedIn => $"Mon compte · {_accountEmail}",
AccountButtonState.Restoring => "Compte : reconnexion…",
AccountButtonState.Offline => $"Mon compte · {_accountEmail} (serveur injoignable)",
_ => "Compte : se connecter ou créer un compte"
};
/// <summary>Nouvel état du compte (appelé sur le fil de l'interface).</summary>
public void ApplyAccountState(AccountButtonState state, string? emailMasked)
{
_accountState = state;
_accountEmail = string.IsNullOrEmpty(emailMasked) ? "connecté" : emailMasked;
OnPropertyChanged(nameof(AccountState));
OnPropertyChanged(nameof(IsAccountSignedIn));
OnPropertyChanged(nameof(IsAccountPending));
OnPropertyChanged(nameof(AccountToolTip));
}
// ------------------------------------------------------------------ Mode maintenance
/// <summary>
@@ -109,6 +153,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
if (wasActive == active) return;
OnPropertyChanged(nameof(IsMaintenance));
OnPropertyChanged(nameof(ShowHelpButton));
OnPropertyChanged(nameof(ShowAccountButton));
OpenLauncherSettingsCommand.RaiseCanExecuteChanged();
}
+509
View File
@@ -0,0 +1,509 @@
<Window x:Class="GameLauncher.Views.AccountWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:GameLauncher.Controls"
Title="Compte"
Width="600"
SizeToContent="Height"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
ResizeMode="NoResize"
ShowInTaskbar="False"
WindowStartupLocation="CenterOwner"
FontFamily="{DynamicResource UiFont}"
Foreground="{DynamicResource TextPrimaryBrush}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal"
ui:WheelAssist.IsEnabled="True"
PreviewKeyDown="OnPreviewKeyDown">
<!--
Bouton « Compte » de la fenêtre principale. ViewModel : AccountWindowViewModel (GameLauncher.Modules.Accounts).
Écrans : connexion, création de compte, nouveau mot de passe exigé, « Mon compte » (onglets Profil, Appareils, Activité,
Mes données). Les mots de passe restent dans les PasswordBox (jamais liés au ViewModel) : lus au moment de l'action,
effacés ensuite (AccountWindow.xaml.cs). Tout texte venu du serveur est affiché tel quel (jamais interprété).
-->
<Window.Resources>
<!-- Contrôle en direct du nouveau mot de passe (PasswordRulesViewModel) -->
<DataTemplate x:Key="PasswordRulesTemplate">
<StackPanel Margin="2,8,0,0">
<TextBlock Text="{Binding RulesText}" FontSize="11.5" TextWrapping="Wrap" Foreground="{DynamicResource TextMutedBrush}"
Visibility="{Binding ShowRules, Converter={StaticResource BoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding Problems}" Focusable="False"
Visibility="{Binding HasProblems, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,0,0,4">
<Path DockPanel.Dock="Left" Width="12" Height="12" Stretch="Uniform" VerticalAlignment="Top" Margin="0,2,8,0"
Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding}" FontSize="12" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}" />
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<DockPanel Visibility="{Binding IsAccepted, Converter={StaticResource BoolToVisibility}}">
<Path DockPanel.Dock="Left" Width="13" Height="13" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,8,0"
Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}" />
<TextBlock DockPanel.Dock="Right" Text="{Binding StrengthText}" FontSize="12" FontWeight="SemiBold" VerticalAlignment="Center"
Margin="10,0,0,0" Foreground="{DynamicResource SuccessBrush}" />
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="4" Maximum="4" Value="{Binding Strength, Mode=OneWay}"
VerticalAlignment="Center" />
</DockPanel>
<TextBlock Text="Les deux mots de passe ne sont pas identiques." FontSize="12" Margin="0,6,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource DangerBrush}"
Visibility="{Binding ShowConfirmationMismatch, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<!-- Libellé d'un champ -->
<Style x:Key="AccountLabelStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="12.5" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="0,0,0,6" />
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
</Style>
<!-- Carte d'une section de « Mon compte » -->
<Style x:Key="AccountCardStyle" TargetType="Border">
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Padding" Value="16,14" />
<Setter Property="Margin" Value="0,0,0,12" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Background" Value="{DynamicResource InputBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}" />
</Style>
</Window.Resources>
<Grid Margin="20">
<!-- Ombre -->
<Border CornerRadius="20" Background="{DynamicResource BackgroundBrush}">
<Border.Effect>
<DropShadowEffect BlurRadius="32" ShadowDepth="8" Direction="270" Opacity="0.6" Color="Black" />
</Border.Effect>
</Border>
<Border Style="{StaticResource DialogCardStyle}" Padding="28,26,14,22" MouseLeftButtonDown="OnDragMove">
<StackPanel>
<!-- ================================================================ En-tête -->
<Grid Margin="0,0,14,18">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border Width="48" Height="48" CornerRadius="14" Background="{DynamicResource AccentGradientBrush}">
<Grid>
<Path Data="{Binding HeaderIcon, Converter={StaticResource IconKey}}" Fill="{DynamicResource OnAccentBrush}"
Width="24" Height="24" Stretch="Uniform"
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<!-- Mon compte : initiale de l'adresse -->
<TextBlock Text="{Binding AvatarText}" FontSize="20" FontWeight="Bold" HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="{DynamicResource OnAccentBrush}"
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}" />
</Grid>
</Border>
<StackPanel Grid.Column="1" Margin="16,0,0,0" VerticalAlignment="Center">
<TextBlock Text="{Binding Title}" FontSize="19" FontWeight="SemiBold" />
<TextBlock Text="{Binding Subtitle}" FontSize="12.5" Margin="0,3,0,0" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
<Button Grid.Column="2" Style="{StaticResource IconButtonStyle}" VerticalAlignment="Top"
Width="32" Height="32" Padding="9" Margin="0,-6,-10,0" ToolTip="Fermer"
ui:IconAssist.Icon="{StaticResource Icon.Close}" Command="{Binding CloseCommand}" />
</Grid>
<!-- Onglets de « Mon compte » -->
<Border HorizontalAlignment="Left" CornerRadius="12" Padding="3" Margin="0,0,14,16" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}"
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal" IsEnabled="{Binding IsIdle}">
<RadioButton Style="{StaticResource AdminSegmentStyle}" GroupName="AccountTabs" Content="Profil"
ui:IconAssist.Icon="{StaticResource Icon.BadgeAccountHorizontal}" IsChecked="{Binding IsProfileSection}" />
<RadioButton Style="{StaticResource AdminSegmentStyle}" GroupName="AccountTabs" Content="Appareils"
ui:IconAssist.Icon="{StaticResource Icon.Laptop}" IsChecked="{Binding IsDevicesSection}" />
<RadioButton Style="{StaticResource AdminSegmentStyle}" GroupName="AccountTabs" Content="Activité"
ui:IconAssist.Icon="{StaticResource Icon.History}" IsChecked="{Binding IsActivitySection}" />
<RadioButton Style="{StaticResource AdminSegmentStyle}" GroupName="AccountTabs" Content="Mes données"
ui:IconAssist.Icon="{StaticResource Icon.FileLockOutline}" IsChecked="{Binding IsDataSection}" />
</StackPanel>
</Border>
<ScrollViewer x:Name="BodyScroll" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
Focusable="False" Padding="0,0,14,0">
<StackPanel>
<!-- Messages -->
<Border CornerRadius="12" Padding="14,10" Margin="0,0,0,14" Background="#26E5484D"
BorderBrush="{DynamicResource DangerBrush}" BorderThickness="1"
Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Top"
Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<Border CornerRadius="12" Padding="14,10" Margin="0,0,0,14" Background="#262BD48B"
BorderBrush="{DynamicResource SuccessBrush}" BorderThickness="1"
Visibility="{Binding HasSuccess, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Top"
Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}" />
<TextBlock Text="{Binding SuccessMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- ============================================ Chargement -->
<StackPanel Orientation="Horizontal" Margin="2,10,0,20"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibility}}">
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Width="90" Height="4" IsIndeterminate="True"
VerticalAlignment="Center" />
<TextBlock Text="{Binding BusyText}" FontSize="12.5" Margin="12,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
<!-- ============================================ Service indisponible -->
<StackPanel Margin="0,6,0,10" Visibility="{Binding IsUnavailable, Converter={StaticResource BoolToVisibility}}">
<Path Width="40" Height="40" Stretch="Uniform" HorizontalAlignment="Center" Fill="{DynamicResource TextMutedBrush}"
Data="{StaticResource Icon.Server}" />
<TextBlock Text="{Binding UnavailableText}" FontSize="13.5" Margin="0,14,0,16" HorizontalAlignment="Center"
TextAlignment="Center" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Réessayer" HorizontalAlignment="Center"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RetryCommand}" />
</StackPanel>
<!-- ============================================ Connexion -->
<StackPanel Visibility="{Binding IsSignIn, Converter={StaticResource BoolToVisibility}}" IsEnabled="{Binding IsIdle}">
<TextBlock Text="Adresse e-mail" Style="{StaticResource AccountLabelStyle}" />
<TextBox x:Name="SignInEmailBox" Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding EmailMaxLength}"
ui:TextAssist.Placeholder="vous@exemple.fr" Margin="0,0,0,14"
Text="{Binding Email, UpdateSourceTrigger=PropertyChanged}" KeyDown="OnSignInKeyDown" />
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource LinkButtonStyle}" Content="Mot de passe oublié ?" FontSize="12"
Command="{Binding ToggleForgotCommand}" />
<TextBlock Text="Mot de passe" Style="{StaticResource AccountLabelStyle}" Margin="0" VerticalAlignment="Center" />
</DockPanel>
<PasswordBox x:Name="SignInPasswordBox" Style="{StaticResource ModernPasswordBoxStyle}" MaxLength="{Binding PasswordMaxLength}"
KeyDown="OnSignInKeyDown" />
<Border CornerRadius="12" Padding="14,10" Margin="0,10,0,0" BorderThickness="1"
Background="{DynamicResource ChipBrush}" BorderBrush="{DynamicResource BorderStrongBrush}"
Visibility="{Binding ShowForgotHelp, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" VerticalAlignment="Top" Margin="0,1,10,0"
Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.Info}" />
<TextBlock Text="{Binding ForgotHelpText}" FontSize="12" TextWrapping="Wrap" />
</DockPanel>
</Border>
<CheckBox Style="{StaticResource ToggleSwitchStyle}" Margin="0,14,0,0" IsChecked="{Binding RememberMe}">
<StackPanel>
<TextBlock Text="{Binding RememberText}" FontSize="13" FontWeight="SemiBold" />
<TextBlock Text="Inutile de saisir votre mot de passe au prochain lancement. Décochez sur un PC partagé."
FontSize="11.5" Margin="0,2,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</CheckBox>
<Border Height="1" Background="{DynamicResource BorderBrush}" Margin="0,18,0,14" />
<DockPanel Visibility="{Binding RegistrationOpen, Converter={StaticResource BoolToVisibility}}">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Créer un compte"
ui:IconAssist.Icon="{StaticResource Icon.Plus}" Command="{Binding ShowRegisterCommand}" />
<TextBlock Text="Pas encore de compte ? C'est gratuit, et jamais obligatoire pour jouer." FontSize="12.5"
VerticalAlignment="Center" TextWrapping="Wrap" Margin="0,0,12,0"
Foreground="{DynamicResource TextSecondaryBrush}" />
</DockPanel>
<TextBlock Text="La création de compte est momentanément fermée." FontSize="12.5" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}"
Visibility="{Binding RegistrationClosed, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
<!-- ============================================ Création de compte -->
<StackPanel Visibility="{Binding IsRegister, Converter={StaticResource BoolToVisibility}}" IsEnabled="{Binding IsIdle}">
<TextBlock Text="Adresse e-mail" Style="{StaticResource AccountLabelStyle}" />
<TextBox x:Name="RegisterEmailBox" Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding EmailMaxLength}"
ui:TextAssist.Placeholder="vous@exemple.fr" Margin="0,0,0,14"
Text="{Binding Email, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="Mot de passe" Style="{StaticResource AccountLabelStyle}" />
<PasswordBox x:Name="RegisterPasswordBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
<TextBlock Text="Confirmation du mot de passe" Style="{StaticResource AccountLabelStyle}" Margin="0,14,0,6" />
<PasswordBox x:Name="RegisterConfirmBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
<ContentControl Content="{Binding NewPassword}" ContentTemplate="{StaticResource PasswordRulesTemplate}" Focusable="False" />
<CheckBox Style="{StaticResource ToggleSwitchStyle}" Margin="0,16,0,0" IsChecked="{Binding RememberMe}">
<TextBlock Text="{Binding RememberText}" FontSize="13" FontWeight="SemiBold" />
</CheckBox>
<CheckBox Style="{StaticResource FeedbackCheckBoxStyle}" Margin="0,12,0,0" IsChecked="{Binding AcceptTerms}">
<TextBlock Text="{Binding TermsText}" FontSize="12.5" TextWrapping="Wrap" />
</CheckBox>
<DockPanel Margin="0,14,0,0">
<Path DockPanel.Dock="Left" Width="14" Height="14" Stretch="Uniform" VerticalAlignment="Top" Margin="0,1,0,0"
Fill="{DynamicResource TextMutedBrush}" Data="{StaticResource Icon.ShieldCheck}" />
<TextBlock Text="{Binding PrivacyText}" FontSize="11.5" Margin="8,0,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
</DockPanel>
</StackPanel>
<!-- ============================================ Nouveau mot de passe exigé -->
<StackPanel Visibility="{Binding IsPasswordChange, Converter={StaticResource BoolToVisibility}}" IsEnabled="{Binding IsIdle}">
<TextBlock Text="{Binding PasswordChangeText}" FontSize="13" Margin="0,0,0,16" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}" />
<TextBlock Text="Nouveau mot de passe" Style="{StaticResource AccountLabelStyle}" />
<PasswordBox x:Name="RequiredNewBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
<TextBlock Text="Confirmation" Style="{StaticResource AccountLabelStyle}" Margin="0,14,0,6" />
<PasswordBox x:Name="RequiredConfirmBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
<ContentControl Content="{Binding NewPassword}" ContentTemplate="{StaticResource PasswordRulesTemplate}" Focusable="False" />
</StackPanel>
<!-- ============================================ Mon compte -->
<StackPanel Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}">
<!-- ··· Profil -->
<StackPanel Visibility="{Binding IsProfileSection, Converter={StaticResource BoolToVisibility}}">
<Border CornerRadius="12" Padding="14,10" Margin="0,0,0,12" BorderThickness="1" Background="#26FFB547"
BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding NewDevicesSuspended, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Top" Margin="0,1,10,0"
Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.ShieldAccount}" />
<TextBlock Text="{Binding SuspendedText}" FontSize="12.5" TextWrapping="Wrap" />
</DockPanel>
</Border>
<Border Style="{StaticResource AccountCardStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="Adresse e-mail" Style="{StaticResource AdminLabelStyle}" />
<TextBlock Grid.Column="1" Text="{Binding EmailMasked}" Style="{StaticResource AdminValueStyle}" />
<TextBlock Grid.Row="1" Text="Référence" Style="{StaticResource AdminLabelStyle}" VerticalAlignment="Center" />
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="{Binding Reference}" FontSize="12.5" FontWeight="SemiBold" VerticalAlignment="Center"
FontFamily="Cascadia Mono, Consolas, Courier New" />
<Button Style="{StaticResource IconButtonStyle}" Width="28" Height="28" Padding="7" Margin="8,0,0,0"
ToolTip="Copier la référence (à communiquer au support)"
ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}" Command="{Binding CopyReferenceCommand}" />
</StackPanel>
<TextBlock Grid.Row="2" Text="Compte créé le" Style="{StaticResource AdminLabelStyle}" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="{Binding CreatedText}" Style="{StaticResource AdminValueStyle}" />
<TextBlock Grid.Row="3" Text="Dernière connexion" Style="{StaticResource AdminLabelStyle}" Margin="0,0,16,0" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding LastLoginText}" Style="{StaticResource AdminValueStyle}" Margin="0" />
</Grid>
</Border>
<!-- Mot de passe -->
<Border Style="{StaticResource AccountCardStyle}">
<StackPanel>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Changer"
ui:IconAssist.Icon="{StaticResource Icon.KeyVariant}" Command="{Binding ShowChangePasswordCommand}"
Visibility="{Binding IsChangingPassword, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Mot de passe" FontSize="13.5" FontWeight="SemiBold" />
<TextBlock FontSize="12" Margin="0,2,0,0" Foreground="{DynamicResource TextMutedBrush}"
Text="{Binding PasswordChangedText, StringFormat='Modifié le {0}'}" />
</StackPanel>
</DockPanel>
<StackPanel Margin="0,14,0,0" IsEnabled="{Binding IsIdle}"
Visibility="{Binding IsChangingPassword, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Mot de passe actuel" Style="{StaticResource AccountLabelStyle}" />
<PasswordBox x:Name="CurrentPasswordBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" />
<TextBlock Text="Nouveau mot de passe" Style="{StaticResource AccountLabelStyle}" Margin="0,14,0,6" />
<PasswordBox x:Name="ChangeNewBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
<TextBlock Text="Confirmation" Style="{StaticResource AccountLabelStyle}" Margin="0,14,0,6" />
<PasswordBox x:Name="ChangeConfirmBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" PasswordChanged="OnNewPasswordChanged" />
<ContentControl Content="{Binding NewPassword}" ContentTemplate="{StaticResource PasswordRulesTemplate}"
Focusable="False" />
<TextBlock Text="Vos autres appareils seront déconnectés." FontSize="11.5" Margin="0,10,0,0"
Foreground="{DynamicResource TextMutedBrush}" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Annuler" Margin="0,0,10,0"
Command="{Binding CancelChangePasswordCommand}" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Enregistrer"
ui:IconAssist.Icon="{StaticResource Icon.Check}" Click="OnChangePasswordClick" />
</StackPanel>
</StackPanel>
</StackPanel>
</Border>
<!-- Double authentification (étape suivante) -->
<Border Style="{StaticResource AccountCardStyle}">
<DockPanel>
<Border DockPanel.Dock="Right" CornerRadius="9" Padding="10,3" VerticalAlignment="Center"
Background="{DynamicResource ChipBrush}">
<TextBlock Text="Bientôt" FontSize="11.5" FontWeight="SemiBold" Foreground="{DynamicResource TextSecondaryBrush}" />
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Double authentification" FontSize="13.5" FontWeight="SemiBold" />
<TextBlock Text="Un code à usage unique (application d'authentification) en plus du mot de passe."
FontSize="12" Margin="0,2,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</DockPanel>
</Border>
</StackPanel>
<!-- ··· Appareils -->
<StackPanel Visibility="{Binding IsDevicesSection, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Appareils sur lesquels votre compte est connecté. Déconnectez ceux que vous ne reconnaissez pas, puis changez de mot de passe."
FontSize="12.5" Margin="0,0,0,12" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}" />
<ItemsControl ItemsSource="{Binding Devices}" Focusable="False" IsEnabled="{Binding IsIdle}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Style="{StaticResource AccountCardStyle}" Padding="14,12">
<DockPanel>
<Border DockPanel.Dock="Left" Width="38" Height="38" CornerRadius="11" Margin="0,0,14,0"
VerticalAlignment="Center" Background="{DynamicResource BadgeBrush}">
<Path Width="18" Height="18" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
Data="{StaticResource Icon.Laptop}" />
</Border>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Déconnecter"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Logout}"
Command="{Binding RemoveCommand}"
Visibility="{Binding CanRemove, Converter={StaticResource BoolToVisibility}}" />
<Border DockPanel.Dock="Right" CornerRadius="9" Padding="10,3" VerticalAlignment="Center" Margin="10,0,0,0"
Background="{DynamicResource AccentGradientBrush}"
Visibility="{Binding IsCurrent, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Cet appareil" FontSize="11.5" FontWeight="SemiBold"
Foreground="{DynamicResource OnAccentBrush}" />
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding Label}" FontSize="13.5" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding Detail}" FontSize="12" Margin="0,2,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Style="{StaticResource LinkButtonStyle}" HorizontalAlignment="Left" Margin="2,4,0,4" FontSize="12.5"
Foreground="{DynamicResource DangerBrush}" Content="Déconnecter tous mes appareils (celui-ci compris)"
ui:IconAssist.Icon="{StaticResource Icon.LinkOff}" Command="{Binding SignOutAllCommand}" />
</StackPanel>
<!-- ··· Activité -->
<StackPanel Visibility="{Binding IsActivitySection, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Dernières connexions et actions sur votre compte. Adresses IP masquées ; un essai de mot de passe que vous ne reconnaissez pas ? Changez de mot de passe."
FontSize="12.5" Margin="0,0,0,12" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}" />
<StackPanel Orientation="Horizontal" Margin="2,4,0,14"
Visibility="{Binding IsActivityLoading, Converter={StaticResource BoolToVisibility}}">
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Width="90" Height="4" IsIndeterminate="True"
VerticalAlignment="Center" />
<TextBlock Text="Chargement de l'activité…" FontSize="12.5" Margin="12,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
<TextBlock Text="Aucune activité enregistrée." FontSize="12.5" Foreground="{DynamicResource TextMutedBrush}"
Visibility="{Binding IsActivityEmpty, Converter={StaticResource BoolToVisibility}}" />
<Border Style="{StaticResource AccountCardStyle}" Padding="6,4"
Visibility="{Binding IsActivityLoading, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}">
<ItemsControl ItemsSource="{Binding Activity}" Focusable="False">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Margin="8,8">
<Grid DockPanel.Dock="Left" Width="16" Height="16" Margin="0,1,12,0" VerticalAlignment="Top">
<Path Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.CheckCircle}"
Visibility="{Binding IsWarning, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<Path Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
Visibility="{Binding IsWarning, Converter={StaticResource BoolToVisibility}}" />
</Grid>
<StackPanel>
<TextBlock Text="{Binding Title}" FontSize="13" TextWrapping="Wrap" />
<TextBlock Text="{Binding Detail}" FontSize="11.5" Margin="0,2,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
</StackPanel>
<!-- ··· Mes données -->
<StackPanel Visibility="{Binding IsDataSection, Converter={StaticResource BoolToVisibility}}" IsEnabled="{Binding IsIdle}">
<Border Style="{StaticResource AccountCardStyle}">
<StackPanel>
<TextBlock Text="Exporter mes données" FontSize="13.5" FontWeight="SemiBold" />
<TextBlock Text="{Binding ExportText}" FontSize="12" Margin="0,4,0,12" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock Text="Mot de passe" Style="{StaticResource AccountLabelStyle}" />
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SecondaryButtonStyle}" Content="Exporter…" Margin="10,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Download}" Click="OnExportClick" />
<PasswordBox x:Name="ExportPasswordBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" />
</DockPanel>
</StackPanel>
</Border>
<Border Style="{StaticResource AccountCardStyle}" BorderBrush="{DynamicResource DangerBrush}">
<StackPanel>
<TextBlock Text="Supprimer mon compte" FontSize="13.5" FontWeight="SemiBold" Foreground="{DynamicResource DangerBrush}" />
<TextBlock Text="{Binding DeleteText}" FontSize="12" Margin="0,4,0,12" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
<CheckBox Style="{StaticResource FeedbackCheckBoxStyle}" Margin="0,0,0,12" IsChecked="{Binding DeleteConfirmed}">
<TextBlock Text="Je comprends que la suppression est définitive." FontSize="12.5" TextWrapping="Wrap" />
</CheckBox>
<TextBlock Text="Mot de passe" Style="{StaticResource AccountLabelStyle}" />
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource DangerButtonStyle}" Content="Supprimer" Margin="10,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.AccountRemove}" Click="OnDeleteClick" />
<PasswordBox x:Name="DeletePasswordBox" Style="{StaticResource ModernPasswordBoxStyle}"
MaxLength="{Binding PasswordMaxLength}" />
</DockPanel>
</StackPanel>
</Border>
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
<!-- ================================================================ Boutons -->
<DockPanel Margin="0,16,14,0">
<Button DockPanel.Dock="Left" Style="{StaticResource LinkButtonStyle}" Content="J'ai déjà un compte" FontSize="12.5"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.ArrowLeft}" Command="{Binding ShowSignInCommand}"
Visibility="{Binding IsRegister, Converter={StaticResource BoolToVisibility}}" />
<Button DockPanel.Dock="Left" Style="{StaticResource LinkButtonStyle}" Content="Se déconnecter" FontSize="12.5"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Logout}" Command="{Binding SignOutCommand}"
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}" />
<Button DockPanel.Dock="Left" Style="{StaticResource LinkButtonStyle}" Content="Actualiser" FontSize="12.5" Margin="16,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}"
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<!-- Action en cours (preuve anti-robot, signature, serveur) -->
<StackPanel Orientation="Horizontal" Margin="0,0,14,0" VerticalAlignment="Center"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}">
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Width="70" Height="4" IsIndeterminate="{Binding IsBusy}"
VerticalAlignment="Center" />
</StackPanel>
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Fermer" Command="{Binding CloseCommand}"
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Annuler" Command="{Binding CloseCommand}"
Visibility="{Binding IsAccount, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Se connecter" Margin="10,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Login}" Click="OnSignInClick" IsEnabled="{Binding IsIdle}"
Visibility="{Binding IsSignIn, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Créer mon compte" Margin="10,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.ArrowRight}" Click="OnRegisterClick" IsEnabled="{Binding IsIdle}"
Visibility="{Binding IsRegister, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Enregistrer et me connecter" Margin="10,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Check}" Click="OnRequiredChangeClick" IsEnabled="{Binding IsIdle}"
Visibility="{Binding IsPasswordChange, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DockPanel>
<TextBlock Text="{Binding BusyText}" FontSize="11.5" Margin="0,8,14,0" HorizontalAlignment="Right" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</Border>
</Grid>
</Window>
@@ -0,0 +1,152 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Threading;
using GameLauncher.Modules.Accounts;
namespace GameLauncher.Views;
/// <summary>
/// Fenêtre « Compte » (voir <see cref="AccountWindowViewModel"/>). Les mots de passe restent dans les PasswordBox : lus au
/// moment de l'action, passés au ViewModel, puis effacés (<see cref="AccountWindowViewModel.ClearPasswordsRequested"/>).
/// Fermer la fenêtre abandonne une action en cours.
/// </summary>
public partial class AccountWindow : Window
{
/// <summary>Hauteur de base de la zone du contenu : au-delà, il défile au lieu d'agrandir la fenêtre.</summary>
private const double BodyMaxHeight = 520;
private readonly AccountWindowViewModel _viewModel;
private bool _shown;
public AccountWindow(AccountWindowViewModel viewModel)
{
InitializeComponent();
_viewModel = viewModel;
DataContext = viewModel;
viewModel.CloseRequested += Close;
viewModel.ClearPasswordsRequested += ClearPasswords;
viewModel.PropertyChanged += OnViewModelChanged;
SourceInitialized += (_, _) => FitToScreen();
Loaded += async (_, _) => await viewModel.InitializeAsync();
ContentRendered += (_, _) => _shown = true;
SizeChanged += (_, e) =>
{
if (_shown && e.HeightChanged) KeepCentered(e.PreviousSize.Height);
};
Closed += (_, _) =>
{
ClearPasswords();
viewModel.CloseRequested -= Close;
viewModel.ClearPasswordsRequested -= ClearPasswords;
viewModel.PropertyChanged -= OnViewModelChanged;
viewModel.Dispose();
};
}
// ------------------------------------------------------------------ Actions (mots de passe lus ici, jamais liés)
private async void OnSignInClick(object sender, RoutedEventArgs e) => await _viewModel.SignInAsync(SignInPasswordBox.Password);
private async void OnSignInKeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
e.Handled = true;
if (string.IsNullOrEmpty(SignInPasswordBox.Password))
{
SignInPasswordBox.Focus();
return;
}
await _viewModel.SignInAsync(SignInPasswordBox.Password);
}
private async void OnRegisterClick(object sender, RoutedEventArgs e) =>
await _viewModel.RegisterAsync(RegisterPasswordBox.Password, RegisterConfirmBox.Password);
private async void OnRequiredChangeClick(object sender, RoutedEventArgs e) =>
await _viewModel.CompleteRequiredPasswordChangeAsync(RequiredNewBox.Password, RequiredConfirmBox.Password);
private async void OnChangePasswordClick(object sender, RoutedEventArgs e) =>
await _viewModel.ChangePasswordAsync(CurrentPasswordBox.Password, ChangeNewBox.Password, ChangeConfirmBox.Password);
private async void OnExportClick(object sender, RoutedEventArgs e) => await _viewModel.ExportAsync(ExportPasswordBox.Password);
private async void OnDeleteClick(object sender, RoutedEventArgs e) => await _viewModel.DeleteAccountAsync(DeletePasswordBox.Password);
/// <summary>Contrôle en direct du nouveau mot de passe de l'écran affiché (création, exigé, changement).</summary>
private void OnNewPasswordChanged(object sender, RoutedEventArgs e)
{
var (password, confirmation) = _viewModel.Step switch
{
AccountStep.Register => (RegisterPasswordBox, RegisterConfirmBox),
AccountStep.PasswordChange => (RequiredNewBox, RequiredConfirmBox),
_ => (ChangeNewBox, ChangeConfirmBox)
};
_viewModel.UpdateNewPassword(password.Password, confirmation.Password);
}
private void ClearPasswords()
{
foreach (var box in new[]
{
SignInPasswordBox, RegisterPasswordBox, RegisterConfirmBox, RequiredNewBox, RequiredConfirmBox, CurrentPasswordBox,
ChangeNewBox, ChangeConfirmBox, ExportPasswordBox, DeletePasswordBox
})
box.Clear();
}
// ------------------------------------------------------------------ Affichage
private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is not (nameof(AccountWindowViewModel.Step) or nameof(AccountWindowViewModel.Section))) return;
BodyScroll.ScrollToTop();
// Curseur dans le premier champ de l'écran affiché.
Dispatcher.InvokeAsync(() =>
{
Control? target = _viewModel.Step switch
{
AccountStep.SignIn => string.IsNullOrEmpty(_viewModel.Email) ? SignInEmailBox : SignInPasswordBox,
AccountStep.Register => string.IsNullOrEmpty(_viewModel.Email) ? RegisterEmailBox : RegisterPasswordBox,
AccountStep.PasswordChange => RequiredNewBox,
_ => null
};
target?.Focus();
}, DispatcherPriority.Input);
}
/// <summary>Taille de base raisonnable, jamais plus grande que la zone utile de l'écran : au-delà, le contenu défile.</summary>
private void FitToScreen()
{
const double chrome = 250; // en-tête, onglets, boutons, marges et ombre
var available = Controls.WindowModeAssist.WorkArea(Owner ?? (System.Windows.Media.Visual)this).Height - chrome;
BodyScroll.MaxHeight = Math.Max(240, Math.Min(BodyMaxHeight, available));
}
/// <summary>
/// La hauteur change avec l'écran : la fenêtre grandit ou rétrécit autour de son centre et reste dans la zone utile de
/// l'écran (même règle que la fenêtre « Aide »). Une fenêtre placée hors des écrans (tools\UiSnapshot) n'est pas déplacée.
/// </summary>
private void KeepCentered(double previousHeight)
{
if (previousHeight <= 0 || double.IsNaN(Top) || double.IsNaN(Left) || WindowState != WindowState.Normal) return;
var work = Controls.WindowModeAssist.WorkArea(this);
if (work.Width < 1 || work.Height < 1 || !work.IntersectsWith(new Rect(Left, Top, ActualWidth, previousHeight))) return;
var top = Top + (previousHeight - ActualHeight) / 2;
Top = Math.Max(work.Top, Math.Min(top, work.Bottom - ActualHeight));
}
/// <summary>Échap ferme la fenêtre, sauf pendant une action en cours.</summary>
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key != Key.Escape || _viewModel.IsBusy) return;
Close();
e.Handled = true;
}
private void OnDragMove(object sender, MouseButtonEventArgs e)
{
if (e.ButtonState == MouseButtonState.Pressed && e.OriginalSource is not (TextBox or PasswordBox)) DragMove();
}
}
+17
View File
@@ -276,6 +276,23 @@
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Aide : avis, idée, bug…" Margin="0,0,4,0"
ui:IconAssist.Icon="{StaticResource Icon.MessageQuestion}" Click="OnHelpClick"
Visibility="{Binding DataContext.ShowHelpButton, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
<!-- Compte joueur : connexion, création, « Mon compte » (absent sans « playerAccounts » et « apiUrl » dans le
launcher.json intégré, masqué pendant une maintenance). Pastille : verte = connecté, orange = reconnexion
ou serveur injoignable. -->
<Grid Margin="0,0,4,0"
Visibility="{Binding DataContext.ShowAccountButton, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}">
<Button x:Name="AccountButton" Style="{StaticResource IconButtonStyle}" Click="OnAccountClick"
ToolTip="{Binding DataContext.AccountToolTip, RelativeSource={RelativeSource AncestorType=Window}}"
ui:IconAssist.Icon="{StaticResource Icon.AccountCircle}" />
<Ellipse Width="9" Height="9" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,6,6,0"
IsHitTestVisible="False" Fill="{DynamicResource SuccessBrush}" Stroke="{DynamicResource SurfaceSolidBrush}"
StrokeThickness="1.5"
Visibility="{Binding DataContext.IsAccountSignedIn, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
<Ellipse Width="9" Height="9" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,6,6,0"
IsHitTestVisible="False" Fill="{DynamicResource WarningBrush}" Stroke="{DynamicResource SurfaceSolidBrush}"
StrokeThickness="1.5"
Visibility="{Binding DataContext.IsAccountPending, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
</Grid>
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Site officiel"
ui:IconAssist.Icon="{StaticResource Icon.Web}"
Command="{Binding OpenWebsiteCommand}"
@@ -84,6 +84,11 @@ public partial class MainWindow : Window, IThemeTransition
if (sender is FrameworkElement { DataContext: GameViewModel game }) HelpRequested?.Invoke(game);
}
/// <summary>Bouton « Compte » : connexion, création de compte, « Mon compte ».</summary>
public event Action? AccountRequested;
private void OnAccountClick(object sender, RoutedEventArgs e) => AccountRequested?.Invoke();
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
// Avec Alt, WPF signale la touche dans SystemKey (Key.System).
+5
View File
@@ -31,6 +31,11 @@
// pour retirer le menu d'administration.
"apiUrl": "https://launcher.lanisys.fr/api/",
// Comptes joueurs (bouton « Compte » : connexion, création de compte, « Mon compte ») avec la même API. Laisser à false
// tant que les comptes ne sont pas ouverts sur le serveur (LauncherApi:Players:Enabled, étapes 8a à 8g). Comme apiUrl,
// la version Release ne le lit QUE dans le launcher.json intégré à l'exécutable.
"playerAccounts": false,
// Catalogue des jeux : informations modifiables depuis le menu d'administration (nom affiché,
// accroche, présentation, liens, thème intégré, images, ordre…), lues au démarrage puis mises en
// cache. Comme apiUrl, la version Release ne la lit que dans le launcher.json intégré à l'exe.
@@ -0,0 +1,249 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Modules;
using GameLauncher.Modules.Accounts;
using GameLauncher.Tests.Admin;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Accounts;
/// <summary>
/// Fenêtre « Compte » sans interface (ViewModel réel, service réel, Launcher API en mémoire) : écrans, contrôles locaux avant
/// tout envoi, messages, onglets de « Mon compte », export, suppression, service fermé.
/// </summary>
public class AccountWindowViewModelTests
{
private const string Email = "joueur@example.fr";
private const string Password = PlayerTestServer.Password;
private static AccountWindowViewModel NewWindow(PlayerAccountService service, FakeDialogs dialogs) =>
new(service, dialogs, ImmediateDispatcher.Instance, "LanisysGames");
[Fact]
public async Task Registration_checks_everything_locally_before_creating_the_account()
{
await using var h = await AccountsHarness.StartAsync();
using var service = h.NewService();
var dialogs = new FakeDialogs();
using var vm = NewWindow(service, dialogs);
var clears = 0;
vm.ClearPasswordsRequested += () => clears++;
await vm.InitializeAsync();
Assert.Equal(AccountStep.SignIn, vm.Step);
Assert.True(vm.RegistrationOpen);
Assert.Contains("version 1", vm.TermsText);
vm.ShowRegisterCommand.Execute(null);
Assert.Equal(AccountStep.Register, vm.Step);
Assert.Equal("Créer un compte", vm.Title);
vm.Email = "pas-une-adresse";
await vm.RegisterAsync(Password, Password);
Assert.Equal("Adresse e-mail invalide.", vm.ErrorMessage);
vm.Email = Email;
vm.UpdateNewPassword("azerty123", "");
Assert.True(vm.NewPassword.HasProblems);
Assert.Equal("Refusé", vm.NewPassword.StrengthText);
await vm.RegisterAsync("azerty123", "azerty123");
Assert.True(vm.HasError);
vm.UpdateNewPassword(Password, "autre chose");
Assert.True(vm.NewPassword.IsAccepted);
Assert.True(vm.NewPassword.ShowConfirmationMismatch);
await vm.RegisterAsync(Password, "autre chose");
Assert.Equal("Les deux mots de passe ne sont pas identiques.", vm.ErrorMessage);
await vm.RegisterAsync(Password, Password);
Assert.Contains("Acceptez les conditions", vm.ErrorMessage);
Assert.Equal(0, h.Server.Store.CountAccounts()); // rien n'a été envoyé
vm.AcceptTerms = true;
await vm.RegisterAsync(Password, Password);
Assert.False(vm.HasError, vm.ErrorMessage);
Assert.Equal(AccountStep.Account, vm.Step);
Assert.Equal("Bienvenue ! Votre compte est créé.", vm.SuccessMessage);
Assert.Equal("j***@e***.fr", vm.Subtitle);
Assert.Matches("^PL-[0-9A-Z]{4}-[0-9A-Z]{4}$", vm.Reference);
Assert.Single(vm.Devices);
Assert.True(vm.Devices[0].IsCurrent);
Assert.False(vm.Devices[0].CanRemove);
Assert.True(clears > 0); // champs de mot de passe effacés après chaque action
vm.CopyReferenceCommand.Execute(null);
Assert.Equal(vm.Reference, dialogs.Clipboard);
}
[Fact]
public async Task Sign_in_errors_are_clear_and_the_account_tabs_load_on_demand()
{
await using var h = await AccountsHarness.StartAsync();
using (var first = h.NewService()) await first.RegisterAsync(Email, Password, "1", remember: false);
h.File.Delete();
using var service = h.NewService();
using var vm = NewWindow(service, new FakeDialogs());
await vm.InitializeAsync();
vm.Email = Email;
await vm.SignInAsync("");
Assert.Equal("Saisissez votre mot de passe.", vm.ErrorMessage);
await vm.SignInAsync("Mauvais-Mot-De-Passe-1");
Assert.Equal("Adresse e-mail ou mot de passe incorrect.", vm.ErrorMessage);
Assert.Equal(AccountStep.SignIn, vm.Step);
vm.ToggleForgotCommand.Execute(null);
Assert.True(vm.ShowForgotHelp);
Assert.Contains("mot de passe temporaire", vm.ForgotHelpText);
await vm.SignInAsync(Password);
Assert.Equal(AccountStep.Account, vm.Step);
Assert.True(vm.IsProfileSection);
Assert.Empty(vm.Activity);
vm.IsActivitySection = true;
await WaitUntil(() => !vm.IsActivityLoading && vm.Activity.Count > 0);
Assert.Contains(vm.Activity, a => a.Title == "Mot de passe incorrect" && a.IsWarning);
Assert.Contains(vm.Activity, a => a.Title == "Connexion" && a.Detail.EndsWith("cet appareil"));
await vm.SignOutCommand.ExecuteAsync();
Assert.Equal(AccountStep.SignIn, vm.Step);
Assert.Equal("Vous êtes déconnecté.", vm.SuccessMessage);
Assert.Equal(PlayerAccountState.SignedOut, service.State);
}
[Fact]
public async Task Required_password_change_then_ordinary_password_change()
{
await using var h = await AccountsHarness.StartAsync();
string reference;
using (var first = h.NewService())
{
await first.RegisterAsync(Email, Password, "1", remember: false);
reference = first.Account!.Reference;
}
h.Server.Store.SetMustChangePassword(h.Server.Store.FindByReference(reference)!.Id, true);
using var service = h.NewService();
using var vm = NewWindow(service, new FakeDialogs());
await vm.InitializeAsync();
vm.Email = Email;
await vm.SignInAsync(Password);
Assert.Equal(AccountStep.PasswordChange, vm.Step);
Assert.Equal("Nouveau mot de passe exigé", vm.Title);
await vm.CompleteRequiredPasswordChangeAsync(Password, Password);
Assert.Contains("différent", vm.ErrorMessage);
await vm.CompleteRequiredPasswordChangeAsync(PlayerTestServer.OtherPassword, PlayerTestServer.OtherPassword);
Assert.Equal(AccountStep.Account, vm.Step);
Assert.Contains("Nouveau mot de passe enregistré", vm.SuccessMessage);
// Changement ordinaire depuis « Profil ».
vm.ShowChangePasswordCommand.Execute(null);
Assert.True(vm.IsChangingPassword);
await vm.ChangePasswordAsync("Pas-Le-Bon-Mot-De-Passe", "Nouvelle-Phrase-De-Passe-42", "Nouvelle-Phrase-De-Passe-42");
Assert.Equal("Mot de passe actuel incorrect.", vm.ErrorMessage);
await vm.ChangePasswordAsync(PlayerTestServer.OtherPassword, "Nouvelle-Phrase-De-Passe-42", "Nouvelle-Phrase-De-Passe-42");
Assert.Equal("Mot de passe modifié.", vm.SuccessMessage);
Assert.False(vm.IsChangingPassword);
}
[Fact]
public async Task Export_writes_the_chosen_file_and_deletion_needs_two_confirmations()
{
await using var h = await AccountsHarness.StartAsync();
using var service = h.NewService();
await service.RegisterAsync(Email, Password, "1", remember: true);
var dialogs = new FakeDialogs();
using var vm = NewWindow(service, dialogs);
await vm.InitializeAsync();
Assert.Equal(AccountStep.Account, vm.Step);
vm.IsDataSection = true;
// Export : emplacement refusé = rien n'est demandé au serveur.
dialogs.NextSaveFile = null;
await vm.ExportAsync(Password);
Assert.False(vm.HasSuccess);
dialogs.NextSaveFile = Path.Combine(h.DataDirectory, "export.json");
await vm.ExportAsync(Password);
Assert.Contains("export.json", vm.SuccessMessage);
using (var json = JsonDocument.Parse(await File.ReadAllBytesAsync(dialogs.NextSaveFile)))
Assert.Equal(PlayerDataExport.Format, json.RootElement.GetProperty("format").GetString());
// Suppression : case à cocher, puis confirmation, puis mot de passe vérifié par le serveur.
await vm.DeleteAccountAsync(Password);
Assert.Contains("Cochez la case", vm.ErrorMessage);
vm.DeleteConfirmed = true;
dialogs.ConfirmAnswer = false;
await vm.DeleteAccountAsync(Password);
Assert.Equal(PlayerAccountState.SignedIn, service.State);
dialogs.ConfirmAnswer = true;
await vm.DeleteAccountAsync(Password);
Assert.Equal(AccountStep.SignIn, vm.Step);
Assert.Equal("Votre compte a été supprimé définitivement.", vm.SuccessMessage);
Assert.Equal(0, h.Server.Store.CountAccounts());
}
[Fact]
public async Task Session_lost_while_the_window_is_open_returns_to_sign_in()
{
await using var h = await AccountsHarness.StartAsync();
using var service = h.NewService();
await service.RegisterAsync(Email, Password, "1", remember: true);
using var vm = NewWindow(service, new FakeDialogs());
await vm.InitializeAsync();
// Déconnexion de tous les appareils depuis un autre PC, puis jeton d'accès échu.
using var laptop = new TestDevice();
var other = (await h.Server.LoginAsync(laptop, Email)).Session!;
using (await h.Server.SendAsync(HttpMethod.Post, ApiRoutes.AccountLogout, other.AccessToken, new PlayerLogoutRequest { AllDevices = true })) { }
h.Server.Api.Time.Advance(TimeSpan.FromMinutes(20));
await vm.RefreshCommand.ExecuteAsync();
Assert.Equal(AccountStep.SignIn, vm.Step);
Assert.Contains("reconnectez-vous", vm.ErrorMessage);
}
[Fact]
public async Task Closed_service_shows_a_friendly_message()
{
await using var h = await AccountsHarness.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Players:Enabled"] = "false" });
using var service = h.NewService();
using var vm = NewWindow(service, new FakeDialogs());
await vm.InitializeAsync();
Assert.Equal(AccountStep.Unavailable, vm.Step);
Assert.Contains("pas encore ouverts", vm.UnavailableText);
}
[Fact]
public async Task Closed_registration_hides_account_creation()
{
await using var h = await AccountsHarness.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Players:RegistrationEnabled"] = "false" });
using var service = h.NewService();
using var vm = NewWindow(service, new FakeDialogs());
await vm.InitializeAsync();
Assert.Equal(AccountStep.SignIn, vm.Step);
Assert.True(vm.RegistrationClosed);
Assert.False(vm.ShowRegisterCommand.CanExecute(null));
}
[Fact]
public void Password_rules_follow_the_shared_checks()
{
var rules = new PasswordRulesViewModel();
Assert.True(rules.ShowRules);
rules.Update("Exovival2026!!", "", "joueur@example.fr");
Assert.True(rules.HasProblems); // tiré du nom du jeu
rules.Update("une phrase de passe assez longue", "une phrase de passe assez longue", null);
Assert.True(rules.IsValid);
Assert.Equal(4, rules.Strength);
rules.Reset();
Assert.False(rules.HasInput);
Assert.False(rules.IsValid);
}
private static async Task WaitUntil(Func<bool> condition)
{
for (var i = 0; i < 200 && !condition(); i++) await Task.Delay(25);
Assert.True(condition());
}
}
@@ -0,0 +1,116 @@
using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Accounts;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Accounts;
/// <summary>
/// Compte joueur du launcher contre la Launcher API démarrée en mémoire : clé d'appareil logicielle (à la place de la puce
/// TPM / clé CNG de Windows), chiffrement AES-GCM à clé aléatoire (à la place de DPAPI), dossier de données temporaire.
/// </summary>
public sealed class AccountsHarness : IAsyncDisposable
{
private readonly List<LauncherApiClient> _clients = new();
private AccountsHarness(PlayerTestServer server)
{
Server = server;
DataDirectory = Path.Combine(Path.GetTempPath(), "launcher-accounts-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(DataDirectory);
}
public PlayerTestServer Server { get; }
public string DataDirectory { get; }
public SoftwareKeyStore Keys { get; } = new();
public TestProtector Protector { get; } = new();
public PlayerSessionFile File => new(DataDirectory, Protector);
public static async Task<AccountsHarness> StartAsync(IDictionary<string, string?>? settings = null) =>
new(await PlayerTestServer.StartAsync(settings));
/// <summary>Nouveau service (comme un nouveau lancement du launcher sur le même PC).</summary>
public PlayerAccountService NewService(Uri? apiUrl = null, HttpMessageHandler? handler = null)
{
var client = new LauncherApiClient(apiUrl ?? Server.Api.BaseUrl, "GameLauncher.Tests", handler);
_clients.Add(client);
return new PlayerAccountService(new PlayerAccountApi(client), Keys, File, "Windows 11 · GameLauncher tests", Server.Api.Time);
}
public async ValueTask DisposeAsync()
{
foreach (var client in _clients) client.Dispose();
await Server.DisposeAsync();
try
{
Directory.Delete(DataDirectory, recursive: true);
}
catch (IOException)
{
}
}
}
/// <summary>Clé d'appareil logicielle (une par « PC ») : même interface que la clé TPM / CNG du launcher.</summary>
public sealed class SoftwareKeyStore : IPlayerDeviceKeyStore
{
private Key? _key;
public int Created { get; private set; }
public IPlayerDeviceKey? TryOpen() => _key;
public IPlayerDeviceKey OpenOrCreate()
{
if (_key is null)
{
_key = new Key();
Created++;
}
return _key;
}
public void Delete() => _key = null;
private sealed class Key : IPlayerDeviceKey
{
private readonly ECDsa _ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
public Key()
{
PublicKey = _ecdsa.ExportSubjectPublicKeyInfo();
KeyId = AdminKeys.ComputeKeyId(PublicKey);
}
public byte[] PublicKey { get; }
public string KeyId { get; }
public ValueTask<byte[]> SignAsync(byte[] message, CancellationToken ct = default) => ValueTask.FromResult(AdminKeys.Sign(_ecdsa, message));
}
}
/// <summary>Chiffrement authentifié à clé aléatoire (remplace DPAPI) : un autre « utilisateur » (autre instance) ne lit rien.</summary>
public sealed class TestProtector : ISecretProtector
{
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
public byte[] Protect(byte[] data)
{
var nonce = RandomNumberGenerator.GetBytes(12);
var cipher = new byte[data.Length];
var tag = new byte[16];
using var aes = new AesGcm(_key, 16);
aes.Encrypt(nonce, data, cipher, tag);
return nonce.Concat(tag).Concat(cipher).ToArray();
}
public byte[] Unprotect(byte[] data)
{
if (data.Length < 28) throw new CryptographicException("trop court");
var plain = new byte[data.Length - 28];
using var aes = new AesGcm(_key, 16);
aes.Decrypt(data.AsSpan(0, 12), data.AsSpan(28), data.AsSpan(12, 16), plain);
return plain;
}
}
@@ -0,0 +1,258 @@
using System.Text;
using System.Text.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Modules.Accounts;
using GameLauncher.Server.Security;
using GameLauncher.Tests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace GameLauncher.Tests.Accounts;
/// <summary>
/// Compte joueur côté launcher (étape 8b) contre la vraie Launcher API : session mémorisée chiffrée et reprise au lancement
/// suivant, rotation du jeton de renouvellement, jeton d'accès renouvelé de lui-même, sessions perdues, hors ligne,
/// nouveau mot de passe exigé, export et suppression.
/// </summary>
public class PlayerAccountServiceTests
{
private const string Email = "joueur@example.fr";
private const string Password = PlayerTestServer.Password;
[Fact]
public async Task Registered_session_is_saved_encrypted_and_resumed_at_next_launch_with_a_new_token()
{
await using var h = await AccountsHarness.StartAsync();
using (var first = h.NewService())
{
await first.RegisterAsync(Email, Password, "1", remember: true);
Assert.Equal(PlayerAccountState.SignedIn, first.State);
Assert.Equal("j***@e***.fr", first.Account!.EmailMasked);
Assert.True(first.IsRemembered);
}
// Fichier chiffré : ni le jeton, ni l'adresse, ni la référence en clair.
var saved = h.File.Load()!;
var raw = await File.ReadAllBytesAsync(h.File.Path);
var text = Encoding.UTF8.GetString(raw);
Assert.DoesNotContain(saved.RefreshToken, text);
Assert.DoesNotContain(saved.Reference, text);
Assert.DoesNotContain("e***", text);
// Lancement suivant : session reprise, jeton de renouvellement remplacé sur le disque.
using var second = h.NewService();
var changes = 0;
second.Changed += () => changes++;
await second.RestoreAsync();
Assert.Equal(PlayerAccountState.SignedIn, second.State);
Assert.True(changes >= 2); // reprise puis connecté
var rotated = h.File.Load()!;
Assert.NotEqual(saved.RefreshToken, rotated.RefreshToken);
Assert.Equal(saved.DeviceId, rotated.DeviceId);
var me = await second.GetMeAsync();
Assert.Single(me.Devices);
Assert.True(me.Devices[0].Current);
Assert.Equal("Windows 11 · GameLauncher tests", me.Devices[0].Label);
}
[Fact]
public async Task Session_file_of_another_user_another_server_or_without_the_device_key_is_discarded()
{
await using var h = await AccountsHarness.StartAsync();
using (var service = h.NewService()) await service.RegisterAsync(Email, Password, "1", remember: true);
var saved = h.File.Load()!;
// Autre utilisateur Windows (autre clé DPAPI) : illisible, supprimé.
var other = new PlayerSessionFile(h.DataDirectory, new TestProtector());
Assert.Null(other.Load());
Assert.False(File.Exists(h.File.Path));
// Autre serveur : ignorée.
h.File.Save(saved with { Origin = "https://autre.exemple.fr" });
using (var service = h.NewService())
{
await service.RestoreAsync();
Assert.Equal(PlayerAccountState.SignedOut, service.State);
}
Assert.False(File.Exists(h.File.Path));
// Clé de l'appareil disparue (profil Windows réinitialisé) : abandonnée.
h.File.Save(saved);
h.Keys.Delete();
using (var service = h.NewService())
{
await service.RestoreAsync();
Assert.Equal(PlayerAccountState.SignedOut, service.State);
}
Assert.False(File.Exists(h.File.Path));
}
[Fact]
public async Task Session_closed_from_another_device_is_dropped_with_an_explanation()
{
await using var h = await AccountsHarness.StartAsync();
using (var service = h.NewService()) await service.RegisterAsync(Email, Password, "1", remember: true);
// Un autre appareil déconnecte tout.
using var laptop = new TestDevice();
var other = (await h.Server.LoginAsync(laptop, Email)).Session!;
using (await h.Server.SendAsync(HttpMethod.Post, ApiRoutes.AccountLogout, other.AccessToken, new PlayerLogoutRequest { AllDevices = true })) { }
using var next = h.NewService();
await next.RestoreAsync();
Assert.Equal(PlayerAccountState.SignedOut, next.State);
Assert.Contains("reconnectez-vous", next.Notice);
Assert.False(File.Exists(h.File.Path));
await Assert.ThrowsAsync<PlayerSessionLostException>(() => next.GetMeAsync());
}
[Fact]
public async Task Unreachable_server_keeps_the_remembered_session_offline()
{
await using var h = await AccountsHarness.StartAsync();
using (var service = h.NewService()) await service.RegisterAsync(Email, Password, "1", remember: true);
var saved = h.File.Load()!;
// Serveur injoignable (coupure réseau simulée).
using var offline = h.NewService(handler: new UnreachableHandler());
await offline.RestoreAsync();
Assert.Equal(PlayerAccountState.Offline, offline.State);
Assert.Equal(saved.Reference, offline.Account!.Reference);
Assert.Equal(saved.RefreshToken, h.File.Load()!.RefreshToken); // rien de perdu
}
[Fact]
public async Task Access_token_is_renewed_before_expiry_and_after_a_server_restart()
{
await using var h = await AccountsHarness.StartAsync();
using var service = h.NewService();
await service.RegisterAsync(Email, Password, "1", remember: true);
var token1 = h.File.Load()!.RefreshToken;
// Échéance du jeton d'accès (15 min) : renouvelé de lui-même.
h.Server.Api.Time.Advance(TimeSpan.FromMinutes(20));
await service.GetMeAsync();
var token2 = h.File.Load()!.RefreshToken;
Assert.NotEqual(token1, token2);
// Serveur redémarré (sessions en mémoire perdues) : un renouvellement, puis l'appel réussit.
h.Server.Api.Services.GetRequiredService<PlayerSessionStore>().RemoveWhere(_ => true);
var me = await service.GetMeAsync();
Assert.Single(me.Devices);
Assert.NotEqual(token2, h.File.Load()!.RefreshToken);
}
[Fact]
public async Task Session_not_remembered_is_never_written_and_is_closed_when_the_launcher_exits()
{
await using var h = await AccountsHarness.StartAsync();
var service = h.NewService();
await service.RegisterAsync(Email, Password, "1", remember: false);
Assert.Equal(PlayerAccountState.SignedIn, service.State);
Assert.False(File.Exists(h.File.Path));
var accountId = h.Server.Store.FindByReference(service.Account!.Reference)!.Id;
Assert.Single(h.Server.Store.Devices(accountId));
service.Shutdown(TimeSpan.FromSeconds(10));
Assert.Empty(h.Server.Store.Devices(accountId));
Assert.Equal(PlayerAccountState.SignedOut, service.State);
service.Dispose();
}
[Fact]
public async Task Wrong_password_and_required_password_change_flow()
{
await using var h = await AccountsHarness.StartAsync();
using (var service = h.NewService()) await service.RegisterAsync(Email, Password, "1", remember: true);
h.Server.Store.SetMustChangePassword(h.Server.Store.FindByReference(h.File.Load()!.Reference)!.Id, true);
h.File.Delete();
using var next = h.NewService();
var wrong = await Assert.ThrowsAsync<ApiException>(() => next.SignInAsync(Email, "Pas-Le-Bon-Mot-2-Passe", true));
Assert.Equal(ApiErrorCodes.LoginFailed, wrong.Code);
Assert.Equal("Adresse e-mail ou mot de passe incorrect.", PlayerAccountTexts.Describe(wrong));
var result = await next.SignInAsync(Email, Password, remember: true);
Assert.True(result.PasswordChangeRequired);
Assert.Equal(PlayerAccountState.SignedOut, next.State);
Assert.False(File.Exists(h.File.Path));
await next.CompleteRequiredPasswordChangeAsync(result.PasswordChangeToken!, Password, PlayerTestServer.OtherPassword, remember: true);
Assert.Equal(PlayerAccountState.SignedIn, next.State);
Assert.True(File.Exists(h.File.Path));
Assert.Equal(1, h.Keys.Created); // même clé d'appareil depuis le début
}
[Fact]
public async Task Export_then_deletion_from_the_launcher()
{
await using var h = await AccountsHarness.StartAsync();
using var service = h.NewService();
await service.RegisterAsync(Email, Password, "1", remember: true);
var reference = service.Account!.Reference;
var export = await service.ExportAsync(Password);
using (var json = JsonDocument.Parse(export))
Assert.Equal(Email, json.RootElement.GetProperty("account").GetProperty("email").GetString());
await Assert.ThrowsAsync<ApiException>(() => service.DeleteAccountAsync("Mauvais-Mot-De-Passe-9"));
Assert.Equal(PlayerAccountState.SignedIn, service.State);
await service.DeleteAccountAsync(Password);
Assert.Equal(PlayerAccountState.SignedOut, service.State);
Assert.False(File.Exists(h.File.Path));
Assert.Null(h.Server.Store.FindByReference(reference));
}
[Fact]
public async Task Signing_out_this_device_or_all_devices()
{
await using var h = await AccountsHarness.StartAsync();
using var service = h.NewService();
await service.RegisterAsync(Email, Password, "1", remember: true);
var accountId = h.Server.Store.FindByReference(service.Account!.Reference)!.Id;
using var laptop = new TestDevice();
await h.Server.LoginAsync(laptop, Email);
Assert.Equal(2, h.Server.Store.Devices(accountId).Count);
await service.SignOutAsync(allDevices: false);
Assert.Equal(PlayerAccountState.SignedOut, service.State);
Assert.False(File.Exists(h.File.Path));
Assert.Single(h.Server.Store.Devices(accountId)); // l'autre appareil reste connecté
await service.SignInAsync(Email, Password, remember: true);
await service.SignOutAsync(allDevices: true);
Assert.Empty(h.Server.Store.Devices(accountId));
}
[Fact]
public async Task Challenge_from_a_server_announcing_another_origin_is_refused()
{
await using var h = await AccountsHarness.StartAsync();
// Même serveur, joint par un autre nom : l'origine annoncée ne correspond pas à l'adresse configurée.
using var service = h.NewService(new Uri($"http://localhost:{h.Server.Api.BaseUrl.Port}/api/"));
var error = await Assert.ThrowsAsync<ApiException>(() => service.RegisterAsync(Email, Password, "1", true));
Assert.Equal("origin_mismatch", error.Code);
Assert.Equal(0, h.Server.Store.CountAccounts());
}
[Fact]
public void Device_label_is_generic()
{
var label = PlayerDeviceLabel.Create("3.4.2");
Assert.EndsWith("· GameLauncher 3.4.2", label);
Assert.DoesNotContain(Environment.MachineName, label);
Assert.Equal(label, PlayerDeviceLabel.Create("3.4.2<script>"));
Assert.EndsWith("· GameLauncher", PlayerDeviceLabel.Create(""));
}
}
/// <summary>Coupure réseau : chaque requête échoue comme un serveur injoignable.</summary>
internal sealed class UnreachableHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
throw new HttpRequestException("Aucune connexion n'a pu être établie (test).");
}
@@ -7,6 +7,8 @@
et attaquée comme le ferait un client réel ou malveillant.
• Admin : module d'administration du launcher (clés, connexion, invitation, verrouillage,
écrans) contre ce même serveur, sans interface graphique.
• Accounts : compte joueur du launcher (session mémorisée chiffrée, renouvellement, fenêtre « Compte »)
contre ce même serveur, sans interface graphique.
• Website : site vitrine (signature de launcher-update.json, manifests, liseuse Markdown).
-->
<PropertyGroup>
@@ -34,6 +36,7 @@
<ProjectReference Include="..\..\server\GameLauncher.Server\GameLauncher.Server.csproj" />
<ProjectReference Include="..\..\src\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj" />
<ProjectReference Include="..\..\src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj" />
<ProjectReference Include="..\..\src\GameLauncher.Modules.Accounts\GameLauncher.Modules.Accounts.csproj" />
</ItemGroup>
<!--
@@ -0,0 +1,284 @@
using System.Net;
using System.Net.Http.Json;
using System.Reflection;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Modules.Accounts;
using GameLauncher.Server.Storage.Players;
using GameLauncher.Tests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace GameLauncher.Tests.Server;
/// <summary>
/// Comptes joueurs, étape 8b (RGPD) : activité récente (sans détail interne, adresses IP masquées), export de toutes ses
/// données (mot de passe exigé), suppression définitive du compte par son titulaire, comptes inactifs (mise en sommeil,
/// réveil par une connexion, suppression).
/// </summary>
public class PlayerPrivacyTests
{
private const string Email = "joueur@example.fr";
[Fact]
public async Task Activity_lists_recent_events_with_masked_addresses_and_no_internal_details()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var session = await server.RegisterAsync(device, Email);
using var stranger = new TestDevice("Windows 10 · autre PC");
using (var failed = await server.LoginRawAsync(stranger, Email, "Mauvais-Mot-De-Passe-1"))
Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode);
using var response = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountActivity, session.AccessToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
var activity = JsonSerializer.Deserialize<PlayerActivityResponse>(body, ApiJson.Options)!;
Assert.Equal(2, activity.Entries.Count);
var failure = activity.Entries[0];
Assert.Equal("account.login_failed", failure.Type);
Assert.False(failure.Success);
Assert.False(failure.ThisDevice);
Assert.Null(failure.Device); // appareil inconnu : aucun nom
Assert.Equal("127.0.0.x", failure.Ip);
var created = activity.Entries[1];
Assert.Equal("account.register", created.Type);
Assert.True(created.Success);
Assert.True(created.ThisDevice);
Assert.Equal(device.Label, created.Device);
// Aucun détail interne (« appareil inconnu », « mot de passe incorrect »…) ni adresse complète.
Assert.DoesNotContain("details", body, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("127.0.0.1", body);
Assert.DoesNotContain(device.KeyId, body);
}
[Theory]
[InlineData("203.0.113.42", "203.0.113.x")]
[InlineData("::ffff:198.51.100.7", "198.51.100.x")]
[InlineData("2001:db8:85a3:1234:5678:8a2e:370:7334", "2001:db8:85a3::/48")]
[InlineData("pas une adresse", null)]
[InlineData(null, null)]
public void Ip_addresses_are_masked_for_the_player(string? ip, string? expected) =>
Assert.Equal(expected, AccountsModule.MaskIp(ip));
[Fact]
public void Every_account_event_type_has_a_french_label()
{
var types = typeof(PlayerEvents).GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.IsLiteral).Select(f => (string)f.GetRawConstantValue()!).ToList();
Assert.True(types.Count >= 19);
foreach (var type in types) Assert.True(PlayerActivityText.IsKnown(type), $"« {type} » sans libellé (PlayerActivityText)");
Assert.Equal("type.inconnu", PlayerActivityText.Describe("type.inconnu", true));
}
[Fact]
public async Task Export_requires_the_password_and_contains_everything_but_secrets()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var session = await server.RegisterAsync(device, "Joueur@Example.fr");
using (var wrong = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountExport, session.AccessToken,
new PlayerPasswordConfirmation { Password = "Pas-Le-Bon-Mot-De-Passe" }))
{
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
Assert.Equal(ApiErrorCodes.LoginFailed, (await TestApi.ReadErrorAsync(wrong)).Code);
}
using (var missing = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountExport, session.AccessToken, new { }))
Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode);
using (var anonymous = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountExport, null,
new PlayerPasswordConfirmation { Password = PlayerTestServer.Password }))
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
using var response = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountExport, session.AccessToken,
new PlayerPasswordConfirmation { Password = PlayerTestServer.Password });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
var text = await response.Content.ReadAsStringAsync();
using var json = JsonDocument.Parse(text);
var root = json.RootElement;
Assert.Equal(PlayerDataExport.Format, root.GetProperty("format").GetString());
Assert.Equal(server.Api.Origin, root.GetProperty("service").GetString());
var account = root.GetProperty("account");
Assert.Equal(session.Account.Reference, account.GetProperty("reference").GetString());
Assert.Equal("joueur@example.fr", account.GetProperty("email").GetString()); // adresse complète (normalisée)
Assert.Equal(device.Label, root.GetProperty("devices")[0].GetProperty("label").GetString());
var types = root.GetProperty("activity").EnumerateArray().Select(e => e.GetProperty("type").GetString()).ToList();
Assert.Contains("account.register", types);
Assert.Contains("account.export", types); // l'essai refusé est inscrit
Assert.Equal(JsonValueKind.Object, root.GetProperty("modules").ValueKind);
// Jamais l'empreinte du mot de passe, ni les secrets des sessions, ni la clé publique complète de l'appareil.
var stored = server.Store.FindByReference(session.Account.Reference)!;
var storedDevice = server.Store.Devices(stored.Id).Single();
Assert.DoesNotContain("argon2", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(stored.PasswordHash, text);
Assert.DoesNotContain(storedDevice.RefreshHash, text);
Assert.DoesNotContain(storedDevice.PublicKey, text);
Assert.DoesNotContain(session.RefreshToken, text);
// L'export réussi est inscrit au journal du compte.
var events = server.Store.Events(stored.Id, 10);
Assert.Contains(events, e => e.Type == PlayerEvents.Export && e.Result == AuditResults.Ok);
Assert.Contains(events, e => e.Type == PlayerEvents.Export && e.Result == AuditResults.Denied);
}
[Fact]
public async Task Export_and_deletion_are_refused_with_the_limited_password_change_token()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var session = await server.RegisterAsync(device, Email);
server.Store.SetMustChangePassword(server.Store.FindByReference(session.Account.Reference)!.Id, true);
var limited = (await server.LoginAsync(device, Email)).PasswordChangeToken!;
foreach (var route in new[] { ApiRoutes.AccountExport, ApiRoutes.AccountDelete })
{
using var response = await server.SendAsync(HttpMethod.Post, route, limited,
new PlayerPasswordConfirmation { Password = PlayerTestServer.Password });
Assert.True(response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden, $"{route} : {(int)response.StatusCode}");
}
using var activity = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountActivity, limited);
Assert.True(activity.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden);
Assert.NotNull(server.Store.FindByReference(session.Account.Reference));
}
[Fact]
public async Task Deleting_the_account_erases_it_and_closes_every_session()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
using var laptop = new TestDevice("Windows 11 · portable");
var session = await server.RegisterAsync(device, Email);
var other = (await server.LoginAsync(laptop, Email)).Session!;
var accountId = server.Store.FindByReference(session.Account.Reference)!.Id;
using (var wrong = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountDelete, session.AccessToken,
new PlayerPasswordConfirmation { Password = PlayerTestServer.OtherPassword }))
Assert.Equal(HttpStatusCode.BadRequest, wrong.StatusCode);
Assert.NotNull(server.Store.FindById(accountId));
using (var deleted = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountDelete, session.AccessToken,
new PlayerPasswordConfirmation { Password = PlayerTestServer.Password }))
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
Assert.Null(server.Store.FindById(accountId));
Assert.Empty(server.Store.Devices(accountId));
Assert.Empty(server.Store.Events(accountId, 100)); // journal du compte effacé avec lui
// Plus aucune session : jetons d'accès des deux appareils, renouvellement, connexion.
foreach (var token in new[] { session.AccessToken, other.AccessToken })
{
using var me = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, token);
Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode);
}
using (var refresh = await server.RefreshRawAsync(laptop, other.RefreshToken))
Assert.Equal(HttpStatusCode.Unauthorized, refresh.StatusCode);
using (var login = await server.LoginRawAsync(device, Email))
Assert.Equal(HttpStatusCode.Unauthorized, login.StatusCode);
// Trace dans le journal d'audit : la référence seulement, jamais l'adresse.
var audit = await File.ReadAllTextAsync(Directory.GetFiles(Path.Combine(server.Api.DataDirectory, "audit"), "audit-*.jsonl").Single());
Assert.Contains("players.self_delete", audit);
Assert.Contains(session.Account.Reference, audit);
Assert.DoesNotContain("joueur@", audit);
// L'adresse est de nouveau libre.
using var again = new TestDevice();
var recreated = await server.RegisterAsync(again, Email);
Assert.NotEqual(session.Account.Reference, recreated.Account.Reference);
}
[Fact]
public async Task Guessing_the_password_from_a_stolen_session_locks_it_and_closes_the_sessions()
{
await using var server = await PlayerTestServer.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Players:TrustedFailuresPerLock"] = "3"
});
using var device = new TestDevice();
var session = await server.RegisterAsync(device, Email);
HttpStatusCode last = 0;
for (var i = 0; i < 3; i++)
{
using var response = await server.SendAsync(HttpMethod.Post, ApiRoutes.AccountDelete, session.AccessToken,
new PlayerPasswordConfirmation { Password = $"Essai-Au-Hasard-{i}-xyz" });
last = response.StatusCode;
}
Assert.Equal(HttpStatusCode.TooManyRequests, last);
using var me = await server.SendAsync(HttpMethod.Get, ApiRoutes.AccountMe, session.AccessToken);
Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode);
Assert.NotNull(server.Store.FindByReference(session.Account.Reference)); // rien n'a été supprimé
}
[Fact]
public async Task Inactive_accounts_fall_asleep_wake_up_on_login_then_are_deleted()
{
await using var server = await PlayerTestServer.StartAsync();
using var device = new TestDevice();
var session = await server.RegisterAsync(device, Email);
using var banned = new TestDevice();
var bannedSession = await server.RegisterAsync(banned, "banni@example.fr");
var accountId = server.Store.FindByReference(session.Account.Reference)!.Id;
var bannedId = server.Store.FindByReference(bannedSession.Account.Reference)!.Id;
server.Store.SetStatus(bannedId, PlayerStatus.Disabled, null, null, "tests", server.Api.Time.GetUtcNow());
var privacy = server.Api.Services.GetRequiredService<PlayerPersonalData>();
// 23 mois : rien. 25 mois : en sommeil, appareils déconnectés (le compte désactivé par un administrateur n'est pas touché).
server.Api.Time.Advance(TimeSpan.FromDays(23 * 31));
Assert.Equal((0, 0), privacy.ApplyInactivity(server.Api.Time.GetUtcNow()));
server.Api.Time.Advance(TimeSpan.FromDays(2 * 31));
Assert.Equal((1, 0), privacy.ApplyInactivity(server.Api.Time.GetUtcNow()));
Assert.Equal(PlayerStatus.Inactive, server.Store.FindById(accountId)!.Status);
Assert.Equal(PlayerStatus.Disabled, server.Store.FindById(bannedId)!.Status);
Assert.Empty(server.Store.Devices(accountId));
// Une connexion avec le bon mot de passe le réveille.
var login = await server.LoginAsync(device, Email);
Assert.Equal(PlayerLoginStatus.Ok, login.Status);
Assert.Equal(PlayerStatus.Active, server.Store.FindById(accountId)!.Status);
Assert.Contains(server.Store.Events(accountId, 20), e => e.Type == PlayerEvents.Reactivated);
// De nouveau 25 mois sans activité, puis 12 mois de sommeil : supprimé définitivement.
server.Api.Time.Advance(TimeSpan.FromDays(25 * 31));
Assert.Equal((1, 0), privacy.ApplyInactivity(server.Api.Time.GetUtcNow()));
server.Api.Time.Advance(TimeSpan.FromDays(11 * 31));
Assert.Equal((0, 0), privacy.ApplyInactivity(server.Api.Time.GetUtcNow()));
server.Api.Time.Advance(TimeSpan.FromDays(2 * 31));
Assert.Equal((0, 1), privacy.ApplyInactivity(server.Api.Time.GetUtcNow()));
Assert.Null(server.Store.FindById(accountId));
Assert.NotNull(server.Store.FindById(bannedId));
var audit = string.Concat(Directory.GetFiles(Path.Combine(server.Api.DataDirectory, "audit"), "audit-*.jsonl").Select(File.ReadAllText));
Assert.Contains("players.inactive_delete", audit);
}
[Fact]
public async Task Inactivity_policy_can_be_turned_off()
{
await using var server = await PlayerTestServer.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Players:InactiveDisableMonths"] = "0"
});
using var device = new TestDevice();
var session = await server.RegisterAsync(device, Email);
server.Api.Time.Advance(TimeSpan.FromDays(10 * 365));
Assert.Equal((0, 0), server.Api.Services.GetRequiredService<PlayerPersonalData>().ApplyInactivity(server.Api.Time.GetUtcNow()));
Assert.Equal(PlayerStatus.Active, server.Store.FindByReference(session.Account.Reference)!.Status);
}
[Theory]
[InlineData("6")]
[InlineData("121")]
public async Task Inactivity_settings_are_validated(string months)
{
await Assert.ThrowsAnyAsync<Exception>(() => PlayerTestServer.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Players:InactiveDisableMonths"] = months
}));
}
}
@@ -54,7 +54,8 @@ public class PlayerSeparationTests
foreach (var (method, path) in new[]
{
("GET", ApiRoutes.AccountMe), ("POST", ApiRoutes.AccountLogout), ("POST", ApiRoutes.AccountPassword),
("DELETE", ApiRoutes.AccountDevice(PlayerIds.NewId()))
("DELETE", ApiRoutes.AccountDevice(PlayerIds.NewId())), ("GET", ApiRoutes.AccountActivity),
("POST", ApiRoutes.AccountExport), ("POST", ApiRoutes.AccountDelete)
})
{
using var response = await server.SendAsync(new HttpMethod(method), path, admin.Token, method == "POST" ? new { } : null);
+10 -3
View File
@@ -51,7 +51,7 @@ tools\ui-snapshot.bat --filter "main-*,launcher-update-verified" --format webp -
## Scènes
* **Fenêtre principale** (`main-*`) : non installé, à jour, mise à jour disponible, téléchargement en
* **Fenêtre principale** (`main-*`) : boutons « Aide » et « Compte » avec un joueur connecté (`main-account`), non installé, à jour, mise à jour disponible, téléchargement en
cours, préparation, installation introuvable, réparation nécessaire, jeu lancé, hors ligne, mise à jour
bloquée par sécurité, démarrage, **mode maintenance** (`main-maintenance` avec la précision de l'administrateur,
`main-maintenance-plain` sans précision, jeu non installé) ; `main-multi` : plusieurs jeux (exemple
@@ -67,6 +67,13 @@ tools\ui-snapshot.bat --filter "main-*,launcher-update-verified" --format webp -
importée, type masqué), formulaire publié avec tous les types de champs (`feedback-custom` : cases à
cocher, note sur 10, texte court, case oui/non ; type « autre » masqué), demande envoyée (avec l'avertissement d'une capture refusée). Envoi simulé, capture d'écran de
démonstration (ni fichier choisi, ni presse-papiers).
* **Fenêtre « Compte »** (`account-*`, bouton de la barre de titre) : connexion (`account-signin`), connexion refusée
avec l'aide « mot de passe oublié » (`account-signin-error`), création de compte avec un mot de passe refusé
(`account-register`) puis accepté (`account-register-valid`), nouveau mot de passe exigé (`account-password-change`),
« Mon compte » : profil (`account-profile`), changement de mot de passe (`account-profile-password`), appareils
(`account-devices`), activité avec essais refusés (`account-activity`), mes données (`account-data`), nouveaux
appareils suspendus (`account-suspended`), comptes pas encore ouverts (`account-unavailable`). Compte **simulé**
(`Scenes\AccountScenes.cs`) : aucun réseau, aucune clé d'appareil, aucun fichier de session.
* **Menu d'administration** (`admin-*`) : connexion (normale, refusée, verrouillée), « J'ai une
invitation » (formulaire, empreinte), puis **chaque page déclarée par les modules** (`admin-page-<id>`,
lues dans `App.CreateModules` : une page ajoutée plus tard est capturée sans modifier l'outil ; la page
@@ -100,8 +107,8 @@ L'outil sert au débogage : **chaque nouvelle fenêtre ou nouveau menu doit y ê
modification. Une page du menu d'administration est capturée automatiquement, mais ses routes de l'API sont
à simuler dans `Fakes\FakeApi.cs` (sinon le rapport les signale) et ses états intéressants (fiche ouverte,
élément choisi…) à ajouter dans `Scenes\AdminScenes.cs`. Une fenêtre du joueur s'ajoute dans
`Scenes\PlayerScenes.cs`, ou dans un ensemble de scènes à part (`Scenes\FeedbackScenes.cs`) déclaré dans
`SnapshotRunner.cs`.
`Scenes\PlayerScenes.cs`, ou dans un ensemble de scènes à part (`Scenes\FeedbackScenes.cs`, `Scenes\AccountScenes.cs`)
déclaré dans `SnapshotRunner.cs`.
## Limites
+249
View File
@@ -0,0 +1,249 @@
using System.Windows;
using System.Windows.Controls;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Accounts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Modules;
using GameLauncher.Modules.Accounts;
using GameLauncher.Platform;
using GameLauncher.Views;
namespace UiSnapshot.Scenes;
/// <summary>
/// Fenêtre « Compte » du joueur (bouton de la barre de titre) : connexion (normale, refusée avec l'aide « mot de passe
/// oublié »), création de compte (mot de passe refusé, puis accepté), nouveau mot de passe exigé, « Mon compte » (profil,
/// changement de mot de passe, appareils, activité, mes données, suspension des nouveaux appareils), service fermé.
/// Vrai ViewModel et vraie fenêtre ; le compte est simulé (<see cref="SampleAccounts"/>) : aucun réseau, aucune clé,
/// aucun fichier.
/// </summary>
internal sealed class AccountScenes : ISceneSet
{
private readonly Fixture _fixture;
public AccountScenes(Fixture fixture) => _fixture = fixture;
/// <param name="SignedIn">Compte connecté à l'ouverture (sinon : écran de connexion).</param>
private sealed record Scene(string Id, string Title, bool SignedIn, Func<AccountWindow, AccountWindowViewModel, SampleAccounts, Task> Apply,
bool Suspended = false, bool Closed = false);
private static readonly Scene[] Scenes =
{
new("account-signin", "Compte · connexion", false, (_, vm, _) =>
{
vm.Email = "joueur@exemple.fr";
return Task.CompletedTask;
}),
new("account-signin-error", "Compte · connexion refusée, aide « mot de passe oublié »", false, async (w, vm, _) =>
{
vm.Email = "joueur@exemple.fr";
await vm.SignInAsync("mauvais mot de passe");
vm.ToggleForgotCommand.Execute(null);
}),
new("account-register", "Compte · création, mot de passe refusé", false, (w, vm, _) =>
{
vm.ShowRegisterCommand.Execute(null);
vm.Email = "joueur@exemple.fr";
SetPasswords(w, "RegisterPasswordBox", "azerty2026", "RegisterConfirmBox", "azerty");
return Task.CompletedTask;
}),
new("account-register-valid", "Compte · création, mot de passe accepté, conditions acceptées", false, (w, vm, _) =>
{
vm.ShowRegisterCommand.Execute(null);
vm.Email = "joueur@exemple.fr";
SetPasswords(w, "RegisterPasswordBox", "Cerf-Volant-Bleu-77!", "RegisterConfirmBox", "Cerf-Volant-Bleu-77!");
vm.AcceptTerms = true;
return Task.CompletedTask;
}),
new("account-password-change", "Compte · nouveau mot de passe exigé par un administrateur", false, async (w, vm, accounts) =>
{
accounts.RequirePasswordChange = true;
vm.Email = "joueur@exemple.fr";
await vm.SignInAsync("mot de passe temporaire");
SetPasswords(w, "RequiredNewBox", "Tournesol-Rapide-2026", "RequiredConfirmBox", "");
}),
new("account-profile", "Mon compte · profil", true, (_, _, _) => Task.CompletedTask),
new("account-profile-password", "Mon compte · changement de mot de passe", true, (w, vm, _) =>
{
vm.ShowChangePasswordCommand.Execute(null);
SetPasswords(w, "ChangeNewBox", "Une-Phrase-De-Passe-Bien-Longue", "ChangeConfirmBox", "Une-Phrase-De-Passe-Bien-Longue");
return Task.CompletedTask;
}),
new("account-devices", "Mon compte · appareils connectés", true, (_, vm, _) =>
{
vm.IsDevicesSection = true;
return Task.CompletedTask;
}),
new("account-activity", "Mon compte · activité récente (essais refusés signalés)", true, async (_, vm, _) =>
{
vm.IsActivitySection = true;
for (var i = 0; i < 40 && (vm.IsActivityLoading || vm.Activity.Count == 0); i++) await Task.Delay(25);
}),
new("account-data", "Mon compte · mes données (export, suppression)", true, (_, vm, _) =>
{
vm.IsDataSection = true;
vm.DeleteConfirmed = true;
return Task.CompletedTask;
}),
new("account-suspended", "Mon compte · nouveaux appareils suspendus (trop d'essais)", true, (_, _, _) => Task.CompletedTask,
Suspended: true),
new("account-unavailable", "Compte · comptes joueurs pas encore ouverts", false, (_, _, _) => Task.CompletedTask, Closed: true)
};
public IEnumerable<SceneInfo> List(IReadOnlyList<string?> themes) =>
themes.SelectMany(theme => Scenes.Select(s => new SceneInfo(s.Id + Capture.Suffix(theme), s.Title)));
public async Task RunAsync(Capture capture)
{
foreach (var theme in capture.Themes)
{
foreach (var scene in Scenes)
{
await capture.ShootAsync(scene.Id + Capture.Suffix(theme), scene.Title, async () =>
{
_fixture.ApplyTheme(theme);
var accounts = new SampleAccounts(scene.SignedIn, scene.Suspended, scene.Closed);
AccountWindow? window = null;
var vm = new AccountWindowViewModel(accounts, new SilentDialogs(), new WpfUiDispatcher(Application.Current.Dispatcher),
"LanisysGames");
window = new AccountWindow(vm); // la fenêtre libère le ViewModel à sa fermeture
Capture.ShowOffscreen(window);
await capture.SettleAsync(); // ouverture : InitializeAsync (compte simulé)
await scene.Apply(window, vm, accounts);
if (vm.HasError && scene.Id != "account-signin-error") capture.Problem($"Fenêtre « Compte » : {vm.ErrorMessage}");
return window;
}, Capture.CloseAsync);
}
}
}
/// <summary>Saisie dans deux champs de mot de passe (la vue met à jour le contrôle en direct).</summary>
private static void SetPasswords(Window window, string first, string firstValue, string second, string secondValue)
{
if (window.FindName(first) is PasswordBox a) a.Password = firstValue;
if (window.FindName(second) is PasswordBox b) b.Password = secondValue;
}
/// <summary>Boîtes de dialogue sans effet (aucune fenêtre, rien dans le presse-papiers).</summary>
private sealed class SilentDialogs : IModuleDialogs
{
public void ShowMessage(string title, string message, ModuleMessageKind kind = ModuleMessageKind.Info) { }
public bool Confirm(string title, string message, string confirmText, string cancelText = "Annuler", bool danger = false) => false;
public string? PickOpenFile(string title, string filter, string? initialDirectory) => null;
public string? PickSaveFile(string title, string filter, string defaultFileName, string? initialDirectory) => null;
public void CopyToClipboard(string text) { }
public void OpenFolder(string path) { }
}
}
/// <summary>Compte joueur simulé (données de démonstration fixes, aucune requête).</summary>
internal sealed class SampleAccounts : IPlayerAccountService
{
private static readonly DateTimeOffset Now = new(2026, 11, 11, 18, 42, 0, TimeSpan.FromHours(1));
private readonly bool _suspended;
private readonly bool _closed;
private PlayerAccountState _state;
public SampleAccounts(bool signedIn, bool suspended, bool closed)
{
_state = signedIn ? PlayerAccountState.SignedIn : PlayerAccountState.SignedOut;
_suspended = suspended;
_closed = closed;
}
/// <summary>La connexion demande un nouveau mot de passe (exigé par un administrateur).</summary>
public bool RequirePasswordChange { get; set; }
public PlayerAccountState State => _state;
public PlayerAccountInfo? Account => _state == PlayerAccountState.SignedIn ? Info : null;
public string? Notice => null;
public event Action? Changed;
private PlayerAccountInfo Info => new()
{
Reference = "PL-7KQ2-M9XD",
EmailMasked = "j***@e***.fr",
CreatedAt = Now.AddMonths(-7).AddDays(-3),
LastLoginAt = Now.AddDays(-2),
PasswordChangedAt = Now.AddMonths(-2),
NewDevicesSuspended = _suspended
};
public Task<PlayerAccountsInfo?> GetServiceInfoAsync(CancellationToken ct = default) =>
Task.FromResult<PlayerAccountsInfo?>(_closed ? null : new PlayerAccountsInfo
{
Enabled = true,
RegistrationOpen = true,
TermsVersion = "2026-11",
PasswordRulesVersion = PasswordRules.Version
});
public Task RestoreAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task<PlayerSignInResult> SignInAsync(string email, string password, bool remember, CancellationToken ct = default)
{
if (RequirePasswordChange) return Task.FromResult(new PlayerSignInResult(true, "glpc_demonstration"));
throw new ApiException(401, ApiErrorCodes.LoginFailed, "Adresse e-mail ou mot de passe incorrect.");
}
public Task RegisterAsync(string email, string password, string termsVersion, bool remember, CancellationToken ct = default) => SignIn();
public Task CompleteRequiredPasswordChangeAsync(string token, string current, string next, bool remember, CancellationToken ct = default) =>
SignIn();
public Task<int> ChangePasswordAsync(string currentPassword, string newPassword, CancellationToken ct = default) => Task.FromResult(1);
public Task<PlayerMeResponse> GetMeAsync(CancellationToken ct = default) => Task.FromResult(new PlayerMeResponse
{
Account = Info,
Devices = new[]
{
new PlayerDeviceInfo
{
Id = new string('a', 32), Label = "Windows 11 · GameLauncher 3.5.0", CreatedAt = Now.AddMonths(-7), LastUsedAt = Now,
Current = true
},
new PlayerDeviceInfo
{
Id = new string('b', 32), Label = "Windows 10 · GameLauncher 3.4.2", CreatedAt = Now.AddMonths(-3), LastUsedAt = Now.AddDays(-12)
}
}
});
public Task<PlayerActivityResponse> GetActivityAsync(CancellationToken ct = default) => Task.FromResult(new PlayerActivityResponse
{
Entries = new[]
{
new PlayerActivityEntry { At = Now.AddMinutes(-3), Type = "session.refresh", Success = true, ThisDevice = true },
new PlayerActivityEntry { At = Now.AddDays(-1).AddHours(-4), Type = "account.login_failed", Ip = "203.0.113.x" },
new PlayerActivityEntry { At = Now.AddDays(-1).AddHours(-4).AddMinutes(1), Type = "account.locked", Success = true, Ip = "203.0.113.x" },
new PlayerActivityEntry
{
At = Now.AddDays(-2), Type = "account.login", Success = true, Device = "Windows 10 · GameLauncher 3.4.2", Ip = "198.51.100.x"
},
new PlayerActivityEntry { At = Now.AddMonths(-2), Type = "account.password_change", Success = true, ThisDevice = true },
new PlayerActivityEntry { At = Now.AddMonths(-7).AddDays(-3), Type = "account.register", Success = true, ThisDevice = true }
}
});
public Task RemoveDeviceAsync(string deviceId, CancellationToken ct = default) => Task.CompletedTask;
public Task SignOutAsync(bool allDevices, CancellationToken ct = default)
{
_state = PlayerAccountState.SignedOut;
Changed?.Invoke();
return Task.CompletedTask;
}
public Task<byte[]> ExportAsync(string password, CancellationToken ct = default) => Task.FromResult("{}"u8.ToArray());
public Task DeleteAccountAsync(string password, CancellationToken ct = default) => SignOutAsync(true, ct);
private Task SignIn()
{
_state = PlayerAccountState.SignedIn;
Changed?.Invoke();
return Task.CompletedTask;
}
}
+11
View File
@@ -107,6 +107,17 @@ internal sealed class PlayerScenes : ISceneSet
DialogKind.Error);
}),
new("main-checking", "Fenêtre principale · démarrage (vérification)", (g, f) => { }),
// Boutons « Aide » et « Compte » de la barre de titre (comptes joueurs activés), joueur connecté (pastille verte).
new("main-account", "Fenêtre principale · boutons « Aide » et « Compte » (joueur connecté)", (g, f) =>
{
Installed(g, f, "1.4.2", "1.4.2");
State(g, LauncherState.Ready, "Prêt à jouer", "Version 1.4.2");
}, l =>
{
l.IsHelpAvailable = true;
l.IsAccountAvailable = true;
l.ApplyAccountState(AccountButtonState.SignedIn, "j***@e***.fr");
}),
// Mode maintenance (publié depuis le menu d'administration) : palette rouge / orange, carte au centre, tout bloqué
// sauf la désinstallation. Avec une précision de l'administrateur, puis sans (jeu non installé).
new("main-maintenance", "Fenêtre principale · mode maintenance (avec message)", (g, f) =>
+1 -1
View File
@@ -31,7 +31,7 @@ internal sealed class SnapshotRunner
var themes = ResolveThemes();
var repository = Fixture.FindRepositoryRoot();
using var fixture = new Fixture(repository);
var sets = new ISceneSet[] { new PlayerScenes(fixture), new FeedbackScenes(fixture), new AdminScenes(fixture) };
var sets = new ISceneSet[] { new PlayerScenes(fixture), new FeedbackScenes(fixture), new AccountScenes(fixture), new AdminScenes(fixture) };
if (_options.List)
{
+1 -1
View File
@@ -2,7 +2,7 @@
<!--
Outil de développement « UiSnapshot » : rend chaque écran du launcher en PNG (fenêtre principale dans
tous ses états, paramètres, boîtes de dialogue, mise à jour du launcher, fenêtre « Aide », menu
tous ses états, paramètres, boîtes de dialogue, mise à jour du launcher, fenêtres « Aide » et « Compte », menu
d'administration et chacune de ses pages), avec des données de démonstration, sans serveur ni compte réel, puis signale
les problèmes de mise en page (texte coupé, débordement, erreurs de liaison…).
Lancement : tools\ui-snapshot.bat (voir LISEZMOI.md).
+2 -1
View File
@@ -3,7 +3,7 @@ rem ============================================================================
rem Captures de toutes les fenêtres du launcher (outil tools\UiSnapshot).
rem Compile le launcher et l'outil, puis écrit dans artifacts\ui-snapshots :
rem - une image par écran, PNG ou WebP (états de la fenêtre principale, paramètres, dialogues,
rem mise à jour du launcher, menu d'administration et chacune de ses pages) ;
rem mise à jour du launcher, fenêtres « Aide » et « Compte », menu d'administration et chacune de ses pages) ;
rem - index.html : planche de toutes les captures ;
rem - report.md : problèmes détectés (texte coupé, débordement, liaisons…).
rem Données de démonstration : aucun serveur, aucune clé ni donnée réelle.
@@ -12,6 +12,7 @@ rem Exemples :
rem tools\ui-snapshot.bat
rem tools\ui-snapshot.bat --filter "main-*" --scale 2
rem tools\ui-snapshot.bat --filter "admin-page-*" --theme all --open
rem tools\ui-snapshot.bat --filter "account-*,main-account" (fenêtre « Compte » du joueur)
rem tools\ui-snapshot.bat --filter "main-*" --format webp --quality 85 (images du site vitrine)
rem tools\ui-snapshot.bat --list
rem ============================================================================