CI / backend (push) Canceled after 0s
短信登录关掉后,生产没有任何注册入口——这一版补上最普通的那条路:
填邮箱、收验证码、设密码,注册完直接进首页。
先发码再注册,不是"注册时顺带发信"
POST /auth/email/code 只做三件事:查重、限流、发码。
填完密码才被告知"这个邮箱注册过了"最气人,所以占用(2019)在发码前就回。
邮箱既是账号名也是唯一联系方式,所以必须真验一次码
验证通过时间落 app_user.email_verified_at,/auth/me 回 email_verified。
"随手填一个"会让账号变成没人能找回的孤岛。
app_user.phone 改为可空(迁移 0011)
邮箱注册的账号本来就没有手机号。user.phone 对这类账号回空串,
成员列表同理,不再伪装成一串星号。
防刷
同邮箱 60 秒一次(2022)、验证码 10 分钟有效、错 5 次即作废(2021)。
6 位数可以被刮,不设上限等于把注册口子交给脚本。
本机联调开关 EMAIL_CODE_DEV_ECHO
为 true 时把码回给调用方且不发真信——否则每跑一次 e2e 就往真实收件箱塞一封。
生产打开会让 validate_runtime() 拒绝启动。
前端
新增 /register(邮箱、验证码、密码、确认、昵称;60 秒倒计时;
服务端关着注册时整页只说"联系管理员开通"),登录页按 /auth/methods
决定要不要显示「还没有账号?用邮箱注册」。
验证
pytest 139 passed、ruff check / format 全绿
tsc --noEmit 0、vite build 通过
e2e register.mjs 7 项:登录页入口 → 拿码 → 注册落进首页 → 退出用邮箱+密码登回来
→ 同一邮箱再发码回 2019 → 控制台零报错
e2e login-email-smtp.mjs 15 项回归通过(含真发一封测试信)
e2e account.mjs 14 项、knowledge.mjs 全绿
顺带修的两处测试
test_account 的字段白名单补 email_verified(接口确实多了这个字段);
test_config_prod 用 Settings(_env_file=None, ...) 构造,别被本机 .env 里的
EMAIL_CODE_DEV_ECHO=true 带偏——那个开关在生产是硬错误,本机却是跑 e2e 必需的。
191 lines
6.2 KiB
Python
191 lines
6.2 KiB
Python
"""账号功能:昵称、密码设置/修改、密码登录。
|
||
|
||
这些都是"账号被占"的高风险入口,所以断言不只看 200,还要看:
|
||
- 密码一字节都不回给客户端(只回 has_password)
|
||
- 没设过密码的人不会被密码登录误放进去
|
||
- 改完密码,旧密码立刻失效
|
||
- 连错太多次要先锁一会儿,别让登录接口变成离线爆破的放大镜
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from httpx import AsyncClient
|
||
|
||
from .conftest import auth, login, unwrap
|
||
|
||
|
||
async def _me(client: AsyncClient, user: dict) -> dict:
|
||
return unwrap(await client.get("/api/v1/auth/me", headers=user["headers"]))
|
||
|
||
|
||
async def test_me_reports_profile_without_leaking_password(client: AsyncClient) -> None:
|
||
user = await login(client)
|
||
data = await _me(client, user)
|
||
|
||
assert data["user"]["has_password"] is False
|
||
assert data["user"]["phone"].endswith(user["phone"][-4:])
|
||
assert "****" in data["user"]["phone"]
|
||
# 只回「设没设」,不回密码本身:字段白名单比黑名单可靠
|
||
assert set(data["user"]) == {
|
||
"id",
|
||
"phone",
|
||
"email",
|
||
"email_verified",
|
||
"nickname",
|
||
"avatar_url",
|
||
"has_password",
|
||
"platform_admin",
|
||
"created_at",
|
||
}
|
||
|
||
|
||
async def test_patch_nickname(client: AsyncClient) -> None:
|
||
user = await login(client)
|
||
data = unwrap(
|
||
await client.patch(
|
||
"/api/v1/auth/me", json={"nickname": " 老王 "}, headers=user["headers"]
|
||
)
|
||
)
|
||
assert data["user"]["nickname"] == "老王"
|
||
|
||
# 空昵称要被拦住,不能变成"无名氏"
|
||
resp = await client.patch("/api/v1/auth/me", json={"nickname": " "}, headers=user["headers"])
|
||
assert resp.json()["code"] == 1002
|
||
|
||
|
||
async def test_set_password_then_login_with_it(client: AsyncClient) -> None:
|
||
user = await login(client)
|
||
unwrap(
|
||
await client.post(
|
||
"/api/v1/auth/password",
|
||
json={"password": "dadao2026"},
|
||
headers=user["headers"],
|
||
)
|
||
)
|
||
assert (await _me(client, user))["user"]["has_password"] is True
|
||
|
||
# 验证码登录照旧可用,两条路并存
|
||
data = unwrap(
|
||
await client.post(
|
||
"/api/v1/auth/login/password",
|
||
json={"phone": user["phone"], "password": "dadao2026"},
|
||
)
|
||
)
|
||
assert data["tokens"]["access_token"]
|
||
assert data["user"]["id"] == user["user_id"]
|
||
|
||
|
||
async def test_password_login_rejects_wrong_password(client: AsyncClient) -> None:
|
||
user = await login(client)
|
||
unwrap(
|
||
await client.post(
|
||
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
|
||
)
|
||
)
|
||
|
||
resp = await client.post(
|
||
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "wrong-pass1"}
|
||
)
|
||
assert resp.status_code == 400
|
||
assert resp.json()["code"] == 2013
|
||
|
||
|
||
async def test_password_login_on_account_without_password(client: AsyncClient) -> None:
|
||
user = await login(client)
|
||
resp = await client.post(
|
||
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"}
|
||
)
|
||
# 没设过密码 = 密码不对,不能因为「没设置」就报 2014 把信息漏出去
|
||
assert resp.json()["code"] == 2013
|
||
|
||
|
||
async def test_unknown_phone_looks_the_same_as_wrong_password(client: AsyncClient) -> None:
|
||
user = await login(client)
|
||
unwrap(
|
||
await client.post(
|
||
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
|
||
)
|
||
)
|
||
resp = await client.post(
|
||
"/api/v1/auth/login/password", json={"phone": "13511112222", "password": "dadao2026"}
|
||
)
|
||
assert resp.json()["code"] == 2013
|
||
|
||
|
||
async def test_change_password_requires_old_one_and_invalidates_it(client: AsyncClient) -> None:
|
||
user = await login(client)
|
||
unwrap(
|
||
await client.post(
|
||
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
|
||
)
|
||
)
|
||
|
||
# 不给旧密码 → 拒绝
|
||
resp = await client.post(
|
||
"/api/v1/auth/password", json={"password": "dadao2027"}, headers=user["headers"]
|
||
)
|
||
assert resp.json()["code"] == 2013
|
||
|
||
# 旧密码给错 → 拒绝
|
||
resp = await client.post(
|
||
"/api/v1/auth/password",
|
||
json={"password": "dadao2027", "old_password": "nope-1234"},
|
||
headers=user["headers"],
|
||
)
|
||
assert resp.json()["code"] == 2013
|
||
|
||
unwrap(
|
||
await client.post(
|
||
"/api/v1/auth/password",
|
||
json={"password": "dadao2027", "old_password": "dadao2026"},
|
||
headers=user["headers"],
|
||
)
|
||
)
|
||
|
||
# 新密码能登,旧密码不能
|
||
unwrap(
|
||
await client.post(
|
||
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2027"}
|
||
)
|
||
)
|
||
resp = await client.post(
|
||
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"}
|
||
)
|
||
assert resp.json()["code"] == 2013
|
||
|
||
|
||
async def test_weak_password_rejected(client: AsyncClient) -> None:
|
||
user = await login(client)
|
||
for weak in ["short1", "12345678", "abcdefgh"]:
|
||
resp = await client.post(
|
||
"/api/v1/auth/password", json={"password": weak}, headers=user["headers"]
|
||
)
|
||
assert resp.json()["code"] == 2015, weak
|
||
|
||
|
||
async def test_password_endpoints_require_login(client: AsyncClient) -> None:
|
||
resp = await client.post("/api/v1/auth/password", json={"password": "dadao2026"})
|
||
assert resp.status_code == 401
|
||
resp = await client.patch("/api/v1/auth/me", json={"nickname": "x"}, headers=auth("nope"))
|
||
assert resp.status_code == 401
|
||
|
||
|
||
async def test_repeated_failures_are_throttled(client: AsyncClient) -> None:
|
||
user = await login(client)
|
||
unwrap(
|
||
await client.post(
|
||
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
|
||
)
|
||
)
|
||
|
||
for _ in range(10):
|
||
await client.post(
|
||
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "bad-pass1"}
|
||
)
|
||
|
||
resp = await client.post(
|
||
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"}
|
||
)
|
||
# 次数用光之后,连正确密码也要先等一会儿——锁的是这段时间,不是这一次请求
|
||
assert resp.json()["code"] == 2016
|