feat: persist authentication sessions in postgres
Verify and deploy API / verify-deploy (push) Failing after 1m45s

This commit is contained in:
2026-09-18 22:16:30 +08:00
parent 426d61139c
commit 434f324bb9
13 changed files with 118 additions and 84 deletions
-2
View File
@@ -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=
-2
View File
@@ -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"
+1 -1
View File
@@ -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:` 登录路径。
-1
View File
@@ -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}
+7 -7
View File
@@ -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 海报、相册权限、长名称、轮空赛程与网络失败重试。
- 根据平台审核要求补齐隐私政策、用户数据删除入口和客服联系方式。
+3
View File
@@ -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"]
-3
View File
@@ -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
+26 -8
View File
@@ -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),
)
+2 -4
View File
@@ -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()
+25 -1
View File
@@ -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"
+35 -51
View File
@@ -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:
-2
View File
@@ -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"
+19 -2
View File
@@ -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