205 lines
5.7 KiB
Python
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)
|