fix(auth): align frontend password validation with production policy
This commit is contained in:
@@ -397,6 +397,16 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
|||||||
|
|
||||||
const parsed = adminCreateUserSchema.safeParse(req.body);
|
const parsed = adminCreateUserSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
const summarizedIssues = parsed.error.issues.map((issue) => ({
|
||||||
|
code: issue.code,
|
||||||
|
path: issue.path.join("."),
|
||||||
|
message: issue.message,
|
||||||
|
}));
|
||||||
|
console.warn("[auth/users] validation failed", {
|
||||||
|
issues: summarizedIssues,
|
||||||
|
requestId: req.headers["x-request-id"],
|
||||||
|
ip: req.ip || req.connection.remoteAddress || "unknown",
|
||||||
|
});
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: "Validation error",
|
error: "Validation error",
|
||||||
message: "Invalid user payload",
|
message: "Invalid user payload",
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import {
|
|||||||
readImpersonationState,
|
readImpersonationState,
|
||||||
USER_KEY,
|
USER_KEY,
|
||||||
} from '../utils/impersonation';
|
} from '../utils/impersonation';
|
||||||
|
import {
|
||||||
|
getPasswordMinLength,
|
||||||
|
getPasswordRequirementsLabel,
|
||||||
|
validatePasswordForCurrentEnv,
|
||||||
|
} from '../utils/passwordPolicy';
|
||||||
|
|
||||||
type AdminUser = {
|
type AdminUser = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -226,6 +231,12 @@ export const Admin: React.FC = () => {
|
|||||||
setError('');
|
setError('');
|
||||||
setSuccess('');
|
setSuccess('');
|
||||||
|
|
||||||
|
const passwordError = validatePasswordForCurrentEnv(createPassword, 'Temporary password');
|
||||||
|
if (passwordError) {
|
||||||
|
setError(passwordError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
email: createEmail.trim().toLowerCase(),
|
email: createEmail.trim().toLowerCase(),
|
||||||
@@ -430,8 +441,9 @@ export const Admin: React.FC = () => {
|
|||||||
type="password"
|
type="password"
|
||||||
value={createPassword}
|
value={createPassword}
|
||||||
onChange={e => setCreatePassword(e.target.value)}
|
onChange={e => setCreatePassword(e.target.value)}
|
||||||
minLength={8}
|
minLength={getPasswordMinLength()}
|
||||||
required
|
required
|
||||||
|
placeholder={`Temporary password (${getPasswordRequirementsLabel()})`}
|
||||||
className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white outline-none"
|
className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { useAuth } from '../context/AuthContext';
|
|||||||
import { Logo } from '../components/Logo';
|
import { Logo } from '../components/Logo';
|
||||||
import * as api from '../api';
|
import * as api from '../api';
|
||||||
import { USER_KEY } from '../utils/impersonation';
|
import { USER_KEY } from '../utils/impersonation';
|
||||||
|
import {
|
||||||
|
getPasswordMinLength,
|
||||||
|
getPasswordRequirementsLabel,
|
||||||
|
validatePasswordForCurrentEnv,
|
||||||
|
} from '../utils/passwordPolicy';
|
||||||
|
|
||||||
export const Login: React.FC = () => {
|
export const Login: React.FC = () => {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -105,8 +110,9 @@ export const Login: React.FC = () => {
|
|||||||
setError('Please enter and confirm a new password');
|
setError('Please enter and confirm a new password');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword.length < 8) {
|
const passwordError = validatePasswordForCurrentEnv(newPassword, 'New password');
|
||||||
setError('New password must be at least 8 characters long');
|
if (passwordError) {
|
||||||
|
setError(passwordError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword !== confirmNewPassword) {
|
if (newPassword !== confirmNewPassword) {
|
||||||
@@ -233,9 +239,9 @@ export const Login: React.FC = () => {
|
|||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={getPasswordMinLength()}
|
||||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||||
placeholder="New password (min 8 characters)"
|
placeholder={`New password (${getPasswordRequirementsLabel()})`}
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -250,7 +256,7 @@ export const Login: React.FC = () => {
|
|||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={getPasswordMinLength()}
|
||||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||||
placeholder="Confirm new password"
|
placeholder="Confirm new password"
|
||||||
value={confirmNewPassword}
|
value={confirmNewPassword}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
|
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { Logo } from '../components/Logo';
|
import { Logo } from '../components/Logo';
|
||||||
import { authPasswordResetConfirm, isAxiosError } from '../api';
|
import { authPasswordResetConfirm, isAxiosError } from '../api';
|
||||||
|
import {
|
||||||
|
getPasswordRequirementsLabel,
|
||||||
|
validatePasswordForCurrentEnv,
|
||||||
|
} from '../utils/passwordPolicy';
|
||||||
|
|
||||||
export const PasswordResetConfirm: React.FC = () => {
|
export const PasswordResetConfirm: React.FC = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -29,8 +33,9 @@ export const PasswordResetConfirm: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 8) {
|
const passwordError = validatePasswordForCurrentEnv(password);
|
||||||
setError('Password must be at least 8 characters long');
|
if (passwordError) {
|
||||||
|
setError(passwordError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +129,7 @@ export const PasswordResetConfirm: React.FC = () => {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||||
placeholder="New password (min 8 characters)"
|
placeholder={`New password (${getPasswordRequirementsLabel()})`}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import * as api from '../api';
|
|||||||
import type { Collection } from '../types';
|
import type { Collection } from '../types';
|
||||||
import { User, Lock, Save, X, Shield } from 'lucide-react';
|
import { User, Lock, Save, X, Shield } from 'lucide-react';
|
||||||
import { USER_KEY } from '../utils/impersonation';
|
import { USER_KEY } from '../utils/impersonation';
|
||||||
|
import {
|
||||||
|
getPasswordRequirementsLabel,
|
||||||
|
validatePasswordForCurrentEnv,
|
||||||
|
} from '../utils/passwordPolicy';
|
||||||
|
|
||||||
export const Profile: React.FC = () => {
|
export const Profile: React.FC = () => {
|
||||||
const { user: authUser, logout, authEnabled } = useAuth();
|
const { user: authUser, logout, authEnabled } = useAuth();
|
||||||
@@ -162,8 +166,9 @@ export const Profile: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 8) {
|
const passwordError = validatePasswordForCurrentEnv(newPassword, 'New password');
|
||||||
setError('New password must be at least 8 characters long');
|
if (passwordError) {
|
||||||
|
setError(passwordError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,7 +493,7 @@ export const Profile: React.FC = () => {
|
|||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500 dark:focus:ring-rose-400 font-medium"
|
className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500 dark:focus:ring-rose-400 font-medium"
|
||||||
placeholder="Enter new password (min 8 characters)"
|
placeholder={`Enter new password (${getPasswordRequirementsLabel()})`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { Logo } from '../components/Logo';
|
import { Logo } from '../components/Logo';
|
||||||
|
import {
|
||||||
|
getPasswordMinLength,
|
||||||
|
getPasswordRequirementsLabel,
|
||||||
|
validatePasswordForCurrentEnv,
|
||||||
|
} from '../utils/passwordPolicy';
|
||||||
|
|
||||||
export const Register: React.FC = () => {
|
export const Register: React.FC = () => {
|
||||||
const strongPasswordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{12,100}$/;
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
@@ -44,13 +48,9 @@ export const Register: React.FC = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
if (import.meta.env.PROD) {
|
const passwordError = validatePasswordForCurrentEnv(password);
|
||||||
if (!strongPasswordPattern.test(password)) {
|
if (passwordError) {
|
||||||
setError('Password must be 12+ chars and include upper, lower, number, and symbol');
|
setError(passwordError);
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else if (password.length < 8) {
|
|
||||||
setError('Password must be at least 8 characters long');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,11 +140,9 @@ export const Register: React.FC = () => {
|
|||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
minLength={import.meta.env.PROD ? 12 : 8}
|
minLength={getPasswordMinLength()}
|
||||||
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||||
placeholder={import.meta.env.PROD
|
placeholder={`Password (${getPasswordRequirementsLabel()})`}
|
||||||
? "Password (12+, upper/lower/number/symbol)"
|
|
||||||
: "Password (min 8 characters)"}
|
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const strongPasswordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{12,100}$/;
|
||||||
|
|
||||||
|
export const getPasswordMinLength = (): number => (import.meta.env.PROD ? 12 : 8);
|
||||||
|
|
||||||
|
export const getPasswordRequirementsLabel = (): string =>
|
||||||
|
import.meta.env.PROD
|
||||||
|
? "12+ chars with upper/lowercase, number, and symbol"
|
||||||
|
: "at least 8 characters";
|
||||||
|
|
||||||
|
export const validatePasswordForCurrentEnv = (
|
||||||
|
password: string,
|
||||||
|
fieldLabel = "Password"
|
||||||
|
): string | null => {
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
if (!strongPasswordPattern.test(password)) {
|
||||||
|
return `${fieldLabel} must be 12+ chars and include upper, lower, number, and symbol`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return `${fieldLabel} must be at least 8 characters long`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
Reference in New Issue
Block a user