Files
picpeak/backend/src/routes/auth.js
T
Paul Nothaft 892e47d017 feat: add multi-administrator support with RBAC and fix backup/restore for S3
## Multi-Administrator System
- Add role-based access control (RBAC) with predefined roles (Super Admin, Admin, Editor, Viewer)
- Add granular permissions system for all admin operations
- Add admin user management page with invite functionality
- Add email invitation system for new administrators
- Add permission middleware protecting all admin routes
- Add PermissionGate component for frontend permission checks
- Track event creator (created_by) for audit purposes

## Backup & Restore Fixes
- Fix S3 backup: endpoint URL handling, manifest loading, field name compatibility
- Fix S3 restore: add list-backups endpoint, transform S3 config from frontend format
- Fix PostgreSQL compatibility: add .returning('id') for insert operations
- Fix disk space check: use df command, handle unknown space gracefully
- Fix dry-run validation to not block on warnings
- Fix req.user → req.admin in restore routes

## Database Migrations
- 054: Add roles table with predefined roles
- 055: Add permissions table
- 056: Add role_permissions junction table
- 057: Add role_id to admin_users
- 058: Add admin_invitations table
- 059: Add admin email templates
- 060: Add created_by to events table

## Other Improvements
- Update .gitignore to exclude planning docs and local backup directory
- Remove SQLite database file from tracking
- Add i18n translations for user management (EN/DE)
2026-01-07 17:10:46 +01:00

524 lines
16 KiB
JavaScript

