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:
@@ -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> {
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user