522 lines
21 KiB
TypeScript
522 lines
21 KiB
TypeScript
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
||
import { db } from '../db/index.js';
|
||
import { hashPassword, verifyPassword } from './passwords.js';
|
||
import { systemUsername, type AuthUser, type UserRole } from '../../auth.js';
|
||
|
||
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();
|
||
}
|
||
type UserRow = {
|
||
id: string;
|
||
username: string;
|
||
display_name: string;
|
||
password_hash: string;
|
||
role: UserRole;
|
||
disabled: number;
|
||
must_change_password: number;
|
||
created_at: number;
|
||
updated_at: number;
|
||
last_login_at: number | null;
|
||
};
|
||
export class AuthError extends Error {
|
||
constructor(
|
||
message: string,
|
||
public status = 400
|
||
) {
|
||
super(message);
|
||
}
|
||
}
|
||
function present(row: UserRow): AuthUser {
|
||
return {
|
||
id: row.id,
|
||
username: row.username,
|
||
displayName: row.display_name,
|
||
role: row.role,
|
||
disabled: !!row.disabled,
|
||
mustChangePassword: !!row.must_change_password,
|
||
createdAt: new Date(row.created_at).toISOString(),
|
||
updatedAt: new Date(row.updated_at).toISOString(),
|
||
lastLoginAt: row.last_login_at === null ? null : new Date(row.last_login_at).toISOString()
|
||
};
|
||
}
|
||
function userRow(id: string) {
|
||
return database().prepare('SELECT * FROM auth_users WHERE id=?').get(id) as UserRow | undefined;
|
||
}
|
||
export function getUser(id: string): AuthUser | null {
|
||
const row = userRow(id);
|
||
return row ? present(row) : null;
|
||
}
|
||
export function listUsers(): AuthUser[] {
|
||
return (
|
||
database().prepare('SELECT * FROM auth_users ORDER BY username').all() as UserRow[]
|
||
).map(present);
|
||
}
|
||
export function setupRequired(): boolean {
|
||
return !database().prepare('SELECT 1 FROM auth_users LIMIT 1').get();
|
||
}
|
||
function username(value: unknown): string {
|
||
if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{2,63}$/.test(value))
|
||
throw new AuthError('Usernames need 3–64 letters, numbers, dots, underscores or hyphens.');
|
||
const normalized = value.toLowerCase();
|
||
if (normalized === systemUsername.toLowerCase())
|
||
throw new AuthError(
|
||
`${systemUsername} is reserved for system actions. Choose another username.`
|
||
);
|
||
return normalized;
|
||
}
|
||
function displayName(value: unknown, fallback: string): string {
|
||
const name = value === undefined ? fallback : value;
|
||
if (
|
||
typeof name !== 'string' ||
|
||
!name.trim() ||
|
||
name.length > 100 ||
|
||
// eslint-disable-next-line no-control-regex -- Reject control characters in display names.
|
||
/[\x00-\x1f\x7f]/.test(name)
|
||
)
|
||
throw new AuthError('Display name must contain 1–100 printable characters.');
|
||
return name.trim();
|
||
}
|
||
function role(value: unknown): UserRole {
|
||
if (!['admin', 'super_viewer', 'operator', 'moderator', 'viewer'].includes(value as string))
|
||
throw new AuthError('Choose administrator, super viewer, operator, moderator or viewer.');
|
||
return value as UserRole;
|
||
}
|
||
export function recordAudit(
|
||
actorId: string | null,
|
||
action: string,
|
||
targetId: string | null = null,
|
||
details: Record<string, unknown> = {}
|
||
): void {
|
||
database()
|
||
.prepare(
|
||
'INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(?,?,?,?,?)'
|
||
)
|
||
.run(actorId, action, targetId, JSON.stringify(details), Date.now());
|
||
}
|
||
export function listAuthAudit() {
|
||
return database()
|
||
.prepare(
|
||
`SELECT a.id,a.actor_id AS actorId,u.username AS actor,a.action,a.target_id AS targetId,a.details,a.created_at AS createdAt
|
||
FROM auth_audit a LEFT JOIN auth_users u ON u.id=a.actor_id ORDER BY a.id DESC LIMIT 200`
|
||
)
|
||
.all();
|
||
}
|
||
|
||
/** Persistent fixed windows are consumed before hashing, except for enabled admin roles. */
|
||
export function consumeAuthAttempt(
|
||
clientAddress: string,
|
||
account: string,
|
||
kind: 'login' | 'setup' | 'password' = 'login',
|
||
loginDevice?: string
|
||
): void {
|
||
// Resolve the current role from storage; setup never has an existing account exemption.
|
||
if (
|
||
kind !== 'setup' &&
|
||
database()
|
||
.prepare(
|
||
`SELECT 1 FROM auth_users WHERE ${kind === 'login' ? 'username' : 'id'}=?
|
||
AND disabled=0 AND role IN ('admin','super_viewer')`
|
||
)
|
||
.get(kind === 'login' ? account.toLowerCase() : account)
|
||
)
|
||
return;
|
||
const now = Date.now();
|
||
const windowMs = 15 * 60_000;
|
||
const ipKey = digest(`${kind}:ip:${clientAddress}`);
|
||
// 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 limits) {
|
||
const previous = database()
|
||
.prepare('SELECT window_start,attempts FROM auth_rate_limits WHERE key=?')
|
||
.get(key) as { window_start: number; attempts: number } | undefined;
|
||
if (
|
||
previous &&
|
||
previous.window_start > now - windowMs &&
|
||
previous.attempts >= limit
|
||
)
|
||
throw new AuthError('Too many attempts. Try again in 15 minutes.', 429);
|
||
}
|
||
for (const [key] of limits)
|
||
database()
|
||
.prepare(
|
||
`INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,1)
|
||
ON CONFLICT(key) DO UPDATE SET attempts=CASE WHEN window_start <= ? THEN 1 ELSE attempts+1 END,
|
||
window_start=CASE WHEN window_start <= ? THEN excluded.window_start ELSE window_start END`
|
||
)
|
||
.run(key, now, now - windowMs, now - windowMs);
|
||
})
|
||
.immediate();
|
||
}
|
||
|
||
function insertUser(input: {
|
||
username: string;
|
||
displayName: string;
|
||
role: UserRole;
|
||
passwordHash: string;
|
||
mustChangePassword: boolean;
|
||
}): AuthUser {
|
||
if (database().prepare('SELECT 1 FROM auth_users WHERE username=?').get(input.username))
|
||
throw new AuthError('That username is already in use.', 409);
|
||
const id = randomUUID();
|
||
const now = Date.now();
|
||
database()
|
||
.prepare(
|
||
`INSERT INTO auth_users(id,username,display_name,password_hash,role,must_change_password,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`
|
||
)
|
||
.run(
|
||
id,
|
||
input.username,
|
||
input.displayName,
|
||
input.passwordHash,
|
||
input.role,
|
||
Number(input.mustChangePassword),
|
||
now,
|
||
now
|
||
);
|
||
return getUser(id)!;
|
||
}
|
||
export interface AuthResult {
|
||
user: AuthUser;
|
||
session: { token: string; expiresAt: number };
|
||
loginDevice?: { token: string; expiresAt: number };
|
||
}
|
||
export async function bootstrapAdmin(
|
||
input: Record<string, unknown>,
|
||
clientAddress: string
|
||
): Promise<AuthResult> {
|
||
consumeAuthAttempt(clientAddress, 'bootstrap', 'setup');
|
||
if (!setupRequired()) throw new AuthError('Initial setup is already complete.', 409);
|
||
const expected = process.env.EC_SETUP_TOKEN;
|
||
if (!expected || expected.length < 32)
|
||
throw new AuthError(
|
||
'The controller owner must configure EC_SETUP_TOKEN with at least 32 characters before setup.',
|
||
503
|
||
);
|
||
if (
|
||
typeof input.token !== 'string' ||
|
||
!timingSafeEqual(
|
||
Buffer.from(digest(input.token), 'hex'),
|
||
Buffer.from(digest(expected), 'hex')
|
||
)
|
||
)
|
||
throw new AuthError('Invalid setup token.', 401);
|
||
const name = username(input.username);
|
||
const label = displayName(input.displayName, name);
|
||
const passwordHash = await hashPassword(input.password);
|
||
return database()
|
||
.transaction(() => {
|
||
if (!setupRequired()) throw new AuthError('Initial setup is already complete.', 409);
|
||
const user = insertUser({
|
||
username: name,
|
||
displayName: label,
|
||
passwordHash,
|
||
role: 'admin',
|
||
mustChangePassword: false
|
||
});
|
||
recordAudit(user.id, 'setup', user.id);
|
||
return { user, session: createSession(user.id) };
|
||
})
|
||
.immediate();
|
||
}
|
||
export async function authenticatePassword(
|
||
input: Record<string, unknown>,
|
||
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, '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);
|
||
const result = database()
|
||
.transaction(() => {
|
||
const current = row ? userRow(row.id) : undefined;
|
||
if (
|
||
!valid ||
|
||
!current ||
|
||
current.disabled ||
|
||
current.password_hash !== row?.password_hash
|
||
) {
|
||
recordAudit(null, 'login.failed');
|
||
return null;
|
||
}
|
||
database()
|
||
.prepare('UPDATE auth_users SET last_login_at=? WHERE id=?')
|
||
.run(Date.now(), current.id);
|
||
recordAudit(current.id, 'login', current.id);
|
||
return {
|
||
user: getUser(current.id)!,
|
||
session: createSession(current.id, input.rememberMe === true),
|
||
loginDevice: createLoginDevice(current, loginDevice)
|
||
};
|
||
})
|
||
.immediate();
|
||
if (!result) throw new AuthError('Invalid username or password.', 401);
|
||
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
|
||
): { token: string; expiresAt: number } {
|
||
const now = Date.now();
|
||
const user = userRow(userId);
|
||
if (!user || user.disabled) throw new AuthError('Account is unavailable.', 401);
|
||
const token = randomBytes(32).toString('base64url');
|
||
const expiresAt = now + (remembered ? REMEMBERED_SESSION_LIFETIME_MS : SESSION_LIFETIME_MS);
|
||
database()
|
||
.prepare(
|
||
'DELETE FROM auth_sessions WHERE expires_at <= ? OR (remembered=0 AND last_seen_at <= ?)'
|
||
)
|
||
.run(now, now - SESSION_IDLE_MS);
|
||
database()
|
||
.prepare(
|
||
'INSERT INTO auth_sessions(token_hash,user_id,created_at,expires_at,last_seen_at,remembered) VALUES(?,?,?,?,?,?)'
|
||
)
|
||
.run(digest(token), userId, now, expiresAt, now, Number(remembered));
|
||
return { token, expiresAt };
|
||
}
|
||
export function readSession(
|
||
token: string | undefined,
|
||
touch = true
|
||
): { user: AuthUser; tokenHash: string; expiresAt: number; remembered: boolean } | null {
|
||
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
|
||
return readSessionHash(digest(token), touch);
|
||
}
|
||
export function readSessionHash(
|
||
tokenHash: string,
|
||
touch = false
|
||
): { user: AuthUser; tokenHash: string; expiresAt: number; remembered: boolean } | null {
|
||
const now = Date.now();
|
||
const row = database()
|
||
.prepare(
|
||
`SELECT u.*,s.expires_at,s.last_seen_at,s.remembered FROM auth_sessions s JOIN auth_users u ON u.id=s.user_id
|
||
WHERE s.token_hash=? AND s.expires_at>? AND (s.remembered=1 OR s.last_seen_at>?) AND u.disabled=0`
|
||
)
|
||
.get(tokenHash, now, now - SESSION_IDLE_MS) as
|
||
(UserRow & { expires_at: number; last_seen_at: number; remembered: number }) | undefined;
|
||
if (!row) return null;
|
||
if (touch && row.last_seen_at < now - 60_000)
|
||
database()
|
||
.prepare('UPDATE auth_sessions SET last_seen_at=? WHERE token_hash=?')
|
||
.run(now, tokenHash);
|
||
return {
|
||
user: present(row),
|
||
tokenHash,
|
||
expiresAt: row.expires_at,
|
||
remembered: !!row.remembered
|
||
};
|
||
}
|
||
export function revokeSession(token: string | undefined): void {
|
||
if (token)
|
||
database().prepare('DELETE FROM auth_sessions WHERE token_hash=?').run(digest(token));
|
||
}
|
||
export function revokeUserSessions(actorId: string, userId: string): void {
|
||
database().transaction(() => {
|
||
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);
|
||
})();
|
||
}
|
||
function requireAdministrator(actorId: string) {
|
||
const actor = userRow(actorId);
|
||
if (!actor || actor.disabled || actor.role !== 'admin' || actor.must_change_password)
|
||
throw new AuthError('Administrator access is required.', 403);
|
||
}
|
||
type AccountSession = { tokenHash: string; clientAddress: string };
|
||
function requireSessionUser(userId: string, tokenHash: string) {
|
||
const session = readSessionHash(tokenHash);
|
||
if (!session || session.user.id !== userId) throw new AuthError('Sign in to continue.', 401);
|
||
return session;
|
||
}
|
||
async function confirmAccountPassword(
|
||
actorId: string,
|
||
currentPassword: unknown,
|
||
session: AccountSession
|
||
): Promise<void> {
|
||
if (!requireSessionUser(actorId, session.tokenHash).remembered) return;
|
||
consumeAuthAttempt(session.clientAddress, actorId, 'password');
|
||
if (!(await verifyPassword(currentPassword, userRow(actorId)?.password_hash ?? null)))
|
||
throw new AuthError('Enter your current password to confirm account changes.', 403);
|
||
requireSessionUser(actorId, session.tokenHash);
|
||
}
|
||
export async function createUser(
|
||
actorId: string,
|
||
input: Record<string, unknown>,
|
||
session?: AccountSession
|
||
): Promise<AuthUser> {
|
||
requireAdministrator(actorId);
|
||
if (session) await confirmAccountPassword(actorId, input.currentPassword, session);
|
||
const name = username(input.username),
|
||
label = displayName(input.displayName, name),
|
||
userRole = role(input.role);
|
||
const passwordHash = await hashPassword(input.password);
|
||
return database()
|
||
.transaction(() => {
|
||
requireAdministrator(actorId);
|
||
if (session) requireSessionUser(actorId, session.tokenHash);
|
||
const user = insertUser({
|
||
username: name,
|
||
displayName: label,
|
||
role: userRole,
|
||
passwordHash,
|
||
mustChangePassword: true
|
||
});
|
||
recordAudit(actorId, 'user.created', user.id, { role: userRole });
|
||
return user;
|
||
})
|
||
.immediate();
|
||
}
|
||
export async function updateUser(
|
||
actorId: string,
|
||
id: string,
|
||
input: Record<string, unknown>,
|
||
session?: AccountSession
|
||
): Promise<AuthUser> {
|
||
requireAdministrator(actorId);
|
||
if (session) await confirmAccountPassword(actorId, input.currentPassword, session);
|
||
if (input.disabled !== undefined && typeof input.disabled !== 'boolean')
|
||
throw new AuthError('Disabled must be true or false.');
|
||
if (input.password !== undefined && actorId === id)
|
||
throw new AuthError('Change your own password from Account using your current password.');
|
||
const passwordHash = input.password === undefined ? null : await hashPassword(input.password);
|
||
return database()
|
||
.transaction(() => {
|
||
requireAdministrator(actorId);
|
||
if (session) requireSessionUser(actorId, session.tokenHash);
|
||
const current = userRow(id);
|
||
if (!current) throw new AuthError('User not found.', 404);
|
||
const nextRole = input.role === undefined ? current.role : role(input.role);
|
||
const disabled =
|
||
input.disabled === undefined ? current.disabled : Number(input.disabled);
|
||
const label = displayName(input.displayName, current.display_name);
|
||
if (
|
||
current.role === 'admin' &&
|
||
!current.disabled &&
|
||
(nextRole !== 'admin' || disabled)
|
||
) {
|
||
const other = database()
|
||
.prepare(
|
||
"SELECT 1 FROM auth_users WHERE role='admin' AND disabled=0 AND id!=? LIMIT 1"
|
||
)
|
||
.get(id);
|
||
if (!other) throw new AuthError('Keep at least one enabled administrator.', 409);
|
||
}
|
||
database()
|
||
.prepare(
|
||
`UPDATE auth_users SET display_name=?,role=?,disabled=?,password_hash=?,must_change_password=?,updated_at=? WHERE id=?`
|
||
)
|
||
.run(
|
||
label,
|
||
nextRole,
|
||
disabled,
|
||
passwordHash ?? current.password_hash,
|
||
passwordHash ? 1 : current.must_change_password,
|
||
Date.now(),
|
||
id
|
||
);
|
||
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,
|
||
passwordReset: !!passwordHash
|
||
});
|
||
return getUser(id)!;
|
||
})
|
||
.immediate();
|
||
}
|
||
export async function changePassword(
|
||
userId: string,
|
||
currentPassword: unknown,
|
||
newPassword: unknown,
|
||
sessionHash?: string
|
||
): Promise<AuthResult> {
|
||
const original = userRow(userId);
|
||
if (
|
||
!original ||
|
||
original.disabled ||
|
||
!(await verifyPassword(currentPassword, original.password_hash))
|
||
)
|
||
throw new AuthError('Current password is incorrect.', 400);
|
||
if (currentPassword === newPassword) throw new AuthError('Choose a different password.');
|
||
const passwordHash = await hashPassword(newPassword);
|
||
return database()
|
||
.transaction(() => {
|
||
const session =
|
||
sessionHash === undefined ? null : requireSessionUser(userId, sessionHash);
|
||
const current = userRow(userId);
|
||
if (!current || current.disabled || current.password_hash !== original.password_hash)
|
||
throw new AuthError(
|
||
'Account changed during password verification. Sign in again.',
|
||
409
|
||
);
|
||
database()
|
||
.prepare(
|
||
'UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=?'
|
||
)
|
||
.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)!,
|
||
session: createSession(userId, session?.remembered ?? false)
|
||
};
|
||
})
|
||
.immediate();
|
||
}
|