auth: sessions, login/logout/me/change-password, CSRF, guards, audit

argon2id passwords with a min length and a common-password list (SecLists,
MIT). Login failures are limited per IP and per email; must_change_password
blocks everything outside /api/auth/*. The first admin is created from env
on startup. pnpm dev derives DATABASE_URL from .env.

Task: M1-T3
This commit is contained in:
iamdoubz committed 2026-10-17 15:08:44 -05:00
1 parent 15b8a20751
commit 13733ac262
20 files changed
+1937 -46

No files matched your search

+2
View File
@@ -11,7 +11,9 @@
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"@fastify/cookie": "^11.1.3",
"@fastify/static": "^10.1.6",
"@node-rs/argon2": "^2.2.2",
"@selfphoneng/shared": "workspace:*",
"drizzle-orm": "^0.45.4",
"fastify": "^5.12.7",
-35
View File
@@ -1,35 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { buildApp } from './app.ts';
import { ConfigError, loadConfig } from './config.ts';
const env = { APP_SECRET: Buffer.alloc(32, 7).toString('base64'), PUBLIC_HOSTNAME: 'pbx.test' };
describe('config', () => {
it('parses a minimal env with defaults', () => {
expect(loadConfig(env)).toMatchObject({ API_PORT: 8080, TRUST_PROXY: true });
});
it('rejects a missing or short APP_SECRET', () => {
expect(() => loadConfig({ PUBLIC_HOSTNAME: 'x' })).toThrow(ConfigError);
expect(() => loadConfig({ ...env, APP_SECRET: 'c2hvcnQ=' })).toThrow(/APP_SECRET/);
});
});
describe('app', () => {
const pub = mkdtempSync(join(tmpdir(), 'sp-public-'));
writeFileSync(join(pub, 'index.html'), '<h1>spa</h1>');
const app = buildApp({ ...loadConfig(env), LOG_LEVEL: 'fatal' }, pub);
it('GET /healthz', async () => {
const r = await app.inject('/healthz');
expect(r.json()).toEqual({ ok: true });
});
it('falls back to index.html for SPA routes only', async () => {
expect((await app.inject('/admin/users')).body).toBe('<h1>spa</h1>');
expect((await app.inject('/api/nope')).statusCode).toBe(404);
expect((await app.inject('/prov/x.cfg')).statusCode).toBe(404);
});
});
+46 -8
View File
@@ -2,13 +2,25 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import Fastify from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import { sql } from 'drizzle-orm';
import type { Config } from './config.ts';
import type { Db } from './db/client.ts';
import { errorHandler, forbidden, notFound, sendError } from './errors.ts';
import { authRoutes } from './routes/auth.ts';
declare module 'fastify' {
interface FastifyInstance {
db: Db;
}
}
// Paths the SPA must never swallow (04 §1 step 5).
const NON_SPA = /^\/(api|prov|hooks)(\/|$)/;
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export function buildApp(config: Config, publicDir = resolve('public')) {
export function buildApp(config: Config, db: Db, publicDir = resolve('public')) {
const app = Fastify({
trustProxy: config.TRUST_PROXY ? '127.0.0.1' : false,
logger: {
@@ -25,16 +37,42 @@ export function buildApp(config: Config, publicDir = resolve('public')) {
},
});
app.get('/healthz', async () => ({ ok: true }));
app.decorate('db', db);
app.decorateRequest('user', null);
app.decorateRequest('sessionId', null);
app.register(fastifyCookie);
app.setErrorHandler(errorHandler);
// CSRF (04 §2.2): browsers can't add this header cross-site without CORS, and CORS is off.
// ponytail: required on every unsafe /api request, not only cookie-authenticated ones; the
// Bearer-token exemption arrives with API tokens in M11.
app.addHook('onRequest', async (req, reply) => {
if (
req.url.startsWith('/api/') &&
!SAFE_METHODS.has(req.method) &&
req.headers['x-requested-with'] !== 'selfphoneng'
)
return sendError(reply, forbidden('Missing X-Requested-With header', 'csrf'));
});
app.get('/healthz', async (_req, reply) => {
try {
await db.execute(sql`select 1`);
return { ok: true };
} catch {
return reply.code(503).send({ ok: false, reasons: ['db'] });
}
});
app.register(authRoutes);
if (existsSync(publicDir)) {
app.register(fastifyStatic, { root: publicDir, wildcard: false });
app.setNotFoundHandler((req, reply) => {
if (req.method === 'GET' && !NON_SPA.test(req.url)) return reply.sendFile('index.html');
return reply
.code(404)
.send({ error: { code: 'not_found', message: 'Not found', details: {} } });
});
}
app.setNotFoundHandler((req, reply) => {
if (existsSync(publicDir) && req.method === 'GET' && !NON_SPA.test(req.url))
return reply.sendFile('index.html');
return sendError(reply, notFound());
});
return app;
}
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// preHandler guards (04 §2.4). Routes list them in `preHandler`.
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { users } from '../db/schema.ts';
import { forbidden, unauthorized } from '../errors.ts';
import { COOKIE, getSessionUser, setSessionCookie } from './sessions.ts';
export type CurrentUser = typeof users.$inferSelect;
declare module 'fastify' {
interface FastifyRequest {
user: CurrentUser | null;
sessionId: string | null;
}
}
/** Sets req.user from the session cookie. Enforces must_change_password outside /api/auth/*. */
export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Promise<void> {
const token = req.cookies[COOKIE];
const found = token ? await getSessionUser(req.server.db, token) : null;
if (!found) throw unauthorized();
if (found.extended) setSessionCookie(reply, token!);
req.user = found.user;
req.sessionId = found.sessionId;
if (found.user.mustChangePassword && !req.url.startsWith('/api/auth/'))
throw forbidden('Change your password first', 'password_change_required');
}
export async function requireAdmin(req: FastifyRequest, reply: FastifyReply): Promise<void> {
await requireAuth(req, reply);
if (req.user!.role !== 'admin') throw forbidden('Admins only');
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Login failure limits (04 §2.2): 5 per IP per 15 min, 10 per email per hour.
// ponytail: in-process memory, resets on restart; fine for one api process (v1 has no HA).
const MIN = 60_000;
const RULES = { ip: { max: 5, windowMs: 15 * MIN }, email: { max: 10, windowMs: 60 * MIN } };
const failures = new Map<string, number[]>();
function recent(key: string, windowMs: number, now: number): number[] {
const list = (failures.get(key) ?? []).filter((t) => now - t < windowMs);
if (list.length) failures.set(key, list);
else failures.delete(key);
return list;
}
export function isLoginBlocked(ip: string, email: string, now = Date.now()): boolean {
return (
recent(`ip:${ip}`, RULES.ip.windowMs, now).length >= RULES.ip.max ||
recent(`email:${email.toLowerCase()}`, RULES.email.windowMs, now).length >= RULES.email.max
);
}
export function recordLoginFailure(ip: string, email: string, now = Date.now()): void {
for (const key of [`ip:${ip}`, `email:${email.toLowerCase()}`]) {
failures.set(key, [...(failures.get(key) ?? []), now]);
}
}
/** Tests only. */
export function resetLoginLimiter(): void {
failures.clear();
}
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// argon2id with library defaults; minimum 10 chars; reject common passwords (04 §2.1).
import { readFileSync } from 'node:fs';
import { hash, verify } from '@node-rs/argon2';
import { PASSWORD_MIN } from '@selfphoneng/shared';
import { badRequest } from '../errors.ts';
const COMMON = new Set(
readFileSync(new URL('./common-passwords.txt', import.meta.url), 'utf8')
.split('\n')
.filter((l) => l && !l.startsWith('#'))
.map((l) => l.toLowerCase()),
);
export const hashPassword = (plain: string) => hash(plain);
export async function verifyPassword(hashed: string, plain: string): Promise<boolean> {
try {
return await verify(hashed, plain);
} catch {
return false;
}
}
// A real hash to verify against when the email is unknown, so timing doesn't reveal which emails exist.
const DUMMY = hash('dummy-password-for-timing');
export async function burnVerify(plain: string): Promise<void> {
await verifyPassword(await DUMMY, plain);
}
export function assertPasswordPolicy(plain: string): void {
if (plain.length < PASSWORD_MIN)
throw badRequest(`Password must be at least ${PASSWORD_MIN} characters`);
if (COMMON.has(plain.toLowerCase())) throw badRequest('This password is too common');
}
+80
View File
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Browser sessions (04 §2.2): random token in the cookie, sha256(token) in sessions.id.
import { createHash, randomBytes } from 'node:crypto';
import { and, eq, gt, ne } from 'drizzle-orm';
import type { FastifyReply } from 'fastify';
import type { Db } from '../db/client.ts';
import { sessions, users } from '../db/schema.ts';
export const COOKIE = 'sp_session';
const DAY = 86_400_000;
const LIFETIME = 14 * DAY;
const EXTEND_BELOW = 7 * DAY;
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
export function setSessionCookie(reply: FastifyReply, token: string) {
reply.setCookie(COOKIE, token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: LIFETIME / 1000,
});
}
export function clearSessionCookie(reply: FastifyReply) {
reply.clearCookie(COOKIE, { path: '/' });
}
export async function createSession(
db: Db,
userId: string,
ip: string | null,
userAgent: string | null,
): Promise<string> {
const token = randomBytes(32).toString('base64url');
await db.insert(sessions).values({
id: sha256(token),
userId,
expiresAt: new Date(Date.now() + LIFETIME),
ip,
userAgent: userAgent?.slice(0, 500) ?? null,
});
return token;
}
/** Resolves a cookie token to its active user, sliding the expiry when under 7 days are left. */
export async function getSessionUser(db: Db, token: string) {
const id = sha256(token);
const [row] = await db
.select({ expiresAt: sessions.expiresAt, user: users })
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date()), eq(users.isActive, true)));
if (!row) return null;
let extended = false;
if (row.expiresAt.getTime() - Date.now() < EXTEND_BELOW) {
await db
.update(sessions)
.set({ expiresAt: new Date(Date.now() + LIFETIME) })
.where(eq(sessions.id, id));
extended = true;
}
return { user: row.user, sessionId: id, extended };
}
export async function deleteSession(db: Db, token: string) {
await db.delete(sessions).where(eq(sessions.id, sha256(token)));
}
/** Logs a user out everywhere except (optionally) the current session. */
export async function deleteUserSessions(db: Db, userId: string, keepSessionId?: string) {
await db
.delete(sessions)
.where(
keepSessionId
? and(eq(sessions.userId, userId), ne(sessions.id, keepSessionId))
: eq(sessions.userId, userId),
);
}
+24
View File
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, expect, it } from 'vitest';
import { ConfigError, loadConfig } from './config.ts';
const env = {
APP_SECRET: Buffer.alloc(32, 7).toString('base64'),
PUBLIC_HOSTNAME: 'pbx.test',
DATABASE_URL: 'postgres://u:p@127.0.0.1:5433/db',
INITIAL_ADMIN_EMAIL: 'admin@example.com',
INITIAL_ADMIN_PASSWORD: 'x',
};
describe('config', () => {
it('parses a minimal env with defaults', () => {
expect(loadConfig(env)).toMatchObject({ API_PORT: 8080, TRUST_PROXY: true });
});
it('rejects a missing or short APP_SECRET', () => {
expect(() => loadConfig({ ...env, APP_SECRET: undefined })).toThrow(ConfigError);
expect(() => loadConfig({ ...env, APP_SECRET: 'c2hvcnQ=' })).toThrow(/APP_SECRET/);
});
it('rejects a non-postgres DATABASE_URL', () => {
expect(() => loadConfig({ ...env, DATABASE_URL: 'mysql://x' })).toThrow(/DATABASE_URL/);
});
});
+10 -1
View File
@@ -1,12 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { z } from 'zod';
// ponytail: only the vars used so far; DB/AMI/admin vars join in M1/M2 with the code that reads them.
// ponytail: only the vars read so far; AMI vars join in M2 with the code that reads them.
const EnvSchema = z.object({
APP_SECRET: z
.string()
.refine((v) => Buffer.from(v, 'base64').length === 32, 'must be 32 random bytes, base64'),
PUBLIC_HOSTNAME: z.string().min(1),
DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, 'must be a postgres:// URL'),
INITIAL_ADMIN_EMAIL: z.email(),
INITIAL_ADMIN_PASSWORD: z.string().min(1),
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
TRUST_PROXY: z.stringbool().default(true),
@@ -18,6 +21,12 @@ export type Config = z.infer<typeof EnvSchema>;
export class ConfigError extends Error {}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
// `pnpm dev` reads .env, which has no DATABASE_URL (compose builds it); point at the dev container.
if (!env.DATABASE_URL && env.POSTGRES_PASSWORD)
env = {
...env,
DATABASE_URL: `postgres://selfphoneng:${env.POSTGRES_PASSWORD}@127.0.0.1:${env.POSTGRES_PORT ?? 5433}/selfphoneng`,
};
const r = EnvSchema.safeParse(env);
if (r.success) return r.data;
const lines = r.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`);
+44
View File
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// API error shape (04 §7.1): HTTP status + { error: { code, message, details } }.
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
import { ZodError } from 'zod';
export class HttpError extends Error {
readonly status: number;
readonly code: string;
readonly details: unknown;
constructor(status: number, code: string, message: string, details: unknown = {}) {
super(message);
this.status = status;
this.code = code;
this.details = details;
}
}
export const badRequest = (message: string, details?: unknown) =>
new HttpError(400, 'validation_failed', message, details);
export const unauthorized = (message = 'Not signed in') =>
new HttpError(401, 'unauthorized', message);
export const forbidden = (message = 'Not allowed', code = 'forbidden') =>
new HttpError(403, code, message);
export const notFound = (message = 'Not found') => new HttpError(404, 'not_found', message);
export const conflict = (message: string, details?: unknown) =>
new HttpError(409, 'conflict', message, details);
export const rateLimited = (message = 'Too many attempts, try again later') =>
new HttpError(429, 'rate_limited', message);
export function sendError(reply: FastifyReply, e: HttpError) {
return reply
.code(e.status)
.send({ error: { code: e.code, message: e.message, details: e.details } });
}
export function errorHandler(err: FastifyError, req: FastifyRequest, reply: FastifyReply) {
if (err instanceof HttpError) return sendError(reply, err);
if (err instanceof ZodError)
return sendError(reply, badRequest('Invalid input', { issues: err.issues }));
const status = err.statusCode ?? 500;
if (status < 500) return sendError(reply, new HttpError(status, 'bad_request', err.message));
req.log.error({ err }, 'unhandled error');
return sendError(reply, new HttpError(500, 'internal', 'Internal error'));
}
+20 -2
View File
@@ -1,6 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Startup order (04 §1).
import { buildApp } from './app.ts';
import { ConfigError, loadConfig } from './config.ts';
import { setSecretKey } from './crypto/secrets.ts';
import { createDb, runMigrations } from './db/client.ts';
import { ensureInitialAdmin } from './services/bootstrap.ts';
let config;
try {
@@ -10,10 +14,24 @@ try {
console.error(err.message);
process.exit(1);
}
setSecretKey(config.APP_SECRET);
const app = buildApp(config);
const { db, pool } = createDb(config.DATABASE_URL);
const app = buildApp(config, db); // creates the pino logger
await runMigrations(db);
app.log.info('database migrated');
if (
(await ensureInitialAdmin(db, config.INITIAL_ADMIN_EMAIL, config.INITIAL_ADMIN_PASSWORD)) ===
'created'
)
app.log.info({ email: config.INITIAL_ADMIN_EMAIL }, 'initial admin created');
await app.listen({ host: '127.0.0.1', port: config.API_PORT });
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
process.once(sig, () => void app.close().then(() => process.exit(0)));
process.once(sig, () => {
void app
.close()
.then(() => pool.end())
.then(() => process.exit(0));
});
}
+120
View File
@@ -0,0 +1,120 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/auth/* (04 §2, §7.3).
import { eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { ChangePasswordSchema, LoginSchema, type AuthUser } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { extensions, users } from '../db/schema.ts';
import { unauthorized, rateLimited, badRequest } from '../errors.ts';
import { audit } from '../services/audit.ts';
import { requireAuth, type CurrentUser } from '../auth/guards.ts';
import { isLoginBlocked, recordLoginFailure } from '../auth/login-limiter.ts';
import {
assertPasswordPolicy,
burnVerify,
hashPassword,
verifyPassword,
} from '../auth/passwords.ts';
import {
COOKIE,
clearSessionCookie,
createSession,
deleteSession,
deleteUserSessions,
setSessionCookie,
} from '../auth/sessions.ts';
export async function toAuthUser(db: Db, u: CurrentUser): Promise<AuthUser> {
const [ext] = await db
.select({ id: extensions.id, number: extensions.number, name: extensions.name })
.from(extensions)
.where(eq(extensions.userId, u.id));
return {
id: u.id,
email: u.email,
displayName: u.displayName,
role: u.role,
mustChangePassword: u.mustChangePassword,
extension: ext ?? null,
};
}
export async function authRoutes(app: FastifyInstance) {
const { db } = app;
app.post('/api/auth/login', async (req, reply) => {
const body = LoginSchema.parse(req.body);
if (isLoginBlocked(req.ip, body.email)) throw rateLimited();
const [user] = await db.select().from(users).where(eq(users.email, body.email));
const ok = user?.isActive
? await verifyPassword(user.passwordHash, body.password)
: (await burnVerify(body.password), false);
if (!user || !ok) {
recordLoginFailure(req.ip, body.email);
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed');
await audit(db, {
actorUserId: user?.id,
actorIp: req.ip,
action: 'login_failed',
entityType: 'user',
entityId: user?.id,
summary: `Failed login for ${body.email}`,
});
throw unauthorized('Wrong email or password');
}
const token = await createSession(db, user.id, req.ip, req.headers['user-agent'] ?? null);
setSessionCookie(reply, token);
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id));
await audit(db, {
actorUserId: user.id,
actorIp: req.ip,
action: 'login',
entityType: 'user',
entityId: user.id,
summary: `${user.email} logged in`,
});
return { user: await toAuthUser(db, user) };
});
app.post('/api/auth/logout', { preHandler: requireAuth }, async (req, reply) => {
await deleteSession(db, req.cookies[COOKIE]!);
clearSessionCookie(reply);
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'logout',
entityType: 'user',
entityId: req.user!.id,
summary: `${req.user!.email} logged out`,
});
return { ok: true };
});
app.get('/api/auth/me', { preHandler: requireAuth }, async (req) => ({
user: await toAuthUser(db, req.user!),
}));
app.post('/api/auth/change-password', { preHandler: requireAuth }, async (req) => {
const body = ChangePasswordSchema.parse(req.body);
const user = req.user!;
if (!(await verifyPassword(user.passwordHash, body.currentPassword)))
throw badRequest('Current password is wrong');
if (body.newPassword === body.currentPassword)
throw badRequest('Choose a password different from the current one');
assertPasswordPolicy(body.newPassword);
await db
.update(users)
.set({ passwordHash: await hashPassword(body.newPassword), mustChangePassword: false })
.where(eq(users.id, user.id));
await deleteUserSessions(db, user.id, req.sessionId!);
await audit(db, {
actorUserId: user.id,
actorIp: req.ip,
action: 'password_changed',
entityType: 'user',
entityId: user.id,
summary: `${user.email} changed their password`,
});
return { ok: true };
});
}
+38
View File
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type { Db } from '../db/client.ts';
import { auditLog } from '../db/schema.ts';
const SECRET_KEY = /pass|secret|token|pin|enc$/i;
/** Deep copy with every secret-looking key replaced by "********" (02 audit_log.diff). */
export function redact(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redact);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, SECRET_KEY.test(k) ? '********' : redact(v)]),
);
}
return value;
}
export type AuditEntry = {
actorUserId?: string | null;
actorIp?: string | null;
action: string;
entityType?: string;
entityId?: string;
summary: string;
diff?: unknown;
};
export async function audit(db: Db, e: AuditEntry): Promise<void> {
await db.insert(auditLog).values({
actorUserId: e.actorUserId ?? null,
actorIp: e.actorIp ?? null,
action: e.action,
entityType: e.entityType ?? null,
entityId: e.entityId ?? null,
summary: e.summary,
diff: e.diff === undefined ? null : redact(e.diff),
});
}
+38
View File
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Startup step 4 (04 §1): create the first admin from env if no admin exists.
import { eq } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { users } from '../db/schema.ts';
import { hashPassword } from '../auth/passwords.ts';
import { audit } from './audit.ts';
export async function ensureInitialAdmin(
db: Db,
email: string,
password: string,
): Promise<'created' | 'exists'> {
const [admin] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.role, 'admin'))
.limit(1);
if (admin) return 'exists';
// The password comes from .env and must be changed at first login, so the policy is not applied here.
const [created] = await db
.insert(users)
.values({
email,
displayName: 'Administrator',
passwordHash: await hashPassword(password),
role: 'admin',
mustChangePassword: true,
})
.returning({ id: users.id });
await audit(db, {
action: 'create',
entityType: 'user',
entityId: created!.id,
summary: `Initial admin ${email} created from INITIAL_ADMIN_EMAIL`,
});
return 'created';
}
+76
View File
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// A full api on a throwaway database, for Fastify inject() tests.
import { randomBytes } from 'node:crypto';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
import { buildApp } from '../src/app.ts';
import { loadConfig } from '../src/config.ts';
import { setSecretKey } from '../src/crypto/secrets.ts';
import { users } from '../src/db/schema.ts';
import { hashPassword } from '../src/auth/passwords.ts';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { ensureInitialAdmin } from '../src/services/bootstrap.ts';
import { createTestDb } from './db.ts';
export const ADMIN = { email: 'admin@example.com', password: 'initial-admin-pw' };
export const H = { 'x-requested-with': 'selfphoneng' };
export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>) => void) {
const t = await createTestDb();
const config = loadConfig({
APP_SECRET: randomBytes(32).toString('base64'),
PUBLIC_HOSTNAME: 'pbx.test',
DATABASE_URL: t.url,
INITIAL_ADMIN_EMAIL: ADMIN.email,
INITIAL_ADMIN_PASSWORD: ADMIN.password,
LOG_LEVEL: 'fatal',
});
setSecretKey(config.APP_SECRET);
const pub = mkdtempSync(join(tmpdir(), 'sp-public-'));
writeFileSync(join(pub, 'index.html'), '<h1>spa</h1>');
const app = buildApp(config, t.db, pub);
extend?.(app);
await ensureInitialAdmin(t.db, ADMIN.email, ADMIN.password);
resetLoginLimiter();
await app.ready();
/** Logs in and returns the Cookie header value. */
async function login(email: string, password: string): Promise<string> {
const r = await app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { email, password },
});
if (r.statusCode !== 200) throw new Error(`login failed: ${r.statusCode} ${r.body}`);
const c = r.cookies.find((x) => x.name === 'sp_session')!;
return `sp_session=${c.value}`;
}
/** Creates a user directly in the DB (no password-change requirement). */
async function addUser(email: string, password: string, role: 'admin' | 'user' = 'user') {
const [u] = await t.db
.insert(users)
.values({ email, displayName: email, passwordHash: await hashPassword(password), role })
.returning();
return u!;
}
async function clearMustChange(email: string) {
await t.db.update(users).set({ mustChangePassword: false }).where(eq(users.email, email));
}
return {
app,
db: t.db,
login,
addUser,
clearMustChange,
close: async () => {
await app.close();
await t.drop();
},
};
}
+151
View File
@@ -0,0 +1,151 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M1-T3 acceptance (09-roadmap) plus the auth rules in 04 §2.
import { desc, eq } from 'drizzle-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { auditLog, users } from '../src/db/schema.ts';
import { requireAdmin } from '../src/auth/guards.ts';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('auth', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let adminPassword = ADMIN.password;
beforeAll(async () => {
// A stand-in admin route until the real admin routes exist.
t = await createTestApp((app) =>
app.get('/api/admin-probe', { preHandler: requireAdmin }, async () => ({ ok: true })),
);
});
afterAll(() => t?.close());
beforeEach(() => resetLoginLimiter());
const login = (email: string, password: string, headers: Record<string, string> = H) =>
t.app.inject({ method: 'POST', url: '/api/auth/login', headers, payload: { email, password } });
const cookieOf = (r: Awaited<ReturnType<typeof login>>) =>
`sp_session=${r.cookies.find((c) => c.name === 'sp_session')!.value}`;
const get = (url: string, cookie?: string) =>
t.app.inject({ url, headers: cookie ? { cookie } : {} });
it('good login sets an HttpOnly, Secure, SameSite=Lax session cookie', async () => {
const r = await login(ADMIN.email, adminPassword);
expect(r.statusCode).toBe(200);
expect(r.cookies.find((x) => x.name === 'sp_session')).toMatchObject({
httpOnly: true,
secure: true,
sameSite: 'Lax',
path: '/',
maxAge: 1209600,
});
expect(r.json().user).toMatchObject({
email: ADMIN.email,
role: 'admin',
mustChangePassword: true,
});
});
it('emails match case-insensitively', async () => {
expect((await login(ADMIN.email.toUpperCase(), adminPassword)).statusCode).toBe(200);
});
it('bad password → 401 and an audited login_failed with the IP', async () => {
const r = await login(ADMIN.email, 'wrong-password');
expect(r.statusCode).toBe(401);
expect(r.json().error.code).toBe('unauthorized');
const [row] = await t.db.select().from(auditLog).orderBy(desc(auditLog.id)).limit(1);
expect(row).toMatchObject({ action: 'login_failed', actorIp: '127.0.0.1' });
expect(JSON.stringify(row)).not.toContain('wrong-password');
});
it('unknown email → the same 401', async () => {
expect((await login('nobody@example.com', 'whatever-pw')).statusCode).toBe(401);
});
it('6th failure from one IP → 429, even with the right password', async () => {
for (let i = 0; i < 5; i++) expect((await login(ADMIN.email, `bad-${i}`)).statusCode).toBe(401);
expect((await login(ADMIN.email, 'bad-6')).statusCode).toBe(429);
expect((await login(ADMIN.email, adminPassword)).statusCode).toBe(429);
});
it('POST without X-Requested-With → 403', async () => {
const r = await login(ADMIN.email, adminPassword, {});
expect(r.statusCode).toBe(403);
expect(r.json().error.code).toBe('csrf');
});
it('must_change_password blocks everything outside /api/auth/* until changed', async () => {
const cookie = cookieOf(await login(ADMIN.email, adminPassword));
const blocked = await get('/api/admin-probe', cookie);
expect(blocked.statusCode).toBe(403);
expect(blocked.json().error.code).toBe('password_change_required');
expect((await get('/api/auth/me', cookie)).statusCode).toBe(200);
const change = (newPassword: string) =>
t.app.inject({
method: 'POST',
url: '/api/auth/change-password',
headers: { ...H, cookie },
payload: { currentPassword: adminPassword, newPassword },
});
expect((await change('short')).statusCode).toBe(400);
expect((await change('qwertyuiop')).json().error.message).toMatch(/too common/);
expect((await change('a-much-better-passphrase')).statusCode).toBe(200);
adminPassword = 'a-much-better-passphrase';
expect((await get('/api/admin-probe', cookie)).statusCode).toBe(200);
});
it('changing the password logs out other sessions', async () => {
const a = cookieOf(await login(ADMIN.email, adminPassword));
const b = cookieOf(await login(ADMIN.email, adminPassword));
const r = await t.app.inject({
method: 'POST',
url: '/api/auth/change-password',
headers: { ...H, cookie: a },
payload: { currentPassword: adminPassword, newPassword: 'another-good-passphrase' },
});
expect(r.statusCode).toBe(200);
adminPassword = 'another-good-passphrase';
expect((await get('/api/auth/me', a)).statusCode).toBe(200);
expect((await get('/api/auth/me', b)).statusCode).toBe(401);
});
it('non-admin hitting an admin route → 403', async () => {
await t.addUser('user@example.com', 'user-password-123');
const cookie = cookieOf(await login('user@example.com', 'user-password-123'));
const r = await get('/api/admin-probe', cookie);
expect(r.statusCode).toBe(403);
expect(r.json().error.code).toBe('forbidden');
});
it('no cookie → 401; logout ends the session', async () => {
expect((await get('/api/auth/me')).statusCode).toBe(401);
const cookie = cookieOf(await login(ADMIN.email, adminPassword));
const out = await t.app.inject({
method: 'POST',
url: '/api/auth/logout',
headers: { ...H, cookie },
});
expect(out.statusCode).toBe(200);
expect((await get('/api/auth/me', cookie)).statusCode).toBe(401);
});
it('inactive users cannot log in', async () => {
const u = await t.addUser('gone@example.com', 'gone-password-123');
await t.db.update(users).set({ isActive: false }).where(eq(users.id, u.id));
expect((await login('gone@example.com', 'gone-password-123')).statusCode).toBe(401);
});
it('every login event is audited', async () => {
const actions = (await t.db.select({ a: auditLog.action }).from(auditLog)).map((r) => r.a);
expect(actions).toEqual(
expect.arrayContaining(['create', 'login', 'login_failed', 'logout', 'password_changed']),
);
});
it('healthz checks the DB; the SPA fallback skips /api and /prov', async () => {
expect((await get('/healthz')).json()).toEqual({ ok: true });
expect((await get('/admin/users')).body).toBe('<h1>spa</h1>');
expect((await get('/api/nope')).statusCode).toBe(404);
expect((await get('/prov/x.cfg')).statusCode).toBe(404);
});
});
+30
View File
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { z } from 'zod';
export const PASSWORD_MIN = 10;
export const LoginSchema = z.object({
email: z.string().trim().min(3).max(254),
password: z.string().min(1).max(200),
totp: z
.string()
.regex(/^\d{6}$/)
.optional(), // M11
});
export const ChangePasswordSchema = z.object({
currentPassword: z.string().min(1).max(200),
newPassword: z.string().min(PASSWORD_MIN).max(200),
});
export type Role = 'admin' | 'user';
/** What /api/auth/login and /api/auth/me return. */
export type AuthUser = {
id: string;
email: string;
displayName: string;
role: Role;
mustChangePassword: boolean;
extension: { id: string; number: string; name: string } | null;
};
+1
View File
@@ -3,3 +3,4 @@ export const APP_NAME = 'SelfPhoneNG';
export * from './phone.ts';
export * from './destination.ts';
export * from './settings.ts';
export * from './auth.ts';
+155
View File
@@ -41,9 +41,15 @@ importers:
apps/api:
dependencies:
'@fastify/cookie':
specifier: ^11.1.3
version: 11.1.3
'@fastify/static':
specifier: ^10.1.6
version: 10.1.6
'@node-rs/argon2':
specifier: ^2.2.2
version: 2.2.2
'@selfphoneng/shared':
specifier: workspace:*
version: link:../../packages/shared
@@ -685,6 +691,9 @@ packages:
'@fastify/ajv-compiler@4.0.7':
resolution: {integrity: sha512-VP0yIfwlc1ohj733keYDqyHm1VWeP9VQxYomunhr90jkC4aNNibbkrRiaNgPgqj1ORxUenILR4qzA/ppYBNAtQ==}
'@fastify/cookie@11.1.3':
resolution: {integrity: sha512-RBhlMNsmSG6C5KGiTkhSXBF75xIrUMSytDDBcpnVewhJ8XVYnJTu+SV6FYKXClIRsBuRjOfKbBSULvnPD3X/mg==}
'@fastify/deepmerge@3.2.2':
resolution: {integrity: sha512-155eqA/WMQdN6gUD4RrhS2K0Pcz2Ou9pQ86L0sipOpP9ONbTA6oYw/hW4VdBjR4egcDHqmWUK9x80oUX7S1mDQ==}
@@ -758,6 +767,92 @@ packages:
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'}
'@node-rs/argon2-android-arm-eabi@2.2.2':
resolution: {integrity: sha512-c11BOrPLBWNC1cDyzGB+Bil+flmxfZbB2hv8EhcobmAsFKMyqkHPNhUY4qhcjv1U+4+Vmw4G8H+l224iO2g/Dw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
'@node-rs/argon2-android-arm64@2.2.2':
resolution: {integrity: sha512-dpMYIoxhN/wY6hFH+3MM5kQErcH3luMekkszbkDF3NKqhB1kFRntkOLDpFY87oItBSHJrsoYkXC3vBRhjYoCjQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@node-rs/argon2-darwin-arm64@2.2.2':
resolution: {integrity: sha512-vxVAXcIG0iiHizkCuImmzVhOFsRnU4Z79xiOxdISLyI+QhOAWbbTaVSXw0PNfThB3zAdkzzDJ/WWu52Hs/qHGg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@node-rs/argon2-darwin-x64@2.2.2':
resolution: {integrity: sha512-r7GbSFpFZZ9EAV9cvIm9prUjOupK7QQXTyP+FJ6bWSMq0h+qRDBlUOXQXduDIIrLEGoBVelixKUtf4ksZ0ljoQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@node-rs/argon2-freebsd-x64@2.2.2':
resolution: {integrity: sha512-AmWxDSmQgprilD6NX3jmxCgcDvWAW7kGJnnWQo+0qav6nfMXdPZYgSezK/skmw1GBWipr4yZMIUzpeNHnVpMxg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@node-rs/argon2-linux-arm-gnueabihf@2.2.2':
resolution: {integrity: sha512-M1gnPBTr9GFeEUWkQ53qCJeTsX03C3kcUeHnDbN7dU2xkI58wMP4Z1sEBN7WmKJ/fTHYEgti4truz7r4A82DLg==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@node-rs/argon2-linux-arm64-gnu@2.2.2':
resolution: {integrity: sha512-4CYDlIdITlLSVcAXYUtEoGUahRaayPXM+4Rcfg3BZx9WGi6UB8HUycs6GNMTPYXV5kVXCpyE2u1HmYUpgmIzOQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@node-rs/argon2-linux-arm64-musl@2.2.2':
resolution: {integrity: sha512-FYlfj3XgQ1xrx6HzQbKbUcKiYMG/3iSi/X81KyhHaItTJ7vZlMwrbVLl9yqDmvotKUF6VjrhrlZ8KRbbzchseA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@node-rs/argon2-linux-x64-gnu@2.2.2':
resolution: {integrity: sha512-ULXLs8UFYZANlwiROM7AeccxZndr2Xk3IstADq3jAUhnwV2W75mxMBTABUZ+7AdKRhPLOrxU77OgnMWlDkYJQg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@node-rs/argon2-linux-x64-musl@2.2.2':
resolution: {integrity: sha512-zgjIwc9TyGodn62vGc+36DGwFqVV5e7L8Ptd2FQWuBxe5aFrcFxvtDGn1/qfYwoHLv09utgtR+RBAPCXepWG3Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@node-rs/argon2-win32-arm64-msvc@2.2.2':
resolution: {integrity: sha512-3nLhvLie/ec5xfxUBXXqGMb7oGeqF+YXrcGckqaTaGkPtOBozho0Kh2IuP/zi9BcOriirTG2LW3gtIOfpuh+mQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@node-rs/argon2-win32-ia32-msvc@2.2.2':
resolution: {integrity: sha512-6RJKD6cFl7UeiYJLjRsXPywVXSFSaSDdr6xGpztNDV31QHCEecWnsnNmKxzi4gVpln2F9XeWEtIgM4Nqw1hLbA==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@node-rs/argon2-win32-x64-msvc@2.2.2':
resolution: {integrity: sha512-dQOO9S4QKBaVul0Uyk/2quJc/OBeHNZRJP+GZZTy1A5kYIaAfIJGHUYLY6oFoAlcuLIkdFouLaHX3fCl8tZm/w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@node-rs/argon2@2.2.2':
resolution: {integrity: sha512-NtdlPPWtR5U4BxX5EGJYza+UklNaoWORmkaKCMqf/nyKeXWKVwNt8KfQoRpNT7OB1DcEwmePafO4wZyW+sbfCQ==}
engines: {node: '>= 10'}
'@oxc-project/types@0.154.0':
resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==}
@@ -2696,6 +2791,11 @@ snapshots:
ajv-formats: 3.0.1(ajv@8.20.0)
fast-uri: 4.2.2
'@fastify/cookie@11.1.3':
dependencies:
cookie: 2.0.1
fastify-plugin: 6.0.1
'@fastify/deepmerge@3.2.2': {}
'@fastify/error@4.2.1': {}
@@ -2778,6 +2878,61 @@ snapshots:
'@lukeed/ms@2.0.2': {}
'@node-rs/argon2-android-arm-eabi@2.2.2':
optional: true
'@node-rs/argon2-android-arm64@2.2.2':
optional: true
'@node-rs/argon2-darwin-arm64@2.2.2':
optional: true
'@node-rs/argon2-darwin-x64@2.2.2':
optional: true
'@node-rs/argon2-freebsd-x64@2.2.2':
optional: true
'@node-rs/argon2-linux-arm-gnueabihf@2.2.2':
optional: true
'@node-rs/argon2-linux-arm64-gnu@2.2.2':
optional: true
'@node-rs/argon2-linux-arm64-musl@2.2.2':
optional: true
'@node-rs/argon2-linux-x64-gnu@2.2.2':
optional: true
'@node-rs/argon2-linux-x64-musl@2.2.2':
optional: true
'@node-rs/argon2-win32-arm64-msvc@2.2.2':
optional: true
'@node-rs/argon2-win32-ia32-msvc@2.2.2':
optional: true
'@node-rs/argon2-win32-x64-msvc@2.2.2':
optional: true
'@node-rs/argon2@2.2.2':
optionalDependencies:
'@node-rs/argon2-android-arm-eabi': 2.2.2
'@node-rs/argon2-android-arm64': 2.2.2
'@node-rs/argon2-darwin-arm64': 2.2.2
'@node-rs/argon2-darwin-x64': 2.2.2
'@node-rs/argon2-freebsd-x64': 2.2.2
'@node-rs/argon2-linux-arm-gnueabihf': 2.2.2
'@node-rs/argon2-linux-arm64-gnu': 2.2.2
'@node-rs/argon2-linux-arm64-musl': 2.2.2
'@node-rs/argon2-linux-x64-gnu': 2.2.2
'@node-rs/argon2-linux-x64-musl': 2.2.2
'@node-rs/argon2-win32-arm64-msvc': 2.2.2
'@node-rs/argon2-win32-ia32-msvc': 2.2.2
'@node-rs/argon2-win32-x64-msvc': 2.2.2
'@oxc-project/types@0.154.0': {}
'@pinojs/redact@0.4.0': {}