diff --git a/2.Design/API.md b/2.Design/API.md index 486b36d..e94350c 100644 --- a/2.Design/API.md +++ b/2.Design/API.md @@ -29,6 +29,10 @@ | 2010 | 429 | 验证码请求太频繁 | 倒计时 60 秒 | | 2011 | 400 | 验证码错误/已过期 | 重发 | | 2012 | 403 | 账号被禁用 | 联系管理员 | +| 2013 | 400 | 密码错误,或这个号还没设过密码 | 提示改用验证码登录 | +| 2014 | 400 | 该账号没有密码(保留码) | 先设密码 | +| 2015 | 422 | 密码太弱(少于 8 位 / 缺字母或数字 / 与旧密码相同) | 就地提示规则 | +| 2016 | 429 | 密码连错太多次(15 分钟内 10 次) | 提示稍后再试或用验证码 | | 3001 | 404 | 工作区不存在 | — | | 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" | | 3004 | 403 | 角色权限不足 | 隐藏对应按钮 | @@ -91,9 +95,39 @@ 返回同形状的 `tokens`。无状态 JWT,**服务端不维护黑名单**——token 泄露只能等过期,这是 M0 已知取舍。 +### `POST /auth/login/password` + +```json +{ "phone": "13800000000", "password": "dadao2026" } +``` + +返回与验证码登录同形状的 `{ user, tokens }`。**没设过密码的账号一律回 `2013`**,与"密码错"同一个码 +(不区分账号是否存在,少一个探测面)。15 分钟内连错 10 次 → `2016`,期间正确密码也挡住。 + +密码校验是 scrypt(`n=2^14, r=8, p=1`,标准库实现,不引 passlib),库里存 +`scrypt$n$r$p$salt$hash`。`password_hash` 这一列**永不出现在任何响应里**。 + +### `POST /auth/password` + +```json +{ "password": "dadao2026", "old_password": "old-one-1" } +``` + +设密码 / 改密码共用。已经有密码时 `old_password` 必填且必须正确;新旧密码相同 → `2015`。 +强度:8–64 位,至少一个字母 + 一个数字。返回 `{ "has_password": true }`。 + +### `PATCH /auth/me` + +```json +{ "nickname": "大道" } +``` + +返回 `{ "user": { … } }`。空昵称(strip 后为空)→ `1002`。手机号暂不支持自助换号。 + ### `GET /auth/me` 返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。 +`user` 里带 `has_password`,前端据此决定账号页显示"设置密码"还是"修改密码"。 --- diff --git a/backend/alembic/versions/0008_user_password.py b/backend/alembic/versions/0008_user_password.py new file mode 100644 index 0000000..f8f06ae --- /dev/null +++ b/backend/alembic/versions/0008_user_password.py @@ -0,0 +1,40 @@ +"""登录密码(M1 账号功能) + +Revision ID: 0008 +Revises: 0007 +Create Date: 2026-10-31 + +M0 只有手机号 + 验证码登录。验证码登录在换手机、短信通道欠费、 +或者只想快一点的时候都不够用,所以给账号加一条**可选**的密码登录路径。 + +两个取舍: + +1. **密码可空**。老用户不去设置就一直走验证码,不会被强制改流程; + 登录页两种方式并存,谁设过谁自己知道。 +2. **只存 scrypt 串**(见 `app/security.py`),不存明文也不存 md5。 + 密码本身永远不回给客户端,接口只返回 `has_password` 这个布尔。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0008" +down_revision: str = "0007" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + """ + ALTER TABLE app_user + ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255) + """ + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS password_hash") diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index ba93351..a176e35 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,4 +1,8 @@ -"""认证:手机号 + 验证码。M0 不做密码、不做第三方登录。""" +"""认证:手机号 + 验证码,可选登录密码。 + +M0 只有验证码登录;密码是**可选**的第二条路:用户自己在账号设置里设一次, +之后登录页可以选「密码」;没设过密码的人只有验证码这一条,不受影响。 +""" from __future__ import annotations @@ -14,7 +18,13 @@ from ..deps import CurrentUser, SessionDep from ..errors import AppError, ErrorCode from ..models import AppUser, Member, Workspace from ..schemas import ok -from ..security import create_token_pair, decode_token +from ..security import ( + create_token_pair, + decode_token, + hash_password, + password_problem, + verify_password, +) from ..stores import get_store, sms_code_key, sms_throttle_key router = APIRouter(prefix="/auth", tags=["认证"]) @@ -22,11 +32,36 @@ settings = get_settings() PHONE_PATTERN = r"^1[3-9]\d{9}$" +# 密码登录连错这么多次就先锁一会儿:暴力猜密码的成本必须比猜验证码高 +PASSWORD_FAIL_LIMIT = 10 +PASSWORD_FAIL_TTL = 900 + def mask_phone(phone: str) -> str: return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***" +def _user_out(user: AppUser) -> dict: + """对外只给脱敏手机号;密码本身一个字节都不出去,只告诉客户端「设没设」。""" + return { + "id": str(user.id), + "phone": mask_phone(user.phone), + "nickname": user.nickname, + "avatar_url": user.avatar_url, + "has_password": bool(user.password_hash), + "created_at": user.created_at, + } + + +def _tokens_out(tokens) -> dict: + return { + "access_token": tokens.access_token, + "refresh_token": tokens.refresh_token, + "token_type": "bearer", + "expires_in": tokens.expires_in, + } + + class SendCodeIn(BaseModel): phone: str = Field(pattern=PHONE_PATTERN, examples=["13800138000"]) @@ -36,6 +71,22 @@ class LoginIn(BaseModel): code: str = Field(min_length=4, max_length=8) +class PasswordLoginIn(BaseModel): + phone: str = Field(pattern=PHONE_PATTERN) + password: str = Field(min_length=1, max_length=64) + + +class ProfilePatch(BaseModel): + nickname: str | None = Field(default=None, min_length=1, max_length=32) + + +class PasswordIn(BaseModel): + """old_password 只在「已经设过密码」时必填;第一次设密码不需要。""" + + password: str = Field(min_length=1, max_length=64) + old_password: str | None = Field(default=None, max_length=64) + + class RefreshIn(BaseModel): refresh_token: str = Field(min_length=10) @@ -81,40 +132,54 @@ async def login(body: LoginIn, session: SessionDep) -> dict: await session.flush() tokens = create_token_pair(user.id) - return ok( - { - "user": { - "id": str(user.id), - "phone": mask_phone(user.phone), - "nickname": user.nickname, - "created_at": user.created_at, - }, - "tokens": { - "access_token": tokens.access_token, - "refresh_token": tokens.refresh_token, - "token_type": "bearer", - "expires_in": tokens.expires_in, - }, - } - ) + return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)}) + + +@router.post("/login/password", summary="密码登录") +async def login_password(body: PasswordLoginIn, session: SessionDep) -> dict: + """手机号 + 密码登录。 + + 两个细节: + - **不区分"没这个人"和"密码错"**,同一个错误码,别让登录接口变成手机号探测工具。 + - 连错 PASSWORD_FAIL_LIMIT 次锁 PASSWORD_FAIL_TTL 秒;成功即清零,正常用户不会感知。 + """ + store = get_store() + fail_key = f"pwd_fail:{body.phone}" + if int(await store.get(fail_key) or 0) >= PASSWORD_FAIL_LIMIT: + raise AppError( + ErrorCode.LOGIN_TOO_MANY, + f"密码错误次数过多,请 {PASSWORD_FAIL_TTL // 60} 分钟后再试,或用验证码登录", + ) + + user = ( + await session.execute(select(AppUser).where(AppUser.phone == body.phone)) + ).scalar_one_or_none() + if user is None or not verify_password(body.password, user.password_hash): + await store.incr(fail_key, PASSWORD_FAIL_TTL) + raise AppError(ErrorCode.PASSWORD_INVALID, "手机号或密码不对") + + if user.status != "active": + raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用") + + await store.delete(fail_key) + tokens = create_token_pair(user.id) + return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)}) @router.post("/refresh", summary="刷新访问令牌") async def refresh(body: RefreshIn) -> dict: user_id = decode_token(body.refresh_token, "refresh") tokens = create_token_pair(user_id) - return ok( - { - "access_token": tokens.access_token, - "refresh_token": tokens.refresh_token, - "token_type": "bearer", - "expires_in": tokens.expires_in, - } - ) + # 这里的形状保持扁平(不带 tokens 信封),老客户端与安卓端都按这个取 + return ok(_tokens_out(tokens)) @router.get("/me", summary="当前用户与所属工作区") async def me(user: CurrentUser, session: SessionDep) -> dict: + row = await session.get(AppUser, user.id) + if row is None: + raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在") + rows = ( await session.execute( select(Workspace, Member.role) @@ -126,11 +191,7 @@ async def me(user: CurrentUser, session: SessionDep) -> dict: return ok( { - "user": { - "id": str(user.id), - "phone": mask_phone(user.phone), - "nickname": user.nickname, - }, + "user": _user_out(row), "workspaces": [ { "id": str(ws.id), @@ -144,3 +205,43 @@ async def me(user: CurrentUser, session: SessionDep) -> dict: ], } ) + + +@router.patch("/me", summary="改自己的昵称") +async def update_me(body: ProfilePatch, user: CurrentUser, session: SessionDep) -> dict: + row = await session.get(AppUser, user.id) + if row is None: + raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在") + + if body.nickname is not None: + nickname = body.nickname.strip() + if not nickname: + raise AppError(ErrorCode.VALIDATION_FAILED, "昵称不能为空") + row.nickname = nickname + + await session.flush() + return ok({"user": _user_out(row)}) + + +@router.post("/password", summary="设置或修改登录密码") +async def set_password(body: PasswordIn, user: CurrentUser, session: SessionDep) -> dict: + """已经设过密码的,必须先给旧密码——否则拿到一个没锁屏的浏览器就能把密码换掉。""" + row = await session.get(AppUser, user.id) + if row is None: + raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在") + + if row.password_hash: + if not body.old_password: + raise AppError(ErrorCode.PASSWORD_INVALID, "请输入当前密码") + if not verify_password(body.old_password, row.password_hash): + raise AppError(ErrorCode.PASSWORD_INVALID, "当前密码不对") + if hmac.compare_digest(body.old_password, body.password): + raise AppError(ErrorCode.PASSWORD_WEAK, "新密码不能和当前密码一样") + + problem = password_problem(body.password) + if problem: + raise AppError(ErrorCode.PASSWORD_WEAK, problem) + + row.password_hash = hash_password(body.password) + await session.flush() + return ok({"has_password": True}) diff --git a/backend/app/errors.py b/backend/app/errors.py index 8b349c9..4b320fd 100644 --- a/backend/app/errors.py +++ b/backend/app/errors.py @@ -25,6 +25,10 @@ class ErrorCode(IntEnum): SMS_CODE_INVALID = 2010 SMS_TOO_FREQUENT = 2011 ACCOUNT_DISABLED = 2012 + PASSWORD_INVALID = 2013 + PASSWORD_NOT_SET = 2014 + PASSWORD_WEAK = 2015 + LOGIN_TOO_MANY = 2016 # 3xxx 租户 / 权限 WORKSPACE_NOT_FOUND = 3001 @@ -86,6 +90,10 @@ _HTTP_STATUS: dict[ErrorCode, int] = { ErrorCode.SMS_CODE_INVALID: 400, ErrorCode.SMS_TOO_FREQUENT: 429, ErrorCode.ACCOUNT_DISABLED: 403, + ErrorCode.PASSWORD_INVALID: 400, + ErrorCode.PASSWORD_NOT_SET: 400, + ErrorCode.PASSWORD_WEAK: 422, + ErrorCode.LOGIN_TOO_MANY: 429, ErrorCode.WORKSPACE_NOT_FOUND: 404, ErrorCode.NOT_A_MEMBER: 403, ErrorCode.INSUFFICIENT_ROLE: 403, diff --git a/backend/app/models.py b/backend/app/models.py index 30a0a4c..0906a54 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -45,6 +45,8 @@ class AppUser(Base, Timestamps): nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="") avatar_url: Mapped[str | None] = mapped_column(String(512)) status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") + # 登录密码(scrypt 串,见 security.hash_password)。为空表示只用验证码登录。 + password_hash: Mapped[str | None] = mapped_column(String(255)) class Workspace(Base, Timestamps): diff --git a/backend/app/security.py b/backend/app/security.py index c469bca..c0834d3 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -8,6 +8,8 @@ from __future__ import annotations +import base64 +import hashlib import hmac import secrets import time @@ -78,3 +80,64 @@ def new_invite_code() -> str: def invite_code_matches(provided: str, expected: str) -> bool: return hmac.compare_digest(provided.strip().upper(), expected) + + +# —— 登录密码 —— +# 用标准库 scrypt,不引入 passlib/bcrypt:少一个依赖,也就少一处装不上的风险。 +# 存储格式 `scrypt$n$r$p$salt_b64$hash_b64`,参数写进串里,将来调强度不用改代码。 +_SCRYPT_N = 2**14 +_SCRYPT_R = 8 +_SCRYPT_P = 1 +_PASSWORD_MIN = 8 +_PASSWORD_MAX = 64 + + +def hash_password(password: str) -> str: + salt = secrets.token_bytes(16) + digest = hashlib.scrypt( + password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=32 + ) + return "$".join( + [ + "scrypt", + str(_SCRYPT_N), + str(_SCRYPT_R), + str(_SCRYPT_P), + base64.b64encode(salt).decode(), + base64.b64encode(digest).decode(), + ] + ) + + +def verify_password(password: str, stored: str | None) -> bool: + """stored 为空(没设过密码)一律返回 False,别把"没密码"当成"密码对"。""" + if not stored: + return False + try: + scheme, n, r, p, salt_b64, hash_b64 = stored.split("$") + if scheme != "scrypt": + return False + digest = hashlib.scrypt( + password.encode("utf-8"), + salt=base64.b64decode(salt_b64), + n=int(n), + r=int(r), + p=int(p), + dklen=len(base64.b64decode(hash_b64)), + ) + except (ValueError, TypeError): + return False + return hmac.compare_digest(digest, base64.b64decode(hash_b64)) + + +def password_problem(password: str) -> str | None: + """返回不合格的原因;合格返回 None。规则写在一处,设置与修改都用它。""" + if len(password) < _PASSWORD_MIN: + return f"密码至少 {_PASSWORD_MIN} 位" + if len(password) > _PASSWORD_MAX: + return f"密码最多 {_PASSWORD_MAX} 位" + has_letter = any(c.isalpha() for c in password) + has_digit = any(c.isdigit() for c in password) + if not (has_letter and has_digit): + return "密码要同时有字母和数字" + return None diff --git a/backend/tests/test_account.py b/backend/tests/test_account.py new file mode 100644 index 0000000..4878401 --- /dev/null +++ b/backend/tests/test_account.py @@ -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 diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 6ed7e02..4e13ab6 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -2,6 +2,9 @@ export interface User { id: string; phone: string; nickname: string; + avatar_url?: string | null; + /** 设没设登录密码;密码本身永远不会回到前端 */ + has_password?: boolean; created_at?: string; } diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 639f444..2587937 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -37,6 +37,8 @@ export function LoginPage() { const next = safeNext(params.get("next")); const [phone, setPhone] = useState(""); const [code, setCode] = useState(""); + const [password, setPassword] = useState(""); + const [mode, setMode] = useState<"code" | "password">("code"); const [devCode, setDevCode] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -69,7 +71,13 @@ export function LoginPage() { setError(null); setBusy(true); try { - const data = await api.post<{ user: User; tokens: Tokens }>("/auth/login", { phone, code }); + const data = + mode === "code" + ? await api.post<{ user: User; tokens: Tokens }>("/auth/login", { phone, code }) + : await api.post<{ user: User; tokens: Tokens }>("/auth/login/password", { + phone, + password, + }); session.save(data.tokens); await reload(); navigate(next ?? "/", { replace: true }); @@ -135,9 +143,36 @@ export function LoginPage() {

登录

-

手机号验证码登录,第一次登录会自动创建账号。

+

+ {mode === "code" + ? "手机号验证码登录,第一次登录会自动创建账号。" + : "用手机号和登录密码进入。没设过密码的,先用验证码登录一次再设。"} +

-
+
+ {( + [ + ["code", "验证码"], + ["password", "密码"], + ] as const + ).map(([key, label]) => ( + + ))} +
+ + - -
+ {mode === "code" ? ( + +
+ setCode(e.target.value.trim())} + maxLength={6} + /> + +
+
+ ) : ( + setCode(e.target.value.trim())} - maxLength={6} + id="password" + type="password" + className="input h-11" + autoComplete="current-password" + placeholder="你的登录密码" + value={password} + onChange={(e) => setPassword(e.target.value)} + maxLength={64} /> - -
-
+ + )} {devCode && (
@@ -188,7 +238,7 @@ export function LoginPage() { variant="primary" size="lg" className="mt-2 w-full" - disabled={busy || !phoneValid || code.length < 4} + disabled={busy || !phoneValid || (mode === "code" ? code.length < 4 : password.length < 1)} > {busy ? "处理中…" : "登录"}