163 lines
5.5 KiB
TypeScript
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.');
|
|
}
|
|
}
|