fix: enforce consistent password policy across all auth flows

Replace duplicated inline validation with a shared validatePassword()
utility that checks minimum length (8), rejects repetitive and common
passwords, and requires uppercase, lowercase, a digit, and a special
character.

- Add server/src/services/passwordPolicy.ts as single source of truth
- Apply to registration, password change, and admin create/edit user
  (admin routes previously had zero validation)
- Fix client min-length mismatch (6 vs 8) in RegisterPage and LoginPage
- Add client-side password length guard to AdminPage forms
- Update register.passwordTooShort and settings.passwordWeak i18n keys
  in all 12 locales to reflect the corrected requirements
This commit is contained in:
jubnl
2026-04-01 07:02:53 +02:00
parent ce8d498f2d
commit e03505dca2
18 changed files with 77 additions and 39 deletions

View File

@@ -255,7 +255,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.passwordRequired': 'أدخل كلمة المرور الحالية والجديدة',
'settings.passwordTooShort': 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل',
'settings.passwordMismatch': 'كلمتا المرور غير متطابقتين',
'settings.passwordWeak': 'يجب أن تحتوي كلمة المرور على حرف كبير وحرف صغير ورقم',
'settings.passwordWeak': 'يجب أن تحتوي كلمة المرور على حرف كبير وحرف صغير ورقم ورمز خاص',
'settings.passwordChanged': 'تم تغيير كلمة المرور بنجاح',
'settings.deleteAccount': 'حذف الحساب',
'settings.deleteAccountTitle': 'هل تريد حذف حسابك؟',
@@ -354,7 +354,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Register
'register.passwordMismatch': 'كلمتا المرور غير متطابقتين',
'register.passwordTooShort': 'يجب أن تتكون كلمة المرور من 6 أحرف على الأقل',
'register.passwordTooShort': 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل',
'register.failed': 'فشل التسجيل',
'register.getStarted': 'ابدأ الآن',
'register.subtitle': 'أنشئ حسابًا وابدأ التخطيط لرحلات أحلامك.',

View File

@@ -225,7 +225,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.passwordRequired': 'Informe a senha atual e a nova',
'settings.passwordTooShort': 'A senha deve ter pelo menos 8 caracteres',
'settings.passwordMismatch': 'As senhas não coincidem',
'settings.passwordWeak': 'A senha deve ter maiúscula, minúscula e número',
'settings.passwordWeak': 'A senha deve ter maiúscula, minúscula, número e um caractere especial',
'settings.passwordChanged': 'Senha alterada com sucesso',
'settings.deleteAccount': 'Excluir conta',
'settings.deleteAccountTitle': 'Excluir sua conta?',
@@ -349,7 +349,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
// Register
'register.passwordMismatch': 'As senhas não coincidem',
'register.passwordTooShort': 'A senha deve ter pelo menos 6 caracteres',
'register.passwordTooShort': 'A senha deve ter pelo menos 8 caracteres',
'register.failed': 'Falha no cadastro',
'register.getStarted': 'Começar',
'register.subtitle': 'Crie uma conta e comece a planejar suas viagens.',

View File

@@ -203,7 +203,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.passwordRequired': 'Zadejte prosím současné i nové heslo',
'settings.passwordTooShort': 'Heslo musí mít alespoň 8 znaků',
'settings.passwordMismatch': 'Hesla se neshodují',
'settings.passwordWeak': 'Heslo musí obsahovat velké a malé písmeno a číslici',
'settings.passwordWeak': 'Heslo musí obsahovat velké a malé písmeno, číslici a speciální znak',
'settings.passwordChanged': 'Heslo bylo úspěšně změněno',
'settings.deleteAccount': 'Smazat účet',
'settings.deleteAccountTitle': 'Smazat váš účet?',
@@ -350,7 +350,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
// Registrace (Register)
'register.passwordMismatch': 'Hesla se neshodují',
'register.passwordTooShort': 'Heslo musí mít alespoň 6 znaků',
'register.passwordTooShort': 'Heslo musí mít alespoň 8 znaků',
'register.failed': 'Registrace se nezdařila',
'register.getStarted': 'Začínáme',
'register.subtitle': 'Vytvořte si účet a začněte plánovat svou vysněnou cestu.',

View File

@@ -250,7 +250,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.passwordRequired': 'Bitte aktuelles und neues Passwort eingeben',
'settings.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
'settings.passwordMismatch': 'Passwörter stimmen nicht überein',
'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben und eine Zahl enthalten',
'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten',
'settings.passwordChanged': 'Passwort erfolgreich geändert',
'settings.deleteAccount': 'Löschen',
'settings.deleteAccountTitle': 'Account wirklich löschen?',
@@ -349,7 +349,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// Register
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
'register.passwordTooShort': 'Passwort muss mindestens 6 Zeichen lang sein',
'register.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
'register.failed': 'Registrierung fehlgeschlagen',
'register.getStarted': 'Jetzt starten',
'register.subtitle': 'Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen.',

View File

@@ -250,7 +250,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.passwordRequired': 'Please enter current and new password',
'settings.passwordTooShort': 'Password must be at least 8 characters',
'settings.passwordMismatch': 'Passwords do not match',
'settings.passwordWeak': 'Password must contain uppercase, lowercase, and a number',
'settings.passwordWeak': 'Password must contain uppercase, lowercase, a number, and a special character',
'settings.passwordChanged': 'Password changed successfully',
'settings.mustChangePassword': 'You must change your password before you can continue. Please set a new password below.',
'settings.deleteAccount': 'Delete account',
@@ -350,7 +350,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Register
'register.passwordMismatch': 'Passwords do not match',
'register.passwordTooShort': 'Password must be at least 6 characters',
'register.passwordTooShort': 'Password must be at least 8 characters',
'register.failed': 'Registration failed',
'register.getStarted': 'Get Started',
'register.subtitle': 'Create an account and start planning your dream trips.',

View File

