Align backend authentication and API response contracts

Replace cookie authentication with revocable Bearer sessions, optional one-use PNG challenges, bounded login limits, and short-lived market WebSocket tickets. Keep captcha disabled by default.

Standardize ordinary JSON responses while preserving HTTP statuses, raw OpenAPI, redirects, SSE, and WebSocket semantics. Revoke long connections with their owning session.

Expose official calendar data and collector status to the frontend and research tools, support bilingual news topics, and keep summaries and forward-only labels explicit. Verify the staged snapshot with all 305 backend tests.
This commit is contained in:
eezd committed 2026-10-26 12:04:21 +08:00
1 parent b9d7497a7d
commit f67a8ca402
24 files changed
+1731 -212

No files matched your search

+97 -23
View File
@@ -1,16 +1,40 @@
import getpass
import json
import secrets
import sys
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Annotated
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import HTTPException, Request, WebSocket, status
from fastapi import Depends, HTTPException, WebSocket
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from redis.exceptions import RedisError
from alphapulse.core.config import get_settings
from alphapulse.core.redis import KEY_PREFIX, redis
COOKIE_NAME = "ap_token"
AUTH_PREFIX = f"{KEY_PREFIX}auth:"
WS_PROTOCOL = "alphapulse.market.v1"
_hasher = PasswordHasher()
_bearer = HTTPBearer(auto_error=False, bearerFormat="JWT")
@dataclass(frozen=True)
class AuthSession:
username: str
jti: str
expires_at: int
def unauthorized() -> HTTPException:
return HTTPException(401, "未登录或登录已过期", headers={"WWW-Authenticate": "Bearer"})
def unavailable() -> HTTPException:
return HTTPException(503, "认证服务暂不可用")
def verify_password(password: str, password_hash: str) -> bool:
@@ -20,33 +44,83 @@ def verify_password(password: str, password_hash: str) -> bool:
return False
def create_token(username: str) -> str:
async def create_token(username: str) -> tuple[str, datetime]:
settings = get_settings()
expire = datetime.now(UTC) + timedelta(days=settings.jwt_expire_days)
return jwt.encode({"sub": username, "exp": expire}, settings.jwt_secret, algorithm="HS256")
def _username_from_token(token: str | None) -> str | None:
if not token:
return None
now = datetime.now(UTC)
expire = now + timedelta(days=settings.jwt_expire_days)
jti = secrets.token_urlsafe(32)
token = jwt.encode({"sub": username, "iat": now, "exp": expire, "jti": jti},
settings.jwt_secret, algorithm="HS256")
try:
payload = jwt.decode(token, get_settings().jwt_secret, algorithms=["HS256"])
except jwt.InvalidTokenError:
return None
return payload["sub"]
await redis.set(f"{AUTH_PREFIX}session:{jti}", username, exat=int(expire.timestamp()))
except RedisError as error:
raise unavailable() from error
return token, expire
def require_admin(request: Request) -> str:
username = _username_from_token(request.cookies.get(COOKIE_NAME))
if username != get_settings().admin_username:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "未登录或登录已过期")
return username
async def check_session(session: AuthSession) -> None:
try:
active = await redis.get(f"{AUTH_PREFIX}session:{session.jti}")
except RedisError as error:
raise unavailable() from error
if session.expires_at <= datetime.now(UTC).timestamp() or active != session.username:
raise unauthorized()
def websocket_admin(websocket: WebSocket) -> str | None:
# WebSocket 握手阶段不能用 HTTPException,由调用方自行关闭连接
username = _username_from_token(websocket.cookies.get(COOKIE_NAME))
return username if username == get_settings().admin_username else None
async def require_session(credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)]) -> AuthSession:
if credentials is None:
raise unauthorized()
token = credentials.credentials
try:
payload = jwt.decode(token, get_settings().jwt_secret, algorithms=["HS256"],
options={"require": ["sub", "iat", "exp", "jti"]})
if not isinstance(payload["jti"], str) or not payload["jti"]:
raise jwt.InvalidTokenError("缺少会话标识")
session = AuthSession(payload["sub"], payload["jti"], payload["exp"])
except jwt.InvalidTokenError as error:
raise unauthorized() from error
await check_session(session)
return session
async def require_admin(session: Annotated[AuthSession, Depends(require_session)]) -> AuthSession:
if session.username != get_settings().admin_username:
raise HTTPException(403, "没有管理员权限")
return session
async def revoke_session(session: AuthSession) -> None:
try:
await redis.delete(f"{AUTH_PREFIX}session:{session.jti}")
except RedisError as error:
raise unavailable() from error
async def create_ws_ticket(session: AuthSession) -> str:
ticket = secrets.token_urlsafe(32)
try:
await redis.set(f"{AUTH_PREFIX}ticket:{ticket}", json.dumps(session.__dict__), ex=30)
except RedisError as error:
raise unavailable() from error
return ticket
async def websocket_admin(websocket: WebSocket) -> AuthSession:
protocols = websocket.scope.get("subprotocols", [])
tickets = [value[7:] for value in protocols if value.startswith("ticket.")]
if WS_PROTOCOL not in protocols or len(tickets) != 1:
raise unauthorized()
try:
raw = await redis.getdel(f"{AUTH_PREFIX}ticket:{tickets[0]}")
except RedisError as error:
raise unavailable() from error
if raw is None:
raise unauthorized()
session = AuthSession(**json.loads(raw))
await check_session(session)
if session.username != get_settings().admin_username:
raise HTTPException(403, "没有管理员权限")
return session
if __name__ == "__main__":
+1
View File
@@ -12,6 +12,7 @@ class Settings(BaseSettings):
jwt_secret: str
admin_username: str
admin_password_hash: str = ""
login_captcha_enabled: bool = False
jwt_expire_days: int = 7
# Fernet 密钥,加密存库的第三方 API key
secret_encryption_key: str
+38
View File
@@ -0,0 +1,38 @@
"""长连接持续校验会话;取消原生成器以保留 Chat 的中断保存语义。"""
import asyncio
from collections.abc import AsyncIterator
from contextlib import suppress
from fastapi import HTTPException
from fastapi.sse import ServerSentEvent
from alphapulse.core.auth import AuthSession, check_session
async def session_events(source: AsyncIterator[ServerSentEvent], session: AuthSession) -> AsyncIterator[ServerSentEvent]:
pending = None
try:
while True:
if pending is None:
pending = asyncio.create_task(anext(source))
done, _ = await asyncio.wait([pending], timeout=1)
try:
await check_session(session)
except HTTPException as error:
yield ServerSentEvent(data={"code": error.status_code, "message": error.detail},
event="auth_error" if error.status_code == 401 else "error")
return
if done:
try:
item = pending.result()
except StopAsyncIteration:
return
pending = None
yield item
finally:
if pending is not None:
pending.cancel()
with suppress(asyncio.CancelledError, StopAsyncIteration):
await pending
await source.aclose()