This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)!,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authenticatePassword, revokeSession } from '$lib/server/auth/index.js';
|
||||
import { authBody, authError, sessionCookie, sessionResponse } from '$lib/server/auth/http.js';
|
||||
import {
|
||||
authBody,
|
||||
authError,
|
||||
loginDeviceCookie,
|
||||
sessionCookie,
|
||||
sessionResponse
|
||||
} from '$lib/server/auth/http.js';
|
||||
export const POST: RequestHandler = async ({ request, cookies, url, getClientAddress }) => {
|
||||
try {
|
||||
const result = await authenticatePassword(await authBody(request), getClientAddress());
|
||||
const result = await authenticatePassword(
|
||||
await authBody(request),
|
||||
getClientAddress(),
|
||||
cookies.get(loginDeviceCookie(url))
|
||||
);
|
||||
revokeSession(cookies.get(sessionCookie(url)));
|
||||
return sessionResponse(result, cookies, url);
|
||||
} catch (cause) {
|
||||
|
||||
Reference in New Issue
Block a user