Skip to content

Commit 4a1c109

Browse files
committed
Harden signup/reset flows: add trimmed name/email validation, password policy with bcrypt 72-byte limit, confirm-password match, and blur-gated inline errors
1 parent 1190ca4 commit 4a1c109

12 files changed

Lines changed: 229 additions & 125 deletions

File tree

frontend/app/api/auth/password-reset/confirm/route.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,20 @@ import { z } from 'zod';
66
import { db } from '@/db';
77
import { passwordResetTokens } from '@/db/schema/passwordResetTokens';
88
import { users } from '@/db/schema/users';
9-
import {
10-
PASSWORD_MAX_LEN,
11-
PASSWORD_MIN_LEN,
12-
PASSWORD_POLICY_REGEX,
13-
} from '@/lib/auth/signup-constraints';
9+
import { PASSWORD_MAX_BYTES, PASSWORD_MIN_LEN, PASSWORD_POLICY_REGEX } from '@/lib/auth/signup-constraints';
1410

1511
const schema = z.object({
1612
token: z.string().uuid(),
1713
password: z
1814
.string()
19-
.min(
20-
PASSWORD_MIN_LEN,
21-
`Password must be at least ${PASSWORD_MIN_LEN} characters`
22-
)
23-
.max(
24-
PASSWORD_MAX_LEN,
25-
`Password must be at most ${PASSWORD_MAX_LEN} characters`
26-
)
15+
.min(PASSWORD_MIN_LEN, `Password must be at least ${PASSWORD_MIN_LEN} characters`)
2716
.regex(/[A-Z]/, 'Password must contain at least one capital letter')
28-
.regex(
29-
/[^A-Za-z0-9]/,
30-
'Password must contain at least one special character'
17+
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character')
18+
.regex(PASSWORD_POLICY_REGEX, 'Password does not meet the required policy')
19+
.refine(
20+
(val) => Buffer.byteLength(val, 'utf8') <= PASSWORD_MAX_BYTES,
21+
`Password must be at most ${PASSWORD_MAX_BYTES} bytes`
3122
)
32-
.regex(PASSWORD_POLICY_REGEX, 'Password does not meet the required policy'),
3323
});
3424

3525
function firstFieldErrorMessage(

frontend/app/api/auth/signup/route.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
EMAIL_MIN_LEN,
1313
NAME_MAX_LEN,
1414
NAME_MIN_LEN,
15-
PASSWORD_MAX_LEN,
15+
PASSWORD_MAX_BYTES,
1616
PASSWORD_MIN_LEN,
1717
PASSWORD_POLICY_REGEX,
1818
} from '@/lib/auth/signup-constraints';
@@ -21,6 +21,20 @@ import { resolveBaseUrl } from '@/lib/http/getBaseUrl';
2121

2222
export const runtime = 'nodejs';
2323

24+
const passwordSchema = z
25+
.string()
26+
.min(
27+
PASSWORD_MIN_LEN,
28+
`Password must be at least ${PASSWORD_MIN_LEN} characters`
29+
)
30+
.regex(/[A-Z]/, 'Password must contain at least one capital letter')
31+
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character')
32+
.regex(PASSWORD_POLICY_REGEX, 'Password does not meet the required policy')
33+
.refine(
34+
val => Buffer.byteLength(val, 'utf8') <= PASSWORD_MAX_BYTES,
35+
`Password must be at most ${PASSWORD_MAX_BYTES} bytes`
36+
);
37+
2438
const signupSchema = z
2539
.object({
2640
name: z
@@ -34,14 +48,8 @@ const signupSchema = z
3448
.min(EMAIL_MIN_LEN, `Email must be at least ${EMAIL_MIN_LEN} characters`)
3549
.max(EMAIL_MAX_LEN, `Email must be at most ${EMAIL_MAX_LEN} characters`)
3650
.email('Invalid email'),
37-
password: z
38-
.string()
39-
.min(PASSWORD_MIN_LEN, `Password must be at least ${PASSWORD_MIN_LEN} characters`)
40-
.max(PASSWORD_MAX_LEN, `Password must be at most ${PASSWORD_MAX_LEN} characters`)
41-
.regex(/[A-Z]/, 'Password must contain at least one capital letter')
42-
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character')
43-
.regex(PASSWORD_POLICY_REGEX, 'Password does not meet the required policy'),
44-
confirmPassword: z.string(),
51+
password: passwordSchema,
52+
confirmPassword: passwordSchema,
4553
})
4654
.superRefine((val, ctx) => {
4755
if (val.password !== val.confirmPassword) {
@@ -109,10 +117,7 @@ export async function POST(req: Request) {
109117
});
110118

111119
return NextResponse.json(
112-
{
113-
success: true,
114-
verificationRequired: true,
115-
},
120+
{ success: true, verificationRequired: true },
116121
{ status: 201 }
117122
);
118123
} catch (error) {

frontend/components/auth/ResetPasswordForm.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { AuthSuccessBanner } from '@/components/auth/AuthSuccessBanner';
99
import { PasswordField } from '@/components/auth/fields/PasswordField';
1010
import { Button } from '@/components/ui/button';
1111
import {
12-
PASSWORD_MAX_LEN,
12+
PASSWORD_MAX_BYTES,
1313
PASSWORD_MIN_LEN,
1414
PASSWORD_POLICY_REGEX,
1515
} from '@/lib/auth/signup-constraints';
@@ -20,6 +20,7 @@ type ResetPasswordFormProps = {
2020

2121
export function ResetPasswordForm({ token }: ResetPasswordFormProps) {
2222
const t = useTranslations('auth.resetPassword');
23+
const tf = useTranslations('auth.fields');
2324
const [loading, setLoading] = useState(false);
2425
const [error, setError] = useState<string | null>(null);
2526
const [success, setSuccess] = useState(false);
@@ -30,16 +31,16 @@ export function ResetPasswordForm({ token }: ResetPasswordFormProps) {
3031
const passwordPolicyOk = useMemo(() => {
3132
if (!passwordValue) return false;
3233
if (passwordValue.length < PASSWORD_MIN_LEN) return false;
33-
if (passwordValue.length > PASSWORD_MAX_LEN) return false;
34+
if (passwordValue.length > PASSWORD_MAX_BYTES) return false;
3435
return PASSWORD_POLICY_REGEX.test(passwordValue);
3536
}, [passwordValue]);
3637

3738
const passwordRequirementsText =
38-
'8–128 characters, at least one capital letter, and at least one special character.';
39+
tf('validation.passwordRequirements', { PASSWORD_MIN_LEN, PASSWORD_MAX_BYTES });
3940

4041
const passwordErrorText =
4142
passwordTouched && !passwordPolicyOk
42-
? `Password must meet requirements: ${passwordRequirementsText}`
43+
? tf('validation.invalidPassword', { passwordRequirementsText })
4344
: null;
4445

4546
const submitDisabled = loading || !passwordPolicyOk;
@@ -93,10 +94,9 @@ export function ResetPasswordForm({ token }: ResetPasswordFormProps) {
9394
<PasswordField
9495
id="password"
9596
name="password"
96-
placeholder="New password"
97+
placeholder={tf('setNewPassword')}
9798
autoComplete="new-password"
9899
minLength={PASSWORD_MIN_LEN}
99-
maxLength={PASSWORD_MAX_LEN}
100100
pattern={PASSWORD_POLICY_REGEX.source}
101101
onChange={setPasswordValue}
102102
onBlur={() => setPasswordTouched(true)}

frontend/components/auth/SignupForm.tsx

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
EMAIL_MIN_LEN,
1919
NAME_MAX_LEN,
2020
NAME_MIN_LEN,
21-
PASSWORD_MAX_LEN,
21+
PASSWORD_MAX_BYTES,
2222
PASSWORD_MIN_LEN,
2323
PASSWORD_POLICY_REGEX,
2424
} from '@/lib/auth/signup-constraints';
@@ -28,8 +28,13 @@ type SignupFormProps = {
2828
returnTo: string;
2929
};
3030

31+
function utf8ByteLength(value: string): number {
32+
return new TextEncoder().encode(value).length;
33+
}
34+
3135
export function SignupForm({ locale, returnTo }: SignupFormProps) {
3236
const t = useTranslations('auth.signup');
37+
const tf = useTranslations('auth.fields');
3338

3439
const [loading, setLoading] = useState(false);
3540
const [error, setError] = useState<string | null>(null);
@@ -68,15 +73,17 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) {
6873
const passwordPolicyOk = useMemo(() => {
6974
if (!passwordValue) return false;
7075
if (passwordValue.length < PASSWORD_MIN_LEN) return false;
71-
if (passwordValue.length > PASSWORD_MAX_LEN) return false;
72-
return PASSWORD_POLICY_REGEX.test(passwordValue);
76+
if (!PASSWORD_POLICY_REGEX.test(passwordValue)) return false;
77+
if (utf8ByteLength(passwordValue) > PASSWORD_MAX_BYTES) return false;
78+
return true;
7379
}, [passwordValue]);
7480

7581
const confirmPasswordPolicyOk = useMemo(() => {
7682
if (!confirmPasswordValue) return false;
7783
if (confirmPasswordValue.length < PASSWORD_MIN_LEN) return false;
78-
if (confirmPasswordValue.length > PASSWORD_MAX_LEN) return false;
79-
return PASSWORD_POLICY_REGEX.test(confirmPasswordValue);
84+
if (!PASSWORD_POLICY_REGEX.test(confirmPasswordValue)) return false;
85+
if (utf8ByteLength(confirmPasswordValue) > PASSWORD_MAX_BYTES) return false;
86+
return true;
8087
}, [confirmPasswordValue]);
8188

8289
const passwordsMatch = useMemo(() => {
@@ -86,36 +93,37 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) {
8693

8794
const nameErrorText =
8895
nameTouched && !nameLooksValid
89-
? `Name must be at least ${NAME_MIN_LEN} characters and at most ${NAME_MAX_LEN} characters`
96+
? tf('validation.invalidName', { NAME_MIN_LEN, NAME_MAX_LEN })
9097
: null;
9198

9299
const emailErrorText = useMemo(() => {
93100
if (!emailTouched) return null;
94-
95-
if (!emailTrimmed) return "Email is required";
101+
if (!emailTrimmed) return null;
96102

97103
if (emailTrimmed.length > EMAIL_MAX_LEN) {
98-
return `Email must not exceed ${EMAIL_MAX_LEN} characters.`;
104+
return tf('validation.emailTooLong', { EMAIL_MAX_LEN });
99105
}
100106

101107
if (!emailFormatOk) {
102-
return 'Email format is invalid.';
108+
return tf('validation.invalidEmail');
103109
}
104110

105111
return null;
106-
}, [emailTouched, emailTrimmed, emailFormatOk]);
112+
}, [emailTouched, emailTrimmed, emailFormatOk, tf]);
107113

108-
const passwordRequirementsText =
109-
'8–128 characters, at least one capital letter, and at least one special character.';
114+
const passwordRequirementsText = tf('validation.passwordRequirements', {
115+
PASSWORD_MIN_LEN,
116+
PASSWORD_MAX_BYTES,
117+
});
110118

111119
const passwordErrorText =
112120
passwordTouched && !passwordPolicyOk
113-
? `Password must meet requirements: ${passwordRequirementsText}`
121+
? tf('validation.invalidPassword', { passwordRequirementsText })
114122
: null;
115123

116124
const confirmPolicyErrorText =
117125
confirmPasswordTouched && !confirmPasswordPolicyOk
118-
? `Password must meet requirements: ${passwordRequirementsText}`
126+
? tf('validation.invalidPassword', { passwordRequirementsText })
119127
: null;
120128

121129
const mismatchErrorText =
@@ -124,11 +132,10 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) {
124132
passwordValue.length > 0 &&
125133
confirmPasswordValue.length > 0 &&
126134
!passwordsMatch
127-
? 'Passwords do not match.'
135+
? tf('validation.passwordsDontMatch')
128136
: null;
129137

130-
const confirmPasswordErrorText =
131-
mismatchErrorText ?? confirmPolicyErrorText ?? null;
138+
const confirmPasswordErrorText = mismatchErrorText ?? confirmPolicyErrorText ?? null;
132139

133140
const submitDisabled =
134141
loading ||
@@ -238,9 +245,7 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) {
238245
onChange={setNameValue}
239246
onBlur={() => setNameTouched(true)}
240247
/>
241-
{nameErrorText && (
242-
<p className="text-sm text-red-600">{nameErrorText}</p>
243-
)}
248+
{nameErrorText && <p className="text-sm text-red-600">{nameErrorText}</p>}
244249
</div>
245250

246251
<div className="space-y-1">
@@ -250,47 +255,53 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) {
250255
onChange={value => {
251256
setEmailValueLive(value);
252257
setEmail(value);
253-
254258
}}
255259
onBlur={() => setEmailTouched(true)}
256260
/>
257-
{emailErrorText && (
258-
<p className="text-sm text-red-600">{emailErrorText}</p>
259-
)}
261+
{emailErrorText && <p className="text-sm text-red-600">{emailErrorText}</p>}
260262
</div>
261263

262264
<div className="space-y-1">
263265
<PasswordField
264266
id="password"
265267
name="password"
266-
placeholder="Password"
268+
placeholder={tf('password')}
267269
autoComplete="new-password"
268270
minLength={PASSWORD_MIN_LEN}
269-
maxLength={PASSWORD_MAX_LEN}
270271
pattern={PASSWORD_POLICY_REGEX.source}
271272
onChange={setPasswordValue}
272273
onBlur={() => setPasswordTouched(true)}
273274
/>
274275
{passwordErrorText && (
275276
<p className="text-sm text-red-600">{passwordErrorText}</p>
276277
)}
278+
{passwordTouched && utf8ByteLength(passwordValue) > PASSWORD_MAX_BYTES && (
279+
<p className="text-sm text-red-600">
280+
{tf('validation.passwordTooLongBytes', { PASSWORD_MAX_BYTES })}
281+
</p>
282+
)}
277283
</div>
278284

279285
<div className="space-y-1">
280286
<PasswordField
281287
id="confirmPassword"
282288
name="confirmPassword"
283-
placeholder="Confirm password"
289+
placeholder={tf('confirmPassword')}
284290
autoComplete="new-password"
285291
minLength={PASSWORD_MIN_LEN}
286-
maxLength={PASSWORD_MAX_LEN}
287292
pattern={PASSWORD_POLICY_REGEX.source}
288293
onChange={setConfirmPasswordValue}
289294
onBlur={() => setConfirmPasswordTouched(true)}
290295
/>
291296
{confirmPasswordErrorText && (
292297
<p className="text-sm text-red-600">{confirmPasswordErrorText}</p>
293298
)}
299+
{confirmPasswordTouched &&
300+
utf8ByteLength(confirmPasswordValue) > PASSWORD_MAX_BYTES && (
301+
<p className="text-sm text-red-600">
302+
{tf('validation.passwordTooLongBytes', { PASSWORD_MAX_BYTES })}
303+
</p>
304+
)}
294305
</div>
295306

296307
{error && <AuthErrorBanner message={error} />}

frontend/components/auth/fields/EmailField.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,11 @@ export function EmailField({
4545
e.currentTarget.setCustomValidity('');
4646
};
4747

48-
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
48+
const handleBlur: React.FocusEventHandler<HTMLInputElement> = (e) => {
4949
const input = e.currentTarget;
5050
const trimmed = input.value.trim();
5151

52-
if (trimmed !== input.value) {
53-
input.value = trimmed;
54-
}
52+
input.value = trimmed;
5553

5654
onChange?.(trimmed);
5755
onBlur?.(e);
@@ -68,7 +66,7 @@ export function EmailField({
6866
className="w-full rounded border px-3 py-2"
6967
onInvalid={handleInvalid}
7068
onInput={handleInput}
71-
onChange={onChange ? e => onChange(e.currentTarget.value) : undefined}
69+
onChange={e => onChange?.(e.currentTarget.value)}
7270
onBlur={handleBlur}
7371
/>
7472
);

frontend/components/auth/fields/NameField.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function NameField({
1515
minLength,
1616
maxLength,
1717
onChange,
18-
onBlur
18+
onBlur,
1919
}: NameFieldProps) {
2020
const t = useTranslations('auth.fields');
2121

0 commit comments

Comments
 (0)