Files
DadaoTask/backend/tests/test_login_email.py
T
Steven 7558637567
CI / backend (push) Canceled after 0s
feat(auth): 短信登录默认关,换成手机号/邮箱 + 密码,发信邮箱进后台
用户要求:内测的固定验证码 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。
2026-11-03 00:00:11 +08:00

132 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""登录方式的开关,以及用邮箱登录。
三条是这个功能的重点,也是测试的重点:
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"]