feat(auth): 手机号也能用密码登录,验证码之外多一条路
短信通道挂了人就进不来,所以给账号加第二把自助的钥匙:设过密码的用密码进, 没设过的照旧只用验证码,不强制。 - app_user.password_hash(迁移 0008 加列)+ scrypt 哈希(标准库实现, n=2^14/r=8/p=1,存 scrypt$n$r$p$salt$hash),不引 passlib - POST /auth/login/password:没设过密码与密码错都回 2013,不区分账号是否存在; 15 分钟内连错 10 次 → 2016,期间正确密码也挡住 - POST /auth/password:设/改共用,改要先验旧密码,新旧相同算弱密码 2015 - PATCH /auth/me 改昵称;用户输出走白名单 _user_out,密码哈希一个字节都不出去 - 登录页顶部「验证码 / 密码」两段切换 tests/test_account.py 10 项;全量 pytest 113 passed,ruff 全绿。
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"""账号功能:昵称、密码设置/修改、密码登录。
|
||||
|
||||
这些都是"账号被占"的高风险入口,所以断言不只看 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",
|
||||
"nickname",
|
||||
"avatar_url",
|
||||
"has_password",
|
||||
"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
|
||||
Reference in New Issue
Block a user