diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json index a89a392e..43cb284a 100644 --- a/.release-please-manifest-beta.json +++ b/.release-please-manifest-beta.json @@ -1,3 +1,3 @@ { - ".": "3.79.1-beta.0" + ".": "3.80.0-beta.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index ae2e303c..e994da5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to PicPeak will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.80.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.79.1-beta.0...v3.80.0-beta.0) (2026-07-03) + + +### Features + +* **backup:** fold .picpeak restore into the Restore wizard's Upload source ([86324e7](https://github.com/PicPeak/picpeak/commit/86324e7da75069e61686b1b77495f02c33b12e1a)) +* first-run setup wizard (feature selection + config) and portable .picpeak backup roundtrip ([e513e83](https://github.com/PicPeak/picpeak/commit/e513e8345b73e37ebedc9c9ec09665ffc5773e23)) +* **setup:** add restore-from-backup branch to the first-run wizard ([a95ee47](https://github.com/PicPeak/picpeak/commit/a95ee473ae7fb2bf3c94c610724476701145d113)) +* **setup:** per-feature config step after feature selection ([07b450a](https://github.com/PicPeak/picpeak/commit/07b450a954a53781d23a71749552e4101c637777)) + + +### Bug Fixes + +* **backup:** address .picpeak review — table filter, superuser guard, tests ([fa7665c](https://github.com/PicPeak/picpeak/commit/fa7665c5b1ad18a4db4f0b59eb4c197a3c9a36e2)) +* **setup:** keep the first-run wizard light regardless of dark mode ([d4b143f](https://github.com/PicPeak/picpeak/commit/d4b143f313d00f2a30abcbc1a880c140d7455e7b)) + ## [3.79.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.79.0-beta.0...v3.79.1-beta.0) (2026-07-02) diff --git a/backend/.env.example b/backend/.env.example index 15ab3ddd..300858e0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 — diff --git a/backend/__tests__/integration/picpeakExport.test.js b/backend/__tests__/integration/picpeakExport.test.js new file mode 100644 index 00000000..bb443d6c --- /dev/null +++ b/backend/__tests__/integration/picpeakExport.test.js @@ -0,0 +1,94 @@ +'use strict'; + +// Validates the engine-neutral .picpeak export: it must produce a real zip with +// a manifest + per-table NDJSON, exclude knex bookkeeping, and honour the photo +// toggle. Uses the shared CRM DB harness (temp SQLite) — no docker needed. +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!'; + +const fs = require('fs'); +const path = require('path'); +const StreamZip = require('node-stream-zip'); +const { bootCrmDb } = require('./helpers/crmDb'); + +let db; +let cleanup; +let tmpDir; +let createPicpeak; + +// bootCrmDb MUST run before requiring the service (which transitively requires +// db.js) so the export reads this test's DB, not the default path. +beforeAll(async () => { + ({ db, cleanup, tmpDir } = await bootCrmDb()); + process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir + ({ createPicpeak } = require('../../src/services/picpeakExportService')); +}, 60000); + +afterAll(async () => { + await cleanup(); +}); + +async function readZip(filePath) { + const zip = new StreamZip.async({ file: filePath }); + const entries = Object.keys(await zip.entries()); + const manifest = JSON.parse((await zip.entryData('manifest.json')).toString('utf8')); + await zip.close(); + return { entries, manifest }; +} + +describe('picpeak export (.picpeak logical export)', () => { + it('produces a .picpeak with a manifest and per-table NDJSON', async () => { + const { filePath, manifest } = await createPicpeak({ includePhotos: false }); + try { + expect(filePath.endsWith('.picpeak')).toBe(true); + expect(fs.existsSync(filePath)).toBe(true); + + expect(manifest.format).toBe(1); + expect(manifest.kind).toBe('picpeak-backup'); + expect(manifest.database.engine).toBe('sqlite'); + expect(manifest.options.includePhotos).toBe(false); + expect(manifest.contains_secrets).toBe(true); + // Migrations seed real tables (e.g. app_settings) — expect several. + expect(Object.keys(manifest.tables).length).toBeGreaterThan(0); + expect(Object.keys(manifest.tables)).toContain('app_settings'); + + const { entries, manifest: zipped } = await readZip(filePath); + expect(entries).toContain('manifest.json'); + expect(entries.some((n) => n.startsWith('data/') && n.endsWith('.ndjson'))).toBe(true); + expect(entries).toContain('data/app_settings.ndjson'); + // Manifest inside the zip matches the returned one. + expect(zipped.tables).toEqual(manifest.tables); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); + } + }); + + it('never exports knex bookkeeping tables', async () => { + const { filePath, manifest } = await createPicpeak({ includePhotos: false }); + try { + const names = Object.keys(manifest.tables); + expect(names).not.toContain('knex_migrations'); + expect(names).not.toContain('knex_migrations_lock'); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); + } + }); + + it('row counts in the manifest match the NDJSON line counts', async () => { + // Insert a couple of settings so at least one table is non-empty. + await db('app_settings') + .insert({ setting_key: 'picpeak_export_test_a', setting_value: JSON.stringify('1'), setting_type: 'string' }) + .onConflict('setting_key').merge(); + + const { filePath, manifest } = await createPicpeak({ includePhotos: false }); + try { + const zip = new StreamZip.async({ file: filePath }); + const buf = await zip.entryData('data/app_settings.ndjson'); + await zip.close(); + const lines = buf.toString('utf8').split('\n').filter((l) => l.trim().length > 0); + expect(lines.length).toBe(manifest.tables.app_settings.rowCount); + expect(manifest.tables.app_settings.rowCount).toBeGreaterThan(0); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); + } + }); +}); diff --git a/backend/__tests__/integration/picpeakRoundtrip.test.js b/backend/__tests__/integration/picpeakRoundtrip.test.js new file mode 100644 index 00000000..8e26de07 --- /dev/null +++ b/backend/__tests__/integration/picpeakRoundtrip.test.js @@ -0,0 +1,180 @@ +'use strict'; + +// Full .picpeak roundtrip on a temp SQLite DB: +// 1. seed a "backup" instance (admin A + a marker setting) +// 2. export → .picpeak +// 3. simulate a reinstall: wipe, create a DIFFERENT current admin B, mutate data +// 4. import the backup with currentAdminId = B +// 5. assert the backup data is restored AND the current account (B) survives, +// while the backup's admin (A) is also present (different email → added). +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!'; + +const fs = require('fs'); +const path = require('path'); +const { bootCrmDb } = require('./helpers/crmDb'); + +let db; +let cleanup; +let tmpDir; +let createPicpeak; +let importFromPicpeak; +let validateManifest; +let superAdminRoleId; + +beforeAll(async () => { + ({ db, cleanup, tmpDir } = await bootCrmDb()); + process.env.STORAGE_PATH = tmpDir; + ({ createPicpeak } = require('../../src/services/picpeakExportService')); + ({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService')); + const role = await db('roles').where({ name: 'super_admin' }).first(); + superAdminRoleId = role.id; +}, 60000); + +afterAll(async () => { + await cleanup(); +}); + +const adminRow = (email, hash) => ({ + username: email, + email, + password_hash: hash, + role_id: superAdminRoleId, + is_active: true, + must_change_password: false, + created_at: new Date(), + updated_at: new Date(), +}); + +async function setMarker(value) { + await db('app_settings') + .insert({ setting_key: 'roundtrip_marker', setting_value: JSON.stringify(value), setting_type: 'string' }) + .onConflict('setting_key').merge(); +} +async function getMarker() { + const row = await db('app_settings').where({ setting_key: 'roundtrip_marker' }).first(); + return row ? JSON.parse(row.setting_value) : null; +} + +describe('.picpeak roundtrip (export → import)', () => { + it('restores backup data and preserves the current account', async () => { + // 1. Seed the "source" instance. + await db('admin_users').del(); + await db('admin_users').insert(adminRow('backup-admin@old.example', 'HASH_A')); + await setMarker('from_backup'); + + // 2. Export. + const { filePath } = await createPicpeak({ includePhotos: false }); + + try { + // 3. Simulate a reinstall: fresh current admin B, mutated data. + await db('admin_users').del(); + const [bId] = await db('admin_users').insert(adminRow('current-admin@new.example', 'HASH_B')).returning('id'); + const currentAdminId = typeof bId === 'object' ? bId.id : bId; + await setMarker('mutated_after_backup'); + + // 4. Import, preserving the current admin. + const result = await importFromPicpeak({ filePath: undefined, picpeakPath: filePath, currentAdminId }); + expect(result.restored).toBe(true); + expect(result.tables).toBeGreaterThan(0); + + // 5a. Backup data restored (marker reverted to the backup value). + expect(await getMarker()).toBe('from_backup'); + + // 5b. The backup's admin is present (different email → added). + const a = await db('admin_users').whereRaw('lower(email) = lower(?)', ['backup-admin@old.example']).first(); + expect(a).toBeTruthy(); + expect(a.password_hash).toBe('HASH_A'); + + // 5c. The current account SURVIVES the override, with its own credentials. + const b = await db('admin_users').whereRaw('lower(email) = lower(?)', ['current-admin@new.example']).first(); + expect(b).toBeTruthy(); + expect(b.password_hash).toBe('HASH_B'); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); + } + }); + + it('overwrites a backup admin that collides with the current account email', async () => { + // Source has an admin at the SAME email the current operator will use. + await db('admin_users').del(); + await db('admin_users').insert(adminRow('shared@example.com', 'OLD_HASH')); + await setMarker('collision_case'); + const { filePath } = await createPicpeak({ includePhotos: false }); + + try { + // Reinstall: current admin uses the same email but a NEW password. + await db('admin_users').del(); + const [id] = await db('admin_users').insert(adminRow('shared@example.com', 'NEW_HASH')).returning('id'); + const currentAdminId = typeof id === 'object' ? id.id : id; + + await importFromPicpeak({ picpeakPath: filePath, currentAdminId }); + + // Exactly one admin at that email, and it keeps the CURRENT password. + const rows = await db('admin_users').whereRaw('lower(email) = lower(?)', ['shared@example.com']); + expect(rows).toHaveLength(1); + expect(rows[0].password_hash).toBe('NEW_HASH'); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); + } + }); + + it('restores files/ and reports filesRestored', async () => { + // A business-doc that lives in storage → travels in the backup. + const docDir = path.join(tmpDir, 'business-docs'); + const marker = path.join(docDir, 'roundtrip-doc.txt'); + fs.mkdirSync(docDir, { recursive: true }); + fs.writeFileSync(marker, 'hello'); + await db('admin_users').del(); + const [id] = await db('admin_users').insert(adminRow('files@example.com', 'H')).returning('id'); + const currentAdminId = typeof id === 'object' ? id.id : id; + + const { filePath } = await createPicpeak({ includePhotos: false }); + try { + fs.rmSync(marker); // delete on disk so the restore must bring it back + const result = await importFromPicpeak({ picpeakPath: filePath, currentAdminId }); + expect(result.filesRestored).toBeGreaterThanOrEqual(1); + expect(fs.existsSync(marker)).toBe(true); + expect(fs.readFileSync(marker, 'utf8')).toBe('hello'); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); + fs.rmSync(docDir, { recursive: true, force: true }); + } + }); +}); + +describe('.picpeak manifest validation', () => { + it('rejects a database-engine mismatch', async () => { + // Harness runs on SQLite, so a pg manifest must be refused. + const blockers = await validateManifest({ + kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {}, + }); + expect(blockers.some((b) => /engine/i.test(b))).toBe(true); + }); + + it('rejects a backup from a newer schema (forward-only)', async () => { + // validateManifest reads knex_migrations for the target's latest migration; + // the harness has none, so create it with an older migration than the backup. + await db.schema.createTable('knex_migrations', (t) => { + t.increments('id'); + t.string('name'); + t.integer('batch'); + t.timestamp('migration_time'); + }); + try { + await db('knex_migrations').insert({ name: '100_baseline', batch: 1 }); + const blockers = await validateManifest({ + kind: 'picpeak-backup', format: 1, + database: { engine: 'sqlite', latest_migration: '999_from_the_future' }, + tables: {}, + }); + expect(blockers.some((b) => /newer/i.test(b))).toBe(true); + } finally { + await db.schema.dropTableIfExists('knex_migrations'); + } + }); + + it('rejects a file that is not a PicPeak backup', async () => { + const blockers = await validateManifest({ some: 'random-json' }); + expect(blockers.length).toBeGreaterThan(0); + }); +}); diff --git a/backend/__tests__/integration/resetAdminMfaCli.test.js b/backend/__tests__/integration/resetAdminMfaCli.test.js new file mode 100644 index 00000000..3aa3f2bb --- /dev/null +++ b/backend/__tests__/integration/resetAdminMfaCli.test.js @@ -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 --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 = 'reset-me@example.com'; + 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 = 'target@example.com'; + const bystanderEmail = 'bystander@example.com'; + 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'); +}); diff --git a/backend/__tests__/middleware/ownership.filterOwnedEventIds.test.js b/backend/__tests__/middleware/ownership.filterOwnedEventIds.test.js new file mode 100644 index 00000000..066080c0 --- /dev/null +++ b/backend/__tests__/middleware/ownership.filterOwnedEventIds.test.js @@ -0,0 +1,78 @@ +/** + * Regression test for the bulk archive/delete ownership bypass. + * + * bulk-archive and bulk-delete acted on body-supplied event ids with no + * ownership filter, so an admin/editor scoped to their own events (the + * single-event routes enforce requireEventOwnership) could archive or + * cascade-delete ANY event by id. filterOwnedEventIds is the helper those + * routes now use to drop foreign/non-existent ids. + */ + +// events owned by admin 7; event 3 owned by someone else; event 4 is +// ownerless (legacy). The mock models: +// whereIn('id', ids).andWhere(created_by IS NULL OR created_by = admin.id) +const EVENTS = [ + { id: 1, created_by: 7 }, + { id: 2, created_by: 7 }, + { id: 3, created_by: 99 }, // foreign + { id: 4, created_by: null }, // ownerless/legacy +]; + +jest.mock('../../src/database/db', () => ({ + db: () => { + const q = { + _ids: null, + _adminId: null, + whereIn(_col, ids) { this._ids = ids; return this; }, + andWhere(cb) { + // Emulate the (created_by IS NULL OR created_by = admin.id) builder + // by capturing the admin id the callback closes over via a probe. + const probe = { + _adminId: null, + whereNull() { return this; }, + orWhere(_col, id) { this._adminId = id; return this; }, + }; + cb(probe); + this._adminId = probe._adminId; + return this; + }, + select() { + return Promise.resolve( + EVENTS + .filter((e) => this._ids.includes(e.id)) + .filter((e) => e.created_by === null || e.created_by === this._adminId) + .map((e) => ({ id: e.id })) + ); + }, + }; + return q; + }, +})); + +const { filterOwnedEventIds } = require('../../src/middleware/ownership'); + +describe('filterOwnedEventIds', () => { + it('super_admin gets every id, nothing denied', async () => { + const { allowed, denied } = await filterOwnedEventIds( + { id: 7, roleName: 'super_admin' }, [1, 3, 4, 999] + ); + expect(allowed).toEqual([1, 3, 4, 999]); + expect(denied).toEqual([]); + }); + + it('non-super_admin keeps owned + ownerless, denies foreign and non-existent', async () => { + const { allowed, denied } = await filterOwnedEventIds( + { id: 7, roleName: 'admin' }, [1, 2, 3, 4, 999] + ); + expect(allowed.sort()).toEqual([1, 2, 4]); // owns 1,2; 4 is ownerless + expect(denied.sort()).toEqual([3, 999]); // 3 foreign, 999 missing + }); + + it('foreign-only request yields empty allowed', async () => { + const { allowed, denied } = await filterOwnedEventIds( + { id: 7, roleName: 'editor' }, [3] + ); + expect(allowed).toEqual([]); + expect(denied).toEqual([3]); + }); +}); diff --git a/backend/__tests__/middleware/photoAuth.thumbnailScope.test.js b/backend/__tests__/middleware/photoAuth.thumbnailScope.test.js new file mode 100644 index 00000000..df4050f7 --- /dev/null +++ b/backend/__tests__/middleware/photoAuth.thumbnailScope.test.js @@ -0,0 +1,103 @@ +/** + * Regression test for the cross-event thumbnail enumeration leak. + * + * Thumbnails are served flat from /thumbnails/thumb_ with + * deterministic, enumerable filenames. photoAuth previously granted any + * holder of a gallery token for ANY active event access to ANY thumbnail + * (it set eventSlug=null and returned next() as long as the token's event + * existed), so a visitor to one gallery could pull another (password- + * protected) gallery's entire thumbnail set. The fix scopes thumbnail + * access to the token's event by matching the requested file against + * photos.thumbnail_path for that event_id. + */ + +process.env.JWT_SECRET = 'test-secret-thumbnail-scope-000000000000'; + +const jwt = require('jsonwebtoken'); + +// Two events, each owning one thumbnail. The photos mock resolves a row +// only when BOTH event_id and thumbnail_path match — i.e. it models the +// real ownership query. +const EVENTS = [ + { id: 10, slug: 'event-a', is_active: 1 }, + { id: 20, slug: 'event-b', is_active: 1 }, +]; +const PHOTOS = [ + { id: 1, event_id: 10, thumbnail_path: 'thumbnails/thumb_event-a_ceremony_0001.jpg' }, + { id: 2, event_id: 20, thumbnail_path: 'thumbnails/thumb_event-b_ceremony_0001.jpg' }, +]; + +jest.mock('../../src/database/db', () => ({ + db: (table) => ({ + _cond: null, + where(cond) { this._cond = cond; return this; }, + first() { + if (table === 'events') { + return Promise.resolve(EVENTS.find((e) => e.id === this._cond.id) || null); + } + if (table === 'photos') { + return Promise.resolve( + PHOTOS.find((p) => p.event_id === this._cond.event_id + && p.thumbnail_path === this._cond.thumbnail_path) || null + ); + } + return Promise.resolve(null); + }, + }), +})); + +jest.mock('../../src/utils/logger', () => ({ + info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), +})); + +const photoAuth = require('../../src/middleware/photoAuth'); + +function galleryToken(eventId) { + return jwt.sign({ type: 'gallery', eventId }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' }); +} + +function makeReqRes(token, thumbPath) { + const req = { path: thumbPath, headers: { authorization: `Bearer ${token}` }, cookies: {} }; + const res = { + statusCode: null, + body: null, + status(code) { this.statusCode = code; return this; }, + json(payload) { this.body = payload; return this; }, + }; + return { req, res }; +} + +describe('photoAuth — thumbnail ownership scoping', () => { + it('denies a gallery token for event A fetching event B\'s thumbnail', async () => { + const { req, res } = makeReqRes(galleryToken(10), '/thumb_event-b_ceremony_0001.jpg'); + const next = jest.fn(); + + await photoAuth(req, res, next); + + // Access denied: middleware must not pass the request through. + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBeGreaterThanOrEqual(400); + expect(req.event).toBeUndefined(); + }); + + it('allows a gallery token to fetch its own event\'s thumbnail', async () => { + const { req, res } = makeReqRes(galleryToken(20), '/thumb_event-b_ceremony_0001.jpg'); + const next = jest.fn(); + + await photoAuth(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.event).toMatchObject({ id: 20 }); + }); + + it('denies a traversal / foreign filename that matches no owned thumbnail', async () => { + const { req, res } = makeReqRes(galleryToken(10), '/thumb_../../etc/passwd'); + const next = jest.fn(); + + await photoAuth(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBeGreaterThanOrEqual(400); + expect(req.event).toBeUndefined(); + }); +}); diff --git a/backend/__tests__/routes/adminMfa.test.js b/backend/__tests__/routes/adminMfa.test.js new file mode 100644 index 00000000..352f2a6e --- /dev/null +++ b/backend/__tests__/routes/adminMfa.test.js @@ -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); + }); +}); diff --git a/backend/__tests__/services/mfaService.test.js b/backend/__tests__/services/mfaService.test.js new file mode 100644 index 00000000..a6e5b36b --- /dev/null +++ b/backend/__tests__/services/mfaService.test.js @@ -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('admin@example.com', 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('admin@example.com', 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); + }); +}); diff --git a/backend/migrations/core/151_add_admin_mfa_recovery.js b/backend/migrations/core/151_add_admin_mfa_recovery.js new file mode 100644 index 00000000..fed7b651 --- /dev/null +++ b/backend/migrations/core/151_add_admin_mfa_recovery.js @@ -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'); + } + }); +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 60ecdaa0..87fbe8ea 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index f9286915..702e7cf1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "picpeak-backend", - "version": "3.79.1-beta.0", + "version": "3.80.0-beta.0", "description": "Backend for PicPeak event photo sharing platform", "main": "server.js", "scripts": { @@ -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", diff --git a/backend/scripts/reset-admin-mfa.js b/backend/scripts/reset-admin-mfa.js new file mode 100644 index 00000000..ce4985cb --- /dev/null +++ b/backend/scripts/reset-admin-mfa.js @@ -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 admin@example.com + * docker compose exec backend node scripts/reset-admin-mfa.js --all --yes + * + * Flags: + * --email target a single admin by email (or --username ) + * --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 , --username , or --all'); + console.log(' e.g. node scripts/reset-admin-mfa.js --email admin@example.com'); + 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); +}); diff --git a/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js b/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js index 1ebbefef..55302bbb 100644 --- a/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js +++ b/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js @@ -101,6 +101,7 @@ describe('verifyGalleryAccess — customer-minted JWT with active assignment', ( it('allows access when the event_customer_assignments row exists', async () => { getGalleryTokenFromRequest.mockReturnValue('tkn'); jwt.verify.mockReturnValue({ + type: 'gallery', eventId: 42, via: 'customer', customerId: 7, @@ -131,6 +132,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => { it('returns 403 CUSTOMER_ASSIGNMENT_REVOKED when the junction row is gone', async () => { getGalleryTokenFromRequest.mockReturnValue('tkn'); jwt.verify.mockReturnValue({ + type: 'gallery', eventId: 42, via: 'customer', customerId: 7, @@ -160,6 +162,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => { // and start 403'ing per-event-password sessions. getGalleryTokenFromRequest.mockReturnValue('tkn'); jwt.verify.mockReturnValue({ + type: 'gallery', eventId: 42, customerId: 7, // intentionally no `via` claim @@ -191,6 +194,7 @@ describe('verifyGalleryAccess — per-event-password JWT', () => { it('does NOT touch event_customer_assignments and passes through', async () => { getGalleryTokenFromRequest.mockReturnValue('tkn'); jwt.verify.mockReturnValue({ + type: 'gallery', eventId: 42, // No via, no customerId — this is the legacy per-event-password // flow where every guest mints their own JWT after entering the diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 214485f1..ccb49903 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -18,6 +18,7 @@ async function adminAuth(req, res, next) { let decoded; try { decoded = jwt.verify(token, process.env.JWT_SECRET, { + algorithms: ['HS256'], issuer: 'picpeak-auth', complete: true }); @@ -140,6 +141,7 @@ async function galleryAuth(req, res, next) { let decoded; try { decoded = jwt.verify(token, process.env.JWT_SECRET, { + algorithms: ['HS256'], issuer: 'picpeak-auth', complete: true }); @@ -209,7 +211,7 @@ async function photoAuth(req, res, next) { let decoded; try { - decoded = jwt.verify(token, process.env.JWT_SECRET); + decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); } catch (err) { return res.status(401).json({ error: 'Invalid token' }); } diff --git a/backend/src/middleware/customerAuth.js b/backend/src/middleware/customerAuth.js index 1d2e717b..e1b9c545 100644 --- a/backend/src/middleware/customerAuth.js +++ b/backend/src/middleware/customerAuth.js @@ -34,6 +34,7 @@ async function customerAuth(req, res, next) { let decoded; try { const verified = jwt.verify(token, process.env.JWT_SECRET, { + algorithms: ['HS256'], issuer: 'picpeak-auth', complete: true, }); diff --git a/backend/src/middleware/gallery.js b/backend/src/middleware/gallery.js index 8d4ccf56..a1ee968a 100644 --- a/backend/src/middleware/gallery.js +++ b/backend/src/middleware/gallery.js @@ -66,18 +66,29 @@ async function verifyGalleryAccess(req, res, next) { let decoded; try { decoded = jwt.verify(token, process.env.JWT_SECRET, { + algorithms: ['HS256'], issuer: 'picpeak-auth' }); } catch (error) { // If verification fails with issuer, try without issuer (backward compatibility) if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) { - decoded = jwt.verify(token, process.env.JWT_SECRET); + decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); } else { throw error; } } logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug }); - + + // Only gallery-scoped tokens grant gallery access. Every legitimate + // path (password login, share link, client access, customer-minted, + // slideshow) mints type:'gallery'. Reject anything else — e.g. a guest + // identity token (type:'guest', for feedback attribution) that carries a + // matching eventId — instead of relying on other token types incidentally + // lacking an eventId to fail the id match below. + if (decoded.type !== 'gallery') { + return res.status(403).json({ error: 'Invalid token type for gallery access' }); + } + // If we have a slug in the URL params or from pre-middleware, verify it matches if (requestedSlug) { // Verify by slug and ensure it matches the token's event diff --git a/backend/src/middleware/guestAuth.js b/backend/src/middleware/guestAuth.js index f50e9d1d..a21eddb5 100644 --- a/backend/src/middleware/guestAuth.js +++ b/backend/src/middleware/guestAuth.js @@ -23,6 +23,7 @@ async function resolveGuest(req, res, next) { let decoded; try { const verified = jwt.verify(token, process.env.JWT_SECRET, { + algorithms: ['HS256'], issuer: 'picpeak-auth', complete: true, }); diff --git a/backend/src/middleware/ownership.js b/backend/src/middleware/ownership.js index 7ebad2ff..322c0aad 100644 --- a/backend/src/middleware/ownership.js +++ b/backend/src/middleware/ownership.js @@ -32,4 +32,36 @@ function requireEventOwnership(req, res, next) { }); } -module.exports = { requireEventOwnership }; +/** + * Return the subset of `eventIds` the admin may act on, mirroring + * requireEventOwnership for bulk routes that can't use it (they take an + * array in the body, not an :id param). super_admin gets everything; + * other roles get events they created plus ownerless legacy/system + * events (created_by IS NULL). Ids that are foreign OR non-existent both + * land in `denied` — deliberately indistinguishable, so bulk routes + * don't become an ownership/existence oracle. + * + * @returns {Promise<{allowed: Array, denied: Array}>} + */ +async function filterOwnedEventIds(admin, eventIds) { + if (admin.roleName === 'super_admin') { + return { allowed: [...eventIds], denied: [] }; + } + const rows = await db('events') + .whereIn('id', eventIds) + .andWhere((q) => q.whereNull('created_by').orWhere('created_by', admin.id)) + .select('id'); + const allowedSet = new Set(rows.map((r) => r.id)); + const allowed = []; + const denied = []; + for (const id of eventIds) { + if (allowedSet.has(id) || allowedSet.has(Number(id))) { + allowed.push(id); + } else { + denied.push(id); + } + } + return { allowed, denied }; +} + +module.exports = { requireEventOwnership, filterOwnedEventIds }; diff --git a/backend/src/middleware/photoAuth.js b/backend/src/middleware/photoAuth.js index 6f832aa1..df7a1433 100644 --- a/backend/src/middleware/photoAuth.js +++ b/backend/src/middleware/photoAuth.js @@ -28,12 +28,13 @@ async function photoAuth(req, res, next) { let decoded; try { decoded = jwt.verify(token, process.env.JWT_SECRET, { + algorithms: ['HS256'], issuer: 'picpeak-auth' }); } catch (issuerError) { // If verification fails with issuer, try without issuer (backward compatibility) if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) { - decoded = jwt.verify(token, process.env.JWT_SECRET); + decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); } else { throw issuerError; } @@ -43,24 +44,36 @@ async function photoAuth(req, res, next) { if (decoded.type === 'gallery') { // For thumbnails, we need to verify the token is for a valid event if (!eventSlug) { - // Extract event ID from the decoded token + // Resolve the token's event (by id, or legacy slug fallback)... + let event = null; if (decoded.eventId) { - const event = await db('events') + event = await db('events') .where({ id: decoded.eventId, is_active: formatBoolean(true) }) .first(); - if (event) { + } + if (!event && decoded.eventSlug) { + event = await db('events') + .where({ slug: decoded.eventSlug, is_active: formatBoolean(true) }) + .first(); + } + // ...then confirm the REQUESTED thumbnail actually belongs to + // that event. Thumbnails are stored flat (thumbnails/thumb_) + // with deterministic, enumerable filenames derived from the + // public event name + a sequential counter. Without this + // ownership check any holder of a gallery token for any event + // could enumerate and fetch another (password-protected) event's + // entire thumbnail set, defeating the gallery password. A + // traversal or foreign filename simply fails to match → denied. + if (event) { + const requestedKey = `thumbnails${req.path}`; + const ownsThumbnail = await db('photos') + .where({ event_id: event.id, thumbnail_path: requestedKey }) + .first(); + if (ownsThumbnail) { req.event = event; return next(); } } - // Fallback to slug - const event = await db('events') - .where({ slug: decoded.eventSlug, is_active: formatBoolean(true) }) - .first(); - if (event) { - req.event = event; - return next(); - } } // For regular photos, check if token matches the event else if (decoded.eventSlug === eventSlug) { diff --git a/backend/src/middleware/sessionTimeout.js b/backend/src/middleware/sessionTimeout.js index 5edce16f..29c0b315 100644 --- a/backend/src/middleware/sessionTimeout.js +++ b/backend/src/middleware/sessionTimeout.js @@ -87,7 +87,7 @@ async function sessionTimeoutMiddleware(req, res, next) { try { // Verify token is valid - const decoded = jwt.verify(token, process.env.JWT_SECRET); + const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); // Check if this is an admin token if (!decoded.id) { @@ -128,7 +128,7 @@ async function sessionTimeoutMiddleware(req, res, next) { for (const [oldToken, _] of sessions.entries()) { if (oldToken !== token) { try { - const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET); + const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET, { algorithms: ['HS256'] }); if (oldDecoded.id === userId) { sessions.delete(oldToken); } diff --git a/backend/src/routes/adminAuth.js b/backend/src/routes/adminAuth.js index b2224825..898f029c 100644 --- a/backend/src/routes/adminAuth.js +++ b/backend/src/routes/adminAuth.js @@ -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; diff --git a/backend/src/routes/adminBackup.js b/backend/src/routes/adminBackup.js index 0c1174a0..67d1479c 100644 --- a/backend/src/routes/adminBackup.js +++ b/backend/src/routes/adminBackup.js @@ -129,6 +129,71 @@ router.post('/run', adminAuth, requirePermission('backup.create'), async (req, r } }); +// Generate + download a portable ".picpeak" export — an engine-neutral logical +// snapshot (DB rows as NDJSON + PDFs/business-docs) that can be re-uploaded to +// another instance via the web UI. `?includePhotos=true` also bundles original +// gallery photos (larger); otherwise the admin re-uploads them per gallery. +// +// SECURITY: the file contains plaintext secrets (SMTP password, admin password +// hashes, API keys). The download UI must warn before offering it. We surface +// the flag as a response header too so the client can double-confirm. +router.get('/picpeak/export', adminAuth, requirePermission('backup.create'), async (req, res) => { + const fsSync = require('fs'); + try { + const includePhotos = req.query.includePhotos === 'true' || req.query.includePhotos === '1'; + const { createPicpeak } = require('../services/picpeakExportService'); + const { filePath } = await createPicpeak({ includePhotos }); + const filename = path.basename(filePath); + res.setHeader('X-Picpeak-Contains-Secrets', 'true'); + res.download(filePath, filename, (err) => { + // Best-effort cleanup of the temp .picpeak (and its temp dir) after send. + fsSync.rm(path.dirname(filePath), { recursive: true, force: true }, () => {}); + if (err) logger.error('[picpeak-export] download failed', { error: err.message }); + }); + } catch (error) { + logger.error('[picpeak-export] failed to create export', { error: error.message }); + if (!res.headersSent) res.status(500).json({ error: 'Failed to create .picpeak export' }); + } +}); + +// Multipart upload for .picpeak restore — streamed to a temp file. Runs AFTER +// auth so an unauthenticated request can't push a large file to disk. +const os = require('os'); +const multer = require('multer'); +const picpeakUpload = multer({ + storage: multer.diskStorage({ + destination: (req, file, cb) => cb(null, os.tmpdir()), + filename: (req, file, cb) => cb(null, `picpeak-upload-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.picpeak`), + }), + limits: { fileSize: 5 * 1024 * 1024 * 1024 }, // 5 GB — .picpeak with photos can be large +}); + +// Upload + restore a .picpeak onto THIS instance. DESTRUCTIVE: full override of +// all data except the current logged-in account (the client shows an explicit +// confirmation before calling this). Returns `usesExternalMedia` so the UI can +// prompt the admin to reconfigure the external-media mount afterwards. +router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), picpeakUpload.single('backup'), async (req, res) => { + const fsSync = require('fs'); + if (!req.file) return res.status(400).json({ error: 'No backup file uploaded' }); + const picpeakPath = req.file.path; + try { + const { importFromPicpeak } = require('../services/picpeakImportService'); + const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.user && req.user.id }); + res.json({ + success: true, + tables: result.tables, + filesRestored: result.filesRestored, + usesExternalMedia: result.usesExternalMedia, + }); + } catch (error) { + const status = error.statusCode || 500; + logger.error('[picpeak-import] restore failed', { error: error.message }); + res.status(status).json({ error: error.message || 'Restore failed', validation: error.validation }); + } finally { + fsSync.unlink(picpeakPath, () => {}); + } +}); + // Get backup run details router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req, res) => { try { diff --git a/backend/src/routes/adminEventRename.js b/backend/src/routes/adminEventRename.js index a1af4f78..8acdbcf0 100644 --- a/backend/src/routes/adminEventRename.js +++ b/backend/src/routes/adminEventRename.js @@ -7,6 +7,7 @@ const express = require('express'); const { body, validationResult } = require('express-validator'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); +const { requireEventOwnership } = require('../middleware/ownership'); const eventRenameService = require('../services/eventRenameService'); const logger = require('../utils/logger'); const router = express.Router(); @@ -15,7 +16,7 @@ const router = express.Router(); * POST /api/admin/events/:eventId/rename * Rename an event */ -router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [ +router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), requireEventOwnership, [ body('newEventName') .trim() .isLength({ min: 3, max: 100 }) @@ -60,7 +61,7 @@ router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [ * POST /api/admin/events/:eventId/validate-rename * Validate a potential rename without executing it */ -router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), [ +router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), requireEventOwnership, [ body('newEventName') .trim() .isLength({ min: 3, max: 100 }) diff --git a/backend/src/routes/adminEvents/archiveBulk.js b/backend/src/routes/adminEvents/archiveBulk.js index 23655e70..75533ef4 100644 --- a/backend/src/routes/adminEvents/archiveBulk.js +++ b/backend/src/routes/adminEvents/archiveBulk.js @@ -10,7 +10,7 @@ const { requirePermission } = require('../../middleware/permissions'); const { archiveEvent } = require('../../services/archiveService'); const logger = require('../../utils/logger'); const { errorResponse } = require('../../utils/routeHelpers'); -const { requireEventOwnership } = require('../../middleware/ownership'); +const { requireEventOwnership, filterOwnedEventIds } = require('../../middleware/ownership'); const { deleteEventCascade } = require('./helpers'); @@ -74,25 +74,39 @@ module.exports = (router) => { } const { eventIds } = req.body; - + if (eventIds.length === 0) { return res.status(400).json({ error: 'No events selected for archiving' }); } - // Get all events to archive - const events = await db('events') - .whereIn('id', eventIds) - .where('is_archived', formatBoolean(false)); - - if (events.length === 0) { - return res.status(400).json({ error: 'No valid events found to archive' }); - } + // Ownership scope: a non-super_admin may only archive events they own. + // Foreign/non-existent ids are dropped and reported as failures so this + // route can't archive another admin's events (the single-event + // /:id/archive route enforces the same via requireEventOwnership). + const { allowed: allowedIds, denied: deniedIds } = await filterOwnedEventIds(req.admin, eventIds); const results = { successful: [], - failed: [] + failed: deniedIds.map((id) => ({ id, name: null, error: 'Access denied or event not found' })) }; + // Get all events to archive + const events = allowedIds.length + ? await db('events') + .whereIn('id', allowedIds) + .where('is_archived', formatBoolean(false)) + : []; + + if (events.length === 0) { + if (results.failed.length > 0) { + return res.json({ + message: `Bulk archive completed: 0 succeeded, ${results.failed.length} failed`, + results + }); + } + return res.status(400).json({ error: 'No valid events found to archive' }); + } + // Process each event for (const event of events) { try { @@ -151,16 +165,21 @@ module.exports = (router) => { const { eventIds } = req.body; - // Editor-role events.delete permission is already gated by the route - // middleware. We do NOT additionally filter to created_by here because - // the per-event delete-cascade is global (matches DELETE /:id which - // also has no role-based filter — that's why events.delete is a - // sensitive permission). + // Ownership scope: a non-super_admin may only delete events they own. + // The single-event DELETE /:id route enforces this via + // requireEventOwnership; this bulk route must match it, otherwise an + // admin/editor scoped to their own events could cascade-delete any + // event by id. Foreign/non-existent ids are dropped and reported as + // failures (indistinguishable, to avoid an existence oracle). + const { allowed: allowedIds, denied: deniedIds } = await filterOwnedEventIds(req.admin, eventIds); - const results = { successful: [], failed: [] }; + const results = { + successful: [], + failed: deniedIds.map((id) => ({ id, name: null, error: 'Access denied or event not found' })) + }; const adminContext = { id: req.admin.id, username: req.admin.username }; - for (const eventId of eventIds) { + for (const eventId of allowedIds) { try { const deleted = await deleteEventCascade(eventId, adminContext); results.successful.push(deleted); diff --git a/backend/src/routes/adminExternalMedia.js b/backend/src/routes/adminExternalMedia.js index 8566bfff..de9e6c06 100644 --- a/backend/src/routes/adminExternalMedia.js +++ b/backend/src/routes/adminExternalMedia.js @@ -3,6 +3,7 @@ const path = require('path'); const fs = require('fs').promises; const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); +const { requireEventOwnership } = require('../middleware/ownership'); const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService'); const { db, logActivity } = require('../database/db'); const sharp = require('sharp'); @@ -48,7 +49,7 @@ async function walkDir(dir, baseDir) { // POST /api/admin/events/:id/import-external // Body: { external_path: string, recursive?: boolean, map?: { individual?: string, collages?: string } } -router.post('/events/:id/import-external', adminAuth, requirePermission('photos.upload'), async (req, res) => { +router.post('/events/:id/import-external', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => { try { const eventId = parseInt(req.params.id); const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {}; diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 3dcd18f3..875925d6 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -600,12 +600,18 @@ router.post( const photo = await db('photos').where({ id: req.params.photoId }).first(); if (!photo) return res.status(404).json({ error: 'Photo not found' }); - // Editor role: only allow retry on photos in events they own. - if (req.admin.roleName === 'editor') { + // Ownership scope: any non-super_admin may only retry photos in events + // they own — matching requireEventOwnership (which scopes both the + // admin and editor roles; only super_admin bypasses). Previously this + // checked the editor role alone, leaving admin-role users able to + // reprocess another admin's photos. + if (req.admin.roleName !== 'super_admin') { const event = await db('events') - .where({ id: photo.event_id, created_by: req.admin.id }) + .where({ id: photo.event_id }) .first(); - if (!event) return res.status(404).json({ error: 'Photo not found' }); + if (event && event.created_by && event.created_by !== req.admin.id) { + return res.status(404).json({ error: 'Photo not found' }); + } } if (photo.processing_status !== 'failed') { diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 185b02e2..8915380f 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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, @@ -14,6 +15,7 @@ const { } = require('../utils/authSecurity'); const { endSession } = require('../middleware/sessionTimeout'); const { revokeToken } = require('../utils/tokenRevocation'); +const { timingSafeEqualStr } = require('../utils/timingSafe'); const logger = require('../utils/logger'); const { errorResponse } = require('../utils/routeHelpers'); const { @@ -33,6 +35,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,48 +140,131 @@ 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({ - id: admin.id, - username: admin.username, - type: 'admin', - role: admin.role_name, // Add role to JWT - ip: ipAddress, - loginTime: Date.now() - }, process.env.JWT_SECRET, { - expiresIn: '24h', - issuer: 'picpeak-auth' - }); - - setAdminAuthCookie(res, token); - - // Token is delivered via HttpOnly cookie only (not in response body) - res.json({ - user: { + // 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, - email: admin.email, - mustChangePassword: admin.must_change_password || false, - role: admin.role_name ? { - name: admin.role_name, - displayName: admin.role_display_name - } : null - } - }); + type: 'mfa_pending', + loginId: username + }, process.env.JWT_SECRET, { + expiresIn: '5m', + issuer: 'picpeak-auth' + }); + return res.json({ mfaRequired: true, mfaToken }); + } + + return await completeAdminLogin(req, res, admin, ipAddress, userAgent, username); } catch (error) { errorResponse(res, error, 500, '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 { @@ -410,7 +538,7 @@ router.post('/gallery/share-login', [ const expectedToken = getEventShareToken(event); - if (!expectedToken || token !== expectedToken) { + if (!expectedToken || !timingSafeEqualStr(token, expectedToken)) { await trackFailedAttempt(shareIdentifier, ipAddress, userAgent); return res.status(401).json({ error: 'Invalid or expired share link' }); } diff --git a/backend/src/routes/galleryFeedback.js b/backend/src/routes/galleryFeedback.js index db58bc8a..9ca5f00d 100644 --- a/backend/src/routes/galleryFeedback.js +++ b/backend/src/routes/galleryFeedback.js @@ -1,6 +1,5 @@ const express = require('express'); const router = express.Router(); -const { photoAuth } = require('../middleware/photoAuth'); const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery'); const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit'); const { resolveGuest } = require('../middleware/guestAuth'); diff --git a/backend/src/routes/protectedImages.js b/backend/src/routes/protectedImages.js index a71af7f9..0bf9e725 100644 --- a/backend/src/routes/protectedImages.js +++ b/backend/src/routes/protectedImages.js @@ -9,6 +9,7 @@ const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/ph const { withLocalCopy } = require('../services/imageProcessor'); const crypto = require('crypto'); const logger = require('../utils/logger'); +const { timingSafeEqualStr } = require('../utils/timingSafe'); const router = express.Router(); @@ -33,9 +34,9 @@ function verifyImageToken(token) { const decoded = Buffer.from(data, 'base64').toString(); const [photoId, expires] = decoded.split(':'); - // Verify signature + // Verify signature (constant-time — avoids leaking the HMAC byte-by-byte) const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex'); - if (signature !== expectedSignature) { + if (!timingSafeEqualStr(signature, expectedSignature)) { return null; } diff --git a/backend/src/routes/secureImages.js b/backend/src/routes/secureImages.js index d47ab7c1..fcd7c065 100644 --- a/backend/src/routes/secureImages.js +++ b/backend/src/routes/secureImages.js @@ -1,6 +1,6 @@ const express = require('express'); const { db } = require('../database/db'); -const { verifyGalleryAccess } = require('../middleware/gallery'); +const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery'); const secureImageService = require('../services/secureImageService'); const secureImageMiddleware = require('../middleware/secureImageMiddleware'); const logger = require('../utils/logger'); @@ -23,7 +23,7 @@ router.post('/:slug/generate-token', async (req, res, next) => { // Add slug to request for verifyGalleryAccess req.requestedSlug = req.params.slug; next(); -}, verifyGalleryAccess, async (req, res) => { +}, verifyGalleryAccess, denySlideshowToken, async (req, res) => { try { const { photoId, accessType = 'view' } = req.body; @@ -273,6 +273,7 @@ router.get('/:slug/secure-download/:photoId/:token', next(); }, verifyGalleryAccess, + denySlideshowToken, async (req, res) => { try { const { photoId, token } = req.params; diff --git a/backend/src/services/mfaService.js b/backend/src/services/mfaService.js new file mode 100644 index 00000000..422a91a5 --- /dev/null +++ b/backend/src/services/mfaService.js @@ -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, +}; diff --git a/backend/src/services/picpeakExportService.js b/backend/src/services/picpeakExportService.js new file mode 100644 index 00000000..561f6f54 --- /dev/null +++ b/backend/src/services/picpeakExportService.js @@ -0,0 +1,231 @@ +'use strict'; + +// Portable ".picpeak" export — a single, self-describing archive that can be +// downloaded from one instance and re-uploaded to another via the web UI only +// (see picpeakImportService for the receiving half). +// +// Deliberately ENGINE-NEUTRAL: instead of a native pg_dump / sqlite .backup +// (which can only ever restore into the same engine and version), each table is +// written as NDJSON. The target rebuilds its own schema by running migrations, +// then loads these rows into it — so an older backup restores cleanly onto a +// newer target (forward-only), and pg↔pg / sqlite↔sqlite both work. +// +// This module is purely additive: it introduces a new artifact and touches no +// existing backup/restore path. + +const fs = require('fs'); +const fsp = require('fs').promises; +const path = require('path'); +const os = require('os'); +const crypto = require('crypto'); +const archiver = require('archiver'); +const { db } = require('../database/db'); +const knexConfig = require('../../knexfile'); +const { getStoragePath } = require('../config/storage'); +const logger = require('../utils/logger'); +const packageJson = require('../../package.json'); + +// Bump only on a breaking change to the on-disk layout below. +const PICPEAK_FORMAT_VERSION = 1; + +// Never exported as data — the target owns these (its own migrations set them). +const EXCLUDED_TABLES = new Set(['knex_migrations', 'knex_migrations_lock']); + +// Storage subdirs holding non-recalculable blobs — always included. +const DOC_DIRS = ['business-docs', 'uploads']; +// Original gallery photos — only when includePhotos is true (large; otherwise +// the admin re-uploads originals per gallery and previews are re-rendered). +const PHOTO_DIRS = ['events/active', 'events/archived']; + +const isPostgres = () => knexConfig.client === 'pg'; + +// db.raw returns `{ rows: [...] }` on Postgres and a bare array on SQLite. +const rawRows = (result) => (isPostgres() ? result.rows : result); + +// All user tables, minus knex bookkeeping. Introspected at runtime so the +// export never rots as tables are added (no hardcoded list to maintain). +async function listDataTables() { + let names; + if (isPostgres()) { + const result = await db.raw(` + SELECT table_name AS name + FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' + ORDER BY table_name + `); + names = rawRows(result).map((r) => r.name); + } else { + const result = await db.raw(` + SELECT name FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + ORDER BY name + `); + names = rawRows(result).map((r) => r.name); + } + return names.filter((n) => !EXCLUDED_TABLES.has(n)); +} + +// The latest applied migration — recorded in the manifest so the importer can +// refuse a backup that is NEWER than the target (forward-only guarantee). +async function getLatestMigration() { + try { + const rows = await db('knex_migrations').orderBy('id', 'desc').limit(1); + return rows[0]?.name || null; + } catch (_) { + return null; + } +} + +// Write one table to /.ndjson (one JSON object per line). +// Returns { rowCount, checksum } for the manifest. JSON.stringify serialises +// Dates to ISO strings, which re-import cleanly on both engines. +// +// Uses a plain select rather than knex `.stream()`: streaming on Postgres pulls +// in the optional `pg-query-stream` dependency (not bundled), so it throws on +// pg. A select works on both engines with no extra dependency. Rows are DB +// metadata (blobs live on disk under files/), so holding a table in memory is +// fine for the instance sizes PicPeak targets. +async function writeTableNdjson(table, dataDir) { + const outPath = path.join(dataDir, `${table}.ndjson`); + const hash = crypto.createHash('sha256'); + const rows = await db(table).select('*'); + const lines = rows.map((row) => { + const line = JSON.stringify(row); + hash.update(`${line}\n`); + return line; + }); + await fsp.writeFile(outPath, lines.length ? `${lines.join('\n')}\n` : '', 'utf8'); + return { rowCount: rows.length, checksum: hash.digest('hex') }; +} + +// Recursively collect files under a storage subdir as { abs, rel } where rel is +// relative to the storage root (so the importer restores the same layout). +async function collectDir(subdir, storageRoot, acc) { + const abs = path.join(storageRoot, subdir); + let entries; + try { + entries = await fsp.readdir(abs, { withFileTypes: true }); + } catch (_) { + return; // subdir may not exist on this install — skip silently + } + for (const entry of entries) { + const childRel = path.join(subdir, entry.name); + if (entry.isDirectory()) { + await collectDir(childRel, storageRoot, acc); + } else if (entry.isFile()) { + acc.push({ abs: path.join(storageRoot, childRel), rel: childRel }); + } + } +} + +async function collectFiles(includePhotos) { + const storageRoot = getStoragePath(); + const dirs = includePhotos ? [...DOC_DIRS, ...PHOTO_DIRS] : [...DOC_DIRS]; + const acc = []; + for (const d of dirs) { + await collectDir(d, storageRoot, acc); + } + return acc; +} + +/** + * Build a .picpeak archive. + * @param {Object} opts + * @param {boolean} [opts.includePhotos=false] include original gallery photos + * @param {string} [opts.outDir] where to write the file (defaults to a temp dir) + * @returns {Promise<{ filePath: string, manifest: object }>} + */ +async function createPicpeak({ includePhotos = false, outDir } = {}) { + const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-export-')); + const dataDir = path.join(staging, 'data'); + await fsp.mkdir(dataDir, { recursive: true }); + + try { + // 1. Dump every table to NDJSON, tracking counts + checksums. + const tables = await listDataTables(); + const tableMeta = {}; + for (const table of tables) { + tableMeta[table] = await writeTableNdjson(table, dataDir); + } + + // 2. Gather the non-recalculable blobs (PDFs, business-docs, uploads, and + // optionally original photos). + const files = await collectFiles(includePhotos); + + // 3. Manifest — everything the importer needs to validate + reconstruct. + const manifest = { + format: PICPEAK_FORMAT_VERSION, + kind: 'picpeak-backup', + created_at: new Date().toISOString(), + app_version: packageJson.version || null, + database: { + engine: isPostgres() ? 'pg' : 'sqlite', + latest_migration: await getLatestMigration(), + }, + options: { includePhotos: !!includePhotos }, + tables: tableMeta, + file_count: files.length, + // NOTE: contains secrets (SMTP password, admin hashes, API keys) in plain + // text — the download surface must warn about this. + contains_secrets: true, + }; + await fsp.writeFile( + path.join(staging, 'manifest.json'), + JSON.stringify(manifest, null, 2), + 'utf8' + ); + + // 4. Zip staging (manifest + data/) plus the blobs under files/. The final + // .picpeak lands in outDir (caller-managed) or a fresh temp dir; either + // way the NDJSON scratch (which holds plaintext secrets) is always + // removed in `finally` below. + const targetDir = outDir || (await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-out-'))); + await fsp.mkdir(targetDir, { recursive: true }); + const stamp = manifest.created_at.replace(/[:.]/g, '-'); + const filePath = path.join(targetDir, `picpeak-backup-${stamp}.picpeak`); + + try { + await new Promise((resolve, reject) => { + const output = fs.createWriteStream(filePath); + const archive = archiver('zip', { zlib: { level: 9 } }); + output.on('close', resolve); + output.on('error', reject); + archive.on('error', reject); + // Surface archiver warnings (e.g. a file vanished mid-run) instead of + // silently shipping an incomplete archive. + archive.on('warning', (err) => reject(err)); + archive.pipe(output); + archive.file(path.join(staging, 'manifest.json'), { name: 'manifest.json' }); + archive.directory(dataDir, 'data'); + for (const f of files) { + archive.file(f.abs, { name: path.posix.join('files', f.rel.split(path.sep).join('/')) }); + } + archive.finalize(); + }); + } catch (err) { + // Archiver failed → the partial .picpeak holds plaintext secrets and is + // useless; remove our own temp out dir so it isn't orphaned. A + // caller-supplied outDir is left untouched. + if (!outDir) await fsp.rm(targetDir, { recursive: true, force: true }).catch(() => {}); + throw err; + } + + logger.info( + `[picpeak-export] wrote ${filePath} (${tables.length} tables, ${files.length} files, includePhotos=${!!includePhotos})` + ); + return { filePath, manifest }; + } finally { + // Always remove the NDJSON scratch dir — it contains a plaintext dump of + // every table (secrets included). The final .picpeak is elsewhere. + await fsp.rm(staging, { recursive: true, force: true }).catch(() => {}); + } +} + +module.exports = { + PICPEAK_FORMAT_VERSION, + EXCLUDED_TABLES, + createPicpeak, + // exported for reuse/testing + listDataTables, + collectFiles, +}; diff --git a/backend/src/services/picpeakImportService.js b/backend/src/services/picpeakImportService.js new file mode 100644 index 00000000..b1a6b0f3 --- /dev/null +++ b/backend/src/services/picpeakImportService.js @@ -0,0 +1,271 @@ +'use strict'; + +// Receiving half of the GUI-only backup roundtrip: takes a ".picpeak" produced +// by picpeakExportService and restores it onto THIS instance. +// +// Restore semantics (agreed design): FULL OVERRIDE — every table is wiped and +// replaced by the backup's rows — EXCEPT the current logged-in admin account, +// which is preserved so the operator is never locked out. A backup admin whose +// email collides with the current account is overwritten with the current +// account's credentials (so the operator's known password keeps working). +// +// Same-engine only (pg↔pg / sqlite↔sqlite) and forward-only (an older backup +// restores onto a newer instance; a newer backup is refused). The target's own +// schema is used as-is — we never replay the backup's DDL. + +const fs = require('fs'); +const fsp = require('fs').promises; +const path = require('path'); +const os = require('os'); +const StreamZip = require('node-stream-zip'); +const { db } = require('../database/db'); +const knexConfig = require('../../knexfile'); +const { getStoragePath } = require('../config/storage'); +const { hasColumnCached } = require('../utils/schemaCache'); +const logger = require('../utils/logger'); +const { PICPEAK_FORMAT_VERSION, EXCLUDED_TABLES, listDataTables } = require('./picpeakExportService'); + +const isPostgres = () => knexConfig.client === 'pg'; + +// Compare migrations by their numeric filename prefix (001_, 107_, 129_ …). +function migrationOrder(name) { + const m = String(name || '').match(/^(\d+)/); + return m ? parseInt(m[1], 10) : -1; +} + +async function readManifestFromZip(picpeakPath) { + const zip = new StreamZip.async({ file: picpeakPath }); + try { + return JSON.parse((await zip.entryData('manifest.json')).toString('utf8')); + } finally { + await zip.close(); + } +} + +// Returns an array of human-readable blockers ([] = OK to restore). +async function validateManifest(manifest) { + const errors = []; + if (!manifest || manifest.kind !== 'picpeak-backup') { + return ['This file is not a PicPeak backup (.picpeak).']; + } + if (Number(manifest.format) > PICPEAK_FORMAT_VERSION) { + errors.push('This backup was created by a newer version of PicPeak. Update this instance first.'); + } + const engine = isPostgres() ? 'pg' : 'sqlite'; + if (manifest.database && manifest.database.engine && manifest.database.engine !== engine) { + errors.push(`Database engine mismatch: the backup is "${manifest.database.engine}" but this instance is "${engine}". Restore is only supported between matching engines.`); + } + // Forward-only: the target schema must be at least as new as the backup's. + let targetLatest = null; + try { + const applied = await db('knex_migrations').orderBy('id', 'desc').limit(1); + targetLatest = applied[0] ? applied[0].name : null; + } catch (_) { + // No knex_migrations table (e.g. some test harnesses) — skip the check. + } + const backupLatest = manifest.database ? manifest.database.latest_migration : null; + if (backupLatest && targetLatest && migrationOrder(backupLatest) > migrationOrder(targetLatest)) { + errors.push('This backup is from a newer database schema than this instance. Update this instance to at least the backup version before restoring.'); + } + return errors; +} + +function parseNdjson(filePath) { + if (!fs.existsSync(filePath)) return []; + return fs + .readFileSync(filePath, 'utf8') + .split('\n') + .filter((l) => l.trim().length > 0) + .map((l) => JSON.parse(l)); +} + +// Re-insert the operator's account inside the restore transaction so they keep +// working credentials. If the backup already loaded an admin with the same +// email, overwrite that row's credentials with the current account's (current +// creds win); otherwise insert the snapshot with a fresh id. +async function reinjectCurrentAdmin(trx, currentAdmin) { + if (!currentAdmin) return; + const existing = await trx('admin_users').whereRaw('lower(email) = lower(?)', [currentAdmin.email]).first(); + if (existing) { + await trx('admin_users').where({ id: existing.id }).update({ + password_hash: currentAdmin.password_hash, + is_active: currentAdmin.is_active, + must_change_password: currentAdmin.must_change_password, + }); + } else { + const row = { ...currentAdmin }; + delete row.id; // let the engine assign a fresh id to avoid collision + await trx('admin_users').insert(row); + } +} + +// The json/jsonb columns of a table (Postgres only). The pg driver returns +// jsonb as parsed JS values, so on re-insert they must be serialised back to +// valid JSON text — otherwise a scalar like the string "PicPeak" is sent +// unquoted and pg rejects it ("invalid input syntax for type json"). +async function jsonColumnsFor(trx, table) { + if (!isPostgres()) return new Set(); + const res = await trx.raw( + "SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? AND data_type IN ('json', 'jsonb')", + [table] + ); + return new Set(res.rows.map((r) => r.column_name)); +} + +function serialiseJsonColumns(rows, jsonCols) { + if (!jsonCols.size) return rows; + return rows.map((row) => { + const out = { ...row }; + for (const col of jsonCols) { + if (out[col] !== undefined && out[col] !== null) out[col] = JSON.stringify(out[col]); + } + return out; + }); +} + +// Whole-DB replace in one transaction with FK enforcement suspended (pg: +// session_replication_role=replica on the trx connection, reset before commit; +// sqlite: defer_foreign_keys so checks run at commit). knex_migrations is never +// in the data set, so the target's schema/migration state is left intact. +async function replaceAllTables(tables, dataDir, currentAdmin) { + await db.transaction(async (trx) => { + if (isPostgres()) { + try { + await trx.raw("SET session_replication_role = 'replica'"); + } catch (_) { + // session_replication_role requires a Postgres SUPERUSER. The bundled + // postgres image's role is one; managed Postgres (RDS / Cloud SQL / …) + // app users usually are not. Fail fast with a clear message BEFORE any + // rows are deleted — the transaction rolls back, so nothing is wiped. + const err = new Error( + 'Restore needs a PostgreSQL superuser to suspend foreign-key checks during the full replace, but this instance’s database user is not a superuser (common on managed Postgres such as RDS or Cloud SQL). Restore onto the bundled Postgres, or grant the role superuser for the restore.' + ); + err.statusCode = 400; + throw err; + } + } else { + await trx.raw('PRAGMA defer_foreign_keys = ON'); + } + + for (const table of tables) { + await trx(table).del(); + } + for (const table of tables) { + const rows = parseNdjson(path.join(dataDir, `${table}.ndjson`)); + if (!rows.length) continue; + const jsonCols = await jsonColumnsFor(trx, table); + await trx.batchInsert(table, serialiseJsonColumns(rows, jsonCols), 100); + } + + await reinjectCurrentAdmin(trx, currentAdmin); + + // Reset the pg session flag BEFORE the connection returns to the pool. + if (isPostgres()) await trx.raw("SET session_replication_role = 'origin'"); + }); +} + +// Copy the archive's files/ tree into storage, overwriting existing files. +async function restoreFiles(stagingDir) { + const src = path.join(stagingDir, 'files'); + if (!fs.existsSync(src)) return 0; + const storageRoot = getStoragePath(); + let count = 0; + async function walk(rel) { + const abs = path.join(src, rel); + for (const entry of await fsp.readdir(abs, { withFileTypes: true })) { + const childRel = path.join(rel, entry.name); + if (entry.isDirectory()) { + await walk(childRel); + } else if (entry.isFile()) { + const dest = path.join(storageRoot, childRel); + await fsp.mkdir(path.dirname(dest), { recursive: true }); + await fsp.copyFile(path.join(src, childRel), dest); + count += 1; + } + } + } + await walk(''); + return count; +} + +// Does the restored data reference an external-media library? If so the caller +// shows a banner telling the admin to (re)configure the external-media mount on +// this instance — those files are NOT in the backup by design. +async function detectExternalMedia() { + try { + if (await hasColumnCached('events', 'external_path')) { + const row = await db('events').whereNotNull('external_path').first(); + if (row) return true; + } + if (await hasColumnCached('photos', 'external_relpath')) { + const row = await db('photos').whereNotNull('external_relpath').first(); + if (row) return true; + } + } catch (_) { + // Best-effort — a detection miss is not worth failing the restore. + } + return false; +} + +/** + * Restore a .picpeak onto this instance. + * @param {Object} opts + * @param {string} opts.picpeakPath path to the uploaded/staged .picpeak + * @param {number} [opts.currentAdminId] admin to preserve across the wipe + * @returns {Promise<{restored:boolean, tables:number, filesRestored:number, usesExternalMedia:boolean, manifest:object}>} + */ +async function importFromPicpeak({ picpeakPath, currentAdminId }) { + const manifest = await readManifestFromZip(picpeakPath); + const blockers = await validateManifest(manifest); + if (blockers.length) { + const err = new Error(blockers[0]); + err.statusCode = 400; + err.validation = blockers; + throw err; + } + + const currentAdmin = currentAdminId + ? await db('admin_users').where({ id: currentAdminId }).first() + : null; + + const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-import-')); + try { + const zip = new StreamZip.async({ file: picpeakPath }); + try { + await zip.extract(null, staging); + } finally { + await zip.close(); + } + + const dataDir = path.join(staging, 'data'); + // Only touch tables that (a) the uploaded manifest lists AND (b) actually + // exist as real tables in THIS database. listDataTables() already excludes + // knex_migrations/_lock (EXCLUDED_TABLES), so a crafted or corrupted + // .picpeak can never make the restore delete the migration bookkeeping — or + // any table that isn't a genuine data table here. + const dbTables = new Set(await listDataTables()); + const manifestTables = Object.keys(manifest.tables || {}); + const tables = manifestTables.filter((tbl) => dbTables.has(tbl) && !EXCLUDED_TABLES.has(tbl)); + const skipped = manifestTables.filter((tbl) => !tables.includes(tbl)); + if (skipped.length) { + logger.warn(`[picpeak-import] ignoring ${skipped.length} backup table(s) not present in this DB (or protected): ${skipped.join(', ')}`); + } + + await replaceAllTables(tables, dataDir, currentAdmin); + const filesRestored = await restoreFiles(staging); + const usesExternalMedia = await detectExternalMedia(); + + logger.info( + `[picpeak-import] restored ${tables.length} tables, ${filesRestored} files (externalMedia=${usesExternalMedia})` + ); + return { restored: true, tables: tables.length, filesRestored, usesExternalMedia, manifest }; + } finally { + await fsp.rm(staging, { recursive: true, force: true }).catch(() => {}); + } +} + +module.exports = { + importFromPicpeak, + readManifestFromZip, + validateManifest, +}; diff --git a/backend/src/utils/rateLimitSecurity.js b/backend/src/utils/rateLimitSecurity.js index d7614f6a..0ba002c4 100644 --- a/backend/src/utils/rateLimitSecurity.js +++ b/backend/src/utils/rateLimitSecurity.js @@ -32,7 +32,7 @@ function hasValidAdminToken(req) { // Critical: Verify token is valid before skipping rate limit // This prevents invalid tokens from bypassing rate limiting - const decoded = jwt.verify(token, process.env.JWT_SECRET); + const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); // Additional validation if (!decoded || typeof decoded !== 'object') { diff --git a/backend/src/utils/timingSafe.js b/backend/src/utils/timingSafe.js new file mode 100644 index 00000000..9fb4b2e7 --- /dev/null +++ b/backend/src/utils/timingSafe.js @@ -0,0 +1,22 @@ +const crypto = require('crypto'); + +/** + * Constant-time string comparison for secrets (share tokens, HMAC + * signatures, etc.). Returns false for non-strings or length mismatch + * without leaking timing beyond the (non-secret) length. Prevents an + * attacker from recovering a token byte-by-byte via response-time + * differences of a naive `a === b`. + */ +function timingSafeEqualStr(a, b) { + if (typeof a !== 'string' || typeof b !== 'string') { + return false; + } + const ab = Buffer.from(a); + const bb = Buffer.from(b); + if (ab.length !== bb.length) { + return false; + } + return crypto.timingSafeEqual(ab, bb); +} + +module.exports = { timingSafeEqualStr }; diff --git a/frontend/package.json b/frontend/package.json index 39012c25..1f11d6f3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "picpeak-frontend", "private": true, - "version": "3.79.1-beta.0", + "version": "3.80.0-beta.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/admin/PicpeakBackupCard.tsx b/frontend/src/components/admin/PicpeakBackupCard.tsx new file mode 100644 index 00000000..10ae1e60 --- /dev/null +++ b/frontend/src/components/admin/PicpeakBackupCard.tsx @@ -0,0 +1,219 @@ +import React, { useRef, useState } from 'react'; +import { Download, Upload, AlertTriangle, ShieldAlert, ExternalLink, CheckCircle2 } from 'lucide-react'; +import { toast } from 'react-toastify'; +import { useTranslation } from 'react-i18next'; + +import { Button, Card } from '../common'; +import { api } from '../../config/api'; + +// Portable ".picpeak" roundtrip, split across two Backup Manager tabs: +// - PicpeakExportCard → Dashboard (making a backup) +// - PicpeakRestoreCard → Restore (restoring a backup) +// The manifest is bundled inside the .picpeak, so there is no separate +// "manifest only" download here. + +interface RestoreResult { + tables: number; + filesRestored: number; + usesExternalMedia: boolean; +} + +// ── Download half (Dashboard) ──────────────────────────────────────────────── +export const PicpeakExportCard: React.FC = () => { + const { t } = useTranslation(); + const [includePhotos, setIncludePhotos] = useState(false); + const [downloading, setDownloading] = useState(false); + + const handleDownload = async () => { + setDownloading(true); + try { + const res = await api.get('/admin/backup/picpeak/export', { + params: { includePhotos }, + responseType: 'blob', + }); + const cd = (res.headers['content-disposition'] as string) || ''; + const match = cd.match(/filename="?([^"]+)"?/); + const filename = (match && match[1]) || 'picpeak-backup.picpeak'; + const url = window.URL.createObjectURL(res.data as Blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + } catch (_) { + toast.error(t('backup.picpeak.downloadFailed', 'Could not create the backup file.')); + } finally { + setDownloading(false); + } + }; + + return ( + +

+ {t('backup.picpeak.title', 'Portable backup (.picpeak)')} +

+

+ {t('backup.picpeak.intro', 'Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.')} +

+ +
+ +
+ +

+ {t('backup.picpeak.secretsWarning', 'This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.')} +

+
+ +
+
+ ); +}; + +PicpeakExportCard.displayName = 'PicpeakExportCard'; + +// ── Restore half (Restore tab) ─────────────────────────────────────────────── +export const PicpeakRestoreCard: React.FC = () => { + const { t } = useTranslation(); + const fileRef = useRef(null); + const [pendingFile, setPendingFile] = useState(null); + const [restoring, setRestoring] = useState(false); + const [result, setResult] = useState(null); + + const onFilePick = (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (f) setPendingFile(f); + e.target.value = ''; // let the user re-pick the same file after cancelling + }; + + const confirmRestore = async () => { + if (!pendingFile) return; + setRestoring(true); + try { + const fd = new FormData(); + fd.append('backup', pendingFile); + const res = await api.post('/admin/backup/picpeak/import', fd); + setResult(res.data); + setPendingFile(null); + toast.success(t('backup.picpeak.restoreDone', 'Backup restored.')); + } catch (e: any) { + const msg = e.response?.data?.error || t('backup.picpeak.restoreFailed', 'Restore failed.'); + toast.error(msg); + setPendingFile(null); + } finally { + setRestoring(false); + } + }; + + return ( + +

+ {t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')} +

+

+ {t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')} +

+ + + + {result && ( +
+
+ +
+

+ {t('backup.picpeak.restoreDone', 'Backup restored.')} +

+

+ {t('backup.picpeak.restoreSummary', '{{tables}} tables and {{files}} files restored.', { + tables: result.tables, + files: result.filesRestored, + })} +

+ {result.usesExternalMedia && ( +

+ + + {t('backup.picpeak.externalMediaNote', 'This backup references an external-media library. Make sure external-media routing is configured on this instance.')}{' '} + + {t('backup.picpeak.externalMediaLink', 'Setup guide')} + + + +

+ )} + +
+
+
+ )} + + {/* Destructive confirmation */} + {pendingFile && ( +
+
+
+ +
+

+ {t('backup.picpeak.confirmTitle', 'Restore will delete all current data')} +

+

+ {t('backup.picpeak.confirmBody', 'This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.')} +

+

{pendingFile.name}

+
+
+
+ + +
+
+
+ )} +
+ ); +}; + +PicpeakRestoreCard.displayName = 'PicpeakRestoreCard'; diff --git a/frontend/src/components/admin/RestoreWizard.jsx b/frontend/src/components/admin/RestoreWizard.jsx index 74f6b047..87c72296 100644 --- a/frontend/src/components/admin/RestoreWizard.jsx +++ b/frontend/src/components/admin/RestoreWizard.jsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { PicpeakRestoreCard } from './PicpeakBackupCard'; import { RefreshCw, AlertTriangle, @@ -283,12 +284,47 @@ export const RestoreWizard = ({ onVerifyIntegrity } = {}) => { )} {restoreData.source === 'upload' && ( - -
- -

{t('backup.restore.source.upload.comingSoon')}

+
+ {/* Two upload kinds: the working portable .picpeak restore, and the + legacy manifest+files upload (still a stub). */} +
+ +
- + + {restoreData.uploadType === 'picpeak' && } + + {restoreData.uploadType === 'manifest' && ( + +
+ +

{t('backup.restore.source.upload.manifestComingSoon', 'Manifest Upload functionality coming soon')}

+
+
+ )} +
)}
); diff --git a/frontend/src/components/admin/SetupConfigStep.tsx b/frontend/src/components/admin/SetupConfigStep.tsx new file mode 100644 index 00000000..b6a3a200 --- /dev/null +++ b/frontend/src/components/admin/SetupConfigStep.tsx @@ -0,0 +1,153 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { ShieldAlert } from 'lucide-react'; + +import { Button, Input } from '../common'; +import type { FeatureKey } from '../../services/featureFlags.service'; +import { businessProfileService } from '../../services/businessProfile.service'; +import { emailService, type EmailConfig } from '../../services/email.service'; + +// Features that need working SMTP to deliver anything. +const EMAIL_FEATURES: FeatureKey[] = ['reminderEmails', 'incomingMail', 'whatsapp', 'bills']; + +interface Props { + selectedFeatures: Set; + onDone: () => void; +} + +// Lean per-feature config, shown after the "How will you use PicPeak?" step. +// Only the sections a selected feature actually needs are rendered; everything +// else keeps its seeded defaults and is tunable later in Settings. Saving is +// best-effort per section — a failure never traps the user on setup. +export const SetupConfigStep: React.FC = ({ selectedFeatures, onDone }) => { + const { t } = useTranslation(); + const showInvoicing = selectedFeatures.has('bills'); + const showEmail = EMAIL_FEATURES.some((f) => selectedFeatures.has(f)); + const [saving, setSaving] = useState(false); + + const [inv, setInv] = useState({ + companyName: '', addressLine1: '', postalCode: '', city: '', countryCode: '', + vatId: '', taxId: '', defaultCurrency: 'CHF', iban: '', + }); + const [mail, setMail] = useState({ + smtp_host: '', smtp_port: '587', smtp_user: '', smtp_pass: '', from_email: '', from_name: '', + }); + + const invField = (k: keyof typeof inv) => (e: React.ChangeEvent) => + setInv((p) => ({ ...p, [k]: e.target.value })); + const mailField = (k: keyof typeof mail) => (e: React.ChangeEvent) => + setMail((p) => ({ ...p, [k]: e.target.value })); + + const finish = async () => { + setSaving(true); + try { + // Invoicing: only persist if they actually started filling it in. + if (showInvoicing && inv.companyName.trim()) { + await businessProfileService.update({ + companyName: inv.companyName.trim(), + addressLine1: inv.addressLine1.trim(), + postalCode: inv.postalCode.trim(), + city: inv.city.trim(), + countryCode: inv.countryCode.trim(), + vatId: inv.vatId.trim(), + taxId: inv.taxId.trim(), + defaultCurrency: inv.defaultCurrency.trim() || 'CHF', + }); + if (inv.iban.trim()) { + await businessProfileService.createBankAccount({ + iban: inv.iban.replace(/\s+/g, ''), + accountHolder: inv.companyName.trim(), + currency: inv.defaultCurrency.trim() || 'CHF', + isDefault: true, + }); + } + } + // Email: only persist if a host was entered. + if (showEmail && mail.smtp_host.trim()) { + const port = parseInt(mail.smtp_port, 10) || 587; + const config: EmailConfig = { + smtp_host: mail.smtp_host.trim(), + smtp_port: port, + smtp_secure: port === 465, + smtp_user: mail.smtp_user.trim(), + smtp_pass: mail.smtp_pass, + from_email: mail.from_email.trim(), + from_name: mail.from_name.trim(), + tls_reject_unauthorized: true, + }; + await emailService.updateConfig(config); + } + } catch (_) { + toast.warn(t('setup.config.saveFailed', 'Some settings could not be saved — you can finish them in Settings.')); + } finally { + setSaving(false); + onDone(); + } + }; + + return ( +
+

