Files
hunjing/backend/app/auth/dependencies.py
T
chenxin 34c481ab9e ✨ feat: WP-13 市场与交易——不可变账本、退款、Webhook 与保全态(ADR-009/021)
- 迁移 11:listing、order(六态 + 幂等唯一约束)、order_item(价格快照,客户端金额不可信)、ledger_entry(不可变 8 类型带符号分录)、webhook_event(Provider 事件 ID 幂等)、personal_domain.billing_status(保全态)
- 购买闭环:下单冻结价格快照 → 支付确认同事务写三分录(charge = platform_fee 10% + creator_payable,双录平衡校验拒绝不平衡提交)
- 退款:反向三条分录修正账本(Ledger 不删不改,ADR-009);重复退款幂等;汇总端点对账 balanced=true
- Webhook:HMAC-SHA256 验签(密钥经环境变量,错签 401)→ 按 Provider 事件 ID 幂等(重复投递 duplicate 不重复入账)→ 买方从订单反查(不信任 payload)
- 保全态:欠费只读保全(全部状态变更 403 且明示不删数据;解除端点不被保全自锁);读取不受影响,恢复即解锁——ADR-021 落地
- 测试 72 项全绿(+5):双录平衡、订单幂等、退款反向与对账、验签/幂等、保全只读与恢复
2026-09-11 11:37:45 +08:00

73 lines
2.7 KiB
Python

"""认证依赖:当前用户解析与状态变更防护。"""
from typing import Annotated
from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.service import SESSION_COOKIE, resolve_session
from app.db.session import DbSession, get_db
from app.models import User
ALLOWED_ORIGINS: set[str] = set() # 由配置注入;空集表示本地开发放行同源
async def get_current_user(
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
token = request.cookies.get(SESSION_COOKIE)
if token is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "未登录")
session = await resolve_session(db, token)
if session is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "会话无效或已过期")
user = await db.get(User, session.user_id)
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "会话主体不存在")
# 请求级上下文供后续依赖使用(不重复查询)
request.state.session_id = session.id
request.state.session_csrf_hash = session.csrf_token_hash
return user
async def require_state_change(
request: Request,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
hunjing_csrf_token: Annotated[str | None, Header(alias="Hunjing-CSRF-Token")] = None,
) -> User:
"""Cookie 状态变更:Origin 校验 + CSRF 双提交 + 保全态拦截(契约 2.1 / ADR-021)。"""
if ALLOWED_ORIGINS:
origin = request.headers.get("Origin")
if origin is not None and origin not in ALLOWED_ORIGINS:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Origin 不被允许")
csrf_hash = getattr(request.state, "session_csrf_hash", None)
if csrf_hash is None or not _csrf_matches(csrf_hash, hunjing_csrf_token):
raise HTTPException(status.HTTP_403_FORBIDDEN, "CSRF 校验失败")
# 保全态:欠费只读保全,状态变更拒绝(数据不删、恢复即解锁)
from sqlalchemy import select
from app.models import PersonalDomain
domain = (
await db.execute(
select(PersonalDomain).where(PersonalDomain.user_id == user.id).limit(1)
)
).scalar_one_or_none()
if domain is not None and domain.billing_status == "preserved":
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"账户处于保全态(只读):恢复付费后自动解锁,数据不会删除",
)
return user
def _csrf_matches(expected_hash: str, provided: str | None) -> bool:
import hashlib
import secrets
if provided is None:
return False
return secrets.compare_digest(expected_hash, hashlib.sha256(provided.encode()).hexdigest())