Files
DadaoTask/backend/app/deps.py
T
Steven 7558637567
CI / backend (push) Canceled after 0s
feat(auth): 短信登录默认关,换成手机号/邮箱 + 密码,发信邮箱进后台
用户要求:内测的固定验证码 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。
2026-11-03 00:00:11 +08:00

131 lines
4.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""请求依赖:认证、租户上下文、角色校验。
关键点:**租户上下文必须在任何业务查询之前绑定**,否则 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)]