Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f125da4f60 | ||
|
|
0c6c686ceb | ||
|
|
f07ffa156a | ||
|
|
7885fe81e0 | ||
|
|
2596736ddb | ||
|
|
c99e21c351 | ||
|
|
dd8bd00091 | ||
|
|
edcb902be5 | ||
|
|
c00685eaec |
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<IPersonalDataProvider, …>()</c>) : l'export et la suppression du compte
|
||||
/// la couvrent alors automatiquement, sans modifier le module des comptes.
|
||||
/// </summary>
|
||||
public interface IPersonalDataProvider
|
||||
{
|
||||
/// <summary>Nom de la section dans l'export (« feedback »…), en minuscules.</summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>Données du joueur, lisibles par machine (null : rien pour ce compte).</summary>
|
||||
JsonNode? Export(PlayerAccountRecord account);
|
||||
|
||||
/// <summary>Efface (ou détache du compte) les données du joueur. Appelé AVANT la suppression du compte lui-même.</summary>
|
||||
void Erase(PlayerAccountRecord account);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export et effacement des données d'un compte joueur, à la demande du joueur ou par la politique des comptes inactifs
|
||||
/// (CNIL : mise en sommeil après <c>Players:InactiveDisableMonths</c> sans activité, suppression après
|
||||
/// <c>Players:InactiveDeleteMonths</c> de sommeil). L'export contient TOUT ce que le serveur sait du compte, y compris
|
||||
/// l'adresse e-mail complète ; jamais l'empreinte du mot de passe ni les secrets des sessions.
|
||||
/// </summary>
|
||||
public sealed class PlayerPersonalData
|
||||
{
|
||||
private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true };
|
||||
|
||||
private readonly PlayerStore _store;
|
||||
private readonly PlayerSecrets _secrets;
|
||||
private readonly PlayerSessionStore _sessions;
|
||||
private readonly IReadOnlyList<IPersonalDataProvider> _providers;
|
||||
private readonly AuditLog _audit;
|
||||
private readonly LauncherApiOptions.PlayersSettings _settings;
|
||||
|
||||
public PlayerPersonalData(PlayerStore store, PlayerSecrets secrets, PlayerSessionStore sessions, IEnumerable<IPersonalDataProvider> providers,
|
||||
AuditLog audit, IOptions<LauncherApiOptions> options)
|
||||
{
|
||||
_store = store;
|
||||
_secrets = secrets;
|
||||
_sessions = sessions;
|
||||
_providers = providers.ToList();
|
||||
_audit = audit;
|
||||
_settings = options.Value.Players;
|
||||
var duplicate = _providers.GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase).FirstOrDefault(g => g.Count() > 1);
|
||||
if (duplicate is not null) throw new InvalidOperationException($"Données personnelles : section « {duplicate.Key} » déclarée deux fois.");
|
||||
}
|
||||
|
||||
/// <summary>Document d'export (JSON indenté, UTF-8).</summary>
|
||||
public byte[] Export(PlayerAccountRecord account, string origin, DateTimeOffset now)
|
||||
{
|
||||
var devices = _store.Devices(account.Id);
|
||||
var document = new JsonObject
|
||||
{
|
||||
["format"] = PlayerDataExport.Format,
|
||||
["exportedAt"] = now,
|
||||
["service"] = origin,
|
||||
["account"] = new JsonObject
|
||||
{
|
||||
["reference"] = account.Reference,
|
||||
["email"] = _secrets.DecryptEmail(account.Id, account.EmailEncrypted),
|
||||
["emailVerified"] = account.EmailVerified,
|
||||
["createdAt"] = account.CreatedAt,
|
||||
["lastLoginAt"] = account.LastLoginAt,
|
||||
["passwordChangedAt"] = account.PasswordChangedAt,
|
||||
["termsVersionAccepted"] = account.TermsVersion,
|
||||
["status"] = account.Status,
|
||||
["disabledUntil"] = account.DisabledUntil,
|
||||
["twoFactorEnabled"] = account.OtpEnabled
|
||||
},
|
||||
["devices"] = new JsonArray(devices.Select(d => (JsonNode)new JsonObject
|
||||
{
|
||||
["label"] = d.Label,
|
||||
["deviceKeyId"] = d.KeyId,
|
||||
["connectedAt"] = d.CreatedAt,
|
||||
["lastUsedAt"] = d.LastUsedAt,
|
||||
["rememberedUntil"] = d.RefreshExpiresAt
|
||||
}).ToArray()),
|
||||
["activity"] = new JsonArray(_store.Events(account.Id, 1000).Select(e => (JsonNode)new JsonObject
|
||||
{
|
||||
["at"] = e.At,
|
||||
["type"] = e.Type,
|
||||
["description"] = PlayerActivityText.Describe(e.Type, e.Result == AuditResults.Ok),
|
||||
["result"] = e.Result,
|
||||
["ip"] = e.Ip,
|
||||
["deviceKeyId"] = e.DeviceKeyId,
|
||||
["details"] = e.Details
|
||||
}).ToArray())
|
||||
};
|
||||
|
||||
var modules = new JsonObject();
|
||||
foreach (var provider in _providers)
|
||||
{
|
||||
if (provider.Export(account) is { } data) modules[provider.Name] = data;
|
||||
}
|
||||
document["modules"] = modules;
|
||||
document["notes"] = new JsonArray(
|
||||
"Le mot de passe n'est jamais conservé : seule une empreinte irréversible existe sur le serveur, elle n'est pas exportée.",
|
||||
$"Activité : {_settings.EventsRetentionMonths} mois au plus ; adresses IP effacées au bout de {_settings.EventsIpRetentionMonths} mois.",
|
||||
"Les clés d'appareil privées ne quittent jamais votre PC : seul leur identifiant public figure ici.");
|
||||
return JsonSerializer.SerializeToUtf8Bytes(document, Indented);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supprime DÉFINITIVEMENT le compte : données des modules, appareils, journal du compte, sessions ouvertes. Une ligne
|
||||
/// du journal d'audit (référence seulement) garde la trace de la suppression.
|
||||
/// </summary>
|
||||
/// <param name="byPlayer">Vrai : à la demande du joueur ; faux : politique des comptes inactifs.</param>
|
||||
public bool Erase(PlayerAccountRecord account, bool byPlayer, string? ip)
|
||||
{
|
||||
foreach (var provider in _providers) provider.Erase(account);
|
||||
_sessions.RemoveWhere(s => s.AccountId == account.Id);
|
||||
if (!_store.DeleteAccount(account.Id)) return false;
|
||||
_sessions.RemoveWhere(s => s.AccountId == account.Id); // session ouverte pendant la suppression
|
||||
_audit.Write(byPlayer ? "players.self_delete" : "players.inactive_delete", AuditResults.Ok,
|
||||
actorName: byPlayer ? "joueur" : "système", ip: ip, target: account.Reference,
|
||||
details: byPlayer
|
||||
? "compte joueur supprimé définitivement à la demande de son titulaire"
|
||||
: $"compte joueur supprimé définitivement après {_settings.InactiveDeleteMonths} mois de sommeil (inactivité)");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Politique des comptes inactifs (au démarrage puis chaque jour) : mise en sommeil des comptes actifs sans activité
|
||||
/// depuis <c>InactiveDisableMonths</c>, suppression de ceux en sommeil depuis <c>InactiveDeleteMonths</c>.
|
||||
/// Renvoie (mis en sommeil, supprimés).
|
||||
/// </summary>
|
||||
public (int Dormant, int Deleted) ApplyInactivity(DateTimeOffset now)
|
||||
{
|
||||
if (_settings.InactiveDisableMonths <= 0) return (0, 0);
|
||||
var dormant = 0;
|
||||
foreach (var account in _store.FindInactive(now.AddMonths(-_settings.InactiveDisableMonths)))
|
||||
{
|
||||
if (!_store.MarkInactive(account.Id, now)) continue;
|
||||
_sessions.RemoveWhere(s => s.AccountId == account.Id);
|
||||
_store.AddEvent(account.Id, PlayerEvents.Inactive, AuditResults.Ok, null, null,
|
||||
$"aucune activité depuis {_settings.InactiveDisableMonths} mois : appareils déconnectés");
|
||||
dormant++;
|
||||
}
|
||||
|
||||
var deleted = 0;
|
||||
if (_settings.InactiveDeleteMonths > 0)
|
||||
{
|
||||
foreach (var account in _store.FindDormantSince(now.AddMonths(-_settings.InactiveDeleteMonths)))
|
||||
{
|
||||
if (Erase(account, byPlayer: false, ip: null)) deleted++;
|
||||
}
|
||||
}
|
||||
return (dormant, deleted);
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%\<Publisher>\GameLauncher).</summary>
|
||||
public const string DefaultDataFolderName = "GameLauncher";
|
||||
@@ -126,6 +126,13 @@ public sealed class LauncherCatalog
|
||||
/// </summary>
|
||||
public long KeyringMinSequence { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// « playerAccounts » : bouton « Compte » (comptes joueurs : connexion, création, « Mon compte »), avec l'API de
|
||||
/// <see cref="ApiUrl"/>. Faux par défaut : à activer quand les comptes sont ouverts sur le serveur. Comme apiUrl, le
|
||||
/// launcher publié ne le lit que dans le launcher.json intégré à l'exe.
|
||||
/// </summary>
|
||||
public bool PlayerAccounts { get; private set; }
|
||||
|
||||
/// <summary>Affichage modifié par le catalogue (voir Catalog.CatalogMerger).</summary>
|
||||
internal void ApplyDisplay(string launcherName, string? defaultGameId, IReadOnlyList<LauncherConfig> games)
|
||||
{
|
||||
@@ -317,7 +324,8 @@ public sealed class LauncherCatalog
|
||||
ApiUrlError = singleApiUrlError,
|
||||
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var singleCatalogError),
|
||||
CatalogUrlError = singleCatalogError,
|
||||
DataFolderName = ReadDataFolder(root)
|
||||
DataFolderName = ReadDataFolder(root),
|
||||
PlayerAccounts = ReadFlag(root, "playerAccounts")
|
||||
}.WithContentSigning(root);
|
||||
}
|
||||
|
||||
@@ -372,7 +380,8 @@ public sealed class LauncherCatalog
|
||||
ApiUrlError = apiUrlError,
|
||||
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var catalogError),
|
||||
CatalogUrlError = catalogError,
|
||||
DataFolderName = ReadDataFolder(root)
|
||||
DataFolderName = ReadDataFolder(root),
|
||||
PlayerAccounts = ReadFlag(root, "playerAccounts")
|
||||
}.WithContentSigning(root);
|
||||
}
|
||||
|
||||
@@ -406,6 +415,10 @@ public sealed class LauncherCatalog
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Booléen facultatif (faux si absent ou mal écrit).</summary>
|
||||
private static bool ReadFlag(JsonObject root, string name) =>
|
||||
Get(root, name) is JsonValue value && value.TryGetValue<bool>(out var flag) && flag;
|
||||
|
||||
/// <summary>« dataFolder » : nom simple de dossier (« GameLauncher » par défaut).</summary>
|
||||
private static string ReadDataFolder(JsonObject root)
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 ============================================================================
|
||||
|
||||
Reference in new issue
Block a user