Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/adr-0069-d2-account-lockout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/platform-objects': minor
'@objectstack/service-settings': minor
'@objectstack/plugin-auth': minor
'@objectstack/cli': minor
---

Auth: account lockout + rate-limit tuning (ADR-0069 D2, P1)

Second slice of ADR-0069 — per-identity brute-force protection, reusing the setting→enforcement pattern from the HIBP PR.

- **Account lockout** `[custom][field]`: new `sys_user.failed_login_count` / `sys_user.locked_until` columns; `auth` settings `lockout_threshold` (0 = off) + `lockout_duration_minutes`. Enforced in the `/sign-in/email` before/after hooks — failures increment the counter, crossing the threshold stamps `locked_until`, and a locked account is rejected **even with the correct password** (survives IP rotation, unlike rate limiting). A successful sign-in resets both.
- **Admin Unlock**: new admin-guarded `POST /api/v1/auth/admin/unlock-user` route + an `unlock_user` action on `sys_user`.
- **Rate-limit tuning** `[native]`: `auth` settings `rate_limit_max` / `rate_limit_window_seconds` wire better-auth's core `rateLimit` with stricter `customRules` for `/sign-in/email`, `/sign-up/email`, `/request-password-reset`, `/reset-password`.

All settings default off / to safe values; additive (no upgrade behavior change). Per ADR-0049 each setting ships with its enforcement. Timestamps are written as `Date` (never epoch-ms) per ADR-0074.
37 changes: 37 additions & 0 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,21 @@ export const SysUser = ObjectSchema.create({
successMessage: 'User unbanned',
refreshAfter: true,
},
{
// ADR-0069 D2 — clear a brute-force lockout early (locked_until auto-
// expires, but an admin can release a user immediately). Hits the
// plugin-auth custom route, which is admin-guarded server-side.
name: 'unlock_user',
label: 'Unlock Account',
icon: 'lock-open',
variant: 'secondary',
locations: ['list_item'],
type: 'api',
target: '/api/v1/auth/admin/unlock-user',
recordIdParam: 'userId',
successMessage: 'Account unlocked',
refreshAfter: true,
},
{
name: 'set_user_password',
label: 'Set Password',
Expand Down Expand Up @@ -410,6 +425,28 @@ export const SysUser = ObjectSchema.create({
description: 'When set, the ban auto-clears at this time.',
}),

// ── Anti-brute-force (ADR-0069 D2) — owned by objectql, better-auth is
// oblivious. The auth manager's sign-in hooks maintain these: failures
// increment the counter; crossing `lockout_threshold` stamps
// `locked_until`; a successful sign-in resets both. Admins can clear
// them early via the Unlock action.
failed_login_count: Field.number({
label: 'Failed Login Count',
required: false,
defaultValue: 0,
readonly: true,
group: 'Admin',
description: 'Consecutive failed sign-in attempts; reset to 0 on success. Maintained by the auth manager.',
}),

locked_until: Field.datetime({
label: 'Locked Until',
required: false,
readonly: true,
group: 'Admin',
description: 'When set and in the future, sign-in is rejected (brute-force lockout). Auto-clears past this time; an admin can clear it early via Unlock.',
}),

ai_access: Field.boolean({
label: 'AI Access',
defaultValue: false,
Expand Down
137 changes: 137 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1594,4 +1594,141 @@ describe('AuthManager', () => {
}
});
});

// ADR-0069 D2: per-identity account lockout + native rate-limit passthrough.
// The lockout state machine is exercised directly via the AuthManager helpers
// with a mocked data engine (deterministic; the live multi-failure path is
// covered by the dogfood smoke).
describe('account lockout + rate limiting (ADR-0069 D2)', () => {
const SECRET = 'test-secret-at-least-32-chars-long';
// findOne honours the `where` clause (the ObjectQL engine key) — so a
// regression to the wrong key (`filter`, silently ignored → returns the
// first/arbitrary row) makes these tests fail, not pass. Caught a real bug
// in dogfood that a query-agnostic mock had masked.
const makeEngine = (user: any) => ({
findOne: vi.fn(async (_obj: string, q: any) => {
const w = q?.where ?? {};
if (!user) return null;
const matches = Object.entries(w).every(([k, v]) => (user as any)[k] === v);
return matches ? user : null;
}),
update: vi.fn(async () => ({ ...(user ?? {}) })),
count: vi.fn(),
});
const mgr = (engine: any, extra: any = {}) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', dataEngine: engine, ...extra });
warn.mockRestore();
return m;
};

it('assertAccountNotLocked is a no-op when lockout is disabled (threshold 0)', async () => {
const engine = makeEngine({ id: 'u1', email: 'a@b.com', locked_until: new Date(Date.now() + 60_000).toISOString() });
const m = mgr(engine, { lockoutThreshold: 0 });
await expect((m as any).assertAccountNotLocked('a@b.com')).resolves.toBeUndefined();
expect(engine.findOne).not.toHaveBeenCalled();
});

it('assertAccountNotLocked throws ACCOUNT_LOCKED while locked_until is in the future', async () => {
const engine = makeEngine({ id: 'u1', email: 'a@b.com', locked_until: new Date(Date.now() + 60_000).toISOString() });
const m = mgr(engine, { lockoutThreshold: 3 });
await expect((m as any).assertAccountNotLocked('a@b.com')).rejects.toMatchObject({
body: { code: 'ACCOUNT_LOCKED' },
});
});

it('assertAccountNotLocked allows sign-in once the lock has expired', async () => {
const engine = makeEngine({ id: 'u1', email: 'a@b.com', locked_until: new Date(Date.now() - 60_000).toISOString() });
const m = mgr(engine, { lockoutThreshold: 3 });
await expect((m as any).assertAccountNotLocked('a@b.com')).resolves.toBeUndefined();
});

it('recordSignInOutcome increments below threshold without locking', async () => {
const engine = makeEngine({ id: 'u1', email: 'a@b.com', failed_login_count: 0, locked_until: null });
const m = mgr(engine, { lockoutThreshold: 3 });
await (m as any).recordSignInOutcome('a@b.com', false);
const patch = engine.update.mock.calls[0][1];
expect(patch.failed_login_count).toBe(1);
expect(patch.locked_until).toBeUndefined();
});

it('recordSignInOutcome stamps locked_until (a Date) once the threshold is reached', async () => {
const engine = makeEngine({ id: 'u1', email: 'a@b.com', failed_login_count: 2, locked_until: null });
const m = mgr(engine, { lockoutThreshold: 3, lockoutDurationMinutes: 15 });
await (m as any).recordSignInOutcome('a@b.com', false);
const patch = engine.update.mock.calls[0][1];
expect(patch.failed_login_count).toBe(3);
expect(patch.locked_until instanceof Date).toBe(true);
// ~15 minutes out (datetime stored as a Date, never epoch-ms — see ADR-0074).
expect((patch.locked_until as Date).getTime()).toBeGreaterThan(Date.now() + 14 * 60_000);
});

it('recordSignInOutcome resets counter + lock on success when there is state to clear', async () => {
const engine = makeEngine({ id: 'u1', email: 'a@b.com', failed_login_count: 2, locked_until: null });
const m = mgr(engine, { lockoutThreshold: 3 });
await (m as any).recordSignInOutcome('a@b.com', true);
expect(engine.update).toHaveBeenCalledWith(
'sys_user',
{ id: 'u1', failed_login_count: 0, locked_until: null },
expect.anything(),
);
});

it('recordSignInOutcome skips the write on success when nothing needs clearing', async () => {
const engine = makeEngine({ id: 'u1', email: 'a@b.com', failed_login_count: 0, locked_until: null });
const m = mgr(engine, { lockoutThreshold: 3 });
await (m as any).recordSignInOutcome('a@b.com', true);
expect(engine.update).not.toHaveBeenCalled();
});

it('recordSignInOutcome is a no-op when lockout is disabled', async () => {
const engine = makeEngine({ id: 'u1', email: 'a@b.com', failed_login_count: 5 });
const m = mgr(engine, { lockoutThreshold: 0 });
await (m as any).recordSignInOutcome('a@b.com', false);
expect(engine.update).not.toHaveBeenCalled();
});

it('unlockUser clears failed_login_count and locked_until', async () => {
const engine = makeEngine({ id: 'u1' });
const m = mgr(engine);
await expect(m.unlockUser('u1')).resolves.toBe(true);
expect(engine.update).toHaveBeenCalledWith(
'sys_user',
{ id: 'u1', failed_login_count: 0, locked_until: null },
expect.anything(),
);
});

it('unlockUser returns false for an unknown user', async () => {
const engine = makeEngine(null);
const m = mgr(engine);
await expect(m.unlockUser('nope')).resolves.toBe(false);
expect(engine.update).not.toHaveBeenCalled();
});

it('passes a configured rateLimit through to betterAuth', async () => {
let captured: any;
(betterAuth as any).mockImplementation((cfg: any) => { captured = cfg; return { handler: vi.fn(), api: {} }; });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const m = new AuthManager({
secret: SECRET,
baseUrl: 'http://localhost:3000',
rateLimit: { enabled: true, window: 60, max: 10, customRules: { '/sign-in/email': { window: 60, max: 10 } } } as any,
});
await m.getAuthInstance();
warn.mockRestore();
expect(captured.rateLimit).toMatchObject({ enabled: true, max: 10, window: 60 });
expect(captured.rateLimit.customRules['/sign-in/email']).toEqual({ window: 60, max: 10 });
});

it('omits rateLimit from the betterAuth config when unset (keeps library defaults)', async () => {
let captured: any;
(betterAuth as any).mockImplementation((cfg: any) => { captured = cfg; return { handler: vi.fn(), api: {} }; });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000' });
await m.getAuthInstance();
warn.mockRestore();
expect(captured).not.toHaveProperty('rateLimit');
});
});
});
150 changes: 150 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,26 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
* "create organization" screen.
*/
databaseHooks?: BetterAuthOptions['databaseHooks'];

