MyKindlebox/mykindlebox/server/auth.ts

163 lines
5.5 KiB
TypeScript

import { randomUUID, randomBytes, scryptSync, timingSafeEqual } from 'crypto';
import { NextFunction, Request, Response } from 'express';
import { eq, lt, sql } from 'drizzle-orm';
import { db } from './db/client';
import { users, authSessions } from './db/schema';
// User-account session auth (see memory-bank/FEATURE_User_Accounts.md).
//
// Replaces the original single shared-password mode (AUTH_PASSWORD env var,
// in-memory session Set): accounts live in the `users` table, sessions in
// `auth_sessions` (so a server restart no longer signs everyone out), and the
// first account is created through POST /api/auth/setup while the users table
// is empty. Auth is always on once a user exists — there is no open mode.
const COOKIE_NAME = 'mkb_session';
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
// Flat pause on a failed login — cheap brute-force damping without the
// bookkeeping of per-IP counters (single-family LAN app).
const FAILED_LOGIN_DELAY_MS = 350;
export interface SessionUser {
id: number;
username: string;
}
// requireAuth attaches the session's user here; protected routes can cast.
export interface AuthedRequest extends Request {
user?: SessionUser;
}
// --- Password hashing (node:crypto scrypt — no native dependency) ---
export function hashPassword(password: string): string {
const salt = randomBytes(16);
const hash = scryptSync(password, salt, 64);
return `scrypt:${salt.toString('hex')}:${hash.toString('hex')}`;
}
export function verifyPassword(password: string, stored: string): boolean {
const [scheme, saltHex, hashHex] = stored.split(':');
if (scheme !== 'scrypt' || !saltHex || !hashHex) return false;
const expected = Buffer.from(hashHex, 'hex');
const actual = scryptSync(password, Buffer.from(saltHex, 'hex'), expected.length);
return timingSafeEqual(actual, expected);
}
// --- Users ---
export function anyUsersExist(): boolean {
return db.select({ id: users.id }).from(users).limit(1).get() !== undefined;
}
// Case-insensitive lookup: the schema's UNIQUE index is case-sensitive, so both
// login and the duplicate-username guard go through lower() instead.
export function findUserByUsername(username: string) {
return db
.select()
.from(users)
.where(sql`lower(${users.username}) = lower(${username})`)
.get();
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const failedLoginDelayMs = FAILED_LOGIN_DELAY_MS;
export const MIN_PASSWORD_LENGTH = 8;
// Shared by POST /api/auth/setup and POST /api/users — returns an error message
// for the 400 response, or null when the pair is acceptable.
export function validateNewCredentials(username: unknown, password: unknown): string | null {
if (typeof username !== 'string' || username.trim().length === 0) {
return 'Username is required';
}
if (username.trim().length > 64) {
return 'Username is too long (max 64 characters)';
}
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
return `Password must be at least ${MIN_PASSWORD_LENGTH} characters`;
}
return null;
}
// --- Cookie / session plumbing ---
function parseCookies(header: string | undefined): Record<string, string> {
const out: Record<string, string> = {};
if (!header) return out;
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq === -1) continue;
const key = part.slice(0, eq).trim();
if (key) out[key] = decodeURIComponent(part.slice(eq + 1).trim());
}
return out;
}
export function readSessionToken(req: Request): string | undefined {
return parseCookies(req.headers.cookie)[COOKIE_NAME];
}
export function createSession(res: Response, userId: number): void {
const token = randomUUID();
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
db.insert(authSessions).values({ token, userId, expiresAt }).run();
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: SESSION_TTL_MS,
});
}
export function destroySession(req: Request, res: Response): void {
const token = readSessionToken(req);
if (token) db.delete(authSessions).where(eq(authSessions.token, token)).run();
res.clearCookie(COOKIE_NAME, { path: '/' });
}
// Resolve the request's session to its user, lazily purging expired sessions.
// ISO strings compare lexicographically, so `lt` against now-ISO is correct.
export function getSessionUser(req: Request): SessionUser | null {
const token = readSessionToken(req);
if (!token) return null;
db.delete(authSessions).where(lt(authSessions.expiresAt, new Date().toISOString())).run();
const row = db
.select({ id: users.id, username: users.username })
.from(authSessions)
.innerJoin(users, eq(authSessions.userId, users.id))
.where(eq(authSessions.token, token))
.get();
return row ?? null;
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const user = getSessionUser(req);
if (!user) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
(req as AuthedRequest).user = user;
next();
}
export function logAuthStatus() {
if (process.env.AUTH_PASSWORD) {
console.warn(
'Auth: AUTH_PASSWORD is set but no longer used — auth is account-based now. ' +
'Remove MYKINDLEBOX_PASSWORD/AUTH_PASSWORD from your environment.'
);
}
if (anyUsersExist()) {
console.log('Auth: user accounts active.');
} else {
console.log('Auth: no users yet — the app will show the first-run setup screen.');
}
}