CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
login-lockout.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 27d5fd3 | 1 | /** |
| 2 | * Login lockout policy (SOC 2 CC6.1) — pure evaluator. | |
| 3 | * | |
| 4 | * Semantics: | |
| 5 | * - An account email is locked when it has accumulated at least | |
| 6 | * `LOGIN_FAIL_LIMIT` failed attempts inside the trailing | |
| 7 | * `LOGIN_FAIL_WINDOW_MS`, AND the most recent failure is younger than | |
| 8 | * `LOGIN_LOCKOUT_MS`. The lockout therefore expires 15 minutes after | |
| 9 | * the last *genuine* failed password attempt. | |
| 10 | * - Attempts made while locked must NOT be recorded as failures — | |
| 11 | * otherwise the lockout window rolls forward forever and a user | |
| 12 | * retrying their (correct) password can never get back in. This was | |
| 13 | * the production bug that made "Account temporarily locked … try again | |
| 14 | * in 15 minutes" permanent. | |
| 15 | * - A successful login clears the failure history for that email so a | |
| 16 | * later single typo can't instantly re-trip a stale window. | |
| 17 | * | |
| 18 | * Kept pure (no DB access) so the policy is unit-testable; the route layer | |
| 19 | * supplies the aggregate counts. | |
| 20 | */ | |
| 21 | ||
| 22 | export const LOGIN_FAIL_WINDOW_MS = 60 * 60 * 1000; // 1 hour | |
| 23 | export const LOGIN_FAIL_LIMIT = 10; | |
| 24 | export const LOGIN_LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes | |
| 25 | ||
| 26 | export interface LockoutInput { | |
| 27 | /** Failed attempts for this email inside the trailing window. */ | |
| 28 | failureCount: number; | |
| 29 | /** Timestamp of the most recent failed attempt, or null if none. */ | |
| 30 | newestFailureAt: Date | null; | |
| 31 | /** Injectable clock for tests; defaults to now. */ | |
| 32 | now?: Date; | |
| 33 | } | |
| 34 | ||
| 35 | export interface LockoutState { | |
| 36 | locked: boolean; | |
| 37 | failureCount: number; | |
| 38 | /** Milliseconds until the lockout lifts (0 when not locked). */ | |
| 39 | retryAfterMs: number; | |
| 40 | } | |
| 41 | ||
| 42 | export function evaluateLockout(input: LockoutInput): LockoutState { | |
| 43 | const now = input.now ?? new Date(); | |
| 44 | const count = Math.max(0, input.failureCount); | |
| 45 | if (count < LOGIN_FAIL_LIMIT || !input.newestFailureAt) { | |
| 46 | return { locked: false, failureCount: count, retryAfterMs: 0 }; | |
| 47 | } | |
| 48 | const sinceNewest = now.getTime() - input.newestFailureAt.getTime(); | |
| 49 | if (sinceNewest >= LOGIN_LOCKOUT_MS) { | |
| 50 | return { locked: false, failureCount: count, retryAfterMs: 0 }; | |
| 51 | } | |
| 52 | return { | |
| 53 | locked: true, | |
| 54 | failureCount: count, | |
| 55 | retryAfterMs: LOGIN_LOCKOUT_MS - sinceNewest, | |
| 56 | }; | |
| 57 | } | |
| 58 | ||
| 59 | /** Human-friendly "try again in N minutes" figure, always at least 1. */ | |
| 60 | export function retryAfterMinutes(state: LockoutState): number { | |
| 61 | return Math.max(1, Math.ceil(state.retryAfterMs / 60_000)); | |
| 62 | } |