auth: two-step sign-in (TOTP) for admins, with recovery codes
ci / check (push) Successful in 1m1s
ci / images (push) Successful in 27s

auth/totp.ts per 08 §2.4: RFC 6238 (HMAC-SHA1, 30 s, 6 digits, ±1
step) with node:crypto only, checked against the RFC's test vectors.
Migration 0003: users.totp_recovery_hashes and totp_last_step.

Admins turn it on from My account (the header's "Account"): scan the
QR or type the key, confirm a code, get 10 recovery codes once. Login
asks for the code only after the password is right (401 totp_required,
no session); a wrong code is a failed login (rate limit, audit). A
step signs in once (totp_last_step updated atomically) and a recovery
code once (array_remove guarded by any()). Off and new codes need the
password; another admin can turn it off for a lost phone, which signs
that user out. Users list shows it (twoStep).

Also, found by the full e2e chain on one shared database:
- Linphone tokens: only the exact token issued is accepted. The last
  base64 character can carry ignored bits, so another spelling decoded
  to the same signed token.
- Test scripts no longer assume a fresh system: m3-t3 picks Dan in the
  test box, m8-check dials whatever slot the park took (findslot =
  next), m10-t4 uses the admin's existing extension, m11-t1 clears the
  public IP on its own first (local nets are in the transports while
  one is set, and transports change only on a restart).

e2e m11-t1-totp; the full chain M1-M11 (33 scripts) passes.

Task: M11-T1
This commit is contained in:
iamdoubz committed 2026-10-19 15:41:53 -05:00
1 parent 65e777ab41
commit 8c750c67ca
31 files changed
+4842 -43

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T1: TOTP against the RFC 6238 test vectors, the ±1 window, no reuse, recovery codes.
import { describe, expect, it } from 'vitest';
import {
base32,
checkTotp,
fromBase32,
hashRecovery,
newRecoveryCodes,
newTotpSecret,
stepAt,
totpCode,
} from './totp.ts';
// RFC 6238 appendix B, SHA1 key "12345678901234567890"; we use the last 6 digits.
const RFC_SECRET = base32(Buffer.from('12345678901234567890'));
describe('totp', () => {
it('matches RFC 6238 SHA1 vectors', () => {
for (const [t, code] of [
[59, '287082'],
[1111111109, '081804'],
[1111111111, '050471'],
[1234567890, '005924'],
[2000000000, '279037'],
[20000000000, '353130'],
] as const)
expect(totpCode(RFC_SECRET, Math.floor(t / 30)), String(t)).toBe(code);
});
it('base32 round-trips; secrets are 32 chars', () => {
const s = newTotpSecret();
expect(s).toMatch(/^[A-Z2-7]{32}$/);
expect(base32(fromBase32(s))).toBe(s);
});
it('accepts the step before and after, not further; never the same step twice', () => {
const now = 1_800_000_000_000;
const s = stepAt(now);
expect(checkTotp(RFC_SECRET, totpCode(RFC_SECRET, s), null, now)).toBe(s);
expect(checkTotp(RFC_SECRET, totpCode(RFC_SECRET, s - 1), null, now)).toBe(s - 1);
expect(checkTotp(RFC_SECRET, totpCode(RFC_SECRET, s + 1), null, now)).toBe(s + 1);
expect(checkTotp(RFC_SECRET, totpCode(RFC_SECRET, s - 2), null, now)).toBeNull();
expect(checkTotp(RFC_SECRET, totpCode(RFC_SECRET, s), s, now)).toBeNull(); // used
expect(checkTotp(RFC_SECRET, '12345', null, now)).toBeNull();
});
it('recovery codes: 10, readable, hashed ignoring case and dashes', () => {
const { codes, hashes } = newRecoveryCodes();
expect(codes).toHaveLength(10);
expect(new Set(codes).size).toBe(10);
for (const c of codes) expect(c).toMatch(/^[a-z2-9]{5}-[a-z2-9]{5}$/);
expect(hashRecovery(codes[0]!.toUpperCase().replace('-', ' '))).toBe(hashes[0]);
});
});
+94
View File
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// TOTP for admins (08 §2.4): RFC 6238, HMAC-SHA1, 30 s steps, 6 digits, ±1 step, and 10
// single-use recovery codes stored as sha256. node:crypto only.
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
const STEP_S = 30;
const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
export function base32(buf: Buffer): string {
let bits = 0;
let value = 0;
let out = '';
for (const byte of buf) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += B32[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
return out;
}
export function fromBase32(s: string): Buffer {
const bytes: number[] = [];
let bits = 0;
let value = 0;
for (const c of s.toUpperCase().replace(/[\s=]/g, '')) {
const i = B32.indexOf(c);
if (i < 0) throw new Error('not base32');
value = (value << 5) | i;
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(bytes);
}
/** A new secret (160 bits), base32 as authenticator apps take it. */
export const newTotpSecret = () => base32(randomBytes(20));
/** The 6-digit code for a 30 s step (RFC 4226 dynamic truncation). */
export function totpCode(secret: string, step: number): string {
const counter = Buffer.alloc(8);
counter.writeBigUInt64BE(BigInt(step));
const h = createHmac('sha1', fromBase32(secret)).update(counter).digest();
const o = h[19]! & 15;
const n = (h.readUInt32BE(o) & 0x7fffffff) % 1_000_000;
return String(n).padStart(6, '0');
}
export const stepAt = (now = Date.now()) => Math.floor(now / 1000 / STEP_S);
/**
* The step `code` is valid for (now ±1), or null. Steps up to `lastStep` were used already:
* a code can't sign in twice.
*/
export function checkTotp(
secret: string,
code: string,
lastStep: number | null,
now = Date.now(),
): number | null {
if (!/^\d{6}$/.test(code)) return null;
const s = stepAt(now);
for (const step of [s - 1, s, s + 1]) {
if (lastStep !== null && step <= lastStep) continue;
if (timingSafeEqual(Buffer.from(totpCode(secret, step)), Buffer.from(code))) return step;
}
return null;
}
export const otpauthUrl = (secret: string, account: string, issuer: string) =>
`otpauth://totp/${encodeURIComponent(`${issuer}:${account}`)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
const RC = 'abcdefghjkmnpqrstuvwxyz23456789'; // no 0/o, 1/l/i
/** 10 recovery codes like "k7m3q-x9tpw", and what to store. */
export function newRecoveryCodes(): { codes: string[]; hashes: string[] } {
const codes = Array.from({ length: 10 }, () => {
const b = randomBytes(10);
const s = [...b].map((x) => RC[x % RC.length]).join('');
return `${s.slice(0, 5)}-${s.slice(5)}`;
});
return { codes, hashes: codes.map(hashRecovery) };
}
/** Case and dashes don't matter when typing a recovery code. */
export const hashRecovery = (code: string) =>
createHash('sha256')
.update(code.toLowerCase().replace(/[^a-z0-9]/g, ''))
.digest('hex');
@@ -0,0 +1,2 @@
ALTER TABLE "users" ADD COLUMN "totp_recovery_hashes" text[] DEFAULT '{}' NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "totp_last_step" bigint;
File diff suppressed because it is too large. Load diff
@@ -22,6 +22,13 @@
"when": 1792371506100,
"tag": "0002_queue_calls",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1792439261267,
"tag": "0003_users_totp",
"breakpoints": true
}
]
}
+4
View File
@@ -57,6 +57,10 @@ export const users = pgTable('users', {
role: userRole().notNull().default('user'),
isActive: boolean().notNull().default(true),
totpSecretEnc: text(),
/** sha256 of the unused recovery codes (08 §2.4). */
totpRecoveryHashes: emptyTextArray(),
/** The last 30 s step a code was accepted for: the same code can't sign in twice. */
totpLastStep: bigint({ mode: 'number' }),
mustChangePassword: boolean().notNull().default(false),
lastLoginAt: tstz(),
...timestamps,
@@ -30,6 +30,12 @@ describe('linphone token', () => {
verifyLinphoneToken(SECRET, forge(`${id}.${Number(exp) + 3600}.${mac}`), now),
).toBeNull();
expect(verifyLinphoneToken(SECRET, 'nope', now)).toBeNull();
// Another spelling of the same bytes: the token is 112 bytes, so its last base64 character
// holds 4 unused bits; flipping the lowest one decodes to the very same token.
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
const other = token.slice(0, -1) + B64[B64.indexOf(token.at(-1)!) ^ 1];
expect(Buffer.from(other, 'base64url').equals(Buffer.from(token, 'base64url'))).toBe(true);
expect(verifyLinphoneToken(SECRET, other, now)).toBeNull();
expect(verifyLinphoneToken(SECRET, forge('a.b'), now)).toBeNull();
});
});
+4 -1
View File
@@ -22,7 +22,10 @@ export function linphoneToken(secret: string, extensionId: string, now = Date.no
/** The extension id in a valid, unexpired token, or null. */
export function verifyLinphoneToken(secret: string, token: string, now = Date.now()) {
const parts = Buffer.from(token, 'base64url').toString('utf8').split('.');
const raw = Buffer.from(token, 'base64url');
// Only the exact token we issued: base64's last character can carry ignored padding bits.
if (raw.toString('base64url') !== token) return null;
const parts = raw.toString('utf8').split('.');
if (parts.length !== 3) return null;
const [id, expS, mac] = parts as [string, string, string];
const exp = Number(expS);
+155 -3
View File
@@ -1,11 +1,36 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/auth/* (04 §2, §7.3).
import { eq } from 'drizzle-orm';
import { and, eq, isNull, lt, or, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { ChangePasswordSchema, LoginSchema, type AuthUser } from '@selfphoneng/shared';
import {
ChangePasswordSchema,
LoginSchema,
PasswordConfirmSchema,
TotpEnableSchema,
APP_NAME,
type AuthUser,
type TotpSetup,
type TotpStatus,
} from '@selfphoneng/shared';
import QRCode from 'qrcode';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import {
checkTotp,
hashRecovery,
newRecoveryCodes,
newTotpSecret,
otpauthUrl,
} from '../auth/totp.ts';
import type { Db } from '../db/client.ts';
import { extensions, users } from '../db/schema.ts';
import { unauthorized, rateLimited, badRequest } from '../errors.ts';
import {
unauthorized,
rateLimited,
badRequest,
conflict,
forbidden,
HttpError,
} 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';
@@ -42,6 +67,115 @@ export async function toAuthUser(db: Db, u: CurrentUser): Promise<AuthUser> {
export async function authRoutes(app: FastifyInstance) {
const { db } = app;
/**
* A TOTP code (each 30 s step once) or an unused recovery code, consumed atomically so two
* logins can't both use it.
*/
async function useSecondFactor(u: typeof users.$inferSelect, code: string): Promise<boolean> {
const step = checkTotp(decrypt(u.totpSecretEnc!), code, u.totpLastStep);
if (step !== null) {
const r = await db
.update(users)
.set({ totpLastStep: step })
.where(
and(eq(users.id, u.id), or(isNull(users.totpLastStep), lt(users.totpLastStep, step))),
)
.returning({ id: users.id });
return r.length === 1;
}
if (/^\d+$/.test(code)) return false;
const h = hashRecovery(code);
const r = await db
.update(users)
.set({ totpRecoveryHashes: sql`array_remove(${users.totpRecoveryHashes}, ${h})` })
.where(and(eq(users.id, u.id), sql`${h} = any(${users.totpRecoveryHashes})`))
.returning({ id: users.id });
return r.length === 1;
}
const myRow = async (id: string) => (await db.select().from(users).where(eq(users.id, id)))[0]!;
/** Two-step sign-in is for admins (08 §2.4). */
const adminOnly = (u: CurrentUser) => {
if (u.role !== 'admin') throw forbidden('Two-step sign-in is for admins');
};
app.get('/api/auth/totp', { preHandler: requireAuth }, async (req): Promise<TotpStatus> => {
const u = await myRow(req.user!.id);
return { enabled: !!u.totpSecretEnc, recoveryCodesLeft: u.totpRecoveryHashes.length };
});
/** A fresh secret to scan; nothing is stored until /enable confirms a code from it. */
app.post('/api/auth/totp/setup', { preHandler: requireAuth }, async (req): Promise<TotpSetup> => {
adminOnly(req.user!);
const secret = newTotpSecret();
const url = otpauthUrl(secret, req.user!.email, `${APP_NAME} ${app.config.PUBLIC_HOSTNAME}`);
return { secret, otpauthUrl: url, qr: await QRCode.toDataURL(url, { margin: 2, width: 240 }) };
});
app.post('/api/auth/totp/enable', { preHandler: requireAuth }, async (req) => {
adminOnly(req.user!);
const { secret, code } = TotpEnableSchema.parse(req.body);
if ((await myRow(req.user!.id)).totpSecretEnc) throw conflict('Two-step sign-in is already on');
const step = checkTotp(secret, code, null);
if (step === null)
throw badRequest('That code doesn’t match. Check the phone’s clock and try again.');
const { codes, hashes } = newRecoveryCodes();
await db
.update(users)
.set({ totpSecretEnc: encrypt(secret), totpLastStep: step, totpRecoveryHashes: hashes })
.where(eq(users.id, req.user!.id));
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'totp_enabled',
entityType: 'user',
entityId: req.user!.id,
summary: `${req.user!.email} turned on two-step sign-in`,
});
return { recoveryCodes: codes };
});
/** Turning it off or making new recovery codes needs the password again. */
async function confirmPassword(id: string, body: unknown) {
const { password } = PasswordConfirmSchema.parse(body);
const u = await myRow(id);
if (!(await verifyPassword(u.passwordHash, password))) throw badRequest('Wrong password');
return u;
}
app.post('/api/auth/totp/disable', { preHandler: requireAuth }, async (req) => {
await confirmPassword(req.user!.id, req.body);
await db
.update(users)
.set({ totpSecretEnc: null, totpLastStep: null, totpRecoveryHashes: [] })
.where(eq(users.id, req.user!.id));
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'totp_disabled',
entityType: 'user',
entityId: req.user!.id,
summary: `${req.user!.email} turned off two-step sign-in`,
});
return { ok: true };
});
app.post('/api/auth/totp/recovery-codes', { preHandler: requireAuth }, async (req) => {
const u = await confirmPassword(req.user!.id, req.body);
if (!u.totpSecretEnc) throw badRequest('Two-step sign-in is off');
const { codes, hashes } = newRecoveryCodes();
await db.update(users).set({ totpRecoveryHashes: hashes }).where(eq(users.id, u.id));
await audit(db, {
actorUserId: u.id,
actorIp: req.ip,
action: 'totp_recovery_codes',
entityType: 'user',
entityId: u.id,
summary: `${u.email} made new recovery codes`,
});
return { recoveryCodes: codes };
});
app.post('/api/auth/login', async (req, reply) => {
const body = LoginSchema.parse(req.body);
if (isLoginBlocked(req.ip, body.email)) throw rateLimited();
@@ -62,6 +196,24 @@ export async function authRoutes(app: FastifyInstance) {
});
throw unauthorized('Wrong email or password');
}
// Two-step sign-in (08 §2.4): the password was right; now the code.
if (user.totpSecretEnc) {
if (!body.totp)
throw new HttpError(401, 'totp_required', 'Enter the code from your authenticator app');
if (!(await useSecondFactor(user, body.totp))) {
recordLoginFailure(req.ip, body.email);
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed: code');
await audit(db, {
actorUserId: user.id,
actorIp: req.ip,
action: 'login_failed',
entityType: 'user',
entityId: user.id,
summary: `Failed login for ${body.email} (wrong authenticator or recovery code)`,
});
throw new HttpError(401, 'totp_invalid', 'That code is wrong or was used already');
}
}
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));
+21
View File
@@ -27,6 +27,7 @@ export const toUserDto = (u: UserRow, extension: UserDto['extension'] = null): U
role: u.role,
isActive: u.isActive,
mustChangePassword: u.mustChangePassword,
twoStep: u.totpSecretEnc !== null,
lastLoginAt: u.lastLoginAt?.toISOString() ?? null,
createdAt: u.createdAt.toISOString(),
extension,
@@ -189,4 +190,24 @@ export async function userRoutes(app: FastifyInstance) {
});
return { temporaryPassword: password };
});
/** For an admin who lost their phone and their recovery codes: another admin turns it off. */
app.post('/api/users/:id/reset-two-step', async (req) => {
const { id } = IdParams.parse(req.params);
const u = await getUser(db, id);
await db
.update(users)
.set({ totpSecretEnc: null, totpLastStep: null, totpRecoveryHashes: [] })
.where(eq(users.id, id));
await deleteUserSessions(db, id);
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'totp_disabled',
entityType: 'user',
entityId: id,
summary: `Turned off two-step sign-in for ${u.email}`,
});
return { ok: true };
});
}
+1 -1
View File
@@ -36,5 +36,5 @@ describe.skipIf(!hasSh)('sp-vm-pincheck', () => {
];
for (const box of ['101', '2000', '55'])
for (const pin of pins) expect(script(box, pin), `${box} ${pin}`).toBe(api(box, pin));
});
}, 60_000); // 54 shells: slow on Windows under a full parallel run
});
+2
View File
@@ -19,6 +19,8 @@ const EXPECTED: Record<string, Record<string, string>> = {
role: "USER-DEFINED:user_role|NO|'user'::user_role",
is_active: 'boolean|NO|true',
totp_secret_enc: 'text|YES|',
totp_recovery_hashes: "ARRAY:_text|NO|'{}'::text[]",
totp_last_step: 'bigint|YES|',
must_change_password: 'boolean|NO|false',
last_login_at: 'timestamp with time zone|YES|',
...ts,
+115
View File
@@ -0,0 +1,115 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T1: two-step sign-in for admins (08 §2.4).
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { stepAt, totpCode } from '../src/auth/totp.ts';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('two-step sign-in', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let cookie: string;
let secret: string;
let recovery: string[];
const req = (method: string, url: string, payload?: unknown, c = cookie) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie: c },
});
const login = (totp?: string): Promise<LightMyRequestResponse> =>
t.app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { email: ADMIN.email, password: ADMIN.password, ...(totp ? { totp } : {}) },
});
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
cookie = await t.login(ADMIN.email, ADMIN.password);
});
afterAll(() => t?.close());
beforeEach(() => resetLoginLimiter());
it('turns on with a code from the scanned secret; gives 10 recovery codes once', async () => {
expect((await req('GET', '/api/auth/totp')).json()).toEqual({
enabled: false,
recoveryCodesLeft: 0,
});
const setup = (await req('POST', '/api/auth/totp/setup', {})).json();
secret = setup.secret;
expect(setup.otpauthUrl).toMatch(/^otpauth:\/\/totp\/.+\?secret=[A-Z2-7]{32}&issuer=/);
expect(setup.qr).toMatch(/^data:image\/png;base64,/);
const bad = await req('POST', '/api/auth/totp/enable', { secret, code: '000000' });
expect(bad.statusCode).toBe(400);
const ok = await req('POST', '/api/auth/totp/enable', {
secret,
code: totpCode(secret, stepAt()),
});
expect(ok.statusCode, ok.body).toBe(200);
recovery = ok.json().recoveryCodes;
expect(recovery).toHaveLength(10);
expect((await req('GET', '/api/auth/totp')).json()).toEqual({
enabled: true,
recoveryCodesLeft: 10,
});
expect(
(await req('POST', '/api/auth/totp/enable', { secret, code: '123456' })).statusCode,
).toBe(409);
});
it('login asks for the code after the password; a code works once', async () => {
const noCode = await login();
expect(noCode.statusCode).toBe(401);
expect(noCode.json().error.code).toBe('totp_required');
expect(noCode.headers['set-cookie']).toBeUndefined();
expect((await login('000000')).json().error.code).toBe('totp_invalid');
// The step used to turn it on is spent; the next one (within the ±1 window) works, once.
const next = totpCode(secret, stepAt() + 1);
expect((await login(next)).statusCode).toBe(200);
expect((await login(next)).json().error.code).toBe('totp_invalid');
// A wrong password never reveals that a code is needed.
const wrongPw = await t.app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { email: ADMIN.email, password: 'nope-nope-nope' },
});
expect(wrongPw.json().error.code).toBe('unauthorized');
});
it('a recovery code works once, typed any way', async () => {
const code = recovery[0]!;
expect((await login(code.toUpperCase())).statusCode).toBe(200);
expect((await login(code)).statusCode).toBe(401);
expect((await req('GET', '/api/auth/totp')).json().recoveryCodesLeft).toBe(9);
});
it('users list shows it; another admin can turn it off; off needs the password', async () => {
const list = (await req('GET', '/api/users')).json().items;
expect(list.find((u: { email: string }) => u.email === ADMIN.email).twoStep).toBe(true);
expect((await req('POST', '/api/auth/totp/disable', { password: 'wrong' })).statusCode).toBe(
400,
);
const off = await req('POST', '/api/auth/totp/disable', { password: ADMIN.password });
expect(off.statusCode).toBe(200);
expect((await login()).statusCode).toBe(200);
// the admin reset (for a lost phone)
const me = (await req('GET', '/api/auth/me')).json().user;
expect((await req('POST', `/api/users/${me.id}/reset-two-step`, {})).statusCode).toBe(200);
});
it('is for admins only', async () => {
cookie = await t.login(ADMIN.email, ADMIN.password); // the reset ended my sessions
const u = (
await req('POST', '/api/users', { email: 'plain@example.com', displayName: 'Plain' })
).json();
await t.clearMustChange('plain@example.com');
const plain = await t.login('plain@example.com', u.temporaryPassword);
expect((await req('POST', '/api/auth/totp/setup', {}, plain)).statusCode).toBe(403);
});
});