Aller au contenu

Story AUTH-002: Operator login (password-only ROPC) + JWT filter + tenant-status guard

Module: identity Slice: S1 reduced (arch §11) — V0 envelope AUTH-001 (arch §0.1, "Flow opérateur ROPC uniquement") Side: [OPERATOR] Version target: V0 — v0.0.0 Priority: 2 Depends on: AUTH-001 Can develop concurrently with: AUTH-003, AUTH-005 Merge order: After AUTH-001 Estimated complexity: M PRD User Stories: AUTH-OP-02 (login portion only — MFA portion is V1), AUTH-OP-08/09 (role on token) Wireframe: N/A — no prototype generated (design ref: docs/design/identity-design.md §5.1 / §7.2)


Objective

Let an operator sign in to the back-office with username + password and receive a tenant-scoped JWT. In v0.0.0 there is no MFA and no remember-device (arch §0.1 locks operator login to ROPC only; MFA is deferred to V1). This story also completes the JWT filter chain — building the TenantContext from claims and reading the tenant-status guard — so every later operator endpoint is protected.


Backend Scope

Entities

Reads/writes users (state, password_failures_counter). No new tables.

Service Layer

  • KeycloakAdminClient.directGrant(username, password) implementation — calls Keycloak POST /realms/papillon-platform/protocol/openid-connect/token with grant_type=password via the confidential papillon-backend client (arch §10.1, ADR-AUTH-04). The frontend never calls Keycloak directly.
  • Login handler: validate password via Keycloak; on success build the session and return the access + refresh tokens. v0.0.0: no OTP branch, no remember-device cookie — a valid password lands the operator straight on the dashboard.
  • Account-state gate before/around password check:
  • PENDING_ACTIVATION → reject (account not activated).
  • DEACTIVATED → reject.
  • LOCKED (5 consecutive password failures, R-OP-013) → reject; admin unlock required.
  • password_failures_counter increments on bad password; locks the account at 5 (R-OP-013).
  • Full JwtValidationFilter chain (arch §7.3): extract Bearer → validate signature (JWKS cached) → check exp + 30 s skew → extract papillon_role → if PLATFORM_ADMIN + null tenant_id set TenantContext.platformAdmin() (sentinel logic itself is AUTH-009/PA but the branch must exist) → else extract tenant_id → read TenantStatusCache → build TenantContext(tenantId, agencyScope, countryCode, role) → set SecurityContextHolder.
  • Tenant-status guard read-path (arch §4.4 / §7.3): GET auth:tenant-status:{tenantId}. In v0.0.0 the only value written is ACTIVE (the suspension/reactivation cascade that writes SUSPENDED/DELETED is V1, AUTH-ADM-05/06). On cache miss, read platform DB and repopulate (TTL 30 s). The guard branch logic (SUSPENDED→403/503, DELETED→410, PENDING→503) must be coded now even though only ACTIVE occurs in v0.0.0.

API Endpoints

Method Path Request Response Auth
POST /api/auth/operator/login {username, password} (no deviceToken in v0.0.0) 200 {mfaRequired:false, accessToken, refreshToken, expiresIn:300, user:{userId, displayName, role, tenantId, agencyScope}} / 401 generic none
POST /api/auth/operator/logout 204 Bearer
POST /api/auth/operator/refresh {refreshToken} 200 new tokens none
GET /api/auth/operator/me 200 {userId, displayName, role, tenantId, agencyScope} Bearer

Note: the V1 response shape includes mfaRequired and an MFA branch; in v0.0.0 mfaRequired is always false. Keep the field for forward-compat.

Validation Rules

  • username non-empty; password non-empty.
  • On bad credentials return the SAME generic error whether the username exists or not (anti-enumeration): auth.operator.login.bad_credentials = "Identifiant ou mot de passe incorrect." (R-OP-001).
  • Access-token TTL 300 s (5 min), refresh 30 min, absolute session 8 h (arch §7.2).

Multi-Tenant Considerations

The JWT carries tenant_id, agency_scope, country_code, papillon_role as custom claims. Every protected request resolves these into TenantContext. AUTH itself queries only the platform datasource (arch §4.2); country_code is propagated through TenantContext for downstream per-country routing. Both V1 DB profiles are unaffected (auth stays on platform DB).

Audit & Logging

  • operator_login_attempt — fields: username, success(bool), failure_reason (BAD_PASSWORD | LOCKED | INACTIVE | DEACTIVATED), mfa_factor (PASSWORD_ONLY in v0.0.0), ip, user_agent.
  • operator_session_starteduser_id, session_id.
  • operator_session_explicit_logoutuser_id, session_id.

Frontend Scope

Pages & Routes

  • Route /login (étape A only — no MFA étape B in v0.0.0). Operator console. Design §5.1.

Components

  • Card (centred, max 420 px), FormField, Input (identifiant), Input type=password with native eye-toggle, Button primary fullWidth.
  • "Mot de passe oublié ?" ghost link is hidden in v0.0.0 (password reset is V1; reset is handled by an OPS runbook in V0 — design decision D-v0.0.0-2).

UI States (ALL REQUIRED)

