Files

205 lines
5.7 KiB
Python

from __future__ import annotations
import hashlib
import hmac
import secrets
import time
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
from fastapi import HTTPException, Request, Response, status
from .config import Settings
from .db import Database, utc_now
COOKIE_NAME = "xuxiang_session"
SESSION_DAYS = 30
_password_hasher = PasswordHasher()
@dataclass(frozen=True)
class AuthUser:
id: str
label: str
role: str
debug_sharing: bool
@property
def is_admin(self) -> bool:
return self.role == "admin"
def public(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"role": self.role,
"debug_sharing": self.debug_sharing,
}
class LoginLimiter:
def __init__(self) -> None:
self.attempts: dict[str, deque[float]] = defaultdict(deque)
def check(self, key: str) -> None:
now = time.monotonic()
attempts = self.attempts[key]
while attempts and now - attempts[0] > 900:
attempts.popleft()
if len(attempts) >= 8:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="请稍后再试",
)
def fail(self, key: str) -> None:
self.attempts[key].append(time.monotonic())
def clear(self, key: str) -> None:
self.attempts.pop(key, None)
limiter = LoginLimiter()
def find_user_for_key(access_key: str, db: Database) -> AuthUser | None:
for candidate in db.list_auth_users():
try:
matches = _password_hasher.verify(
candidate["access_key_hash"], access_key
)
except (VerifyMismatchError, InvalidHashError):
matches = False
if matches:
return AuthUser(
id=candidate["id"],
label=candidate["label"],
role=candidate["role"],
debug_sharing=bool(candidate["debug_sharing"]),
)
return None
def _token_hash(token: str, settings: Settings) -> str:
secret = (settings.session_secret or "").encode()
return hmac.new(secret, token.encode(), hashlib.sha256).hexdigest()
def create_session(
response: Response,
db: Database,
settings: Settings,
user_id: str,
) -> None:
token = secrets.token_urlsafe(40)
expires = datetime.now(UTC) + timedelta(days=SESSION_DAYS)
with db.connect() as connection:
connection.execute(
"DELETE FROM sessions WHERE expires_at < ?", (utc_now(),)
)
connection.execute(
"""
INSERT INTO sessions (
token_hash, user_id, created_at, expires_at
) VALUES (?, ?, ?, ?)
""",
(
_token_hash(token, settings),
user_id,
utc_now(),
expires.isoformat(),
),
)
response.set_cookie(
COOKIE_NAME,
token,
max_age=SESSION_DAYS * 24 * 60 * 60,
httponly=True,
secure=settings.cookie_secure,
samesite="strict",
path="/",
)
def delete_session(
request: Request, response: Response, db: Database, settings: Settings
) -> None:
token = request.cookies.get(COOKIE_NAME)
if token:
with db.connect() as connection:
connection.execute(
"DELETE FROM sessions WHERE token_hash = ?",
(_token_hash(token, settings),),
)
response.delete_cookie(
COOKIE_NAME,
path="/",
secure=settings.cookie_secure,
httponly=True,
samesite="strict",
)
def require_auth(
request: Request, db: Database, settings: Settings
) -> AuthUser:
if settings.auth_disabled:
users = db.list_auth_users()
if users:
user = users[0]
return AuthUser(
id=user["id"],
label=user["label"],
role="admin",
debug_sharing=True,
)
return AuthUser(
id="development",
label="本地开发",
role="admin",
debug_sharing=True,
)
if not settings.users or not settings.session_secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="应用尚未配置访问身份",
)
token = request.cookies.get(COOKIE_NAME)
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
with db.connect() as connection:
row = connection.execute(
"""
SELECT u.id, u.label, u.role, u.debug_sharing, s.expires_at
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ?
""",
(_token_hash(token, settings),),
).fetchone()
if not row or row["expires_at"] <= utc_now():
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return AuthUser(
id=row["id"],
label=row["label"],
role=row["role"],
debug_sharing=bool(row["debug_sharing"]),
)
def require_admin(user: AuthUser) -> None:
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
def require_same_origin_intent(request: Request) -> None:
if request.method in {"GET", "HEAD", "OPTIONS"}:
return
if request.url.path == "/api/login":
return
if request.headers.get("x-note-client") != "xuxiang-web":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)