Story AUTH-011: Challenge attempt-limit, 30-min lockout, and 3-cycle permanent revoke¶
Module: identity Slice: S5 (arch §11) — V0 envelope AUTH-006 (arch §0.1, attempt-limit applied at v0.1.0) Side: [BACKEND] Version target: V0 — v0.1.0 Priority: 11 Depends on: AUTH-007 (challenge), AUTH-005 (signed-link state) Can develop concurrently with: AUTH-010, AUTH-012 Merge order: After AUTH-007 Estimated complexity: M PRD User Stories: AUTH-CP-04 (customer lockout cycle) Wireframe: N/A — backend only (the lockout / permanently-revoked customer screens are served by the CP module; AUTH provides the states + French copy)
Objective¶
Through v0.0.2–v0.0.5 the customer challenge counts attempts but never locks (deliberate, arch §0.2). v0.1.0 turns the limit on: after 5 wrong attempts the link locks for 30 minutes; after 3 such lockout cycles the link is permanently revoked, the file flips to an anomaly, and the operator is notified. This is the brute-force protection for the customer auth path, working together with the Redis rate-limit (AUTH-012).
Backend Scope¶
Entities¶
Updates signed_links (attempt_counter, cycle_counter, lockout_until, state, version). Appends to customer_auth_attempts.
Service Layer¶
- Enforce the attempt limit in
ChallengeVerificationService.submitChallenge(R-CP-010/011/012/013): - On the 5th consecutive wrong attempt: set
lockout_until = now() + 30 min,cycle_counter++, stateLOCKED; append a LOCKOUT_TRIGGERED attempt row; auditcustomer_auth_lockout_started. - During the lockout window: a click/submit consumes no attempt; return "réessayez dans X minutes" (R-CP-011).
- On the first submit after the lockout window expires: if
cycle_counter < 3, resetattempt_counterto 0/5 and re-present the challenge (auditcustomer_auth_lockout_resumed); else proceed to permanent revoke. - On
cycle_counter == 3(15 cumulative failures): set statePERMANENTLY_REVOKED, flip the file to anomalyAUTH_PERMANENTLY_LOCKED, notify the operator (file timeline + anomaly view), auditAUTH_LINK_PERMANENTLY_REVOKED(reason=MAX_LOCKOUT_CYCLES). - Optimistic concurrency on counter updates (arch §5.1, ADR-AUTH-03):
UPDATE ... SET attempt_counter = attempt_counter + 1, version = version + 1 WHERE link_id = :id AND version = :expected. On 0 rows updated → re-read and retry. This guarantees concurrent multi-device submits never double-increment (exactly one lockout is triggered). The Postgres row is the system of record; no Redis-fronted counters in V1.
API Endpoints¶
None new. Reached via the CP boundary (POST /api/portail/{token}/challenge).
Validation Rules¶
- 5 attempts per cycle; 30-min lockout; max 3 cycles → permanent revoke (AUTH-NFR-03).
- Counters are authoritative server-side; a page reload during lockout shows a refreshed countdown (R-CP-002).
Multi-Tenant Considerations¶
All counters live on the tenant-scoped signed_links row on the platform datasource. The operator anomaly notification is routed to the link's own tenant/agency.
Audit & Logging¶
customer_auth_lockout_started—campaign_id,file_id,cycle_index.customer_auth_lockout_resumed—campaign_id,file_id,cycle_index.AUTH_LINK_PERMANENTLY_REVOKED—campaign_id,file_id,reason(MAX_LOCKOUT_CYCLES).
Frontend Scope¶
N/A — backend only. The lockout and permanently-revoked screens are CP stories; AUTH supplies the states and the French copy below.
French copy provided to CP (design §4.3 / §4.5 / §10.1)¶
- Lockout titre: « Trop de tentatives »
- Lockout corps: « Pour des raisons de sécurité, l'accès est mis en pause. Réessayez dans {dureeRestante}. »
- Lockout sortie: « En cas de doute, appelez votre conseiller : 📞 {agencyPhone} »
- Last-chance warning at 4/5 (design §10.1): « ⚠️ Encore une erreur et l'accès sera bloqué 30 minutes. »
- Permanently revoked (design §4.5): « Pour des raisons de sécurité, ce lien a été désactivé. Contactez votre conseiller : 📞 {agencyPhone} »
UI States (ALL REQUIRED)¶
N/A — backend only.
Acceptance Criteria¶
AC-001: 5th wrong attempt triggers a 30-min lockout
Given a customer has failed 4 challenge attempts
When the 5th attempt is also wrong
Then the link enters a 30-min lockout (state LOCKED, lockout_until = now + 30 min, cycle_counter incremented)
And the customer sees "Trop de tentatives ... Réessayez dans {dureeRestante}."
And customer_auth_lockout_started is audit-logged
AC-002: After the lockout window the counter resets and the cycle increments
Given a link in lockout whose 30-min window has elapsed and cycle_counter < 3
When the customer returns
Then attempt_counter resets to 0/5, the challenge is re-presented
And customer_auth_lockout_resumed is audit-logged
AC-003: Three lockout cycles permanently revoke the link
Given the lockout-cycle counter reaches 3 (15 cumulative failures)
When the threshold is crossed
Then the link state becomes PERMANENTLY_REVOKED, the file flips to anomaly AUTH_PERMANENTLY_LOCKED, and the operator is notified
And the customer sees "Pour des raisons de sécurité, ce lien a été désactivé. Contactez votre conseiller ..."
And AUTH_LINK_PERMANENTLY_REVOKED(reason=MAX_LOCKOUT_CYCLES) is audit-logged
AC-004: Concurrent multi-device submissions do not double-increment
Given a link with attempt_counter = 4
When two devices submit a wrong value at the same time
Then optimistic concurrency ensures the counter ends at exactly 5 and exactly one lockout is triggered
Compliance Rules¶
- CIMA 001-2024 art. 26 single-use link = VÉRIFIÉ; attempt-limit strengthens the security posture.
- [LEGAL-REVIEW]: authentication mechanism, not proof of consent (red flag #5 unchanged).
Standards & Conventions¶
docs/standards/java-spring-guidelines.md(optimistic locking via version column).docs/standards/critical-rules.md(tenant isolation; HMAC-only attempt values).docs/standards/api-guidelines.md.
Testing Requirements¶
Unit Tests¶
- 5th wrong → lockout; window-expiry reset (cycle<3); cycle==3 → permanent revoke.
- During lockout, a submit consumes no attempt.
Integration Tests¶
- Optimistic concurrency test (arch §13.4): two concurrent wrong submits on attempt_counter=4 → final counter exactly 5, state LOCKED, exactly one lockout event.
- Permanent revoke flips the file to anomaly and notifies the operator.
- Reload during lockout shows a refreshed (server-driven) countdown.
What QA Will Validate¶
- The 5/30-min/3-cycle behaviour matches the rules; permanent revoke notifies the operator; concurrent devices cannot bypass the limit; all customer copy is exact French.
Out of Scope¶
- Expiry / TTL — AUTH-010 (same sub-version, separate slice).
- Redis rate-limiting — AUTH-012.
- HMAC + anti-replay — V1.
- The customer-facing React lockout / revoked screens — CP module.
Definition of Done¶
- [ ] 5-attempt limit, 30-min lockout, and 3-cycle permanent revoke enforced
- [ ] Lockout window consumes no attempt; post-window reset increments the cycle
- [ ] Permanent revoke flips the file to anomaly + notifies the operator
- [ ] Optimistic concurrency prevents double-increment on concurrent submits
- [ ] Exact French lockout / revoked copy provided to CP
- [ ] Audit entries: customer_auth_lockout_started, customer_auth_lockout_resumed, AUTH_LINK_PERMANENTLY_REVOKED
- [ ] Unit + integration tests written and passing (incl. concurrency test)
- [ ] Tenant isolation verified (platform DB; tenant_id scoping)
- [ ] No silent failures