Prevent cross-address login lockouts (Refs #63)
CI / checks (push) Successful in 6m30s

This commit is contained in:
2026-10-17 10:55:28 -04:00
parent f3b9699317
commit 4501eba873
14 changed files with 922 additions and 61 deletions
+12 -1
View File
@@ -1,11 +1,14 @@
import { error, isHttpError, json, type Cookies } from '@sveltejs/kit';
import { AuthError, SESSION_COOKIE, type AuthResult } from './index.js';
import { AuthError, LOGIN_DEVICE_COOKIE, SESSION_COOKIE, type AuthResult } from './index.js';
import { PasswordServiceBusyError } from './passwords.js';
import type { AuthUser } from '../../auth.js';
export function sessionCookie(url: URL): string {
return url.protocol === 'https:' ? SESSION_COOKIE : 'ec_session_dev';
}
export function loginDeviceCookie(url: URL): string {
return url.protocol === 'https:' ? LOGIN_DEVICE_COOKIE : 'ec_login_device_dev';
}
export function requireUserTransport(url: URL): void {
if (url.protocol === 'https:') return;
if (
@@ -34,6 +37,14 @@ export function sessionResponse(result: AuthResult, cookies: Cookies, url: URL)
sameSite: 'lax',
expires: new Date(result.session.expiresAt)
});
if (result.loginDevice)
cookies.set(loginDeviceCookie(url), result.loginDevice.token, {
path: '/',
httpOnly: true,
secure: url.protocol === 'https:',
sameSite: 'lax',
expires: new Date(result.loginDevice.expiresAt)
});
return json({ user: result.user }, { headers: { 'Cache-Control': 'no-store' } });
}
export function requireUser(user: AuthUser | null): AuthUser {
+53 -11
View File
@@ -7,6 +7,8 @@ export const SESSION_COOKIE = '__Host-ec_session';
export const SESSION_LIFETIME_MS = 12 * 60 * 60_000;
export const SESSION_IDLE_MS = 30 * 60_000;
export const REMEMBERED_SESSION_LIFETIME_MS = 30 * 24 * 60 * 60_000;
export const LOGIN_DEVICE_COOKIE = '__Host-ec_login_device';
export const LOGIN_DEVICE_LIFETIME_MS = 30 * 24 * 60 * 60_000;
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
function database() {
return db();
@@ -111,7 +113,8 @@ export function listAuthAudit() {
export function consumeAuthAttempt(
clientAddress: string,
account: string,
kind: 'login' | 'setup' | 'password' = 'login'
kind: 'login' | 'setup' | 'password' = 'login',
loginDevice?: string
): void {
// Resolve the current role from storage; setup never has an existing account exemption.
if (
@@ -127,16 +130,22 @@ export function consumeAuthAttempt(
const now = Date.now();
const windowMs = 15 * 60_000;
const ipKey = digest(`${kind}:ip:${clientAddress}`);
const accountKey = digest(`${kind}:account:${account.toLowerCase()}`);
// Keep the hashed account prefix identifiable for local account recovery.
const accountKey =
kind === 'login'
? `login:account:${digest(account.toLowerCase())}:ip:${digest(clientAddress)}`
: digest(`${kind}:account:${account.toLowerCase()}`);
// A prior successful login permits another password check even when this
// account/address pair is exhausted. The shared address limit still applies.
const knownDevice = kind === 'login' && readLoginDevice(account, loginDevice);
const limits: [string, number][] = [[ipKey, 30]];
if (!knownDevice) limits.push([accountKey, 10]);
database()
.transaction(() => {
database()
.prepare('DELETE FROM auth_rate_limits WHERE window_start < ?')
.run(now - 24 * 60 * 60_000);
for (const [key, limit] of [
[ipKey, 30],
[accountKey, 10]
] as const) {
for (const [key, limit] of limits) {
const previous = database()
.prepare('SELECT window_start,attempts FROM auth_rate_limits WHERE key=?')
.get(key) as { window_start: number; attempts: number } | undefined;
@@ -147,7 +156,7 @@ export function consumeAuthAttempt(
)
throw new AuthError('Too many attempts. Try again in 15 minutes.', 429);
}
for (const key of [ipKey, accountKey])
for (const [key] of limits)
database()
.prepare(
`INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,1)
@@ -189,6 +198,7 @@ function insertUser(input: {
export interface AuthResult {
user: AuthUser;
session: { token: string; expiresAt: number };
loginDevice?: { token: string; expiresAt: number };
}
export async function bootstrapAdmin(
input: Record<string, unknown>,
@@ -230,13 +240,14 @@ export async function bootstrapAdmin(
}
export async function authenticatePassword(
input: Record<string, unknown>,
clientAddress: string
clientAddress: string,
loginDevice?: string
): Promise<AuthResult> {
if (input.rememberMe !== undefined && typeof input.rememberMe !== 'boolean')
throw new AuthError('Keep me signed in must be true or false.');
const name =
typeof input.username === 'string' ? input.username.slice(0, 64).toLowerCase() : '';
consumeAuthAttempt(clientAddress, name);
consumeAuthAttempt(clientAddress, name, 'login', loginDevice);
const row = database().prepare('SELECT * FROM auth_users WHERE username=?').get(name) as
UserRow | undefined;
const valid = await verifyPassword(input.password, row?.password_hash ?? null);
@@ -258,7 +269,8 @@ export async function authenticatePassword(
recordAudit(current.id, 'login', current.id);
return {
user: getUser(current.id)!,
session: createSession(current.id, input.rememberMe === true)
session: createSession(current.id, input.rememberMe === true),
loginDevice: createLoginDevice(current, loginDevice)
};
})
.immediate();
@@ -266,6 +278,32 @@ export async function authenticatePassword(
return result;
}
function readLoginDevice(account: string, token: string | undefined) {
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
return database()
.prepare(
`SELECT d.expires_at FROM auth_login_devices d JOIN auth_users u ON u.id=d.user_id
WHERE d.token_hash=? AND d.expires_at>? AND u.username=? AND u.disabled=0`
)
.get(digest(token), Date.now(), account) as { expires_at: number } | undefined;
}
/** Proof of a previous password login only; this token never authenticates a session. */
function createLoginDevice(user: UserRow, previousToken?: string) {
const now = Date.now();
database().prepare('DELETE FROM auth_login_devices WHERE expires_at<=?').run(now);
const previous = readLoginDevice(user.username, previousToken);
if (previous && previousToken) return { token: previousToken, expiresAt: previous.expires_at };
const token = randomBytes(32).toString('base64url');
const expiresAt = now + LOGIN_DEVICE_LIFETIME_MS;
database()
.prepare(
'INSERT INTO auth_login_devices(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)'
)
.run(digest(token), user.id, now, expiresAt);
return { token, expiresAt };
}
export function createSession(
userId: string,
remembered = false
@@ -327,6 +365,7 @@ export function revokeUserSessions(actorId: string, userId: string): void {
requireAdministrator(actorId);
if (!userRow(userId)) throw new AuthError('User not found.', 404);
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId);
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(userId);
recordAudit(actorId, 'sessions.revoked', userId);
})();
}
@@ -427,8 +466,10 @@ export async function updateUser(
Date.now(),
id
);
if (passwordHash || disabled !== current.disabled || nextRole !== current.role)
if (passwordHash || disabled !== current.disabled || nextRole !== current.role) {
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(id);
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(id);
}
recordAudit(actorId, 'user.updated', id, {
role: nextRole,
disabled: !!disabled,
@@ -469,6 +510,7 @@ export async function changePassword(
)
.run(passwordHash, Date.now(), userId);
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId);
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(userId);
recordAudit(userId, 'password.changed', userId);
return {
user: getUser(userId)!,
+11
View File
@@ -33,6 +33,17 @@ export function migrateRememberedSessions(handle: Database.Database): void {
`);
}
export function migrateLoginDevices(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS auth_login_devices (
token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS auth_login_devices_user ON auth_login_devices(user_id);
CREATE INDEX IF NOT EXISTS auth_login_devices_expires_at ON auth_login_devices(expires_at);
`);
}
/** Run with foreign keys disabled by the migration runner before its transaction. */
export function migrateSuperViewerRole(handle: Database.Database): void {
const schema = handle
+3 -1
View File
@@ -16,6 +16,7 @@ import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js'
import { migrateHostSchema } from '../hosts/schema.js';
import {
migrateAuthSchema,
migrateLoginDevices,
migrateRememberedSessions,
migrateSuperViewerRole,
migrateModeratorRole
@@ -160,7 +161,8 @@ export const migrations: readonly Migration[] = [
'ALTER TABLE moderation_runtimes ADD COLUMN session_sequence INTEGER NOT NULL DEFAULT 0'
);
}
}
},
{ version: 28, name: 'known login devices', run: migrateLoginDevices }
];
export const databaseVersion = migrations.length;