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; return result;
} }
export function requestLogin(email: string, password: string): Promise<ApiResult> { export async function requestLogin(email: string, password: string): Promise<ApiResult> {
return post("/auth/login/request", { email, password }); 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> { export function verifyLogin(email: string, otpCode: string): Promise<ApiResult> {
+9 -19
View File
@@ -1,5 +1,6 @@
"""로그인, 재인증, 세션 및 비밀번호 API.""" """로그인, 재인증, 세션 및 비밀번호 API."""
import asyncio
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -7,7 +8,6 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from common_util.common_util_auth import ( from common_util.common_util_auth import (
create_session,
delete_session_cookie, delete_session_cookie,
generate_device_token, generate_device_token,
generate_otp, generate_otp,
@@ -22,16 +22,15 @@ from common_util.common_util_auth import (
) )
from common_util.common_util_auth_repository import ( from common_util.common_util_auth_repository import (
change_password, change_password,
clear_login_failures,
consume_otp, consume_otp,
delete_session, delete_session,
finish_login,
get_active_otp, get_active_otp,
get_user_by_email, get_user_by_email,
has_trusted_device, has_trusted_device,
record_failed_login, record_failed_login,
record_login, record_login,
replace_otp, replace_otp,
trust_device,
) )
from common_util.common_util_email import send_email_background from common_util.common_util_email import send_email_background
from common_util.common_util_email_templates import otp_email, security_alert_email 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(): if user["account_locked_until"] and user["account_locked_until"] > datetime.utcnow():
await record_login(user["id"], email, "FAILURE", "ACCOUNT_LOCKED", agent) await record_login(user["id"], email, "FAILURE", "ACCOUNT_LOCKED", agent)
raise HTTPException(status_code=423, detail="로그인 실패 누적으로 계정이 잠겼습니다.") 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"]) failures = await record_failed_login(user["id"])
await record_login(user["id"], email, "FAILURE", "INVALID_PASSWORD", agent) await record_login(user["id"], email, "FAILURE", "INVALID_PASSWORD", agent)
if failures >= 5 and ADMIN_EMAIL: if failures >= 5 and ADMIN_EMAIL:
@@ -108,25 +108,15 @@ async def request_login(payload: LoginRequest, request: Request):
async def _finish_login( async def _finish_login(
user: dict, agent: str, previous_device_token_hash: str | None = None user: dict, agent: str, previous_device_token_hash: str | None = None
) -> JSONResponse: ) -> JSONResponse:
await clear_login_failures(user["id"])
device_token = generate_device_token() device_token = generate_device_token()
await trust_device( session_id = await finish_login(
user["id"], user["id"],
hash_device_token(device_token), user["email"],
agent,
hash_user_agent(agent), hash_user_agent(agent),
hash_device_token(device_token),
previous_device_token_hash, 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( response = JSONResponse(
{ {
"status": "success", "status": "success",
@@ -146,7 +136,7 @@ async def verify_login(payload: OtpVerifyRequest, request: Request):
not user not user
or not otp or not otp
or otp["expires_at"] < datetime.utcnow() 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: if user:
failures = await record_failed_login(user["id"]) failures = await record_failed_login(user["id"])
+1 -15
View File
@@ -3,7 +3,7 @@
import hashlib import hashlib
import re import re
import secrets import secrets
from datetime import datetime, timedelta from datetime import datetime
from typing import Any from typing import Any
import bcrypt 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]: async def verify_session(request: Request) -> dict[str, Any]:
session_id = request.cookies.get(SESSION_COOKIE_NAME) session_id = request.cookies.get(SESSION_COOKIE_NAME)
if not session_id: if not session_id:
+77 -29
View File
@@ -9,7 +9,7 @@ from typing import Any
import aiomysql import aiomysql
from config.config_db import get_db_pool 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: def _company_code() -> str:
@@ -188,14 +188,25 @@ async def record_login(
) -> None: ) -> None:
pool = get_db_pool() pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor: async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute( await _insert_login_log(cursor, user_id, email, status, reason, user_agent)
"""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 connection.commit() 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: async def record_failed_login(user_id: int) -> int:
pool = get_db_pool() pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor: 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 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() pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor: async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute( await connection.begin()
"""UPDATE users SET login_failures = 0, last_failed_at = NULL, try:
account_locked_until = NULL WHERE id = %s""", await cursor.execute(
(user_id,), """UPDATE users SET login_failures = 0, last_failed_at = NULL,
) account_locked_until = NULL, last_login = CURRENT_TIMESTAMP
await connection.commit() 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: async def has_trusted_device(user_id: int, token_hash: str) -> bool:
@@ -253,25 +292,34 @@ async def trust_device(
) -> None: ) -> None:
pool = get_db_pool() pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor: async with pool.acquire() as connection, connection.cursor() as cursor:
if previous_token_hash: await _trust_device(cursor, user_id, token_hash, user_agent_hash, 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 connection.commit() 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: async def delete_session(session_id: str) -> None:
pool = get_db_pool() pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor: 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"]