Skip to content

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 sur Areas/Api.

Ces deux populations ont des stockages, schémas et flows séparés.

Back-office : Identity ASP.NET Core

Configuration (Startup.cs)

csharp
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

csharp
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)

csharp
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 :

  1. Reçoit email + password.
  2. AuthService valide :
    • 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.
  3. Si OK, génère JWT signé (JwtSecretKey config) + crée/met à jour CustomerRefreshToken persisté.
  4. 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éfautWordPressPasswordHasher.AllowLegacyMd5, piloté par Legacy:AllowWordPressMd5 (défaut false). 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 OldPasswordWp indéfiniment. Prévoir un job qui vide la colonne et envoie un lien de reset.
  • Bug connu (non corrigé, comportement inchangé) : VerifyPhpass reconstruit 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é DB

SSO 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_token qui 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.
  • TenantId et ClientId sont 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 PENDING uniquement. 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ôle PENDING, et RedirectPendingUserMiddleware le cloue sur /Pending tant 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 PENDING apparaî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 un if sur IsMicrosoftAccount dans Login.cshtml.cs empêchait la prise de contrôle de tous les comptes staff.
  • Pending.OnGetRefresh utilise RefreshSignInAsync, 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. acquireTokenSilent peut renvoyer un AuthenticationResult sans idToken ; FormData sérialise undefined en chaîne "undefined". backendAuthentication refuse donc un token falsy, et autoLogin rend 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.
  • JwtSecurityTokenHandler doit avoir MapInboundClaims = false. Sa table de correspondance par défaut renomme les claims courts en URI schemas.*tid devient http://schemas.microsoft.com/identity/claims/tenantid, email devient ClaimTypes.Email, etc. Lire FindFirstValue("tid") avec le mapping actif renvoie donc toujours null et 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_username n'est pas dans la table, email l'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 : demander User.Read ne servait qu'à transformer chaque renouvellement silencieux en réponse access-token-only, sans id_token.
  • _LayoutLogin.cshtml doit versionner ses assets (?v=@cacheBuster) comme _Layout.cshtml. C'est la page qui porte tout le code SSO : sans cache-buster, les navigateurs gardent le Horizon.js d'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.

TraceCe 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 keysLe conteneur atteint bien login.microsoftonline.com. Absente = dépendance sortante bloquée.
No id_token in the requestChamp vide ou mal nommé côté client.
Microsoft token comes from tenant X but Y is expectedtid ≠ 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 algorithmalg 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 XValidation OK ; ce qui suit est un problème de compte, pas de token.
no Horizon account, provisioning / existing Horizon accountQuelle branche d'AuthService a été prise.
account {Email} is deactivatedCompte trouvé mais Active = false.
Linking existing native account ... local password is removedPremiè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/Login avec 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 depuis TwoFactor: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.cs redirige déjà vers LoginWith2fa sur RequiresTwoFactor.
  • Manage/EnableAuthenticator — clé + QR (URI otpauth://, rendu client-side par qrcodejs) + vérif code → active 2FA + génère recovery codes (Manage/ShowRecoveryCodes). La lib qrcodejs est vendorée dans Frontend/Custom/assets/js/vendor/qrcode/qrcode.min.js et concaténée dans Horizon.js par le gulp Custom (⚠️ wwwroot/assets est gitignored/régénéré en CI — ne PAS y déposer de fichier à la main). Horizon.js étant chargé après la section Scripts, 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 page Disable2fa n'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 font document.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 branche else de @if (authUser.IsMicrosoftAccount). Résultat : pour un utilisateur connecté en SSO Microsoft, aucun token dans le layout → querySelector(...) renvoie nullTypeError: 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.cshtml n'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 /.dev et /.tools (Startup.cs:300 et :311DevPipeline / ToolsPipeline) : app.Map crée une branche qui ne traverse pas les UseAuthentication() / UseAuthorization() du pipeline principal (déclarés plus bas, :320-321). Les contrôleurs y restent couverts par le filtre global AuthorizeFilter("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 dans ToolsPipeline (juil. 2026) avec [Authorize(Policy = "DefaultPolicy", Roles = Roles.ADMINISTRATOR)] sur MiscController : 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. ⚠ /.tools est 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 par ITwoFactorPolicyService).
  • 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.PaymentType n'a rien à voir avec l'auth (c'est une donnée métier polluée — cf. business-rules).

Contributors

No contributors

Changelog

No recent changes