|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#25224 cut 2] The sign-in lockout path of `AuthManager` (ADR-0069 D2) — |
| 5 | + * the locked-account gate before the credential check, the failure counter |
| 6 | + * and lock stamp after it, the `login_failed` row of a refused attempt, the |
| 7 | + * account a sign-in identifier resolves to, the second factor's lockout |
| 8 | + * projection — and the last-login stamp the same after-hook writes (D7), |
| 9 | + * moved out of `auth-manager.ts` whole. |
| 10 | + * |
| 11 | + * A MOVE, not a redesign. Each function below is the body of the |
| 12 | + * `AuthManager` method of the same name as it stood, re-indented one level |
| 13 | + * and otherwise unedited: the same reads and writes, the same refusal with |
| 14 | + * the same code, the same audit rows, failing open exactly where they did. |
| 15 | + * |
| 16 | + * ## How the moved bodies reach the class |
| 17 | + * |
| 18 | + * The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts` |
| 19 | + * header carries the full reasoning) and `auth-manager-actor-guards.ts` |
| 20 | + * follows, applied per method. The class keeps every method — the published |
| 21 | + * `dist/*.d.ts` lists each one's name, docblock and position — and each body |
| 22 | + * is one literal of exactly the {@link SignInLockoutHost} members that method |
| 23 | + * reads, plus a `.call`. The methods are no longer `async`: each returns the |
| 24 | + * moved function's own promise, so a caller awaits the same promise the body |
| 25 | + * settles, with no extra wrapper. A method is handed over as a closure that |
| 26 | + * calls it on the manager at call time, so a test double or spy set on the |
| 27 | + * manager is still the one the body reaches — `findSignInAccount` and |
| 28 | + * `resolveSignInAccountId` included, which bodies here ask through the |
| 29 | + * manager rather than calling the function here directly. |
| 30 | + * |
| 31 | + * The one field, `config`, goes as a GETTER: the class reassigns it after |
| 32 | + * construction (`setBaseUrl` and `applyConfigPatch`), and the bodies read it |
| 33 | + * after an `await`, so a value copied when the method was called could part |
| 34 | + * from the field the body used to read. |
| 35 | + * |
| 36 | + * Nothing re-exports this module, so the published surface does not move. |
| 37 | + */ |
| 38 | + |
| 39 | +import type { AuthManager } from './auth-manager.js'; |
| 40 | +import type { IDataEngine } from '@objectstack/core'; |
| 41 | +import type { EngineQueryOptions } from '@objectstack/spec/data'; |
| 42 | +import { |
| 43 | + accountLockedEventFor, |
| 44 | + emitAuthSessionAuditEvent, |
| 45 | + refusalCodeOf, |
| 46 | + signInClientFingerprint, |
| 47 | + signInFailureEventFor, |
| 48 | + SIGN_IN_EMAIL_PATH, |
| 49 | +} from './auth-session-audit.js'; |
| 50 | + |
| 51 | +/** |
| 52 | + * The `AuthManager` members the functions below read — every one of them, and |
| 53 | + * no other; each function's `this` is the `Pick` of the members IT reads. Each |
| 54 | + * is typed AS the class member (an indexed access on the class), so a change |
| 55 | + * to a member's signature on the class is a change here too, and naming a |
| 56 | + * member the class does not have is a compile error. See the module header for |
| 57 | + * why `config` is a getter. |
| 58 | + */ |
| 59 | +export interface SignInLockoutHost { |
| 60 | + readonly config: AuthManager['config']; |
| 61 | + findSignInAccount: AuthManager['findSignInAccount']; |
| 62 | + getDataEngine: AuthManager['getDataEngine']; |
| 63 | + logDurabilityDegradation: AuthManager['logDurabilityDegradation']; |
| 64 | + resolveSignInAccountId: AuthManager['resolveSignInAccountId']; |
| 65 | +} |
| 66 | + |
| 67 | +/** The body of `AuthManager#assertAccountNotLocked`; the method's docblock there is its contract. */ |
| 68 | +export async function assertAccountNotLocked(this: Pick<SignInLockoutHost, 'config' | 'findSignInAccount' | 'getDataEngine'>, email: string, ctx?: unknown): Promise<void> { |
| 69 | + const threshold = Number(this.config.lockoutThreshold) || 0; |
| 70 | + if (threshold <= 0) return; |
| 71 | + const engine = this.getDataEngine(); |
| 72 | + if (!engine) return; |
| 73 | + let locked = false; |
| 74 | + let lockedUserId: string | undefined; |
| 75 | + try { |
| 76 | + const u = await this.findSignInAccount(engine, email, ['locked_until']); |
| 77 | + const lu = u?.locked_until as string | number | Date | null | undefined; |
| 78 | + locked = !!(lu && new Date(lu).getTime() > Date.now()); |
| 79 | + if (locked && u) lockedUserId = u.id; |
| 80 | + } catch { |
| 81 | + return; // fail-open |
| 82 | + } |
| 83 | + if (locked) { |
| 84 | + if (ctx !== undefined && lockedUserId) { |
| 85 | + await emitAuthSessionAuditEvent( |
| 86 | + this.config.getAuditSink?.(), |
| 87 | + signInFailureEventFor(lockedUserId, { |
| 88 | + path: SIGN_IN_EMAIL_PATH, |
| 89 | + reason: 'ACCOUNT_LOCKED', |
| 90 | + fingerprint: signInClientFingerprint(ctx), |
| 91 | + }), |
| 92 | + ); |
| 93 | + } |
| 94 | + const { APIError } = await import('better-auth/api'); |
| 95 | + throw new APIError('FORBIDDEN', { |
| 96 | + message: |
| 97 | + 'This account is temporarily locked after too many failed sign-in ' + |
| 98 | + 'attempts. Try again later or ask an administrator to unlock it.', |
| 99 | + code: 'ACCOUNT_LOCKED', |
| 100 | + }); |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +/** The body of `AuthManager#recordSignInOutcome`; the method's docblock there is its contract. */ |
| 105 | +export async function recordSignInOutcome( |
| 106 | + this: Pick<SignInLockoutHost, 'config' | 'findSignInAccount' | 'getDataEngine' | 'logDurabilityDegradation'>, |
| 107 | + email: string, |
| 108 | + success: boolean, |
| 109 | +): Promise<{ userId: string; lockedUntil: Date; threshold: number } | undefined> { |
| 110 | + const threshold = Number(this.config.lockoutThreshold) || 0; |
| 111 | + if (threshold <= 0) return undefined; |
| 112 | + const engine = this.getDataEngine(); |
| 113 | + if (!engine) return undefined; |
| 114 | + try { |
| 115 | + const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; |
| 116 | + const u = await this.findSignInAccount(engine, email, ['failed_login_count', 'locked_until']); |
| 117 | + if (!u) return undefined; |
| 118 | + if (success) { |
| 119 | + if ((Number(u.failed_login_count) || 0) !== 0 || u.locked_until) { |
| 120 | + await engine.update( |
| 121 | + 'sys_user', |
| 122 | + { id: u.id, failed_login_count: 0, locked_until: null }, |
| 123 | + { context: SYSTEM_CTX } as any, |
| 124 | + ); |
| 125 | + } |
| 126 | + return undefined; |
| 127 | + } |
| 128 | + const next = (Number(u.failed_login_count) || 0) + 1; |
| 129 | + const patch: Record<string, unknown> = { id: u.id, failed_login_count: next }; |
| 130 | + let lockedUntil: Date | undefined; |
| 131 | + if (next >= threshold) { |
| 132 | + const mins = Number(this.config.lockoutDurationMinutes) || 15; |
| 133 | + lockedUntil = new Date(Date.now() + mins * 60_000); |
| 134 | + patch.locked_until = lockedUntil; |
| 135 | + } |
| 136 | + await engine.update('sys_user', patch, { context: SYSTEM_CTX } as any); |
| 137 | + return lockedUntil ? { userId: u.id, lockedUntil, threshold } : undefined; |
| 138 | + } catch (e) { |
| 139 | + // Never break the auth response. [#12981] But lockout accounting that |
| 140 | + // silently stops accounting is brute-force protection that is OFF. |
| 141 | + this.logDurabilityDegradation( |
| 142 | + 'AuthManager: the sign-in lockout counter was NOT written — the auth response is ' |
| 143 | + + 'unaffected, so nothing looks broken. This attempt was not counted, which means ' |
| 144 | + + 'consecutive failures do not accumulate and the account is never locked: ' |
| 145 | + + '`lockoutThreshold` (ADR-0069 D2) is configured and, for this identity, not enforced. ' |
| 146 | + + 'A brute-force run against this account meets no limit while this persists, and the ' |
| 147 | + + 'only notice is this line. Remedy: make the sys_user update land — check the write ' |
| 148 | + + 'permission on `failed_login_count` / `locked_until` and driver connectivity.', |
| 149 | + e, |
| 150 | + { object: 'sys_user', threshold, success }, |
| 151 | + ); |
| 152 | + return undefined; |
| 153 | + } |
| 154 | +} |
| 155 | + |
| 156 | +/** The body of `AuthManager#auditRefusedSignIn`; the method's docblock there is its contract. */ |
| 157 | +export async function auditRefusedSignIn( |
| 158 | + this: Pick<SignInLockoutHost, 'config' | 'resolveSignInAccountId'>, |
| 159 | + ctx: any, |
| 160 | + email: string, |
| 161 | + lockStamped: { userId: string; lockedUntil: Date; threshold: number } | undefined, |
| 162 | +): Promise<void> { |
| 163 | + const sink = this.config.getAuditSink?.(); |
| 164 | + if (!sink) return; |
| 165 | + const userId = lockStamped?.userId ?? (await this.resolveSignInAccountId(email)); |
| 166 | + if (!userId) return; |
| 167 | + const fingerprint = signInClientFingerprint(ctx); |
| 168 | + await emitAuthSessionAuditEvent( |
| 169 | + sink, |
| 170 | + signInFailureEventFor(userId, { |
| 171 | + path: SIGN_IN_EMAIL_PATH, |
| 172 | + reason: refusalCodeOf(ctx?.context?.returned), |
| 173 | + fingerprint, |
| 174 | + }), |
| 175 | + ); |
| 176 | + if (lockStamped) { |
| 177 | + await emitAuthSessionAuditEvent( |
| 178 | + sink, |
| 179 | + accountLockedEventFor(lockStamped.userId, { |
| 180 | + path: SIGN_IN_EMAIL_PATH, |
| 181 | + lockedUntil: lockStamped.lockedUntil, |
| 182 | + threshold: lockStamped.threshold, |
| 183 | + fingerprint, |
| 184 | + }), |
| 185 | + ); |
| 186 | + } |
| 187 | +} |
| 188 | + |
| 189 | +/** The body of `AuthManager#resolveSignInAccountId`; the method's docblock there is its contract. */ |
| 190 | +export async function resolveSignInAccountId(this: Pick<SignInLockoutHost, 'findSignInAccount' | 'getDataEngine' | 'logDurabilityDegradation'>, email: string): Promise<string | undefined> { |
| 191 | + const engine = this.getDataEngine(); |
| 192 | + if (!engine) return undefined; |
| 193 | + try { |
| 194 | + return (await this.findSignInAccount(engine, email, []))?.id; |
| 195 | + } catch (e) { |
| 196 | + // The refusal stands either way; what is lost is its ledger row. |
| 197 | + this.logDurabilityDegradation( |
| 198 | + 'AuthManager: a refused sign-in was NOT recorded in sys_audit_log — the account it named ' |
| 199 | + + 'could not be resolved, so its `login_failed` row was not written. The refusal itself was ' |
| 200 | + + 'answered normally, so nothing looks broken, and the Auth preset of Setup → Audit Log is ' |
| 201 | + + 'missing this attempt. Remedy: make the sys_user read land — check driver connectivity.', |
| 202 | + e, |
| 203 | + { object: 'sys_user', action: 'login_failed' }, |
| 204 | + ); |
| 205 | + return undefined; |
| 206 | + } |
| 207 | +} |
| 208 | + |
| 209 | +/** The body of `AuthManager#findSignInAccount`; the method's docblock there is its contract. */ |
| 210 | +export async function findSignInAccount( |
| 211 | + engine: IDataEngine, |
| 212 | + email: string, |
| 213 | + fields: readonly string[], |
| 214 | +): Promise<({ id: string } & Record<string, unknown>) | undefined> { |
| 215 | + const query: EngineQueryOptions = { |
| 216 | + where: { email: email.toLowerCase() }, |
| 217 | + fields: ['id', ...fields], |
| 218 | + context: { isSystem: true, positions: [], permissions: [] }, |
| 219 | + }; |
| 220 | + const row = await engine.findOne('sys_user', query); |
| 221 | + return row && typeof row.id === 'string' && row.id |
| 222 | + ? (row as { id: string } & Record<string, unknown>) |
| 223 | + : undefined; |
| 224 | +} |
| 225 | + |
| 226 | +/** The body of `AuthManager#resolveTwoFactorAccountLockout`; the method's docblock there is its contract. */ |
| 227 | +export function resolveTwoFactorAccountLockout(this: Pick<SignInLockoutHost, 'config'>): { enabled: boolean; maxFailedAttempts: number; durationSeconds: number } | undefined { |
| 228 | + const threshold = Number(this.config.lockoutThreshold) || 0; |
| 229 | + if (threshold <= 0) return undefined; |
| 230 | + const minutes = Number(this.config.lockoutDurationMinutes) || 15; |
| 231 | + return { |
| 232 | + enabled: true, |
| 233 | + maxFailedAttempts: threshold, |
| 234 | + durationSeconds: minutes * 60, |
| 235 | + }; |
| 236 | +} |
| 237 | + |
| 238 | +/** The body of `AuthManager#stampLastLogin`; the method's docblock there is its contract. */ |
| 239 | +export async function stampLastLogin(this: Pick<SignInLockoutHost, 'getDataEngine' | 'logDurabilityDegradation'>, userId: string, ip: string | undefined): Promise<void> { |
| 240 | + const engine = this.getDataEngine(); |
| 241 | + if (!engine || !userId) return; |
| 242 | + try { |
| 243 | + const SYSTEM_CTX = { isSystem: true, attributedUserId: userId, positions: [], permissions: [] }; |
| 244 | + const patch: Record<string, unknown> = { id: userId, last_login_at: new Date() }; |
| 245 | + // Cap to the column width (IPv6 textual max 45) — a malformed/oversized |
| 246 | + // forwarded header must not blow up the write. |
| 247 | + if (ip) patch.last_login_ip = ip.slice(0, 45); |
| 248 | + await engine.update('sys_user', patch, { context: SYSTEM_CTX } as any); |
| 249 | + } catch (e) { |
| 250 | + // Never break the auth response. [#12981] But this write is the ONLY |
| 251 | + // trace the sign-in leaves in the compliance ledger. |
| 252 | + this.logDurabilityDegradation( |
| 253 | + 'AuthManager: the last-login stamp was NOT written — the sign-in succeeded and the user ' |
| 254 | + + 'holds a valid session, so nothing looks wrong. `last_login_at` / `last_login_ip` keep ' |
| 255 | + + 'their previous values, and because this update is what plugin-audit turns into the ' |
| 256 | + + 'change-trail row, this sign-in leaves NO trace in the compliance ledger at ' |
| 257 | + + 'all: "when did this account last sign in, and from where" now answers with a stale ' |
| 258 | + + 'row, and a login from a new address is exactly what that trail is read for. Remedy: ' |
| 259 | + + 'make the sys_user update land; the next sign-in that lands re-stamps it, but this one ' |
| 260 | + + 'is not recoverable.', |
| 261 | + e, |
| 262 | + { object: 'sys_user', fields: ['last_login_at', 'last_login_ip'] }, |
| 263 | + ); |
| 264 | + } |
| 265 | +} |
0 commit comments