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
+130
View File
@@ -0,0 +1,130 @@
"""系统设置(SMTP):只有平台管理员能改,授权码不回显,改完立刻生效。
为什么值得单独测:这是全站唯一一个**跨租户**的写接口。租户隔离测试管不到它,
一旦漏了权限判断,任何登录用户都能改掉全平台发信账号(等于劫持找回密码邮件)。
"""
from __future__ import annotations
from httpx import AsyncClient
from app.config import get_settings
from app.services import mail
from .conftest import login, unwrap
settings = get_settings()
SMTP = {
"host": "smtp.126.com",
"port": 465,
"username": "shufenhf@126.com",
"password": "CH46ngqQ7Z2XE2eZ",
"from_name": "达道Task",
"use_ssl": True,
}
async def _admin(client: AsyncClient) -> dict:
"""白名单里的手机号登录,就是平台管理员。"""
user = await login(client, phone="13605513683")
return user
async def test_settings_require_platform_admin(client: AsyncClient, monkeypatch) -> None:
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
outsider = await login(client)
read = await client.get("/api/v1/settings/smtp", headers=outsider["headers"])
assert read.json()["code"] == 3004
write = await client.put("/api/v1/settings/smtp", json=SMTP, headers=outsider["headers"])
assert write.json()["code"] == 3004
async def test_platform_admin_flag_comes_back_in_me(client: AsyncClient, monkeypatch) -> None:
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
admin = await _admin(client)
outsider = await login(client)
me_admin = unwrap(await client.get("/api/v1/auth/me", headers=admin["headers"]))
me_outsider = unwrap(await client.get("/api/v1/auth/me", headers=outsider["headers"]))
assert me_admin["user"]["platform_admin"] is True
assert me_outsider["user"]["platform_admin"] is False
async def test_save_smtp_hides_password_and_persists(client: AsyncClient, monkeypatch) -> None:
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
admin = await _admin(client)
empty = unwrap(await client.get("/api/v1/settings/smtp", headers=admin["headers"]))
assert empty["configured"] is False
saved = unwrap(await client.put("/api/v1/settings/smtp", json=SMTP, headers=admin["headers"]))
assert saved["configured"] is True
assert saved["source"] == "db"
assert saved["host"] == "smtp.126.com"
assert saved["password_set"] is True
# 授权码一个字节都不能回给页面
assert "password" not in saved
assert "CH46ngqQ7Z2XE2eZ" not in str(saved)
again = unwrap(await client.get("/api/v1/settings/smtp", headers=admin["headers"]))
assert again["username"] == "shufenhf@126.com"
assert again["port"] == 465
async def test_saving_without_password_keeps_old_one(client: AsyncClient, monkeypatch) -> None:
"""授权码留空 = 沿用原来的。否则每次改端口都得重新粘贴一遍授权码。"""
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
admin = await _admin(client)
await client.put("/api/v1/settings/smtp", json=SMTP, headers=admin["headers"])
payload = {**SMTP, "port": 587, "use_ssl": False, "password": ""}
resp = await client.put("/api/v1/settings/smtp", json=payload, headers=admin["headers"])
saved = unwrap(resp)
assert saved["port"] == 587
assert saved["password_set"] is True
from app.db import SessionFactory
async with SessionFactory() as session:
cfg = await mail.load_config(session)
assert cfg is not None
assert cfg.password == "CH46ngqQ7Z2XE2eZ"
assert cfg.port == 587
async def test_test_mail_reports_missing_config(client: AsyncClient, monkeypatch) -> None:
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
admin = await _admin(client)
resp = await client.post(
"/api/v1/settings/smtp/test", json={"to": "steven@example.com"}, headers=admin["headers"]
)
assert resp.json()["code"] == 2020
async def test_test_mail_actually_sends(client: AsyncClient, monkeypatch) -> None:
"""真发信在这里被替换掉——测的是"取到配置、走对收件人",不是 SMTP 本身。"""
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
admin = await _admin(client)
await client.put("/api/v1/settings/smtp", json=SMTP, headers=admin["headers"])
sent: list[tuple] = []
def fake_send(cfg, to, subject, text, html): # noqa: ANN001 - 与真实签名一致
sent.append((cfg.host, cfg.username, to, subject, text))
monkeypatch.setattr(mail, "_send_sync", fake_send)
data = unwrap(
await client.post(
"/api/v1/settings/smtp/test",
json={"to": "steven@example.com"},
headers=admin["headers"],
)
)
assert data["sent"] is True
assert data["to"] == "steven@example.com"
assert sent and sent[0][0] == "smtp.126.com"
assert sent[0][1] == "shufenhf@126.com"