From 9cf1eb615ca99e53c2e1606c4e2e20a30a986156 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 18 Oct 2026 19:20:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20make=20password=20length=20config?= =?UTF-8?q?urable=20via=20env=20(min=205=E2=80=9330,=20max=2030=E2=80=9312?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PASSWORD_MIN_LENGTH / PASSWORD_MAX_LENGTH (validated in src/env.ts) and a shared passwordPolicy helper. The policy drives the zod schemas in register, password reset and the admin create-user dialog, plus the live requirement checks and strength meter in the register form. Locale messages now interpolate the configured minimum via {min} instead of hard-coding 12. --- .env.example | 4 ++++ src/actions/password-reset.ts | 7 ++++++- src/actions/register.ts | 14 ++++++++++---- src/app/(site)/register/page.tsx | 3 +++ src/app/(site)/reset/page.tsx | 9 +++++++-- src/components/auth/auth-field.tsx | 6 ++++++ src/components/auth/register-form.tsx | 23 ++++++++++++++++++----- src/env.ts | 5 +++++ src/lib/auth/password-policy.ts | 27 +++++++++++++++++++++++++++ src/lib/validators/user.test.ts | 10 +++++----- src/lib/validators/user.ts | 12 +++++++++++- src/messages/ar.json | 10 +++++----- src/messages/bg.json | 10 +++++----- src/messages/cs.json | 10 +++++----- src/messages/da.json | 10 +++++----- src/messages/de.json | 10 +++++----- src/messages/el.json | 10 +++++----- src/messages/en.json | 10 +++++----- src/messages/es.json | 10 +++++----- src/messages/fi.json | 10 +++++----- src/messages/fr.json | 10 +++++----- src/messages/hr.json | 10 +++++----- src/messages/hu.json | 10 +++++----- src/messages/it.json | 10 +++++----- src/messages/ja.json | 10 +++++----- src/messages/nl.json | 10 +++++----- src/messages/no.json | 10 +++++----- src/messages/pl.json | 10 +++++----- src/messages/pt.json | 10 +++++----- src/messages/ro.json | 10 +++++----- src/messages/ru.json | 10 +++++----- src/messages/sk.json | 10 +++++----- src/messages/sr.json | 10 +++++----- src/messages/sv.json | 10 +++++----- src/messages/tr.json | 10 +++++----- src/messages/uk.json | 10 +++++----- 36 files changed, 227 insertions(+), 143 deletions(-) create mode 100644 src/lib/auth/password-policy.ts diff --git a/.env.example b/.env.example index 37f5f68d..300b23a8 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,10 @@ AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars APP_KEY=base64:your-app-key-here= # Bcrypt cost factor for new password hashes. BCRYPT_COST=12 +# Password-length policy for every new password (register, password reset and +# staff-created accounts). Minimum: 5-30. Maximum: 30-128. +PASSWORD_MIN_LENGTH=12 +PASSWORD_MAX_LENGTH=128 # --- ANTI-DDOS (app-layer gate, production only) --- # On by default in production. Set to "false" to disable (not recommended). diff --git a/src/actions/password-reset.ts b/src/actions/password-reset.ts index 93794de5..8d948501 100644 --- a/src/actions/password-reset.ts +++ b/src/actions/password-reset.ts @@ -6,6 +6,7 @@ import { redirect } from "next/navigation"; import { env } from "@/env"; import { invalidateLoginCache } from "@/lib/auth/login-core"; import { hashPassword } from "@/lib/auth/password"; +import { passwordPolicy } from "@/lib/auth/password-policy"; import { revokeUserCredentials } from "@/lib/auth/session-revocation"; import { db, PasswordReset, User } from "@/lib/db"; import { logger } from "@/lib/logger"; @@ -109,7 +110,11 @@ export async function resetPassword(formData: FormData): Promise { } let error: "password" | "invalid" | "failed" | null = null; - if (password.length < 12) error = "password"; + if ( + password.length < passwordPolicy.minLength || + password.length > passwordPolicy.maxLength + ) + error = "password"; if (!error) { try { diff --git a/src/actions/register.ts b/src/actions/register.ts index 366d6055..7e3652c9 100644 --- a/src/actions/register.ts +++ b/src/actions/register.ts @@ -5,6 +5,7 @@ import { after } from "next/server"; import { z } from "zod"; import { sendVerification } from "@/lib/auth/email-verification"; import { hashPassword } from "@/lib/auth/password"; +import { passwordPolicy } from "@/lib/auth/password-policy"; import { DEFAULT_AVATAR_FIGURE } from "@/lib/avatar-figure"; import { invalidateKey } from "@/lib/cached-db"; import { db, User } from "@/lib/db"; @@ -83,6 +84,11 @@ function hasDisposableEmailDomain(email: string): boolean { return domain ? DISPOSABLE_EMAIL_DOMAINS.has(domain) : false; } +// Rendered into the schema below and mapped back to a locale-independent code +// via `ZOD_MESSAGE_CODES`, so the length always reflects the configured policy. +const PASSWORD_MIN_MESSAGE = `Password must be at least ${passwordPolicy.minLength} characters`; +const PASSWORD_MAX_MESSAGE = "Password is too long"; + const registerSchema = z .object({ username: z @@ -105,8 +111,8 @@ const registerSchema = z ), password: z .string() - .min(12, "Password must be at least 12 characters") // Increased min length - .max(128, "Password is too long") // Added max length + .min(passwordPolicy.minLength, PASSWORD_MIN_MESSAGE) + .max(passwordPolicy.maxLength, PASSWORD_MAX_MESSAGE) .regex(/[A-Z]/, "Password must contain at least one uppercase letter") .regex(/[a-z]/, "Password must contain at least one lowercase letter") .regex(/[0-9]/, "Password must contain at least one digit") @@ -160,8 +166,8 @@ const ZOD_MESSAGE_CODES: Record = { "This username is reserved": "usernameReserved", "Enter a valid email address": "emailValid", "Temporary email domains are not allowed": "emailDisposable", - "Password must be at least 12 characters": "passwordMinLength", - "Password is too long": "passwordMaxLength", + [PASSWORD_MIN_MESSAGE]: "passwordMinLength", + [PASSWORD_MAX_MESSAGE]: "passwordMaxLength", "Password must contain at least one uppercase letter": "passwordUpper", "Password must contain at least one lowercase letter": "passwordLower", "Password must contain at least one digit": "passwordDigit", diff --git a/src/app/(site)/register/page.tsx b/src/app/(site)/register/page.tsx index c81b64bc..e0656e9e 100644 --- a/src/app/(site)/register/page.tsx +++ b/src/app/(site)/register/page.tsx @@ -7,6 +7,7 @@ import { } from "@/components/auth/auth-landing"; import { RegisterForm } from "@/components/auth/register-form"; import { auth } from "@/lib/auth"; +import { passwordPolicy } from "@/lib/auth/password-policy"; export async function generateMetadata(): Promise { const t = await getTranslations("pages.register"); @@ -50,6 +51,8 @@ export default async function RegisterPage() { hotelName={data.hotelName} captcha={data.captcha} nonce={data.nonce} + passwordMinLength={passwordPolicy.minLength} + passwordMaxLength={passwordPolicy.maxLength} /> } /> diff --git a/src/app/(site)/reset/page.tsx b/src/app/(site)/reset/page.tsx index d4e7c9a0..4e4a4ba7 100644 --- a/src/app/(site)/reset/page.tsx +++ b/src/app/(site)/reset/page.tsx @@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server"; import { resetPassword } from "@/actions/password-reset"; import Link from "@/components/link"; import { ContentCard } from "@/components/public/ui"; +import { passwordPolicy } from "@/lib/auth/password-policy"; export async function generateMetadata(): Promise { const t = await getTranslations("pages.reset"); @@ -41,8 +42,12 @@ export default async function ResetPage({