auth: two-step sign-in (TOTP) for admins, with recovery codes
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:
1 parent
65e777ab41
commit
8c750c67ca
31 files changed
+4842
-43
No files matched your search
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,8 @@ import type {
|
||||
SoundDto,
|
||||
SoftphoneQr,
|
||||
MohClassDto,
|
||||
TotpSetup,
|
||||
TotpStatus,
|
||||
VoipmsDidDto,
|
||||
TimeConditionCreate,
|
||||
TimeConditionDto,
|
||||
@@ -121,8 +123,20 @@ async function upload<T>(method: string, url: string, body: Blob): Promise<T> {
|
||||
|
||||
export const authApi = {
|
||||
me: () => api<{ user: AuthUser }>('GET', '/api/auth/me').then((r) => r.user),
|
||||
login: (email: string, password: string) =>
|
||||
api<{ user: AuthUser }>('POST', '/api/auth/login', { email, password }).then((r) => r.user),
|
||||
login: (email: string, password: string, totp?: string) =>
|
||||
api<{ user: AuthUser }>('POST', '/api/auth/login', {
|
||||
email,
|
||||
password,
|
||||
...(totp ? { totp } : {}),
|
||||
}).then((r) => r.user),
|
||||
totp: () => api<TotpStatus>('GET', '/api/auth/totp'),
|
||||
totpSetup: () => api<TotpSetup>('POST', '/api/auth/totp/setup', {}),
|
||||
totpEnable: (secret: string, code: string) =>
|
||||
api<{ recoveryCodes: string[] }>('POST', '/api/auth/totp/enable', { secret, code }),
|
||||
totpDisable: (password: string) =>
|
||||
api<{ ok: true }>('POST', '/api/auth/totp/disable', { password }),
|
||||
totpNewCodes: (password: string) =>
|
||||
api<{ recoveryCodes: string[] }>('POST', '/api/auth/totp/recovery-codes', { password }),
|
||||
logout: () => api<{ ok: true }>('POST', '/api/auth/logout'),
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
api<{ ok: true }>('POST', '/api/auth/change-password', { currentPassword, newPassword }),
|
||||
@@ -136,6 +150,7 @@ export const usersApi = {
|
||||
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/users/${id}`),
|
||||
resetPassword: (id: string) =>
|
||||
api<{ temporaryPassword: string }>('POST', `/api/users/${id}/reset-password`),
|
||||
resetTwoStep: (id: string) => api<{ ok: true }>('POST', `/api/users/${id}/reset-two-step`, {}),
|
||||
};
|
||||
|
||||
export const extensionsApi = {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { QueuesPage } from './pages/admin/QueuesPage.tsx';
|
||||
import { ConferencesPage } from './pages/admin/ConferencesPage.tsx';
|
||||
import { PagingPage } from './pages/admin/PagingPage.tsx';
|
||||
import { PhonesPage } from './pages/admin/PhonesPage.tsx';
|
||||
import { AccountPage } from './pages/admin/AccountPage.tsx';
|
||||
import { IvrsPage } from './pages/admin/IvrsPage.tsx';
|
||||
import { OfficeHoursPage } from './pages/admin/OfficeHoursPage.tsx';
|
||||
import { BlockedPage } from './pages/admin/BlockedPage.tsx';
|
||||
@@ -80,6 +81,7 @@ const router = createBrowserRouter([
|
||||
})),
|
||||
{ path: 'settings/:tab', element: <SettingsPage /> },
|
||||
{ path: 'queues/:id', element: <QueuesPage /> },
|
||||
{ path: 'account', element: <AccountPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '/me/*', element: <RequireAuth>{(user) => <MePage user={user} />}</RequireAuth> },
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { APP_NAME, LoginSchema } from '@selfphoneng/shared';
|
||||
import { authApi } from '../api/client.ts';
|
||||
import { ApiError, authApi } from '../api/client.ts';
|
||||
import { homeFor } from '../auth/RequireAuth.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../components/ui.tsx';
|
||||
|
||||
@@ -26,13 +26,16 @@ export function LoginPage() {
|
||||
const [params] = useSearchParams();
|
||||
const qc = useQueryClient();
|
||||
const [error, setError] = useState<string>();
|
||||
// Two-step sign-in: shown once the server says the password was right and a code is needed.
|
||||
const [needCode, setNeedCode] = useState(false);
|
||||
const [code, setCode] = useState('');
|
||||
const form = useForm({ resolver: zodResolver(LoginSchema.omit({ totp: true })) });
|
||||
const { errors, isSubmitting } = form.formState;
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ email, password }) => {
|
||||
setError(undefined);
|
||||
try {
|
||||
const user = await authApi.login(email, password);
|
||||
const user = await authApi.login(email, password, needCode ? code : undefined);
|
||||
qc.setQueryData(['me'], user);
|
||||
const next = params.get('next');
|
||||
// Only same-site paths: never redirect to another origin.
|
||||
@@ -40,6 +43,10 @@ export function LoginPage() {
|
||||
replace: true,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.code === 'totp_required') {
|
||||
setNeedCode(true);
|
||||
return;
|
||||
}
|
||||
setError((e as Error).message);
|
||||
}
|
||||
});
|
||||
@@ -70,6 +77,23 @@ export function LoginPage() {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{needCode && (
|
||||
<Field
|
||||
label="Authentication code"
|
||||
hint="From your authenticator app, or one of your recovery codes"
|
||||
>
|
||||
{(a) => (
|
||||
<input
|
||||
{...a}
|
||||
autoFocus
|
||||
autoComplete="one-time-code"
|
||||
className={inputClass}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<Button type="submit" variant="primary" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// My account (admins): password, and two-step sign-in (TOTP, 08 §2.4) with recovery codes.
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { authApi } from '../../api/client.ts';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
export function AccountPage() {
|
||||
return (
|
||||
<section className="flex max-w-xl flex-col gap-6">
|
||||
<h1 className="text-xl font-semibold">My account</h1>
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="font-medium">Password</h2>
|
||||
<Link className="text-sm text-sky-700 underline dark:text-sky-400" to="/change-password">
|
||||
Change password
|
||||
</Link>
|
||||
</section>
|
||||
<TwoStep />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TwoStep() {
|
||||
const qc = useQueryClient();
|
||||
const status = useQuery({ queryKey: ['totp'], queryFn: authApi.totp });
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['totp'] });
|
||||
const [codes, setCodes] = useState<string[] | null>(null);
|
||||
const [code, setCode] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const setup = useMutation({ mutationFn: authApi.totpSetup });
|
||||
const enable = useMutation({
|
||||
mutationFn: () => authApi.totpEnable(setup.data!.secret, code.trim()),
|
||||
onSuccess: async (r) => {
|
||||
setCodes(r.recoveryCodes);
|
||||
setup.reset();
|
||||
setCode('');
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
const disable = useMutation({
|
||||
mutationFn: () => authApi.totpDisable(password),
|
||||
onSuccess: async () => {
|
||||
setPassword('');
|
||||
setCodes(null);
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
const newCodes = useMutation({
|
||||
mutationFn: () => authApi.totpNewCodes(password),
|
||||
onSuccess: async (r) => {
|
||||
setPassword('');
|
||||
setCodes(r.recoveryCodes);
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
const passwordField = (
|
||||
<Field label="Your password">
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className={inputClass}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
|
||||
return (
|
||||
<section aria-labelledby="two-step-h" className="flex flex-col gap-3 text-sm">
|
||||
<h2 id="two-step-h" className="font-medium">
|
||||
Two-step sign-in
|
||||
</h2>
|
||||
<p className="text-zinc-600 dark:text-zinc-400">
|
||||
Besides your password, signing in asks for a code from an authenticator app on your phone
|
||||
(any app that does TOTP, such as Aegis, 2FAS, Google or Microsoft Authenticator).
|
||||
</p>
|
||||
{status.isError && <Alert>{status.error.message}</Alert>}
|
||||
{codes && (
|
||||
<div role="note" className="rounded-md bg-amber-50 p-3 dark:bg-amber-950">
|
||||
<p className="mb-2 font-medium">
|
||||
Recovery codes: save them now, they won't be shown again
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
Each one signs you in once if you lose your phone. Keep them somewhere safe, away from
|
||||
the phone.
|
||||
</p>
|
||||
<ul aria-label="Recovery codes" className="grid grid-cols-2 gap-1 font-mono">
|
||||
{codes.map((c) => (
|
||||
<li key={c}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{status.data && !status.data.enabled && !setup.data && (
|
||||
<Button className="self-start" onClick={() => setup.mutate()} disabled={setup.isPending}>
|
||||
Turn on two-step sign-in
|
||||
</Button>
|
||||
)}
|
||||
{setup.data && (
|
||||
<form
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
enable.mutate();
|
||||
}}
|
||||
>
|
||||
<p>1. Scan this with your authenticator app:</p>
|
||||
<img
|
||||
src={setup.data.qr}
|
||||
alt="Authenticator setup QR code"
|
||||
width={240}
|
||||
height={240}
|
||||
className="rounded bg-white"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Or type this key: <span className="font-mono break-all">{setup.data.secret}</span>
|
||||
</p>
|
||||
<Field label="2. Enter the 6-digit code it shows">
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className={`${inputClass} max-w-40`}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" variant="primary" disabled={enable.isPending}>
|
||||
Turn on
|
||||
</Button>
|
||||
<Button onClick={() => setup.reset()}>Cancel</Button>
|
||||
</div>
|
||||
{enable.error && <Alert>{enable.error.message}</Alert>}
|
||||
</form>
|
||||
)}
|
||||
{status.data?.enabled && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p>
|
||||
<strong>On.</strong> {status.data.recoveryCodesLeft} recovery code
|
||||
{status.data.recoveryCodesLeft === 1 ? '' : 's'} left.
|
||||
</p>
|
||||
{passwordField}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => newCodes.mutate()} disabled={!password || newCodes.isPending}>
|
||||
New recovery codes
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => disable.mutate()}
|
||||
disabled={!password || disable.isPending}
|
||||
>
|
||||
Turn off
|
||||
</Button>
|
||||
</div>
|
||||
{(disable.error ?? newCodes.error) && (
|
||||
<Alert>{(disable.error ?? newCodes.error)!.message}</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -101,8 +101,8 @@ export function AdminLayout({ user }: { user: AuthUser }) {
|
||||
<ThemeToggle />
|
||||
{/* On phones this lives in the menu instead. */}
|
||||
<span className="hidden sm:contents">
|
||||
<Button variant="ghost" onClick={() => nav('/change-password')}>
|
||||
Password
|
||||
<Button variant="ghost" onClick={() => nav('/admin/account')}>
|
||||
Account
|
||||
</Button>
|
||||
</span>
|
||||
<Button variant="ghost" onClick={logout}>
|
||||
@@ -120,9 +120,9 @@ export function AdminLayout({ user }: { user: AuthUser }) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() => nav('/change-password')}
|
||||
onClick={() => nav('/admin/account')}
|
||||
>
|
||||
Change password
|
||||
My account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// User + extension editor (05 §2). Tabs so far: General, Phone, Calling, Emergency, Credentials.
|
||||
// Emergency too. Voicemail (M4) and Forwarding (M6) arrive with their features.
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
@@ -237,6 +237,7 @@ function GeneralTab({
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Creates a new temporary password and signs them out everywhere.
|
||||
</p>
|
||||
{user.twoStep && <ResetTwoStep user={user} />}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
@@ -560,3 +561,27 @@ function EmergencyTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** For a user who lost their phone and recovery codes (08 §2.4). */
|
||||
function ResetTwoStep({ user }: { user: UserDto }) {
|
||||
const qc = useQueryClient();
|
||||
const reset = useMutation({
|
||||
mutationFn: () => usersApi.resetTwoStep(user.id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
|
||||
});
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{reset.isSuccess ? (
|
||||
<p className="text-sm text-green-700 dark:text-green-400">Two-step sign-in is off.</p>
|
||||
) : (
|
||||
<Button onClick={() => reset.mutate()} disabled={reset.isPending}>
|
||||
Turn off two-step sign-in
|
||||
</Button>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
For a lost phone: they sign in with their password alone and can turn it on again.
|
||||
</p>
|
||||
{reset.error && <Alert>{reset.error.message}</Alert>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,6 +73,8 @@ create table users (
|
||||
role user_role not null default 'user',
|
||||
is_active boolean not null default true,
|
||||
totp_secret_enc text, -- null = 2FA off (M11)
|
||||
totp_recovery_hashes text[] not null default '{}', -- sha256 of unused recovery codes (M11)
|
||||
totp_last_step bigint, -- last 30 s step signed in with: no replay (M11)
|
||||
must_change_password boolean not null default false,
|
||||
last_login_at timestamptz
|
||||
);
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
- Minimum 10 characters. Reject the 1,000 most common passwords (bundle a list in `auth/common-passwords.txt`).
|
||||
|
||||
### 2.2 Sessions (browser)
|
||||
- `POST /api/auth/login {email, password, totp?}` → on success, create a 32-byte random
|
||||
- `POST /api/auth/login {email, password, totp?}` (`totp`: the code or a recovery code; when 2FA is on
|
||||
and it's missing, 401 `totp_required`; wrong, 401 `totp_invalid`; M11-T1; also `GET /api/auth/totp`,
|
||||
`POST /api/auth/totp/setup|enable|disable|recovery-codes`, 08 §2.4) → on success, create a 32-byte random
|
||||
token. Store `sha256(token)` in `sessions.id` and set the cookie
|
||||
`sp_session=<token>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1209600` (14 days).
|
||||
- Sliding expiry: if a session has less than 7 days left when used, extend it to 14 days.
|
||||
|
||||
@@ -51,6 +51,14 @@ aren't filtered. The setting is on Settings → Security, with the provisioning
|
||||
- HSTS is set by the reverse proxy (Caddy does it by default; nginx example adds `add_header Strict-Transport-Security "max-age=31536000" always;`).
|
||||
- `TRUST_PROXY=true` → Fastify `trustProxy: '127.0.0.1'` only.
|
||||
- Admin TOTP (M11): RFC 6238, 30 s, 6 digits, ±1 window, 10 single-use recovery codes (stored hashed).
|
||||
- As built (M11-T1), TOTP (`auth/totp.ts`, node:crypto only, RFC 6238 test vectors in its test):
|
||||
opt-in for admins on **My account** (header → Account): scan the QR or type the key, confirm one
|
||||
code, get 10 recovery codes once (`k7m3q-x9tpw`; case and dashes don't matter). 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). Each 30 s step signs in once (`users.totp_last_step`, updated
|
||||
atomically) and each recovery code once (`array_remove` with an `any()` guard). Turning it off or
|
||||
making new codes needs the password. Another admin can turn it off for a lost phone
|
||||
(`POST /api/users/:id/reset-two-step`, signs that user out). `scripts/e2e/m11-t1-totp.mjs`.
|
||||
- As built (M11-T1): the headers come from an `onSend` hook on every response (UI, API, `/prov`);
|
||||
`connect-src` also allows `WEBRTC_WS_URL`'s origin when it's set. The dark-mode bootstrap moved
|
||||
from an inline `<script>` in `index.html` to `/theme.js`, so `script-src` stays `'self'`.
|
||||
|
||||
@@ -6,10 +6,8 @@ export const PASSWORD_MIN = 10;
|
||||
export const LoginSchema = z.object({
|
||||
email: z.string().trim().min(3, 'Enter your email').max(254),
|
||||
password: z.string().min(1, 'Enter your password').max(200),
|
||||
totp: z
|
||||
.string()
|
||||
.regex(/^\d{6}$/)
|
||||
.optional(), // M11
|
||||
/** The authenticator code, or a recovery code ("k7m3q-x9tpw"), when two-step sign-in is on. */
|
||||
totp: z.string().trim().max(20).optional(),
|
||||
});
|
||||
|
||||
export const ChangePasswordSchema = z.object({
|
||||
@@ -31,3 +29,16 @@ export type AuthUser = {
|
||||
mustChangePassword: boolean;
|
||||
extension: { id: string; number: string; name: string } | null;
|
||||
};
|
||||
|
||||
/** Two-step sign-in (TOTP, 08 §2.4) for the signed-in user. */
|
||||
export type TotpStatus = { enabled: boolean; recoveryCodesLeft: number };
|
||||
/** Start: the secret to scan (not stored until a code confirms it). */
|
||||
export type TotpSetup = { secret: string; otpauthUrl: string; qr: string };
|
||||
export const TotpEnableSchema = z.object({
|
||||
secret: z.string().regex(/^[A-Z2-7]{32}$/),
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d{6}$/, 'The 6-digit code from the app'),
|
||||
});
|
||||
export const PasswordConfirmSchema = z.object({ password: z.string().min(1).max(200) });
|
||||
@@ -29,6 +29,8 @@ export type UserDto = {
|
||||
role: 'admin' | 'user';
|
||||
isActive: boolean;
|
||||
mustChangePassword: boolean;
|
||||
/** Two-step sign-in (TOTP) is on. */
|
||||
twoStep: boolean;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
extension: { id: string; number: string } | null;
|
||||
|
||||
@@ -203,3 +203,8 @@ violation; turns the phone ACL on with the phones' Docker network unlisted (390
|
||||
"Not match Endpoint ACL") and then listed (it registers); and changes 391's voicemail PIN from the
|
||||
phone with `*98` and DTMF: `1111` is refused by `sp-vm-pincheck`, `7391` is stored by the api and
|
||||
survives a later config write.
|
||||
|
||||
`m11-t1-totp.mjs` (after `m1-t4.mjs`): the admin turns two-step sign-in on from **My account**
|
||||
(a wrong code refused, 10 recovery codes shown), signs out and back in with password then a code,
|
||||
then with a recovery code (9 left), and turns it off with the password. Codes come from
|
||||
`apps/api/src/auth/totp.ts` (node runs it directly).
|
||||
@@ -83,7 +83,9 @@ try {
|
||||
const entry = (n) => new RegExp(`<entry name="${n}">([^<]*)</entry>`).exec(xml)?.[1];
|
||||
assert.equal(entry('username'), '370');
|
||||
assert.match(xml, /reg_identity">"Lin Mobile" <sip:370@/);
|
||||
const tampered = path.replace(/.(\.xml)$/, (m, x) => (m[0] === 'A' ? 'B' : 'A') + x);
|
||||
// Change a character in the middle (the last one of base64 may only hold padding bits).
|
||||
const i = path.lastIndexOf('/') + 20;
|
||||
const tampered = path.slice(0, i) + (path[i] === 'A' ? 'B' : 'A') + path.slice(i + 1);
|
||||
assert.equal((await fetch(`${base}${tampered}`)).status, 404, 'a changed token');
|
||||
console.log('the link gives the account XML; a changed token gets 404');
|
||||
|
||||
|
||||
+22
-18
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M10-T4 with real phones: 380 calls 381; the admin (desk phone 382) presses Listen, then Whisper,
|
||||
// then Barge on the dashboard. Each time 382 rings, answers, and Asterisk runs ChanSpy on the
|
||||
// M10-T4 with real phones: 380 calls 381; the admin (desk phone 382, or theirs) presses Listen, then Whisper,
|
||||
// then Barge on the dashboard. Each time it rings, answers, and Asterisk runs ChanSpy on the
|
||||
// caller's leg with the mode's options (q, E, and w / B). When the call ends, the spy leg ends too.
|
||||
// Runs on m2-live, after m1-t4.mjs.
|
||||
// Usage: node scripts/e2e/m10-t4.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
|
||||
@@ -58,19 +58,20 @@ const channels = () =>
|
||||
});
|
||||
|
||||
const me = (await api('GET', '/api/auth/me')).user;
|
||||
// The admin's own extension is the spy phone; another script may have given them one already.
|
||||
const boss =
|
||||
me.extension ??
|
||||
(await api('POST', '/api/extensions', { number: '382', name: 'Boss Bo', userId: me.id }));
|
||||
const BOSS = boss.number;
|
||||
const phoneList = [];
|
||||
for (const [n, name, userId] of [
|
||||
['380', 'Cal Caller', null],
|
||||
['381', 'Agent Ava', null],
|
||||
['382', 'Boss Bo', me.id],
|
||||
for (const [n, name] of [
|
||||
['380', 'Cal Caller'],
|
||||
['381', 'Agent Ava'],
|
||||
[BOSS, null],
|
||||
]) {
|
||||
const e = await api('POST', '/api/extensions', {
|
||||
number: n,
|
||||
name,
|
||||
...(userId ? { userId } : {}),
|
||||
});
|
||||
const e = name ? await api('POST', '/api/extensions', { number: n, name }) : boss;
|
||||
const pass = (await api('GET', `/api/extensions/${e.id}/sip-credentials`)).password;
|
||||
phoneList.push({ ext: n, password: pass, answer: 'auto', toneHz: 300 + Number(n) });
|
||||
phoneList.push({ ext: n, password: pass, answer: 'auto', toneHz: 300 + (Number(n) % 500) });
|
||||
}
|
||||
const phones = startPhones({ name: 'sp-live-phones-m10t4', net: NET, ast: AST, phones: phoneList });
|
||||
|
||||
@@ -85,7 +86,7 @@ const press = (label) =>
|
||||
try {
|
||||
await waitUntil(
|
||||
() =>
|
||||
['380', '381', '382'].every((e) =>
|
||||
['380', '381', BOSS].every((e) =>
|
||||
new RegExp(`${e}/sip:.*Avail`).test(cli('pjsip show contacts')),
|
||||
),
|
||||
'phones',
|
||||
@@ -109,8 +110,8 @@ try {
|
||||
]) {
|
||||
await press(label);
|
||||
const spyLeg = await waitUntil(
|
||||
() => channels().find((c) => c.name.startsWith('PJSIP/382-') && c.app === 'ChanSpy'),
|
||||
`${label}: 382 runs ChanSpy`,
|
||||
() => channels().find((c) => c.name.startsWith(`PJSIP/${BOSS}-`) && c.app === 'ChanSpy'),
|
||||
`${label}: ${BOSS} runs ChanSpy`,
|
||||
);
|
||||
assert.equal(spyLeg.state, 'Up');
|
||||
assert.match(spyLeg.data, new RegExp(`^PJSIP/38[01]-[0-9a-f]+,${opts}$`), spyLeg.data);
|
||||
@@ -119,10 +120,13 @@ try {
|
||||
`${label}: status shown`,
|
||||
);
|
||||
if (label === 'Listen') await p.shot('m10-t4-01-listening');
|
||||
console.log(`${label}: 382 answered and runs ChanSpy(${spyLeg.data})`);
|
||||
console.log(`${label}: ${BOSS} answered and runs ChanSpy(${spyLeg.data})`);
|
||||
if (label !== 'Barge') {
|
||||
cli(`channel request hangup ${spyLeg.name}`);
|
||||
await waitUntil(() => !channels().some((c) => c.name.startsWith('PJSIP/382-')), 'spy off');
|
||||
await waitUntil(
|
||||
() => !channels().some((c) => c.name.startsWith(`PJSIP/${BOSS}-`)),
|
||||
'spy off',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +134,7 @@ try {
|
||||
const caller = channels().find((c) => c.name.startsWith('PJSIP/380-'));
|
||||
cli(`channel request hangup ${caller.name}`);
|
||||
await waitUntil(
|
||||
() => !channels().some((c) => c.name.startsWith('PJSIP/382-')),
|
||||
() => !channels().some((c) => c.name.startsWith(`PJSIP/${BOSS}-`)),
|
||||
'spy ends',
|
||||
15_000,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M11-T1 two-step sign-in in a browser: the admin turns it on from My account (scans the key,
|
||||
// enters a code, gets 10 recovery codes), signs out, signs in with password then a code, then with
|
||||
// a recovery code, and turns it off again (later scripts sign in with the password alone).
|
||||
// Codes come from the api's own TOTP module, like an authenticator app would make them.
|
||||
// Runs after m1-t4.mjs.
|
||||
// Usage: node scripts/e2e/m11-t1-totp.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
|
||||
import assert from 'node:assert/strict';
|
||||
import { launch } from './cdp.mjs';
|
||||
|
||||
const { stepAt, totpCode } = await import('../../apps/api/src/auth/totp.ts');
|
||||
const [base, email, pw, shotsDir] = process.argv.slice(2);
|
||||
const p = await launch({ shotsDir });
|
||||
const signIn = async (code) => {
|
||||
await p.goto(`${base}/login`);
|
||||
await p.waitFor(`!!document.querySelector('input[type=email]')`);
|
||||
await p.fill('Email', email);
|
||||
await p.fill('Password', pw);
|
||||
await p.click('Sign in');
|
||||
await p.waitFor(`document.body.innerText.includes('Authentication code')`, 'asked for the code');
|
||||
await p.fill('Authentication code', code);
|
||||
await p.click('Sign in');
|
||||
await p.waitFor(`location.pathname === '/admin'`, 'signed in');
|
||||
};
|
||||
const signOut = async () => {
|
||||
await p.click('Sign out');
|
||||
await p.waitFor(`location.pathname === '/login'`);
|
||||
};
|
||||
try {
|
||||
await p.goto(`${base}/login`);
|
||||
await p.waitFor(`!!document.querySelector('input[type=email]')`);
|
||||
await p.fill('Email', email);
|
||||
await p.fill('Password', pw);
|
||||
await p.click('Sign in');
|
||||
await p.waitFor(`location.pathname === '/admin'`);
|
||||
|
||||
// Turn it on.
|
||||
await p.click('Account');
|
||||
await p.waitFor(`document.body.innerText.includes('Turn on two-step sign-in')`, 'status');
|
||||
await p.click('Turn on two-step sign-in');
|
||||
await p.waitFor(`!!document.querySelector('img[alt="Authenticator setup QR code"]')`, 'QR');
|
||||
const secret = await p.evaluate(
|
||||
`[...document.querySelectorAll('span.font-mono')].map(s => s.textContent).find(t => /^[A-Z2-7]{32}$/.test(t))`,
|
||||
);
|
||||
assert.match(secret, /^[A-Z2-7]{32}$/);
|
||||
await p.fill('2. Enter the 6-digit code it shows', '000000');
|
||||
await p.click('Turn on');
|
||||
await p.waitFor(`document.body.innerText.includes('doesn’t match')`, 'wrong code refused');
|
||||
await p.fill('2. Enter the 6-digit code it shows', totpCode(secret, stepAt()));
|
||||
await p.click('Turn on');
|
||||
await p.waitFor(`!!document.querySelector('ul[aria-label="Recovery codes"]')`, 'codes');
|
||||
const codes = await p.evaluate(
|
||||
`[...document.querySelectorAll('ul[aria-label="Recovery codes"] li')].map(l => l.textContent)`,
|
||||
);
|
||||
assert.equal(codes.length, 10);
|
||||
await p.shot('m11-t1-02-recovery-codes');
|
||||
console.log('two-step sign-in on from My account (a wrong code refused); 10 recovery codes');
|
||||
|
||||
// Sign in with a code (the next step: the current one was spent turning it on).
|
||||
await signOut();
|
||||
await signIn(totpCode(secret, stepAt() + 1));
|
||||
console.log('signed in with password, then the authenticator code');
|
||||
|
||||
// And with a recovery code.
|
||||
await signOut();
|
||||
await signIn(codes[0]);
|
||||
await p.click('Account');
|
||||
await p.waitFor(`document.body.innerText.includes('9 recovery codes left')`, '9 left');
|
||||
console.log('signed in with a recovery code; 9 left');
|
||||
|
||||
// Off again.
|
||||
await p.fill('Your password', pw);
|
||||
await p.click('Turn off');
|
||||
await p.waitFor(`document.body.innerText.includes('Turn on two-step sign-in')`, 'off');
|
||||
console.log('turned off with the password');
|
||||
console.log('M11-T1 (two-step sign-in): PASS');
|
||||
} catch (e) {
|
||||
await p.shot('m11-t1-totp-fail').catch(() => {});
|
||||
console.log(`FAIL: ${e.message}`);
|
||||
console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await p.close();
|
||||
}
|
||||
@@ -89,6 +89,11 @@ try {
|
||||
const e = await api('POST', '/api/extensions', { number: n, name, voicemailPin: '4826' });
|
||||
exts[n] = e;
|
||||
}
|
||||
// With a public IP set, the local nets are also in the SIP transports, which only change on a
|
||||
// restart (01 §4.3); another script may have set one. Without it, they reload at once.
|
||||
// Clearing it is itself a transport change (that apply skips the PJSIP reload), so on its own.
|
||||
await api('PATCH', '/api/settings', { 'network.publicIp': null });
|
||||
await sleep(3000);
|
||||
await api('PATCH', '/api/settings', {
|
||||
'sip.restrictPhonesToLocalNets': true,
|
||||
'network.localNets': ['192.168.0.0/16'],
|
||||
@@ -158,6 +163,7 @@ try {
|
||||
await api('PATCH', '/api/settings', {
|
||||
'sip.restrictPhonesToLocalNets': settings['sip.restrictPhonesToLocalNets'],
|
||||
'network.localNets': settings['network.localNets'],
|
||||
'network.publicIp': settings['network.publicIp'],
|
||||
}).catch(() => {});
|
||||
cli('channel request hangup all');
|
||||
await p.close();
|
||||
|
||||
@@ -15,6 +15,14 @@ const [base, email, pw, shotsDir] = process.argv.slice(2);
|
||||
const p = await launch({ shotsDir });
|
||||
const body = () => p.evaluate('document.body.innerText');
|
||||
async function testNumber(n) {
|
||||
// As Dan (101): other scripts may have made extensions that sort first.
|
||||
await p.evaluate(`(() => {
|
||||
const s = [...document.querySelectorAll('select')].find(s => s.labels?.[0]?.textContent === 'Caller');
|
||||
const o = [...s.options].find(o => o.textContent.startsWith('101 '));
|
||||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set.call(s, o.value);
|
||||
s.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
})()`);
|
||||
await p.fill('Dials', n);
|
||||
await p.evaluate(
|
||||
`[...document.querySelectorAll('form button[type=submit]')].find(b => b.textContent === 'Test').click(), true`,
|
||||
|
||||
@@ -172,12 +172,13 @@ try {
|
||||
Context: 'sp-from-ext',
|
||||
Exten: '700',
|
||||
});
|
||||
await waitUntil(() => parkedSlot() === '701', 'caller 1 parked on 701');
|
||||
console.log('341 parked caller 1 on 701');
|
||||
cli('channel originate PJSIP/343 extension 701@sp-from-ext');
|
||||
// findslot = next: 701 on a fresh system, the slot after the last one used otherwise.
|
||||
const slot = await waitUntil(() => parkedSlot(), 'caller 1 parked');
|
||||
console.log(`341 parked caller 1 on ${slot}`);
|
||||
cli(`channel originate PJSIP/343 extension ${slot}@sp-from-ext`);
|
||||
await waitUntil(() => talkingTo('343', callers[0]), '343 talks to caller 1', 20_000);
|
||||
await waitUntil(() => !parkedSlot(), 'slot 701 free');
|
||||
console.log('343 dialed 701 and talks to caller 1');
|
||||
await waitUntil(() => !parkedSlot(), 'slot free');
|
||||
console.log(`343 dialed ${slot} and talks to caller 1`);
|
||||
|
||||
// 341 is free again: caller 2 reaches 341; caller 3 waits past the limit and goes to 342.
|
||||
await waitUntil(() => talkingTo('341', callers[1]), 'caller 2 talks to 341', 20_000);
|
||||
|
||||
Reference in new issue
Block a user