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:
Steven
2026-10-31 04:04:55 +08:00
parent 29aae12224
commit a5510cdb79
9 changed files with 544 additions and 56 deletions
+34
View File
@@ -29,6 +29,10 @@
| 2010 | 429 | 验证码请求太频繁 | 倒计时 60 秒 | | 2010 | 429 | 验证码请求太频繁 | 倒计时 60 秒 |
| 2011 | 400 | 验证码错误/已过期 | 重发 | | 2011 | 400 | 验证码错误/已过期 | 重发 |
| 2012 | 403 | 账号被禁用 | 联系管理员 | | 2012 | 403 | 账号被禁用 | 联系管理员 |
| 2013 | 400 | 密码错误,或这个号还没设过密码 | 提示改用验证码登录 |
| 2014 | 400 | 该账号没有密码(保留码) | 先设密码 |
| 2015 | 422 | 密码太弱(少于 8 位 / 缺字母或数字 / 与旧密码相同) | 就地提示规则 |
| 2016 | 429 | 密码连错太多次(15 分钟内 10 次) | 提示稍后再试或用验证码 |
| 3001 | 404 | 工作区不存在 | — | | 3001 | 404 | 工作区不存在 | — |
| 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" | | 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" |
| 3004 | 403 | 角色权限不足 | 隐藏对应按钮 | | 3004 | 403 | 角色权限不足 | 隐藏对应按钮 |
@@ -91,9 +95,39 @@
返回同形状的 `tokens`。无状态 JWT,**服务端不维护黑名单**——token 泄露只能等过期,这是 M0 已知取舍。 返回同形状的 `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` ### `GET /auth/me`
返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。 返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。
`user` 里带 `has_password`,前端据此决定账号页显示"设置密码"还是"修改密码"。
--- ---
@@ -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")
+131 -30
View File
@@ -1,4 +1,8 @@
"""认证:手机号 + 验证码。M0 不做密码、不做第三方登录。""" """认证:手机号 + 验证码,可选登录密码。
M0 只有验证码登录;密码是**可选**的第二条路:用户自己在账号设置里设一次,
之后登录页可以选「密码」;没设过密码的人只有验证码这一条,不受影响。
"""
from __future__ import annotations from __future__ import annotations
@@ -14,7 +18,13 @@ from ..deps import CurrentUser, SessionDep
from ..errors import AppError, ErrorCode from ..errors import AppError, ErrorCode
from ..models import AppUser, Member, Workspace from ..models import AppUser, Member, Workspace
from ..schemas import ok 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 from ..stores import get_store, sms_code_key, sms_throttle_key
router = APIRouter(prefix="/auth", tags=["认证"]) router = APIRouter(prefix="/auth", tags=["认证"])
@@ -22,11 +32,36 @@ settings = get_settings()
PHONE_PATTERN = r"^1[3-9]\d{9}$" PHONE_PATTERN = r"^1[3-9]\d{9}$"
# 密码登录连错这么多次就先锁一会儿:暴力猜密码的成本必须比猜验证码高
PASSWORD_FAIL_LIMIT = 10
PASSWORD_FAIL_TTL = 900
def mask_phone(phone: str) -> str: def mask_phone(phone: str) -> str:
return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***" 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): class SendCodeIn(BaseModel):
phone: str = Field(pattern=PHONE_PATTERN, examples=["13800138000"]) phone: str = Field(pattern=PHONE_PATTERN, examples=["13800138000"])
@@ -36,6 +71,22 @@ class LoginIn(BaseModel):
code: str = Field(min_length=4, max_length=8) 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): class RefreshIn(BaseModel):
refresh_token: str = Field(min_length=10) refresh_token: str = Field(min_length=10)
@@ -81,40 +132,54 @@ async def login(body: LoginIn, session: SessionDep) -> dict:
await session.flush() await session.flush()
tokens = create_token_pair(user.id) tokens = create_token_pair(user.id)
return ok( return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
{
"user": {
"id": str(user.id), @router.post("/login/password", summary="密码登录")
"phone": mask_phone(user.phone), async def login_password(body: PasswordLoginIn, session: SessionDep) -> dict:
"nickname": user.nickname, """手机号 + 密码登录。
"created_at": user.created_at,
}, 两个细节:
"tokens": { - **不区分"没这个人"和"密码错"**,同一个错误码,别让登录接口变成手机号探测工具。
"access_token": tokens.access_token, - 连错 PASSWORD_FAIL_LIMIT 次锁 PASSWORD_FAIL_TTL 秒;成功即清零,正常用户不会感知。
"refresh_token": tokens.refresh_token, """
"token_type": "bearer", store = get_store()
"expires_in": tokens.expires_in, 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="刷新访问令牌") @router.post("/refresh", summary="刷新访问令牌")
async def refresh(body: RefreshIn) -> dict: async def refresh(body: RefreshIn) -> dict:
user_id = decode_token(body.refresh_token, "refresh") user_id = decode_token(body.refresh_token, "refresh")
tokens = create_token_pair(user_id) tokens = create_token_pair(user_id)
return ok( # 这里的形状保持扁平(不带 tokens 信封),老客户端与安卓端都按这个取
{ return ok(_tokens_out(tokens))
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"token_type": "bearer",
"expires_in": tokens.expires_in,
}
)
@router.get("/me", summary="当前用户与所属工作区") @router.get("/me", summary="当前用户与所属工作区")
async def me(user: CurrentUser, session: SessionDep) -> dict: async def me(user: CurrentUser, session: SessionDep) -> dict:
row = await session.get(AppUser, user.id)
if row is None:
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
rows = ( rows = (
await session.execute( await session.execute(
select(Workspace, Member.role) select(Workspace, Member.role)
@@ -126,11 +191,7 @@ async def me(user: CurrentUser, session: SessionDep) -> dict:
return ok( return ok(
{ {
"user": { "user": _user_out(row),
"id": str(user.id),
"phone": mask_phone(user.phone),
"nickname": user.nickname,
},
"workspaces": [ "workspaces": [
{ {
"id": str(ws.id), "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})
+8
View File
@@ -25,6 +25,10 @@ class ErrorCode(IntEnum):
SMS_CODE_INVALID = 2010 SMS_CODE_INVALID = 2010
SMS_TOO_FREQUENT = 2011 SMS_TOO_FREQUENT = 2011
ACCOUNT_DISABLED = 2012 ACCOUNT_DISABLED = 2012
PASSWORD_INVALID = 2013
PASSWORD_NOT_SET = 2014
PASSWORD_WEAK = 2015
LOGIN_TOO_MANY = 2016
# 3xxx 租户 / 权限 # 3xxx 租户 / 权限
WORKSPACE_NOT_FOUND = 3001 WORKSPACE_NOT_FOUND = 3001
@@ -86,6 +90,10 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.SMS_CODE_INVALID: 400, ErrorCode.SMS_CODE_INVALID: 400,
ErrorCode.SMS_TOO_FREQUENT: 429, ErrorCode.SMS_TOO_FREQUENT: 429,
ErrorCode.ACCOUNT_DISABLED: 403, 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.WORKSPACE_NOT_FOUND: 404,
ErrorCode.NOT_A_MEMBER: 403, ErrorCode.NOT_A_MEMBER: 403,
ErrorCode.INSUFFICIENT_ROLE: 403, ErrorCode.INSUFFICIENT_ROLE: 403,
+2
View File
@@ -45,6 +45,8 @@ class AppUser(Base, Timestamps):
nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="") nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="")
avatar_url: Mapped[str | None] = mapped_column(String(512)) avatar_url: Mapped[str | None] = mapped_column(String(512))
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") 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): class Workspace(Base, Timestamps):
+63
View File
@@ -8,6 +8,8 @@
from __future__ import annotations from __future__ import annotations
import base64
import hashlib
import hmac import hmac
import secrets import secrets
import time import time
@@ -78,3 +80,64 @@ def new_invite_code() -> str:
def invite_code_matches(provided: str, expected: str) -> bool: def invite_code_matches(provided: str, expected: str) -> bool:
return hmac.compare_digest(provided.strip().upper(), expected) 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
+187
View File
@@ -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
+3
View File
@@ -2,6 +2,9 @@ export interface User {
id: string; id: string;
phone: string; phone: string;
nickname: string; nickname: string;
avatar_url?: string | null;
/** 设没设登录密码;密码本身永远不会回到前端 */
has_password?: boolean;
created_at?: string; created_at?: string;
} }
+54 -4
View File
@@ -37,6 +37,8 @@ export function LoginPage() {
const next = safeNext(params.get("next")); const next = safeNext(params.get("next"));
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [mode, setMode] = useState<"code" | "password">("code");
const [devCode, setDevCode] = useState<string | null>(null); const [devCode, setDevCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -69,7 +71,13 @@ export function LoginPage() {
setError(null); setError(null);
setBusy(true); setBusy(true);
try { 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); session.save(data.tokens);
await reload(); await reload();
navigate(next ?? "/", { replace: true }); navigate(next ?? "/", { replace: true });
@@ -135,9 +143,36 @@ export function LoginPage() {
</div> </div>
<h2 className="text-2xl font-semibold">登录</h2> <h2 className="text-2xl font-semibold">登录</h2>
<p className="mt-1.5 text-sm text-ink-500">手机号验证码登录,第一次登录会自动创建账号。</p> <p className="mt-1.5 text-sm text-ink-500">
{mode === "code"
? "手机号验证码登录,第一次登录会自动创建账号。"
: "用手机号和登录密码进入。没设过密码的,先用验证码登录一次再设。"}
</p>
<form className="mt-7 space-y-4" onSubmit={submit}> <div className="mt-5 inline-flex rounded-lg bg-paper-200/60 p-0.5 text-xs">
{(
[
["code", "验证码"],
["password", "密码"],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => {
setMode(key);
setError(null);
}}
className={`rounded-[6px] px-3 py-1.5 font-medium transition ${
mode === key ? "bg-white text-ink-900 shadow-sm" : "text-ink-500 hover:text-ink-700"
}`}
>
{label}
</button>
))}
</div>
<form className="mt-5 space-y-4" onSubmit={submit}>
<Field label="手机号"> <Field label="手机号">
<input <input
id="phone" id="phone"
@@ -151,6 +186,7 @@ export function LoginPage() {
/> />
</Field> </Field>
{mode === "code" ? (
<Field label="验证码"> <Field label="验证码">
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
@@ -174,6 +210,20 @@ export function LoginPage() {
</Button> </Button>
</div> </div>
</Field> </Field>
) : (
<Field label="密码">
<input
id="password"
type="password"
className="input h-11"
autoComplete="current-password"
placeholder="你的登录密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
maxLength={64}
/>
</Field>
)}
{devCode && ( {devCode && (
<div className="flex items-start gap-2 rounded-lg border border-clay-500/20 bg-clay-50 px-3 py-2 text-xs text-clay-700"> <div className="flex items-start gap-2 rounded-lg border border-clay-500/20 bg-clay-50 px-3 py-2 text-xs text-clay-700">
@@ -188,7 +238,7 @@ export function LoginPage() {
variant="primary" variant="primary"
size="lg" size="lg"
className="mt-2 w-full" className="mt-2 w-full"
disabled={busy || !phoneValid || code.length < 4} disabled={busy || !phoneValid || (mode === "code" ? code.length < 4 : password.length < 1)}
> >
{busy ? "处理中…" : "登录"} {busy ? "处理中…" : "登录"}
</Button> </Button>