Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

login-lockout.tsBlame62 lines · 1 contributor
27d5fd3Claude1/**
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
22export const LOGIN_FAIL_WINDOW_MS = 60 * 60 * 1000; // 1 hour
23export const LOGIN_FAIL_LIMIT = 10;
24export const LOGIN_LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
25
26export 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
35export interface LockoutState {
36 locked: boolean;
37 failureCount: number;
38 /** Milliseconds until the lockout lifts (0 when not locked). */
39 retryAfterMs: number;
40}
41
42export 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. */
60export function retryAfterMinutes(state: LockoutState): number {
61 return Math.max(1, Math.ceil(state.retryAfterMs / 60_000));
62}