@@ -347,7 +347,7 @@ const es: Record<string, string> = {
// Register
'register.passwordMismatch': 'Las contraseñas no coinciden',
'register.passwordTooShort': 'La contraseña debe tener al menos 6 caracteres',
'register.passwordTooShort': 'La contraseña debe tener al menos 8 caracteres',
'register.failed': 'Falló el registro',
'register.getStarted': 'Empezar',
'register.subtitle': 'Crea una cuenta y empieza a planificar tus viajes.',
@@ -1425,7 +1425,7 @@ const es: Record<string, string> = {
// Settings (2.6.2)
'settings.currentPasswordRequired': 'La contraseña actual es obligatoria',
'settings.passwordWeak': 'La contraseña debe contener mayúsculas, minúsculas y números',
'settings.passwordWeak': 'La contraseña debe contener mayúsculas, minúsculas, números y un carácter especial',
// Permissions
'admin.tabs.permissions': 'Permisos',

View File

@@ -250,7 +250,7 @@ const fr: Record<string, string> = {
'settings.passwordRequired': 'Veuillez saisir le mot de passe actuel et le nouveau',
'settings.passwordTooShort': 'Le mot de passe doit comporter au moins 8 caractères',
'settings.passwordMismatch': 'Les mots de passe ne correspondent pas',
'settings.passwordWeak': 'Le mot de passe doit contenir des majuscules, des minuscules et un chiffre',
'settings.passwordWeak': 'Le mot de passe doit contenir des majuscules, des minuscules, un chiffre et un caractère spécial',
'settings.passwordChanged': 'Mot de passe modifié avec succès',
'settings.deleteAccount': 'Supprimer le compte',
'settings.deleteAccountTitle': 'Supprimer votre compte ?',
@@ -349,7 +349,7 @@ const fr: Record<string, string> = {
// Register
'register.passwordMismatch': 'Les mots de passe ne correspondent pas',
'register.passwordTooShort': 'Le mot de passe doit comporter au moins 6 caractères',
'register.passwordTooShort': 'Le mot de passe doit comporter au moins 8 caractères',
'register.failed': 'Échec de l\'inscription',
'register.getStarted': 'Commencer',
'register.subtitle': 'Créez un compte et commencez à planifier vos voyages de rêve.',

View File

@@ -201,7 +201,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'settings.passwordRequired': 'Kérjük, add meg a jelenlegi és az új jelszót',
'settings.currentPasswordRequired': 'A jelenlegi jelszó megadása kötelező',
'settings.passwordTooShort': 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
'settings.passwordWeak': 'A jelszónak tartalmaznia kell nagybetűt, kisbetűt és számot',
'settings.passwordWeak': 'A jelszónak tartalmaznia kell nagybetűt, kisbetűt, számot és speciális karaktert',
'settings.passwordMismatch': 'A jelszavak nem egyeznek',
'settings.passwordChanged': 'Jelszó sikeresen módosítva',
'settings.deleteAccount': 'Törlés',
@@ -349,7 +349,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
// Regisztráció
'register.passwordMismatch': 'A jelszavak nem egyeznek',
'register.passwordTooShort': 'A jelszónak legalább 6 karakter hosszúnak kell lennie',
'register.passwordTooShort': 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
'register.failed': 'Regisztráció sikertelen',
'register.getStarted': 'Kezdjük',
'register.subtitle': 'Hozz létre egy fiókot, és kezdd el megtervezni álomutazásaidat.',

View File

@@ -202,7 +202,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.passwordRequired': 'Inserisci la password attuale e quella nuova',
'settings.passwordTooShort': 'La password deve contenere almeno 8 caratteri',
'settings.passwordMismatch': 'Le password non corrispondono',
'settings.passwordWeak': 'La password deve contenere lettere maiuscole, minuscole e un numero',
'settings.passwordWeak': 'La password deve contenere lettere maiuscole, minuscole, un numero e un carattere speciale',
'settings.passwordChanged': 'Password cambiata con successo',
'settings.deleteAccount': 'Elimina account',
'settings.deleteAccountTitle': 'Eliminare il tuo account?',
@@ -349,7 +349,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
// Register
'register.passwordMismatch': 'Le password non corrispondono',
'register.passwordTooShort': 'La password deve contenere almeno 6 caratteri',
'register.passwordTooShort': 'La password deve contenere almeno 8 caratteri',
'register.failed': 'Registrazione fallita',
'register.getStarted': 'Inizia',
'register.subtitle': 'Crea un account e inizia a programmare i viaggi dei tuoi sogni.',

View File

@@ -250,7 +250,7 @@ const nl: Record<string, string> = {
'settings.passwordRequired': 'Voer het huidige en nieuwe wachtwoord in',
'settings.passwordTooShort': 'Wachtwoord moet minimaal 8 tekens bevatten',
'settings.passwordMismatch': 'Wachtwoorden komen niet overeen',
'settings.passwordWeak': 'Wachtwoord moet hoofdletters, kleine letters en een cijfer bevatten',
'settings.passwordWeak': 'Wachtwoord moet hoofdletters, kleine letters, een cijfer en een speciaal teken bevatten',
'settings.passwordChanged': 'Wachtwoord succesvol gewijzigd',
'settings.deleteAccount': 'Account verwijderen',
'settings.deleteAccountTitle': 'Account verwijderen?',
@@ -349,7 +349,7 @@ const nl: Record<string, string> = {
// Register
'register.passwordMismatch': 'Wachtwoorden komen niet overeen',
'register.passwordTooShort': 'Wachtwoord moet minimaal 6 tekens bevatten',
'register.passwordTooShort': 'Wachtwoord moet minimaal 8 tekens bevatten',
'register.failed': 'Registratie mislukt',
'register.getStarted': 'Aan de slag',
'register.subtitle': 'Maak een account aan en begin met het plannen van je droomreizen.',

View File

@@ -250,7 +250,7 @@ const ru: Record<string, string> = {
'settings.passwordRequired': 'Введите текущий и новый пароль',
'settings.passwordTooShort': 'Пароль должен содержать не менее 8 символов',
'settings.passwordMismatch': 'Пароли не совпадают',
'settings.passwordWeak': 'Пароль должен содержать заглавные, строчные буквы и цифру',
'settings.passwordWeak': 'Пароль должен содержать заглавные, строчные буквы, цифру и специальный символ',
'settings.passwordChanged': 'Пароль успешно изменён',
'settings.deleteAccount': 'Удалить аккаунт',
'settings.deleteAccountTitle': 'Удалить ваш аккаунт?',
@@ -349,7 +349,7 @@ const ru: Record<string, string> = {
// Register
'register.passwordMismatch': 'Пароли не совпадают',
'register.passwordTooShort': 'Пароль должен содержать не менее 6 символов',
'register.passwordTooShort': 'Пароль должен содержать не менее 8 символов',
'register.failed': 'Ошибка регистрации',
'register.getStarted': 'Начать',
'register.subtitle': 'Создайте аккаунт и начните планировать поездки мечты.',

View File

@@ -250,7 +250,7 @@ const zh: Record<string, string> = {
'settings.passwordRequired': '请输入当前密码和新密码',
'settings.passwordTooShort': '密码至少需要 8 个字符',
'settings.passwordMismatch': '两次输入的密码不一致',
'settings.passwordWeak': '密码必须包含大写字母、小写字母和数字',
'settings.passwordWeak': '密码必须包含大写字母、小写字母、数字和特殊字符',
'settings.passwordChanged': '密码修改成功',
'settings.deleteAccount': '删除账户',
'settings.deleteAccountTitle': '确定删除账户?',
@@ -349,7 +349,7 @@ const zh: Record<string, string> = {
// Register
'register.passwordMismatch': '两次输入的密码不一致',
'register.passwordTooShort': '密码至少需要 6 个字符',
'register.passwordTooShort': '密码至少需要 8 个字符',
'register.failed': '注册失败',
'register.getStarted': '开始使用',
'register.subtitle': '创建账户,开始规划你的梦想旅行。',

View File

@@ -253,6 +253,10 @@ export default function AdminPage(): React.ReactElement {
toast.error(t('admin.toast.fieldsRequired'))
return
}
if (createForm.password.trim().length < 8) {
toast.error(t('settings.passwordTooShort'))
return
}
try {
const data = await adminApi.createUser(createForm)
setUsers(prev => [data.user, ...prev])
@@ -308,7 +312,13 @@ export default function AdminPage(): React.ReactElement {
email: editForm.email.trim() || undefined,
role: editForm.role,
}
if (editForm.password.trim()) payload.password = editForm.password.trim()
if (editForm.password.trim()) {
if (editForm.password.trim().length < 8) {
toast.error(t('settings.passwordTooShort'))
return
}
payload.password = editForm.password.trim()
}
const data = await adminApi.updateUser(editingUser.id, payload)
setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u))
setEditingUser(null)

View File

@@ -150,7 +150,7 @@ export default function LoginPage(): React.ReactElement {
}
if (mode === 'register') {
if (!username.trim()) { setError('Username is required'); setIsLoading(false); return }
if (password.length < 6) { setError('Password must be at least 6 characters'); setIsLoading(false); return }
if (password.length < 8) { setError('Password must be at least 8 characters'); setIsLoading(false); return }
await register(username, email, password, inviteToken || undefined)
} else {
const result = await login(email, password)

View File

@@ -26,7 +26,7 @@ export default function RegisterPage(): React.ReactElement {
return
}
if (password.length < 6) {
if (password.length < 8) {
setError(t('register.passwordTooShort'))
return
}

View File

@@ -10,6 +10,7 @@ import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
import { getAllPermissions, savePermissions, PERMISSION_ACTIONS } from '../services/permissions';
import { revokeUserSessions } from '../mcp';
import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto';
import { validatePassword } from '../services/passwordPolicy';
import { updateJwtSecret } from '../config';
const router = express.Router();
@@ -47,6 +48,9 @@ router.post('/users', (req: Request, res: Response) => {
return res.status(400).json({ error: 'Username, email and password are required' });
}
const pwCheck = validatePassword(password.trim());
if (!pwCheck.ok) return res.status(400).json({ error: pwCheck.reason });
if (role && !['user', 'admin'].includes(role)) {
return res.status(400).json({ error: 'Invalid role' });
}
@@ -97,6 +101,10 @@ router.put('/users/:id', (req: Request, res: Response) => {
if (conflict) return res.status(409).json({ error: 'Email already taken' });
}
if (password) {
const pwCheck = validatePassword(password);
if (!pwCheck.ok) return res.status(400).json({ error: pwCheck.reason });
}
const passwordHash = password ? bcrypt.hashSync(password, 12) : null;
db.prepare(`

View File

@@ -10,6 +10,7 @@ import fetch from 'node-fetch';
import { authenticator } from 'otplib';
import QRCode from 'qrcode';
import { db } from '../db/database';
import { validatePassword } from '../services/passwordPolicy';
import { authenticate, optionalAuth, demoUploadBlock } from '../middleware/auth';
import { JWT_SECRET } from '../config';
import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto';
@@ -268,13 +269,8 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
return res.status(400).json({ error: 'Username, email and password are required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
if (!/[A-Z]/.test(password) || !/[a-z]/.test(password) || !/[0-9]/.test(password)) {
return res.status(400).json({ error: 'Password must contain at least one uppercase letter, one lowercase letter, and one number' });
}
const pwCheck = validatePassword(password);
if (!pwCheck.ok) return res.status(400).json({ error: pwCheck.reason });
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
@@ -382,11 +378,8 @@ router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req
const { current_password, new_password } = req.body;
if (!current_password) return res.status(400).json({ error: 'Current password is required' });
if (!new_password) return res.status(400).json({ error: 'New password is required' });
if (new_password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
if (!/[A-Z]/.test(new_password) || !/[a-z]/.test(new_password) || !/[0-9]/.test(new_password)) {
return res.status(400).json({ error: 'Password must contain at least one uppercase letter, one lowercase letter, and one number' });
}
const pwCheck = validatePassword(new_password);
if (!pwCheck.ok) return res.status(400).json({ error: pwCheck.reason });
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(authReq.user.id) as { password_hash: string } | undefined;
if (!user || !bcrypt.compareSync(current_password, user.password_hash)) {

View File

@@ -0,0 +1,27 @@
const COMMON_PASSWORDS = new Set([
'password', '12345678', '123456789', '1234567890', 'password1',
'qwerty123', 'iloveyou', 'admin123', 'letmein12', 'welcome1',
'monkey123', 'dragon12', 'master12', 'qwerty12', 'abc12345',
'trustno1', 'baseball', 'football', 'shadow12', 'michael1',
'jennifer', 'superman', 'abcdefgh', 'abcd1234', 'password123',
'admin1234', 'changeme', 'welcome123', 'passw0rd', 'p@ssword',
]);
export function validatePassword(password: string): { ok: boolean; reason?: string } {
if (password.length < 8) return { ok: false, reason: 'Password must be at least 8 characters' };
if (/^(.)\1+$/.test(password)) {
return { ok: false, reason: 'Password is too repetitive' };
}
if (COMMON_PASSWORDS.has(password.toLowerCase())) {
return { ok: false, reason: 'Password is too common. Please choose a unique password.' };
}
if (!/[A-Z]/.test(password)) return { ok: false, reason: 'Password must contain at least one uppercase letter' };
if (!/[a-z]/.test(password)) return { ok: false, reason: 'Password must contain at least one lowercase letter' };
if (!/[0-9]/.test(password)) return { ok: false, reason: 'Password must contain at least one number' };
if (!/[^A-Za-z0-9]/.test(password)) return { ok: false, reason: 'Password must contain at least one special character' };
return { ok: true };
}