feat(portal): rate-limit and lock out brute-force portal logins (PMS-501) #385

Merged
Claude-Run merged 1 commit from feat/PMS-501-portal-login-rate-limit-lockout into main 2026-06-26 15:00:52 +02:00
Member

The portal login endpoint (POST /api/v1/portal/auth/login) had no per-IP or per-account rate limiting, no failed-attempt accounting, and no lockout, so an attacker who knew a tenant slug and a contact email could grind unlimited password guesses bounded only by Argon2 latency.

Add a layered in-memory limiter (PortalLoginLimiter, mirroring auth::rate_limit::LoginLimiter) stored in PortalRouterState and checked inline at the top of the login handler: 20/min per source IP plus 5/min per (tenant_slug, lowercased email) pair (the slug is part of the key because portal emails are only unique within a tenant). Over-quota returns 429 with a Retry-After header.

Add a persistent failed-attempt counter and exponential-backoff lockout that survives a process restart and spans replicas (which the in-memory store cannot): migration 082 adds portal_failed_login_count and portal_locked_until to contacts. The service rejects with 429 while a lockout window is active, increments the counter on each credential failure, arms a doubling lock (30s, 60s, 120s, ... capped at 1h) once five consecutive failures accumulate, and clears both on the next successful login.

Covered by unit tests for the backoff schedule and limiter keying, plus integration tests asserting the durable lockout state and the success-path counter reset.

#PMS-501

The portal login endpoint (`POST /api/v1/portal/auth/login`) had no per-IP or per-account rate limiting, no failed-attempt accounting, and no lockout, so an attacker who knew a tenant slug and a contact email could grind unlimited password guesses bounded only by Argon2 latency. Add a layered in-memory limiter (`PortalLoginLimiter`, mirroring `auth::rate_limit::LoginLimiter`) stored in `PortalRouterState` and checked inline at the top of the `login` handler: 20/min per source IP plus 5/min per `(tenant_slug, lowercased email)` pair (the slug is part of the key because portal emails are only unique within a tenant). Over-quota returns 429 with a `Retry-After` header. Add a persistent failed-attempt counter and exponential-backoff lockout that survives a process restart and spans replicas (which the in-memory store cannot): migration 082 adds `portal_failed_login_count` and `portal_locked_until` to `contacts`. The service rejects with 429 while a lockout window is active, increments the counter on each credential failure, arms a doubling lock (30s, 60s, 120s, ... capped at 1h) once five consecutive failures accumulate, and clears both on the next successful login. Covered by unit tests for the backoff schedule and limiter keying, plus integration tests asserting the durable lockout state and the success-path counter reset. #PMS-501
feat(portal): rate-limit and lock out brute-force portal logins (PMS-501)
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 1m2s
Check / fmt + clippy + build + tests (pull_request) Successful in 5m17s
Integration / integration tests (pull_request) Successful in 21m47s
Create release / Gate (release-branch merges only) (pull_request) Successful in 2s
Create release / Create release from merged PR (pull_request) Has been skipped
9f431b3527
The portal login endpoint (`POST /api/v1/portal/auth/login`) had no per-IP or per-account rate limiting, no failed-attempt accounting, and no lockout, so an attacker who knew a tenant slug and a contact email could grind unlimited password guesses bounded only by Argon2 latency.

Add a layered in-memory limiter (`PortalLoginLimiter`, mirroring `auth::rate_limit::LoginLimiter`) stored in `PortalRouterState` and checked inline at the top of the `login` handler: 20/min per source IP plus 5/min per `(tenant_slug, lowercased email)` pair (the slug is part of the key because portal emails are only unique within a tenant). Over-quota returns 429 with a `Retry-After` header.

Add a persistent failed-attempt counter and exponential-backoff lockout that survives a process restart and spans replicas (which the in-memory store cannot): migration 082 adds `portal_failed_login_count` and `portal_locked_until` to `contacts`. The service rejects with 429 while a lockout window is active, increments the counter on each credential failure, arms a doubling lock (30s, 60s, 120s, ... capped at 1h) once five consecutive failures accumulate, and clears both on the next successful login.

Covered by unit tests for the backoff schedule and limiter keying, plus integration tests asserting the durable lockout state and the success-path counter reset.

#PMS-501
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!385
No description provided.