Add optional 30-day remembered sessions
CI / checks (push) Successful in 6m29s

This commit is contained in:
2026-10-13 14:42:53 -04:00
parent e85dc58130
commit 82e07ea33e
13 changed files with 677 additions and 72 deletions
+61 -16
View File
@@ -6,6 +6,7 @@ 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;
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
function database() {
return db();
@@ -231,6 +232,8 @@ export async function authenticatePassword(
input: Record<string, unknown>,
clientAddress: 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);
@@ -253,54 +256,67 @@ export async function authenticatePassword(
.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) };
return {
user: getUser(current.id)!,
session: createSession(current.id, input.rememberMe === true)
};
})
.immediate();
if (!result) throw new AuthError('Invalid username or password.', 401);
return result;
}
export function createSession(userId: string): { token: string; expiresAt: number } {
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 + SESSION_LIFETIME_MS;
const expiresAt = now + (remembered ? REMEMBERED_SESSION_LIFETIME_MS : SESSION_LIFETIME_MS);
database()
.prepare('DELETE FROM auth_sessions WHERE expires_at <= ? OR last_seen_at <= ?')
.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) VALUES(?,?,?,?,?)'
'INSERT INTO auth_sessions(token_hash,user_id,created_at,expires_at,last_seen_at,remembered) VALUES(?,?,?,?,?,?)'
)
.run(digest(token), userId, now, expiresAt, now);
.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 } | null {
): { 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 } | null {
): { 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 FROM auth_sessions s JOIN auth_users u ON u.id=s.user_id
WHERE s.token_hash=? AND s.expires_at>? AND s.last_seen_at>? AND u.disabled=0`
`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 }) | undefined;
(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 };
return {
user: present(row),
tokenHash,
expiresAt: row.expires_at,
remembered: !!row.remembered
};
}
export function revokeSession(token: string | undefined): void {
if (token)
@@ -319,11 +335,30 @@ function requireAdministrator(actorId: string) {
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>
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);
@@ -331,6 +366,7 @@ export async function createUser(
return database()
.transaction(() => {
requireAdministrator(actorId);
if (session) requireSessionUser(actorId, session.tokenHash);
const user = insertUser({
username: name,
displayName: label,
@@ -346,9 +382,11 @@ export async function createUser(
export async function updateUser(
actorId: string,
id: string,
input: Record<string, unknown>
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)
@@ -357,6 +395,7 @@ export async function updateUser(
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);
@@ -402,7 +441,8 @@ export async function updateUser(
export async function changePassword(
userId: string,
currentPassword: unknown,
newPassword: unknown
newPassword: unknown,
sessionHash?: string
): Promise<AuthResult> {
const original = userRow(userId);
if (
@@ -415,6 +455,8 @@ export async function changePassword(
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(
@@ -428,7 +470,10 @@ export async function changePassword(
.run(passwordHash, Date.now(), userId);
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId);
recordAudit(userId, 'password.changed', userId);
return { user: getUser(userId)!, session: createSession(userId) };
return {
user: getUser(userId)!,
session: createSession(userId, session?.remembered ?? false)
};
})
.immediate();
}
+9
View File
@@ -24,6 +24,15 @@ export function migrateAuthSchema(handle: Database.Database): void {
`);
}
export function migrateRememberedSessions(handle: Database.Database): void {
const columns = handle.pragma('table_info(auth_sessions)') as { name: string }[];
if (columns.some((column) => column.name === 'remembered')) return;
handle.exec(`
ALTER TABLE auth_sessions ADD COLUMN remembered INTEGER NOT NULL DEFAULT 0
CHECK(remembered IN (0,1));
`);
}
/** Run with foreign keys disabled by the migration runner before its transaction. */
export function migrateSuperViewerRole(handle: Database.Database): void {
const schema = handle