fix(503-1 · 503-2 로그인): 성공 뒤 세션 캐시 비움(되튕김) · 마무리 쓰기 한 트랜잭션 · bcrypt 스레드

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 12:13:35 +09:00
co-authored by Claude Opus 5.5
parent 163ab72881
commit 0b8c34a2c2
5 changed files with 271 additions and 65 deletions
+5 -2
View File
@@ -29,8 +29,11 @@ async function post(path: string, body?: object): Promise<ApiResult> {
return result;
}
export function requestLogin(email: string, password: string): Promise<ApiResult> {
return post("/auth/login/request", { email, password });
export async function requestLogin(email: string, password: string): Promise<ApiResult> {
const result = await post("/auth/login/request", { email, password });
// 화면이 뜰 때 기억한 「로그인 안 됨」(5 s)을 응답 뒤에 버림 — 안 버리면 대시보드 가드가 되튕김(309-1)
clearSessionCache();
return result;
}
export function verifyLogin(email: string, otpCode: string): Promise<ApiResult> {
+9 -19
View File
@@ -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"])
+1 -15
View File
@@ -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:
+77 -29
View File
@@ -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:
+179
View File
@@ -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"]