feat(auth): 邮箱验证码注册,生产重新有了自助入口
CI / backend (push) Canceled after 0s

短信登录关掉后,生产没有任何注册入口——这一版补上最普通的那条路:
填邮箱、收验证码、设密码,注册完直接进首页。

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

  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 必需的。
This commit is contained in:
Steven
2026-11-03 01:04:26 +08:00
parent c0c5230fd1
commit b5170ad1ce
20 changed files with 955 additions and 16 deletions
+144 -4
View File
@@ -1,9 +1,12 @@
"""认证:手机号 / 邮箱 + 密码,手机验证码可选。
"""认证:手机号 / 邮箱 + 密码,邮箱验证码注册,手机验证码可选。
当前**默认关掉短信验证码**(`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
@@ -11,10 +14,12 @@ 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
@@ -28,7 +33,15 @@ from ..security import (
password_problem,
verify_password,
)
from ..stores import get_store, sms_code_key, sms_throttle_key
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()
@@ -42,7 +55,9 @@ PASSWORD_FAIL_LIMIT = 10
PASSWORD_FAIL_TTL = 900
def mask_phone(phone: str) -> str:
def mask_phone(phone: str | None) -> str:
if not phone:
return ""
return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***"
@@ -60,10 +75,11 @@ def _user_out(user: AppUser) -> dict:
"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": user.phone in settings.platform_admin_phones,
"platform_admin": bool(user.phone) and user.phone in settings.platform_admin_phones,
"created_at": user.created_at,
}
@@ -125,6 +141,7 @@ async def methods() -> dict:
"password": True,
"email_password": True,
"sms_code": settings.sms_login_enabled,
"email_register": settings.email_register_enabled,
}
)
@@ -321,3 +338,126 @@ async def set_password(body: PasswordIn, user: CurrentUser, session: SessionDep)
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)})
+2 -1
View File
@@ -132,7 +132,8 @@ async def list_members(
"role": m.role,
"status": m.status,
"nickname": u.nickname,
"phone": f"{u.phone[:3]}****{u.phone[-4:]}",
# 邮箱注册的账号没有手机号:留空比显示一串星号诚实
"phone": f"{u.phone[:3]}****{u.phone[-4:]}" if u.phone else "",
"joined_at": m.joined_at,
}
for m, u in rows
+19
View File
@@ -85,6 +85,20 @@ class Settings(BaseSettings):
smtp_use_ssl: bool = True
smtp_timeout_seconds: int = 10
# ── 邮箱注册 ──────────────────────────────────────────
# 手机验证码关掉以后,新用户靠「邮箱 + 验证码」自己注册进来。
# 注册必须真的发出验证码(发信配置在「系统设置」里),所以这个开关跟 SMTP 是否配好绑定:
# 没配 SMTP 时发码接口会回 2020,而不是假装发出去。
email_register_enabled: bool = True
email_code_ttl_seconds: int = 600
# 同一邮箱两次发码的最小间隔;测试里设为 0 可关闭节流
email_code_throttle_seconds: int = 60
# 一个邮箱最多试错几次验证码,超了就作废(防止把 6 位数当彩票刮)
email_code_max_attempts: int = 5
# 开发/联调用:把验证码直接回给调用方,**不发真信**。
# 生产打开等于任何人都能注册任意邮箱,所以 validate_runtime() 会直接拒绝启动。
email_code_dev_echo: bool = False
# ── 幂等 ──────────────────────────────────────────────
idempotency_enabled: bool = True
idempotency_ttl_seconds: int = 86_400
@@ -163,6 +177,11 @@ class Settings(BaseSettings):
"短信验证码登录开着(SMS_LOGIN_ENABLED=true),但 SMS_PROVIDER=dev 只会发固定验证码"
"(等于知道手机号就能进);接真短信通道之前请把 SMS_LOGIN_ENABLED 关掉"
)
if self.email_code_dev_echo:
problems.append(
"EMAIL_CODE_DEV_ECHO=true 会把注册验证码直接回给调用方,"
"等于任何人都能注册任意邮箱;生产必须关掉(只在本地联调时打开)"
)
if problems:
raise RuntimeError("配置校验失败:" + ";".join(problems))
+3 -1
View File
@@ -27,7 +27,9 @@ ROLE_RANK: dict[str, int] = {"owner": 4, "admin": 3, "member": 2, "guest": 1}
@dataclass(frozen=True)
class AuthUser:
id: uuid.UUID
phone: str
# 邮箱注册的账号还没有手机号,所以可空;用手机号的地方(平台管理员白名单)按
# "空就不匹配" 处理,别拿 None 去比字符串。
phone: str | None
nickname: str
+4
View File
@@ -33,6 +33,8 @@ class ErrorCode(IntEnum):
EMAIL_INVALID = 2018
EMAIL_TAKEN = 2019
MAIL_SEND_FAILED = 2020
EMAIL_CODE_INVALID = 2021
EMAIL_CODE_TOO_FREQUENT = 2022
# 3xxx 租户 / 权限
WORKSPACE_NOT_FOUND = 3001
@@ -102,6 +104,8 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.EMAIL_INVALID: 422,
ErrorCode.EMAIL_TAKEN: 409,
ErrorCode.MAIL_SEND_FAILED: 502,
ErrorCode.EMAIL_CODE_INVALID: 400,
ErrorCode.EMAIL_CODE_TOO_FREQUENT: 429,
ErrorCode.WORKSPACE_NOT_FOUND: 404,
ErrorCode.NOT_A_MEMBER: 403,
ErrorCode.INSUFFICIENT_ROLE: 403,
+6 -1
View File
@@ -41,7 +41,9 @@ class AppUser(Base, Timestamps):
__tablename__ = "app_user"
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
phone: Mapped[str] = mapped_column(String(32), nullable=False, unique=True)
# 手机号可空:短信登录关掉以后,新账号是「邮箱 + 验证码」注册的,没有手机号。
# 老账号照旧有;将来绑手机号(换绑/找回)再补。
phone: Mapped[str | None] = mapped_column(String(32), unique=True)
nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="")
avatar_url: Mapped[str | None] = mapped_column(String(512))
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
@@ -50,6 +52,9 @@ class AppUser(Base, Timestamps):
# 邮箱:既是「邮箱 + 密码」登录的账号名,也是以后找回密码/发通知的去处。
# 可空(老用户还没有),唯一性由迁移里的 lower(email) 部分唯一索引保证。
email: Mapped[str | None] = mapped_column(String(255))
# 邮箱验过的时间:注册时发码验证过就有值;账号页手工绑邮箱时也走同一个验证流程。
# 用"有没有值"区分"填了个字符串"和"这个地址真是他的"。
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class SystemSetting(Base, Timestamps):
+21
View File
@@ -200,3 +200,24 @@ async def config_payload(session: AsyncSession) -> dict:
"source": "db" if row else "env",
"updated_at": row.updated_at if row else None,
}
async def send_verify_code(session: AsyncSession, *, to: str, code: str, minutes: int) -> None:
"""发注册/验证用的数字验证码。
模板写得朴素但说清楚了"谁在什么地方注册"和"不是你就算了"——收件箱里躺着一条
没头没尾的六位数,用户只会当成钓鱼。
"""
text = (
f"你的达道Task验证码是 {code}\n\n"
f"{minutes} 分钟内有效。如果不是本人操作,忽略这封邮件即可,账号不会有任何变化。"
)
html = (
'<div style="font:14px/1.7 -apple-system,Segoe UI,sans-serif;color:#1f2a28">'
"<p>你的达道Task验证码是:</p>"
f'<p style="font:600 28px/1.4 ui-monospace,Menlo,Consolas,monospace;'
f'letter-spacing:6px;color:#2f6054">{code}</p>'
f"<p>{minutes} 分钟内有效。如果不是本人操作,忽略这封邮件即可,账号不会有任何变化。</p>"
"</div>"
)
await send_mail(session, to=to, subject="达道Task 验证码", text=text, html=html)
+14
View File
@@ -107,5 +107,19 @@ def sms_throttle_key(phone: str) -> str:
return f"sms:throttle:{phone}"
# ── 邮箱注册 ────────────────────────────────────────────
def email_code_key(email: str) -> str:
return f"email:code:{email}"
def email_tries_key(email: str) -> str:
"""试错计数:错一次算一次,超过上限就把验证码作废,别让人拿 6 位数当彩票刮。"""
return f"email:tries:{email}"
def email_throttle_key(email: str) -> str:
return f"email:throttle:{email}"
def idempotency_key(user_id: str, key: str) -> str:
return f"idem:{user_id}:{key}"