mirror of
https://github.com/ONLYOFFICE/server.git
synced 2026-02-10 18:05:07 +08:00
[bug] Validate password strength on the backend; Fix bug 77233
This commit is contained in:
@ -4,9 +4,35 @@ import './App.css';
|
||||
import {store} from './store';
|
||||
import AuthWrapper from './components/AuthWrapper/AuthWrapper';
|
||||
import ConfigLoader from './components/ConfigLoader/ConfigLoader';
|
||||
import {useSchemaLoader} from './hooks/useSchemaLoader';
|
||||
import Menu from './components/Menu/Menu';
|
||||
import {menuItems} from './config/menuItems';
|
||||
|
||||
function AppContent() {
|
||||
useSchemaLoader();
|
||||
|
||||
return (
|
||||
<div className='app'>
|
||||
<AuthWrapper>
|
||||
<div className='appLayout'>
|
||||
<Menu />
|
||||
<div className='mainContent'>
|
||||
<ConfigLoader>
|
||||
<Routes>
|
||||
<Route path='/' element={<Navigate to='/statistics' replace />} />
|
||||
<Route path='/index.html' element={<Navigate to='/statistics' replace />} />
|
||||
{menuItems.map(item => (
|
||||
<Route key={item.key} path={item.path} element={<item.component />} />
|
||||
))}
|
||||
</Routes>
|
||||
</ConfigLoader>
|
||||
</div>
|
||||
</div>
|
||||
</AuthWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple basename computation from URL path.
|
||||
* Basename is everything before the last path segment.
|
||||
@ -35,24 +61,7 @@ function App() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<BrowserRouter basename={basename}>
|
||||
<div className='app'>
|
||||
<AuthWrapper>
|
||||
<div className='appLayout'>
|
||||
<Menu />
|
||||
<div className='mainContent'>
|
||||
<ConfigLoader>
|
||||
<Routes>
|
||||
<Route path='/' element={<Navigate to='/statistics' replace />} />
|
||||
<Route path='/index.html' element={<Navigate to='/statistics' replace />} />
|
||||
{menuItems.map(item => (
|
||||
<Route key={item.key} path={item.path} element={<item.component />} />
|
||||
))}
|
||||
</Routes>
|
||||
</ConfigLoader>
|
||||
</div>
|
||||
</div>
|
||||
</AuthWrapper>
|
||||
</div>
|
||||
<AppContent />
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
@ -34,8 +34,7 @@ export const fetchConfiguration = async () => {
|
||||
};
|
||||
|
||||
export const fetchConfigurationSchema = async () => {
|
||||
const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/config/schema`, {credentials: 'include'});
|
||||
if (response.status === 401) throw new Error('UNAUTHORIZED');
|
||||
const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/config/schema`);
|
||||
if (!response.ok) throw new Error('Failed to fetch configuration schema');
|
||||
return response.json();
|
||||
};
|
||||
|
||||
@ -1,15 +1,7 @@
|
||||
import {useEffect} from 'react';
|
||||
import {useSelector, useDispatch} from 'react-redux';
|
||||
import {
|
||||
selectConfig,
|
||||
selectConfigLoading,
|
||||
selectConfigError,
|
||||
selectSchema,
|
||||
selectSchemaLoading,
|
||||
selectSchemaError,
|
||||
fetchConfig,
|
||||
fetchSchema
|
||||
} from '../../store/slices/configSlice';
|
||||
import {selectConfig, selectConfigLoading, selectConfigError, selectSchema, fetchConfig} from '../../store/slices/configSlice';
|
||||
import {selectIsAuthenticated} from '../../store/slices/userSlice';
|
||||
import Button from '../Button/Button';
|
||||
|
||||
const ConfigLoader = ({children}) => {
|
||||
@ -18,23 +10,16 @@ const ConfigLoader = ({children}) => {
|
||||
const configLoading = useSelector(selectConfigLoading);
|
||||
const configError = useSelector(selectConfigError);
|
||||
const schema = useSelector(selectSchema);
|
||||
const schemaLoading = useSelector(selectSchemaLoading);
|
||||
const schemaError = useSelector(selectSchemaError);
|
||||
const isAuthenticated = useSelector(selectIsAuthenticated);
|
||||
|
||||
const loading = configLoading || schemaLoading;
|
||||
const error = configError || schemaError;
|
||||
const loading = configLoading;
|
||||
const error = configError;
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch config if not loaded
|
||||
if (!config && !configLoading && !configError) {
|
||||
if (isAuthenticated && !config && !configLoading && !configError) {
|
||||
dispatch(fetchConfig());
|
||||
}
|
||||
|
||||
// Fetch schema if not loaded (only once per session)
|
||||
if (!schema && !schemaLoading && !schemaError) {
|
||||
dispatch(fetchSchema());
|
||||
}
|
||||
}, [config, configLoading, configError, schema, schemaLoading, schemaError, dispatch]);
|
||||
}, [config, configLoading, configError, isAuthenticated, dispatch]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@ -1,31 +1,22 @@
|
||||
import React, {useState} from 'react';
|
||||
import {useState} from 'react';
|
||||
import Input from '../Input/Input';
|
||||
import PasswordRequirements from '../PasswordRequirements/PasswordRequirements';
|
||||
import {validatePasswordStrength} from '../../utils/passwordValidation';
|
||||
import {usePasswordValidation} from '../../utils/passwordValidation';
|
||||
|
||||
/**
|
||||
* Password input component with requirements display on focus
|
||||
* Matches DocSpace PasswordInput standard behavior
|
||||
*/
|
||||
function PasswordInputWithRequirements({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
description,
|
||||
error,
|
||||
settings,
|
||||
...props
|
||||
}) {
|
||||
function PasswordInputWithRequirements({label, value, onChange, placeholder, description, error, ...props}) {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const {isValid} = usePasswordValidation(value);
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
const passwordValidation = validatePasswordStrength(value || '');
|
||||
if (passwordValidation.isValid || !value) {
|
||||
if (isValid || !value) {
|
||||
setIsFocused(false);
|
||||
}
|
||||
};
|
||||
@ -35,7 +26,7 @@ function PasswordInputWithRequirements({
|
||||
<div style={{position: 'relative'}}>
|
||||
<Input
|
||||
label={label}
|
||||
type="password"
|
||||
type='password'
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
@ -45,11 +36,7 @@ function PasswordInputWithRequirements({
|
||||
onBlur={handleBlur}
|
||||
{...props}
|
||||
/>
|
||||
<PasswordRequirements
|
||||
password={value}
|
||||
isVisible={isFocused}
|
||||
settings={settings}
|
||||
/>
|
||||
<PasswordRequirements password={value} isVisible={isFocused} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,19 +1,47 @@
|
||||
import React, {useState} from 'react';
|
||||
import {getPasswordRequirementsStatus} from '../../utils/passwordValidation';
|
||||
import SuccessGreenIcon from '../../assets/SuccessGreen.svg';
|
||||
import FailRedIcon from '../../assets/FailRed.svg';
|
||||
import styles from './PasswordRequirements.module.scss';
|
||||
import {useSelector} from 'react-redux';
|
||||
import {selectSchema} from '../../store/slices/configSlice';
|
||||
import {useMemo} from 'react';
|
||||
import {usePasswordValidation} from '../../utils/passwordValidation';
|
||||
|
||||
/**
|
||||
* Component that displays password requirements with progress bar
|
||||
* Matches DocSpace PasswordInput standard
|
||||
* @param {string} password - Password to validate
|
||||
* @param {boolean} isVisible - Whether to show the requirements (e.g., on focus)
|
||||
* @param {Object} settings - Password settings (optional)
|
||||
*/
|
||||
function PasswordRequirements({password, isVisible = false, settings}) {
|
||||
const requirementsStatus = getPasswordRequirementsStatus(password || '', settings);
|
||||
const {requirements, progress, isValid} = requirementsStatus;
|
||||
function PasswordRequirements({password, isVisible = false}) {
|
||||
const schema = useSelector(selectSchema);
|
||||
const {invalidRules, isValid} = usePasswordValidation(password);
|
||||
|
||||
const passwordValidation = schema?.properties?.adminPanel?.properties?.passwordValidation;
|
||||
|
||||
const requirements = useMemo(() => {
|
||||
const rules = [
|
||||
{key: 'minLength', format: 'passlength'},
|
||||
{key: 'hasDigit', format: 'passdigit'},
|
||||
{key: 'hasUppercase', format: 'passupper'},
|
||||
{key: 'hasSpecialChar', format: 'passspecial'}
|
||||
];
|
||||
|
||||
const invalidRulesSet = new Set(invalidRules);
|
||||
|
||||
return rules.map(rule => {
|
||||
const property = passwordValidation.properties[rule.key];
|
||||
const isValid = !invalidRulesSet.has(rule.key);
|
||||
|
||||
return {
|
||||
text: property?.description,
|
||||
isValid
|
||||
};
|
||||
});
|
||||
}, [passwordValidation, invalidRules]);
|
||||
|
||||
const validRequirements = requirements.filter(req => req.isValid).length;
|
||||
const totalRequirements = requirements.length;
|
||||
const progress = (validRequirements / totalRequirements) * 100;
|
||||
|
||||
const shouldShow = isVisible || (!isValid && password);
|
||||
|
||||
@ -24,25 +52,15 @@ function PasswordRequirements({password, isVisible = false, settings}) {
|
||||
return (
|
||||
<div className={styles.requirementsContainer}>
|
||||
<div className={styles.progressBar}>
|
||||
<div
|
||||
className={`${styles.progressFill} ${isValid ? styles.progressComplete : ''}`}
|
||||
style={{width: `${progress}%`}}
|
||||
/>
|
||||
<div className={`${styles.progressFill} ${isValid ? styles.progressComplete : ''}`} style={{width: `${progress}%`}} />
|
||||
</div>
|
||||
|
||||
<div className={styles.requirementsTitle}>Password must contain:</div>
|
||||
|
||||
<div className={styles.requirementsTitle}>Password must:</div>
|
||||
<ul className={styles.requirementsList}>
|
||||
{requirements.map((requirement, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className={`${styles.requirementItem} ${requirement.isValid ? styles.valid : styles.invalid}`}
|
||||
>
|
||||
<li key={index} className={`${styles.requirementItem} ${requirement.isValid ? styles.valid : styles.invalid}`}>
|
||||
<span className={styles.bullet}>
|
||||
{requirement.isValid ? (
|
||||
<img src={SuccessGreenIcon} alt="Success" />
|
||||
) : (
|
||||
<img src={FailRedIcon} alt="Fail" />
|
||||
)}
|
||||
{requirement.isValid ? <img src={SuccessGreenIcon} alt='Success' /> : <img src={FailRedIcon} alt='Fail' />}
|
||||
</span>
|
||||
<span className={styles.text}>{requirement.text}</span>
|
||||
</li>
|
||||
@ -53,4 +71,3 @@ function PasswordRequirements({password, isVisible = false, settings}) {
|
||||
}
|
||||
|
||||
export default PasswordRequirements;
|
||||
|
||||
|
||||
@ -36,7 +36,7 @@
|
||||
}
|
||||
|
||||
.requirementsTitle {
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
margin-bottom: 6px;
|
||||
@ -54,7 +54,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@ -63,9 +63,9 @@
|
||||
}
|
||||
|
||||
.bullet {
|
||||
margin-right: 8px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@ -4,9 +4,6 @@ import Ajv from 'ajv';
|
||||
import addFormats from 'ajv-formats';
|
||||
import {selectSchema, selectSchemaLoading, selectSchemaError} from '../store/slices/configSlice';
|
||||
|
||||
// Cron expression with 6 space-separated fields (server-compatible)
|
||||
const CRON6_REGEX = /^\s*\S+(?:\s+\S+){5}\s*$/;
|
||||
|
||||
/**
|
||||
* Hook for field validation using backend schema
|
||||
* @returns {Object} { validateField, getFieldError, isLoading, error }
|
||||
@ -25,7 +22,19 @@ export const useFieldValidation = () => {
|
||||
// Build AJV validator with custom and standard formats
|
||||
const ajv = new Ajv({allErrors: true, strict: false});
|
||||
addFormats(ajv); // Add standard formats including email
|
||||
ajv.addFormat('cron6', CRON6_REGEX); // Add custom cron6 format
|
||||
|
||||
// Register formats from schema $defs.formats (regex strings)
|
||||
const formats = schema?.$defs?.formats;
|
||||
if (formats && typeof formats === 'object') {
|
||||
for (const [name, patternString] of Object.entries(formats)) {
|
||||
try {
|
||||
const re = new RegExp(patternString);
|
||||
ajv.addFormat(name, re);
|
||||
} catch (e) {
|
||||
console.warn('Invalid format regex in schema $defs.formats:', name, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const validateFn = ajv.compile(schema);
|
||||
setValidator(() => validateFn);
|
||||
|
||||
27
AdminPanel/client/src/hooks/useSchemaLoader.js
Normal file
27
AdminPanel/client/src/hooks/useSchemaLoader.js
Normal file
@ -0,0 +1,27 @@
|
||||
import {useEffect} from 'react';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
import {selectSchema, selectSchemaLoading, selectSchemaError, fetchSchema} from '../store/slices/configSlice';
|
||||
|
||||
/**
|
||||
* Hook to load schema on app startup
|
||||
* Fetches schema immediately when the hook is first used
|
||||
*/
|
||||
export const useSchemaLoader = () => {
|
||||
const dispatch = useDispatch();
|
||||
const schema = useSelector(selectSchema);
|
||||
const schemaLoading = useSelector(selectSchemaLoading);
|
||||
const schemaError = useSelector(selectSchemaError);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch schema if not loaded (always fetch, no auth required)
|
||||
if (!schema && !schemaLoading && !schemaError) {
|
||||
dispatch(fetchSchema());
|
||||
}
|
||||
}, [schema, schemaLoading, schemaError, dispatch]);
|
||||
|
||||
return {
|
||||
schema,
|
||||
schemaLoading,
|
||||
schemaError
|
||||
};
|
||||
};
|
||||
@ -5,7 +5,7 @@ import PageDescription from '../../components/PageDescription/PageDescription';
|
||||
import Input from '../../components/Input/Input';
|
||||
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
|
||||
import PasswordInputWithRequirements from '../../components/PasswordInputWithRequirements/PasswordInputWithRequirements';
|
||||
import {validatePasswordStrength} from '../../utils/passwordValidation';
|
||||
import {usePasswordValidation} from '../../utils/passwordValidation';
|
||||
import styles from './ChangePassword.module.scss';
|
||||
|
||||
function ChangePassword() {
|
||||
@ -15,21 +15,22 @@ function ChangePassword() {
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [passwordSuccess, setPasswordSuccess] = useState(false);
|
||||
|
||||
const {isValid: newPasswordIsValid, isLoading} = usePasswordValidation(newPassword);
|
||||
|
||||
// Check if form can be submitted
|
||||
const canSubmit = () => {
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
if (!currentPassword || !newPassword || !confirmPassword || isLoading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const passwordValidation = validatePasswordStrength(newPassword);
|
||||
if (!passwordValidation.isValid) {
|
||||
|
||||
if (!newPasswordIsValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@ -37,28 +38,6 @@ function ChangePassword() {
|
||||
setPasswordError('');
|
||||
setPasswordSuccess(false);
|
||||
|
||||
// Validation
|
||||
if (!currentPassword) {
|
||||
setPasswordError('Current password is required');
|
||||
throw new Error('Validation failed');
|
||||
}
|
||||
|
||||
if (!newPassword) {
|
||||
setPasswordError('New password is required');
|
||||
throw new Error('Validation failed');
|
||||
}
|
||||
|
||||
const passwordValidation = validatePasswordStrength(newPassword);
|
||||
if (!passwordValidation.isValid) {
|
||||
setPasswordError(passwordValidation.errorMessage);
|
||||
throw new Error('Validation failed');
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError('New passwords do not match');
|
||||
throw new Error('Validation failed');
|
||||
}
|
||||
|
||||
try {
|
||||
await changePassword({currentPassword, newPassword});
|
||||
setPasswordSuccess(true);
|
||||
@ -111,9 +90,7 @@ function ChangePassword() {
|
||||
description='Re-enter your new password'
|
||||
/>
|
||||
<div className={styles.passwordMismatch}>
|
||||
{newPassword && confirmPassword && newPassword !== confirmPassword && validatePasswordStrength(newPassword).isValid && (
|
||||
'Passwords don\'t match'
|
||||
)}
|
||||
{newPassword && confirmPassword && newPassword !== confirmPassword && newPasswordIsValid && "Passwords don't match"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import {fetchUser} from '../../store/slices/userSlice';
|
||||
import Input from '../../components/Input/Input';
|
||||
import Button from '../../components/Button/Button';
|
||||
import PasswordInputWithRequirements from '../../components/PasswordInputWithRequirements/PasswordInputWithRequirements';
|
||||
import {validatePasswordStrength} from '../../utils/passwordValidation';
|
||||
import {usePasswordValidation} from '../../utils/passwordValidation';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
export default function Setup() {
|
||||
@ -16,55 +16,28 @@ export default function Setup() {
|
||||
const dispatch = useDispatch();
|
||||
const buttonRef = useRef();
|
||||
|
||||
const {isValid: passwordIsValid, isLoading} = usePasswordValidation(password);
|
||||
|
||||
// Check if form can be submitted
|
||||
const canSubmit = () => {
|
||||
if (!bootstrapToken.trim() || !password || !confirmPassword) {
|
||||
if (!bootstrapToken.trim() || !password || !confirmPassword || isLoading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const passwordValidation = validatePasswordStrength(password);
|
||||
if (!passwordValidation.isValid) {
|
||||
|
||||
// Check if password meets all requirements using hook
|
||||
if (!passwordIsValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setErrors({});
|
||||
|
||||
// Validate all fields
|
||||
const newErrors = {};
|
||||
|
||||
if (!bootstrapToken.trim()) {
|
||||
newErrors.bootstrapToken = 'Bootstrap token is required';
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
newErrors.password = 'Password is required';
|
||||
} else {
|
||||
const passwordValidation = validatePasswordStrength(password);
|
||||
if (!passwordValidation.isValid) {
|
||||
newErrors.password = passwordValidation.errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
if (!confirmPassword) {
|
||||
newErrors.confirmPassword = 'Please confirm your password';
|
||||
} else if (password !== confirmPassword) {
|
||||
newErrors.confirmPassword = 'Passwords do not match';
|
||||
}
|
||||
|
||||
// If there are validation errors, show them
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
throw new Error('Validation failed');
|
||||
}
|
||||
|
||||
try {
|
||||
await setupAdminPassword({bootstrapToken, password});
|
||||
// Wait for cookie to be set and verify authentication works
|
||||
@ -129,9 +102,7 @@ export default function Setup() {
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<div className={styles.passwordMismatch}>
|
||||
{password && confirmPassword && password !== confirmPassword && validatePasswordStrength(password).isValid && (
|
||||
'Passwords don\'t match'
|
||||
)}
|
||||
{password && confirmPassword && password !== confirmPassword && passwordIsValid && "Passwords don't match"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,113 +1,31 @@
|
||||
/**
|
||||
* Password validation utility
|
||||
* Validates password strength according to security requirements
|
||||
*/
|
||||
|
||||
// Default password settings matching DocSpace standard
|
||||
const DEFAULT_PASSWORD_SETTINGS = {
|
||||
minLength: 8,
|
||||
maxLength: 128,
|
||||
upperCase: true,
|
||||
digits: true,
|
||||
specSymbols: true
|
||||
};
|
||||
import {useFieldValidation} from '../hooks/useFieldValidation';
|
||||
import {useMemo} from 'react';
|
||||
|
||||
/**
|
||||
* Validates password strength
|
||||
* Hook for password validation
|
||||
* @param {string} password - Password to validate
|
||||
* @param {Object} settings - Password settings (optional)
|
||||
* @returns {Object} Validation result with isValid boolean and error message
|
||||
* @returns {Object} { isValid, errorMessage, invalidRules, isLoading, error }
|
||||
*/
|
||||
export function validatePasswordStrength(password, settings = DEFAULT_PASSWORD_SETTINGS) {
|
||||
const errors = [];
|
||||
export function usePasswordValidation(password) {
|
||||
const {validateField, isLoading} = useFieldValidation();
|
||||
|
||||
// Check minimum length
|
||||
if (password.length < settings.minLength) {
|
||||
errors.push(`Password must be at least ${settings.minLength} characters long`);
|
||||
}
|
||||
const validationResult = useMemo(() => {
|
||||
const rules = ['minLength', 'hasDigit', 'hasUppercase', 'hasSpecialChar'];
|
||||
|
||||
// Check maximum length
|
||||
if (password.length > settings.maxLength) {
|
||||
errors.push(`Password must not exceed ${settings.maxLength} characters`);
|
||||
}
|
||||
const invalidRules = rules.filter(rule => {
|
||||
const fieldPath = `adminPanel.passwordValidation.${rule}`;
|
||||
const error = validateField(fieldPath, password || '');
|
||||
return !!error;
|
||||
});
|
||||
|
||||
// Check for at least one digit
|
||||
if (settings.digits && !/\d/.test(password)) {
|
||||
errors.push('Password must contain at least one digit');
|
||||
}
|
||||
|
||||
// Check for at least one uppercase letter
|
||||
if (settings.upperCase && !/[A-Z]/.test(password)) {
|
||||
errors.push('Password must contain at least one uppercase letter');
|
||||
}
|
||||
|
||||
// Check for at least one special character
|
||||
if (settings.specSymbols && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
|
||||
errors.push('Password must contain at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)');
|
||||
}
|
||||
return {
|
||||
isValid: invalidRules.length === 0,
|
||||
invalidRules
|
||||
};
|
||||
}, [validateField, password]);
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors: errors,
|
||||
errorMessage: errors.length > 0 ? errors.join('. ') : null
|
||||
...validationResult,
|
||||
isLoading
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets detailed password requirements with validation status
|
||||
* @param {string} password - Password to validate
|
||||
* @param {Object} settings - Password settings (optional)
|
||||
* @returns {Object} Requirements status with progress and requirements array
|
||||
*/
|
||||
export function getPasswordRequirementsStatus(password, settings = DEFAULT_PASSWORD_SETTINGS) {
|
||||
const requirements = [];
|
||||
|
||||
// Length requirement
|
||||
requirements.push({
|
||||
text: `from ${settings.minLength} to ${settings.maxLength} characters`,
|
||||
isValid: password.length >= settings.minLength && password.length <= settings.maxLength
|
||||
});
|
||||
|
||||
// Digits requirement
|
||||
if (settings.digits) {
|
||||
requirements.push({
|
||||
text: 'digits',
|
||||
isValid: /\d/.test(password)
|
||||
});
|
||||
}
|
||||
|
||||
// Uppercase requirement
|
||||
if (settings.upperCase) {
|
||||
requirements.push({
|
||||
text: 'capital letters',
|
||||
isValid: /[A-Z]/.test(password)
|
||||
});
|
||||
}
|
||||
|
||||
// Special symbols requirement
|
||||
if (settings.specSymbols) {
|
||||
requirements.push({
|
||||
text: 'special characters (!@#$%^&*)',
|
||||
isValid: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate progress (0-100)
|
||||
const validRequirements = requirements.filter(req => req.isValid).length;
|
||||
const totalRequirements = requirements.length;
|
||||
const progress = totalRequirements > 0 ? (validRequirements / totalRequirements) * 100 : 0;
|
||||
|
||||
return {
|
||||
requirements,
|
||||
progress,
|
||||
isValid: progress === 100
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets password strength requirements as a readable string
|
||||
* @returns {string} Requirements description
|
||||
*/
|
||||
export function getPasswordRequirements() {
|
||||
return 'Password must be 8-128 characters long and contain at least one uppercase letter, one lowercase letter, one digit, and one special character';
|
||||
}
|
||||
|
||||
@ -8,12 +8,44 @@ const bootstrap = require('../../bootstrap');
|
||||
const adminPanelJwtSecret = require('../../jwtSecret');
|
||||
const tenantManager = require('../../../../../Common/sources/tenantManager');
|
||||
const commonDefines = require('../../../../../Common/sources/commondefines');
|
||||
const {validateScoped} = require('../config/config.service');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(express.json());
|
||||
router.use(cookieParser());
|
||||
|
||||
/**
|
||||
* Validates password against all requirements using existing config service
|
||||
* @param {operationContext} ctx - Operation context
|
||||
* @param {string} password - Password to validate
|
||||
* @returns {Object} Validation result with isValid boolean and error messages
|
||||
*/
|
||||
function validatePassword(ctx, password) {
|
||||
const testData = {
|
||||
adminPanel: {
|
||||
passwordValidation: {
|
||||
minLength: password,
|
||||
hasDigit: password,
|
||||
hasUppercase: password,
|
||||
hasSpecialChar: password
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = validateScoped(ctx, testData);
|
||||
|
||||
if (result.value) {
|
||||
return {
|
||||
isValid: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create session cookie with standard options
|
||||
* @param {import('express').Response} res - Express response
|
||||
@ -106,12 +138,9 @@ router.post('/setup', async (req, res) => {
|
||||
return res.status(400).json({error: 'Password is required'});
|
||||
}
|
||||
|
||||
if (password.length < passwordManager.PASSWORD_MIN_LENGTH) {
|
||||
return res.status(400).json({error: `Password must be at least ${passwordManager.PASSWORD_MIN_LENGTH} characters long`});
|
||||
}
|
||||
|
||||
if (password.length > passwordManager.PASSWORD_MAX_LENGTH) {
|
||||
return res.status(400).json({error: `Password must not exceed ${passwordManager.PASSWORD_MAX_LENGTH} characters`});
|
||||
const passwordValidationResult = validatePassword(ctx, password);
|
||||
if (!passwordValidationResult.isValid) {
|
||||
return res.status(400).json({error: 'Password is too weak'});
|
||||
}
|
||||
|
||||
await passwordManager.saveAdminPassword(ctx, password);
|
||||
@ -143,12 +172,9 @@ router.post('/change-password', requireAuth, async (req, res) => {
|
||||
return res.status(400).json({error: 'Current password and new password are required'});
|
||||
}
|
||||
|
||||
if (newPassword.length < passwordManager.PASSWORD_MIN_LENGTH) {
|
||||
return res.status(400).json({error: `Password must be at least ${passwordManager.PASSWORD_MIN_LENGTH} characters long`});
|
||||
}
|
||||
|
||||
if (newPassword.length > passwordManager.PASSWORD_MAX_LENGTH) {
|
||||
return res.status(400).json({error: `Password must not exceed ${passwordManager.PASSWORD_MAX_LENGTH} characters`});
|
||||
const passwordValidationResult = validatePassword(ctx, newPassword);
|
||||
if (!passwordValidationResult.isValid) {
|
||||
return res.status(400).json({error: 'Password is too weak'});
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
|
||||
@ -39,7 +39,6 @@ const supersetSchema = require('../../../../../Common/config/schemas/config.sche
|
||||
const {deriveSchemaForScope, X_SCOPE_KEYWORD} = require('./config.schema.utils');
|
||||
|
||||
// Constants
|
||||
const CRON6_REGEX = /^\s*\S+(?:\s+\S+){5}\s*$/;
|
||||
const AJV_CONFIG = {allErrors: true, strict: false};
|
||||
const AJV_FILTER_CONFIG = {allErrors: true, strict: false, removeAdditional: true};
|
||||
|
||||
@ -49,7 +48,14 @@ const AJV_FILTER_CONFIG = {allErrors: true, strict: false, removeAdditional: tru
|
||||
*/
|
||||
function registerAjvExtras(instance) {
|
||||
instance.addKeyword({keyword: X_SCOPE_KEYWORD, schemaType: ['string', 'array'], errors: false});
|
||||
instance.addFormat('cron6', CRON6_REGEX);
|
||||
|
||||
const formats = supersetSchema?.$defs?.formats;
|
||||
if (formats && typeof formats === 'object') {
|
||||
for (const [name, patternString] of Object.entries(formats)) {
|
||||
const re = new RegExp(patternString);
|
||||
instance.addFormat(name, re);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -114,13 +120,4 @@ function getScopedConfig(ctx) {
|
||||
return configCopy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the derived per-scope schema for ctx (admin or tenant).
|
||||
* @param {operationContext} ctx
|
||||
* @returns {object}
|
||||
*/
|
||||
function getScopedSchema(ctx) {
|
||||
return isAdminScope(ctx) ? adminSchema : tenantSchema;
|
||||
}
|
||||
|
||||
module.exports = {validateScoped, getScopedConfig, getScopedSchema};
|
||||
module.exports = {validateScoped, getScopedConfig};
|
||||
|
||||
@ -4,10 +4,11 @@ const express = require('express');
|
||||
const bodyParser = require('body-parser');
|
||||
const tenantManager = require('../../../../../Common/sources/tenantManager');
|
||||
const runtimeConfigManager = require('../../../../../Common/sources/runtimeConfigManager');
|
||||
const {getScopedConfig, validateScoped, getScopedSchema} = require('./config.service');
|
||||
const {getScopedConfig, validateScoped} = require('./config.service');
|
||||
const {validateJWT} = require('../../middleware/auth');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const utils = require('../../../../../Common/sources/utils');
|
||||
const supersetSchema = require('../../../../../Common/config/schemas/config.schema.json');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(cookieParser());
|
||||
@ -35,18 +36,8 @@ router.get('/', validateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/schema', validateJWT, async (req, res) => {
|
||||
const ctx = req.ctx;
|
||||
try {
|
||||
ctx.logger.info('config schema start');
|
||||
const schema = getScopedSchema(ctx);
|
||||
res.json(schema);
|
||||
} catch (error) {
|
||||
ctx.logger.error('Config schema error: %s', error.stack);
|
||||
res.status(500).json({error: 'Internal server error'});
|
||||
} finally {
|
||||
ctx.logger.info('config schema end');
|
||||
}
|
||||
router.get('/schema', async (_req, res) => {
|
||||
res.json(supersetSchema);
|
||||
});
|
||||
|
||||
router.patch('/', validateJWT, rawFileParser, async (req, res) => {
|
||||
|
||||
@ -5,6 +5,15 @@
|
||||
"description": "Superset schema with x-scope markers. Use at runtime to derive per-scope schemas.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"formats": {
|
||||
"cron6": "^\\s*\\S+(?:\\s+\\S+){5}\\s*$",
|
||||
"passlength": "^.{8,128}$",
|
||||
"passdigit": ".*\\d.*",
|
||||
"passupper": ".*[A-Z].*",
|
||||
"passspecial": ".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?].*"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"aiSettings": {
|
||||
"type": "object",
|
||||
@ -297,103 +306,141 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"rules": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"licenseExpirationWarning": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"enable": {"type": "boolean", "x-scope": ["admin", "tenant"]},
|
||||
"policies": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"repeatInterval": {
|
||||
"type": "string",
|
||||
"pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$",
|
||||
"description": "Repeat interval in time format (e.g., '1d', '1h', '30m')",
|
||||
"x-scope": ["admin", "tenant"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"licenseExpirationError": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"enable": {"type": "boolean", "x-scope": ["admin", "tenant"]},
|
||||
"policies": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"repeatInterval": {
|
||||
"type": "string",
|
||||
"pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$",
|
||||
"description": "Repeat interval in time format (e.g., '1d', '1h', '30m')",
|
||||
"x-scope": ["admin", "tenant"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"licenseLimitEdit": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"enable": {"type": "boolean", "x-scope": ["admin", "tenant"]},
|
||||
"policies": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"repeatInterval": {
|
||||
"type": "string",
|
||||
"pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$",
|
||||
"description": "Repeat interval in time format (e.g., '1d', '1h', '30m')",
|
||||
"x-scope": ["admin", "tenant"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"licenseLimitLiveViewer": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"enable": {"type": "boolean", "x-scope": ["admin", "tenant"]},
|
||||
"policies": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"repeatInterval": {
|
||||
"type": "string",
|
||||
"pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$",
|
||||
"description": "Repeat interval in time format (e.g., '1d', '1h', '30m')",
|
||||
"x-scope": ["admin", "tenant"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"notification": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"rules": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"licenseExpirationWarning": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"enable": {"type": "boolean", "x-scope": ["admin", "tenant"]},
|
||||
"policies": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"repeatInterval": {
|
||||
"type": "string",
|
||||
"pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$",
|
||||
"description": "Repeat interval in time format (e.g., '1d', '1h', '30m')",
|
||||
"x-scope": ["admin", "tenant"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"licenseExpirationError": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"enable": {"type": "boolean", "x-scope": ["admin", "tenant"]},
|
||||
"policies": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"repeatInterval": {
|
||||
"type": "string",
|
||||
"pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$",
|
||||
"description": "Repeat interval in time format (e.g., '1d', '1h', '30m')",
|
||||
"x-scope": ["admin", "tenant"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"licenseLimitEdit": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"enable": {"type": "boolean", "x-scope": ["admin", "tenant"]},
|
||||
"policies": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"repeatInterval": {
|
||||
"type": "string",
|
||||
"pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$",
|
||||
"description": "Repeat interval in time format (e.g., '1d', '1h', '30m')",
|
||||
"x-scope": ["admin", "tenant"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"licenseLimitLiveViewer": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"enable": {"type": "boolean", "x-scope": ["admin", "tenant"]},
|
||||
"policies": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"repeatInterval": {
|
||||
"type": "string",
|
||||
"pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$",
|
||||
"description": "Repeat interval in time format (e.g., '1d', '1h', '30m')",
|
||||
"x-scope": ["admin", "tenant"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"adminPanel": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"properties": {
|
||||
"passwordValidation": {
|
||||
"type": "object",
|
||||
"x-scope": ["admin", "tenant"],
|
||||
"description": "Password validation requirements using custom format types",
|
||||
"properties": {
|
||||
"minLength": {
|
||||
"type": "string",
|
||||
"format": "passlength",
|
||||
"description": "be at least 8 characters long",
|
||||
"x-scope": ["admin", "tenant"]
|
||||
},
|
||||
"hasDigit": {
|
||||
"type": "string",
|
||||
"format": "passdigit",
|
||||
"description": "contain at least one digit",
|
||||
"x-scope": ["admin", "tenant"]
|
||||
},
|
||||
"hasUppercase": {
|
||||
"type": "string",
|
||||
"format": "passupper",
|
||||
"description": "contain at least one uppercase letter",
|
||||
"x-scope": ["admin", "tenant"]
|
||||
},
|
||||
"hasSpecialChar": {
|
||||
"type": "string",
|
||||
"format": "passspecial",
|
||||
"description": "contain at least one special character",
|
||||
"x-scope": ["admin", "tenant"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user