const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha');
const {
trackFailedAttempt,
trackSuccessfulLogin,
checkAccountLockout,
checkSuspiciousActivity,
getGenericAuthError
} = require('../utils/authSecurity');
const { endSession } = require('../middleware/sessionTimeout');
const logger = require('../utils/logger');
const {
setAdminAuthCookie,
clearAdminAuthCookie,
setGalleryAuthCookies,
clearGalleryAuthCookies,
getAdminTokenFromRequest,
getGalleryTokenFromRequest,
} = require('../utils/tokenUtils');
const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService');
const { getClientIp } = require('../utils/requestIp');
const {
validatePasswordInContext,
getBcryptRounds,
logPasswordValidationFailure
} = require('../utils/passwordValidation');
const router = express.Router();
// Admin login with enhanced security
router.post('/admin/login', [
body('username').notEmpty().trim(),
body('password').notEmpty()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { username, password, recaptchaToken } = req.body;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
// Check account lockout first
const lockoutStatus = await checkAccountLockout(username);
if (lockoutStatus.isLocked) {
logger.warn('Login attempt on locked account', { username, ipAddress });
return res.status(423).json({
error: 'Account temporarily locked due to too many failed attempts',
retryAfter: lockoutStatus.remainingTime
});
}
// Verify reCAPTCHA
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
await trackFailedAttempt(username, ipAddress, userAgent);
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
// Check for suspicious activity
const isSuspicious = await checkSuspiciousActivity(username, ipAddress);
if (isSuspicious) {
// Still allow login but log it
logger.warn('Suspicious login pattern detected', { username, ipAddress });
}
// Fetch admin with role information
const admin = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.username', username)
.orWhere('admin_users.email', username)
.select(
'admin_users.*',
'roles.name as role_name',
'roles.display_name as role_display_name'
)
.first();
// Use generic error to prevent user enumeration
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
await trackFailedAttempt(username, ipAddress, userAgent);
return res.status(401).json({ error: getGenericAuthError() });
}
if (!admin.is_active) {
await trackFailedAttempt(username, ipAddress, userAgent);
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);
// Include role in response
res.json({
token,
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
}
});
} catch (error) {
logger.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
});
// Logout endpoint
router.post('/logout', async (req, res) => {
try {
const adminToken = getAdminTokenFromRequest(req);
const galleryToken = getGalleryTokenFromRequest(req);
const token = adminToken || galleryToken;
if (token) {
// End the session
endSession(token);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
logger.info('User logged out', {
userId: decoded.id,
username: decoded.username,
type: decoded.type
});
if (decoded.type === 'admin') {
clearAdminAuthCookie(res);
} else if (decoded.type === 'gallery') {
clearGalleryAuthCookies(res, decoded.eventSlug);
}
} catch (err) {
// Token might be invalid, but still process logout and clear cookies
clearAdminAuthCookie(res);
clearGalleryAuthCookies(res);
}
} else {
// No token found, but ensure cookies are cleared
clearAdminAuthCookie(res);
clearGalleryAuthCookies(res);
}
res.json({ message: 'Logged out successfully' });
} catch (error) {
logger.error('Logout error:', error);
res.status(500).json({ error: 'Logout failed' });
}
});
// Gallery password verification with enhanced security
router.post('/gallery/verify', [
body('slug').notEmpty().trim(),
body('password').optional().isString()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slug, password, recaptchaToken } = req.body;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (requiresPassword) {
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress);
if (lockoutStatus.isLocked) {
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
if (!password) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_fail'
});
return res.status(401).json({ error: 'Invalid gallery or password' });
}
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_success'
});
} else {
logger.info('Public gallery access granted without password', { slug, ipAddress });
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_success'
});
}
const token = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
setGalleryAuthCookies(res, token, event.slug);
res.json({
token,
event: {
id: event.id,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
welcome_message: event.welcome_message,
color_theme: event.color_theme,
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id,
require_password: requiresPassword
}
});
} catch (error) {
logger.error('Gallery verification error:', error);
res.status(500).json({ error: 'Verification failed' });
}
});
// Share link authentication (token-based)
router.post('/gallery/share-login', [
body('slug').notEmpty().trim(),
body('token').notEmpty()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slug, token } = req.body;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
let event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event) {
const resolved = await resolveShareIdentifier(slug);
if (resolved?.event) {
event = resolved.event;
}
}
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
const expectedToken = getEventShareToken(event);
if (!expectedToken || token !== expectedToken) {
return res.status(401).json({ error: 'Invalid or expired share link' });
}
const jwtToken = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
setGalleryAuthCookies(res, jwtToken, event.slug);
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({
token: jwtToken,
event: {
id: event.id,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
welcome_message: event.welcome_message,
color_theme: event.color_theme,
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id,
require_password: requiresPassword
}
});
} catch (error) {
logger.error('Share link authentication error:', error);
res.status(500).json({ error: 'Share link login failed' });
}
});
// Gallery logout to clear cookies
router.post('/gallery/logout', async (req, res) => {
try {
const { slug } = req.body || {};
clearGalleryAuthCookies(res, slug);
res.json({ message: 'Logged out successfully' });
} catch (error) {
logger.error('Gallery logout error:', error);
res.status(500).json({ error: 'Logout failed' });
}
});
// Get current session info
router.get('/session', async (req, res) => {
try {
const { slug } = req.query;
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Calculate remaining time
const now = Date.now() / 1000;
const remainingTime = Math.max(0, decoded.exp - now);
res.json({
valid: true,
type: decoded.type,
expiresIn: Math.floor(remainingTime),
user: decoded.username || decoded.eventSlug,
eventSlug: decoded.eventSlug,
adminUsername: decoded.username
});
} catch (err) {
res.json({
valid: false,
error: 'Invalid or expired token'
});
}
} catch (error) {
res.status(500).json({ error: 'Session check failed' });
}
});
// Admin password change with validation
router.post('/admin/change-password', [
body('currentPassword').notEmpty(),
body('newPassword').notEmpty(),
body('confirmPassword').notEmpty()
.custom((value, { req }) => value === req.body.newPassword)
.withMessage('Passwords do not match')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { currentPassword, newPassword } = req.body;
const ipAddress = getClientIp(req);
// Get admin from request (should be set by auth middleware)
if (!req.admin) {
return res.status(401).json({ error: 'Authentication required' });
}
const adminId = req.admin.id;
// Get admin user
const admin = await db('admin_users').where({ id: adminId }).first();
if (!admin) {
return res.status(404).json({ error: 'User not found' });
}
// Verify current password
const validPassword = await bcrypt.compare(currentPassword, admin.password_hash);
if (!validPassword) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
// Validate new password
const passwordValidation = validatePasswordInContext(newPassword, 'admin', {
username: admin.username,
email: admin.email
});
if (!passwordValidation.valid) {
logPasswordValidationFailure('admin_password_change', passwordValidation.errors, {
userId: adminId,
username: admin.username
});
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
});
}
// Hash new password with configurable rounds
const hashedPassword = await bcrypt.hash(newPassword, getBcryptRounds());
// Update password and track change time
await db('admin_users').where('id', adminId).update({
password_hash: hashedPassword,
password_changed_at: new Date(),
must_change_password: false
});
// Log password change
logger.info('Admin password changed', {
userId: adminId,
username: admin.username,
ip: ipAddress
});
res.json({
message: 'Password changed successfully',
score: passwordValidation.score
});
} catch (error) {
logger.error('Password change error:', error);
res.status(500).json({ error: 'Failed to change password' });
}
});
// Password strength check endpoint (for real-time validation)
router.post('/password-strength', [
body('password').notEmpty(),
body('context').isIn(['admin', 'gallery']).optional()
], async (req, res) => {
try {
const { password, context = 'gallery' } = req.body;
// Get user data if available (for context-aware validation)
const userData = {};
if (context === 'admin' && req.admin) {
userData.username = req.admin.username;
userData.email = req.admin.email;
}
const validation = validatePasswordInContext(password, context, userData);
res.json({
valid: validation.valid,
score: validation.score,
errors: validation.errors,
feedback: validation.feedback
});
} catch (error) {
res.status(500).json({ error: 'Failed to check password strength' });
}
});
module.exports = router;