Files
DadaoTask/backend/app/deps.py
T
Steven b5170ad1ce
CI / backend (push) Canceled after 0s
feat(auth): 邮箱验证码注册,生产重新有了自助入口
短信登录关掉后,生产没有任何注册入口——这一版补上最普通的那条路:
填邮箱、收验证码、设密码,注册完直接进首页。

先发码再注册,不是"注册时顺带发信"

  POST /auth/email/code 只做三件事:查重、限流、发码。
  填完密码才被告知"这个邮箱注册过了"最气人,所以占用(2019)在发码前就回。

邮箱既是账号名也是唯一联系方式,所以必须真验一次码

  验证通过时间落 app_user.email_verified_at,/auth/me 回 email_verified。
  "随手填一个"会让账号变成没人能找回的孤岛。

app_user.phone 改为可空(迁移 0011)

  邮箱注册的账号本来就没有手机号。user.phone 对这类账号回空串,
  成员列表同理,不再伪装成一串星号。

防刷

  同邮箱 60 秒一次(2022)、验证码 10 分钟有效、错 5 次即作废(2021)。
  6 位数可以被刮,不设上限等于把注册口子交给脚本。

本机联调开关 EMAIL_CODE_DEV_ECHO

  为 true 时把码回给调用方且不发真信——否则每跑一次 e2e 就往真实收件箱塞一封。
  生产打开会让 validate_runtime() 拒绝启动。

前端

  新增 /register(邮箱、验证码、密码、确认、昵称;60 秒倒计时;
  服务端关着注册时整页只说"联系管理员开通"),登录页按 /auth/methods
  决定要不要显示「还没有账号?用邮箱注册」。

验证

  pytest 139 passed、ruff check / format 全绿
  tsc --noEmit 0、vite build 通过
  e2e register.mjs 7 项:登录页入口 → 拿码 → 注册落进首页 → 退出用邮箱+密码登回来
    → 同一邮箱再发码回 2019 → 控制台零报错
  e2e login-email-smtp.mjs 15 项回归通过(含真发一封测试信)
  e2e account.mjs 14 项、knowledge.mjs 全绿

顺带修的两处测试

  test_account 的字段白名单补 email_verified(接口确实多了这个字段);
  test_config_prod 用 Settings(_env_file=None, ...) 构造,别被本机 .env 里的
  EMAIL_CODE_DEV_ECHO=true 带偏——那个开关在生产是硬错误,本机却是跑 e2e 必需的。
2026-11-03 01:04:26 +08:00

133 lines
4.5 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
# 邮箱注册的账号还没有手机号,所以可空;用手机号的地方(平台管理员白名单)按
# "空就不匹配" 处理,别拿 None 去比字符串。
phone: str | None
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)]