/**
* ADR-0069 D2 — account lockout (anti-brute-force). After this many
* consecutive failed sign-ins the account is locked for
* {@link lockoutDurationMinutes}. `0` (default) disables lockout.
* Enforced per-identity in the `/sign-in/email` before/after hooks
* (survives IP rotation, unlike the per-IP {@link rateLimit}).
*/
lockoutThreshold?: number;

/** Minutes an account stays locked once the threshold is crossed. Default 15. */
lockoutDurationMinutes?: number;

/**
* ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to
* better-auth's core `rateLimit`. The settings bind tightens `customRules`
* for the auth endpoints (`/sign-in/email`, `/sign-up/email`,
* `/reset-password`). Multi-node deployments need a shared `storage`.
*/
rateLimit?: BetterAuthOptions['rateLimit'];
}

/**
Expand Down Expand Up @@ -531,6 +551,11 @@ export class AuthManager {
updateAge: this.config.session?.updateAge || 60 * 60 * 24, // 1 day default
},

// ADR-0069 D2 — per-IP rate limiting (native). Only set when configured
// so better-auth keeps its own defaults otherwise. The settings bind
// supplies stricter `customRules` for the auth endpoints.
...(this.config.rateLimit ? { rateLimit: this.config.rateLimit } : {}),

// better-auth plugins — registered based on AuthPluginConfig flags
plugins,

Expand Down Expand Up @@ -675,6 +700,15 @@ export class AuthManager {
// fall through to better-auth's own handler
}

// ── ADR-0069 D2: account lockout (gate) ─────────────────────
// Reject a sign-in for a locked identity BEFORE better-auth checks
// the password — a lock must hold even against the correct password.
if (ctx?.path === '/sign-in/email') {
const email = typeof ctx?.body?.email === 'string' ? ctx.body.email : '';
if (email) await this.assertAccountNotLocked(email);
return;
}

if (ctx?.path !== '/sign-up/email') return;
const ep = ctx?.context?.options?.emailAndPassword;
if (!ep?.disableSignUp) return;
Expand All @@ -690,6 +724,25 @@ export class AuthManager {
}
}),
after: createAuthMiddleware(async (ctx: any) => {
// ── ADR-0069 D2: account lockout (counter) ──────────────────
// better-auth catches an INVALID_EMAIL_OR_PASSWORD APIError and runs
// the after-hook with it on `ctx.context.returned`; a success leaves
// the session payload there. Count failures, reset on success.
if (ctx?.path === '/sign-in/email') {
const email = typeof ctx?.body?.email === 'string' ? ctx.body.email : '';
if (email) {
let succeeded = true;
try {
const { isAPIError } = await import('better-auth/api');
succeeded = !isAPIError(ctx?.context?.returned);
} catch {
succeeded = !(ctx?.context?.returned instanceof Error);
}
await this.recordSignInOutcome(email, succeeded);
}
return;
}

if (ctx?.path !== '/sign-up/email') return;
const ep = ctx?.context?.options?.emailAndPassword;
if (ep && ctx.context.__osDisableSignUpOrig !== undefined) {
Expand Down Expand Up @@ -1863,6 +1916,103 @@ export class AuthManager {
}
}

/**
* ADR-0069 D2 — throw `ACCOUNT_LOCKED` when the identity is currently locked
* out (brute-force protection). No-op when lockout is disabled
* (`lockoutThreshold <= 0`) or no data engine is wired. Fails OPEN on a
* lookup error: an infra hiccup must never block every login.
*/
private async assertAccountNotLocked(email: string): Promise<void> {
const threshold = Number(this.config.lockoutThreshold) || 0;
if (threshold <= 0) return;
const engine = this.getDataEngine();
if (!engine) return;
let locked = false;
try {
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
const u = await engine.findOne('sys_user', {
where: { email }, fields: ['id', 'locked_until'], context: SYSTEM_CTX,
} as any);
const lu = u?.locked_until;
locked = !!(lu && new Date(lu).getTime() > Date.now());
} catch {
return; // fail-open
}
if (locked) {
const { APIError } = await import('better-auth/api');
throw new APIError('FORBIDDEN', {
message:
'This account is temporarily locked after too many failed sign-in ' +
'attempts. Try again later or ask an administrator to unlock it.',
code: 'ACCOUNT_LOCKED',
});
}
}

