Feat: 강력한 비밀번호 정책 및 로그인 보안 강화
- 비밀번호 최소 10자 이상, 특수문자 1개 이상 필수 - 20회 로그인 실패 시 비밀번호 재설정 필요 - 로그인 페이지에 남은 시도 횟수 경고 표시 - 계정 잠금 시 비밀번호 재설정 링크 제공 - 회원가입 페이지에 비밀번호 요구사항 체크리스트 UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,11 +13,15 @@ export default function LoginPage() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [remainingAttempts, setRemainingAttempts] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLocked(false);
|
||||
setRemainingAttempts(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
@@ -29,7 +33,19 @@ export default function LoginPage() {
|
||||
|
||||
router.push('/');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Login failed. Please check your credentials.');
|
||||
const errorMsg = err.response?.data?.detail || 'Login failed. Please check your credentials.';
|
||||
setError(errorMsg);
|
||||
|
||||
// Check if account is locked (password reset required)
|
||||
if (errorMsg.includes('Password reset required') || errorMsg.includes('Account locked')) {
|
||||
setIsLocked(true);
|
||||
}
|
||||
|
||||
// Extract remaining attempts from error message
|
||||
const attemptsMatch = errorMsg.match(/(\d+) attempts? remaining/);
|
||||
if (attemptsMatch) {
|
||||
setRemainingAttempts(parseInt(attemptsMatch[1]));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -45,8 +61,46 @@ export default function LoginPage() {
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-8">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-4 rounded-md mb-6">
|
||||
{error}
|
||||
<div className={`p-4 rounded-md mb-6 ${
|
||||
isLocked
|
||||
? 'bg-orange-50 border border-orange-200'
|
||||
: remainingAttempts !== null && remainingAttempts <= 5
|
||||
? 'bg-amber-50 border border-amber-200'
|
||||
: 'bg-red-50'
|
||||
}`}>
|
||||
<div className={
|
||||
isLocked
|
||||
? 'text-orange-700'
|
||||
: remainingAttempts !== null && remainingAttempts <= 5
|
||||
? 'text-amber-700'
|
||||
: 'text-red-600'
|
||||
}>
|
||||
{isLocked && (
|
||||
<div className="flex items-center mb-2">
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="font-semibold">Account Locked</span>
|
||||
</div>
|
||||
)}
|
||||
{!isLocked && remainingAttempts !== null && remainingAttempts <= 5 && (
|
||||
<div className="flex items-center mb-2">
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="font-semibold">Warning: Account will be locked soon</span>
|
||||
</div>
|
||||
)}
|
||||
{error}
|
||||
</div>
|
||||
{isLocked && (
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="inline-block mt-3 text-primary-600 hover:text-primary-700 font-medium hover:underline"
|
||||
>
|
||||
Reset your password →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -29,6 +29,13 @@ export default function RegisterPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
|
||||
// Password validation
|
||||
const passwordChecks = {
|
||||
length: formData.password.length >= 10,
|
||||
special: /[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\\/`~]/.test(formData.password),
|
||||
};
|
||||
const isPasswordValid = passwordChecks.length && passwordChecks.special;
|
||||
|
||||
// Countdown timer for resend
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
@@ -109,8 +116,8 @@ export default function RegisterPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password.length < 6) {
|
||||
setError(t.passwordTooShort || 'Password must be at least 6 characters');
|
||||
if (!isPasswordValid) {
|
||||
setError(t.passwordRequirementsNotMet || 'Password does not meet requirements');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -332,8 +339,10 @@ export default function RegisterPage() {
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className="w-full border border-gray-300 rounded-md px-4 py-2 pr-10 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"
|
||||
className={`w-full border rounded-md px-4 py-2 pr-10 focus:ring-primary-500 focus:border-primary-500 ${
|
||||
formData.password && !isPasswordValid ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="••••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
@@ -353,6 +362,33 @@ export default function RegisterPage() {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Password Requirements Checklist */}
|
||||
<div className="mt-2 text-sm space-y-1">
|
||||
<div className={`flex items-center ${passwordChecks.length ? 'text-green-600' : 'text-gray-500'}`}>
|
||||
{passwordChecks.length ? (
|
||||
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" strokeWidth="2" />
|
||||
</svg>
|
||||
)}
|
||||
{t.passwordMinLength || 'At least 10 characters'}
|
||||
</div>
|
||||
<div className={`flex items-center ${passwordChecks.special ? 'text-green-600' : 'text-gray-500'}`}>
|
||||
{passwordChecks.special ? (
|
||||
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" strokeWidth="2" />
|
||||
</svg>
|
||||
)}
|
||||
{t.passwordSpecialChar || 'At least 1 special character (!@#$%^&*...)'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
@@ -390,8 +426,8 @@ export default function RegisterPage() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-md hover:bg-primary-700 transition disabled:opacity-50"
|
||||
disabled={loading || !isPasswordValid || formData.password !== formData.confirmPassword}
|
||||
className="w-full bg-primary-600 text-white py-3 rounded-md hover:bg-primary-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (t.creatingAccount || 'Creating account...') : (t.createAccount || 'Create Account')}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user