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
+4 -1
View File
@@ -13,6 +13,9 @@ os.environ["ENV"] = "test"
os.environ.setdefault("SMS_DEV_CODE", "123456")
# 测试里关掉发码节流,否则"同一手机号再登录一次"要等 60 秒
os.environ.setdefault("SMS_THROTTLE_SECONDS", "0")
# 线上默认关掉了短信验证码登录(固定码等于谁都能进),但测试夹具 `login()` 靠它批量造用户。
# 这里显式打开;想验证"关掉之后回 2017"的用例自己 monkeypatch 一下 settings 即可。
os.environ.setdefault("SMS_LOGIN_ENABLED", "true")
# 测试库由 config 的 test_database_url / test_migration_database_url 决定(见 backend/.env),
# 这里不再改 os.environ —— 配置对象自己会读 .env。
@@ -36,7 +39,7 @@ from app.stores import reset_store_for_tests
settings = get_settings()
DEV_CODE = settings.sms_dev_code
_TABLES = "audit_log, invitation, member, workspace, app_user"
_TABLES = "system_setting, audit_log, invitation, member, workspace, app_user"
def _run_migrations() -> None: