From 0b8c34a2c2c043422fce1be6e999beaf42e43884 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 21 Oct 2026 12:13:35 +0900 Subject: [PATCH] =?UTF-8?q?fix(503-1=20=C2=B7=20503-2=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8):=20=EC=84=B1=EA=B3=B5=20=EB=92=A4=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=BA=90=EC=8B=9C=20=EB=B9=84=EC=9B=80(=EB=90=98?= =?UTF-8?q?=ED=8A=95=EA=B9=80)=20=C2=B7=20=EB=A7=88=EB=AC=B4=EB=A6=AC=20?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=ED=95=9C=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=C2=B7=20bcrypt=20=EC=8A=A4=EB=A0=88=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - requestLogin 응답 뒤 clearSessionCache — 화면 뜬 뒤 4 s 안 누름도 대시보드 - finish_login = 한 커넥션 · begin 1 · commit 1(users UPDATE · 신뢰 기기 · 세션 · login_logs) · 실패 = rollback - verify_password = asyncio.to_thread(로그인 · OTP 확인) — 이벤트 루프 안 막음 - 시험 test_309_1_login.py Co-Authored-By: Claude Opus 5.5 --- A06_Login/A06_Login_Api_Fetch.ts | 7 +- A06_Login/A06_Login_Router.py | 28 ++-- common_util/common_util_auth.py | 16 +- common_util/common_util_auth_repository.py | 106 ++++++++---- resources/tester/test_309_1_login.py | 179 +++++++++++++++++++++ 5 files changed, 271 insertions(+), 65 deletions(-) create mode 100644 resources/tester/test_309_1_login.py diff --git a/A06_Login/A06_Login_Api_Fetch.ts b/A06_Login/A06_Login_Api_Fetch.ts index 66532ac0e..4b0088898 100644 --- a/A06_Login/A06_Login_Api_Fetch.ts +++ b/A06_Login/A06_Login_Api_Fetch.ts @@ -29,8 +29,11 @@ async function post(path: string, body?: object): Promise { return result; } -export function requestLogin(email: string, password: string): Promise { - return post("/auth/login/request", { email, password }); +export async function requestLogin(email: string, password: string): Promise { + const result = await post("/auth/login/request", { email, password }); + // 화면이 뜰 때 기억한 「로그인 안 됨」(5 s)을 응답 뒤에 버림 — 안 버리면 대시보드 가드가 되튕김(309-1) + clearSessionCache(); + return result; } export function verifyLogin(email: string, otpCode: string): Promise { diff --git a/A06_Login/A06_Login_Router.py b/A06_Login/A06_Login_Router.py index e60f62f4a..162fc091c 100644 --- a/A06_Login/A06_Login_Router.py +++ b/A06_Login/A06_Login_Router.py @@ -1,5 +1,6 @@ """로그인, 재인증, 세션 및 비밀번호 API.""" +import asyncio import logging from datetime import datetime, timedelta @@ -7,7 +8,6 @@ from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse from common_util.common_util_auth import ( - create_session, delete_session_cookie, generate_device_token, generate_otp, @@ -22,16 +22,15 @@ from common_util.common_util_auth import ( ) from common_util.common_util_auth_repository import ( change_password, - clear_login_failures, consume_otp, delete_session, + finish_login, get_active_otp, get_user_by_email, has_trusted_device, record_failed_login, record_login, replace_otp, - trust_device, ) from common_util.common_util_email import send_email_background from common_util.common_util_email_templates import otp_email, security_alert_email @@ -66,7 +65,8 @@ async def request_login(payload: LoginRequest, request: Request): if user["account_locked_until"] and user["account_locked_until"] > datetime.utcnow(): await record_login(user["id"], email, "FAILURE", "ACCOUNT_LOCKED", agent) raise HTTPException(status_code=423, detail="로그인 실패 누적으로 계정이 잠겼습니다.") - if not verify_password(payload.password, user["password_hash"]): + # bcrypt(cost 12 · 약 0.2 s)는 스레드에서 — 이벤트 루프(다른 요청)를 막지 않게 + if not await asyncio.to_thread(verify_password, payload.password, user["password_hash"]): failures = await record_failed_login(user["id"]) await record_login(user["id"], email, "FAILURE", "INVALID_PASSWORD", agent) if failures >= 5 and ADMIN_EMAIL: @@ -108,25 +108,15 @@ async def request_login(payload: LoginRequest, request: Request): async def _finish_login( user: dict, agent: str, previous_device_token_hash: str | None = None ) -> JSONResponse: - await clear_login_failures(user["id"]) device_token = generate_device_token() - await trust_device( + session_id = await finish_login( user["id"], - hash_device_token(device_token), + user["email"], + agent, hash_user_agent(agent), + hash_device_token(device_token), previous_device_token_hash, ) - from config.config_db import get_db_pool - - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute( - "UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = %s", - (user["id"],), - ) - await connection.commit() - session_id = await create_session(user["id"], agent) - await record_login(user["id"], user["email"], "SUCCESS", None, agent) response = JSONResponse( { "status": "success", @@ -146,7 +136,7 @@ async def verify_login(payload: OtpVerifyRequest, request: Request): not user or not otp or otp["expires_at"] < datetime.utcnow() - or not verify_password(payload.otp_code, otp["otp_hash"]) + or not await asyncio.to_thread(verify_password, payload.otp_code, otp["otp_hash"]) ): if user: failures = await record_failed_login(user["id"]) diff --git a/common_util/common_util_auth.py b/common_util/common_util_auth.py index 0129110b5..89d314aaa 100644 --- a/common_util/common_util_auth.py +++ b/common_util/common_util_auth.py @@ -3,7 +3,7 @@ import hashlib import re import secrets -from datetime import datetime, timedelta +from datetime import datetime from typing import Any import bcrypt @@ -88,20 +88,6 @@ def delete_session_cookie(response: Response) -> None: ) -async def create_session(user_id: int, user_agent: str) -> str: - session_id = secrets.token_urlsafe(32) - expires_at = datetime.utcnow() + timedelta(seconds=SESSION_MAX_AGE_SECONDS) - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute( - """INSERT INTO sessions (id, user_id, user_agent_hash, expires_at) - VALUES (%s, %s, %s, %s)""", - (session_id, user_id, hash_user_agent(user_agent), expires_at), - ) - await connection.commit() - return session_id - - async def verify_session(request: Request) -> dict[str, Any]: session_id = request.cookies.get(SESSION_COOKIE_NAME) if not session_id: diff --git a/common_util/common_util_auth_repository.py b/common_util/common_util_auth_repository.py index ced86cdd8..558608db7 100644 --- a/common_util/common_util_auth_repository.py +++ b/common_util/common_util_auth_repository.py @@ -9,7 +9,7 @@ from typing import Any import aiomysql from config.config_db import get_db_pool -from config.config_system import EMAIL_REVERIFY_DAYS, OTP_VALID_MINUTES +from config.config_system import EMAIL_REVERIFY_DAYS, OTP_VALID_MINUTES, SESSION_MAX_AGE_SECONDS def _company_code() -> str: @@ -188,14 +188,25 @@ async def record_login( ) -> None: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute( - """INSERT INTO login_logs (user_id, email, status, failure_reason, user_agent) - VALUES (%s, %s, %s, %s, %s)""", - (user_id, email, status, reason, user_agent), - ) + await _insert_login_log(cursor, user_id, email, status, reason, user_agent) await connection.commit() +async def _insert_login_log( + cursor: Any, + user_id: int | None, + email: str, + status: str, + reason: str | None, + user_agent: str, +) -> None: + await cursor.execute( + """INSERT INTO login_logs (user_id, email, status, failure_reason, user_agent) + VALUES (%s, %s, %s, %s, %s)""", + (user_id, email, status, reason, user_agent), + ) + + async def record_failed_login(user_id: int) -> int: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: @@ -214,15 +225,43 @@ async def record_failed_login(user_id: int) -> int: return failures -async def clear_login_failures(user_id: int) -> None: +async def finish_login( + user_id: int, + email: str, + user_agent: str, + user_agent_hash: str, + device_token_hash: str, + previous_device_token_hash: str | None, +) -> str: + """로그인 마무리 쓰기(실패 지움·last_login · 신뢰 기기 · 세션 · login_logs) = 한 트랜잭션. + + 하나라도 실패하면 모두 되돌리고 예외를 올린다 — 반쪽 로그인 없음.""" + session_id = secrets.token_urlsafe(32) + expires_at = datetime.utcnow() + timedelta(seconds=SESSION_MAX_AGE_SECONDS) pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute( - """UPDATE users SET login_failures = 0, last_failed_at = NULL, - account_locked_until = NULL WHERE id = %s""", - (user_id,), - ) - await connection.commit() + await connection.begin() + try: + await cursor.execute( + """UPDATE users SET login_failures = 0, last_failed_at = NULL, + account_locked_until = NULL, last_login = CURRENT_TIMESTAMP + WHERE id = %s""", + (user_id,), + ) + await _trust_device( + cursor, user_id, device_token_hash, user_agent_hash, previous_device_token_hash + ) + await cursor.execute( + """INSERT INTO sessions (id, user_id, user_agent_hash, expires_at) + VALUES (%s, %s, %s, %s)""", + (session_id, user_id, user_agent_hash, expires_at), + ) + await _insert_login_log(cursor, user_id, email, "SUCCESS", None, user_agent) + await connection.commit() + except Exception: + await connection.rollback() + raise + return session_id async def has_trusted_device(user_id: int, token_hash: str) -> bool: @@ -253,25 +292,34 @@ async def trust_device( ) -> None: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: - if previous_token_hash: - await cursor.execute( - """UPDATE trusted_devices - SET token_hash = %s, user_agent_hash = %s, - verified_at = CURRENT_TIMESTAMP, last_used_at = CURRENT_TIMESTAMP - WHERE user_id = %s AND token_hash = %s""", - (token_hash, user_agent_hash, user_id, previous_token_hash), - ) - if cursor.rowcount: - await connection.commit() - return - await cursor.execute( - """INSERT INTO trusted_devices (user_id, user_agent_hash, token_hash) - VALUES (%s, %s, %s)""", - (user_id, user_agent_hash, token_hash), - ) + await _trust_device(cursor, user_id, token_hash, user_agent_hash, previous_token_hash) await connection.commit() +async def _trust_device( + cursor: Any, + user_id: int, + token_hash: str, + user_agent_hash: str, + previous_token_hash: str | None, +) -> None: + if previous_token_hash: + await cursor.execute( + """UPDATE trusted_devices + SET token_hash = %s, user_agent_hash = %s, + verified_at = CURRENT_TIMESTAMP, last_used_at = CURRENT_TIMESTAMP + WHERE user_id = %s AND token_hash = %s""", + (token_hash, user_agent_hash, user_id, previous_token_hash), + ) + if cursor.rowcount: + return + await cursor.execute( + """INSERT INTO trusted_devices (user_id, user_agent_hash, token_hash) + VALUES (%s, %s, %s)""", + (user_id, user_agent_hash, token_hash), + ) + + async def delete_session(session_id: str) -> None: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: diff --git a/resources/tester/test_309_1_login.py b/resources/tester/test_309_1_login.py new file mode 100644 index 000000000..81ff18265 --- /dev/null +++ b/resources/tester/test_309_1_login.py @@ -0,0 +1,179 @@ +"""503-1 · 503-2 로그인 흠(요구 309-1 · 310). + +- 되튕김 — requestLogin 응답 뒤 세션 확인 캐시를 비움 +- 마무리 쓰기 = 한 커넥션 · 한 트랜잭션(begin 1 · commit 1) · 실패 = rollback · 예외 +- bcrypt 확인 = 이벤트 루프 밖 스레드 +- 보안 그대로 — 5 번 틀림 15 분 잠금 · 잠긴 계정 423 · 실패 기록 +""" + +import asyncio +import re +import threading +from pathlib import Path + +import pytest +from fastapi import HTTPException + +from A06_Login import A06_Login_Router as router +from common_util import common_util_auth_repository as repo + +ROOT = Path(__file__).resolve().parents[2] + + +class FakeCursor: + def __init__(self, conn, fail_on=None): + self.conn, self.fail_on, self.rowcount = conn, fail_on, 0 + + async def execute(self, sql, args=None): + self.conn.log.append(("execute", " ".join(sql.split()))) + if self.fail_on and self.fail_on in sql: + raise RuntimeError("db down") + self.rowcount = 1 + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + +class FakeConn: + def __init__(self, fail_on=None): + self.log, self.fail_on = [], fail_on + + def cursor(self, *a): + return FakeCursor(self, self.fail_on) + + async def begin(self): + self.log.append(("begin",)) + + async def commit(self): + self.log.append(("commit",)) + + async def rollback(self): + self.log.append(("rollback",)) + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + +class FakePool: + def __init__(self, fail_on=None): + self.conns = [] + self.fail_on = fail_on + + def acquire(self): + conn = FakeConn(self.fail_on) + self.conns.append(conn) + return conn + + +def _finish(monkeypatch, fail_on=None, previous=None): + pool = FakePool(fail_on) + monkeypatch.setattr(repo, "get_db_pool", lambda: pool) + result = asyncio.run(repo.finish_login(7, "a@b.c", "ua", "uah", "dth", previous)) + return pool, result + + +def test_되튕김_캐시_비움(): + src = (ROOT / "A06_Login/A06_Login_Api_Fetch.ts").read_text(encoding="utf-8") + body = re.search(r"export async function requestLogin\(.*?\n}\n", src, re.S).group(0) + assert body.index('await post("/auth/login/request"') < body.index("clearSessionCache();") + + +def test_마무리_한_트랜잭션(monkeypatch): + pool, session_id = _finish(monkeypatch) + assert len(pool.conns) == 1 + log = pool.conns[0].log + kinds = [e[0] for e in log] + assert kinds.count("begin") == 1 and kinds.count("commit") == 1 and "rollback" not in kinds + assert kinds[0] == "begin" and kinds[-1] == "commit" + sqls = [e[1] for e in log if e[0] == "execute"] + assert "login_failures = 0" in sqls[0] and "last_login = CURRENT_TIMESTAMP" in sqls[0] + assert sqls[1].startswith("INSERT INTO trusted_devices") + assert sqls[2].startswith("INSERT INTO sessions") + assert sqls[3].startswith("INSERT INTO login_logs") + assert len(sqls) == 4 and len(session_id) >= 40 + + +def test_마무리_신뢰기기_갱신(monkeypatch): + pool, _ = _finish(monkeypatch, previous="old") + sqls = [e[1] for e in pool.conns[0].log if e[0] == "execute"] + assert sqls[1].startswith("UPDATE trusted_devices") + assert not any(s.startswith("INSERT INTO trusted_devices") for s in sqls) + + +@pytest.mark.parametrize("fail_on", ["INSERT INTO sessions", "INSERT INTO login_logs"]) +def test_마무리_실패_되돌림(monkeypatch, fail_on): + with pytest.raises(RuntimeError): + _finish(monkeypatch, fail_on=fail_on) + log = repo.get_db_pool().conns[0].log + kinds = [e[0] for e in log] + assert "commit" not in kinds and kinds[-1] == "rollback" + + +def _user(**kw): + base = { + "id": 7, + "email": "a@b.c", + "role": "USER", + "status": "ACTIVE", + "password_hash": "h", + "account_locked_until": None, + "last_email_verified_at": None, + } + return base | kw + + +class _Req: + headers = {"user-agent": "t"} + cookies = {} + + +def _payload(): + return router.LoginRequest(email="a@example.com", password="pw-12345678") + + +def test_bcrypt_스레드(monkeypatch): + seen = {} + + def fake_verify(value, hashed): + seen["thread"] = threading.current_thread() is threading.main_thread() + return False + + async def noop(*a, **k): + return 1 + + async def get_user(email): + return _user() + + monkeypatch.setattr(router, "verify_password", fake_verify) + monkeypatch.setattr(router, "get_user_by_email", get_user) + monkeypatch.setattr(router, "record_failed_login", noop) + monkeypatch.setattr(router, "record_login", noop) + with pytest.raises(HTTPException) as e: + asyncio.run(router.request_login(_payload(), _Req())) + assert e.value.status_code == 401 and seen["thread"] is False + + +def test_잠금_그대로(monkeypatch): + src = (ROOT / "common_util/common_util_auth_repository.py").read_text(encoding="utf-8") + assert "login_failures + 1 >= 5" in src and "INTERVAL 15 MINUTE" in src + logged = [] + + async def rec(*a): + logged.append(a[3]) + + async def get_user(email): + from datetime import datetime, timedelta + + return _user(account_locked_until=datetime.utcnow() + timedelta(minutes=5)) + + monkeypatch.setattr(router, "get_user_by_email", get_user) + monkeypatch.setattr(router, "record_login", rec) + with pytest.raises(HTTPException) as e: + asyncio.run(router.request_login(_payload(), _Req())) + assert e.value.status_code == 423 and logged == ["ACCOUNT_LOCKED"]