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
+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: