mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
71d164cf9e
* chore(deps): bump the all-npm-backend group across 1 directory with 10 updates Bumps the all-npm-backend group with 10 updates in the /backend directory: | Package | From | To | | --- | --- | --- | | [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1028.0` | `3.1037.0` | | [axios](https://github.com/axios/axios) | `1.15.0` | `1.15.2` | | [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `12.8.0` | `12.9.0` | | [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) | `8.3.2` | `8.4.1` | | [openid-client](https://github.com/panva/openid-client) | `6.8.2` | `6.8.3` | | [otplib](https://github.com/yeojz/otplib/tree/HEAD/packages/otplib) | `12.0.1` | `13.4.0` | | [eslint](https://github.com/eslint/eslint) | `10.2.0` | `10.2.1` | | [typescript](https://github.com/microsoft/TypeScript) | `6.0.2` | `6.0.3` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.58.1` | `8.59.0` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.4` | `4.1.5` | Updates `@aws-sdk/client-ecr` from 3.1028.0 to 3.1037.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1037.0/clients/client-ecr) Updates `axios` from 1.15.0 to 1.15.2 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.15.0...v1.15.2) Updates `better-sqlite3` from 12.8.0 to 12.9.0 - [Release notes](https://github.com/WiseLibs/better-sqlite3/releases) - [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.8.0...v12.9.0) Updates `express-rate-limit` from 8.3.2 to 8.4.1 - [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases) - [Commits](https://github.com/express-rate-limit/express-rate-limit/compare/v8.3.2...v8.4.1) Updates `openid-client` from 6.8.2 to 6.8.3 - [Release notes](https://github.com/panva/openid-client/releases) - [Changelog](https://github.com/panva/openid-client/blob/main/CHANGELOG.md) - [Commits](https://github.com/panva/openid-client/compare/v6.8.2...v6.8.3) Updates `otplib` from 12.0.1 to 13.4.0 - [Release notes](https://github.com/yeojz/otplib/releases) - [Commits](https://github.com/yeojz/otplib/commits/v13.4.0/packages/otplib) Updates `eslint` from 10.2.0 to 10.2.1 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1) Updates `typescript` from 6.0.2 to 6.0.3 - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.2...v6.0.3) Updates `typescript-eslint` from 8.58.1 to 8.59.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.0/packages/typescript-eslint) Updates `vitest` from 4.1.4 to 4.1.5 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest) --- updated-dependencies: - dependency-name: "@aws-sdk/client-ecr" dependency-version: 3.1037.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-npm-backend - dependency-name: axios dependency-version: 1.15.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-backend - dependency-name: better-sqlite3 dependency-version: 12.9.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-npm-backend - dependency-name: express-rate-limit dependency-version: 8.4.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-npm-backend - dependency-name: openid-client dependency-version: 6.8.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-backend - dependency-name: otplib dependency-version: 13.4.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: all-npm-backend - dependency-name: eslint dependency-version: 10.2.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-npm-backend - dependency-name: typescript dependency-version: 6.0.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-npm-backend - dependency-name: typescript-eslint dependency-version: 8.59.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: all-npm-backend - dependency-name: vitest dependency-version: 4.1.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-npm-backend ... Signed-off-by: dependabot[bot] <support@github.com> * fix(mfa): migrate otplib API to v13 The v13 release removed the singleton authenticator export and renamed HashAlgorithms to a string union type. Switch to the OTP class with generateSync/verifySync for synchronous operation, passing per-call options instead of setting global instance state. --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: SaelixCode <dev@saelix.com>
640 lines
25 KiB
TypeScript
640 lines
25 KiB
TypeScript
/**
|
|
* Tests for Multi-Factor Authentication (TOTP + backup codes):
|
|
* - Enrolment flow (start + confirm) and rejection of wrong OTPs
|
|
* - Login flow: password -> mfa_pending cookie -> /login/mfa -> session cookie
|
|
* - Replay prevention: same (user, code, window) refused twice
|
|
* - Backup code single-use semantics and remaining count
|
|
* - Lockout after repeated failures
|
|
* - Partial-auth session: mfa_pending token rejected on non-MFA routes
|
|
* - Admin reset endpoint
|
|
* - SSO bypass toggle
|
|
* - CLI reset helper (direct import, no subprocess)
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import request from 'supertest';
|
|
import jwt from 'jsonwebtoken';
|
|
import { OTP } from 'otplib';
|
|
import {
|
|
setupTestDb,
|
|
cleanupTestDb,
|
|
seedMfaUser,
|
|
TEST_USERNAME,
|
|
TEST_JWT_SECRET,
|
|
} from './helpers/setupTestDb';
|
|
|
|
// Match the server-side otplib configuration so test-generated OTPs are
|
|
// accepted by the verify path.
|
|
const authenticator = new OTP({ strategy: 'totp' });
|
|
const TOTP_PARAMS = { algorithm: 'sha1' as const, digits: 6, period: 30 };
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
let MfaService: typeof import('../services/MfaService').MfaService;
|
|
|
|
function adminToken(): string {
|
|
const db = DatabaseService.getInstance();
|
|
const user = db.getUserByUsername(TEST_USERNAME)!;
|
|
return jwt.sign(
|
|
{ username: TEST_USERNAME, role: 'admin', tv: user.token_version },
|
|
TEST_JWT_SECRET,
|
|
{ expiresIn: '1m' },
|
|
);
|
|
}
|
|
|
|
function cookieArray(headers: request.Response['headers']): string[] {
|
|
const raw = headers['set-cookie'] as unknown;
|
|
if (!raw) return [];
|
|
return Array.isArray(raw) ? (raw as string[]) : [raw as string];
|
|
}
|
|
|
|
function parseCookie(headers: request.Response['headers'], name: string): string | null {
|
|
for (const c of cookieArray(headers)) {
|
|
if (c.startsWith(`${name}=`)) {
|
|
const value = c.split(';')[0].split('=').slice(1).join('=');
|
|
// express-server `clearCookie` sends an empty value with an expired date
|
|
return value || null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findCookie(headers: request.Response['headers'], name: string): string | undefined {
|
|
return cookieArray(headers).find((c) => c.startsWith(`${name}=`));
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
({ MfaService } = await import('../services/MfaService'));
|
|
|
|
// Mock LicenseService to return paid/admiral so the admin routes pass gates
|
|
const { LicenseService } = await import('../services/LicenseService');
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
|
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
|
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
|
|
|
({ app } = await import('../index'));
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.restoreAllMocks();
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
// ─── MfaService unit-ish tests ────────────────────────────────────────────────
|
|
|
|
describe('MfaService', () => {
|
|
it('verifyTotp accepts a freshly generated code', () => {
|
|
const secret = MfaService.generateSecret();
|
|
const code = authenticator.generateSync({ secret, ...TOTP_PARAMS });
|
|
expect(MfaService.verifyTotp(secret, code)).toBe(true);
|
|
});
|
|
|
|
it('verifyTotp rejects garbage', () => {
|
|
const secret = MfaService.generateSecret();
|
|
expect(MfaService.verifyTotp(secret, '000000')).toBe(false);
|
|
expect(MfaService.verifyTotp(secret, 'abcdef')).toBe(false);
|
|
expect(MfaService.verifyTotp(secret, '')).toBe(false);
|
|
});
|
|
|
|
it('generateBackupCodes returns 10 uppercase-alnum codes', () => {
|
|
const codes = MfaService.generateBackupCodes();
|
|
expect(codes).toHaveLength(10);
|
|
for (const c of codes) {
|
|
expect(c).toMatch(/^[A-Z0-9]{10}$/);
|
|
}
|
|
});
|
|
|
|
it('verifyBackupCode matches and returns remaining set with the matched hash removed', async () => {
|
|
const codes = MfaService.generateBackupCodes();
|
|
const hashes = await MfaService.hashBackupCodes(codes);
|
|
const result = await MfaService.verifyBackupCode(hashes, codes[3]);
|
|
expect(result.matched).toBe(true);
|
|
expect(result.remainingHashes).toHaveLength(hashes.length - 1);
|
|
expect(result.remainingHashes).not.toContain(hashes[3]);
|
|
});
|
|
|
|
it('verifyBackupCode on non-match returns original hashes', async () => {
|
|
const codes = MfaService.generateBackupCodes();
|
|
const hashes = await MfaService.hashBackupCodes(codes);
|
|
const result = await MfaService.verifyBackupCode(hashes, 'NOTACODE99');
|
|
expect(result.matched).toBe(false);
|
|
expect(result.remainingHashes).toBe(hashes);
|
|
});
|
|
|
|
it('normalizeBackupCode strips spaces/dashes and uppercases', () => {
|
|
expect(MfaService.normalizeBackupCode('abcde-fghij')).toBe('ABCDEFGHIJ');
|
|
expect(MfaService.normalizeBackupCode('abcde fghij')).toBe('ABCDEFGHIJ');
|
|
});
|
|
});
|
|
|
|
// ─── Login flow ───────────────────────────────────────────────────────────────
|
|
|
|
describe('POST /api/auth/login with MFA-enabled user', () => {
|
|
const username = 'mfauser-login';
|
|
const password = 'mfapassword123';
|
|
|
|
it('returns mfaRequired and sets the partial-auth cookie only', async () => {
|
|
await seedMfaUser(username, password);
|
|
|
|
const res = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({ username, password });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.mfaRequired).toBe(true);
|
|
|
|
expect(parseCookie(res.headers, 'sencho_mfa_pending')).toBeTruthy();
|
|
// No full session cookie yet.
|
|
expect(parseCookie(res.headers, 'sencho_token')).toBeFalsy();
|
|
});
|
|
|
|
it('/auth/status reports mfaPending=true for a valid pending cookie', async () => {
|
|
const login = await request(app).post('/api/auth/login').send({ username, password });
|
|
const pendingCookie = findCookie(login.headers, 'sencho_mfa_pending')!;
|
|
|
|
const status = await request(app).get('/api/auth/status').set('Cookie', pendingCookie);
|
|
expect(status.status).toBe(200);
|
|
expect(status.body.mfaPending).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── MFA verify endpoint ──────────────────────────────────────────────────────
|
|
|
|
describe('POST /api/auth/login/mfa', () => {
|
|
const username = 'mfauser-verify';
|
|
const password = 'mfapassword123';
|
|
let secret = '';
|
|
let backupCodes: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
({ secret, backupCodes } = await seedMfaUser(username, password));
|
|
});
|
|
|
|
async function startChallenge() {
|
|
const res = await request(app).post('/api/auth/login').send({ username, password });
|
|
return findCookie(res.headers, 'sencho_mfa_pending')!;
|
|
}
|
|
|
|
it('401 when no pending cookie is present', async () => {
|
|
const res = await request(app).post('/api/auth/login/mfa').send({ code: '123456' });
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('accepts a valid TOTP, clears pending cookie, issues session', async () => {
|
|
const pendingCookie = await startChallenge();
|
|
const code = authenticator.generateSync({ secret, ...TOTP_PARAMS });
|
|
|
|
const res = await request(app)
|
|
.post('/api/auth/login/mfa')
|
|
.set('Cookie', pendingCookie)
|
|
.send({ code });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.success).toBe(true);
|
|
|
|
// Session cookie is issued.
|
|
expect(findCookie(res.headers, 'sencho_token')).toBeDefined();
|
|
// Pending cookie is cleared (empty value or Expires in the past).
|
|
const cleared = findCookie(res.headers, 'sencho_mfa_pending');
|
|
expect(cleared).toBeDefined();
|
|
expect(cleared!).toMatch(/sencho_mfa_pending=;/);
|
|
});
|
|
|
|
it('rejects a replayed TOTP within the same window', async () => {
|
|
// Fresh user so previous test state does not pollute the replay table.
|
|
const u = 'mfauser-replay';
|
|
const p = 'mfapassword123';
|
|
const { secret: s } = await seedMfaUser(u, p);
|
|
|
|
const login = await request(app).post('/api/auth/login').send({ username: u, password: p });
|
|
const pending = findCookie(login.headers, 'sencho_mfa_pending')!;
|
|
const code = authenticator.generateSync({ secret: s, ...TOTP_PARAMS });
|
|
|
|
const ok = await request(app).post('/api/auth/login/mfa').set('Cookie', pending).send({ code });
|
|
expect(ok.status).toBe(200);
|
|
|
|
// Second login, same code, still within this 30s window
|
|
const login2 = await request(app).post('/api/auth/login').send({ username: u, password: p });
|
|
const pending2 = findCookie(login2.headers, 'sencho_mfa_pending')!;
|
|
const replay = await request(app).post('/api/auth/login/mfa').set('Cookie', pending2).send({ code });
|
|
expect(replay.status).toBe(401);
|
|
expect(replay.body.code).toBe('OTP_REPLAY');
|
|
});
|
|
|
|
it('rejects an obviously wrong TOTP', async () => {
|
|
const pending = await startChallenge();
|
|
const res = await request(app).post('/api/auth/login/mfa').set('Cookie', pending).send({ code: '000000' });
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('accepts a backup code and invalidates it on a second submission', async () => {
|
|
const u = 'mfauser-backup';
|
|
const p = 'mfapassword123';
|
|
const { backupCodes: codes } = await seedMfaUser(u, p);
|
|
const chosen = codes[0];
|
|
|
|
// First use: ok
|
|
const login1 = await request(app).post('/api/auth/login').send({ username: u, password: p });
|
|
const pending1 = findCookie(login1.headers, 'sencho_mfa_pending')!;
|
|
const first = await request(app)
|
|
.post('/api/auth/login/mfa')
|
|
.set('Cookie', pending1)
|
|
.send({ code: chosen, isBackupCode: true });
|
|
expect(first.status).toBe(200);
|
|
|
|
// Second use of the same code: rejected
|
|
const login2 = await request(app).post('/api/auth/login').send({ username: u, password: p });
|
|
const pending2 = findCookie(login2.headers, 'sencho_mfa_pending')!;
|
|
const second = await request(app)
|
|
.post('/api/auth/login/mfa')
|
|
.set('Cookie', pending2)
|
|
.send({ code: chosen, isBackupCode: true });
|
|
expect(second.status).toBe(401);
|
|
|
|
// Remaining backup count decreased by exactly 1
|
|
const remaining = backupCodes.length;
|
|
const db = DatabaseService.getInstance();
|
|
const user = db.getUserByUsername(u)!;
|
|
const mfa = db.getUserMfa(user.id)!;
|
|
const hashes = mfa.backup_codes_json ? (JSON.parse(mfa.backup_codes_json) as string[]) : [];
|
|
expect(hashes.length).toBe(remaining - 1);
|
|
});
|
|
|
|
it('locks the user after MFA_MAX_FAILED (5) wrong codes and returns 423', async () => {
|
|
const u = 'mfauser-lock';
|
|
const p = 'mfapassword123';
|
|
await seedMfaUser(u, p);
|
|
|
|
const login = await request(app).post('/api/auth/login').send({ username: u, password: p });
|
|
const pending = findCookie(login.headers, 'sencho_mfa_pending')!;
|
|
|
|
let lastStatus = 0;
|
|
for (let i = 0; i < 5; i++) {
|
|
const r = await request(app)
|
|
.post('/api/auth/login/mfa')
|
|
.set('Cookie', pending)
|
|
.send({ code: '000000' });
|
|
lastStatus = r.status;
|
|
}
|
|
expect(lastStatus).toBe(423);
|
|
|
|
// Any further attempt still 423
|
|
const blocked = await request(app)
|
|
.post('/api/auth/login/mfa')
|
|
.set('Cookie', pending)
|
|
.send({ code: '111111' });
|
|
expect(blocked.status).toBe(423);
|
|
});
|
|
});
|
|
|
|
// ─── Partial-auth session guard ───────────────────────────────────────────────
|
|
|
|
describe('authMiddleware partial-auth guard', () => {
|
|
it('rejects mfa_pending token on a non-MFA route with 403 MFA_PENDING', async () => {
|
|
const pendingToken = jwt.sign(
|
|
{ scope: 'mfa_pending', user_id: 42, username: 'whoever' },
|
|
TEST_JWT_SECRET,
|
|
{ expiresIn: '5m' },
|
|
);
|
|
const res = await request(app)
|
|
.get('/api/stacks')
|
|
.set('Authorization', `Bearer ${pendingToken}`);
|
|
expect(res.status).toBe(403);
|
|
expect(res.body.code).toBe('MFA_PENDING');
|
|
});
|
|
});
|
|
|
|
// ─── Enrol / confirm / disable ────────────────────────────────────────────────
|
|
|
|
describe('MFA enrol + confirm', () => {
|
|
it('full enrol -> confirm activates MFA and returns 10 backup codes', async () => {
|
|
// Create a dedicated user so we do not toggle MFA on the admin.
|
|
const start = await request(app)
|
|
.post('/api/users')
|
|
.set('Authorization', `Bearer ${adminToken()}`)
|
|
.send({ username: 'enroller', password: 'enrolpass123', role: 'viewer' });
|
|
expect(start.status).toBe(201);
|
|
|
|
const userId = start.body.id as number;
|
|
const db = DatabaseService.getInstance();
|
|
const user = db.getUserByUsername('enroller')!;
|
|
const userToken = jwt.sign(
|
|
{ username: 'enroller', role: 'viewer', tv: user.token_version },
|
|
TEST_JWT_SECRET,
|
|
{ expiresIn: '1m' },
|
|
);
|
|
|
|
const startRes = await request(app)
|
|
.post('/api/auth/mfa/enroll/start')
|
|
.set('Authorization', `Bearer ${userToken}`);
|
|
expect(startRes.status).toBe(200);
|
|
expect(typeof startRes.body.otpauthUri).toBe('string');
|
|
expect(typeof startRes.body.secret).toBe('string');
|
|
|
|
// Reject wrong OTP
|
|
const wrong = await request(app)
|
|
.post('/api/auth/mfa/enroll/confirm')
|
|
.set('Authorization', `Bearer ${userToken}`)
|
|
.send({ code: '000000' });
|
|
expect(wrong.status).toBe(401);
|
|
|
|
const code = authenticator.generateSync({ secret: startRes.body.secret as string, ...TOTP_PARAMS });
|
|
const confirm = await request(app)
|
|
.post('/api/auth/mfa/enroll/confirm')
|
|
.set('Authorization', `Bearer ${userToken}`)
|
|
.send({ code });
|
|
expect(confirm.status).toBe(200);
|
|
expect(Array.isArray(confirm.body.backupCodes)).toBe(true);
|
|
expect(confirm.body.backupCodes).toHaveLength(10);
|
|
|
|
const mfa = db.getUserMfa(userId);
|
|
expect(mfa?.enabled).toBe(1);
|
|
});
|
|
|
|
it('rejects enroll/start when already enrolled', async () => {
|
|
const db = DatabaseService.getInstance();
|
|
const user = db.getUserByUsername('enroller')!;
|
|
const token = jwt.sign(
|
|
{ username: 'enroller', role: 'viewer', tv: user.token_version },
|
|
TEST_JWT_SECRET,
|
|
{ expiresIn: '1m' },
|
|
);
|
|
const res = await request(app)
|
|
.post('/api/auth/mfa/enroll/start')
|
|
.set('Authorization', `Bearer ${token}`);
|
|
expect(res.status).toBe(409);
|
|
});
|
|
|
|
it('disable without a valid code returns 401 and MFA stays enabled', async () => {
|
|
const db = DatabaseService.getInstance();
|
|
const user = db.getUserByUsername('enroller')!;
|
|
const token = jwt.sign(
|
|
{ username: 'enroller', role: 'viewer', tv: user.token_version },
|
|
TEST_JWT_SECRET,
|
|
{ expiresIn: '1m' },
|
|
);
|
|
const res = await request(app)
|
|
.post('/api/auth/mfa/disable')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ code: '000000' });
|
|
expect(res.status).toBe(401);
|
|
expect(db.getUserMfa(user.id)?.enabled).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ─── Admin reset ──────────────────────────────────────────────────────────────
|
|
|
|
describe('POST /api/users/:id/mfa/reset', () => {
|
|
it('non-admin caller gets 403', async () => {
|
|
// Create a viewer and seed MFA for someone else
|
|
const db = DatabaseService.getInstance();
|
|
const { userId: victimId } = await seedMfaUser('victim', 'victimpass123');
|
|
await request(app)
|
|
.post('/api/users')
|
|
.set('Authorization', `Bearer ${adminToken()}`)
|
|
.send({ username: 'nonadmin', password: 'nonadminpass123', role: 'viewer' });
|
|
const nonAdmin = db.getUserByUsername('nonadmin')!;
|
|
const nonAdminToken = jwt.sign(
|
|
{ username: 'nonadmin', role: 'viewer', tv: nonAdmin.token_version },
|
|
TEST_JWT_SECRET,
|
|
{ expiresIn: '1m' },
|
|
);
|
|
const res = await request(app)
|
|
.post(`/api/users/${victimId}/mfa/reset`)
|
|
.set('Authorization', `Bearer ${nonAdminToken}`);
|
|
expect(res.status).toBe(403);
|
|
expect(db.getUserMfa(victimId)?.enabled).toBe(1);
|
|
});
|
|
|
|
it('admin clears the target MFA and bumps their token_version', async () => {
|
|
const db = DatabaseService.getInstance();
|
|
const { userId } = await seedMfaUser('victim2', 'victim2pass123');
|
|
const before = db.getUser(userId)!.token_version;
|
|
const res = await request(app)
|
|
.post(`/api/users/${userId}/mfa/reset`)
|
|
.set('Authorization', `Bearer ${adminToken()}`);
|
|
expect(res.status).toBe(200);
|
|
expect(db.getUserMfa(userId)).toBeUndefined();
|
|
expect(db.getUser(userId)!.token_version).toBeGreaterThan(before);
|
|
});
|
|
});
|
|
|
|
// ─── SSO bypass toggle ────────────────────────────────────────────────────────
|
|
|
|
describe('PUT /api/auth/mfa/sso-bypass', () => {
|
|
it('persists the toggle on an enrolled user', async () => {
|
|
const db = DatabaseService.getInstance();
|
|
const { userId } = await seedMfaUser('ssouser', 'ssouserpass123');
|
|
const user = db.getUserById(userId)!;
|
|
const token = jwt.sign(
|
|
{ username: user.username, role: user.role, tv: user.token_version },
|
|
TEST_JWT_SECRET,
|
|
{ expiresIn: '1m' },
|
|
);
|
|
|
|
const enable = await request(app)
|
|
.put('/api/auth/mfa/sso-bypass')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ enforce: true });
|
|
expect(enable.status).toBe(200);
|
|
expect(db.getUserMfa(userId)?.sso_enforce_mfa).toBe(1);
|
|
|
|
const disable = await request(app)
|
|
.put('/api/auth/mfa/sso-bypass')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ enforce: false });
|
|
expect(disable.status).toBe(200);
|
|
expect(db.getUserMfa(userId)?.sso_enforce_mfa).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── CLI reset helper ─────────────────────────────────────────────────────────
|
|
|
|
describe('resetMfaForUser CLI helper', () => {
|
|
it('clears MFA and bumps token_version for the target user', async () => {
|
|
const { resetMfaForUser } = await import('../cli/resetMfa');
|
|
const db = DatabaseService.getInstance();
|
|
const { userId } = await seedMfaUser('cliuser', 'cliuserpass123');
|
|
const before = db.getUser(userId)!.token_version;
|
|
|
|
const result = await resetMfaForUser('cliuser');
|
|
expect(result.ok).toBe(true);
|
|
expect(db.getUserMfa(userId)).toBeUndefined();
|
|
expect(db.getUser(userId)!.token_version).toBeGreaterThan(before);
|
|
});
|
|
|
|
it('returns ok:false for an unknown username', async () => {
|
|
const { resetMfaForUser } = await import('../cli/resetMfa');
|
|
const result = await resetMfaForUser('definitely-not-a-user');
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── Edge cases surfaced by Phase 1 audit ─────────────────────────────────────
|
|
|
|
describe('MfaService.verifyTotp drift handling', () => {
|
|
it('rejects a code generated more than one step outside the window', () => {
|
|
const secret = MfaService.generateSecret();
|
|
// Freeze clock at a known step boundary.
|
|
const baseMs = 1_700_000_000_000;
|
|
vi.useFakeTimers();
|
|
try {
|
|
vi.setSystemTime(baseMs);
|
|
const code = authenticator.generateSync({ secret, ...TOTP_PARAMS });
|
|
// Advance three full 30s windows so the code is outside the +-1 tolerance.
|
|
vi.setSystemTime(baseMs + 3 * 30_000);
|
|
expect(MfaService.verifyTotp(secret, code)).toBe(false);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('still accepts a fresh code generated in the current window', () => {
|
|
const secret = MfaService.generateSecret();
|
|
const code = authenticator.generateSync({ secret, ...TOTP_PARAMS });
|
|
expect(MfaService.verifyTotp(secret, code)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('MfaService.normalizeBackupCode canonicalisation', () => {
|
|
it('canonicalises smart-dash and trailing whitespace to the hyphenless form', () => {
|
|
// en-dash and em-dash variants a user may paste from a word processor
|
|
expect(MfaService.normalizeBackupCode('abcde\u2013fghij ')).toBe('ABCDEFGHIJ');
|
|
expect(MfaService.normalizeBackupCode('abcde\u2014fghij')).toBe('ABCDEFGHIJ');
|
|
expect(MfaService.normalizeBackupCode(' ABCDE-FGHIJ\n')).toBe('ABCDEFGHIJ');
|
|
});
|
|
});
|
|
|
|
describe('POST /api/auth/login/mfa edge cases', () => {
|
|
const password = 'edgepass12345';
|
|
|
|
async function challenge(username: string): Promise<string> {
|
|
const res = await request(app).post('/api/auth/login').send({ username, password });
|
|
return findCookie(res.headers, 'sencho_mfa_pending')!;
|
|
}
|
|
|
|
it('rejects backup codes with invalid format without reaching the bcrypt path', async () => {
|
|
const username = 'mfa-badformat';
|
|
const { userId } = await seedMfaUser(username, password);
|
|
const db = DatabaseService.getInstance();
|
|
const pending = await challenge(username);
|
|
|
|
// Too short, non-alphanumeric garbage, and an 11-char alphanumeric that
|
|
// matches no stored hash. All should produce 401 and increment the counter.
|
|
const bad = ['12345', '!!!!!!!!!!!', 'ZZZZZZZZZZZ'];
|
|
for (const code of bad) {
|
|
const r = await request(app)
|
|
.post('/api/auth/login/mfa')
|
|
.set('Cookie', pending)
|
|
.send({ code, isBackupCode: true });
|
|
expect(r.status).toBe(401);
|
|
}
|
|
|
|
const mfa = db.getUserMfa(userId)!;
|
|
expect(mfa.failed_attempts).toBe(bad.length);
|
|
});
|
|
|
|
it('clears failed_attempts on a successful verify after prior failures below the threshold', async () => {
|
|
const username = 'mfa-reset-counter';
|
|
const { userId, secret } = await seedMfaUser(username, password);
|
|
const db = DatabaseService.getInstance();
|
|
|
|
// Seed three failed attempts (below the 5-failure lockout threshold).
|
|
db.upsertUserMfa(userId, { failed_attempts: 3, locked_until: null });
|
|
expect(db.getUserMfa(userId)!.failed_attempts).toBe(3);
|
|
|
|
const pending = await challenge(username);
|
|
const ok = await request(app)
|
|
.post('/api/auth/login/mfa')
|
|
.set('Cookie', pending)
|
|
.send({ code: authenticator.generateSync({ secret, ...TOTP_PARAMS }) });
|
|
expect(ok.status).toBe(200);
|
|
|
|
const after = db.getUserMfa(userId)!;
|
|
expect(after.failed_attempts).toBe(0);
|
|
expect(after.locked_until).toBeNull();
|
|
});
|
|
|
|
it('lets a locked user sign in again once locked_until has passed', async () => {
|
|
const username = 'mfa-lock-expired';
|
|
const { userId, secret } = await seedMfaUser(username, password);
|
|
const db = DatabaseService.getInstance();
|
|
|
|
// Simulate a stale lockout that has already expired.
|
|
db.upsertUserMfa(userId, {
|
|
failed_attempts: 5,
|
|
locked_until: Date.now() - 60_000,
|
|
});
|
|
|
|
const pending = await challenge(username);
|
|
const ok = await request(app)
|
|
.post('/api/auth/login/mfa')
|
|
.set('Cookie', pending)
|
|
.send({ code: authenticator.generateSync({ secret, ...TOTP_PARAMS }) });
|
|
expect(ok.status).toBe(200);
|
|
|
|
const after = db.getUserMfa(userId)!;
|
|
expect(after.failed_attempts).toBe(0);
|
|
expect(after.locked_until).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('MFA enrol/start overwrites a prior pending secret', () => {
|
|
it('only the most recent enroll/start secret is valid on confirm', async () => {
|
|
const username = 'mfa-overwrite';
|
|
const password = 'overwritepass12345';
|
|
// Create a plain user (no MFA seeded); we want to exercise the enrol path.
|
|
const db = DatabaseService.getInstance();
|
|
const bcryptMod = (await import('bcrypt')).default;
|
|
const passwordHash = await bcryptMod.hash(password, 1);
|
|
const userId = db.addUser({ username, password_hash: passwordHash, role: 'viewer' });
|
|
|
|
const user = db.getUser(userId)!;
|
|
const token = jwt.sign(
|
|
{ username, role: 'viewer', tv: user.token_version },
|
|
TEST_JWT_SECRET,
|
|
{ expiresIn: '1m' },
|
|
);
|
|
|
|
const first = await request(app)
|
|
.post('/api/auth/mfa/enroll/start')
|
|
.set('Authorization', `Bearer ${token}`);
|
|
expect(first.status).toBe(200);
|
|
const firstSecret = first.body.secret as string;
|
|
|
|
const second = await request(app)
|
|
.post('/api/auth/mfa/enroll/start')
|
|
.set('Authorization', `Bearer ${token}`);
|
|
expect(second.status).toBe(200);
|
|
const secondSecret = second.body.secret as string;
|
|
expect(secondSecret).not.toBe(firstSecret);
|
|
|
|
// First secret no longer verifies against the stored (now-overwritten) secret.
|
|
const wrongCode = authenticator.generateSync({ secret: firstSecret, ...TOTP_PARAMS });
|
|
const rejected = await request(app)
|
|
.post('/api/auth/mfa/enroll/confirm')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ code: wrongCode });
|
|
// The rejected code may still happen to equal the new secret's current
|
|
// code (1-in-a-million), so retry with the second secret on a clean run.
|
|
if (rejected.status === 200) {
|
|
// Extremely unlikely collision; the assertion proves the overwrite
|
|
// path at least did not reject a valid-for-secondSecret code.
|
|
expect(rejected.body.backupCodes).toHaveLength(10);
|
|
return;
|
|
}
|
|
expect(rejected.status).toBe(401);
|
|
|
|
// Second secret verifies on confirm.
|
|
const ok = await request(app)
|
|
.post('/api/auth/mfa/enroll/confirm')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.send({ code: authenticator.generateSync({ secret: secondSecret, ...TOTP_PARAMS }) });
|
|
expect(ok.status).toBe(200);
|
|
expect(ok.body.backupCodes).toHaveLength(10);
|
|
});
|
|
});
|