diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index a1c13ee..4b36fcc 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -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) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 304c7c6..944218f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 6b1b614..df7dc6a 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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""" diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 85bf67f..288104c 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -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(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() {
{error && ( -
- {error} +
+
+ {isLocked && ( +
+ + + + Account Locked +
+ )} + {!isLocked && remainingAttempts !== null && remainingAttempts <= 5 && ( +
+ + + + Warning: Account will be locked soon +
+ )} + {error} +
+ {isLocked && ( + + Reset your password → + + )}
)} diff --git a/frontend/src/app/register/page.tsx b/frontend/src/app/register/page.tsx index 8acde80..ba83408 100644 --- a/frontend/src/app/register/page.tsx +++ b/frontend/src/app/register/page.tsx @@ -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 />
+ {/* Password Requirements Checklist */} +
+
+ {passwordChecks.length ? ( + + + + ) : ( + + + + )} + {t.passwordMinLength || 'At least 10 characters'} +
+
+ {passwordChecks.special ? ( + + + + ) : ( + + + + )} + {t.passwordSpecialChar || 'At least 1 special character (!@#$%^&*...)'} +
+
@@ -390,8 +426,8 @@ export default function RegisterPage() { diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 793cf06..5e7a7cf 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -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 = { 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 = { creatingAccount: 'Бүртгэл үүсгэж байна...', passwordsDoNotMatch: 'Нууц үг таарахгүй байна', passwordTooShort: 'Нууц үг 6-аас дээш тэмдэгт байх ёстой', + passwordMinLength: '10-аас дээш тэмдэгт', + passwordSpecialChar: '1-ээс дээш тусгай тэмдэгт (!@#$%^&*...)', + passwordRequirementsNotMet: 'Нууц үг шаардлага хангахгүй байна', registrationFailed: 'Бүртгэл амжилтгүй. Дахин оролдоно уу.', alreadyHaveAccount: 'Бүртгэлтэй юу?', signIn: 'Нэвтрэх', @@ -1865,6 +1874,9 @@ const translations: Record = { creatingAccount: 'Создание аккаунта...', passwordsDoNotMatch: 'Пароли не совпадают', passwordTooShort: 'Пароль должен быть не менее 6 символов', + passwordMinLength: 'Минимум 10 символов', + passwordSpecialChar: 'Минимум 1 специальный символ (!@#$%^&*...)', + passwordRequirementsNotMet: 'Пароль не соответствует требованиям', registrationFailed: 'Регистрация не удалась. Попробуйте снова.', alreadyHaveAccount: 'Уже есть аккаунт?', signIn: 'Войти', @@ -2331,6 +2343,9 @@ const translations: Record = { creatingAccount: '계정 생성 중...', passwordsDoNotMatch: '비밀번호가 일치하지 않습니다', passwordTooShort: '비밀번호는 6자 이상이어야 합니다', + passwordMinLength: '최소 10자 이상', + passwordSpecialChar: '특수문자 1개 이상 포함 (!@#$%^&*...)', + passwordRequirementsNotMet: '비밀번호 조건을 충족하지 않습니다', registrationFailed: '가입 실패. 다시 시도해 주세요.', alreadyHaveAccount: '이미 계정이 있으신가요?', signIn: '로그인',