Merge pull request #739 from PicPeak/feat/admin-mfa
feat: admin two-factor authentication (TOTP) with recovery codes + CLI reset
This commit is contained in:
@@ -9,6 +9,16 @@ PORT=3001
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
|
||||
|
||||
# Admin 2FA (TOTP) secret encryption key — OPTIONAL.
|
||||
# Admin authenticator secrets are encrypted at rest (AES-256-GCM). By default
|
||||
# the key is derived from JWT_SECRET, so you do NOT need to set this. Set it
|
||||
# only if you want the MFA encryption key decoupled from JWT_SECRET (e.g. so
|
||||
# rotating JWT_SECRET doesn't invalidate enrolled authenticators). If you set
|
||||
# it, changing/losing it makes existing 2FA secrets undecryptable — recover
|
||||
# with: docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
|
||||
# Generate with: openssl rand -base64 32
|
||||
#MFA_ENCRYPTION_KEY=
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: 'auto' in production, false in dev (#427)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* CLI test for scripts/reset-admin-mfa.js — break-glass MFA reset (#738).
|
||||
*
|
||||
* Boots a temp-SQLite DB, seeds an admin with MFA fully enabled, then runs
|
||||
* the script in a child process (--email <addr> --yes) pointed at the same
|
||||
* DB file, and asserts the four MFA columns are zeroed. The script runs in
|
||||
* its own process with its own knex connection; the parent connection is
|
||||
* idle during the spawn so the SQLite write lock isn't contended.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
const SCRIPT = path.resolve(__dirname, '..', '..', 'scripts', 'reset-admin-mfa.js');
|
||||
|
||||
async function seedEnrolledAdmin(email) {
|
||||
const inserted = await db('admin_users').insert({
|
||||
username: email.split('@')[0],
|
||||
email,
|
||||
password_hash: 'x',
|
||||
is_active: true,
|
||||
two_factor_enabled: true,
|
||||
two_factor_secret: 'iv.tag.ct',
|
||||
two_factor_recovery_codes: JSON.stringify(['$2b$10$fakehashfakehashfakehashfa']),
|
||||
two_factor_enrolled_at: new Date(),
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
return inserted[0]?.id ?? inserted[0];
|
||||
}
|
||||
|
||||
it('zeroes the four MFA columns for the targeted admin', async () => {
|
||||
const email = '[email protected]';
|
||||
const id = await seedEnrolledAdmin(email);
|
||||
|
||||
execFileSync('node', [SCRIPT, '--email', email, '--yes'], {
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'test',
|
||||
TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH,
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const row = await db('admin_users').where({ id }).first();
|
||||
expect(Number(row.two_factor_enabled)).toBe(0);
|
||||
expect(row.two_factor_secret).toBeNull();
|
||||
expect(row.two_factor_recovery_codes).toBeNull();
|
||||
expect(row.two_factor_enrolled_at).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a different admin untouched', async () => {
|
||||
const targetEmail = '[email protected]';
|
||||
const bystanderEmail = '[email protected]';
|
||||
const targetId = await seedEnrolledAdmin(targetEmail);
|
||||
const bystanderId = await seedEnrolledAdmin(bystanderEmail);
|
||||
|
||||
execFileSync('node', [SCRIPT, '--email', targetEmail, '--yes'], {
|
||||
env: { ...process.env, NODE_ENV: 'test', TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const target = await db('admin_users').where({ id: targetId }).first();
|
||||
const bystander = await db('admin_users').where({ id: bystanderId }).first();
|
||||
expect(Number(target.two_factor_enabled)).toBe(0);
|
||||
expect(Number(bystander.two_factor_enabled)).toBe(1);
|
||||
expect(bystander.two_factor_secret).toBe('iv.tag.ct');
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* HTTP-level tests for the admin TOTP MFA feature (#738).
|
||||
*
|
||||
* Two surfaces:
|
||||
* 1. Enrollment (adminAuth-gated) — POST /mfa/setup, /mfa/enable,
|
||||
* GET /mfa/status, POST /mfa/disable — mounted like server.js at
|
||||
* /api/admin/auth (src/routes/adminAuth.js).
|
||||
* 2. Login challenge — POST /admin/login + POST /admin/login/mfa
|
||||
* (src/routes/auth.js, mounted /api/auth).
|
||||
*
|
||||
* Uses the same real-SQLite harness as the CRM route tests
|
||||
* (bootCrmDb + seedMinimal + mintAdminToken). Valid TOTP codes are
|
||||
* generated in-test via otplib's authenticator against the secret the
|
||||
* /setup endpoint returns in plaintext.
|
||||
*
|
||||
* NOTE: env (TEST_DATABASE_PATH / JWT_SECRET) must be set BEFORE the
|
||||
* first require of db.js — mirror adminCrmAuth.test.js exactly.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-adminmfa-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-route-test-secret';
|
||||
// reCAPTCHA disabled (default) → verifyRecaptcha returns true, so login
|
||||
// tests don't need a token. Be explicit so a leaked env can't flip it on.
|
||||
delete process.env.RECAPTCHA_SECRET_KEY;
|
||||
|
||||
const request = require('supertest');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { authenticator } = require('otplib');
|
||||
|
||||
const {
|
||||
bootCrmDb, mintAdminToken, buildRouteApp,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminApp; // /api/admin/auth (enrollment)
|
||||
let authApp; // /api/auth (login challenge)
|
||||
|
||||
/**
|
||||
* Seed a bare admin (password known) and return its id + login creds.
|
||||
* seedMinimal always creates username 'tester'; we need distinct rows per
|
||||
* scenario, so insert directly with a unique username/email.
|
||||
*/
|
||||
async function seedAdmin({ username, superAdmin = false } = {}) {
|
||||
const password = 'correct-horse';
|
||||
const passwordHash = await bcrypt.hash(password, 4);
|
||||
const uname = username || `admin-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const row = {
|
||||
username: uname,
|
||||
email: `${uname}@example.com`,
|
||||
password_hash: passwordHash,
|
||||
must_change_password: false,
|
||||
is_active: true,
|
||||
created_at: new Date(),
|
||||
};
|
||||
if (superAdmin) {
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
if (!role) throw new Error('super_admin role not seeded');
|
||||
row.role_id = role.id;
|
||||
}
|
||||
const inserted = await db('admin_users').insert(row).returning('id');
|
||||
const id = inserted[0]?.id ?? inserted[0];
|
||||
return { id, username: uname, password };
|
||||
}
|
||||
|
||||
/** Run the full setup→enable enrollment against the live app. Returns
|
||||
* the plaintext TOTP secret (for later login codes) and recovery codes. */
|
||||
async function enroll(adminId) {
|
||||
const token = mintAdminToken(adminId);
|
||||
const setup = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(setup.status).toBe(200);
|
||||
const secret = setup.body.secret;
|
||||
|
||||
const enable = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: authenticator.generate(secret) });
|
||||
expect(enable.status).toBe(200);
|
||||
return { secret, recoveryCodes: enable.body.recoveryCodes, token };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth'));
|
||||
authApp = buildRouteApp('/api/auth', require('../../src/routes/auth'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('MFA enrollment — /api/admin/auth/mfa/*', () => {
|
||||
it('setup returns a secret + otpauth URI + QR and does NOT enable yet', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.secret).toEqual(expect.any(String));
|
||||
expect(res.body.otpauthUri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(res.body.qr).toMatch(/^data:image\/png;base64,/);
|
||||
|
||||
// Not yet enabled: status must still report disabled.
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
|
||||
// And the row stores an encrypted secret (not the plaintext one).
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(row.two_factor_secret).toBeTruthy();
|
||||
expect(row.two_factor_secret).not.toBe(res.body.secret);
|
||||
expect(Number(row.two_factor_enabled)).toBe(0);
|
||||
});
|
||||
|
||||
it('full flow: setup → enable(valid TOTP) → status shows enabled + 10 recovery codes', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { recoveryCodes, token } = await enroll(admin.id);
|
||||
|
||||
expect(Array.isArray(recoveryCodes)).toBe(true);
|
||||
expect(recoveryCodes).toHaveLength(10);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.body.enabled).toBe(true);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(10);
|
||||
expect(status.body.enrolledAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('enable with a WRONG code is rejected (400) and MFA stays off', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
const setup = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
const valid = authenticator.generate(setup.body.secret);
|
||||
const wrong = valid === '000000' ? '111111' : '000000';
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: wrong });
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('enable before setup is rejected', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: '123456' });
|
||||
// No provisional secret → ValidationError (400).
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('all enrollment endpoints require a valid admin token (401 without one)', async () => {
|
||||
const noToken = await request(adminApp).get('/api/admin/auth/mfa/status');
|
||||
expect(noToken.status).toBe(401);
|
||||
const setup = await request(adminApp).post('/api/admin/auth/mfa/setup');
|
||||
expect(setup.status).toBe(401);
|
||||
});
|
||||
|
||||
// Regression guard for #735: super_admin used to be blocked from enrolling.
|
||||
// Enrollment operates on req.admin.id and is role-agnostic — assert a
|
||||
// super_admin can complete the full setup→enable flow.
|
||||
it('#735 regression — a super_admin can enroll in MFA', async () => {
|
||||
const admin = await seedAdmin({ superAdmin: true });
|
||||
const { recoveryCodes, token } = await enroll(admin.id);
|
||||
expect(recoveryCodes).toHaveLength(10);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MFA disable — /api/admin/auth/mfa/disable', () => {
|
||||
it('requires a valid code; a wrong code is rejected and state persists', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { token } = await enroll(admin.id);
|
||||
|
||||
const bad = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: '000000' });
|
||||
expect(bad.status).toBe(400);
|
||||
|
||||
const stillOn = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(stillOn.body.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('a valid TOTP disables MFA and clears the stored secret', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: authenticator.generate(secret) });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(0);
|
||||
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(row.two_factor_secret).toBeNull();
|
||||
expect(row.two_factor_recovery_codes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
|
||||
it('an enrolled admin gets mfaRequired + mfaToken, NO session cookie', async () => {
|
||||
const admin = await seedAdmin();
|
||||
await enroll(admin.id);
|
||||
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.mfaRequired).toBe(true);
|
||||
expect(res.body.mfaToken).toEqual(expect.any(String));
|
||||
expect(res.body.user).toBeUndefined(); // no completed session
|
||||
// No admin auth cookie should have been set on the challenge response.
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.join(';')).not.toMatch(/adminToken/i);
|
||||
});
|
||||
|
||||
it('a NON-enrolled admin logs in directly (no mfaRequired)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.mfaRequired).toBeUndefined();
|
||||
expect(res.body.user).toBeDefined();
|
||||
expect(res.body.user.username).toBe(admin.username);
|
||||
});
|
||||
|
||||
it('login/mfa with a valid TOTP completes the session', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
|
||||
const challenge = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const { mfaToken } = challenge.body;
|
||||
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken, code: authenticator.generate(secret) });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toBeDefined();
|
||||
expect(res.body.user.id).toBe(admin.id);
|
||||
});
|
||||
|
||||
it('login/mfa with a wrong code is 401 MFA_INVALID', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const challenge = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
|
||||
const valid = authenticator.generate(secret);
|
||||
const wrong = valid === '000000' ? '111111' : '000000';
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: challenge.body.mfaToken, code: wrong });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe('MFA_INVALID');
|
||||
expect(res.body.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a recovery code logs in and is then single-use (second use fails)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { recoveryCodes } = await enroll(admin.id);
|
||||
const recovery = recoveryCodes[0];
|
||||
|
||||
// First challenge + recovery-code exchange succeeds.
|
||||
const c1 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const first = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c1.body.mfaToken, code: recovery });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.user).toBeDefined();
|
||||
|
||||
// recoveryCodesRemaining dropped by one.
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${mintAdminToken(admin.id)}`);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(9);
|
||||
|
||||
// Second use of the SAME recovery code must fail.
|
||||
const c2 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const second = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c2.body.mfaToken, code: recovery });
|
||||
expect(second.status).toBe(401);
|
||||
expect(second.body.code).toBe('MFA_INVALID');
|
||||
});
|
||||
|
||||
it('login/mfa rejects a non-mfa_pending token (e.g. a normal admin JWT)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
await enroll(admin.id);
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: mintAdminToken(admin.id), code: '123456' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Unit tests for mfaService — admin TOTP MFA (#738).
|
||||
*
|
||||
* Pure unit: no DB, no Express. Exercises the crypto/verification surface
|
||||
* directly. JWT_SECRET is set at the top so getEncryptionKey()'s scrypt
|
||||
* derivation has key material (the service derives the AES key from
|
||||
* MFA_ENCRYPTION_KEY, falling back to JWT_SECRET).
|
||||
*/
|
||||
|
||||
// Must be set BEFORE the service is required — the key is derived lazily per
|
||||
// call, but keep it explicit and stable so encrypt/decrypt round-trips.
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-unit-test-secret';
|
||||
delete process.env.MFA_ENCRYPTION_KEY; // ensure we derive from JWT_SECRET
|
||||
|
||||
const { authenticator } = require('otplib');
|
||||
const mfaService = require('../../src/services/mfaService');
|
||||
|
||||
describe('mfaService — secret encryption (AES-256-GCM)', () => {
|
||||
it('round-trips encrypt → decrypt to the original secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const blob = mfaService.encryptSecret(secret);
|
||||
expect(blob).toEqual(expect.any(String));
|
||||
expect(blob).not.toContain(secret); // stored form is not plaintext
|
||||
expect(blob.split('.')).toHaveLength(3); // iv.tag.ciphertext
|
||||
expect(mfaService.decryptSecret(blob)).toBe(secret);
|
||||
});
|
||||
|
||||
it('produces a different ciphertext each time (random IV) but decrypts identically', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const a = mfaService.encryptSecret(secret);
|
||||
const b = mfaService.encryptSecret(secret);
|
||||
expect(a).not.toBe(b);
|
||||
expect(mfaService.decryptSecret(a)).toBe(secret);
|
||||
expect(mfaService.decryptSecret(b)).toBe(secret);
|
||||
});
|
||||
|
||||
it('throws when decrypting a malformed blob (wrong segment count)', () => {
|
||||
expect(() => mfaService.decryptSecret('garbage')).toThrow();
|
||||
expect(() => mfaService.decryptSecret('only.two')).toThrow();
|
||||
});
|
||||
|
||||
it('throws when the auth tag / ciphertext is tampered with', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const [iv, tag, ct] = mfaService.encryptSecret(secret).split('.');
|
||||
// Flip a character in the ciphertext → GCM auth check must fail.
|
||||
const tampered = ct.slice(0, -2) + (ct.slice(-2) === 'AA' ? 'BB' : 'AA');
|
||||
expect(() => mfaService.decryptSecret([iv, tag, tampered].join('.'))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — TOTP verification', () => {
|
||||
it('accepts a freshly generated code for the plaintext secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotp(code, secret)).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates whitespace in the submitted code', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotp(` ${code} `, secret)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong code', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
const wrong = code === '000000' ? '111111' : '000000';
|
||||
expect(mfaService.verifyTotp(wrong, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty inputs rather than throwing', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
expect(mfaService.verifyTotp('', secret)).toBe(false);
|
||||
expect(mfaService.verifyTotp('123456', '')).toBe(false);
|
||||
expect(mfaService.verifyTotp(null, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifies through the encrypted blob (verifyTotpEncrypted)', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const stored = mfaService.encryptSecret(secret);
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotpEncrypted(code, stored)).toBe(true);
|
||||
|
||||
const wrong = code === '000000' ? '111111' : '000000';
|
||||
expect(mfaService.verifyTotpEncrypted(wrong, stored)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifyTotpEncrypted returns false (no throw) for a corrupt blob', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotpEncrypted(code, 'not-a-valid-blob')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — otpauth URI / QR', () => {
|
||||
it('builds an otpauth:// URI containing issuer, account and secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const uri = mfaService.buildOtpauthUri('[email protected]', secret);
|
||||
expect(uri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(uri).toContain(encodeURIComponent(mfaService.ISSUER));
|
||||
expect(uri).toContain(`secret=${secret}`);
|
||||
});
|
||||
|
||||
it('builds a PNG data-URL QR for the URI', async () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const uri = mfaService.buildOtpauthUri('[email protected]', secret);
|
||||
const qr = await mfaService.buildQrDataUrl(uri);
|
||||
expect(qr).toMatch(/^data:image\/png;base64,/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — recovery codes', () => {
|
||||
it('generates 10 distinct plaintext codes and 10 distinct hashes', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
expect(plain).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
|
||||
expect(hashed).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
|
||||
expect(new Set(plain).size).toBe(10);
|
||||
expect(new Set(hashed).size).toBe(10);
|
||||
// Hashes are bcrypt, not the plaintext.
|
||||
hashed.forEach((h) => expect(h).toMatch(/^\$2[aby]\$/));
|
||||
plain.forEach((p) => expect(hashed).not.toContain(p));
|
||||
});
|
||||
|
||||
it('formats a raw code into 4-char groups', () => {
|
||||
expect(mfaService.formatRecoveryCode('abcdefghij')).toBe('abcd-efgh-ij');
|
||||
});
|
||||
|
||||
it('consumes a valid recovery code once and removes it (single-use)', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
const target = plain[3];
|
||||
|
||||
const first = await mfaService.consumeRecoveryCode(target, hashed);
|
||||
expect(first.matched).toBe(true);
|
||||
expect(first.remainingHashes).toHaveLength(9);
|
||||
|
||||
// Reusing the same code against the reduced set must now fail.
|
||||
const reuse = await mfaService.consumeRecoveryCode(target, first.remainingHashes);
|
||||
expect(reuse.matched).toBe(false);
|
||||
expect(reuse.remainingHashes).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('matches case-insensitively and trims whitespace', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode(` ${plain[0].toUpperCase()} `, hashed);
|
||||
expect(res.matched).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong code and leaves the hash set unchanged', async () => {
|
||||
const { hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode('zzzz-zzzz-zz', hashed);
|
||||
expect(res.matched).toBe(false);
|
||||
expect(res.remainingHashes).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('handles empty / missing input safely', async () => {
|
||||
const { hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode('', hashed);
|
||||
expect(res.matched).toBe(false);
|
||||
expect(res.remainingHashes).toBe(hashed);
|
||||
const noHashes = await mfaService.consumeRecoveryCode('abcd-efgh-ij', null);
|
||||
expect(noHashes.matched).toBe(false);
|
||||
expect(noHashes.remainingHashes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — parseRecoveryCodes', () => {
|
||||
it('parses a JSON string array', () => {
|
||||
expect(mfaService.parseRecoveryCodes(JSON.stringify(['a', 'b']))).toEqual(['a', 'b']);
|
||||
});
|
||||
it('passes an already-array through', () => {
|
||||
expect(mfaService.parseRecoveryCodes(['a', 'b'])).toEqual(['a', 'b']);
|
||||
});
|
||||
it('returns [] for null / garbage / non-array JSON', () => {
|
||||
expect(mfaService.parseRecoveryCodes(null)).toEqual([]);
|
||||
expect(mfaService.parseRecoveryCodes('{not json')).toEqual([]);
|
||||
expect(mfaService.parseRecoveryCodes(JSON.stringify({ a: 1 }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — isEnrolled coercion', () => {
|
||||
it('treats true / 1 / "1" as enrolled', () => {
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: true })).toBe(true);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: 1 })).toBe(true);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: '1' })).toBe(true);
|
||||
});
|
||||
it('treats false / 0 / null / missing as not enrolled', () => {
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: false })).toBe(false);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: 0 })).toBe(false);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: null })).toBe(false);
|
||||
expect(mfaService.isEnrolled({})).toBe(false);
|
||||
expect(mfaService.isEnrolled(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Migration 151: admin MFA (TOTP) enrollment support — issue #738.
|
||||
*
|
||||
* The `admin_users.two_factor_enabled` / `two_factor_secret` columns already
|
||||
* exist from the legacy migration 016 but were never wired to any code. This
|
||||
* migration adds the two columns the real TOTP flow needs on top of them:
|
||||
*
|
||||
* - two_factor_recovery_codes: JSON array of one-time backup codes, stored
|
||||
* HASHED (never plaintext), so a locked-out admin can log in without the
|
||||
* authenticator. Consumed on use.
|
||||
* - two_factor_enrolled_at: when the admin completed enrollment (audit /
|
||||
* display only).
|
||||
*
|
||||
* The TOTP secret itself continues to live in the existing `two_factor_secret`
|
||||
* column, but is now stored ENCRYPTED at rest (AES-256-GCM) by mfaService —
|
||||
* the column type is unchanged (the encrypted blob is short).
|
||||
*
|
||||
* Additive and idempotent: only adds columns, guarded by hasColumn, so it is
|
||||
* safe to re-run and touches no existing data.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
const hasRecovery = await knex.schema.hasColumn('admin_users', 'two_factor_recovery_codes');
|
||||
const hasEnrolledAt = await knex.schema.hasColumn('admin_users', 'two_factor_enrolled_at');
|
||||
const hasEnabled = await knex.schema.hasColumn('admin_users', 'two_factor_enabled');
|
||||
const hasSecret = await knex.schema.hasColumn('admin_users', 'two_factor_secret');
|
||||
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
// Backfill the legacy columns too, in case an install somehow lacks them
|
||||
// (016 is a legacy migration; guard defensively).
|
||||
if (!hasEnabled) {
|
||||
t.boolean('two_factor_enabled').defaultTo(false);
|
||||
}
|
||||
if (!hasSecret) {
|
||||
t.string('two_factor_secret').nullable();
|
||||
}
|
||||
if (!hasRecovery) {
|
||||
t.text('two_factor_recovery_codes').nullable();
|
||||
}
|
||||
if (!hasEnrolledAt) {
|
||||
t.timestamp('two_factor_enrolled_at').nullable();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
const hasRecovery = await knex.schema.hasColumn('admin_users', 'two_factor_recovery_codes');
|
||||
const hasEnrolledAt = await knex.schema.hasColumn('admin_users', 'two_factor_enrolled_at');
|
||||
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
// Only drop what THIS migration added; leave the legacy 016 columns.
|
||||
if (hasRecovery) {
|
||||
t.dropColumn('two_factor_recovery_codes');
|
||||
}
|
||||
if (hasEnrolledAt) {
|
||||
t.dropColumn('two_factor_enrolled_at');
|
||||
}
|
||||
});
|
||||
};
|
||||
Generated
+72
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.74.0-beta.0",
|
||||
"version": "3.80.0-beta.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.74.0-beta.0",
|
||||
"version": "3.80.0-beta.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
@@ -40,6 +40,7 @@
|
||||
"node-cron": "^3.0.2",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"pg": "^8.16.3",
|
||||
@@ -2703,6 +2704,56 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/core": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
||||
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@otplib/plugin-crypto": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
|
||||
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/plugin-thirty-two": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
|
||||
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"thirty-two": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-default": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
|
||||
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-v11": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
|
||||
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
@@ -9371,6 +9422,17 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/otplib": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
|
||||
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/preset-default": "^12.0.1",
|
||||
"@otplib/preset-v11": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
@@ -11668,6 +11730,14 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/thirty-two": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
|
||||
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
|
||||
"engines": {
|
||||
"node": ">=0.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||
|
||||
@@ -46,9 +46,11 @@
|
||||
"node-cron": "^3.0.2",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"pg": "^8.16.3",
|
||||
"postcss": "8.5.10",
|
||||
"qrcode": "^1.5.4",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
@@ -57,11 +59,10 @@
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"swissqrbill": "^4.3.0",
|
||||
"tar": ">=7.5.16",
|
||||
"uuid": "^11.1.1",
|
||||
"winston": "^3.8.2",
|
||||
"zxcvbn": "^4.4.2",
|
||||
"postcss": "8.5.10",
|
||||
"tar": ">=7.5.16"
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.40.0",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* reset-admin-mfa.js — disable two-factor auth for a locked-out admin (#738).
|
||||
*
|
||||
* Break-glass recovery for when an admin loses their authenticator AND their
|
||||
* recovery codes. Clears the MFA state so the admin can log in with just their
|
||||
* password and re-enroll from Settings.
|
||||
*
|
||||
* Usage (inside the running backend container):
|
||||
* docker compose exec backend node scripts/reset-admin-mfa.js --email [email protected]
|
||||
* docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
|
||||
*
|
||||
* Flags:
|
||||
* --email <addr> target a single admin by email (or --username <name>)
|
||||
* --all reset MFA for EVERY admin (full lockout / break-glass)
|
||||
* --yes non-interactive (skip the confirmation prompt)
|
||||
*/
|
||||
|
||||
const readline = require('readline');
|
||||
const { db, logActivity } = require('../src/database/db');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const hasFlag = (f) => args.includes(f);
|
||||
const getOption = (name) => {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
return i !== -1 && i + 1 < args.length ? args[i + 1] : null;
|
||||
};
|
||||
|
||||
const force = hasFlag('--yes') || hasFlag('--force') || hasFlag('--non-interactive');
|
||||
const all = hasFlag('--all');
|
||||
const email = getOption('email');
|
||||
const username = getOption('username');
|
||||
|
||||
const MFA_CLEAR = {
|
||||
two_factor_enabled: false,
|
||||
two_factor_secret: null,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
function ask(prompt) {
|
||||
if (force) return Promise.resolve('yes');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => rl.question(prompt, (a) => { rl.close(); resolve(a); }));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\n========================================');
|
||||
console.log('PicPeak Admin MFA Reset Tool');
|
||||
console.log('========================================\n');
|
||||
|
||||
if (!all && !email && !username) {
|
||||
console.error('❌ Specify a target: --email <addr>, --username <name>, or --all');
|
||||
console.log(' e.g. node scripts/reset-admin-mfa.js --email [email protected]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve target admins.
|
||||
let targets;
|
||||
if (all) {
|
||||
targets = await db('admin_users').select('id', 'username', 'email', 'two_factor_enabled');
|
||||
} else {
|
||||
const q = db('admin_users');
|
||||
if (email) q.where({ email });
|
||||
if (username) q.where({ username });
|
||||
targets = await q.select('id', 'username', 'email', 'two_factor_enabled');
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
console.error('❌ No matching admin user found.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const enrolled = targets.filter((t) => t.two_factor_enabled === true || t.two_factor_enabled === 1);
|
||||
console.log(`Matched ${targets.length} admin(s); ${enrolled.length} currently have MFA enabled:`);
|
||||
for (const t of targets) {
|
||||
const flag = (t.two_factor_enabled === true || t.two_factor_enabled === 1) ? 'MFA ON' : 'mfa off';
|
||||
console.log(` - ${t.username} <${t.email}> [${flag}]`);
|
||||
}
|
||||
|
||||
const confirm = await ask('\nDisable MFA for the above? (yes/no): ');
|
||||
const normalized = String(confirm).trim().toLowerCase();
|
||||
if (normalized !== 'yes' && normalized !== 'y') {
|
||||
console.log('\n❌ Cancelled. No changes made.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ids = targets.map((t) => t.id);
|
||||
const updated = await db('admin_users').whereIn('id', ids).update(MFA_CLEAR);
|
||||
|
||||
for (const t of targets) {
|
||||
try {
|
||||
await logActivity('admin_mfa_reset_cli',
|
||||
{ admin_id: t.id, via: 'cli' },
|
||||
null,
|
||||
{ type: 'system', id: 0, name: 'reset-admin-mfa.js' }
|
||||
);
|
||||
} catch (_) { /* activity log is best-effort */ }
|
||||
}
|
||||
|
||||
console.log(`\n✅ MFA disabled for ${updated} admin(s). They can now log in with just their password and re-enroll from Settings → Security.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('❌ Failed to reset MFA:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout
|
||||
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
|
||||
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
|
||||
const mfaService = require('../services/mfaService');
|
||||
const router = express.Router();
|
||||
|
||||
// Get admin profile
|
||||
@@ -184,4 +185,175 @@ router.post('/logout', adminAuth, handleAsync(async (req, res) => {
|
||||
successResponse(res, { message: 'Logged out successfully' });
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-factor authentication (TOTP) — issue #738.
|
||||
//
|
||||
// All endpoints operate on the AUTHENTICATED admin's own account
|
||||
// (req.admin.id) — enrollment is per-user and works for every role,
|
||||
// super_admin included (closes #735). The TOTP secret is stored encrypted
|
||||
// at rest and recovery codes are hashed; see services/mfaService.js.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isMfaEnabled = mfaService.isEnrolled;
|
||||
|
||||
// Current MFA state for the logged-in admin.
|
||||
router.get('/mfa/status', adminAuth, handleAsync(async (req, res) => {
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
const enabled = isMfaEnabled(admin);
|
||||
res.json({
|
||||
enabled,
|
||||
enrolledAt: enabled ? admin.two_factor_enrolled_at || null : null,
|
||||
recoveryCodesRemaining: enabled
|
||||
? mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes).length
|
||||
: 0
|
||||
});
|
||||
}));
|
||||
|
||||
// Begin enrollment: mint a provisional secret, store it encrypted (NOT yet
|
||||
// enabled), and return the otpauth URI + QR for the authenticator app. Calling
|
||||
// this again before /enable simply regenerates the provisional secret.
|
||||
router.post('/mfa/setup', adminAuth, handleAsync(async (req, res) => {
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (isMfaEnabled(admin)) {
|
||||
throw new ConflictError('Two-factor authentication is already enabled');
|
||||
}
|
||||
|
||||
const secret = mfaService.generateSecret();
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_secret: mfaService.encryptSecret(secret),
|
||||
two_factor_enabled: false,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
const accountName = admin.email || admin.username;
|
||||
const otpauthUri = mfaService.buildOtpauthUri(accountName, secret);
|
||||
const qr = await mfaService.buildQrDataUrl(otpauthUri);
|
||||
|
||||
res.json({
|
||||
// `secret` is returned for manual entry when a QR can't be scanned.
|
||||
secret,
|
||||
otpauthUri,
|
||||
qr,
|
||||
issuer: mfaService.ISSUER,
|
||||
account: accountName
|
||||
});
|
||||
}));
|
||||
|
||||
// Complete enrollment: verify a code against the provisional secret, enable
|
||||
// MFA, and return one-time recovery codes (shown exactly once).
|
||||
router.post('/mfa/enable', [
|
||||
adminAuth,
|
||||
body('code').notEmpty().withMessage('Verification code is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (isMfaEnabled(admin)) {
|
||||
throw new ConflictError('Two-factor authentication is already enabled');
|
||||
}
|
||||
if (!admin.two_factor_secret) {
|
||||
throw new ValidationError('Start setup before enabling two-factor authentication');
|
||||
}
|
||||
if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) {
|
||||
throw new ValidationError('Invalid verification code');
|
||||
}
|
||||
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_enabled: true,
|
||||
two_factor_enrolled_at: new Date(),
|
||||
two_factor_recovery_codes: JSON.stringify(hashed),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_mfa_enabled',
|
||||
{ admin_id: admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
successResponse(res, {
|
||||
message: 'Two-factor authentication enabled',
|
||||
recoveryCodes: plain
|
||||
});
|
||||
}));
|
||||
|
||||
// Disable MFA. Requires a fresh TOTP or recovery code so a hijacked session
|
||||
// can't silently strip the second factor.
|
||||
router.post('/mfa/disable', [
|
||||
adminAuth,
|
||||
body('code').notEmpty().withMessage('A current code is required to disable 2FA')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (!isMfaEnabled(admin)) {
|
||||
throw new ValidationError('Two-factor authentication is not enabled');
|
||||
}
|
||||
|
||||
const totpOk = mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret);
|
||||
let recoveryOk = false;
|
||||
if (!totpOk) {
|
||||
const stored = mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes);
|
||||
recoveryOk = (await mfaService.consumeRecoveryCode(req.body.code, stored)).matched;
|
||||
}
|
||||
if (!totpOk && !recoveryOk) {
|
||||
throw new ValidationError('Invalid verification code');
|
||||
}
|
||||
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_enabled: false,
|
||||
two_factor_secret: null,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_mfa_disabled',
|
||||
{ admin_id: admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
successResponse(res, { message: 'Two-factor authentication disabled' });
|
||||
}));
|
||||
|
||||
// Regenerate recovery codes (invalidates the old set). Requires a fresh TOTP
|
||||
// code. Returns the new codes once.
|
||||
router.post('/mfa/recovery-codes', [
|
||||
adminAuth,
|
||||
body('code').notEmpty().withMessage('A current authenticator code is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (!isMfaEnabled(admin)) {
|
||||
throw new ValidationError('Two-factor authentication is not enabled');
|
||||
}
|
||||
if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) {
|
||||
throw new ValidationError('Invalid verification code');
|
||||
}
|
||||
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_recovery_codes: JSON.stringify(hashed),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_mfa_recovery_regenerated',
|
||||
{ admin_id: admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
successResponse(res, {
|
||||
message: 'Recovery codes regenerated',
|
||||
recoveryCodes: plain
|
||||
});
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+159
-32
@@ -2,9 +2,10 @@ const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db } = require('../database/db');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||
const mfaService = require('../services/mfaService');
|
||||
const {
|
||||
trackFailedAttempt,
|
||||
trackSuccessfulLogin,
|
||||
@@ -33,6 +34,49 @@ const {
|
||||
} = require('../utils/passwordValidation');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Finish a successful admin login: reset the lockout counter, stamp
|
||||
* last_login, mint the 24h admin JWT, set the HttpOnly cookie, and return the
|
||||
* user payload. Shared by the direct (no-MFA) path and the MFA-verify path so
|
||||
* both produce an identical session. `lockoutKey` is the identifier the user
|
||||
* typed (username or email) so success/failure tracking stays in one bucket.
|
||||
*/
|
||||
async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) {
|
||||
await trackSuccessfulLogin(lockoutKey, ipAddress, userAgent);
|
||||
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
last_login: new Date(),
|
||||
last_login_ip: ipAddress
|
||||
});
|
||||
|
||||
const token = jwt.sign({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
type: 'admin',
|
||||
role: admin.role_name,
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setAdminAuthCookie(res, token);
|
||||
|
||||
return res.json({
|
||||
user: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
mustChangePassword: admin.must_change_password || false,
|
||||
role: admin.role_name ? {
|
||||
name: admin.role_name,
|
||||
displayName: admin.role_display_name
|
||||
} : null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Admin login with enhanced security
|
||||
router.post('/admin/login', [
|
||||
body('username').notEmpty().trim(),
|
||||
@@ -95,49 +139,132 @@ router.post('/admin/login', [
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
// Successful login
|
||||
await trackSuccessfulLogin(username, ipAddress, userAgent);
|
||||
|
||||
// Update last login and login metadata
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
last_login: new Date(),
|
||||
last_login_ip: ipAddress
|
||||
});
|
||||
|
||||
// Generate token with additional claims including role
|
||||
const token = jwt.sign({
|
||||
// Second factor: if this admin has TOTP enabled, do NOT complete the login
|
||||
// yet. Issue a short-lived, single-purpose mfa_pending token and require the
|
||||
// code via /admin/login/mfa. We deliberately don't reset the lockout counter
|
||||
// (trackSuccessfulLogin) or stamp last_login until the second factor passes,
|
||||
// so MFA brute-force is still gated by the account lockout. `loginId` carries
|
||||
// the typed identifier so the verify step tracks the same lockout bucket.
|
||||
if (mfaService.isEnrolled(admin)) {
|
||||
const mfaToken = jwt.sign({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
type: 'admin',
|
||||
role: admin.role_name, // Add role to JWT
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
type: 'mfa_pending',
|
||||
loginId: username
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
expiresIn: '5m',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setAdminAuthCookie(res, token);
|
||||
|
||||
// Token is delivered via HttpOnly cookie only (not in response body)
|
||||
res.json({
|
||||
user: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
mustChangePassword: admin.must_change_password || false,
|
||||
role: admin.role_name ? {
|
||||
name: admin.role_name,
|
||||
displayName: admin.role_display_name
|
||||
} : null
|
||||
return res.json({ mfaRequired: true, mfaToken });
|
||||
}
|
||||
});
|
||||
|
||||
return await completeAdminLogin(req, res, admin, ipAddress, userAgent, username);
|
||||
} catch (error) {
|
||||
logger.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Second-factor verification. Exchanges the short-lived mfa_pending token
|
||||
// (from /admin/login) plus a TOTP or recovery code for a full admin session.
|
||||
router.post('/admin/login/mfa', [
|
||||
body('mfaToken').notEmpty(),
|
||||
body('code').notEmpty().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { mfaToken, code } = req.body;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(mfaToken, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(401).json({
|
||||
error: 'Your verification session expired. Please sign in again.',
|
||||
code: 'MFA_SESSION_EXPIRED'
|
||||
});
|
||||
}
|
||||
|
||||
if (decoded.type !== 'mfa_pending') {
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
const lockoutKey = decoded.loginId || decoded.username;
|
||||
const lockoutStatus = await checkAccountLockout(lockoutKey);
|
||||
if (lockoutStatus.isLocked) {
|
||||
return res.status(423).json({
|
||||
error: 'Account temporarily locked due to too many failed attempts',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
const admin = await db('admin_users')
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where('admin_users.id', decoded.id)
|
||||
.select(
|
||||
'admin_users.*',
|
||||
'roles.name as role_name',
|
||||
'roles.display_name as role_display_name'
|
||||
)
|
||||
.first();
|
||||
|
||||
if (!admin || !admin.is_active || !mfaService.isEnrolled(admin)) {
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
// TOTP first, then a one-time recovery code.
|
||||
let ok = mfaService.verifyTotpEncrypted(code, admin.two_factor_secret);
|
||||
let usedRecovery = false;
|
||||
let remainingHashes = null;
|
||||
if (!ok) {
|
||||
const stored = mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes);
|
||||
const result = await mfaService.consumeRecoveryCode(code, stored);
|
||||
if (result.matched) {
|
||||
ok = true;
|
||||
usedRecovery = true;
|
||||
remainingHashes = result.remainingHashes;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
await trackFailedAttempt(lockoutKey, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid verification code', code: 'MFA_INVALID' });
|
||||
}
|
||||
|
||||
if (usedRecovery) {
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_recovery_codes: JSON.stringify(remainingHashes),
|
||||
updated_at: new Date()
|
||||
});
|
||||
await logActivity('admin_mfa_recovery_used',
|
||||
{ admin_id: admin.id, remaining: remainingHashes.length },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
}
|
||||
|
||||
await logActivity('admin_mfa_login',
|
||||
{ admin_id: admin.id, method: usedRecovery ? 'recovery_code' : 'totp' },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
return await completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey);
|
||||
} catch (error) {
|
||||
logger.error('MFA verification error:', error);
|
||||
res.status(500).json({ error: 'Verification failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout endpoint
|
||||
router.post('/logout', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* mfaService — TOTP (RFC 6238) multi-factor auth for admin accounts (#738).
|
||||
*
|
||||
* Responsibilities:
|
||||
* - generate/verify TOTP secrets (otplib, standard SHA1/6-digit/30s so
|
||||
* Google Authenticator / Authy / 1Password all work);
|
||||
* - encrypt the secret at rest (AES-256-GCM) so a DB leak alone doesn't
|
||||
* yield working authenticator seeds;
|
||||
* - generate/verify one-time recovery codes, hashed (bcrypt) and single-use;
|
||||
* - build the otpauth:// URI + QR data-URL for enrollment.
|
||||
*
|
||||
* The encryption key is derived (scrypt) from MFA_ENCRYPTION_KEY when set,
|
||||
* otherwise from JWT_SECRET. Rotating either invalidates stored secrets —
|
||||
* the same blast radius as rotating JWT_SECRET already has for sessions, and
|
||||
* `reset-admin-mfa.js` is the recovery path.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { authenticator } = require('otplib');
|
||||
const QRCode = require('qrcode');
|
||||
|
||||
// Standard TOTP params; window:1 tolerates ±1 step (30s) of clock drift.
|
||||
authenticator.options = { window: 1 };
|
||||
|
||||
const ISSUER = 'PicPeak';
|
||||
const RECOVERY_CODE_COUNT = 10;
|
||||
const RECOVERY_CODE_BYTES = 10; // ~80 bits of entropy per code
|
||||
const RECOVERY_BCRYPT_ROUNDS = 10;
|
||||
|
||||
const ENC_ALGO = 'aes-256-gcm';
|
||||
const ENC_SALT = 'picpeak-mfa-secret-v1'; // fixed: derivation must be stable
|
||||
|
||||
function getEncryptionKey() {
|
||||
const material = process.env.MFA_ENCRYPTION_KEY || process.env.JWT_SECRET;
|
||||
if (!material) {
|
||||
throw new Error('mfaService: MFA_ENCRYPTION_KEY or JWT_SECRET must be set');
|
||||
}
|
||||
return crypto.scryptSync(material, ENC_SALT, 32);
|
||||
}
|
||||
|
||||
/** Generate a fresh base32 TOTP secret. */
|
||||
function generateSecret() {
|
||||
return authenticator.generateSecret();
|
||||
}
|
||||
|
||||
/** AES-256-GCM encrypt a secret → "iv.tag.ciphertext" (all base64url). */
|
||||
function encryptSecret(plainSecret) {
|
||||
const key = getEncryptionKey();
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv(ENC_ALGO, key, iv);
|
||||
const ct = Buffer.concat([cipher.update(plainSecret, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return [iv, tag, ct].map((b) => b.toString('base64url')).join('.');
|
||||
}
|
||||
|
||||
/** Reverse of encryptSecret. Throws on tamper/wrong key. */
|
||||
function decryptSecret(stored) {
|
||||
const key = getEncryptionKey();
|
||||
const [ivB64, tagB64, ctB64] = String(stored).split('.');
|
||||
if (!ivB64 || !tagB64 || !ctB64) {
|
||||
throw new Error('mfaService: malformed encrypted secret');
|
||||
}
|
||||
const decipher = crypto.createDecipheriv(ENC_ALGO, key, Buffer.from(ivB64, 'base64url'));
|
||||
decipher.setAuthTag(Buffer.from(tagB64, 'base64url'));
|
||||
const pt = Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64url')), decipher.final()]);
|
||||
return pt.toString('utf8');
|
||||
}
|
||||
|
||||
/** Verify a 6-digit TOTP code against the (plaintext) secret. */
|
||||
function verifyTotp(code, plainSecret) {
|
||||
if (!code || !plainSecret) return false;
|
||||
try {
|
||||
return authenticator.verify({ token: String(code).replace(/\s+/g, ''), secret: plainSecret });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify a code against a STORED (encrypted) secret. */
|
||||
function verifyTotpEncrypted(code, storedSecret) {
|
||||
try {
|
||||
return verifyTotp(code, decryptSecret(storedSecret));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** otpauth:// URI for an authenticator app. */
|
||||
function buildOtpauthUri(accountName, plainSecret) {
|
||||
return authenticator.keyuri(accountName, ISSUER, plainSecret);
|
||||
}
|
||||
|
||||
/** QR code (PNG data URL) for the otpauth URI. */
|
||||
async function buildQrDataUrl(otpauthUri) {
|
||||
return QRCode.toDataURL(otpauthUri, { errorCorrectionLevel: 'M', margin: 1, width: 240 });
|
||||
}
|
||||
|
||||
/** Format a raw code as human-friendly groups, e.g. "abcd-efgh-jk". */
|
||||
function formatRecoveryCode(raw) {
|
||||
return raw.match(/.{1,4}/g).join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate RECOVERY_CODE_COUNT one-time codes. Returns the plaintext codes
|
||||
* (shown to the admin ONCE) and their bcrypt hashes (persisted).
|
||||
*/
|
||||
async function generateRecoveryCodes() {
|
||||
const plain = [];
|
||||
const hashed = [];
|
||||
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
|
||||
// base32-ish, lowercase, no ambiguous chars
|
||||
const raw = crypto.randomBytes(RECOVERY_CODE_BYTES)
|
||||
.toString('base64')
|
||||
.replace(/[^a-zA-Z0-9]/g, '')
|
||||
.toLowerCase()
|
||||
.slice(0, 10);
|
||||
const code = formatRecoveryCode(raw);
|
||||
plain.push(code);
|
||||
hashed.push(await bcrypt.hash(code, RECOVERY_BCRYPT_ROUNDS));
|
||||
}
|
||||
return { plain, hashed };
|
||||
}
|
||||
|
||||
function normalizeRecoveryInput(code) {
|
||||
return String(code || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a submitted recovery code against the stored hash array. On match,
|
||||
* returns the remaining hashes (matched one removed — single use). On miss,
|
||||
* matched:false and the array unchanged.
|
||||
*
|
||||
* @param {string[]} storedHashes
|
||||
* @returns {Promise<{matched: boolean, remainingHashes: string[]}>}
|
||||
*/
|
||||
async function consumeRecoveryCode(code, storedHashes) {
|
||||
const input = normalizeRecoveryInput(code);
|
||||
const hashes = Array.isArray(storedHashes) ? storedHashes : [];
|
||||
if (!input) return { matched: false, remainingHashes: hashes };
|
||||
for (let i = 0; i < hashes.length; i++) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (await bcrypt.compare(input, hashes[i])) {
|
||||
const remaining = hashes.slice(0, i).concat(hashes.slice(i + 1));
|
||||
return { matched: true, remainingHashes: remaining };
|
||||
}
|
||||
}
|
||||
return { matched: false, remainingHashes: hashes };
|
||||
}
|
||||
|
||||
/** True when an admin row has MFA enabled (coerces SQLite/PG boolean shapes). */
|
||||
function isEnrolled(admin) {
|
||||
const v = admin && admin.two_factor_enabled;
|
||||
return v === true || v === 1 || v === '1';
|
||||
}
|
||||
|
||||
/** Parse the DB column (JSON text) into an array of hashes. */
|
||||
function parseRecoveryCodes(raw) {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateSecret,
|
||||
encryptSecret,
|
||||
decryptSecret,
|
||||
verifyTotp,
|
||||
verifyTotpEncrypted,
|
||||
buildOtpauthUri,
|
||||
buildQrDataUrl,
|
||||
generateRecoveryCodes,
|
||||
consumeRecoveryCode,
|
||||
parseRecoveryCodes,
|
||||
isEnrolled,
|
||||
formatRecoveryCode,
|
||||
ISSUER,
|
||||
RECOVERY_CODE_COUNT,
|
||||
};
|
||||
@@ -0,0 +1,320 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { ShieldCheck, ShieldOff, Copy, Download, Check, KeyRound, AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { Button, Card, Input, Loading, useConfirm } from '../../../components/common';
|
||||
import { mfaService } from '../../../services/mfa.service';
|
||||
|
||||
// Per-user admin TOTP MFA management (issue #738). Lives on the admin's own
|
||||
// account surface (Settings → General → Admin Account). Self-service: acts on
|
||||
// the currently authenticated admin only.
|
||||
|
||||
interface RecoveryCodesPanelProps {
|
||||
codes: string[];
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const RecoveryCodesPanel: React.FC<RecoveryCodesPanelProps> = ({ codes, onConfirm }) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
|
||||
const asText = codes.join('\n');
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(asText);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error(t('settings.mfa.copyFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([`${asText}\n`], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'picpeak-recovery-codes.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">{t('settings.mfa.recoveryCodesWarning')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 p-4 rounded-lg bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 font-mono text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{codes.map((code) => (
|
||||
<span key={code} className="select-all">{code}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" leftIcon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />} onClick={handleCopy}>
|
||||
{copied ? t('settings.mfa.copied') : t('settings.mfa.copy')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />} onClick={handleDownload}>
|
||||
{t('settings.mfa.download')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('settings.mfa.recoveryCodesAck')}</span>
|
||||
</label>
|
||||
|
||||
<Button variant="primary" disabled={!acknowledged} onClick={onConfirm}>
|
||||
{t('settings.mfa.done')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MfaSettingsCard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ['admin-mfa-status'],
|
||||
queryFn: () => mfaService.getStatus(),
|
||||
});
|
||||
|
||||
// Enrollment flow state
|
||||
const [setupData, setSetupData] = useState<Awaited<ReturnType<typeof mfaService.setup>> | null>(null);
|
||||
const [enableCode, setEnableCode] = useState('');
|
||||
const [enableError, setEnableError] = useState<string | null>(null);
|
||||
|
||||
// Recovery codes to display once (after enable or regenerate)
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||
|
||||
// Regenerate flow state
|
||||
const [showRegenerate, setShowRegenerate] = useState(false);
|
||||
const [regenerateCode, setRegenerateCode] = useState('');
|
||||
const [regenerateError, setRegenerateError] = useState<string | null>(null);
|
||||
|
||||
const invalidateStatus = () => queryClient.invalidateQueries({ queryKey: ['admin-mfa-status'] });
|
||||
|
||||
const errorMessage = (error: any, fallbackKey: string): string =>
|
||||
error?.response?.data?.error || t(fallbackKey);
|
||||
|
||||
const setupMutation = useMutation({
|
||||
mutationFn: () => mfaService.setup(),
|
||||
onSuccess: (data) => {
|
||||
setSetupData(data);
|
||||
setEnableCode('');
|
||||
setEnableError(null);
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, 'settings.mfa.setupFailed')),
|
||||
});
|
||||
|
||||
const enableMutation = useMutation({
|
||||
mutationFn: (code: string) => mfaService.enable(code),
|
||||
onSuccess: (data) => {
|
||||
setRecoveryCodes(data.recoveryCodes);
|
||||
setSetupData(null);
|
||||
setEnableCode('');
|
||||
setEnableError(null);
|
||||
invalidateStatus();
|
||||
},
|
||||
onError: (error) => setEnableError(errorMessage(error, 'settings.mfa.enableFailed')),
|
||||
});
|
||||
|
||||
const disableMutation = useMutation({
|
||||
mutationFn: (code: string) => mfaService.disable(code),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.mfa.disabledToast'));
|
||||
invalidateStatus();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, 'settings.mfa.disableFailed')),
|
||||
});
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: (code: string) => mfaService.regenerateRecoveryCodes(code),
|
||||
onSuccess: (data) => {
|
||||
setRecoveryCodes(data.recoveryCodes);
|
||||
setShowRegenerate(false);
|
||||
setRegenerateCode('');
|
||||
setRegenerateError(null);
|
||||
invalidateStatus();
|
||||
},
|
||||
onError: (error) => setRegenerateError(errorMessage(error, 'settings.mfa.regenerateFailed')),
|
||||
});
|
||||
|
||||
const handleDisable = async () => {
|
||||
const code = window.prompt(t('settings.mfa.disablePrompt'));
|
||||
if (code === null) return;
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) {
|
||||
toast.error(t('settings.mfa.codeRequired'));
|
||||
return;
|
||||
}
|
||||
const ok = await confirm({
|
||||
title: t('settings.mfa.disableConfirmTitle'),
|
||||
message: t('settings.mfa.disableConfirmMessage'),
|
||||
variant: 'danger',
|
||||
confirmLabel: t('settings.mfa.disableConfirmButton'),
|
||||
});
|
||||
if (ok) disableMutation.mutate(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ShieldCheck className="w-5 h-5 text-neutral-700 dark:text-neutral-300" />
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('settings.mfa.title')}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">{t('settings.mfa.description')}</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 flex justify-center">
|
||||
<Loading size="md" />
|
||||
</div>
|
||||
) : recoveryCodes ? (
|
||||
<RecoveryCodesPanel codes={recoveryCodes} onConfirm={() => setRecoveryCodes(null)} />
|
||||
) : status?.enabled ? (
|
||||
/* ---------------- Enrolled ---------------- */
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 flex items-center gap-2">
|
||||
<ShieldCheck className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" />
|
||||
<span className="text-sm text-green-800 dark:text-green-200">{t('settings.mfa.enabledBadge')}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('settings.mfa.recoveryCodesRemaining', { count: status.recoveryCodesRemaining })}
|
||||
</p>
|
||||
|
||||
{showRegenerate ? (
|
||||
<div className="space-y-3 p-4 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">{t('settings.mfa.regenerateHelp')}</p>
|
||||
<Input
|
||||
type="text"
|
||||
value={regenerateCode}
|
||||
onChange={(e) => {
|
||||
setRegenerateCode(e.target.value);
|
||||
if (regenerateError) setRegenerateError(null);
|
||||
}}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
leftIcon={<KeyRound className="w-5 h-5 text-neutral-400" />}
|
||||
error={regenerateError || undefined}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
isLoading={regenerateMutation.isPending}
|
||||
onClick={() => {
|
||||
const trimmed = regenerateCode.trim();
|
||||
if (!trimmed) { setRegenerateError(t('settings.mfa.codeRequired')); return; }
|
||||
regenerateMutation.mutate(trimmed);
|
||||
}}
|
||||
>
|
||||
{t('settings.mfa.regenerateConfirm')}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => { setShowRegenerate(false); setRegenerateCode(''); setRegenerateError(null); }}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => setShowRegenerate(true)}>
|
||||
{t('settings.mfa.regenerate')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<ShieldOff className="w-4 h-4" />}
|
||||
isLoading={disableMutation.isPending}
|
||||
onClick={handleDisable}
|
||||
>
|
||||
{t('settings.mfa.disable')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : setupData ? (
|
||||
/* ---------------- Setup in progress ---------------- */
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">{t('settings.mfa.setupScanInstruction')}</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start">
|
||||
<img
|
||||
src={setupData.qr}
|
||||
alt={t('settings.mfa.qrAlt')}
|
||||
className="w-44 h-44 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white p-2"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('settings.mfa.manualEntry')}</p>
|
||||
<code className="block px-3 py-2 rounded bg-neutral-100 dark:bg-neutral-800 text-sm font-mono text-neutral-900 dark:text-neutral-100 break-all select-all">
|
||||
{setupData.secret}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="mfa-enable-code" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.mfa.enterCodeLabel')}
|
||||
</label>
|
||||
<Input
|
||||
id="mfa-enable-code"
|
||||
type="text"
|
||||
value={enableCode}
|
||||
onChange={(e) => {
|
||||
setEnableCode(e.target.value);
|
||||
if (enableError) setEnableError(null);
|
||||
}}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
leftIcon={<KeyRound className="w-5 h-5 text-neutral-400" />}
|
||||
error={enableError || undefined}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
isLoading={enableMutation.isPending}
|
||||
onClick={() => {
|
||||
const trimmed = enableCode.trim();
|
||||
if (!trimmed) { setEnableError(t('settings.mfa.codeRequired')); return; }
|
||||
enableMutation.mutate(trimmed);
|
||||
}}
|
||||
>
|
||||
{t('settings.mfa.enable')}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => { setSetupData(null); setEnableCode(''); setEnableError(null); }}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ---------------- Not enrolled ---------------- */
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('settings.mfa.notEnrolled')}</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<ShieldCheck className="w-5 h-5" />}
|
||||
isLoading={setupMutation.isPending}
|
||||
onClick={() => setupMutation.mutate()}
|
||||
>
|
||||
{t('settings.mfa.setUp')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -35,7 +35,6 @@ export interface GeneralSettings {
|
||||
export interface SecuritySettings {
|
||||
password_min_length: number;
|
||||
password_complexity: string;
|
||||
enable_2fa: boolean;
|
||||
session_timeout_minutes: number;
|
||||
max_login_attempts: number;
|
||||
attempt_window_minutes: number;
|
||||
@@ -134,7 +133,6 @@ export function useSettingsState() {
|
||||
const [securitySettings, setSecuritySettings] = useState<SecuritySettings>({
|
||||
password_min_length: 8,
|
||||
password_complexity: 'moderate',
|
||||
enable_2fa: false,
|
||||
session_timeout_minutes: 60,
|
||||
max_login_attempts: 5,
|
||||
attempt_window_minutes: 15,
|
||||
@@ -231,7 +229,6 @@ export function useSettingsState() {
|
||||
setSecuritySettings({
|
||||
password_min_length: toNumber(settings.security_password_min_length, 8),
|
||||
password_complexity: settings.security_password_complexity ?? 'moderate',
|
||||
enable_2fa: toBoolean(settings.security_enable_2fa, false),
|
||||
session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60),
|
||||
max_login_attempts: toNumber(settings.security_max_login_attempts, 5),
|
||||
attempt_window_minutes: toNumber(settings.security_attempt_window_minutes, 15),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import type { GeneralSettings } from '../hooks/useSettingsState';
|
||||
import { MAX_FILES_PER_UPLOAD_LIMIT } from '../hooks/useSettingsState';
|
||||
import { SUPPORTED_LANGUAGES } from "../../../components/common/LanguageSelector.tsx";
|
||||
import { MfaSettingsCard } from '../components/MfaSettingsCard';
|
||||
|
||||
interface GeneralTabProps {
|
||||
generalSettings: GeneralSettings;
|
||||
@@ -94,6 +95,10 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Per-user two-factor authentication (issue #738) — lives beside the
|
||||
admin's own account details rather than the admin-wide Security tab. */}
|
||||
<MfaSettingsCard />
|
||||
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.general.siteConfiguration')}</h2>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Save, Key, AlertCircle } from 'lucide-react';
|
||||
import { Save, Key, AlertCircle, ShieldCheck } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../../../components/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SecuritySettings } from '../hooks/useSettingsState';
|
||||
@@ -124,15 +124,15 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={securitySettings.enable_2fa}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, enable_2fa: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.security.enable2FA')}</span>
|
||||
</label>
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-800/60 border border-neutral-200 dark:border-neutral-700 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<ShieldCheck className="w-5 h-5 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">{t('settings.security.twoFactorTitle')}</p>
|
||||
<p className="mt-1">{t('settings.security.twoFactorNote')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1422,13 +1422,14 @@
|
||||
"attemptWindowMinutesHelp": "Zeitraum, in dem fehlgeschlagene Anmeldeversuche gezählt werden",
|
||||
"lockoutDurationMinutes": "Sperrdauer (Minuten)",
|
||||
"lockoutDurationMinutesHelp": "Wie lange Galerie oder Konto nach zu vielen Fehlern gesperrt bleiben",
|
||||
"enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren",
|
||||
"recaptchaSettings": "reCAPTCHA-Einstellungen",
|
||||
"enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren",
|
||||
"siteKey": "Site-Schlüssel",
|
||||
"secretKey": "Geheimer Schlüssel",
|
||||
"recaptchaHelp": "Holen Sie sich Ihre reCAPTCHA-Schlüssel von",
|
||||
"saveSecuritySettings": "Sicherheitseinstellungen speichern"
|
||||
"saveSecuritySettings": "Sicherheitseinstellungen speichern",
|
||||
"twoFactorTitle": "Zwei-Faktor-Authentifizierung",
|
||||
"twoFactorNote": "Die Zwei-Faktor-Authentifizierung wird jetzt pro Admin unter Einstellungen → Allgemein → Admin-Konto verwaltet. Jeder Admin aktiviert sie für seine eigene Anmeldung."
|
||||
},
|
||||
"events": {
|
||||
"title": "Veranstaltungserstellung",
|
||||
@@ -2040,6 +2041,42 @@
|
||||
"testSend": "Test senden",
|
||||
"testSending": "Senden…",
|
||||
"testSentToast": "Testnachricht gesendet (ID: {{id}})."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Zwei-Faktor-Authentifizierung",
|
||||
"description": "Sichere deine Admin-Anmeldung mit einem zweiten Schritt über eine Authenticator-App (TOTP).",
|
||||
"notEnrolled": "Die Zwei-Faktor-Authentifizierung ist für dein Konto nicht aktiviert.",
|
||||
"setUp": "Einrichten",
|
||||
"setupScanInstruction": "Scanne diesen QR-Code mit deiner Authenticator-App (z. B. Google Authenticator, 1Password, Authy).",
|
||||
"manualEntry": "Oder gib diesen Schlüssel manuell ein:",
|
||||
"qrAlt": "QR-Code zur Zwei-Faktor-Einrichtung",
|
||||
"enterCodeLabel": "Gib den 6-stelligen Code aus deiner App ein",
|
||||
"codePlaceholder": "123456",
|
||||
"enable": "Aktivieren",
|
||||
"enabledBadge": "Die Zwei-Faktor-Authentifizierung ist aktiviert.",
|
||||
"recoveryCodesRemaining": "Noch {{count}} Wiederherstellungscode übrig.",
|
||||
"recoveryCodesRemaining_other": "Noch {{count}} Wiederherstellungscodes übrig.",
|
||||
"regenerate": "Wiederherstellungscodes neu erzeugen",
|
||||
"regenerateHelp": "Gib einen aktuellen Authentifizierungscode ein, um neue Wiederherstellungscodes zu erzeugen. Deine alten Codes werden ungültig.",
|
||||
"regenerateConfirm": "Neu erzeugen",
|
||||
"disable": "Deaktivieren",
|
||||
"disablePrompt": "Gib einen aktuellen Authentifizierungs- oder Wiederherstellungscode ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren:",
|
||||
"disableConfirmTitle": "Zwei-Faktor-Authentifizierung deaktivieren?",
|
||||
"disableConfirmMessage": "Für dein Konto ist bei der Anmeldung dann kein zweiter Schritt mehr erforderlich. Du kannst sie jederzeit wieder aktivieren.",
|
||||
"disableConfirmButton": "Deaktivieren",
|
||||
"disabledToast": "Zwei-Faktor-Authentifizierung deaktiviert.",
|
||||
"codeRequired": "Ein Code ist erforderlich.",
|
||||
"recoveryCodesWarning": "Speichere diese Wiederherstellungscodes jetzt. Jeder kann einmal verwendet werden, falls du den Zugriff auf deine Authenticator-App verlierst. Sie werden nicht erneut angezeigt.",
|
||||
"recoveryCodesAck": "Ich habe meine Wiederherstellungscodes an einem sicheren Ort gespeichert.",
|
||||
"copy": "Kopieren",
|
||||
"copied": "Kopiert",
|
||||
"copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen.",
|
||||
"download": ".txt herunterladen",
|
||||
"done": "Fertig",
|
||||
"setupFailed": "Zwei-Faktor-Einrichtung konnte nicht gestartet werden. Bitte versuche es erneut.",
|
||||
"enableFailed": "Zwei-Faktor-Authentifizierung konnte nicht aktiviert werden. Prüfe den Code und versuche es erneut.",
|
||||
"disableFailed": "Zwei-Faktor-Authentifizierung konnte nicht deaktiviert werden. Prüfe den Code und versuche es erneut.",
|
||||
"regenerateFailed": "Wiederherstellungscodes konnten nicht neu erzeugt werden. Prüfe den Code und versuche es erneut."
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
@@ -3585,7 +3622,25 @@
|
||||
"generalError": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
|
||||
"needHelp": "Hilfe benötigt? Kontakt",
|
||||
"poweredBy": "Bereitgestellt von PicPeak",
|
||||
"devModeHint": "Entwicklungsmodus: E-Mail: [email protected], Passwort: admin123"
|
||||
"devModeHint": "Entwicklungsmodus: E-Mail: [email protected], Passwort: admin123",
|
||||
"mfa": {
|
||||
"title": "Zwei-Faktor-Authentifizierung",
|
||||
"subtitle": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
|
||||
"recoverySubtitle": "Gib einen deiner Wiederherstellungscodes ein.",
|
||||
"codeLabel": "Authentifizierungscode",
|
||||
"codePlaceholder": "123456",
|
||||
"recoveryCodeLabel": "Wiederherstellungscode",
|
||||
"recoveryCodePlaceholder": "awzq-jca3-va",
|
||||
"verify": "Bestätigen",
|
||||
"back": "Zurück",
|
||||
"useRecoveryCode": "Stattdessen Wiederherstellungscode verwenden",
|
||||
"useAuthenticator": "Stattdessen Authenticator-App verwenden",
|
||||
"codeRequired": "Gib deinen Authentifizierungscode ein",
|
||||
"invalidCode": "Ungültiger Code. Bitte versuche es erneut.",
|
||||
"sessionExpired": "Deine Bestätigungssitzung ist abgelaufen. Bitte melde dich erneut an.",
|
||||
"locked": "Konto wegen zu vieler Versuche vorübergehend gesperrt. Versuche es später erneut.",
|
||||
"lockedRetry": "Konto vorübergehend gesperrt. Versuche es in {{seconds}} Sekunden erneut."
|
||||
}
|
||||
},
|
||||
"cssTemplates": {
|
||||
"title": "Benutzerdefinierte CSS-Vorlagen",
|
||||
|
||||
@@ -969,13 +969,14 @@
|
||||
"attemptWindowMinutesHelp": "How long to look back when counting failed login attempts",
|
||||
"lockoutDurationMinutes": "Lockout Duration (minutes)",
|
||||
"lockoutDurationMinutesHelp": "How long the gallery or account stays locked after too many failures",
|
||||
"enable2FA": "Enable two-factor authentication for admins",
|
||||
"recaptchaSettings": "reCAPTCHA Settings",
|
||||
"enableRecaptcha": "Enable reCAPTCHA for login forms",
|
||||
"siteKey": "Site Key",
|
||||
"secretKey": "Secret Key",
|
||||
"recaptchaHelp": "Get your reCAPTCHA keys from",
|
||||
"saveSecuritySettings": "Save Security Settings"
|
||||
"saveSecuritySettings": "Save Security Settings",
|
||||
"twoFactorTitle": "Two-factor authentication",
|
||||
"twoFactorNote": "Two-factor authentication is now managed per admin from Settings → General → Admin Account. Each admin enables it for their own login."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Categories",
|
||||
@@ -1587,6 +1588,42 @@
|
||||
"testSend": "Send test",
|
||||
"testSending": "Sending…",
|
||||
"testSentToast": "Test message sent (id: {{id}})."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Two-factor authentication",
|
||||
"description": "Add a second step to your admin sign-in using an authenticator app (TOTP).",
|
||||
"notEnrolled": "Two-factor authentication is not enabled for your account.",
|
||||
"setUp": "Set up",
|
||||
"setupScanInstruction": "Scan this QR code with your authenticator app (e.g. Google Authenticator, 1Password, Authy).",
|
||||
"manualEntry": "Or enter this secret manually:",
|
||||
"qrAlt": "Two-factor setup QR code",
|
||||
"enterCodeLabel": "Enter the 6-digit code from your app",
|
||||
"codePlaceholder": "123456",
|
||||
"enable": "Enable",
|
||||
"enabledBadge": "Two-factor authentication is enabled.",
|
||||
"recoveryCodesRemaining": "{{count}} recovery code remaining.",
|
||||
"recoveryCodesRemaining_other": "{{count}} recovery codes remaining.",
|
||||
"regenerate": "Regenerate recovery codes",
|
||||
"regenerateHelp": "Enter a current authentication code to generate a new set of recovery codes. Your old codes will stop working.",
|
||||
"regenerateConfirm": "Regenerate",
|
||||
"disable": "Disable",
|
||||
"disablePrompt": "Enter a current authentication or recovery code to disable two-factor authentication:",
|
||||
"disableConfirmTitle": "Disable two-factor authentication?",
|
||||
"disableConfirmMessage": "Your account will no longer require a second step at sign-in. You can re-enable it at any time.",
|
||||
"disableConfirmButton": "Disable",
|
||||
"disabledToast": "Two-factor authentication disabled.",
|
||||
"codeRequired": "A code is required.",
|
||||
"recoveryCodesWarning": "Save these recovery codes now. Each can be used once if you lose access to your authenticator app. They will not be shown again.",
|
||||
"recoveryCodesAck": "I have saved my recovery codes in a safe place.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copyFailed": "Failed to copy to clipboard.",
|
||||
"download": "Download .txt",
|
||||
"done": "Done",
|
||||
"setupFailed": "Could not start two-factor setup. Please try again.",
|
||||
"enableFailed": "Could not enable two-factor authentication. Check the code and try again.",
|
||||
"disableFailed": "Could not disable two-factor authentication. Check the code and try again.",
|
||||
"regenerateFailed": "Could not regenerate recovery codes. Check the code and try again."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
@@ -3481,7 +3518,25 @@
|
||||
"generalError": "An error occurred. Please try again.",
|
||||
"needHelp": "Need help? Contact",
|
||||
"poweredBy": "Powered by PicPeak",
|
||||
"devModeHint": "Development Mode: Use email: [email protected], password: admin123"
|
||||
"devModeHint": "Development Mode: Use email: [email protected], password: admin123",
|
||||
"mfa": {
|
||||
"title": "Two-factor authentication",
|
||||
"subtitle": "Enter the 6-digit code from your authenticator app.",
|
||||
"recoverySubtitle": "Enter one of your recovery codes.",
|
||||
"codeLabel": "Authentication code",
|
||||
"codePlaceholder": "123456",
|
||||
"recoveryCodeLabel": "Recovery code",
|
||||
"recoveryCodePlaceholder": "awzq-jca3-va",
|
||||
"verify": "Verify",
|
||||
"back": "Back",
|
||||
"useRecoveryCode": "Use a recovery code instead",
|
||||
"useAuthenticator": "Use your authenticator app instead",
|
||||
"codeRequired": "Enter your authentication code",
|
||||
"invalidCode": "Invalid code. Please try again.",
|
||||
"sessionExpired": "Your verification session expired. Please sign in again.",
|
||||
"locked": "Account temporarily locked due to too many attempts. Try again later.",
|
||||
"lockedRetry": "Account temporarily locked. Try again in {{seconds}} seconds."
|
||||
}
|
||||
},
|
||||
"slideshow": {
|
||||
"adminTitle": "Live Slideshow",
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Navigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react';
|
||||
import { Lock, Mail, Eye, EyeOff, AlertCircle, ShieldCheck, KeyRound, ArrowLeft } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Input, Card, ReCaptcha } from '../../components/common';
|
||||
import { useAdminAuth } from '../../contexts';
|
||||
import { authService } from '../../services/auth.service';
|
||||
import { isMfaChallenge } from '../../types';
|
||||
import { setupService } from '../../services/setup.service';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
|
||||
@@ -29,6 +30,14 @@ export const AdminLoginPage: React.FC = () => {
|
||||
const [loginSuccess, setLoginSuccess] = useState(false);
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
|
||||
// Two-step MFA challenge state (issue #738). When the first step returns
|
||||
// { mfaRequired, mfaToken } we swap the form to a code entry step.
|
||||
const [step, setStep] = useState<'credentials' | 'mfa'>('credentials');
|
||||
const [mfaToken, setMfaToken] = useState<string | null>(null);
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
const [useRecoveryCode, setUseRecoveryCode] = useState(false);
|
||||
const [mfaError, setMfaError] = useState<string | null>(null);
|
||||
|
||||
const { data: settingsData } = usePublicSettings();
|
||||
const { isDark } = useAdminDarkMode();
|
||||
|
||||
@@ -103,6 +112,15 @@ export const AdminLoginPage: React.FC = () => {
|
||||
...formData,
|
||||
recaptchaToken
|
||||
});
|
||||
// MFA enabled → move to the second step instead of logging in.
|
||||
if (isMfaChallenge(response)) {
|
||||
setMfaToken(response.mfaToken);
|
||||
setMfaCode('');
|
||||
setUseRecoveryCode(false);
|
||||
setMfaError(null);
|
||||
setStep('mfa');
|
||||
return;
|
||||
}
|
||||
login(response.token, response.user);
|
||||
toast.success(t('adminLogin.loginSuccess'));
|
||||
setLoginSuccess(true);
|
||||
@@ -142,6 +160,62 @@ export const AdminLoginPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const backToCredentials = () => {
|
||||
setStep('credentials');
|
||||
setMfaToken(null);
|
||||
setMfaCode('');
|
||||
setMfaError(null);
|
||||
setUseRecoveryCode(false);
|
||||
};
|
||||
|
||||
const handleMfaSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
toast.dismiss();
|
||||
|
||||
const code = mfaCode.trim();
|
||||
if (!code) {
|
||||
setMfaError(t('adminLogin.mfa.codeRequired'));
|
||||
return;
|
||||
}
|
||||
if (!mfaToken) {
|
||||
// Token lost somehow — restart the flow.
|
||||
toast.info(t('adminLogin.mfa.sessionExpired'));
|
||||
backToCredentials();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setMfaError(null);
|
||||
|
||||
try {
|
||||
const response = await authService.adminLoginMfa({ mfaToken, code });
|
||||
login(response.token, response.user);
|
||||
toast.success(t('adminLogin.loginSuccess'));
|
||||
setLoginSuccess(true);
|
||||
} catch (error: any) {
|
||||
const data = error.response?.data;
|
||||
const code = data?.code;
|
||||
if (error.response?.status === 423) {
|
||||
const retryAfter = data?.retryAfter;
|
||||
toast.error(
|
||||
retryAfter
|
||||
? t('adminLogin.mfa.lockedRetry', { seconds: retryAfter })
|
||||
: t('adminLogin.mfa.locked')
|
||||
);
|
||||
backToCredentials();
|
||||
} else if (code === 'MFA_SESSION_EXPIRED') {
|
||||
toast.info(t('adminLogin.mfa.sessionExpired'));
|
||||
backToCredentials();
|
||||
} else if (code === 'MFA_INVALID') {
|
||||
setMfaError(t('adminLogin.mfa.invalidCode'));
|
||||
} else {
|
||||
setMfaError(data?.error || t('adminLogin.generalError'));
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="w-full max-w-md">
|
||||
@@ -177,6 +251,7 @@ export const AdminLoginPage: React.FC = () => {
|
||||
|
||||
{/* Login Form */}
|
||||
<Card padding="lg">
|
||||
{step === 'credentials' ? (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Form Error */}
|
||||
{errors.form && (
|
||||
@@ -263,6 +338,81 @@ export const AdminLoginPage: React.FC = () => {
|
||||
{t('adminLogin.signIn')}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleMfaSubmit} className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-3 w-12 h-12 rounded-full flex items-center justify-center bg-primary-50 dark:bg-primary-900/30">
|
||||
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--color-primary, #5C8762)' }} />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--color-text, #171717)' }}>
|
||||
{t('adminLogin.mfa.title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
||||
{useRecoveryCode ? t('adminLogin.mfa.recoverySubtitle') : t('adminLogin.mfa.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mfaError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-800">{mfaError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="mfa-code" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{useRecoveryCode ? t('adminLogin.mfa.recoveryCodeLabel') : t('adminLogin.mfa.codeLabel')}
|
||||
</label>
|
||||
<Input
|
||||
id="mfa-code"
|
||||
type="text"
|
||||
value={mfaCode}
|
||||
onChange={(e) => {
|
||||
setMfaCode(e.target.value);
|
||||
if (mfaError) setMfaError(null);
|
||||
}}
|
||||
placeholder={useRecoveryCode ? t('adminLogin.mfa.recoveryCodePlaceholder') : t('adminLogin.mfa.codePlaceholder')}
|
||||
leftIcon={<KeyRound className="w-5 h-5 text-neutral-400" />}
|
||||
inputMode={useRecoveryCode ? 'text' : 'numeric'}
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{t('adminLogin.mfa.verify')}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToCredentials}
|
||||
className="inline-flex items-center gap-1 text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t('adminLogin.mfa.back')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUseRecoveryCode((v) => !v);
|
||||
setMfaCode('');
|
||||
setMfaError(null);
|
||||
}}
|
||||
className="hover:underline"
|
||||
style={{ color: 'var(--color-primary, #5C8762)' }}
|
||||
>
|
||||
{useRecoveryCode ? t('adminLogin.mfa.useAuthenticator') : t('adminLogin.mfa.useRecoveryCode')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from '../config/api';
|
||||
import type { LoginResponse, GalleryAuthResponse, AdminUser } from '../types';
|
||||
import type { LoginResponse, AdminLoginResponse, GalleryAuthResponse, AdminUser } from '../types';
|
||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||
|
||||
const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthResponse => ({
|
||||
@@ -14,9 +14,10 @@ const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthRes
|
||||
|
||||
export const authService = {
|
||||
// Admin authentication
|
||||
async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise<LoginResponse> {
|
||||
// Backend expects 'username' field, but we accept email
|
||||
const response = await api.post<LoginResponse>('/auth/admin/login', {
|
||||
async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise<AdminLoginResponse> {
|
||||
// Backend expects 'username' field, but we accept email.
|
||||
// Returns either { user } (session set) or an MFA challenge { mfaRequired, mfaToken }.
|
||||
const response = await api.post<AdminLoginResponse>('/auth/admin/login', {
|
||||
username: credentials.email,
|
||||
password: credentials.password,
|
||||
recaptchaToken: credentials.recaptchaToken
|
||||
@@ -24,6 +25,14 @@ export const authService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Second step of the two-step admin login. `code` accepts a 6-digit TOTP
|
||||
// or a recovery code (e.g. "awzq-jca3-va"). On success the session cookie
|
||||
// is set server-side and the user object is returned.
|
||||
async adminLoginMfa(payload: { mfaToken: string; code: string }): Promise<LoginResponse> {
|
||||
const response = await api.post<LoginResponse>('/auth/admin/login/mfa', payload);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async adminLogout() {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
// Per-user admin TOTP MFA (issue #738). All endpoints operate on the
|
||||
// currently authenticated admin's own account.
|
||||
|
||||
export interface MfaStatus {
|
||||
enabled: boolean;
|
||||
enrolledAt: string | null;
|
||||
recoveryCodesRemaining: number;
|
||||
}
|
||||
|
||||
export interface MfaSetupResponse {
|
||||
secret: string;
|
||||
otpauthUri: string;
|
||||
qr: string; // PNG data URL
|
||||
issuer: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
export interface MfaRecoveryCodesResponse {
|
||||
message: string;
|
||||
recoveryCodes: string[];
|
||||
}
|
||||
|
||||
export const mfaService = {
|
||||
async getStatus(): Promise<MfaStatus> {
|
||||
const response = await api.get<MfaStatus>('/admin/auth/mfa/status');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async setup(): Promise<MfaSetupResponse> {
|
||||
const response = await api.post<MfaSetupResponse>('/admin/auth/mfa/setup');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async enable(code: string): Promise<MfaRecoveryCodesResponse> {
|
||||
const response = await api.post<MfaRecoveryCodesResponse>('/admin/auth/mfa/enable', { code });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async disable(code: string): Promise<{ message: string }> {
|
||||
const response = await api.post<{ message: string }>('/admin/auth/mfa/disable', { code });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async regenerateRecoveryCodes(code: string): Promise<MfaRecoveryCodesResponse> {
|
||||
const response = await api.post<MfaRecoveryCodesResponse>('/admin/auth/mfa/recovery-codes', { code });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -252,6 +252,20 @@ export interface LoginResponse {
|
||||
user: AdminUser;
|
||||
}
|
||||
|
||||
// Two-step admin login: when MFA is enabled, POST /auth/admin/login returns
|
||||
// this challenge instead of a session (no cookie yet). The mfaToken is a
|
||||
// short-lived (5 min) JWT exchanged at POST /auth/admin/login/mfa.
|
||||
export interface MfaChallengeResponse {
|
||||
mfaRequired: true;
|
||||
mfaToken: string;
|
||||
}
|
||||
|
||||
export type AdminLoginResponse = LoginResponse | MfaChallengeResponse;
|
||||
|
||||
export function isMfaChallenge(res: AdminLoginResponse): res is MfaChallengeResponse {
|
||||
return (res as MfaChallengeResponse).mfaRequired === true;
|
||||
}
|
||||
|
||||
export interface GalleryAuthResponse {
|
||||
token: string;
|
||||
event: {
|
||||
|
||||
Reference in New Issue
Block a user