🔐 feat: Add Configurable Min. Password Length (#9315)

- Added support for a minimum password length defined by the MIN_PASSWORD_LENGTH environment variable.
- Updated login, registration, and reset password forms to utilize the configured minimum length.
- Enhanced validation schemas to reflect the new minimum password length requirement.
- Included tests to ensure the minimum password length functionality works as expected.
This commit is contained in:
Danny Avila 2025-08-27 16:30:56 -04:00 committed by GitHub
parent ea3b671182
commit ba424666f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 87 additions and 9 deletions

View file

@ -40,6 +40,13 @@ NO_INDEX=true
# Defaulted to 1. # Defaulted to 1.
TRUST_PROXY=1 TRUST_PROXY=1
# Minimum password length for user authentication
# Default: 8
# Note: When using LDAP authentication, you may want to set this to 1
# to bypass local password validation, as LDAP servers handle their own
# password policies.
# MIN_PASSWORD_LENGTH=8
#===============# #===============#
# JSON Logging # # JSON Logging #
#===============# #===============#

View file

@ -117,6 +117,11 @@ router.get('/', async function (req, res) {
openidReuseTokens, openidReuseTokens,
}; };
const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);
if (minPasswordLength && !isNaN(minPasswordLength)) {
payload.minPasswordLength = minPasswordLength;
}
payload.mcpServers = {}; payload.mcpServers = {};
const getMCPServers = () => { const getMCPServers = () => {
try { try {

View file

@ -1,5 +1,7 @@
const { z } = require('zod'); const { z } = require('zod');
const MIN_PASSWORD_LENGTH = parseInt(process.env.MIN_PASSWORD_LENGTH, 10) || 8;
const allowedCharactersRegex = new RegExp( const allowedCharactersRegex = new RegExp(
'^[' + '^[' +
'a-zA-Z0-9_.@#$%&*()' + // Basic Latin characters and symbols 'a-zA-Z0-9_.@#$%&*()' + // Basic Latin characters and symbols
@ -32,7 +34,7 @@ const loginSchema = z.object({
email: z.string().email(), email: z.string().email(),
password: z password: z
.string() .string()
.min(8) .min(MIN_PASSWORD_LENGTH)
.max(128) .max(128)
.refine((value) => value.trim().length > 0, { .refine((value) => value.trim().length > 0, {
message: 'Password cannot be only spaces', message: 'Password cannot be only spaces',
@ -50,14 +52,14 @@ const registerSchema = z
email: z.string().email(), email: z.string().email(),
password: z password: z
.string() .string()
.min(8) .min(MIN_PASSWORD_LENGTH)
.max(128) .max(128)
.refine((value) => value.trim().length > 0, { .refine((value) => value.trim().length > 0, {
message: 'Password cannot be only spaces', message: 'Password cannot be only spaces',
}), }),
confirm_password: z confirm_password: z
.string() .string()
.min(8) .min(MIN_PASSWORD_LENGTH)
.max(128) .max(128)
.refine((value) => value.trim().length > 0, { .refine((value) => value.trim().length > 0, {
message: 'Password cannot be only spaces', message: 'Password cannot be only spaces',

View file

@ -258,7 +258,7 @@ describe('Zod Schemas', () => {
email: 'john@example.com', email: 'john@example.com',
password: 'password123', password: 'password123',
confirm_password: 'password123', confirm_password: 'password123',
extraField: 'I shouldn\'t be here', extraField: "I shouldn't be here",
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
@ -407,7 +407,7 @@ describe('Zod Schemas', () => {
'john{doe}', // Contains `{` and `}` 'john{doe}', // Contains `{` and `}`
'j', // Only one character 'j', // Only one character
'a'.repeat(81), // More than 80 characters 'a'.repeat(81), // More than 80 characters
'\' OR \'1\'=\'1\'; --', // SQL Injection "' OR '1'='1'; --", // SQL Injection
'{$ne: null}', // MongoDB Injection '{$ne: null}', // MongoDB Injection
'<script>alert("XSS")</script>', // Basic XSS '<script>alert("XSS")</script>', // Basic XSS
'"><script>alert("XSS")</script>', // XSS breaking out of an attribute '"><script>alert("XSS")</script>', // XSS breaking out of an attribute
@ -453,4 +453,64 @@ describe('Zod Schemas', () => {
expect(result).toBe('name: String must contain at least 3 character(s)'); expect(result).toBe('name: String must contain at least 3 character(s)');
}); });
}); });
describe('MIN_PASSWORD_LENGTH environment variable', () => {
// Note: These tests verify the behavior based on whatever MIN_PASSWORD_LENGTH
// was set when the validators module was loaded
const minLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10) || 8;
it('should respect the configured minimum password length for login', () => {
// Test password exactly at minimum length
const resultValid = loginSchema.safeParse({
email: 'test@example.com',
password: 'a'.repeat(minLength),
});
expect(resultValid.success).toBe(true);
// Test password one character below minimum
if (minLength > 1) {
const resultInvalid = loginSchema.safeParse({
email: 'test@example.com',
password: 'a'.repeat(minLength - 1),
});
expect(resultInvalid.success).toBe(false);
}
});
it('should respect the configured minimum password length for registration', () => {
// Test password exactly at minimum length
const resultValid = registerSchema.safeParse({
name: 'John Doe',
email: 'john@example.com',
password: 'a'.repeat(minLength),
confirm_password: 'a'.repeat(minLength),
});
expect(resultValid.success).toBe(true);
// Test password one character below minimum
if (minLength > 1) {
const resultInvalid = registerSchema.safeParse({
name: 'John Doe',
email: 'john@example.com',
password: 'a'.repeat(minLength - 1),
confirm_password: 'a'.repeat(minLength - 1),
});
expect(resultInvalid.success).toBe(false);
}
});
it('should handle edge case of very short minimum password length', () => {
// This test is meaningful only if MIN_PASSWORD_LENGTH is set to a very low value
if (minLength <= 3) {
const result = loginSchema.safeParse({
email: 'test@example.com',
password: 'abc',
});
expect(result.success).toBe(minLength <= 3);
} else {
// Skip this test if minimum length is > 3
expect(true).toBe(true);
}
});
});
}); });

View file

@ -125,7 +125,10 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
aria-label={localize('com_auth_password')} aria-label={localize('com_auth_password')}
{...register('password', { {...register('password', {
required: localize('com_auth_password_required'), required: localize('com_auth_password_required'),
minLength: { value: 8, message: localize('com_auth_password_min_length') }, minLength: {
value: startupConfig?.minPasswordLength || 8,
message: localize('com_auth_password_min_length'),
},
maxLength: { value: 128, message: localize('com_auth_password_max_length') }, maxLength: { value: 128, message: localize('com_auth_password_max_length') },
})} })}
aria-invalid={!!errors.password} aria-invalid={!!errors.password}

View file

@ -165,7 +165,7 @@ const Registration: React.FC = () => {
{renderInput('password', 'com_auth_password', 'password', { {renderInput('password', 'com_auth_password', 'password', {
required: localize('com_auth_password_required'), required: localize('com_auth_password_required'),
minLength: { minLength: {
value: 8, value: startupConfig?.minPasswordLength || 8,
message: localize('com_auth_password_min_length'), message: localize('com_auth_password_min_length'),
}, },
maxLength: { maxLength: {

View file

@ -19,7 +19,7 @@ function ResetPassword() {
const [params] = useSearchParams(); const [params] = useSearchParams();
const password = watch('password'); const password = watch('password');
const resetPassword = useResetPasswordMutation(); const resetPassword = useResetPasswordMutation();
const { setError, setHeaderText } = useOutletContext<TLoginLayoutContext>(); const { setError, setHeaderText, startupConfig } = useOutletContext<TLoginLayoutContext>();
const onSubmit = (data: TResetPassword) => { const onSubmit = (data: TResetPassword) => {
resetPassword.mutate(data, { resetPassword.mutate(data, {
@ -83,7 +83,7 @@ function ResetPassword() {
{...register('password', { {...register('password', {
required: localize('com_auth_password_required'), required: localize('com_auth_password_required'),
minLength: { minLength: {
value: 8, value: startupConfig?.minPasswordLength || 8,
message: localize('com_auth_password_min_length'), message: localize('com_auth_password_min_length'),
}, },
maxLength: { maxLength: {

View file

@ -641,6 +641,7 @@ export type TStartupConfig = {
sharePointPickerGraphScope?: string; sharePointPickerGraphScope?: string;
sharePointPickerSharePointScope?: string; sharePointPickerSharePointScope?: string;
openidReuseTokens?: boolean; openidReuseTokens?: boolean;
minPasswordLength?: number;
webSearch?: { webSearch?: {
searchProvider?: SearchProviders; searchProvider?: SearchProviders;
scraperType?: ScraperTypes; scraperType?: ScraperTypes;