diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..88555e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg +*.egg-info/ +dist/ +build/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.whl +.installed.cfg + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# IDEs +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Environment variables +.env +.env.* +!.env.example + +# Node +node_modules/ +npm-debug.log* + +# Distribution +*.tar.gz +*.zip diff --git a/backend/auth.py b/backend/auth.py index 511f234..deabddf 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -2,7 +2,7 @@ import os import bcrypt import secrets -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Optional @@ -67,7 +67,7 @@ def create_access_token( ) -> str: to_encode = data.copy() if not no_expiry: - expire = datetime.utcnow() + ( + expire = datetime.now(timezone.utc) + ( expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) ) to_encode.update({"exp": expire}) diff --git a/backend/auth_router.py b/backend/auth_router.py index 0fb4767..1ae751d 100644 --- a/backend/auth_router.py +++ b/backend/auth_router.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from secrets import compare_digest, randbelow, token_urlsafe import base64 import os @@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordRequestForm from typing import Optional, cast -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, ConfigDict, EmailStr from sqlalchemy.orm import Session from webauthn import ( generate_authentication_options, @@ -82,8 +82,7 @@ class UserResponse(BaseModel): business_registration_number: Optional[str] = None representative_name: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class Token(BaseModel): @@ -254,7 +253,7 @@ class PasswordRecoveryResetResponse(BaseModel): def _issue_recovery_token(prefix: str) -> tuple[str, datetime]: - expires_at = datetime.utcnow() + timedelta(minutes=10) + expires_at = datetime.now(timezone.utc) + timedelta(minutes=10) return f"{prefix}_{token_urlsafe(24)}", expires_at @@ -273,7 +272,7 @@ def _normalize_password_recovery_scope(scope: str) -> str: def _purge_expired_password_recovery_sessions() -> None: - now = datetime.utcnow() + now = datetime.now(timezone.utc) expired_tokens = [ session_token for session_token, session_state in _password_recovery_store.items() @@ -380,7 +379,7 @@ def start_passkey_registration( "user_id": int(user.id), "challenge": _to_base64url(options.challenge), "device_label": str(payload.device_label or "이 기기 패스키").strip() or "이 기기 패스키", - "expires_at": datetime.utcnow() + timedelta(minutes=5), + "expires_at": datetime.now(timezone.utc) + timedelta(minutes=5), "user_handle": user_handle, "rp_id": rp_id, "expected_origin": expected_origin, @@ -403,7 +402,7 @@ def finish_passkey_registration( raise HTTPException(status_code=404, detail="패스키 등록 세션을 찾을 수 없습니다") expires_at = state.get("expires_at") - if not isinstance(expires_at, datetime) or expires_at <= datetime.utcnow(): + if not isinstance(expires_at, datetime) or expires_at <= datetime.now(timezone.utc): _passkey_registration_store.pop(payload.registration_token, None) raise HTTPException(status_code=410, detail="패스키 등록 세션이 만료되었습니다") @@ -430,7 +429,7 @@ def finish_passkey_registration( user.passkey_public_key = _to_base64url(verification.credential_public_key) user.passkey_device_label = str(state.get("device_label") or "이 기기 패스키") user.passkey_sign_count = int(verification.sign_count) - user.passkey_registered_at = datetime.utcnow() + user.passkey_registered_at = datetime.now(timezone.utc) db.add(user) db.commit() _passkey_registration_store.pop(payload.registration_token, None) @@ -465,7 +464,7 @@ def start_passkey_login( ) _passkey_login_store[str(user.email)] = { "challenge": _to_base64url(options.challenge), - "expires_at": datetime.utcnow() + timedelta(minutes=5), + "expires_at": datetime.now(timezone.utc) + timedelta(minutes=5), "credential_id": str(user.passkey_credential_id), "rp_id": rp_id, "expected_origin": expected_origin, @@ -492,7 +491,7 @@ def finish_passkey_login( raise HTTPException(status_code=404, detail="패스키 로그인 세션을 찾을 수 없습니다") expires_at = state.get("expires_at") - if not isinstance(expires_at, datetime) or expires_at <= datetime.utcnow(): + if not isinstance(expires_at, datetime) or expires_at <= datetime.now(timezone.utc): _passkey_login_store.pop(str(user.email), None) raise HTTPException(status_code=410, detail="패스키 로그인 세션이 만료되었습니다") @@ -581,7 +580,7 @@ def verify_password_recovery_identity(payload: PasswordRecoveryVerifyIdentityReq raise HTTPException(status_code=404, detail="복구 세션을 찾을 수 없습니다") expires_at = session_state.get("expires_at") - if not isinstance(expires_at, datetime) or expires_at <= datetime.utcnow(): + if not isinstance(expires_at, datetime) or expires_at <= datetime.now(timezone.utc): _password_recovery_store.pop(payload.recovery_session_token, None) raise HTTPException(status_code=410, detail="복구 세션이 만료되었습니다") @@ -638,7 +637,7 @@ def reset_password_via_recovery( raise HTTPException(status_code=403, detail="본인확인 검증이 완료되지 않았습니다") reset_expires_at = session_state.get("reset_expires_at") - if not isinstance(reset_expires_at, datetime) or reset_expires_at <= datetime.utcnow(): + if not isinstance(reset_expires_at, datetime) or reset_expires_at <= datetime.now(timezone.utc): _password_recovery_store.pop(session_token, None) raise HTTPException(status_code=410, detail="재설정 토큰이 만료되었습니다") diff --git a/tests/test_auth_router_security.py b/tests/test_auth_router_security.py index 5b861c5..245042d 100644 --- a/tests/test_auth_router_security.py +++ b/tests/test_auth_router_security.py @@ -1,7 +1,7 @@ import importlib import sys import types -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from types import SimpleNamespace import pytest @@ -50,6 +50,39 @@ class _StubUser: fake_models.User = _StubUser + # Stub webauthn if not installed so auth_router can be imported without it + if "webauthn" not in sys.modules: + _stub_webauthn = types.ModuleType("webauthn") + for _name in ( + "generate_authentication_options", + "generate_registration_options", + "options_to_json", + "verify_authentication_response", + "verify_registration_response", + ): + setattr(_stub_webauthn, _name, None) + + _stub_structs = types.ModuleType("webauthn.helpers.structs") + for _name in ( + "PublicKeyCredentialType", + "AuthenticatorAssertionResponse", + "AuthenticatorAttestationResponse", + "AuthenticatorSelectionCriteria", + "PublicKeyCredentialDescriptor", + "RegistrationCredential", + "AuthenticationCredential", + "UserVerificationRequirement", + "ResidentKeyRequirement", + ): + setattr(_stub_structs, _name, None) + + _stub_helpers = types.ModuleType("webauthn.helpers") + _stub_helpers.structs = _stub_structs + + monkeypatch.setitem(sys.modules, "webauthn", _stub_webauthn) + monkeypatch.setitem(sys.modules, "webauthn.helpers", _stub_helpers) + monkeypatch.setitem(sys.modules, "webauthn.helpers.structs", _stub_structs) + monkeypatch.setitem(sys.modules, "backend.database", fake_database) monkeypatch.setitem(sys.modules, "backend.models", fake_models) return importlib.import_module("backend.auth_router") @@ -96,7 +129,7 @@ def test_password_recovery_verify_identity_limits_failed_attempts(monkeypatch): "verified": False, "verification_code": "654321", "verification_attempts": 0, - "expires_at": datetime.utcnow() + timedelta(minutes=5), + "expires_at": datetime.now(timezone.utc) + timedelta(minutes=5), } payload = auth_router.PasswordRecoveryVerifyIdentityRequest( recovery_session_token=recovery_session_token, @@ -123,8 +156,8 @@ def test_reset_password_requires_verified_identity(monkeypatch): "scope": "admin", "verified": False, "reset_token": "reset_token", - "reset_expires_at": datetime.utcnow() + timedelta(minutes=5), - "expires_at": datetime.utcnow() + timedelta(minutes=5), + "reset_expires_at": datetime.now(timezone.utc) + timedelta(minutes=5), + "expires_at": datetime.now(timezone.utc) + timedelta(minutes=5), } with pytest.raises(HTTPException) as exc_info: @@ -151,8 +184,8 @@ def test_reset_password_updates_hash_and_clears_session(monkeypatch): "verified": True, "identity_session_token": "identity-proof", "reset_token": "reset_token", - "reset_expires_at": datetime.utcnow() + timedelta(minutes=5), - "expires_at": datetime.utcnow() + timedelta(minutes=5), + "reset_expires_at": datetime.now(timezone.utc) + timedelta(minutes=5), + "expires_at": datetime.now(timezone.utc) + timedelta(minutes=5), } response = auth_router.reset_password_via_recovery(