State Behavior French Copy
Loading « Se connecter » button in loading state (inline DS spinner)
Empty Blank form
Error Helper text under the card; focus returns to the identifiant field « Identifiant ou mot de passe incorrect. » (generic). For specific account states: « Compte non activé. Cliquez sur le lien d'invitation reçu. » / « Compte désactivé. Contactez votre administrateur. » / « Compte verrouillé après plusieurs tentatives. Contactez votre administrateur. »
Offline / low network Persistent toast; button stays clickable (server decides) « Connexion lente. Vérifiez votre réseau. »
Success In v0.0.0 (no MFA) go straight to the dashboard (no toast)

Responsive Behavior

  • Desktop-first. 768 / 1024 / 1440: card centred, max 420 px (design §8.2). Login layout does not change across breakpoints.

Interactions

  • Password eye-toggle (native). On error, focus returns to the identifiant field.

Acceptance Criteria

AC-001: Valid password signs the operator in (no MFA in v0.0.0)
Given an ACTIVE operator with a valid username and password
When they submit the login form
Then Keycloak validates the password via Direct Grant
And a tenant-scoped JWT (access + refresh) is returned with mfaRequired:false
And the operator lands on the dashboard
And operator_login_attempt (success=true, mfa_factor=PASSWORD_ONLY) and operator_session_started are audit-logged
AC-002: Bad password returns a generic error and increments the failure counter
Given any login form submission with a wrong password
When the operator submits
Then the response is 401 with the generic copy "Identifiant ou mot de passe incorrect." (no account-existence leak)
And password_failures_counter is incremented
And after 5 consecutive failures the account moves to LOCKED (admin unlock required)
And operator_login_attempt (success=false, failure_reason=BAD_PASSWORD) is audit-logged
AC-003: Non-active accounts are rejected with the correct copy
Given a user in PENDING_ACTIVATION
When they submit valid credentials
Then login is rejected with "Compte non activé. Cliquez sur le lien d'invitation reçu."
And given a DEACTIVATED user, login is rejected with "Compte désactivé. Contactez votre administrateur."
AC-004: Protected endpoints require a valid JWT and build TenantContext
Given a valid access token
When GET /api/auth/operator/me is called
Then the response is 200 with the user's role, tenantId and agencyScope
And given a missing or invalid token the response is 401

Compliance Rules

  • RBAC opérateur + traçabilité = VÉRIFIÉ (CIMA art. 41 + Décision ARTCI 2023-0877). The login event must be audit-logged.
  • All authentication strings are in French, fr-FR (AUTH-NFR-07).

Standards & Conventions

  • docs/standards/java-spring-guidelines.md, docs/standards/api-guidelines.md (response envelope {data} / {error:{code,message}}).
  • docs/standards/react-ts-guidelines.md, docs/standards/design-system.md (Card, Input, Button).
  • docs/standards/multi-tenant-model.md (role enum, claim mappers).
  • docs/standards/critical-rules.md (tenant isolation).

Testing Requirements

Unit Tests

  • Generic error returned for both unknown user and wrong password (anti-enumeration).
  • Failure counter increments; account locks at 5.
  • PENDING_ACTIVATION / DEACTIVATED / LOCKED states each rejected with the correct copy.

Integration Tests

  • Direct Grant against Testcontainers Keycloak: valid password → JWT; the filter builds TenantContext from claims.
  • Tenant isolation: a JWT for tenant A cannot read /me data belonging to tenant B.
  • /me returns 401 without a token; 200 with a valid token.

What QA Will Validate

  • Login works with password only (no OTP step appears in v0.0.0); the forgot-password link is hidden; error copy is exact French; locked account behaviour after 5 failures.

Out of Scope

  • SMS OTP / MFA, WhatsApp fallback, remember-device cookie — V1 (AUTH-OP-02 MFA portion, AUTH-OP-03). v0.0.0 is ROPC password-only.
  • Step-up MFA, bypass codes, session idle/absolute timeout enforcement — V1.
  • Password reset / forgot-password flow — V1 (OPS runbook in V0).
  • Writing SUSPENDED/DELETED/PENDING into the tenant-status cache — that comes from the lifecycle cascade in V1 (AUTH-ADM-05/06). The guard branch logic is coded but only ACTIVE occurs in v0.0.0.

Definition of Done

  • [ ] Login returns a tenant-scoped JWT on valid password (no MFA step in v0.0.0)
  • [ ] Generic anti-enumeration error on bad credentials; counter locks at 5
  • [ ] PENDING_ACTIVATION / DEACTIVATED / LOCKED rejected with exact French copy
  • [ ] JWT filter builds TenantContext and protects /me, /logout, /refresh
  • [ ] Login page renders at 768 / 1024 / 1440 px; forgot-password link hidden
  • [ ] All five UI states implemented with exact French copy
  • [ ] Audit entries: operator_login_attempt, operator_session_started, operator_session_explicit_logout
  • [ ] Unit + integration tests written and passing
  • [ ] Tenant isolation verified (tenant_id scoping; both V1 DB profiles)
  • [ ] No silent failures on network/Keycloak errors (Keycloak down → 503, never skip auth)