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({