Implement per-IP gallery lockouts and UI controls (#42)
This commit is contained in:
+5
-2
@@ -219,14 +219,17 @@ Update `.env` with:
|
|||||||
- **URL Configuration** (for backend CORS):
|
- **URL Configuration** (for backend CORS):
|
||||||
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
|
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
|
||||||
- Example (Docker): `http://localhost:3000`
|
- Example (Docker): `http://localhost:3000`
|
||||||
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
|
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
|
||||||
- Example (Docker): `http://localhost:3000`
|
- Example (Docker): `http://localhost:3000`
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
|
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
|
||||||
- Always include the scheme (`http://` or `https://`).
|
- Always include the scheme (`http://` or `https://`).
|
||||||
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
|
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
|
||||||
|
|
||||||
|
#### Authentication Security
|
||||||
|
- Configure login attempt thresholds from **Admin → Settings → Security**. Defaults are 5 failed attempts per IP within 15 minutes, resulting in a 30 minute lockout.
|
||||||
|
|
||||||
#### External Database Example
|
#### External Database Example
|
||||||
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
|
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const {
|
|||||||
} = require('../services/publicSiteService');
|
} = require('../services/publicSiteService');
|
||||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||||
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
|
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
|
||||||
|
const { resetSecurityConfigCache } = require('../utils/authSecurity');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
|
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
|
||||||
|
|
||||||
@@ -590,6 +591,8 @@ router.put('/security', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetSecurityConfigCache();
|
||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
await db('activity_logs').insert({
|
await db('activity_logs').insert({
|
||||||
activity_type: 'security_settings_updated',
|
activity_type: 'security_settings_updated',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const {
|
|||||||
} = require('../utils/passwordValidation');
|
} = require('../utils/passwordValidation');
|
||||||
const { endSession } = require('../middleware/sessionTimeout');
|
const { endSession } = require('../middleware/sessionTimeout');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const { getClientIp } = require('../utils/requestIp');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Admin login with enhanced security
|
// Admin login with enhanced security
|
||||||
@@ -33,7 +34,7 @@ router.post('/admin/login', [
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { username, password, recaptchaToken } = req.body;
|
const { username, password, recaptchaToken } = req.body;
|
||||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
const ipAddress = getClientIp(req);
|
||||||
const userAgent = req.headers['user-agent'] || '';
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
|
|
||||||
// Check account lockout first
|
// Check account lockout first
|
||||||
@@ -175,7 +176,7 @@ router.post('/admin/change-password', [
|
|||||||
logger.info('Admin password changed', {
|
logger.info('Admin password changed', {
|
||||||
userId: adminId,
|
userId: adminId,
|
||||||
username: admin.username,
|
username: admin.username,
|
||||||
ip: req.ip
|
ip: ipAddress
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -229,14 +230,14 @@ router.post('/gallery/verify', [
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { slug, password, recaptchaToken } = req.body;
|
const { slug, password, recaptchaToken } = req.body;
|
||||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
const ipAddress = getClientIp(req);
|
||||||
const userAgent = req.headers['user-agent'] || '';
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
|
|
||||||
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
||||||
const requiresPassword = !(event && (event.require_password === false || event.require_password === 0 || event.require_password === '0'));
|
const requiresPassword = !(event && (event.require_password === false || event.require_password === 0 || event.require_password === '0'));
|
||||||
|
|
||||||
if (requiresPassword) {
|
if (requiresPassword) {
|
||||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress);
|
||||||
if (lockoutStatus.isLocked) {
|
if (lockoutStatus.isLocked) {
|
||||||
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
||||||
return res.status(423).json({
|
return res.status(423).json({
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const {
|
|||||||
getGalleryTokenFromRequest,
|
getGalleryTokenFromRequest,
|
||||||
} = require('../utils/tokenUtils');
|
} = require('../utils/tokenUtils');
|
||||||
const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService');
|
const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService');
|
||||||
|
const { getClientIp } = require('../utils/requestIp');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Admin login with enhanced security
|
// Admin login with enhanced security
|
||||||
@@ -37,7 +38,7 @@ router.post('/admin/login', [
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { username, password, recaptchaToken } = req.body;
|
const { username, password, recaptchaToken } = req.body;
|
||||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
const ipAddress = getClientIp(req);
|
||||||
const userAgent = req.headers['user-agent'] || '';
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
|
|
||||||
// Check account lockout first
|
// Check account lockout first
|
||||||
@@ -172,7 +173,7 @@ router.post('/gallery/verify', [
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { slug, password, recaptchaToken } = req.body;
|
const { slug, password, recaptchaToken } = req.body;
|
||||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
const ipAddress = getClientIp(req);
|
||||||
const userAgent = req.headers['user-agent'] || '';
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
const event = await db('events')
|
const event = await db('events')
|
||||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||||
@@ -186,7 +187,7 @@ router.post('/gallery/verify', [
|
|||||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||||
|
|
||||||
if (requiresPassword) {
|
if (requiresPassword) {
|
||||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress);
|
||||||
if (lockoutStatus.isLocked) {
|
if (lockoutStatus.isLocked) {
|
||||||
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
||||||
return res.status(423).json({
|
return res.status(423).json({
|
||||||
@@ -282,7 +283,7 @@ router.post('/gallery/share-login', [
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { slug, token } = req.body;
|
const { slug, token } = req.body;
|
||||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
const ipAddress = getClientIp(req);
|
||||||
const userAgent = req.headers['user-agent'] || '';
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
|
|
||||||
let event = await db('events')
|
let event = await db('events')
|
||||||
|
|||||||
@@ -7,10 +7,140 @@ const { db } = require('../database/db');
|
|||||||
const { formatBoolean } = require('./dbCompat');
|
const { formatBoolean } = require('./dbCompat');
|
||||||
const logger = require('./logger');
|
const logger = require('./logger');
|
||||||
|
|
||||||
// Configuration constants
|
const DEFAULT_SECURITY_CONFIG = Object.freeze({
|
||||||
const MAX_LOGIN_ATTEMPTS = 5;
|
maxAttempts: 5,
|
||||||
const LOCKOUT_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds
|
lockoutDurationMs: 30 * 60 * 1000, // 30 minutes
|
||||||
const ATTEMPT_WINDOW = 15 * 60 * 1000; // 15 minutes window for counting attempts
|
attemptWindowMs: 15 * 60 * 1000 // 15 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const SECURITY_CONFIG_CACHE_MS = 60 * 1000; // 1 minute cache
|
||||||
|
let cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG };
|
||||||
|
let cachedConfigFetchedAt = 0;
|
||||||
|
|
||||||
|
function parseStoredValue(rawValue) {
|
||||||
|
if (rawValue === undefined || rawValue === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawValue !== 'string') {
|
||||||
|
return rawValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawValue);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Unable to parse stored security setting value "${rawValue}", using raw string.`);
|
||||||
|
return rawValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePositiveInteger(name, value, fallback, options = {}) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericValue = Number(value);
|
||||||
|
|
||||||
|
if (!Number.isFinite(numericValue)) {
|
||||||
|
logger.warn(`Invalid numeric value for ${name}: ${value}. Falling back to default (${fallback}).`);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
let adjustedValue = Math.floor(numericValue);
|
||||||
|
|
||||||
|
if (options.min !== undefined && adjustedValue < options.min) {
|
||||||
|
logger.warn(`Value for ${name} below minimum (${options.min}). Clamping to minimum.`);
|
||||||
|
adjustedValue = options.min;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.max !== undefined && adjustedValue > options.max) {
|
||||||
|
logger.warn(`Value for ${name} exceeds maximum (${options.max}). Clamping to maximum.`);
|
||||||
|
adjustedValue = options.max;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adjustedValue <= 0) {
|
||||||
|
logger.warn(`Value for ${name} must be positive. Falling back to default (${fallback}).`);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjustedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSecurityConfigFromSettings() {
|
||||||
|
const rows = await db('app_settings').whereIn('setting_key', [
|
||||||
|
'security_max_login_attempts',
|
||||||
|
'security_lockout_duration_minutes',
|
||||||
|
'security_attempt_window_minutes'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const config = { ...DEFAULT_SECURITY_CONFIG };
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const value = parseStoredValue(row.setting_value);
|
||||||
|
|
||||||
|
switch (row.setting_key) {
|
||||||
|
case 'security_max_login_attempts': {
|
||||||
|
config.maxAttempts = normalizePositiveInteger(
|
||||||
|
'security_max_login_attempts',
|
||||||
|
value,
|
||||||
|
DEFAULT_SECURITY_CONFIG.maxAttempts,
|
||||||
|
{ min: 1, max: 50 }
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'security_lockout_duration_minutes': {
|
||||||
|
const minutes = normalizePositiveInteger(
|
||||||
|
'security_lockout_duration_minutes',
|
||||||
|
value,
|
||||||
|
DEFAULT_SECURITY_CONFIG.lockoutDurationMs / (60 * 1000),
|
||||||
|
{ min: 1, max: 24 * 60 }
|
||||||
|
);
|
||||||
|
config.lockoutDurationMs = minutes * 60 * 1000;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'security_attempt_window_minutes': {
|
||||||
|
const minutes = normalizePositiveInteger(
|
||||||
|
'security_attempt_window_minutes',
|
||||||
|
value,
|
||||||
|
DEFAULT_SECURITY_CONFIG.attemptWindowMs / (60 * 1000),
|
||||||
|
{ min: 1, max: 24 * 60 }
|
||||||
|
);
|
||||||
|
config.attemptWindowMs = minutes * 60 * 1000;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSecurityConfig(options = {}) {
|
||||||
|
const now = Date.now();
|
||||||
|
const forceRefresh = options.forceRefresh === true;
|
||||||
|
|
||||||
|
if (!forceRefresh && cachedSecurityConfig && (now - cachedConfigFetchedAt) < SECURITY_CONFIG_CACHE_MS) {
|
||||||
|
return cachedSecurityConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await loadSecurityConfigFromSettings();
|
||||||
|
cachedSecurityConfig = config;
|
||||||
|
cachedConfigFetchedAt = now;
|
||||||
|
return cachedSecurityConfig;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error loading security configuration:', error);
|
||||||
|
cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG };
|
||||||
|
cachedConfigFetchedAt = now;
|
||||||
|
return cachedSecurityConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSecurityConfigCache() {
|
||||||
|
cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG };
|
||||||
|
cachedConfigFetchedAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track failed login attempt
|
* Track failed login attempt
|
||||||
@@ -60,6 +190,8 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { attemptWindowMs } = await getSecurityConfig();
|
||||||
|
|
||||||
await db('login_attempts').insert({
|
await db('login_attempts').insert({
|
||||||
identifier,
|
identifier,
|
||||||
ip_address: ipAddress,
|
ip_address: ipAddress,
|
||||||
@@ -69,7 +201,7 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Clear old failed attempts for this user
|
// Clear old failed attempts for this user
|
||||||
const cutoffTime = new Date(Date.now() - ATTEMPT_WINDOW);
|
const cutoffTime = new Date(Date.now() - attemptWindowMs);
|
||||||
await db('login_attempts')
|
await db('login_attempts')
|
||||||
.where('identifier', identifier)
|
.where('identifier', identifier)
|
||||||
.where('success', formatBoolean(false))
|
.where('success', formatBoolean(false))
|
||||||
@@ -83,9 +215,10 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
|
|||||||
/**
|
/**
|
||||||
* Check if account is locked due to too many failed attempts
|
* Check if account is locked due to too many failed attempts
|
||||||
* @param {string} identifier - Username or email
|
* @param {string} identifier - Username or email
|
||||||
|
* @param {string} [ipAddress] - Optional IP address scope
|
||||||
* @returns {Promise<{isLocked: boolean, remainingTime?: number}>}
|
* @returns {Promise<{isLocked: boolean, remainingTime?: number}>}
|
||||||
*/
|
*/
|
||||||
async function checkAccountLockout(identifier) {
|
async function checkAccountLockout(identifier, ipAddress) {
|
||||||
try {
|
try {
|
||||||
// Check if table exists first
|
// Check if table exists first
|
||||||
const tableExists = await db.schema.hasTable('login_attempts');
|
const tableExists = await db.schema.hasTable('login_attempts');
|
||||||
@@ -93,20 +226,28 @@ async function checkAccountLockout(identifier) {
|
|||||||
return { isLocked: false };
|
return { isLocked: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const recentWindow = new Date(Date.now() - ATTEMPT_WINDOW);
|
const { attemptWindowMs, maxAttempts, lockoutDurationMs } = await getSecurityConfig();
|
||||||
|
|
||||||
|
const recentWindow = new Date(Date.now() - attemptWindowMs);
|
||||||
|
|
||||||
// Get recent failed attempts
|
// Get recent failed attempts
|
||||||
const failedAttempts = await db('login_attempts')
|
const failedAttemptsQuery = db('login_attempts')
|
||||||
.where('identifier', identifier)
|
.where('identifier', identifier)
|
||||||
.where('success', formatBoolean(false))
|
.where('success', formatBoolean(false))
|
||||||
.where('attempt_time', '>=', recentWindow.toISOString())
|
.where('attempt_time', '>=', recentWindow.toISOString());
|
||||||
.orderBy('attempt_time', 'desc')
|
|
||||||
.limit(MAX_LOGIN_ATTEMPTS);
|
|
||||||
|
|
||||||
if (failedAttempts.length >= MAX_LOGIN_ATTEMPTS) {
|
if (ipAddress) {
|
||||||
|
failedAttemptsQuery.andWhere('ip_address', ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedAttempts = await failedAttemptsQuery
|
||||||
|
.orderBy('attempt_time', 'desc')
|
||||||
|
.limit(maxAttempts);
|
||||||
|
|
||||||
|
if (failedAttempts.length >= maxAttempts) {
|
||||||
// Check if still within lockout period
|
// Check if still within lockout period
|
||||||
const oldestAttempt = failedAttempts[failedAttempts.length - 1];
|
const oldestAttempt = failedAttempts[failedAttempts.length - 1];
|
||||||
const lockoutEnd = new Date(oldestAttempt.attempt_time).getTime() + LOCKOUT_DURATION;
|
const lockoutEnd = new Date(oldestAttempt.attempt_time).getTime() + lockoutDurationMs;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (now < lockoutEnd) {
|
if (now < lockoutEnd) {
|
||||||
@@ -216,6 +357,6 @@ module.exports = {
|
|||||||
checkSuspiciousActivity,
|
checkSuspiciousActivity,
|
||||||
getGenericAuthError,
|
getGenericAuthError,
|
||||||
initializeCleanupJob,
|
initializeCleanupJob,
|
||||||
MAX_LOGIN_ATTEMPTS,
|
getSecurityConfig,
|
||||||
LOCKOUT_DURATION
|
resetSecurityConfigCache
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Resolve the originating client IP address, accounting for reverse proxies.
|
||||||
|
* Returns the first entry from X-Forwarded-For when available, otherwise falls back
|
||||||
|
* to Express/Node connection properties.
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function getClientIp(req) {
|
||||||
|
if (!req) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardedFor = req.headers['x-forwarded-for'];
|
||||||
|
|
||||||
|
if (typeof forwardedFor === 'string' && forwardedFor.length > 0) {
|
||||||
|
const [firstIp] = forwardedFor.split(',').map(part => part.trim()).filter(Boolean);
|
||||||
|
if (firstIp) {
|
||||||
|
return firstIp;
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
|
||||||
|
const [firstIp] = forwardedFor;
|
||||||
|
if (firstIp) {
|
||||||
|
return firstIp.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
req.ip ||
|
||||||
|
req.connection?.remoteAddress ||
|
||||||
|
req.socket?.remoteAddress ||
|
||||||
|
req.connection?.socket?.remoteAddress ||
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getClientIp };
|
||||||
@@ -892,7 +892,11 @@
|
|||||||
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
|
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
|
||||||
"sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten",
|
"sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten",
|
||||||
"maxLoginAttempts": "Max. Anmeldeversuche",
|
"maxLoginAttempts": "Max. Anmeldeversuche",
|
||||||
"maxLoginAttemptsHelp": "Maximale fehlgeschlagene Anmeldeversuche vor Sperrung",
|
"maxLoginAttemptsHelp": "Maximale fehlgeschlagene Anmeldeversuche pro IP vor Sperrung",
|
||||||
|
"attemptWindowMinutes": "Versuchsfenster (Minuten)",
|
||||||
|
"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",
|
"enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren",
|
||||||
"recaptchaSettings": "reCAPTCHA-Einstellungen",
|
"recaptchaSettings": "reCAPTCHA-Einstellungen",
|
||||||
"enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren",
|
"enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren",
|
||||||
|
|||||||
@@ -572,7 +572,11 @@
|
|||||||
"sessionTimeout": "Session Timeout (minutes)",
|
"sessionTimeout": "Session Timeout (minutes)",
|
||||||
"sessionTimeoutHelp": "Admin session timeout in minutes",
|
"sessionTimeoutHelp": "Admin session timeout in minutes",
|
||||||
"maxLoginAttempts": "Max Login Attempts",
|
"maxLoginAttempts": "Max Login Attempts",
|
||||||
"maxLoginAttemptsHelp": "Maximum failed login attempts before lockout",
|
"maxLoginAttemptsHelp": "Maximum failed login attempts per IP before lockout",
|
||||||
|
"attemptWindowMinutes": "Attempt Window (minutes)",
|
||||||
|
"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",
|
"enable2FA": "Enable two-factor authentication for admins",
|
||||||
"recaptchaSettings": "reCAPTCHA Settings",
|
"recaptchaSettings": "reCAPTCHA Settings",
|
||||||
"enableRecaptcha": "Enable reCAPTCHA for login forms",
|
"enableRecaptcha": "Enable reCAPTCHA for login forms",
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ export const SettingsPage: React.FC = () => {
|
|||||||
enable_2fa: false,
|
enable_2fa: false,
|
||||||
session_timeout_minutes: 60,
|
session_timeout_minutes: 60,
|
||||||
max_login_attempts: 5,
|
max_login_attempts: 5,
|
||||||
|
attempt_window_minutes: 15,
|
||||||
|
lockout_duration_minutes: 30,
|
||||||
enable_recaptcha: false,
|
enable_recaptcha: false,
|
||||||
recaptcha_site_key: '',
|
recaptcha_site_key: '',
|
||||||
recaptcha_secret_key: ''
|
recaptcha_secret_key: ''
|
||||||
@@ -173,6 +175,8 @@ export const SettingsPage: React.FC = () => {
|
|||||||
enable_2fa: toBoolean(settings.security_enable_2fa, false),
|
enable_2fa: toBoolean(settings.security_enable_2fa, false),
|
||||||
session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60),
|
session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60),
|
||||||
max_login_attempts: toNumber(settings.security_max_login_attempts, 5),
|
max_login_attempts: toNumber(settings.security_max_login_attempts, 5),
|
||||||
|
attempt_window_minutes: toNumber(settings.security_attempt_window_minutes, 15),
|
||||||
|
lockout_duration_minutes: toNumber(settings.security_lockout_duration_minutes, 30),
|
||||||
enable_recaptcha: toBoolean(settings.security_enable_recaptcha, false),
|
enable_recaptcha: toBoolean(settings.security_enable_recaptcha, false),
|
||||||
recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
|
recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
|
||||||
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
|
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
|
||||||
@@ -1346,7 +1350,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.sessionAuth')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.sessionAuth')}</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
{t('settings.security.sessionTimeout')}
|
{t('settings.security.sessionTimeout')}
|
||||||
@@ -1354,11 +1358,41 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={securitySettings.session_timeout_minutes}
|
value={securitySettings.session_timeout_minutes}
|
||||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, session_timeout_minutes: parseInt(e.target.value) || 60 }))}
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, session_timeout_minutes: parseInt(e.target.value, 10) || 60 }))}
|
||||||
min="5"
|
min="5"
|
||||||
max="1440"
|
max="1440"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.security.attemptWindowMinutes')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={securitySettings.attempt_window_minutes}
|
||||||
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, attempt_window_minutes: parseInt(e.target.value, 10) || 15 }))}
|
||||||
|
min="1"
|
||||||
|
max="1440"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-sm text-neutral-600">
|
||||||
|
{t('settings.security.attemptWindowMinutesHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.security.lockoutDurationMinutes')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={securitySettings.lockout_duration_minutes}
|
||||||
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, lockout_duration_minutes: parseInt(e.target.value, 10) || 30 }))}
|
||||||
|
min="1"
|
||||||
|
max="1440"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-sm text-neutral-600">
|
||||||
|
{t('settings.security.lockoutDurationMinutesHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
{t('settings.security.maxLoginAttempts')}
|
{t('settings.security.maxLoginAttempts')}
|
||||||
@@ -1366,10 +1400,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={securitySettings.max_login_attempts}
|
value={securitySettings.max_login_attempts}
|
||||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, max_login_attempts: parseInt(e.target.value) || 5 }))}
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, max_login_attempts: parseInt(e.target.value, 10) || 5 }))}
|
||||||
min="3"
|
min="1"
|
||||||
max="10"
|
max="50"
|
||||||
/>
|
/>
|
||||||
|
<p className="mt-1 text-sm text-neutral-600">
|
||||||
|
{t('settings.security.maxLoginAttemptsHelp')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user