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,
};
};