+ {t('setup.config.intro', 'A few details for the features you picked. Anything you skip keeps its default and can be set later in Settings.')} +

+ + {showInvoicing && ( +
+

{t('setup.config.invoicing', 'Invoicing details')}

+
+ +

+ {t('setup.config.invoicingDisclaimer', 'Used on your invoices. Bank/IBAN and VAT details are your responsibility — verify them with your bank and Treuhänder/tax advisor.')} +

+
+ + +
+ +
+
+
+ + +
+
+ + +
+ +
+ )} + + {showEmail && ( +
+

{t('setup.config.email', 'Email delivery (SMTP)')}

+

{t('setup.config.emailHint', 'Required to send reminders, invoices and notifications.')}

+
+
+ +
+
+ + +
+
+ + +
+
+ )} + +
+ + +
+
+ ); +}; + +SetupConfigStep.displayName = 'SetupConfigStep'; diff --git a/frontend/src/components/common/AuthenticatedImage.tsx b/frontend/src/components/common/AuthenticatedImage.tsx index 71ad2c2f..ea862703 100644 --- a/frontend/src/components/common/AuthenticatedImage.tsx +++ b/frontend/src/components/common/AuthenticatedImage.tsx @@ -137,18 +137,26 @@ export const AuthenticatedImage: React.FC = ({ throw new Error('No URL provided'); } - // Build full URL for the image + // Build full URL for the image. Only relative paths are app-owned; + // an absolute URL is passed through untouched. + const isRelative = rawUrl.startsWith('/'); const fullImageUrl = rawUrl.startsWith('/admin') ? buildResourceUrl(`/api${rawUrl}`) - : rawUrl.startsWith('/') + : isRelative ? buildResourceUrl(rawUrl) : rawUrl; const headers: Record = {}; - const slugForRequest = resolveSlug(rawUrl); - const token = getGalleryToken(slugForRequest); - if (token) { - headers.Authorization = `Bearer ${token}`; + // Attach the gallery bearer token ONLY to relative (same-app) image + // paths. Never send it to an absolute/external URL — that would leak + // gallery credentials cross-origin. AuthenticatedImage does not + // support external URLs by design. + if (isRelative) { + const slugForRequest = resolveSlug(rawUrl); + const token = getGalleryToken(slugForRequest); + if (token) { + headers.Authorization = `Bearer ${token}`; + } } const response = await fetch(fullImageUrl, { diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts index d2e609ae..697afea4 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -56,11 +56,19 @@ api.interceptors.request.use( const pathname = rawPath.startsWith('/') ? rawPath : `/${rawPath}`; - const isGalleryEndpoint = /^\/gallery\//.test(pathname) - || /^\/secure-images\//.test(pathname) - || /^\/auth\/gallery\//.test(pathname); + // Never attach the gallery token to an absolute URL. Requests to the + // app's own API use relative paths (axios prepends baseURL); an + // absolute URL could point at any origin, and extracting its + // `/gallery/...` pathname would otherwise match below and leak the + // bearer token cross-origin. + const isAbsoluteUrl = /^https?:\/\//i.test(config.url || ''); - const isGallerySessionCheck = pathname === '/auth/session' + const isGalleryEndpoint = !isAbsoluteUrl && ( + /^\/gallery\//.test(pathname) + || /^\/secure-images\//.test(pathname) + || /^\/auth\/gallery\//.test(pathname)); + + const isGallerySessionCheck = !isAbsoluteUrl && pathname === '/auth/session' && (!!paramSlug || window.location.pathname.startsWith('/gallery/')); if (isGalleryEndpoint || isGallerySessionCheck) { diff --git a/frontend/src/features/settings/components/MfaSettingsCard.tsx b/frontend/src/features/settings/components/MfaSettingsCard.tsx new file mode 100644 index 00000000..7bf0a58d --- /dev/null +++ b/frontend/src/features/settings/components/MfaSettingsCard.tsx @@ -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 = ({ 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 ( +
+
+ +

{t('settings.mfa.recoveryCodesWarning')}

+
+ +
+ {codes.map((code) => ( + {code} + ))} +
+ +
+ + +
+ + + + +
+ ); +}; + +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> | null>(null); + const [enableCode, setEnableCode] = useState(''); + const [enableError, setEnableError] = useState(null); + + // Recovery codes to display once (after enable or regenerate) + const [recoveryCodes, setRecoveryCodes] = useState(null); + + // Regenerate flow state + const [showRegenerate, setShowRegenerate] = useState(false); + const [regenerateCode, setRegenerateCode] = useState(''); + const [regenerateError, setRegenerateError] = useState(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 ( + +
+ +

