feat: add cookie sessions and backend role authorization
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
"""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"
|
||||
)
|
||||
Reference in New Issue
Block a user