260706_1_계정, 로그인, 보안 초안 작성
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
"""비밀번호, OTP, 세션 쿠키와 권한 검증 공통 기능."""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import (
|
||||
PASSWORD_BCRYPT_ROUNDS,
|
||||
SESSION_COOKIE_NAME,
|
||||
SESSION_COOKIE_SECURE,
|
||||
SESSION_IDLE_TIMEOUT_SECONDS,
|
||||
SESSION_MAX_AGE_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def hash_password(value: str) -> str:
|
||||
return bcrypt.hashpw(value.encode(), bcrypt.gensalt(rounds=PASSWORD_BCRYPT_ROUNDS)).decode()
|
||||
|
||||
|
||||
def verify_password(value: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(value.encode(), hashed.encode())
|
||||
|
||||
|
||||
def generate_otp() -> str:
|
||||
return f"{secrets.randbelow(1_000_000):06d}"
|
||||
|
||||
|
||||
def hash_user_agent(user_agent: str) -> str:
|
||||
return hashlib.sha256(user_agent.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, session_id: str) -> None:
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE_NAME,
|
||||
value=session_id,
|
||||
max_age=SESSION_MAX_AGE_SECONDS,
|
||||
secure=SESSION_COOKIE_SECURE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def delete_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(
|
||||
key=SESSION_COOKIE_NAME,
|
||||
secure=SESSION_COOKIE_SECURE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT s.id, s.user_id, s.created_at, s.expires_at, s.last_activity_at,
|
||||
u.email, u.name, u.company_id, u.role, u.is_master, u.status
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = %s""",
|
||||
(session_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=401, detail="유효하지 않은 세션입니다.")
|
||||
|
||||
now = datetime.utcnow()
|
||||
expired = now > row[3] or (now - row[4]).total_seconds() > SESSION_IDLE_TIMEOUT_SECONDS
|
||||
if expired or row[10] != "ACTIVE":
|
||||
await cursor.execute("DELETE FROM sessions WHERE id = %s", (session_id,))
|
||||
await connection.commit()
|
||||
raise HTTPException(status_code=401, detail="세션이 만료되었습니다.")
|
||||
|
||||
await cursor.execute(
|
||||
"UPDATE sessions SET last_activity_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||
(session_id,),
|
||||
)
|
||||
await connection.commit()
|
||||
return {
|
||||
"session_id": row[0],
|
||||
"user_id": row[1],
|
||||
"email": row[5],
|
||||
"name": row[6],
|
||||
"company_id": row[7],
|
||||
"role": row[8],
|
||||
"is_master": bool(row[9]),
|
||||
}
|
||||
|
||||
|
||||
async def get_optional_session(request: Request) -> dict[str, Any] | None:
|
||||
"""세션이 유효하면 세션 정보를, 없거나 만료되면 None을 반환한다.
|
||||
|
||||
로그인 여부와 무관하게 접근 가능한 엔드포인트(예: 기술지원 문의)에서
|
||||
로그인 사용자면 추가 정보를 붙이기 위해 사용한다. 401을 던지지 않는다.
|
||||
"""
|
||||
try:
|
||||
return await verify_session(request)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
async def require_master(session: dict[str, Any] = Depends(verify_session)) -> dict[str, Any]:
|
||||
if not session["is_master"] and session["role"] != "SYSTEM_ADMIN":
|
||||
raise HTTPException(status_code=403, detail="마스터 권한이 필요합니다.")
|
||||
return session
|
||||
|
||||
|
||||
async def require_system_admin(
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
if session["role"] != "SYSTEM_ADMIN":
|
||||
raise HTTPException(status_code=403, detail="시스템 관리자 권한이 필요합니다.")
|
||||
return session
|
||||
Reference in New Issue
Block a user