162 lines
5.8 KiB
Python
162 lines
5.8 KiB
Python
"""Database-backed sessions; no bearer secrets are stored in the database."""
|
|
|
|
import hashlib
|
|
import hmac
|
|
import re
|
|
import secrets
|
|
from dataclasses import dataclass
|
|
from datetime import timedelta
|
|
from functools import lru_cache
|
|
|
|
from argon2 import PasswordHasher
|
|
from argon2.exceptions import InvalidHashError, VerificationError
|
|
from fastapi import Depends, HTTPException, Request, Response
|
|
from sqlalchemy import case, delete, select
|
|
from sqlalchemy.dialects.postgresql import insert
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.config import settings
|
|
from app.db.session import get_db
|
|
from app.models.auth import AuthRateLimit, AuthSession, User
|
|
from app.models.catalog import utc_now
|
|
from app.schemas.auth import SessionRead, UserRead
|
|
|
|
COOKIE_NAME = "nalati_session"
|
|
UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|
PASSWORD_HASHER = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def dummy_hash():
|
|
return PASSWORD_HASHER.hash(secrets.token_urlsafe(32))
|
|
|
|
|
|
def check_password(encoded: str, password: str) -> bool:
|
|
try:
|
|
return PASSWORD_HASHER.verify(encoded, password)
|
|
except (VerificationError, InvalidHashError):
|
|
return False
|
|
|
|
|
|
def token_hash(raw: str) -> str:
|
|
return hashlib.sha256(raw.encode()).hexdigest()
|
|
|
|
|
|
def csrf_token(raw: str) -> str:
|
|
# Domain-separated derivative; knowing the CSRF token does not reveal the cookie.
|
|
return hmac.new(raw.encode(), b"nalati-csrf-v1", hashlib.sha256).hexdigest()
|
|
|
|
|
|
def require_origin(request: Request):
|
|
if request.headers.get("origin") not in settings.auth_origin_list:
|
|
raise HTTPException(403, "请求来源不受信任")
|
|
|
|
|
|
def limit_auth_requests(db: Session, request: Request, username: str | None = None):
|
|
"""Atomic, shared across workers and restarts; never trust forwarded client headers here."""
|
|
now = utc_now()
|
|
window = timedelta(minutes=5)
|
|
buckets = [("ip:" + (request.client.host if request.client else "unknown"), 30)]
|
|
if username is not None:
|
|
buckets.append(("username:" + username, 10))
|
|
blocked = False
|
|
for identity, maximum in buckets:
|
|
statement = insert(AuthRateLimit).values(
|
|
key=token_hash(identity), window_start=now, attempts=1
|
|
)
|
|
expired = AuthRateLimit.window_start <= now - window
|
|
statement = statement.on_conflict_do_update(
|
|
index_elements=[AuthRateLimit.key],
|
|
set_={
|
|
"window_start": case((expired, now), else_=AuthRateLimit.window_start),
|
|
"attempts": case((expired, 1), else_=AuthRateLimit.attempts + 1),
|
|
},
|
|
).returning(AuthRateLimit.attempts)
|
|
blocked |= db.scalar(statement) > maximum
|
|
db.execute(delete(AuthRateLimit).where(AuthRateLimit.window_start < now - timedelta(days=1)))
|
|
# Persist even failed authentication; no password or raw address is logged.
|
|
db.commit()
|
|
if blocked:
|
|
raise HTTPException(429, "认证请求过于频繁,请稍后重试", headers={"Retry-After": "300"})
|
|
|
|
|
|
@dataclass
|
|
class CurrentSession:
|
|
user: User
|
|
session: AuthSession
|
|
raw_token: str
|
|
|
|
def response(self):
|
|
return SessionRead(
|
|
**UserRead.model_validate(self.user).model_dump(),
|
|
csrf_token=csrf_token(self.raw_token),
|
|
)
|
|
|
|
|
|
def require_session(request: Request, db: Session = Depends(get_db)) -> CurrentSession:
|
|
raw = request.cookies.get(COOKIE_NAME, "")
|
|
if not re.fullmatch(r"[A-Za-z0-9_-]{43}", raw):
|
|
raise HTTPException(401, "请先登录")
|
|
session = db.get(AuthSession, token_hash(raw))
|
|
if session is None:
|
|
raise HTTPException(401, "登录已失效,请重新登录")
|
|
# Lock the user before checking the current session again, serializing password changes/login.
|
|
user = db.scalar(select(User).where(User.id == session.user_id).with_for_update())
|
|
session = db.scalar(
|
|
select(AuthSession)
|
|
.where(AuthSession.token_hash == token_hash(raw))
|
|
.execution_options(populate_existing=True)
|
|
)
|
|
if (
|
|
user is None
|
|
or session is None
|
|
or session.expires_at <= utc_now()
|
|
or session.credential_version != user.credential_version
|
|
):
|
|
raise HTTPException(401, "登录已失效,请重新登录")
|
|
if request.method in UNSAFE_METHODS:
|
|
require_origin(request)
|
|
submitted = request.headers.get("x-csrf-token", "")
|
|
if not hmac.compare_digest(submitted.encode(), csrf_token(raw).encode()):
|
|
raise HTTPException(403, "CSRF 校验失败,请刷新登录状态后重试")
|
|
return CurrentSession(user, session, raw)
|
|
|
|
|
|
def require_admin(current: CurrentSession = Depends(require_session)):
|
|
if current.user.role != "admin":
|
|
raise HTTPException(403, "需要管理员权限")
|
|
return current
|
|
|
|
|
|
def issue_session(db: Session, user: User, request: Request, response: Response):
|
|
now = utc_now()
|
|
old = request.cookies.get(COOKIE_NAME, "")
|
|
db.execute(delete(AuthSession).where(AuthSession.expires_at <= now))
|
|
if old:
|
|
db.execute(delete(AuthSession).where(AuthSession.token_hash == token_hash(old)))
|
|
raw = secrets.token_urlsafe(32)
|
|
session = AuthSession(
|
|
token_hash=token_hash(raw),
|
|
user_id=user.id,
|
|
credential_version=user.credential_version,
|
|
expires_at=now + timedelta(seconds=settings.auth_session_seconds),
|
|
)
|
|
db.add(session)
|
|
db.commit()
|
|
response.set_cookie(
|
|
COOKIE_NAME,
|
|
raw,
|
|
max_age=settings.auth_session_seconds,
|
|
httponly=True,
|
|
secure=settings.auth_cookie_secure,
|
|
samesite="lax",
|
|
path="/",
|
|
)
|
|
return CurrentSession(user, session, raw).response()
|
|
|
|
|
|
def clear_cookie(response: Response):
|
|
response.delete_cookie(
|
|
COOKIE_NAME, path="/", httponly=True, secure=settings.auth_cookie_secure, samesite="lax"
|
|
)
|