Merge remote-tracking branch 'origin/main' into refactor/codebase-cleanup
# Conflicts: # backend/src/routes/adminEvents.js # backend/src/routes/protectedImages.js # frontend/src/pages/admin/EventDetailsPage.tsx
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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') {
|
||||
|
||||
+165
-37
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user