feat: persist authentication sessions in postgres
Verify and deploy API / verify-deploy (push) Failing after 1m45s
Verify and deploy API / verify-deploy (push) Failing after 1m45s
This commit is contained in:
@@ -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=
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:` 登录路径。
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 海报、相册权限、长名称、轮空赛程与网络失败重试。
|
||||
- 根据平台审核要求补齐隐私政策、用户数据删除入口和客服联系方式。
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user