Identity & Auth
Deux populations distinctes
User= utilisateur back-office Buchard (commercial, comptable, exploitation, direction). Authentifié par cookie ASP.NET Identity sur Razor Pages.Customer= client final qui réserve via le site web ou l'app mobile. Authentifié via JWT Bearer surAreas/Api.
Ces deux populations ont des stockages, schémas et flows séparés.
Back-office : Identity ASP.NET Core
Configuration (Startup.cs)
services.AddDefaultIdentity<User>(options =>
{
options.SignIn.RequireConfirmedAccount = false;
options.SignIn.RequireConfirmedEmail = false;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequiredLength = 12;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddRoles<Role>()
.AddUserStore<UserStore>()
.AddRoleStore<RoleStore>()
.AddUserManager<UserManager>()
.AddRoleManager<RoleManager>()
.AddSignInManager<ApplicationSignInManager<User>>()
.AddDefaultTokenProviders();Stores custom
Domain/Stores/UserStore.cs,RoleStore.cs: implémentations custom (à explorer si besoin).Application/Library/ApplicationSignInManager.cs: SignInManager surchargé.
Policy
services.AddAuthorization(options =>
{
options.AddPolicy("DefaultPolicy", policy => {
policy.AuthenticationSchemes.Add(IdentityConstants.ApplicationScheme);
policy.RequireAuthenticatedUser();
});
options.AddPolicy("Api", policy => {
policy.AuthenticationSchemes.Add(JwtBearerDefaults.AuthenticationScheme);
policy.RequireAuthenticatedUser();
});
});
services.AddRazorPages(options => {
options.Conventions.AuthorizeFolder("/", "DefaultPolicy");
});Toutes les Razor pages du site sont protégées par DefaultPolicy (cookie). Login : /Identity/Account/Login. Forbidden redirect custom vers /Forbidden.
Roles
Domain/Roles.cs définit les rôles applicatifs (à valider en interview module Identity).
API : JWT Bearer pour clients web/mobile
Configuration (Startup.cs)
services.AddAuthentication(options => {
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options => {
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_configuration["JwtSecretKey"])),
ValidateIssuer = false,
ValidateAudience = false,
};
});Login client
Endpoint Areas/Api/LoginController :
- Reçoit email + password.
AuthServicevalide :- D'abord avec BCrypt (
BCrypt.Net.BCrypt.Verify) pour les comptes natifs. - Sinon fallback WordPress hash (
Library/WordPressPasswordHasher) pour les comptes migrés depuis l'ancien site.
- D'abord avec BCrypt (
- Si OK, génère JWT signé (
JwtSecretKeyconfig) + crée/met à jourCustomerRefreshTokenpersisté. - Renvoie
{ token, refreshToken, customerId }.
Refresh token
CustomerRefreshToken entité :
- Lié à un
Customer. - Permet de renouveler le JWT sans re-login.
- Stocké hashé (?) en base — à confirmer.
Hash legacy WordPress
Les clients migrés depuis l'ancien site WordPress ont leur password hashé en format WP. Le fallback WordPressPasswordHasher les valide au login, puis LoginController re-hashe en PBKDF2 Identity et vide OldPasswordWp (confirmé dans le code : la migration au premier login réussi est bien faite).
Formats acceptés : $wp$ (bcrypt sur HMAC-SHA384), bcrypt nu ($2y$/$2a$/$2b$), phpass ($P$/$H$, MD5 itéré).
- MD5 nu (hash de 32 caractères) est refusé par défaut —
WordPressPasswordHasher.AllowLegacyMd5, piloté parLegacy:AllowWordPressMd5(défautfalse). Un tel hash est cassable en quelques secondes depuis une fuite de base ou de backup, et l'accepter maintenait ce credential faible valide indéfiniment. Les clients concernés doivent passer par « mot de passe oublié ». Ne réactiver que temporairement, avec une date de fin. - Les comparaisons de hash utilisent
CryptographicOperations.FixedTimeEquals(une comparaison de chaînes révèle le nombre de caractères initiaux corrects). - Reste à faire : la migration n'a pas de date butoir et ne se déclenche qu'au login, donc un compte dormant conserve son
OldPasswordWpindéfiniment. Prévoir un job qui vide la colonne et envoie un lien de reset. - Bug connu (non corrigé, comportement inchangé) :
VerifyPhpassreconstruit le hash avec le préfixe$P$en dur, donc un hash stocké en$H$ne peut pas être vérifié.
Customer auth : flow complet
1. Site web POST /api/Login {email, password}
2. LoginController → AuthService.LoginAsync
3. BCrypt.Verify ; si false → WordPressPasswordHasher.Verify
4. Si OK : génération JWT (TTL court) + CustomerRefreshToken (TTL long)
5. Site stocke localement
6. Requêtes API avec Bearer JWT
7. Quand JWT expire → POST /api/Login/Refresh avec le refreshToken → nouveau JWT
8. Logout → invalide le refreshToken côté DBSSO Microsoft Entra ID (back-office)
Implémenté (la version précédente de cette doc affirmait le contraire — c'était faux). Il n'y a pas de configuration dans Startup.cs parce que le flux est piloté côté client par MSAL et validé à la main dans le handler de login.
Configuration : section Sso:Microsoft (ClientId, TenantId, Authority, RedirectUri, PostLogoutRedirectUri). Login.cshtml les injecte dans MSAL ; TenantId est requis côté serveur pour la validation (à défaut il est déduit du dernier segment de Authority).
Flux :
1. MSAL (MicrosoftLogin.js) → loginRedirect, scopes openid/profile/User.Read
2. JS POST /Identity/Account/Login/Microsoft { idToken, __RequestVerificationToken }
3. MicrosoftIdTokenValidator valide l'id_token contre le JWKS du tenant :
signature RS*, iss = login.microsoftonline.com/{tenant}/v2.0, aud = ClientId,
claim tid = TenantId, lifetime (skew 2 min)
4. AuthService.MicrosoftSignInOrRegister → compte EXISTANT uniquement
5. SignInManager.SignInAsync (aucun mot de passe vérifié)Règles à ne pas casser :
- C'est l'
id_tokenqui est validé, jamais l'access_token. L'audience d'un access token Graph est Graph, pas Horizon : « Graph l'a accepté » ne prouve rien sur le destinataire. Valider un access token n'a pas de sens ici. TenantIdetClientIdsont obligatoires. Sans eux le validateur refuse toute connexion (fail closed) au lieu de laisser passer. Les autorités multi-tenant (common,organizations,consumers) sont refusées : Horizon est mono-organisation.- Auto-provisioning en
PENDINGuniquement. Un UPN inconnu du tenant Buchard obtient bien un compte Horizon — c'est le parcours métier voulu : le compte est créé, puis un admin lui assigne un rôle. Trois garde-fous : le compte est créé sans mot de passe (CreateAsync(user)sans credential), il ne reçoit que le rôlePENDING, etRedirectPendingUserMiddlewarele cloue sur/Pendingtant qu'aucun autre rôle ne lui est donné. Ce qui rendait l'ancienne version dangereuse n'était pas la création automatique mais ses deux compagnons : un access token Graph pris pour une preuve d'identité, et un mot de passe local dérivé et devinable. - ⚠️ Personne n'est notifié quand un compte
PENDINGapparaît : il attend que quelqu'un ouvre la liste des utilisateurs. À traiter si le volume le justifie. - Les comptes SSO n'ont pas de mot de passe local. Le lien d'un compte natif vers Microsoft supprime son
PasswordHash(RemovePasswordAsync) au lieu de le remplacer. Ne jamais réintroduire de mot de passe dérivé de l'e-mail : c'était le cas avant (préfixe constant committé), et seul unifsurIsMicrosoftAccountdansLogin.cshtml.csempêchait la prise de contrôle de tous les comptes staff. Pending.OnGetRefreshutiliseRefreshSignInAsync, pas un re-login par mot de passe.- Le POST Microsoft est protégé par antiforgery comme n'importe quelle requête mutante (le middleware qui forgeait un token valide pour cet endpoint a été supprimé).
- Ne jamais poster un id_token vide.
acquireTokenSilentpeut renvoyer unAuthenticationResultsansidToken;FormDatasérialiseundefineden chaîne"undefined".backendAuthenticationrefuse donc un token falsy, etautoLoginrend la main au formulaire (login interactif) plutôt que d'envoyer du vide — sinon le serveur logue en boucle « Missing / Invalid Microsoft token » sans que personne ne comprenne pourquoi. JwtSecurityTokenHandlerdoit avoirMapInboundClaims = false. Sa table de correspondance par défaut renomme les claims courts en URIschemas.*—tiddevienthttp://schemas.microsoft.com/identity/claims/tenantid,emaildevientClaimTypes.Email, etc. LireFindFirstValue("tid")avec le mapping actif renvoie donc toujoursnullet le contrôle de tenant rejette tous les tokens (« comes from tenant none »). Les claims se lisent sous leur nom brut :tid,preferred_username,upn,email,given_name,family_name,name. Ne pas mélanger les deux conventions :preferred_usernamen'est pas dans la table,emaill'est — un code qui marche pour l'un échoue silencieusement pour l'autre.- Scopes MSAL =
["openid", "profile"]uniquement. Depuis que le backend valide l'id_token lui-même, plus rien n'appelle Microsoft Graph : demanderUser.Readne servait qu'à transformer chaque renouvellement silencieux en réponse access-token-only, sans id_token. _LayoutLogin.cshtmldoit versionner ses assets (?v=@cacheBuster) comme_Layout.cshtml. C'est la page qui porte tout le code SSO : sans cache-buster, les navigateurs gardent leHorizon.jsd'avant le déploiement et continuent de poster l'ancien contrat (accessToken, sans jeton antiforgery) — rejeté en 400 avant le handler, donc invisible dans les logs applicatifs.
Diagnostiquer une panne SSO
Traces serveur (Serilog, MinimumLevel.Information — tout ce qui suit sort en prod). Aucune ne contient de token : uniquement présence, longueur, alg, tid, claims. Elles contiennent en revanche des UPN/e-mails de collaborateurs.
| Trace | Ce qu'elle dit |
|---|---|
Microsoft sign-in attempt: fields [...] | Les noms des champs postés. accessToken = le navigateur tourne encore sur le bundle d'avant août (cache) ; idToken = contrat courant. Donne aussi la longueur du token, le scheme (vérifie UseForwardedHeaders) et l'User-Agent. |
Microsoft OIDC metadata resolved ... N signing keys | Le conteneur atteint bien login.microsoftonline.com. Absente = dépendance sortante bloquée. |
No id_token in the request | Champ vide ou mal nommé côté client. |
Microsoft token comes from tenant X but Y is expected | tid ≠ tenant configuré, avec les deux valeurs. tenant none = le claim n'a pas été trouvé, pas un token étranger : signature, issuer, audience et durée de vie sont déjà validés à ce stade. Vérifier MapInboundClaims avant de suspecter la configuration. |
token signed with an unexpected algorithm | alg non-RS*. |
carries no usable principal name. Claims present: ... | Ni preferred_username, ni upn, ni email — la liste des claims reçus permet de voir ce qu'Entra a envoyé. |
Microsoft id_token validated for X | Validation OK ; ce qui suit est un problème de compte, pas de token. |
no Horizon account, provisioning / existing Horizon account | Quelle branche d'AuthService a été prise. |
account {Email} is deactivated | Compte trouvé mais Active = false. |
Linking existing native account ... local password is removed | Première connexion SSO d'un compte natif. |
Côté navigateur, console préfixée [SSO] : envoi du token, réponse de handleRedirectPromise, renouvellement silencieux sans id_token, et le corps du 400 renvoyé par le serveur (backendAuthentication le remonte au lieu de le jeter).
Cookies
ConfigureApplicationCookie :
OnRedirectToAccessDenied→ custom redirect vers/Forbidden.OnRedirectToLogin(en prod uniquement) → redirect explicite vers/Identity/Account/Loginavec le domaine courant.
2FA TOTP back-office (depuis juin 2026)
Authentification à deux facteurs par application TOTP (Google Authenticator, Proton, etc.) pour les utilisateurs back-office natifs. Repose sur l'infra native ASP.NET Core Identity déjà en place (IdentityUser<Guid>.TwoFactorEnabled, AddDefaultTokenProviders(), tokens dans AspNetUserTokens) — pas de table custom.
Entité
User : un seul champ ajouté — TwoFactorEnabledAt (DateTime?, audit). TwoFactorEnabled vient de la base Identity. Clé authenticator + recovery codes = tokens Identity. Pas de colonne deadline : la date butoir est une date fixe en config, pas du par-utilisateur.
Politique (logique testable)
Application/Services/TwoFactorPolicyService.cs (ITwoFactorPolicyService, singleton, lit IConfiguration) :
SetupDeadline= date fixe parsée depuisTwoFactor:SetupDeadline(appsettings,yyyy-MM-dd, UTC). Null si non configurée → feature dormante.IsSubjectToTwoFactor(user)= natif uniquement (!IsMicrosoftAccount).Evaluate(user, nowUtc)→Allowed | BeforeDeadline | SetupRequired. Source unique de vérité, partagée par le middleware et la bannière.
Enforcement
Application/Middlewares/Enforce2faMiddleware.cs, enregistré dans Startup.cs juste après RedirectPendingUserMiddleware (chaîne non-API). Laisse passer /Identity/*. Sinon charge l'user (UserManager.GetUserAsync) et, si Evaluate == SetupRequired, force /Identity/Account/Manage/EnableAuthenticator. Bannière de rappel dans Pages/Shared/_Layout.cshtml quand BeforeDeadline (affiche TwoFactorPolicy.SetupDeadline).
Pages (Areas/Identity/Pages/Account)
LoginWith2fa+LoginWithRecoveryCode([AllowAnonymous]) — 2e facteur au login.Login.cshtml.csredirige déjà versLoginWith2fasurRequiresTwoFactor.Manage/EnableAuthenticator— clé + QR (URIotpauth://, rendu client-side parqrcodejs) + vérif code → active 2FA + génère recovery codes (Manage/ShowRecoveryCodes). La libqrcodejsest vendorée dansFrontend/Custom/assets/js/vendor/qrcode/qrcode.min.jset concaténée dansHorizon.jspar le gulp Custom (⚠️wwwroot/assetsest gitignored/régénéré en CI — ne PAS y déposer de fichier à la main).Horizon.jsétant chargé après la sectionScripts, l'init QR est différée via$(document).ready.Manage/TwoFactorAuthentication(hub),Manage/ResetAuthenticator,Manage/GenerateRecoveryCodes. Toutes[Authorize(Policy = "DefaultPolicy")](cookie), layout_LayoutLogin. Pas de désactivation : le 2FA étant obligatoire, aucune pageDisable2fan'existe (un utilisateur peut seulement réinitialiser son authenticator ou régénérer ses codes).
Reset admin
IUserService.ResetTwoFactorAsync(id) (bouton dans la modale d'édition utilisateur Pages/Users/Form.cshtml → handler Users/Index.OnPostResetTwoFactor, AJAX + antiforgery). Désactive 2FA + ResetAuthenticatorKeyAsync. La date butoir fixe s'applique ensuite (reforcé si dépassée). Réservé à la page Users ([Authorize(Roles = "Administrator")]).
Pièges
- Token antiforgery global dans
_Layout.cshtml(ajouté août 2026, juste après<body>) : une vingtaine d'appels JS/Vue fontdocument.querySelector('input[name="__RequestVerificationToken"]').value(notes, booking, passenger-seats, travel-design, cancel-booking/occurrence, deck-configurator, activity-costs, occurrence-assign-resources…). Ce champ n'existait qu'incidemment, via le<form method="post">de déconnexion du header — lequel n'est rendu que dans la brancheelsede@if (authUser.IsMicrosoftAccount). Résultat : pour un utilisateur connecté en SSO Microsoft, aucun token dans le layout →querySelector(...)renvoienull→TypeError: Cannot read properties of null (reading 'value')sur toute page sans formulaire POST propre (symptôme observé : le tiroir Notes ne se charge plus). Ne pas retirer ce@Html.AntiForgeryToken(). Avoir deux champs sur une page (layout + formulaire) est sans conséquence : tous les tokens d'une même requête sont valides._LayoutExternal.cshtmln'en a pas — vérifié, aucun de ses deux consommateurs (Accommodations/Occupancy,Resources/Lists) n'en a besoin. - Customer ≠ User : ne PAS confondre les deux quand on bosse sur l'auth. Un dev qui ajoute
[Authorize]sans préciser la policy bloque les API web/mobile. - 2FA — pages Identity et policy cookie : les pages
Manage/*du 2FA portent[Authorize(Policy = "DefaultPolicy")]explicite. Un[Authorize]nu utiliserait le scheme par défaut (JWT) et casserait l'accès cookie. - Pipelines branchés
/.devet/.tools(Startup.cs:300et:311→DevPipeline/ToolsPipeline) :app.Mapcrée une branche qui ne traverse pas lesUseAuthentication()/UseAuthorization()du pipeline principal (déclarés plus bas,:320-321). Les contrôleurs y restent couverts par le filtre globalAuthorizeFilter("DefaultPolicy")(:109, qui s'authentifie lui-même en cookie), mais tout[Authorize]posé sur un contrôleur de ces branches exige que la branche ait ses propres middlewares d'auth — sinon l'endpoint porte des métadonnées d'autorisation sans middleware pour les traiter. Ajoutés dansToolsPipeline(juil. 2026) avec[Authorize(Policy = "DefaultPolicy", Roles = Roles.ADMINISTRATOR)]surMiscController: la policy est obligatoire en plus du rôle, sans elle le scheme par défaut (JWT) ignore le cookie back-office et l'admin se prend un 401. ⚠/.toolsest mappé dans tous les environnements (contrairement à/.dev, dev only) — c'est volontaire, il sert à lancer des process one-shot en prod. - 2FA — comptes Microsoft exemptés : ne jamais forcer le 2FA applicatif sur
IsMicrosoftAccount(toujours passer parITwoFactorPolicyService). - JwtSecretKey est en
appsettings.json(ne pas committer en clair !). - Les rôles back-office sont gérés via
Role+UserRole, pas via JWT claims. - Le
Customer.PaymentTypen'a rien à voir avec l'auth (c'est une donnée métier polluée — cf. business-rules).

