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:
1 parent
b9d7497a7d
commit
f67a8ca402
24 files changed
+1731
-212
No files matched your search
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user