Files
DadaoTask/backend/app/api/auth.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

464 lines
17 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.
"""认证:手机号 / 邮箱 + 密码,邮箱验证码注册,手机验证码可选。
当前**默认关掉短信验证码**(`SMS_LOGIN_ENABLED=false`):内测阶段的固定验证码
等于"知道手机号就能进任意账号"。关掉后 `/auth/sms/send` 与 `/auth/login` 直接
回 2017,登录走「手机号 或 邮箱 + 密码」。登录页先问 `/auth/methods`,只显示
服务端真开着的登录方式,免得点进去才报错。
新用户走「邮箱 + 验证码」注册(`POST /auth/email/code` → `POST /auth/register`):
短信一关,就没有别的注册入口了。注册出来的账号没有手机号,`app_user.phone` 因此可空。
"""
from __future__ import annotations
import hmac
import re
import secrets
from datetime import datetime, timezone
from fastapi import APIRouter
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import get_settings
from ..deps import CurrentUser, SessionDep
from ..errors import AppError, ErrorCode
from ..models import AppUser, Member, Workspace
from ..schemas import ok
from ..security import (
create_token_pair,
decode_token,
hash_password,
password_problem,
verify_password,
)
from ..services import mail
from ..stores import (
email_code_key,
email_throttle_key,
email_tries_key,
get_store,
sms_code_key,
sms_throttle_key,
)
router = APIRouter(prefix="/auth", tags=["认证"])
settings = get_settings()
PHONE_PATTERN = r"^1[3-9]\d{9}$"
# 邮箱只做形状校验:真正的可达性靠发信验证,正则再严也证明不了地址存在
EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$")
# 密码登录连错这么多次就先锁一会儿:暴力猜密码的成本必须比猜验证码高
PASSWORD_FAIL_LIMIT = 10
PASSWORD_FAIL_TTL = 900
def mask_phone(phone: str | None) -> str:
if not phone:
return ""
return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***"
def normalize_email(email: str) -> str:
"""邮箱大小写不敏感:统一小写存,登录时也按小写比。"""
return email.strip().lower()
def _user_out(user: AppUser) -> dict:
"""对外只给脱敏手机号;密码本身一个字节都不出去,只告诉客户端「设没设」。
邮箱是用户自己的账号名,给全值(账号页要显示、要能改)。
"""
return {
"id": str(user.id),
"phone": mask_phone(user.phone),
"email": user.email,
"email_verified": user.email_verified_at is not None,
"nickname": user.nickname,
"avatar_url": user.avatar_url,
"has_password": bool(user.password_hash),
"platform_admin": bool(user.phone) and user.phone in settings.platform_admin_phones,
"created_at": user.created_at,
}
def _tokens_out(tokens) -> dict:
return {
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"token_type": "bearer",
"expires_in": tokens.expires_in,
}
class SendCodeIn(BaseModel):
phone: str = Field(pattern=PHONE_PATTERN, examples=["13800138000"])
class LoginIn(BaseModel):
phone: str = Field(pattern=PHONE_PATTERN)
code: str = Field(min_length=4, max_length=8)
class PasswordLoginIn(BaseModel):
"""手机号或邮箱 + 密码。两个都给也行,手机号优先(老客户端只发 phone)。"""
phone: str | None = Field(default=None, pattern=PHONE_PATTERN)
email: str | None = Field(default=None, max_length=255)
password: str = Field(min_length=1, max_length=64)
@model_validator(mode="after")
def _one_account(self) -> PasswordLoginIn:
if not self.phone and not (self.email and self.email.strip()):
raise ValueError("手机号或邮箱填一个")
return self
class ProfilePatch(BaseModel):
nickname: str | None = Field(default=None, min_length=1, max_length=32)
# 传空串表示解绑;不传表示不动
email: str | None = Field(default=None, max_length=255)
class PasswordIn(BaseModel):
"""old_password 只在「已经设过密码」时必填;第一次设密码不需要。"""
password: str = Field(min_length=1, max_length=64)
old_password: str | None = Field(default=None, max_length=64)
class RefreshIn(BaseModel):
refresh_token: str = Field(min_length=10)
@router.get("/methods", summary="这台服务现在开放哪些登录方式")
async def methods() -> dict:
"""登录页据此渲染,别让用户点进去才发现这条路关着。"""
return ok(
{
"password": True,
"email_password": True,
"sms_code": settings.sms_login_enabled,
"email_register": settings.email_register_enabled,
}
)
@router.post("/sms/send", summary="发送登录验证码")
async def send_code(body: SendCodeIn) -> dict:
if not settings.sms_login_enabled:
raise AppError(
ErrorCode.LOGIN_METHOD_DISABLED,
"短信验证码登录暂未开放,请用手机号或邮箱 + 密码登录",
)
store = get_store()
if settings.sms_throttle_seconds > 0:
hits = await store.incr(sms_throttle_key(body.phone), settings.sms_throttle_seconds)
if hits > 1:
raise AppError(
ErrorCode.SMS_TOO_FREQUENT,
f"发送过于频繁,请 {settings.sms_throttle_seconds} 秒后重试",
)
if settings.sms_provider == "dev":
code = settings.sms_dev_code
else:
code = f"{secrets.randbelow(1_000_000):06d}"
# TODO(M0 遗留):接入阿里云/腾讯云短信;当前直接在响应里不回显验证码
raise AppError(ErrorCode.INTERNAL, "短信通道尚未接入", http_status=501)
await store.set(sms_code_key(body.phone), code, settings.sms_code_ttl_seconds)
return ok({"sent": True, "expires_in": settings.sms_code_ttl_seconds, "dev_code": code})
@router.post("/login", summary="验证码登录(不存在则自动注册)")
async def login(body: LoginIn, session: SessionDep) -> dict:
if not settings.sms_login_enabled:
raise AppError(
ErrorCode.LOGIN_METHOD_DISABLED,
"短信验证码登录暂未开放,请用手机号或邮箱 + 密码登录",
)
store = get_store()
stored = await store.get(sms_code_key(body.phone))
if stored is None:
raise AppError(ErrorCode.SMS_CODE_INVALID, "验证码已过期,请重新获取")
if not hmac.compare_digest(stored, body.code):
raise AppError(ErrorCode.SMS_CODE_INVALID, "验证码错误")
await store.delete(sms_code_key(body.phone))
user = (
await session.execute(select(AppUser).where(AppUser.phone == body.phone))
).scalar_one_or_none()
if user is None:
user = AppUser(phone=body.phone, nickname=f"用户{body.phone[-4:]}")
session.add(user)
await session.flush()
tokens = create_token_pair(user.id)
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
@router.post("/login/password", summary="手机号 / 邮箱 + 密码登录")
async def login_password(body: PasswordLoginIn, session: SessionDep) -> dict:
"""手机号或邮箱 + 密码登录。
三个细节:
- **不区分"没这个人"和"密码错"**,同一个错误码,别让登录接口变成账号探测工具。
- 连错 PASSWORD_FAIL_LIMIT 次锁 PASSWORD_FAIL_TTL 秒;成功即清零,正常用户不会感知。
失败计数按"填的那个账号"记,换手机号/邮箱轮着试也照样累计。
- 邮箱大小写不敏感(存的时候已经统一小写,这里再 lower 一次兜住历史数据)。
"""
store = get_store()
account_key = body.phone or normalize_email(body.email or "")
fail_key = f"pwd_fail:{account_key}"
if int(await store.get(fail_key) or 0) >= PASSWORD_FAIL_LIMIT:
raise AppError(
ErrorCode.LOGIN_TOO_MANY,
f"密码错误次数过多,请 {PASSWORD_FAIL_TTL // 60} 分钟后再试",
)
if body.phone:
stmt = select(AppUser).where(AppUser.phone == body.phone)
hint = "手机号或密码不对"
else:
stmt = select(AppUser).where(func.lower(AppUser.email) == account_key)
hint = "邮箱或密码不对"
user = (await session.execute(stmt)).scalar_one_or_none()
if user is None or not verify_password(body.password, user.password_hash):
await store.incr(fail_key, PASSWORD_FAIL_TTL)
raise AppError(ErrorCode.PASSWORD_INVALID, hint)
if user.status != "active":
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用")
await store.delete(fail_key)
tokens = create_token_pair(user.id)
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
@router.post("/refresh", summary="刷新访问令牌")
async def refresh(body: RefreshIn) -> dict:
user_id = decode_token(body.refresh_token, "refresh")
tokens = create_token_pair(user_id)
# 这里的形状保持扁平(不带 tokens 信封),老客户端与安卓端都按这个取
return ok(_tokens_out(tokens))
@router.get("/me", summary="当前用户与所属工作区")
async def me(user: CurrentUser, session: SessionDep) -> dict:
row = await session.get(AppUser, user.id)
if row is None:
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
rows = (
await session.execute(
select(Workspace, Member.role)
.join(Member, Member.workspace_id == Workspace.id)
.where(Member.user_id == user.id, Member.status == "active")
.order_by(Workspace.created_at.desc())
)
).all()
return ok(
{
"user": _user_out(row),
"workspaces": [
{
"id": str(ws.id),
"name": ws.name,
"slug": ws.slug,
"plan": ws.plan,
"role": role,
"created_at": ws.created_at,
}
for ws, role in rows
],
}
)
@router.patch("/me", summary="改自己的昵称与邮箱")
async def update_me(body: ProfilePatch, user: CurrentUser, session: SessionDep) -> dict:
row = await session.get(AppUser, user.id)
if row is None:
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
if body.nickname is not None:
nickname = body.nickname.strip()
if not nickname:
raise AppError(ErrorCode.VALIDATION_FAILED, "昵称不能为空")
row.nickname = nickname
if body.email is not None:
raw = body.email.strip()
if not raw:
# 解绑:邮箱只是"多一条登录方式",不是必填项
row.email = None
else:
email = normalize_email(raw)
if not EMAIL_RE.match(email):
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
taken = (
await session.execute(
select(AppUser.id).where(
func.lower(AppUser.email) == email, AppUser.id != user.id
)
)
).scalar_one_or_none()
if taken is not None:
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经绑在别的账号上了")
row.email = email
await session.flush()
return ok({"user": _user_out(row)})
@router.post("/password", summary="设置或修改登录密码")
async def set_password(body: PasswordIn, user: CurrentUser, session: SessionDep) -> dict:
"""已经设过密码的,必须先给旧密码——否则拿到一个没锁屏的浏览器就能把密码换掉。"""
row = await session.get(AppUser, user.id)
if row is None:
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
if row.password_hash:
if not body.old_password:
raise AppError(ErrorCode.PASSWORD_INVALID, "请输入当前密码")
if not verify_password(body.old_password, row.password_hash):
raise AppError(ErrorCode.PASSWORD_INVALID, "当前密码不对")
if hmac.compare_digest(body.old_password, body.password):
raise AppError(ErrorCode.PASSWORD_WEAK, "新密码不能和当前密码一样")
problem = password_problem(body.password)
if problem:
raise AppError(ErrorCode.PASSWORD_WEAK, problem)
row.password_hash = hash_password(body.password)
await session.flush()
return ok({"has_password": True})
# ── 邮箱注册 ────────────────────────────────────────────
class EmailCodeIn(BaseModel):
email: str = Field(max_length=255, examples=["someone@example.com"])
class RegisterIn(BaseModel):
"""邮箱 + 验证码 + 密码,一步把账号建出来。
昵称不填就取邮箱 @ 前面那段,别让新用户第一眼看到一个空名字。
"""
email: str = Field(max_length=255)
code: str = Field(min_length=4, max_length=8)
password: str = Field(min_length=1, max_length=64)
nickname: str | None = Field(default=None, max_length=32)
async def _email_taken(session: AsyncSession, email: str) -> bool:
stmt = select(AppUser.id).where(func.lower(AppUser.email) == email)
return (await session.execute(stmt)).first() is not None
@router.post("/email/code", summary="发送邮箱注册验证码")
async def send_email_code(body: EmailCodeIn, session: SessionDep) -> dict:
"""发码前先查邮箱是否已被占用:让用户填完密码才发现"这个邮箱注册过了"最气人。"""
if not settings.email_register_enabled:
raise AppError(
ErrorCode.LOGIN_METHOD_DISABLED,
"邮箱注册暂未开放,请联系管理员开通账号",
)
email = normalize_email(body.email)
if not EMAIL_RE.match(email):
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
if await _email_taken(session, email):
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经注册过了,直接登录即可")
store = get_store()
if settings.email_code_throttle_seconds > 0:
hits = await store.incr(email_throttle_key(email), settings.email_code_throttle_seconds)
if hits > 1:
raise AppError(
ErrorCode.EMAIL_CODE_TOO_FREQUENT,
f"发送过于频繁,请 {settings.email_code_throttle_seconds} 秒后重试",
)
code = f"{secrets.randbelow(1_000_000):06d}"
await store.set(email_code_key(email), code, settings.email_code_ttl_seconds)
await store.delete(email_tries_key(email))
minutes = max(1, settings.email_code_ttl_seconds // 60)
if settings.email_code_dev_echo:
# 本机联调:不发真信,直接把码回给调用方(生产打开会被 validate_runtime 拒掉)
return ok(
{
"sent": True,
"expires_in": settings.email_code_ttl_seconds,
"dev_code": code,
}
)
await mail.send_verify_code(session, to=email, code=code, minutes=minutes)
return ok({"sent": True, "expires_in": settings.email_code_ttl_seconds})
@router.post("/register", summary="邮箱注册(验证码 + 密码)")
async def register(body: RegisterIn, session: SessionDep) -> dict:
"""邮箱注册:验码 → 建账号 → 直接发令牌,注册完就是登录状态。
账号没有手机号(`phone` 为空),所以邮件是唯一联系方式——这也是为什么注册必须
真验一次码,而不是"填个邮箱就算"。
"""
if not settings.email_register_enabled:
raise AppError(
ErrorCode.LOGIN_METHOD_DISABLED,
"邮箱注册暂未开放,请联系管理员开通账号",
)
email = normalize_email(body.email)
if not EMAIL_RE.match(email):
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
store = get_store()
stored = await store.get(email_code_key(email))
if not stored:
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码已过期,请重新获取")
tries = await store.incr(email_tries_key(email), settings.email_code_ttl_seconds)
if tries > settings.email_code_max_attempts:
await store.delete(email_code_key(email))
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码错太多次,请重新获取")
if not hmac.compare_digest(stored, body.code.strip()):
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码不对")
problem = password_problem(body.password)
if problem:
raise AppError(ErrorCode.PASSWORD_WEAK, problem)
# 拿到验证码之后这段时间里,别人可能已经用同一个邮箱注册完了
if await _email_taken(session, email):
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经注册过了,直接登录即可")
nickname = (body.nickname or "").strip() or email.split("@", 1)[0][:32]
user = AppUser(
email=email,
phone=None,
nickname=nickname,
password_hash=hash_password(body.password),
email_verified_at=datetime.now(timezone.utc),
status="active",
)
session.add(user)
await session.flush()
await store.delete(email_code_key(email))
await store.delete(email_tries_key(email))
await store.delete(email_throttle_key(email))
tokens = create_token_pair(user.id)
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})