{t('settings.mfa.title')}

+
+

{t('settings.mfa.description')}

+ + {isLoading ? ( +
+ +
+ ) : recoveryCodes ? ( + setRecoveryCodes(null)} /> + ) : status?.enabled ? ( + /* ---------------- Enrolled ---------------- */ +
+
+ + {t('settings.mfa.enabledBadge')} +
+ +

+ {t('settings.mfa.recoveryCodesRemaining', { count: status.recoveryCodesRemaining })} +

+ + {showRegenerate ? ( +
+

{t('settings.mfa.regenerateHelp')}

+ { + setRegenerateCode(e.target.value); + if (regenerateError) setRegenerateError(null); + }} + placeholder={t('settings.mfa.codePlaceholder')} + leftIcon={} + error={regenerateError || undefined} + autoComplete="one-time-code" + /> +
+ + +
+
+ ) : ( +
+ + +
+ )} +
+ ) : setupData ? ( + /* ---------------- Setup in progress ---------------- */ +
+

{t('settings.mfa.setupScanInstruction')}

+
+ {t('settings.mfa.qrAlt')} +
+

{t('settings.mfa.manualEntry')}

+ + {setupData.secret} + +
+
+ +
+ + { + setEnableCode(e.target.value); + if (enableError) setEnableError(null); + }} + placeholder={t('settings.mfa.codePlaceholder')} + leftIcon={} + error={enableError || undefined} + inputMode="numeric" + autoComplete="one-time-code" + /> +
+ +
+ + +
+
+ ) : ( + /* ---------------- Not enrolled ---------------- */ +
+

{t('settings.mfa.notEnrolled')}

+ +
+ )} +
+ ); +}; diff --git a/frontend/src/features/settings/hooks/useSettingsState.ts b/frontend/src/features/settings/hooks/useSettingsState.ts index 7344907e..b8977d55 100644 --- a/frontend/src/features/settings/hooks/useSettingsState.ts +++ b/frontend/src/features/settings/hooks/useSettingsState.ts @@ -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({ 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), diff --git a/frontend/src/features/settings/tabs/GeneralTab.tsx b/frontend/src/features/settings/tabs/GeneralTab.tsx index 85e63de3..d250570e 100644 --- a/frontend/src/features/settings/tabs/GeneralTab.tsx +++ b/frontend/src/features/settings/tabs/GeneralTab.tsx @@ -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 = ({ )}
+ {/* Per-user two-factor authentication (issue #738) — lives beside the + admin's own account details rather than the admin-wide Security tab. */} + +

{t('settings.general.siteConfiguration')}

diff --git a/frontend/src/features/settings/tabs/SecurityTab.tsx b/frontend/src/features/settings/tabs/SecurityTab.tsx index 79675bf0..cb338f13 100644 --- a/frontend/src/features/settings/tabs/SecurityTab.tsx +++ b/frontend/src/features/settings/tabs/SecurityTab.tsx @@ -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 = ({ - +
+
+ +
+

{t('settings.security.twoFactorTitle')}

+

{t('settings.security.twoFactorNote')}

+
+
+
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index c231bd27..633af451 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -634,7 +634,16 @@ "upload": { "name": "Backup hochladen", "description": "Eine Backup-Datei hochladen", - "comingSoon": "Upload-Funktion kommt bald" + "comingSoon": "Upload-Funktion kommt bald", + "manifestComingSoon": "Manifest-Upload-Funktion kommt bald", + "picpeak": { + "name": ".picpeak-Backup", + "description": "Portables Voll-Backup — stellt alles wieder her (vollständige Überschreibung, Ihr aktuelles Konto bleibt erhalten)." + }, + "manifest": { + "name": "Manifest + Dateien", + "description": "Ein Manifest und die zugehörigen Backup-Dateien hochladen (Legacy-Format)." + } }, "configuration": { "s3": "S3-Konfiguration", @@ -1413,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", @@ -1779,7 +1789,27 @@ "title": "E-Mail-Einstellungen" }, "backup": { - "title": "Backup" + "title": "Backup", + "picpeak": { + "title": "Portables Backup (.picpeak)", + "intro": "Laden Sie eine einzelne, in sich geschlossene Datei herunter und laden Sie sie auf einer anderen Instanz hoch, um diese zu klonen — komplett im Browser.", + "includePhotos": "Original-Galeriefotos einschließen (größere Datei)", + "secretsWarning": "Diese Datei enthält Geheimnisse im Klartext (E-Mail-Passwort, Admin-Zugangsdaten, API-Schlüssel). Bewahren Sie sie sicher auf und übertragen Sie sie nur über vertrauenswürdige Kanäle.", + "download": ".picpeak herunterladen", + "downloadFailed": "Die Backup-Datei konnte nicht erstellt werden.", + "restoreTitle": "Aus einer .picpeak wiederherstellen", + "restoreIntro": "Laden Sie eine .picpeak von dieser oder einer anderen Instanz hoch. Nur dieselbe Datenbank-Engine.", + "chooseFile": ".picpeak-Datei auswählen…", + "restoreDone": "Backup wiederhergestellt.", + "restoreFailed": "Wiederherstellung fehlgeschlagen.", + "restoreSummary": "{{tables}} Tabellen und {{files}} Dateien wiederhergestellt.", + "externalMediaNote": "Dieses Backup verweist auf eine externe Medienbibliothek. Stellen Sie sicher, dass das externe Medien-Routing auf dieser Instanz konfiguriert ist.", + "externalMediaLink": "Einrichtungsanleitung", + "reload": "App neu laden", + "confirmTitle": "Die Wiederherstellung löscht alle aktuellen Daten", + "confirmBody": "Dies ersetzt ALLE Daten auf dieser Instanz dauerhaft durch das hochgeladene Backup, mit Ausnahme Ihres aktuellen Kontos. Dies kann nicht rückgängig gemacht werden.", + "confirmRestore": "Löschen & wiederherstellen" + } }, "branding": { "title": "Branding" @@ -2011,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": { @@ -3467,6 +3533,45 @@ "subtitle": "Erstellen Sie Ihr Administrator-Konto, um loszulegen", "tokenStepSubtitle": "Geben Sie zunächst Ihren einmaligen Setup-Token ein", "accountStepSubtitle": "Erstellen Sie nun Ihr Administrator-Konto", + "usageSubtitle": "Wie möchten Sie PicPeak nutzen?", + "usageAlwaysOn": "Galerien, Analysen und Benutzerverwaltung sind immer enthalten. Wählen Sie unten optionale Funktionen — Sie können dies jederzeit in den Einstellungen ändern.", + "usageGroupCrm": "Kundenverwaltung", + "usageGroupAccounting": "Buchhaltung", + "usageGroupAutomation": "Automatisierung & Versand", + "usageDepsNote": "Rechnungen aktivieren automatisch den Buchhaltungsbereich.", + "usageSkip": "Nur mit Galerien fortfahren", + "finish": "Einrichtung abschließen", + "featuresSaveFailed": "Ihre Funktionsauswahl konnte nicht gespeichert werden — Sie können sie später unter Einstellungen → Funktionen festlegen.", + "restoreStepSubtitle": "Aus einem Backup wiederherstellen", + "restoreEntry": "Wechsel von einer anderen PicPeak-Instanz?", + "restoreEntryHint": "Stellen Sie stattdessen ein .picpeak-Backup wieder her, anstatt neu einzurichten.", + "restoreIntro": "Laden Sie ein .picpeak-Backup hoch, um eine andere Instanz auf diese zu klonen. Dies ersetzt alles außer dem gerade erstellten Konto.", + "config": { + "subtitle": "Richten Sie Ihre Funktionen ein", + "intro": "Einige Angaben zu den gewählten Funktionen. Was Sie überspringen, behält den Standard und kann später in den Einstellungen festgelegt werden.", + "invoicing": "Rechnungsdaten", + "invoicingDisclaimer": "Erscheint auf Ihren Rechnungen. Bank-/IBAN- und Mehrwertsteuerangaben liegen in Ihrer Verantwortung — prüfen Sie sie mit Ihrer Bank und Ihrem Treuhänder/Steuerberater.", + "companyName": "Firma / rechtlicher Name", + "addressLine1": "Strasse und Nummer", + "postalCode": "PLZ", + "city": "Ort", + "countryCode": "Ländercode (z. B. CH)", + "currency": "Währung (z. B. CHF)", + "vatId": "MwSt-Nummer (oder leer lassen)", + "taxId": "Steuernummer (oder MwSt-Nummer)", + "iban": "IBAN (für Rechnungszahlungen)", + "email": "E-Mail-Versand (SMTP)", + "emailHint": "Erforderlich, um Erinnerungen, Rechnungen und Benachrichtigungen zu senden.", + "smtpHost": "SMTP-Host", + "smtpPort": "Port", + "smtpUser": "Benutzername", + "smtpPass": "Passwort", + "fromEmail": "Absenderadresse", + "fromName": "Absendername", + "skip": "Vorerst überspringen", + "finish": "Einrichtung abschließen", + "saveFailed": "Einige Einstellungen konnten nicht gespeichert werden — Sie können sie in den Einstellungen abschließen." + }, "stepOf": "Schritt {{current}} von {{total}}", "continue": "Weiter", "back": "Zurück", @@ -3517,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: admin@example.com, Passwort: admin123" + "devModeHint": "Entwicklungsmodus: E-Mail: admin@example.com, 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", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e5c89510..fbb30759 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", @@ -1335,7 +1336,27 @@ "title": "Email Settings" }, "backup": { - "title": "Backup" + "title": "Backup", + "picpeak": { + "title": "Portable backup (.picpeak)", + "intro": "Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.", + "includePhotos": "Include original gallery photos (larger file)", + "secretsWarning": "This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.", + "download": "Download .picpeak", + "downloadFailed": "Could not create the backup file.", + "restoreTitle": "Restore from a .picpeak", + "restoreIntro": "Upload a .picpeak taken from this or another instance. Same database engine only.", + "chooseFile": "Choose .picpeak file…", + "restoreDone": "Backup restored.", + "restoreFailed": "Restore failed.", + "restoreSummary": "{{tables}} tables and {{files}} files restored.", + "externalMediaNote": "This backup references an external-media library. Make sure external-media routing is configured on this instance.", + "externalMediaLink": "Setup guide", + "reload": "Reload app", + "confirmTitle": "Restore will delete all current data", + "confirmBody": "This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.", + "confirmRestore": "Delete & restore" + } }, "branding": { "title": "Branding" @@ -1567,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": { @@ -3002,7 +3059,16 @@ "upload": { "name": "Upload Backup", "description": "Upload a backup file", - "comingSoon": "Upload functionality coming soon" + "comingSoon": "Upload functionality coming soon", + "manifestComingSoon": "Manifest Upload functionality coming soon", + "picpeak": { + "name": ".picpeak backup", + "description": "Portable full backup — restores everything (full override, keeps your current account)." + }, + "manifest": { + "name": "Manifest + files", + "description": "Upload a manifest and its backup files (legacy format)." + } }, "configuration": { "s3": "S3 Configuration", @@ -3363,6 +3429,45 @@ "subtitle": "Create your administrator account to get started", "tokenStepSubtitle": "First, enter your one-time setup token", "accountStepSubtitle": "Now create your administrator account", + "usageSubtitle": "How will you use PicPeak?", + "usageAlwaysOn": "Galleries, analytics and user management are always included. Pick any extras below — you can change these anytime in Settings.", + "usageGroupCrm": "Client management", + "usageGroupAccounting": "Accounting", + "usageGroupAutomation": "Automation & delivery", + "usageDepsNote": "Invoices automatically enable the Accounting area.", + "usageSkip": "Continue with galleries only", + "finish": "Finish setup", + "featuresSaveFailed": "Could not save your feature choices — you can set them later in Settings → Features.", + "restoreStepSubtitle": "Restore from a backup", + "restoreEntry": "Migrating from another PicPeak?", + "restoreEntryHint": "Restore a .picpeak backup instead of setting up fresh.", + "restoreIntro": "Upload a .picpeak backup to clone another instance onto this one. This replaces everything except the account you just created.", + "config": { + "subtitle": "Set up your features", + "intro": "A few details for the features you picked. Anything you skip keeps its default and can be set later in Settings.", + "invoicing": "Invoicing details", + "invoicingDisclaimer": "Used on your invoices. Bank/IBAN and VAT details are your responsibility — verify them with your bank and Treuhänder/tax advisor.", + "companyName": "Company / legal name", + "addressLine1": "Street and number", + "postalCode": "Postal code", + "city": "City", + "countryCode": "Country code (e.g. CH)", + "currency": "Currency (e.g. CHF)", + "vatId": "VAT ID (or leave blank)", + "taxId": "Tax number (or VAT ID)", + "iban": "IBAN (for invoice payments)", + "email": "Email delivery (SMTP)", + "emailHint": "Required to send reminders, invoices and notifications.", + "smtpHost": "SMTP host", + "smtpPort": "Port", + "smtpUser": "Username", + "smtpPass": "Password", + "fromEmail": "From address", + "fromName": "From name", + "skip": "Skip for now", + "finish": "Finish setup", + "saveFailed": "Some settings could not be saved — you can finish them in Settings." + }, "stepOf": "Step {{current}} of {{total}}", "continue": "Continue", "back": "Back", @@ -3413,7 +3518,25 @@ "generalError": "An error occurred. Please try again.", "needHelp": "Need help? Contact", "poweredBy": "Powered by PicPeak", - "devModeHint": "Development Mode: Use email: admin@example.com, password: admin123" + "devModeHint": "Development Mode: Use email: admin@example.com, 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", diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index 789a8db4..790c01e7 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -8,6 +8,9 @@ import { useTranslation } from 'react-i18next'; import { Button, Input, Card, Loading } from '../components/common'; import { useAdminAuth } from '../contexts'; import { setupService } from '../services/setup.service'; +import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service'; +import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard'; +import { SetupConfigStep } from '../components/admin/SetupConfigStep'; import { resolveLoginLogoClasses } from '../utils/loginLogoSize'; import type { AdminUser } from '../types'; @@ -16,6 +19,19 @@ import type { AdminUser } from '../types'; const SETUP_DOCS_URL = 'https://github.com/PicPeak/picpeak/blob/main/README.md#first-run--create-your-admin-account'; +// "How will you use PicPeak?" — the opt-in feature groups shown after the admin +// account is created. galleries/analytics/userManagement are always on and not +// listed. Labels/descriptions reuse the existing Settings→Features i18n keys +// (`settings.features..title/description`) so translations stay in sync. +// Server-side applyDependencyRules resolves dependencies (e.g. Invoices pulls in +// Accounting) when we PUT the selection, so we only send the raw ticks. +const USAGE_GROUPS: { id: string; titleKey: string; features: FeatureKey[] }[] = [ + { id: 'crm', titleKey: 'setup.usageGroupCrm', features: ['quotes', 'contracts', 'bills', 'hoursLogging', 'customerPortal', 'calendar'] }, + { id: 'accounting', titleKey: 'setup.usageGroupAccounting', features: ['taxReport', 'incomingInvoices', 'expenses'] }, + { id: 'automation', titleKey: 'setup.usageGroupAutomation', features: ['reminderEmails', 'slideshow', 'workflows', 'whatsapp', 'incomingMail'] }, +]; +const ALL_USAGE_FEATURES: FeatureKey[] = USAGE_GROUPS.flatMap((g) => g.features); + // First-run screen. Reached on a fresh instance where no admin account exists // yet — creates the first (super_admin) account from the browser using the // one-time setup token printed to the server logs. Once an admin exists the @@ -37,13 +53,15 @@ export const SetupPage: React.FC = () => { staleTime: Infinity, }); - const [step, setStep] = useState<'token' | 'account'>('token'); + const [step, setStep] = useState<'token' | 'account' | 'usage' | 'restore' | 'config'>('token'); const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' }); const [showPassword, setShowPassword] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [isVerifyingToken, setIsVerifyingToken] = useState(false); const [copied, setCopied] = useState(false); const [errors, setErrors] = useState>({}); + const [selectedFeatures, setSelectedFeatures] = useState>(new Set()); + const [isSavingFeatures, setIsSavingFeatures] = useState(false); if (statusLoading) { return ; @@ -144,7 +162,10 @@ export const SetupPage: React.FC = () => { }; login('', adminUser); toast.success(t('setup.success')); - navigate('/admin/dashboard', { replace: true }); + // Admin now exists and we're logged in (cookie set) — advance to the + // opt-in "How will you use PicPeak?" step rather than jumping straight to + // the dashboard. Authenticated calls (feature flags) work from here. + setStep('usage'); } catch (error: any) { const httpStatus = error.response?.status; const data = error.response?.data; @@ -184,10 +205,44 @@ export const SetupPage: React.FC = () => { } }; - const stepNumber = step === 'token' ? 1 : 2; + const toggleFeature = (key: FeatureKey) => { + setSelectedFeatures((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + // Persist the feature selection, then enter the app. Saving is best-effort — + // if it fails the admin can still flip features later in Settings, so we don't + // trap them on the setup screen. + const finishSetup = async () => { + setIsSavingFeatures(true); + try { + const flags: Partial = {}; + for (const key of ALL_USAGE_FEATURES) flags[key] = selectedFeatures.has(key); + await featureFlagsService.update(flags); + } catch (_) { + toast.warn(t('setup.featuresSaveFailed')); + } finally { + setIsSavingFeatures(false); + // If the chosen features need config the wizard can collect (invoicing, + // email), go to the config step; otherwise enter the app. + const needsConfig = + selectedFeatures.has('bills') || + selectedFeatures.has('reminderEmails') || + selectedFeatures.has('incomingMail') || + selectedFeatures.has('whatsapp'); + if (needsConfig) setStep('config'); + else navigate('/admin/dashboard', { replace: true }); + } + }; + + const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3; return ( -
+
{/* On a fresh instance there are no branding settings yet, so use the @@ -202,13 +257,23 @@ export const SetupPage: React.FC = () => {
); })()} -

{t('setup.title')}

-

- {step === 'token' ? t('setup.tokenStepSubtitle') : t('setup.accountStepSubtitle')} -

-

- {t('setup.stepOf', { current: stepNumber, total: 2 })} +

{t('setup.title')}

+

+ {step === 'token' + ? t('setup.tokenStepSubtitle') + : step === 'account' + ? t('setup.accountStepSubtitle') + : step === 'restore' + ? t('setup.restoreStepSubtitle') + : step === 'config' + ? t('setup.config.subtitle') + : t('setup.usageSubtitle')}

+ {(step === 'token' || step === 'account' || step === 'usage') && ( +

+ {t('setup.stepOf', { current: stepNumber, total: 3 })} +

+ )}
@@ -271,7 +336,7 @@ export const SetupPage: React.FC = () => { {t('setup.continue')} - ) : ( + ) : step === 'account' ? (
+ ) : step === 'usage' ? ( +
+

+ {t('setup.usageAlwaysOn')} +

+ + + + {USAGE_GROUPS.map((group) => ( +
+

{t(group.titleKey)}

+
+ {group.features.map((key) => ( + + ))} +
+
+ ))} + + {selectedFeatures.has('bills') && !selectedFeatures.has('taxReport') && ( +

{t('setup.usageDepsNote')}

+ )} + + +
+ ) : step === 'restore' ? ( +
+

{t('setup.restoreIntro')}

+ + +
+ ) : ( + navigate('/admin/dashboard', { replace: true })} + /> )}
diff --git a/frontend/src/pages/admin/AdminLoginPage.tsx b/frontend/src/pages/admin/AdminLoginPage.tsx index 5a482708..52173d69 100644 --- a/frontend/src/pages/admin/AdminLoginPage.tsx +++ b/frontend/src/pages/admin/AdminLoginPage.tsx @@ -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(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(null); + const [mfaCode, setMfaCode] = useState(''); + const [useRecoveryCode, setUseRecoveryCode] = useState(false); + const [mfaError, setMfaError] = useState(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 (
@@ -177,6 +251,7 @@ export const AdminLoginPage: React.FC = () => { {/* Login Form */} + {step === 'credentials' ? (
{/* Form Error */} {errors.form && ( @@ -263,6 +338,81 @@ export const AdminLoginPage: React.FC = () => { {t('adminLogin.signIn')} + ) : ( +
+
+
+ +
+

+ {t('adminLogin.mfa.title')} +

+

+ {useRecoveryCode ? t('adminLogin.mfa.recoverySubtitle') : t('adminLogin.mfa.subtitle')} +

+
+ + {mfaError && ( +
+ +

{mfaError}

+
+ )} + +
+ + { + setMfaCode(e.target.value); + if (mfaError) setMfaError(null); + }} + placeholder={useRecoveryCode ? t('adminLogin.mfa.recoveryCodePlaceholder') : t('adminLogin.mfa.codePlaceholder')} + leftIcon={} + inputMode={useRecoveryCode ? 'text' : 'numeric'} + autoComplete="one-time-code" + autoFocus + /> +
+ + + +
+ + +
+ + )}
{/* Footer */} diff --git a/frontend/src/pages/admin/BackupManagement.tsx b/frontend/src/pages/admin/BackupManagement.tsx index d8074859..a20177f8 100644 --- a/frontend/src/pages/admin/BackupManagement.tsx +++ b/frontend/src/pages/admin/BackupManagement.tsx @@ -23,6 +23,7 @@ import { BackupDashboard } from '../../components/admin/BackupDashboard'; import { BackupConfiguration } from '../../components/admin/BackupConfiguration'; import { BackupHistory } from '../../components/admin/BackupHistory'; import { RestoreWizard } from '../../components/admin/RestoreWizard'; +import { PicpeakExportCard } from '../../components/admin/PicpeakBackupCard'; import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard'; import { BackupCoverageCard } from '../../components/admin/BackupCoverageCard'; import { api } from '../../config/api'; @@ -193,12 +194,15 @@ export const BackupManagement: React.FC = () => { {/* Tab Content */}
{activeTab === 'dashboard' && ( - manualBackupMutation.mutate()} - isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending} - /> +
+ manualBackupMutation.mutate()} + isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending} + /> + +
)} {activeTab === 'configuration' && ( diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index 46d8add5..ff081149 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -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 { - // Backend expects 'username' field, but we accept email - const response = await api.post('/auth/admin/login', { + async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise { + // Backend expects 'username' field, but we accept email. + // Returns either { user } (session set) or an MFA challenge { mfaRequired, mfaToken }. + const response = await api.post('/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 { + const response = await api.post('/auth/admin/login/mfa', payload); + return response.data; + }, + async adminLogout() { try { await api.post('/auth/logout'); diff --git a/frontend/src/services/mfa.service.ts b/frontend/src/services/mfa.service.ts new file mode 100644 index 00000000..0324d619 --- /dev/null +++ b/frontend/src/services/mfa.service.ts @@ -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 { + const response = await api.get('/admin/auth/mfa/status'); + return response.data; + }, + + async setup(): Promise { + const response = await api.post('/admin/auth/mfa/setup'); + return response.data; + }, + + async enable(code: string): Promise { + const response = await api.post('/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 { + const response = await api.post('/admin/auth/mfa/recovery-codes', { code }); + return response.data; + }, +}; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b84491bf..a33955a3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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: {