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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user