From 434f324bb935dda7b8019da90a9feef4af883cb3 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 18 Sep 2026 22:16:30 +0800 Subject: [PATCH] feat: persist authentication sessions in postgres --- .env.example | 2 - .gitea/workflows/docker-deploy.yml | 2 - README.md | 2 +- compose.yml | 1 - docs/architecture.md | 14 ++--- pyproject.toml | 3 ++ scripts/deploy.sh | 3 -- src/pk_lab_server/api.py | 34 +++++++++--- src/pk_lab_server/config.py | 6 +-- src/pk_lab_server/models.py | 26 ++++++++- src/pk_lab_server/security.py | 86 ++++++++++++------------------ tests/conftest.py | 2 - tests/test_api.py | 21 +++++++- 13 files changed, 118 insertions(+), 84 deletions(-) diff --git a/.env.example b/.env.example index d82c4f6..878730a 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,6 @@ DEPLOY_IMAGE=pk-lab-server:local DEPLOY_PORT=18082 POSTGRES_PASSWORD=replace-with-a-long-random-value -TOKEN_SECRET=replace-with-at-least-32-random-bytes PUBLIC_BASE_URL=https://api.example.com CORS_ORIGINS=https://pk.example.com,https://rice.example.com TEST_AUTH_ENABLED=false @@ -9,4 +8,3 @@ XHS_HUB_APP_ID= XHS_HUB_APP_SECRET= XHS_RICE_APP_ID= XHS_RICE_APP_SECRET= - diff --git a/.gitea/workflows/docker-deploy.yml b/.gitea/workflows/docker-deploy.yml index c2bed28..57e1987 100644 --- a/.gitea/workflows/docker-deploy.yml +++ b/.gitea/workflows/docker-deploy.yml @@ -25,7 +25,6 @@ jobs: CORS_ORIGINS: ${{ vars.CORS_ORIGINS || 'http://47.76.92.159:3000,http://47.76.92.159:3002,http://localhost:3000,http://localhost:3002' }} TEST_AUTH_ENABLED: ${{ vars.TEST_AUTH_ENABLED || 'true' }} POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} - TOKEN_SECRET: ${{ secrets.TOKEN_SECRET }} XHS_HUB_APP_ID: ${{ secrets.XHS_HUB_APP_ID }} XHS_HUB_APP_SECRET: ${{ secrets.XHS_HUB_APP_SECRET }} XHS_RICE_APP_ID: ${{ secrets.XHS_RICE_APP_ID }} @@ -63,7 +62,6 @@ jobs: run: | set -eu test -n "$POSTGRES_PASSWORD" - test -n "$TOKEN_SECRET" export DEPLOY_IMAGE="pk-lab-server:$SOURCE_REVISION" sh scripts/deploy.sh curl --fail --silent --show-error "http://127.0.0.1:$DEPLOY_PORT/health" diff --git a/README.md b/README.md index f1a3c66..5312d99 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ## CI 所需配置 -部署前必须由仓库所有者配置 Secrets `POSTGRES_PASSWORD`、`TOKEN_SECRET`;上线真实登录前再配置两套 `XHS_*_APP_ID/SECRET`。拉取源码默认使用 Gitea 工作流自带令牌,也可用 `GITEA_TOKEN` 覆盖。Variables:`DEPLOY_PORT`(默认 `18082`)、`PUBLIC_BASE_URL`、`CORS_ORIGINS`、`TEST_AUTH_ENABLED`。默认允许 `3000`(综合版)和 `3002`(饭类独立版)访问。 +部署前只需由仓库所有者配置 Secret `POSTGRES_PASSWORD`。用户登录采用 PostgreSQL 持久化 session,服务端只保存随机 token 的 SHA-256 哈希,不需要全局 token 签名密钥。上线真实登录前再配置两套 `XHS_*_APP_ID/SECRET`。拉取源码默认使用 Gitea 工作流自带令牌,也可用 `GITEA_TOKEN` 覆盖。Variables:`DEPLOY_PORT`(默认 `18082`)、`PUBLIC_BASE_URL`、`CORS_ORIGINS`、`TEST_AUTH_ENABLED`。默认允许 `3000`(综合版)和 `3002`(饭类独立版)访问。 当前流水线默认 `TEST_AUTH_ENABLED=true`,让两个公网预览可以创建明确隔离的测试身份。接入真实小红书 App ID/Secret 后,必须把该变量改成 `false`,关闭 `dev:` 登录路径。 diff --git a/compose.yml b/compose.yml index a3e240f..3d97889 100644 --- a/compose.yml +++ b/compose.yml @@ -29,7 +29,6 @@ services: - "${DEPLOY_BIND_IP:-0.0.0.0}:${DEPLOY_PORT:-18082}:8000" environment: DATABASE_URL: postgresql+psycopg://pk_lab:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@postgres:5432/pk_lab - TOKEN_SECRET: ${TOKEN_SECRET:?Set TOKEN_SECRET} PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:18082} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000,http://localhost:3002} TEST_AUTH_ENABLED: ${TEST_AUTH_ENABLED:-false} diff --git a/docs/architecture.md b/docs/architecture.md index eb8a8ba..11c5e80 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,7 +3,7 @@ ## 三仓关系 ```text -pk-lab-xhs (综合小程序 + :3001 公网预览) ─┐ +pk-lab-xhs (综合小程序 + :3000 公网预览) ─┐ ├─ HTTPS ─ pk-lab-server (:18082) ─ PostgreSQL 16 rice-pk-xhs (饭类小程序 + :3002 公网预览) ───┘ ``` @@ -21,6 +21,7 @@ rice-pk-xhs (饭类小程序 + :3002 公网预览) ───┘ - `users`:内部用户,不保存小红书密码或 session key。 - `app_identities`:两套小程序各自的 `open_id` 映射。 +- `auth_sessions`:随机 session token 的 SHA-256 哈希、所属用户和 app、有效期及撤销时间;原始 token 只返回给客户端。 - `tests` / `candidates`:题库和候选项;首批 7 套、288 项。 - `test_runs`:冠军、亚军、Top 排名、每轮左右选项与胜者、来源 app。 - `link_codes`:SHA-256 后的一次性绑定码、10 分钟过期时间与使用时间。 @@ -31,11 +32,11 @@ rice-pk-xhs (饭类小程序 + :3002 公网预览) ───┘ Secrets: -- `GITEA_TOKEN`:CI 下载精确 commit 归档。 - `POSTGRES_PASSWORD`:长随机密码,首次部署后不要随意更换。 -- `TOKEN_SECRET`:至少 32 字节随机值,变更会使现有登录失效。 -- `XHS_HUB_APP_ID` / `XHS_HUB_APP_SECRET`:综合版。 -- `XHS_RICE_APP_ID` / `XHS_RICE_APP_SECRET`:饭类独立版。 +- `XHS_HUB_APP_ID` / `XHS_HUB_APP_SECRET`:综合版,创建小程序应用后再配置。 +- `XHS_RICE_APP_ID` / `XHS_RICE_APP_SECRET`:饭类独立版,创建小程序应用后再配置。 + +首次测试部署只要求 `POSTGRES_PASSWORD`;源码下载使用工作流内置的 Gitea job token。 Variables: @@ -46,7 +47,7 @@ Variables: ### 两个前端 -每个仓库配置 `GITEA_TOKEN` Secret、`API_BASE_URL` Variable;综合版默认 `DEPLOY_PORT=3001`,独立版默认 `DEPLOY_PORT=3002`。 +每个仓库可配置 `API_BASE_URL` Variable;综合版默认 `DEPLOY_PORT=3000`,独立版默认 `DEPLOY_PORT=3002`。 ## 永久运行保证 @@ -64,4 +65,3 @@ Variables: - 配置 App ID/Secret,确认真实 `xhs.login -> code2Session` 后关闭测试登录。 - 在开发者工具和真机检查 Canvas 海报、相册权限、长名称、轮空赛程与网络失败重试。 - 根据平台审核要求补齐隐私政策、用户数据删除入口和客服联系方式。 - diff --git a/pyproject.toml b/pyproject.toml index 12b2950..babfced 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,3 +30,6 @@ testpaths = ["tests"] target-version = "py312" line-length = 100 +[tool.ruff.lint] +# FastAPI intentionally declares dependency providers as default values. +ignore = ["B008"] diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 563934b..38082d5 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -3,8 +3,5 @@ set -eu : "${DEPLOY_IMAGE:?Set DEPLOY_IMAGE}" : "${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}" -: "${TOKEN_SECRET:?Set TOKEN_SECRET}" - docker compose -p pk-lab-production up -d --remove-orphans --wait docker compose -p pk-lab-production ps - diff --git a/src/pk_lab_server/api.py b/src/pk_lab_server/api.py index 06b14e8..111d4a3 100644 --- a/src/pk_lab_server/api.py +++ b/src/pk_lab_server/api.py @@ -2,13 +2,13 @@ import hashlib import secrets from datetime import UTC, datetime, timedelta -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Response from sqlalchemy import func, select, update from sqlalchemy.orm import Session, selectinload from .config import get_settings from .db import get_db -from .models import AppIdentity, Candidate, LinkCode, TestDefinition, TestRun, User, now_utc +from .models import AppIdentity, AuthSession, LinkCode, TestDefinition, TestRun, User, now_utc from .schemas import ( CompleteRunRequest, LinkConfirmIn, @@ -20,10 +20,9 @@ from .schemas import ( TestCard, TestDetail, ) -from .security import TokenClaims, current_claims, current_user, issue_token +from .security import SessionClaims, current_claims, current_user, issue_session from .xhs_auth import XhsAuthError, exchange_code - router = APIRouter(prefix="/api/v1") @@ -54,15 +53,27 @@ async def login(body: LoginRequest, db: Session = Depends(get_db)) -> LoginRespo user = db.get(User, identity.user_id) if user is None: raise HTTPException(status_code=500, detail="identity references a missing user") + token = issue_session(db, user.id, body.app_key) db.commit() return LoginResponse( - token=issue_token(user.id, body.app_key), + token=token, user_id=user.id, app_key=body.app_key, linked_apps=_linked_apps(db, user.id), ) +@router.post("/auth/logout", status_code=204) +def logout( + claims: SessionClaims = Depends(current_claims), db: Session = Depends(get_db) +) -> Response: + session = db.get(AuthSession, claims.session_id) + if session is not None: + session.revoked_at = now_utc() + db.commit() + return Response(status_code=204) + + @router.get("/tests", response_model=list[TestCard]) def list_tests(db: Session = Depends(get_db)) -> list[TestCard]: tests = db.scalars( @@ -130,7 +141,7 @@ def complete_run( slug: str, body: CompleteRunRequest, user: User = Depends(current_user), - claims: TokenClaims = Depends(current_claims), + claims: SessionClaims = Depends(current_claims), db: Session = Depends(get_db), ) -> RunOut: if body.app_key != claims.app_key: @@ -232,7 +243,7 @@ def create_link_code(user: User = Depends(current_user), db: Session = Depends(g def confirm_link_code( body: LinkConfirmIn, user: User = Depends(current_user), - claims: TokenClaims = Depends(current_claims), + claims: SessionClaims = Depends(current_claims), db: Session = Depends(get_db), ) -> LinkConfirmOut: link = db.scalar( @@ -250,11 +261,18 @@ def confirm_link_code( raise HTTPException(status_code=409, detail="both users already contain the same app identity") db.execute(update(AppIdentity).where(AppIdentity.user_id == user.id).values(user_id=target_id)) db.execute(update(TestRun).where(TestRun.user_id == user.id).values(user_id=target_id)) + db.execute(update(AuthSession).where(AuthSession.user_id == user.id).values(user_id=target_id)) + db.execute( + update(AuthSession) + .where(AuthSession.id == claims.session_id) + .values(revoked_at=now_utc()) + ) db.delete(user) link.used_at = now_utc() + token = issue_session(db, target_id, claims.app_key) db.commit() return LinkConfirmOut( - token=issue_token(target_id, claims.app_key), + token=token, user_id=target_id, linked_apps=_linked_apps(db, target_id), ) diff --git a/src/pk_lab_server/config.py b/src/pk_lab_server/config.py index 95816f3..f01ba3b 100644 --- a/src/pk_lab_server/config.py +++ b/src/pk_lab_server/config.py @@ -8,9 +8,8 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") database_url: str = "sqlite:///./pk-lab.db" - token_secret: str = "development-only-change-me" - token_ttl_seconds: int = 60 * 60 * 24 * 30 - cors_origins: str = "http://localhost:3001,http://localhost:3002" + session_ttl_seconds: int = 60 * 60 * 24 * 30 + cors_origins: str = "http://localhost:3000,http://localhost:3002" public_base_url: str = "http://localhost:18082" test_auth_enabled: bool = False xhs_token_url: str = "https://miniapp.xiaohongshu.com/api/rmp/token" @@ -35,4 +34,3 @@ class Settings(BaseSettings): @lru_cache def get_settings() -> Settings: return Settings() - diff --git a/src/pk_lab_server/models.py b/src/pk_lab_server/models.py index eb71d31..c0d5836 100644 --- a/src/pk_lab_server/models.py +++ b/src/pk_lab_server/models.py @@ -1,7 +1,17 @@ import uuid from datetime import UTC, datetime -from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy import ( + JSON, + Boolean, + DateTime, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, +) from sqlalchemy.orm import Mapped, mapped_column, relationship from .db import Base @@ -39,6 +49,20 @@ class AppIdentity(Base): user: Mapped[User] = relationship() +class AuthSession(Base): + __tablename__ = "auth_sessions" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + app_key: Mapped[str] = mapped_column(String(20), index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) + + user: Mapped[User] = relationship() + + class TestDefinition(Base): __tablename__ = "tests" diff --git a/src/pk_lab_server/security.py b/src/pk_lab_server/security.py index be50ee2..77c531a 100644 --- a/src/pk_lab_server/security.py +++ b/src/pk_lab_server/security.py @@ -1,84 +1,68 @@ -import base64 -import binascii import hashlib -import hmac -import json import secrets -import time from dataclasses import dataclass +from datetime import UTC, datetime, timedelta from fastapi import Depends, HTTPException from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select from sqlalchemy.orm import Session from .config import get_settings from .db import get_db -from .models import User - +from .models import AuthSession, User bearer = HTTPBearer(auto_error=False) -def _b64(data: bytes) -> str: - return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") +def _token_hash(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() -def _unb64(value: str) -> bytes: - return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) - - -def issue_token(user_id: str, app_key: str) -> str: - settings = get_settings() - payload = { - "sub": user_id, - "app": app_key, - "iat": int(time.time()), - "exp": int(time.time()) + settings.token_ttl_seconds, - "nonce": secrets.token_hex(8), - } - encoded = _b64(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()) - signature = hmac.new(settings.token_secret.encode(), encoded.encode(), hashlib.sha256).digest() - return encoded + "." + _b64(signature) +def issue_session(db: Session, user_id: str, app_key: str) -> str: + token = secrets.token_urlsafe(48) + db.add( + AuthSession( + token_hash=_token_hash(token), + user_id=user_id, + app_key=app_key, + expires_at=datetime.now(UTC) + timedelta(seconds=get_settings().session_ttl_seconds), + ) + ) + return token @dataclass(frozen=True) -class TokenClaims: +class SessionClaims: + session_id: str user_id: str app_key: str -def parse_token(token: str) -> TokenClaims: - settings = get_settings() - try: - encoded, supplied = token.split(".", 1) - expected = hmac.new(settings.token_secret.encode(), encoded.encode(), hashlib.sha256).digest() - if not hmac.compare_digest(expected, _unb64(supplied)): - raise ValueError("signature") - payload = json.loads(_unb64(encoded)) - if int(payload["exp"]) < int(time.time()): - raise ValueError("expired") - return TokenClaims(user_id=str(payload["sub"]), app_key=str(payload["app"])) - except ( - KeyError, - ValueError, - TypeError, - UnicodeDecodeError, - binascii.Error, - json.JSONDecodeError, - ) as exc: - raise HTTPException(status_code=401, detail="invalid or expired token") from exc - - def current_claims( credentials: HTTPAuthorizationCredentials | None = Depends(bearer), -) -> TokenClaims: + db: Session = Depends(get_db), +) -> SessionClaims: if credentials is None or credentials.scheme.lower() != "bearer": raise HTTPException(status_code=401, detail="authentication required") - return parse_token(credentials.credentials) + session = db.scalar( + select(AuthSession).where( + AuthSession.token_hash == _token_hash(credentials.credentials), + AuthSession.revoked_at.is_(None), + AuthSession.expires_at > datetime.now(UTC), + ) + ) + if session is None: + raise HTTPException(status_code=401, detail="invalid or expired session") + return SessionClaims( + session_id=session.id, + user_id=session.user_id, + app_key=session.app_key, + ) def current_user( - claims: TokenClaims = Depends(current_claims), db: Session = Depends(get_db) + claims: SessionClaims = Depends(current_claims), db: Session = Depends(get_db) ) -> User: user = db.get(User, claims.user_id) if user is None: diff --git a/tests/conftest.py b/tests/conftest.py index d9eb20d..6a0eea5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,4 @@ import os from pathlib import Path os.environ["DATABASE_URL"] = f"sqlite:///{Path('/tmp/pk-lab-test.db')}" -os.environ["TOKEN_SECRET"] = "test-secret" os.environ["TEST_AUTH_ENABLED"] = "true" - diff --git a/tests/test_api.py b/tests/test_api.py index 468f572..15f8f1d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,11 @@ -from fastapi.testclient import TestClient +import hashlib +from fastapi.testclient import TestClient +from sqlalchemy import select + +from pk_lab_server.db import SessionLocal from pk_lab_server.main import app +from pk_lab_server.models import AuthSession def test_health_and_seeded_tests(): @@ -20,6 +25,17 @@ def test_login_complete_and_read_result(): assert login.status_code == 200 token = login.json()["token"] headers = {"Authorization": f"Bearer {token}"} + with SessionLocal() as db: + stored = db.scalar( + select(AuthSession).where( + AuthSession.token_hash == hashlib.sha256(token.encode()).hexdigest() + ) + ) + assert stored is not None + assert stored.token_hash != token + assert client.get( + "/api/v1/me/results", headers={"Authorization": "Bearer forged-token"} + ).status_code == 401 detail = client.get("/api/v1/tests/rice").json() left, right = detail["candidates"][:2] created = client.post( @@ -43,4 +59,5 @@ def test_login_complete_and_read_result(): assert created.status_code == 201, created.text history = client.get("/api/v1/me/results?test_slug=rice", headers=headers).json() assert history[0]["champion_name"] == "蛋炒饭" - + assert client.post("/api/v1/auth/logout", headers=headers).status_code == 204 + assert client.get("/api/v1/me/results", headers=headers).status_code == 401