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:
+75
-25
@@ -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<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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() {
|
||||
</div>
|
||||
|
||||
<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="手机号">
|
||||
<input
|
||||
id="phone"
|
||||
@@ -151,29 +186,44 @@ export function LoginPage() {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="验证码">
|
||||
<div className="flex gap-2">
|
||||
{mode === "code" ? (
|
||||
<Field label="验证码">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="code"
|
||||
className="input h-11 tracking-[0.3em]"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="6 位数字"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.trim())}
|
||||
maxLength={6}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-11 shrink-0 px-3.5"
|
||||
disabled={!phoneValid || busy || countdown > 0}
|
||||
onClick={sendCode}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s` : "获取验证码"}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="密码">
|
||||
<input
|
||||
id="code"
|
||||
className="input h-11 tracking-[0.3em]"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="6 位数字"
|
||||
value={code}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-11 shrink-0 px-3.5"
|
||||
disabled={!phoneValid || busy || countdown > 0}
|
||||
onClick={sendCode}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s` : "获取验证码"}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{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">
|
||||
@@ -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 ? "处理中…" : "登录"}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user