assistant/apps/server/auth.py

143 lines
7.2 KiB
Python

"""Independent local operator authentication adapted from the reference session approach."""
from __future__ import annotations
import hashlib
import hmac
import re
import secrets
import sqlite3
import time
from fastapi import HTTPException, Request, Response
from .db import Store, StoreConflict, identifier, now
COOKIE = "assistant_maintainer"
TTL = 12 * 60 * 60
def password_hash(password: str) -> str:
salt = secrets.token_bytes(16)
result = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1, dklen=64)
return f"scrypt${salt.hex()}${result.hex()}"
def password_matches(password: str, stored: str) -> bool:
try:
_, salt, expected = stored.split("$")
result = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1, dklen=64)
return hmac.compare_digest(result.hex(), expected)
except (ValueError, TypeError):
return False
def token_hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
class Auth:
def __init__(self, store: Store):
self.store = store
def bootstrap_admin(self) -> bool:
"""Create the requested local account only when no maintainer exists."""
with self.store.connect(immediate=True) as conn:
if conn.execute("SELECT 1 FROM operators LIMIT 1").fetchone():
return False
conn.execute("INSERT INTO operators(id,username,password_hash,created_at) VALUES($1,$2,$3,$4)", (identifier("operator"), "admin", password_hash("123456"), now()))
return True
def session(self, request: Request) -> dict | None:
raw = request.cookies.get(COOKIE, "")
if not raw:
return None
return self.store.one("SELECT s.*,o.username FROM auth_sessions s LEFT JOIN operators o ON o.id=s.operator_id WHERE token_hash=$1 AND expires_at>$2", (token_hash(raw), int(time.time())))
def issue(self, request: Request, response: Response, operator_id: str | None = None) -> dict:
previous = request.cookies.get(COOKIE)
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
with self.store.connect(immediate=True) as conn:
if previous:
conn.execute("DELETE FROM auth_sessions WHERE token_hash=$1", (token_hash(previous),))
conn.execute("DELETE FROM auth_sessions WHERE expires_at<$1", (int(time.time()),))
conn.execute("INSERT INTO auth_sessions(token_hash,operator_id,csrf,expires_at,created_at) VALUES($1,$2,$3,$4,$5)", (token_hash(raw), operator_id, csrf, int(time.time()) + TTL, now()))
response.set_cookie(COOKIE, raw, max_age=TTL, httponly=True, samesite="strict", secure=request.url.scheme == "https", path="/")
return {"operator_id": operator_id, "csrf": csrf}
def payload(self, session: dict) -> dict:
initialized = bool(self.store.one("SELECT id FROM operators LIMIT 1"))
authenticated = bool(session.get("operator_id"))
user = None
if authenticated:
user = self.store.one("SELECT id,username,created_at FROM operators WHERE id=$1", (session["operator_id"],))
return {"initialized": initialized, "authenticated": authenticated, "user": user, "csrf_token": session["csrf"], "mode": "local_maintainer"}
def status(self, request: Request, response: Response) -> dict:
return self.payload(self.session(request) or self.issue(request, response))
def csrf(self, request: Request) -> dict:
session = self.session(request)
header = request.headers.get("X-CSRF-Token", "")
if not session or not header or not hmac.compare_digest(header, session["csrf"]):
raise HTTPException(403, "会话校验已失效,请刷新页面后重试")
return session
def operator(self, request: Request, mutate: bool = False) -> str:
session = self.csrf(request) if mutate else self.session(request)
if not session or not session.get("operator_id"):
raise HTTPException(401, "请先登录维护后台")
return session["operator_id"]
@staticmethod
def validate_credentials(username: str, password: str) -> tuple[str, str]:
username = username.strip()
if not re.fullmatch(r"[\w.-]{3,64}", username):
raise HTTPException(422, "维护者用户名需为 3 至 64 位字母、数字、中文、点或下划线")
if len(password) < 10 or len(password) > 256:
raise HTTPException(422, "维护者密码需为 10 至 256 个字符")
return username, password
def setup(self, request: Request, response: Response, username: str, password: str) -> dict:
self.csrf(request)
username, password = self.validate_credentials(username, password)
encoded = password_hash(password)
operator_id = identifier("operator")
try:
with self.store.connect(immediate=True) as conn:
if conn.execute("SELECT 1 FROM operators LIMIT 1").fetchone():
raise HTTPException(409, "维护者已初始化,请使用登录入口")
conn.execute("INSERT INTO operators(id,username,password_hash,created_at) VALUES($1,$2,$3,$4)", (operator_id, username, encoded, now()))
except (sqlite3.IntegrityError, StoreConflict):
raise HTTPException(409, "维护者已初始化,请使用登录入口") from None
return self.payload(self.issue(request, response, operator_id))
def login(self, request: Request, response: Response, username: str, password: str) -> dict:
self.csrf(request)
key = token_hash(username.strip().casefold())
current_time = int(time.time())
with self.store.connect(immediate=True) as conn:
attempt = conn.execute("SELECT * FROM auth_attempts WHERE key=$1", (key,)).fetchone()
if attempt and current_time - attempt["window_start"] < 300 and attempt["failures"] >= 5:
raise HTTPException(429, "尝试次数较多,请稍后再试")
user = conn.execute("SELECT * FROM operators WHERE username=$1", (username.strip(),)).fetchone()
# The fixed dummy hash keeps a nonexistent username on the scrypt path.
dummy = "scrypt$" + "00" * 16 + "$" + "00" * 64
valid = password_matches(password, user["password_hash"] if user else dummy)
if not user or not valid:
if not attempt or current_time - attempt["window_start"] >= 300:
conn.execute("INSERT INTO auth_attempts(key,failures,window_start) VALUES($1,$2,$3) ON CONFLICT(key) DO UPDATE SET failures=excluded.failures,window_start=excluded.window_start", (key, 1, current_time))
else:
conn.execute("UPDATE auth_attempts SET failures=failures+1 WHERE key=$1", (key,))
error = True
else:
conn.execute("DELETE FROM auth_attempts WHERE key=$1", (key,))
operator_id = user["id"]
error = False
if error:
raise HTTPException(401, "用户名或密码不正确")
return self.payload(self.issue(request, response, operator_id))
def logout(self, request: Request, response: Response) -> dict:
self.csrf(request)
return self.payload(self.issue(request, response))