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:
AutonetSellCar Deploy
2026-01-01 19:19:36 +09:00
parent 1e3ad2fa65
commit 9853f0b4d5
6 changed files with 169 additions and 11 deletions

View File

@@ -161,7 +161,41 @@ def login(
):
"""로그인"""
user = db.query(User).filter(User.email == form_data.username).first()
if not user or not verify_password(form_data.password, user.password_hash):
# 사용자가 존재하는 경우 로그인 실패 처리
if user:
# 비밀번호 재설정 필요 여부 체크
if getattr(user, 'password_reset_required', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Password reset required. Too many failed login attempts.",
headers={"WWW-Authenticate": "Bearer"},
)
# 비밀번호 검증
if not verify_password(form_data.password, user.password_hash):
# 실패 횟수 증가
failed_attempts = getattr(user, 'failed_login_attempts', 0) or 0
user.failed_login_attempts = failed_attempts + 1
# 20회 이상 실패 시 비밀번호 재설정 필요
if user.failed_login_attempts >= 20:
user.password_reset_required = True
db.commit()
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account locked. Password reset required due to too many failed attempts.",
headers={"WWW-Authenticate": "Bearer"},
)
db.commit()
remaining = 20 - user.failed_login_attempts
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Incorrect email or password. {remaining} attempts remaining before account lock.",
headers={"WWW-Authenticate": "Bearer"},
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
@@ -184,6 +218,10 @@ def login(
headers={"WWW-Authenticate": "Bearer"},
)
# 로그인 성공 - 실패 횟수 초기화
user.failed_login_attempts = 0
db.commit()
access_token = create_access_token(data={"sub": user.email})
return Token(access_token=access_token)

View File

@@ -41,6 +41,11 @@ class User(Base):
withdrawal_reason = Column(String(500), nullable=True) # 탈퇴 사유
deleted_at = Column(DateTime(timezone=True), nullable=True) # 실제 삭제 시각 (soft delete)
# Login security
failed_login_attempts = Column(Integer, default=0) # 로그인 실패 횟수
locked_until = Column(DateTime(timezone=True), nullable=True) # 계정 잠금 해제 시간
password_reset_required = Column(Boolean, default=False) # 비밀번호 재설정 필요 여부
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Note: foreign_keys specified as string to avoid circular import

View File

@@ -1,6 +1,7 @@
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, field_validator
from typing import Optional
from datetime import datetime
import re
class UserCreate(BaseModel):
@@ -11,6 +12,15 @@ class UserCreate(BaseModel):
country: str = "Mongolia"
referred_by: Optional[str] = None # Referral code of the user who referred
@field_validator('password')
@classmethod
def validate_password(cls, v):
if len(v) < 10:
raise ValueError('Password must be at least 10 characters long')
if not re.search(r'[!@#$%^&*(),.?":{}|<>_\-+=\[\]\\\/`~]', v):
raise ValueError('Password must contain at least one special character')
return v
class UserUpdate(BaseModel):
"""Schema for updating user profile"""

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -465,6 +465,9 @@ export interface Translations {
creatingAccount: string;
passwordsDoNotMatch: string;
passwordTooShort: string;
passwordMinLength: string;
passwordSpecialChar: string;
passwordRequirementsNotMet: string;
registrationFailed: string;
alreadyHaveAccount: string;
signIn: string;
@@ -933,6 +936,9 @@ const translations: Record<Language, Translations> = {
creatingAccount: 'Creating account...',
passwordsDoNotMatch: 'Passwords do not match',
passwordTooShort: 'Password must be at least 6 characters',
passwordMinLength: 'At least 10 characters',
passwordSpecialChar: 'At least 1 special character (!@#$%^&*...)',
passwordRequirementsNotMet: 'Password does not meet requirements',
registrationFailed: 'Registration failed. Please try again.',
alreadyHaveAccount: 'Already have an account?',
signIn: 'Sign in',
@@ -1399,6 +1405,9 @@ const translations: Record<Language, Translations> = {
creatingAccount: 'Бүртгэл үүсгэж байна...',
passwordsDoNotMatch: 'Нууц үг таарахгүй байна',
passwordTooShort: 'Нууц үг 6-аас дээш тэмдэгт байх ёстой',
passwordMinLength: '10-аас дээш тэмдэгт',
passwordSpecialChar: '1-ээс дээш тусгай тэмдэгт (!@#$%^&*...)',
passwordRequirementsNotMet: 'Нууц үг шаардлага хангахгүй байна',
registrationFailed: 'Бүртгэл амжилтгүй. Дахин оролдоно уу.',
alreadyHaveAccount: 'Бүртгэлтэй юу?',
signIn: 'Нэвтрэх',
@@ -1865,6 +1874,9 @@ const translations: Record<Language, Translations> = {
creatingAccount: 'Создание аккаунта...',
passwordsDoNotMatch: 'Пароли не совпадают',
passwordTooShort: 'Пароль должен быть не менее 6 символов',
passwordMinLength: 'Минимум 10 символов',
passwordSpecialChar: 'Минимум 1 специальный символ (!@#$%^&*...)',
passwordRequirementsNotMet: 'Пароль не соответствует требованиям',
registrationFailed: 'Регистрация не удалась. Попробуйте снова.',
alreadyHaveAccount: 'Уже есть аккаунт?',
signIn: 'Войти',
@@ -2331,6 +2343,9 @@ const translations: Record<Language, Translations> = {
creatingAccount: '계정 생성 중...',
passwordsDoNotMatch: '비밀번호가 일치하지 않습니다',
passwordTooShort: '비밀번호는 6자 이상이어야 합니다',
passwordMinLength: '최소 10자 이상',
passwordSpecialChar: '특수문자 1개 이상 포함 (!@#$%^&*...)',
passwordRequirementsNotMet: '비밀번호 조건을 충족하지 않습니다',
registrationFailed: '가입 실패. 다시 시도해 주세요.',
alreadyHaveAccount: '이미 계정이 있으신가요?',
signIn: '로그인',