Add password security: default credential warning and strength validation (#386)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
This commit is contained in:
Copilot
2025-10-26 19:22:51 +08:00
committed by GitHub
parent 2f7726b008
commit 5ca5e2ad47
13 changed files with 347 additions and 16 deletions

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChangePasswordCredentials } from '../types';
import { changePassword } from '../services/authService';
import { validatePasswordStrength } from '../utils/passwordValidation';
interface ChangePasswordFormProps {
onSuccess?: () => void;
@@ -18,6 +19,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [passwordErrors, setPasswordErrors] = useState<string[]>([]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
@@ -25,6 +27,12 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
setConfirmPassword(value);
} else {
setFormData(prev => ({ ...prev, [name]: value }));
// Validate password strength on change for new password
if (name === 'newPassword') {
const validation = validatePasswordStrength(value);
setPasswordErrors(validation.errors);
}
}
};
@@ -32,6 +40,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
e.preventDefault();
setError(null);
// Validate password strength
const validation = validatePasswordStrength(formData.newPassword);
if (!validation.isValid) {
setError(t('auth.passwordStrengthError'));
setPasswordErrors(validation.errors);
return;
}
// Validate passwords match
if (formData.newPassword !== confirmPassword) {
setError(t('auth.passwordsNotMatch'));
@@ -100,8 +116,24 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
value={formData.newPassword}
onChange={handleChange}
required
minLength={6}
minLength={8}
/>
{/* Password strength hints */}
{formData.newPassword && passwordErrors.length > 0 && (
<div className="mt-2 text-sm text-gray-600">
<p className="font-semibold mb-1">{t('auth.passwordStrengthHint')}</p>
<ul className="list-disc list-inside space-y-1">
{passwordErrors.map((errorKey) => (
<li key={errorKey} className="text-red-600">
{t(`auth.${errorKey}`)}
</li>
))}
</ul>
</div>
)}
{formData.newPassword && passwordErrors.length === 0 && (
<p className="mt-2 text-sm text-green-600"> {t('auth.passwordStrengthHint')}</p>
)}
</div>
<div className="mb-6">
@@ -116,7 +148,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
value={confirmPassword}
onChange={handleChange}
required
minLength={6}
minLength={8}
/>
</div>

View File

@@ -0,0 +1,116 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
interface DefaultPasswordWarningModalProps {
isOpen: boolean;
onClose: () => void;
}
const DefaultPasswordWarningModal: React.FC<DefaultPasswordWarningModalProps> = ({
isOpen,
onClose,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
if (!isOpen) return null;
const handleGoToSettings = () => {
onClose();
navigate('/settings');
// Auto-scroll to password section after a small delay to ensure page is loaded
setTimeout(() => {
const passwordSection = document.querySelector('[data-section="password"]');
if (passwordSection) {
passwordSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
// If the section is collapsed, expand it
const clickTarget = passwordSection.querySelector('[role="button"]');
if (clickTarget && !passwordSection.querySelector('.mt-4')) {
(clickTarget as HTMLElement).click();
}
}
}, 100);
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
return (
<div
className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4"
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full transform transition-all duration-200 ease-out"
role="dialog"
aria-modal="true"
aria-labelledby="password-warning-title"
aria-describedby="password-warning-message"
>
<div className="p-6">
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<svg
className="w-6 h-6 text-yellow-600 dark:text-yellow-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<div className="flex-1">
<h3
id="password-warning-title"
className="text-lg font-medium text-gray-900 dark:text-white mb-2"
>
{t('auth.defaultPasswordWarning')}
</h3>
<p
id="password-warning-message"
className="text-gray-600 dark:text-gray-300 leading-relaxed"
>
{t('auth.defaultPasswordMessage')}
</p>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors duration-150 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleGoToSettings}
className="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-md transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500 btn-warning"
autoFocus
>
{t('auth.goToSettings')}
</button>
</div>
</div>
</div>
</div>
);
};
export default DefaultPasswordWarningModal;

View File

@@ -14,12 +14,12 @@ const initialState: AuthState = {
// Create auth context
const AuthContext = createContext<{
auth: AuthState;
login: (username: string, password: string) => Promise<boolean>;
login: (username: string, password: string) => Promise<{ success: boolean; isUsingDefaultPassword?: boolean }>;
register: (username: string, password: string, isAdmin?: boolean) => Promise<boolean>;
logout: () => void;
}>({
auth: initialState,
login: async () => false,
login: async () => ({ success: false }),
register: async () => false,
logout: () => { },
});
@@ -90,7 +90,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}, []);
// Login function
const login = async (username: string, password: string): Promise<boolean> => {
const login = async (username: string, password: string): Promise<{ success: boolean; isUsingDefaultPassword?: boolean }> => {
try {
const response = await authService.login({ username, password });
@@ -101,14 +101,17 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
user: response.user,
error: null,
});
return true;
return {
success: true,
isUsingDefaultPassword: response.isUsingDefaultPassword,
};
} else {
setAuth({
...initialState,
loading: false,
error: response.message || 'Authentication failed',
});
return false;
return { success: false };
}
} catch (error) {
setAuth({
@@ -116,7 +119,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
loading: false,
error: 'Authentication failed',
});
return false;
return { success: false };
}
};

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../contexts/AuthContext';
import ThemeSwitch from '@/components/ui/ThemeSwitch';
import LanguageSwitch from '@/components/ui/LanguageSwitch';
import DefaultPasswordWarningModal from '@/components/ui/DefaultPasswordWarningModal';
const LoginPage: React.FC = () => {
const { t } = useTranslation();
@@ -11,6 +12,7 @@ const LoginPage: React.FC = () => {
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showDefaultPasswordWarning, setShowDefaultPasswordWarning] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
@@ -26,10 +28,15 @@ const LoginPage: React.FC = () => {
return;
}
const success = await login(username, password);
const result = await login(username, password);
if (success) {
navigate('/');
if (result.success) {
if (result.isUsingDefaultPassword) {
// Show warning modal instead of navigating immediately
setShowDefaultPasswordWarning(true);
} else {
navigate('/');
}
} else {
setError(t('auth.loginFailed'));
}
@@ -40,6 +47,11 @@ const LoginPage: React.FC = () => {
}
};
const handleCloseWarning = () => {
setShowDefaultPasswordWarning(false);
navigate('/');
};
return (
<div className="relative min-h-screen w-full overflow-hidden bg-gray-50 dark:bg-gray-950">
{/* Top-right controls */}
@@ -138,6 +150,12 @@ const LoginPage: React.FC = () => {
</div>
</div>
</div>
{/* Default Password Warning Modal */}
<DefaultPasswordWarningModal
isOpen={showDefaultPasswordWarning}
onClose={handleCloseWarning}
/>
</div>
);
};

View File

@@ -794,10 +794,11 @@ const SettingsPage: React.FC = () => {
</PermissionChecker>
{/* Change Password */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card" data-section="password">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('password')}
role="button"
>
<h2 className="font-semibold text-gray-800">{t('auth.changePassword')}</h2>
<span className="text-gray-500">{sectionsVisible.password ? '▼' : '►'}</span>

View File

@@ -359,6 +359,7 @@ export interface AuthResponse {
token?: string;
user?: IUser;
message?: string;
isUsingDefaultPassword?: boolean;
}
// Official Registry types (from registry.modelcontextprotocol.io)

View File

@@ -0,0 +1,38 @@
/**
* Frontend password strength validation utility
* Should match backend validation rules
*/
export interface PasswordValidationResult {
isValid: boolean;
errors: string[];
}
export const validatePasswordStrength = (password: string): PasswordValidationResult => {
const errors: string[] = [];
// Check minimum length
if (password.length < 8) {
errors.push('passwordMinLength');
}
// Check for at least one letter
if (!/[a-zA-Z]/.test(password)) {
errors.push('passwordRequireLetter');
}
// Check for at least one number
if (!/\d/.test(password)) {
errors.push('passwordRequireNumber');
}
// Check for at least one special character
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
errors.push('passwordRequireSpecial');
}
return {
isValid: errors.length === 0,
errors,
};
};

View File

@@ -69,7 +69,16 @@
"changePasswordError": "Failed to change password",
"changePassword": "Change Password",
"passwordChanged": "Password changed successfully",
"passwordChangeError": "Failed to change password"
"passwordChangeError": "Failed to change password",
"defaultPasswordWarning": "Default Password Security Warning",
"defaultPasswordMessage": "You are using the default password (admin123), which poses a security risk. Please change your password immediately to protect your account.",
"goToSettings": "Go to Settings",
"passwordStrengthError": "Password does not meet security requirements",
"passwordMinLength": "Password must be at least 8 characters long",
"passwordRequireLetter": "Password must contain at least one letter",
"passwordRequireNumber": "Password must contain at least one number",
"passwordRequireSpecial": "Password must contain at least one special character",
"passwordStrengthHint": "Password must be at least 8 characters and contain letters, numbers, and special characters"
},
"server": {
"addServer": "Add Server",

View File

@@ -69,7 +69,16 @@
"changePasswordError": "Échec du changement de mot de passe",
"changePassword": "Changer le mot de passe",
"passwordChanged": "Mot de passe changé avec succès",
"passwordChangeError": "Échec du changement de mot de passe"
"passwordChangeError": "Échec du changement de mot de passe",
"defaultPasswordWarning": "Avertissement de sécurité du mot de passe par défaut",
"defaultPasswordMessage": "Vous utilisez le mot de passe par défaut (admin123), ce qui présente un risque de sécurité. Veuillez changer votre mot de passe immédiatement pour protéger votre compte.",
"goToSettings": "Aller aux paramètres",
"passwordStrengthError": "Le mot de passe ne répond pas aux exigences de sécurité",
"passwordMinLength": "Le mot de passe doit contenir au moins 8 caractères",
"passwordRequireLetter": "Le mot de passe doit contenir au moins une lettre",
"passwordRequireNumber": "Le mot de passe doit contenir au moins un chiffre",
"passwordRequireSpecial": "Le mot de passe doit contenir au moins un caractère spécial",
"passwordStrengthHint": "Le mot de passe doit contenir au moins 8 caractères et inclure des lettres, des chiffres et des caractères spéciaux"
},
"server": {
"addServer": "Ajouter un serveur",

View File

@@ -69,7 +69,16 @@
"changePasswordError": "修改密码失败",
"changePassword": "修改密码",
"passwordChanged": "密码修改成功",
"passwordChangeError": "修改密码失败"
"passwordChangeError": "修改密码失败",
"defaultPasswordWarning": "默认密码安全警告",
"defaultPasswordMessage": "您正在使用默认密码admin123这存在安全风险。为了保护您的账户安全请立即修改密码。",
"goToSettings": "前往修改",
"passwordStrengthError": "密码不符合安全要求",
"passwordMinLength": "密码长度至少为 8 个字符",
"passwordRequireLetter": "密码必须包含至少一个字母",
"passwordRequireNumber": "密码必须包含至少一个数字",
"passwordRequireSpecial": "密码必须包含至少一个特殊字符",
"passwordStrengthHint": "密码必须至少 8 个字符,且包含字母、数字和特殊字符"
},
"server": {
"addServer": "添加服务器",

View File

@@ -10,6 +10,8 @@ import {
import { getDataService } from '../services/services.js';
import { DataService } from '../services/dataService.js';
import { JWT_SECRET } from '../config/jwt.js';
import { validatePasswordStrength, isDefaultPassword } from '../utils/passwordValidation.js';
import { getPackageVersion } from '../utils/version.js';
const dataService: DataService = getDataService();
@@ -64,6 +66,14 @@ export const login = async (req: Request, res: Response): Promise<void> => {
},
};
// Check if user is admin with default password
const version = getPackageVersion();
const isUsingDefaultPassword =
user.username === 'admin' &&
user.isAdmin &&
isDefaultPassword(password) &&
version !== 'dev';
jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }, (err, token) => {
if (err) throw err;
res.json({
@@ -75,6 +85,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
isAdmin: user.isAdmin,
permissions: dataService.getPermissions(user),
},
isUsingDefaultPassword,
});
});
} catch (error) {
@@ -172,6 +183,17 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
const username = (req as any).user.username;
try {
// Validate new password strength
const validationResult = validatePasswordStrength(newPassword);
if (!validationResult.isValid) {
res.status(400).json({
success: false,
message: 'Password does not meet security requirements',
errors: validationResult.errors,
});
return;
}
// Find user by username
const user = findUserByUsername(username);

View File

@@ -10,6 +10,7 @@ import {
getAdminCount,
} from '../services/userService.js';
import { loadSettings } from '../config/index.js';
import { validatePasswordStrength } from '../utils/passwordValidation.js';
// Admin permission check middleware function
const requireAdmin = (req: Request, res: Response): boolean => {
@@ -100,6 +101,17 @@ export const createUser = async (req: Request, res: Response): Promise<void> =>
return;
}
// Validate password strength
const validationResult = validatePasswordStrength(password);
if (!validationResult.isValid) {
res.status(400).json({
success: false,
message: 'Password does not meet security requirements',
errors: validationResult.errors,
});
return;
}
const newUser = await createNewUser(username, password, isAdmin || false);
if (!newUser) {
res.status(400).json({
@@ -163,7 +175,19 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
const updateData: any = {};
if (isAdmin !== undefined) updateData.isAdmin = isAdmin;
if (newPassword) updateData.newPassword = newPassword;
if (newPassword) {
// Validate new password strength
const validationResult = validatePasswordStrength(newPassword);
if (!validationResult.isValid) {
res.status(400).json({
success: false,
message: 'Password does not meet security requirements',
errors: validationResult.errors,
});
return;
}
updateData.newPassword = newPassword;
}
if (Object.keys(updateData).length === 0) {
res.status(400).json({

View File

@@ -0,0 +1,49 @@
/**
* Password strength validation utility
* Requirements:
* - At least 8 characters
* - Contains at least one letter
* - Contains at least one number
* - Contains at least one special character
*/
export interface PasswordValidationResult {
isValid: boolean;
errors: string[];
}
export const validatePasswordStrength = (password: string): PasswordValidationResult => {
const errors: string[] = [];
// Check minimum length
if (password.length < 8) {
errors.push('Password must be at least 8 characters long');
}
// Check for at least one letter
if (!/[a-zA-Z]/.test(password)) {
errors.push('Password must contain at least one letter');
}
// Check for at least one number
if (!/\d/.test(password)) {
errors.push('Password must contain at least one number');
}
// Check for at least one special character
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
errors.push('Password must contain at least one special character');
}
return {
isValid: errors.length === 0,
errors,
};
};
/**
* Check if a password is the default password (admin123)
*/
export const isDefaultPassword = (plainPassword: string): boolean => {
return plainPassword === 'admin123';
};