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
+217
View File
@@ -0,0 +1,217 @@
"""邮箱注册:发码 → 验码 → 建账号 → 直接登录。
手机验证码关掉以后这是唯一的新用户入口,所以三条都要真的走通:
① 发码真的走到 SMTP(不是"调了函数就算");② 验证码错了/过期了要报错;
③ 建出来的账号没有手机号也能正常用(`app_user.phone` 因此可空)。
本机联调用的 `EMAIL_CODE_DEV_ECHO` 只在 dev 生效,生产打开会被 validate_runtime 拒掉。
"""
from __future__ import annotations
from typing import Any
import pytest
from httpx import AsyncClient
from app.api import auth as auth_api
from app.errors import ErrorCode
from app.services import mail
from tests.conftest import unwrap
def _settings() -> Any:
"""settings 是模块级的缓存对象,monkeypatch 改它、用例结束自动还原。"""
return auth_api.settings
@pytest.fixture
async def sent(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
"""把真发信换成记账:记下收件人与验证码,不碰网络。"""
box: list[dict[str, Any]] = []
async def fake_send(session: Any, *, to: str, code: str, minutes: int) -> None:
box.append({"to": to, "code": code, "minutes": minutes})
monkeypatch.setattr(_settings(), "email_code_dev_echo", False)
monkeypatch.setattr(_settings(), "email_code_throttle_seconds", 0)
monkeypatch.setattr(mail, "send_verify_code", fake_send)
return box
async def _send(client: AsyncClient, email: str) -> dict[str, Any]:
return unwrap(await client.post("/api/v1/auth/email/code", json={"email": email}))
async def test_send_code_goes_through_smtp(client: AsyncClient, sent: list[dict[str, Any]]) -> None:
"""发码必须真的交给发信服务,并且邮箱统一小写。"""
data = await _send(client, " Newcomer@Example.COM ")
assert data["sent"] is True
assert data["expires_in"] > 0
# 不带 dev_code:真发信时把码回给客户端等于白验
assert "dev_code" not in data
assert sent == [{"to": "newcomer@example.com", "code": sent[0]["code"], "minutes": 10}]
assert len(sent[0]["code"]) == 6 and sent[0]["code"].isdigit()
async def test_dev_echo_returns_code_without_sending(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""本机联调:不回真信,把码回给调用方(生产会拒绝启动)。"""
called: list[str] = []
async def fake_send(session: Any, *, to: str, code: str, minutes: int) -> None:
called.append(to)
monkeypatch.setattr(_settings(), "email_code_dev_echo", True)
monkeypatch.setattr(_settings(), "email_code_throttle_seconds", 0)
monkeypatch.setattr(mail, "send_verify_code", fake_send)
data = await _send(client, "dev@example.com")
assert data["sent"] is True
assert len(data["dev_code"]) == 6
assert called == []
async def test_register_creates_account_and_logs_in(
client: AsyncClient, sent: list[dict[str, Any]]
) -> None:
"""注册完就是登录态:能拿到令牌,/auth/me 也认。"""
email = "fresh@example.com"
await _send(client, email)
code = sent[-1]["code"]
data = unwrap(
await client.post(
"/api/v1/auth/register",
json={"email": " FRESH@example.com ", "code": code, "password": "dadao2026"},
)
)
assert data["user"]["email"] == email
assert data["user"]["email_verified"] is True
assert data["user"]["has_password"] is True
# 邮箱注册没有手机号:脱敏字段留空,别拼出一串星号
assert data["user"]["phone"] == ""
# 没填昵称就取 @ 前面那段,别让新账号顶着一个空名字
assert data["user"]["nickname"] == "fresh"
headers = {"Authorization": f"Bearer {data['tokens']['access_token']}"}
me = unwrap(await client.get("/api/v1/auth/me", headers=headers))
assert me["user"]["id"] == data["user"]["id"]
# 验证码用过就作废,不能拿同一个码再注册一次
replay = await client.post(
"/api/v1/auth/register",
json={"email": email, "code": code, "password": "dadao2026"},
)
assert replay.json()["code"] == ErrorCode.EMAIL_CODE_INVALID
# 注册完可以用「邮箱 + 密码」登录
login = unwrap(
await client.post(
"/api/v1/auth/login/password",
json={"email": email, "password": "dadao2026"},
)
)
assert login["user"]["id"] == data["user"]["id"]
async def test_register_rejects_wrong_or_missing_code(
client: AsyncClient, sent: list[dict[str, Any]]
) -> None:
email = "oops@example.com"
await _send(client, email)
wrong = await client.post(
"/api/v1/auth/register",
json={"email": email, "code": "000000", "password": "dadao2026"},
)
assert wrong.json()["code"] == ErrorCode.EMAIL_CODE_INVALID
# 压根没发过码:同样"过期",不告诉对方"这个邮箱存不存在"
never = await client.post(
"/api/v1/auth/register",
json={"email": "never@example.com", "code": "123456", "password": "dadao2026"},
)
assert never.json()["code"] == ErrorCode.EMAIL_CODE_INVALID
async def test_register_code_burns_out_after_too_many_tries(
client: AsyncClient, sent: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch
) -> None:
"""6 位数可以被刮,所以错够次数就把码作废。"""
monkeypatch.setattr(_settings(), "email_code_max_attempts", 2)
email = "guess@example.com"
await _send(client, email)
good = sent[-1]["code"]
wrong = "000000" if good != "000000" else "111111"
for _ in range(2):
resp = await client.post(
"/api/v1/auth/register",
json={"email": email, "code": wrong, "password": "dadao2026"},
)
assert resp.json()["code"] == ErrorCode.EMAIL_CODE_INVALID
burned = await client.post(
"/api/v1/auth/register",
json={"email": email, "code": good, "password": "dadao2026"},
)
assert burned.json()["code"] == ErrorCode.EMAIL_CODE_INVALID
assert "重新获取" in burned.json()["message"]
async def test_send_code_refuses_registered_email(
client: AsyncClient, sent: list[dict[str, Any]]
) -> None:
email = "taken@example.com"
await _send(client, email)
unwrap(
await client.post(
"/api/v1/auth/register",
json={"email": email, "code": sent[-1]["code"], "password": "dadao2026"},
)
)
again = await client.post("/api/v1/auth/email/code", json={"email": email})
assert again.json()["code"] == ErrorCode.EMAIL_TAKEN
async def test_send_code_throttles(client: AsyncClient, sent: list[dict[str, Any]]) -> None:
_settings().email_code_throttle_seconds = 60
try:
await _send(client, "throttle@example.com")
again = await client.post("/api/v1/auth/email/code", json={"email": "throttle@example.com"})
assert again.json()["code"] == ErrorCode.EMAIL_CODE_TOO_FREQUENT
finally:
_settings().email_code_throttle_seconds = 0
async def test_register_validates_password_and_email(
client: AsyncClient, sent: list[dict[str, Any]]
) -> None:
await _send(client, "weak@example.com")
weak = await client.post(
"/api/v1/auth/register",
json={"email": "weak@example.com", "code": sent[-1]["code"], "password": "short"},
)
assert weak.json()["code"] == ErrorCode.PASSWORD_WEAK
bad = await client.post("/api/v1/auth/email/code", json={"email": "not-an-email"})
assert bad.json()["code"] == ErrorCode.EMAIL_INVALID
async def test_registration_can_be_closed(
client: AsyncClient, sent: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(_settings(), "email_register_enabled", False)
assert (await client.post("/api/v1/auth/email/code", json={"email": "x@example.com"})).json()[
"code"
] == ErrorCode.LOGIN_METHOD_DISABLED
assert (
await client.post(
"/api/v1/auth/register",
json={"email": "x@example.com", "code": "123456", "password": "dadao2026"},
)
).json()["code"] == ErrorCode.LOGIN_METHOD_DISABLED
# /auth/methods 也要如实告诉前端这条路关着
methods = unwrap(await client.get("/api/v1/auth/methods"))
assert methods["email_register"] is False