用户要求:内测的固定验证码 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:
@@ -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:
|
||||
|
||||
@@ -29,9 +29,11 @@ async def test_me_reports_profile_without_leaking_password(client: AsyncClient)
|
||||
assert set(data["user"]) == {
|
||||
"id",
|
||||
"phone",
|
||||
"email",
|
||||
"nickname",
|
||||
"avatar_url",
|
||||
"has_password",
|
||||
"platform_admin",
|
||||
"created_at",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""生产配置校验:固定验证码必须显式开关,否则启动即失败。
|
||||
|
||||
这两条是「部署纪律」的守门员:生产一旦误用 dev 验证码,等于任意手机号可登录。
|
||||
现在的规则更简单——**短信验证码登录默认关着**,关着时 SMS_PROVIDER 是 dev 也无所谓,
|
||||
因为那条路根本不通;只有在 SMS_LOGIN_ENABLED=true 时才要求真通道。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,14 +26,18 @@ def _prod(**overrides: object) -> Settings:
|
||||
return Settings(**values)
|
||||
|
||||
|
||||
def test_prod_rejects_dev_sms_without_switch() -> None:
|
||||
with pytest.raises(RuntimeError, match="SMS_PROVIDER=dev"):
|
||||
_prod(sms_provider="dev").validate_runtime()
|
||||
def test_prod_rejects_dev_sms_when_login_enabled() -> None:
|
||||
with pytest.raises(RuntimeError, match="SMS_LOGIN_ENABLED"):
|
||||
_prod(sms_provider="dev", sms_login_enabled=True).validate_runtime()
|
||||
|
||||
|
||||
def test_prod_allows_dev_sms_with_internal_test_login() -> None:
|
||||
# 内测模式:显式开关后放行,启动时 main.lifespan 会打 WARNING
|
||||
_prod(sms_provider="dev", internal_test_login=True).validate_runtime()
|
||||
def test_prod_is_fine_with_sms_login_closed() -> None:
|
||||
# 默认就是这条:短信登录关着,provider 配成 dev 也不会有人能用它进来
|
||||
_prod(sms_provider="dev", sms_login_enabled=False).validate_runtime()
|
||||
|
||||
|
||||
def test_prod_allows_real_sms_provider_when_enabled() -> None:
|
||||
_prod(sms_provider="aliyun", sms_login_enabled=True).validate_runtime()
|
||||
|
||||
|
||||
def test_prod_still_rejects_weak_jwt_secret() -> None:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user