/**
* ADR-0069 D2 — record a sign-in outcome for lockout accounting. On failure
* increments `failed_login_count` and, once it reaches `lockoutThreshold`,
* stamps `locked_until = now + lockoutDurationMinutes`. On success resets
* both (only writing when there is something to clear, to avoid a no-op
* history row on every login). No-op when lockout is disabled. Never throws —
* a counter write must not turn a valid login into an error.
*/
private async recordSignInOutcome(email: string, success: boolean): Promise<void> {
const threshold = Number(this.config.lockoutThreshold) || 0;
if (threshold <= 0) return;
const engine = this.getDataEngine();
if (!engine) return;
try {
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
const u = await engine.findOne('sys_user', {
where: { email },
fields: ['id', 'failed_login_count', 'locked_until'],
context: SYSTEM_CTX,
} as any);
if (!u?.id) return;
if (success) {
if ((Number(u.failed_login_count) || 0) !== 0 || u.locked_until) {
await engine.update(
'sys_user',
{ id: u.id, failed_login_count: 0, locked_until: null },
{ context: SYSTEM_CTX } as any,
);
}
return;
}
const next = (Number(u.failed_login_count) || 0) + 1;
const patch: Record<string, unknown> = { id: u.id, failed_login_count: next };
if (next >= threshold) {
const mins = Number(this.config.lockoutDurationMinutes) || 15;
patch.locked_until = new Date(Date.now() + mins * 60_000);
}
await engine.update('sys_user', patch, { context: SYSTEM_CTX } as any);
} catch {
// Lockout accounting is best-effort — never break the auth response.
}
}

/**
* ADR-0069 D2 — clear a user's lockout state (admin "Unlock" action).
* Resets `failed_login_count` and `locked_until`. Returns false when no data
* engine is wired or the user does not exist.
*/
public async unlockUser(userId: string): Promise<boolean> {
const engine = this.getDataEngine();
if (!engine || !userId) return false;
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
const u = await engine.findOne('sys_user', {
where: { id: userId }, fields: ['id'], context: SYSTEM_CTX,
} as any);
if (!u?.id) return false;
await engine.update(
'sys_user',
{ id: userId, failed_login_count: 0, locked_until: null },
{ context: SYSTEM_CTX } as any,
);
return true;
}

/**
* Returns the data engine wired into this auth manager. Used by route
* handlers (e.g. bootstrap-status) that need to query identity tables
Expand Down
Loading