feat(auth): 短信登录默认关,换成手机号/邮箱 + 密码,发信邮箱进后台
CI / backend (push) Canceled after 0s

用户要求:内测的固定验证码 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。
This commit is contained in:
Steven
2026-11-03 00:00:11 +08:00
parent 23687d6e37
commit 7558637567
29 changed files with 1702 additions and 93 deletions
+131
View File
@@ -0,0 +1,131 @@
"""登录方式的开关,以及用邮箱登录。
三条是这个功能的重点,也是测试的重点:
1. **短信验证码能整体关掉**。关掉后连"发码"这一步都不该通——否则等于留了个后门:
内置验证码仍然对得上,只是没人去点。
2. **邮箱是账号名,不是联系方式**:绑定之后能用它登录,改绑立刻生效、解绑立刻失效。
3. **邮箱唯一且大小写不敏感**:`Shufen@126.com` 和 `shufen@126.com` 是同一个人,
不能让第二个账号绑走。
"""
from __future__ import annotations
from httpx import AsyncClient
from app.config import get_settings
from .conftest import login, unwrap
settings = get_settings()
async def _set_password(client: AsyncClient, user: dict, password: str) -> None:
unwrap(
await client.post(
"/api/v1/auth/password", json={"password": password}, headers=user["headers"]
)
)
async def _bind_email(client: AsyncClient, user: dict, email: str) -> dict:
return unwrap(
await client.patch("/api/v1/auth/me", json={"email": email}, headers=user["headers"])
)
async def test_methods_lists_what_is_open(client: AsyncClient) -> None:
data = unwrap(await client.get("/api/v1/auth/methods"))
assert data["password"] is True
assert data["email_password"] is True
# 测试环境把短信开着(夹具要靠它造用户);线上默认是 false
assert data["sms_code"] is True
async def test_sms_login_can_be_closed(client: AsyncClient, monkeypatch) -> None:
monkeypatch.setattr(settings, "sms_login_enabled", False)
methods = unwrap(await client.get("/api/v1/auth/methods"))
assert methods["sms_code"] is False
sent = await client.post("/api/v1/auth/sms/send", json={"phone": "13800138000"})
assert sent.json()["code"] == 2017
# 就算手里还捏着旧验证码,登录这一步也必须关死
logged = await client.post(
"/api/v1/auth/login", json={"phone": "13800138000", "code": settings.sms_dev_code}
)
assert logged.json()["code"] == 2017
async def test_bind_email_and_login_with_it(client: AsyncClient) -> None:
user = await login(client)
await _set_password(client, user, "Dadao2026pw")
data = await _bind_email(client, user, " Shufen@126.com ")
# 存的是小写,避免大小写绕出第二个账号
assert data["user"]["email"] == "shufen@126.com"
logged = unwrap(
await client.post(
"/api/v1/auth/login/password",
json={"email": "SHUFEN@126.COM", "password": "Dadao2026pw"},
)
)
assert logged["user"]["id"] == user["user_id"]
async def test_email_login_rejects_wrong_password(client: AsyncClient) -> None:
user = await login(client)
await _set_password(client, user, "Dadao2026pw")
await _bind_email(client, user, "shufen@126.com")
resp = await client.post(
"/api/v1/auth/login/password", json={"email": "shufen@126.com", "password": "nope-nope-1"}
)
assert resp.json()["code"] == 2013
async def test_email_is_validated_and_unique(client: AsyncClient) -> None:
first = await login(client)
bad = await client.patch(
"/api/v1/auth/me", json={"email": "not-an-email"}, headers=first["headers"]
)
assert bad.json()["code"] == 2018
await _bind_email(client, first, "shufen@126.com")
second = await login(client)
taken = await client.patch(
"/api/v1/auth/me", json={"email": "SHUFEN@126.com"}, headers=second["headers"]
)
assert taken.json()["code"] == 2019
async def test_unbind_email_then_login_by_email_fails(client: AsyncClient) -> None:
user = await login(client)
await _set_password(client, user, "Dadao2026pw")
await _bind_email(client, user, "shufen@126.com")
cleared = await _bind_email(client, user, "")
assert cleared["user"]["email"] is None
resp = await client.post(
"/api/v1/auth/login/password",
json={"email": "shufen@126.com", "password": "Dadao2026pw"},
)
assert resp.json()["code"] == 2013
async def test_phone_login_still_works(client: AsyncClient) -> None:
"""老客户端/安卓只发 phone,这条路不能因为加了邮箱就断。"""
user = await login(client)
await _set_password(client, user, "Dadao2026pw")
logged = unwrap(
await client.post(
"/api/v1/auth/login/password",
json={"phone": user["phone"], "password": "Dadao2026pw"},
)
)
assert logged["user"]["id"] == user["user_id"]