CI / backend (push) Canceled after 0s
用户要求:内测的固定验证码 123456 等于"知道手机号就能进任何账号",先关掉; 登录改用账号密码,并多一种"邮箱当账号名"的方式;发信邮箱要能在后台改。 短信那条路(默认关): - `SMS_LOGIN_ENABLED=false`,`/auth/sms/send` 与 `/auth/login` 统一回 2017,不是静默失败; - 生产校验改成"开着短信登录就必须配真通道",不开时 provider 是 dev 也不拦; - 新增 `GET /auth/methods`,登录页据此渲染,关着的路不出现(少一次"点进去才发现不能用")。 邮箱登录: - `app_user.email` + `uq_app_user_email`(`lower(email)` 部分索引),登录与绑定都归一成小写; - `POST /auth/login/password` 收 `phone` 或 `email`(二选一),失败统一 2013、连错 10 次锁 15 分钟; - 绑定在 `PATCH /auth/me`:格式错 2018、被占用 2019、留空串表示解绑。 系统设置(跨租户,只有平台管理员能改): - 新表 `system_setting`(不挂租户、无 RLS,写权限由 `deps.platform_admin` 把关); - `GET/PUT /settings/smtp`、`POST /settings/smtp/test`,授权码只写不读,留空表示沿用旧值; - `services/mail.py` 只用标准库 smtplib:465 走 SSL、587 走 STARTTLS,认证/拒收/网络失败都归 2020 但消息不同; - Web 新增「系统设置」页(状态卡 + 测试发信),侧栏只对平台管理员显示入口;账号页补「邮箱」。 验证:pytest 130 passed、ruff check/format 全绿、tsc 通过、vite build 通过; `web/e2e/login-email-smtp.mjs` 15 项全绿(含用 126 授权码真发一封测试邮件)。 顺带记下的两个坑:`platform_admin_phones` 是 list,pydantic-settings 会按 JSON 解析, 要用 `NoDecode` 交给 before-validator 拆逗号;迁移号写成 0009 会撞既有的 0009,一并改成 0010。
131 lines
4.3 KiB
Python
131 lines
4.3 KiB
Python
"""请求依赖:认证、租户上下文、角色校验。
|
||
|
||
关键点:**租户上下文必须在任何业务查询之前绑定**,否则 RLS 会返回空集,
|
||
表现为"数据凭空消失"而不是报错——这类 bug 最难查,所以封装成依赖强制调用。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from dataclasses import dataclass
|
||
from typing import Annotated
|
||
|
||
from fastapi import Depends, Header, Path
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from .config import get_settings
|
||
from .db import bind_tenant, bind_user, get_session
|
||
from .errors import AppError, ErrorCode
|
||
from .logging_setup import user_id_ctx, workspace_id_ctx
|
||
from .models import AppUser, Member
|
||
from .security import decode_token
|
||
|
||
ROLE_RANK: dict[str, int] = {"owner": 4, "admin": 3, "member": 2, "guest": 1}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AuthUser:
|
||
id: uuid.UUID
|
||
phone: str
|
||
nickname: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class TenantCtx:
|
||
ws_id: uuid.UUID
|
||
user: AuthUser
|
||
role: str
|
||
|
||
def require_role(self, minimum: str) -> None:
|
||
if ROLE_RANK.get(self.role, 0) < ROLE_RANK[minimum]:
|
||
raise AppError(ErrorCode.INSUFFICIENT_ROLE, f"需要 {minimum} 及以上权限")
|
||
|
||
|
||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||
|
||
|
||
async def get_bearer_token(authorization: Annotated[str | None, Header()] = None) -> str:
|
||
if not authorization or not authorization.lower().startswith("bearer "):
|
||
raise AppError(ErrorCode.UNAUTHORIZED, "缺少访问令牌")
|
||
return authorization.split(" ", 1)[1].strip()
|
||
|
||
|
||
TokenDep = Annotated[str, Depends(get_bearer_token)]
|
||
|
||
|
||
async def current_user(token: TokenDep, session: SessionDep) -> AuthUser:
|
||
user_id = decode_token(token, "access")
|
||
user = await session.get(AppUser, user_id)
|
||
if user is None:
|
||
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
|
||
if user.status != "active":
|
||
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用")
|
||
# 绑定 user_id:member 表策略允许"看到自己的成员记录",用于"我加入了哪些工作区"
|
||
await bind_user(session, user.id)
|
||
user_id_ctx.set(str(user.id))
|
||
return AuthUser(id=user.id, phone=user.phone, nickname=user.nickname)
|
||
|
||
|
||
CurrentUser = Annotated[AuthUser, Depends(current_user)]
|
||
|
||
|
||
async def tenant_ctx(
|
||
ws_id: Annotated[uuid.UUID, Path(alias="ws_id")],
|
||
user: CurrentUser,
|
||
session: SessionDep,
|
||
) -> TenantCtx:
|
||
"""仅绑定租户(不要求是成员)。用于"凭邀请码加入工作区"这类场景。"""
|
||
await bind_tenant(session, ws_id, user.id)
|
||
workspace_id_ctx.set(str(ws_id))
|
||
return TenantCtx(ws_id=ws_id, user=user, role="")
|
||
|
||
|
||
TenantDep = Annotated[TenantCtx, Depends(tenant_ctx)]
|
||
|
||
|
||
async def member_ctx(ctx: TenantDep, session: SessionDep) -> TenantCtx:
|
||
"""要求是有效成员,并把角色带进上下文。"""
|
||
row = await session.execute(
|
||
select(Member).where(
|
||
Member.workspace_id == ctx.ws_id,
|
||
Member.user_id == ctx.user.id,
|
||
Member.status == "active",
|
||
)
|
||
)
|
||
member = row.scalar_one_or_none()
|
||
if member is None:
|
||
# 不区分"工作区不存在"和"你不是成员",避免探测他人工作区是否存在
|
||
raise AppError(ErrorCode.NOT_A_MEMBER, "无权访问该工作区")
|
||
return TenantCtx(ws_id=ctx.ws_id, user=ctx.user, role=member.role)
|
||
|
||
|
||
MemberDep = Annotated[TenantCtx, Depends(member_ctx)]
|
||
|
||
|
||
def require_role(minimum: str):
|
||
async def _guard(ctx: MemberDep) -> TenantCtx:
|
||
ctx.require_role(minimum)
|
||
return ctx
|
||
|
||
return _guard
|
||
|
||
|
||
OwnerDep = Annotated[TenantCtx, Depends(require_role("owner"))]
|
||
AdminDep = Annotated[TenantCtx, Depends(require_role("admin"))]
|
||
|
||
|
||
async def platform_admin(user: CurrentUser) -> AuthUser:
|
||
"""平台管理员:能改 SMTP 这类**跨租户**的系统设置。
|
||
|
||
刻意不复用工作区角色:某个工作区的 owner 只说明他管得了自己的工作区,
|
||
不代表他能看到整台服务的发信账号(那里面存着别人的授权码)。
|
||
名单来自配置 `PLATFORM_ADMIN_PHONES`,换人不用发版。
|
||
"""
|
||
if user.phone not in get_settings().platform_admin_phones:
|
||
raise AppError(ErrorCode.INSUFFICIENT_ROLE, "只有平台管理员能改系统设置")
|
||
return user
|
||
|
||
|
||
PlatformAdminDep = Annotated[AuthUser, Depends(platform_admin)]
|