1import { z } from 'zod';
2
3// Dynamic password constraints configuration
4export interface PasswordConstraints {
5 minLength: number;
6 maxLength: number;
7 requireUppercase: boolean;
8 requireLowercase: boolean;
9 requireNumbers: boolean;
10 requireSpecialChars: boolean;
11 forbiddenPatterns: string[];
12}
13
14// Default password constraints
15export const DEFAULT_PASSWORD_CONSTRAINTS: PasswordConstraints = {
16 minLength: 8,
17 maxLength: 64,
18 requireUppercase: true,
19 requireLowercase: true,
20 requireNumbers: true,
21 requireSpecialChars: false,
22 forbiddenPatterns: ['password', '123456', 'qwerty', 'admin']
23};
24
25// Validation error messages
26export const PASSWORD_ERROR_MESSAGES = {
27 minLength: (min: number) => `Password must be at least ${min} characters long`,
28 maxLength: (max: number) => `Password must be at most ${max} characters long`,
29 requireUppercase: 'Password must contain at least one uppercase letter',
30 requireLowercase: 'Password must contain at least one lowercase letter',
31 requireNumbers: 'Password must contain at least one number',
32 requireSpecialChars: 'Password must contain at least one special character (!@#$%^&*)',
33 forbiddenPattern: (pattern: string) => `Password cannot contain '${pattern}'`,
34 common: 'Password is too common or weak'
35};
36
37// Create dynamic password schema based on constraints
38export function createPasswordSchema(
39 constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS
40) {
41 let schema = z
42 .string()
43 .min(constraints.minLength, PASSWORD_ERROR_MESSAGES.minLength(constraints.minLength))
44 .max(constraints.maxLength, PASSWORD_ERROR_MESSAGES.maxLength(constraints.maxLength));
45
46 // Add character type requirements
47 if (constraints.requireUppercase) {
48 schema = schema.regex(/[A-Z]/, PASSWORD_ERROR_MESSAGES.requireUppercase);
49 }
50
51 if (constraints.requireLowercase) {
52 schema = schema.regex(/[a-z]/, PASSWORD_ERROR_MESSAGES.requireLowercase);
53 }
54
55 if (constraints.requireNumbers) {
56 schema = schema.regex(/\d/, PASSWORD_ERROR_MESSAGES.requireNumbers);
57 }
58
59 if (constraints.requireSpecialChars) {
60 schema = schema.regex(
61 /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/,
62 PASSWORD_ERROR_MESSAGES.requireSpecialChars
63 );
64 }
65
66 // Add forbidden patterns check
67 if (constraints.forbiddenPatterns.length > 0) {
68 return schema.refine(
69 (password) => {
70 const lowerPassword = password.toLowerCase();
71 return !constraints.forbiddenPatterns.some((pattern) =>
72 lowerPassword.includes(pattern.toLowerCase())
73 );
74 },
75 {
76 message: PASSWORD_ERROR_MESSAGES.common
77 }
78 );
79 }
80
81 return schema;
82}
83
84// Login validation schema
85export const loginSchema = z.object({
86 username: z
87 .string()
88 .min(3, 'Username must be at least 3 characters')
89 .max(32, 'Username must be at most 32 characters')
90 .regex(
91 /^[a-zA-Z0-9_-]+$/,
92 'Username can only contain letters, numbers, underscores, and dashes'
93 ),
94 password: z.string().min(1, 'Password is required')
95});
96
97// Registration validation schema (using dynamic password validation)
98export function createRegistrationSchema(
99 constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS
100) {
101 return z
102 .object({
103 suUsername: z
104 .string()
105 .min(3, 'Username must be at least 3 characters')
106 .max(32, 'Username must be at most 32 characters')
107 .regex(
108 /^[a-zA-Z0-9_-]+$/,
109 'Username can only contain letters, numbers, underscores, and dashes'
110 ),
111 suPassword: createPasswordSchema(constraints),
112 suConfirmPassword: z.string(),
113 captchaAnswer: z.string().min(1, 'Please answer the PDR question'),
114 turnstileToken: z.string().optional()
115 })
116 .refine((data) => data.suPassword === data.suConfirmPassword, {
117 message: 'Passwords do not match',
118 path: ['suConfirmPassword']
119 });
120}
121
122// Utility function to validate password strength and return detailed feedback
123export function validatePasswordStrength(
124 password: string,
125 constraints: PasswordConstraints = DEFAULT_PASSWORD_CONSTRAINTS
126) {
127 const errors: string[] = [];
128 const requirements: { met: boolean; message: string }[] = [];
129
130 // Length checks
131 if (password.length < constraints.minLength) {
132 errors.push(PASSWORD_ERROR_MESSAGES.minLength(constraints.minLength));
133 }
134 requirements.push({
135 met: password.length >= constraints.minLength,
136 message: `At least ${constraints.minLength} characters`
137 });
138
139 if (password.length > constraints.maxLength) {
140 errors.push(PASSWORD_ERROR_MESSAGES.maxLength(constraints.maxLength));
141 }
142
143 // Character type checks
144 if (constraints.requireUppercase) {
145 const hasUppercase = /[A-Z]/.test(password);
146 if (!hasUppercase) errors.push(PASSWORD_ERROR_MESSAGES.requireUppercase);
147 requirements.push({
148 met: hasUppercase,
149 message: 'At least one uppercase letter'
150 });
151 }
152
153 if (constraints.requireLowercase) {
154 const hasLowercase = /[a-z]/.test(password);
155 if (!hasLowercase) errors.push(PASSWORD_ERROR_MESSAGES.requireLowercase);
156 requirements.push({
157 met: hasLowercase,
158 message: 'At least one lowercase letter'
159 });
160 }
161
162 if (constraints.requireNumbers) {
163 const hasNumbers = /\d/.test(password);
164 if (!hasNumbers) errors.push(PASSWORD_ERROR_MESSAGES.requireNumbers);
165 requirements.push({
166 met: hasNumbers,
167 message: 'At least one number'
168 });
169 }
170
171 if (constraints.requireSpecialChars) {
172 const hasSpecialChars = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password);
173 if (!hasSpecialChars) errors.push(PASSWORD_ERROR_MESSAGES.requireSpecialChars);
174 requirements.push({
175 met: hasSpecialChars,
176 message: 'At least one special character'
177 });
178 }
179
180 // Forbidden patterns check
181 if (constraints.forbiddenPatterns.length > 0) {
182 const lowerPassword = password.toLowerCase();
183 const hasForbiddenPattern = constraints.forbiddenPatterns.some((pattern) =>
184 lowerPassword.includes(pattern.toLowerCase())
185 );
186 if (hasForbiddenPattern) {
187 errors.push(PASSWORD_ERROR_MESSAGES.common);
188 }
189 requirements.push({
190 met: !hasForbiddenPattern,
191 message: 'Not a common or weak password'
192 });
193 }
194
195 return {
196 isValid: errors.length === 0,
197 errors,
198 requirements,
199 strength: calculatePasswordStrength(password, requirements)
200 };
201}
202
203// Calculate password strength score (0-100)
204function calculatePasswordStrength(
205 password: string,
206 requirements: { met: boolean; message: string }[]
207): number {
208 const metRequirements = requirements.filter((req) => req.met).length;
209 const totalRequirements = requirements.length;
210
211 if (totalRequirements === 0) return 0;
212
213 let baseScore = (metRequirements / totalRequirements) * 80;
214
215 // Bonus points for length beyond minimum
216 if (password.length > 12) baseScore += 10;
217 if (password.length > 16) baseScore += 10;
218
219 return Math.min(100, Math.round(baseScore));
220}