feat: add captcha and unique username login

This commit is contained in:
flym 2026-09-11 16:25:32 +08:00
parent de6fdb02c3
commit c439ccb2d1
7 changed files with 89 additions and 7 deletions

View File

@ -10,12 +10,16 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
const isLogin = mode === "login"; const isLogin = mode === "login";
const [csrf, setCsrf] = useState(""); const [csrf, setCsrf] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [channel, setChannel] = useState<"email" | "phone">("email"); const [channel, setChannel] = useState<"email" | "phone">("email");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmation, setConfirmation] = useState(""); const [confirmation, setConfirmation] = useState("");
const [verificationId, setVerificationId] = useState(""); const [verificationId, setVerificationId] = useState("");
const [verificationCode, setVerificationCode] = useState(""); const [verificationCode, setVerificationCode] = useState("");
const [captchaId, setCaptchaId] = useState("");
const [captchaImage, setCaptchaImage] = useState("");
const [captchaCode, setCaptchaCode] = useState("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -27,6 +31,16 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
.catch(() => setError("暂时无法连接认证服务")); .catch(() => setError("暂时无法连接认证服务"));
}, []); }, []);
async function refreshCaptcha() {
setCaptchaCode("");
const response = await fetch(`${apiBase}/auth/captcha`, { credentials: "include" });
const body = await response.json();
setCaptchaId(body.captcha_id ?? "");
setCaptchaImage(body.image ?? "");
}
useEffect(() => { refreshCaptcha().catch(() => setError("暂时无法加载图形验证码")); }, []);
async function submit(event: FormEvent<HTMLFormElement>) { async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setError(""); setError("");
@ -41,7 +55,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json", ...(csrf ? { "X-CSRF-Token": csrf } : {}) }, headers: { "Content-Type": "application/json", ...(csrf ? { "X-CSRF-Token": csrf } : {}) },
body: JSON.stringify(isLogin ? { identifier: email, password } : { email: channel === "email" ? email : email || null, phone: phone || null, verification_channel: channel, password }), body: JSON.stringify(isLogin ? { identifier: email, password, captcha_id: captchaId, captcha_code: captchaCode } : { username, email: channel === "email" ? email : email || null, phone: phone || null, verification_channel: channel, password, captcha_id: captchaId, captcha_code: captchaCode }),
}); });
const body = await response.json().catch(() => ({})); const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error?.message ?? "请求失败,请稍后重试"); if (!response.ok) throw new Error(body.error?.message ?? "请求失败,请稍后重试");
@ -53,6 +67,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
} }
} catch (submitError) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "请求失败,请稍后重试"); setError(submitError instanceof Error ? submitError.message : "请求失败,请稍后重试");
refreshCaptcha().catch(() => undefined);
} finally { } finally {
setBusy(false); setBusy(false);
} }
@ -103,9 +118,12 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
</form> : <form className="mt-8 space-y-5" onSubmit={submit}> </form> : <form className="mt-8 space-y-5" onSubmit={submit}>
{isLogin ? <div><label className="field-label" htmlFor="email"></label><input className="field-input" id="email" onChange={(event) => setEmail(event.target.value)} placeholder="name@example.com / 13800138000" required value={email} /></div> : <> {isLogin ? <div><label className="field-label" htmlFor="email"></label><input className="field-input" id="email" onChange={(event) => setEmail(event.target.value)} placeholder="name@example.com / 13800138000" required value={email} /></div> : <>
<div className="grid grid-cols-2 gap-2 rounded-lg border border-line p-1"><button className={`rounded-md px-3 py-2 text-sm ${channel === "email" ? "bg-cyan text-slate-950" : "text-muted"}`} onClick={() => setChannel("email")} type="button"></button><button className={`rounded-md px-3 py-2 text-sm ${channel === "phone" ? "bg-cyan text-slate-950" : "text-muted"}`} onClick={() => setChannel("phone")} type="button"></button></div> <div className="grid grid-cols-2 gap-2 rounded-lg border border-line p-1"><button className={`rounded-md px-3 py-2 text-sm ${channel === "email" ? "bg-cyan text-slate-950" : "text-muted"}`} onClick={() => setChannel("email")} type="button"></button><button className={`rounded-md px-3 py-2 text-sm ${channel === "phone" ? "bg-cyan text-slate-950" : "text-muted"}`} onClick={() => setChannel("phone")} type="button"></button></div>
<div><label className="field-label" htmlFor="username"></label><input className="field-input" id="username" onChange={(event) => setUsername(event.target.value)} placeholder="3-32 位字母、数字、下划线或短横线" required value={username} /></div>
<div><label className="field-label" htmlFor="email">{channel === "email" ? "(必填)" : "(可选)"}</label><input className="field-input" id="email" onChange={(event) => setEmail(event.target.value)} placeholder="name@example.com" required={channel === "email"} type="email" value={email} /></div> <div><label className="field-label" htmlFor="email">{channel === "email" ? "(必填)" : "(可选)"}</label><input className="field-input" id="email" onChange={(event) => setEmail(event.target.value)} placeholder="name@example.com" required={channel === "email"} type="email" value={email} /></div>
<div><label className="field-label" htmlFor="phone">{channel === "phone" ? "(必填)" : "(可选)"}</label><input className="field-input" id="phone" onChange={(event) => setPhone(event.target.value)} placeholder="中国大陆手机号" required={channel === "phone"} type="tel" value={phone} /></div> <div><label className="field-label" htmlFor="phone">{channel === "phone" ? "(必填)" : "(可选)"}</label><input className="field-input" id="phone" onChange={(event) => setPhone(event.target.value)} placeholder="中国大陆手机号" required={channel === "phone"} type="tel" value={phone} /></div>
</>} </>}
<div className="flex items-center gap-3"><img alt="图形验证码" className="h-16 w-[170px] rounded-lg border border-line bg-slate-950" src={captchaImage ? `data:image/svg+xml,${encodeURIComponent(captchaImage)}` : undefined} /><button className="text-sm text-muted hover:text-copy" onClick={refreshCaptcha} type="button"></button></div>
<input className="field-input" onChange={(event) => setCaptchaCode(event.target.value)} placeholder="输入图形验证码" required value={captchaCode} />
<div><label className="field-label" htmlFor="password"></label><input className="field-input" id="password" minLength={8} onChange={(event) => setPassword(event.target.value)} placeholder="至少 8 位字符" required type="password" value={password} /></div> <div><label className="field-label" htmlFor="password"></label><input className="field-input" id="password" minLength={8} onChange={(event) => setPassword(event.target.value)} placeholder="至少 8 位字符" required type="password" value={password} /></div>
{!isLogin ? <div><label className="field-label" htmlFor="password-confirm"></label><input className="field-input" id="password-confirm" minLength={8} onChange={(event) => setConfirmation(event.target.value)} placeholder="再次输入密码" required type="password" value={confirmation} /></div> : null} {!isLogin ? <div><label className="field-label" htmlFor="password-confirm"></label><input className="field-input" id="password-confirm" minLength={8} onChange={(event) => setConfirmation(event.target.value)} placeholder="再次输入密码" required type="password" value={confirmation} /></div> : null}
{error ? <p className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm leading-6 text-danger" role="alert">{error}</p> : null} {error ? <p className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm leading-6 text-danger" role="alert">{error}</p> : null}

View File

@ -20,11 +20,11 @@ def main() -> None:
raise SystemExit("admin email already exists; refusing to overwrite") raise SystemExit("admin email already exists; refusing to overwrite")
row = connection.execute( row = connection.execute(
""" """
INSERT INTO users(email, password_hash, role, plan, email_verified) INSERT INTO users(username, email, password_hash, role, plan, email_verified)
VALUES (%s, %s, 'admin', 'free', true) VALUES (%s, %s, 'admin', 'free', true)
RETURNING id RETURNING id
""", """,
(email, hash_password(password)), (f"admin_{email.split('@', 1)[0]}", email, hash_password(password)),
).fetchone() ).fetchone()
connection.commit() connection.commit()
print(f"created admin {row[0]}") print(f"created admin {row[0]}")

View File

@ -44,9 +44,11 @@ from .security import (
check_origin, check_origin,
clear_session_cookie, clear_session_cookie,
code_digest, code_digest,
consume_captcha,
current_user, current_user,
hash_password, hash_password,
new_csrf_token, new_csrf_token,
new_captcha,
new_token, new_token,
password_hasher, password_hasher,
rate_limiter, rate_limiter,
@ -184,7 +186,7 @@ def effective_plan(connection: Connection, user: dict) -> str:
def user_public(connection: Connection, user: dict) -> UserPublic: def user_public(connection: Connection, user: dict) -> UserPublic:
return UserPublic( return UserPublic(
id=user["id"], email=user["email"], phone=user["phone"], role=user["role"], plan=effective_plan(connection, user), id=user["id"], username=user["username"], email=user["email"], phone=user["phone"], role=user["role"], plan=effective_plan(connection, user),
status=user["status"], email_verified=user["email_verified"], phone_verified=user["phone_verified"], status=user["status"], email_verified=user["email_verified"], phone_verified=user["phone_verified"],
created_at=user["created_at"], last_login_at=user["last_login_at"], created_at=user["created_at"], last_login_at=user["last_login_at"],
) )
@ -383,9 +385,26 @@ def csrf(response: Response, request: Request):
return {"csrf_token": token} return {"csrf_token": token}
def captcha_client_key(request: Request) -> str:
return request.client.host if request.client else "unknown"
@app.get("/api/v1/auth/captcha")
def captcha(request: Request):
check_origin(request)
captcha_id, answer = new_captcha(captcha_client_key(request))
colors = ["#58d7f2", "#f59e8b", "#a78bfa"]
lines = "".join(f'<path d="M0 {secrets.randbelow(70) + 10} Q 90 {secrets.randbelow(70) + 10} 180 {secrets.randbelow(70) + 10}" stroke="{color}" stroke-width="1.5" opacity=".6" fill="none"/>' for color in colors)
chars = "".join(f'<text x="{22 + index * 29}" y="45" transform="rotate({secrets.randbelow(25) - 12} {22 + index * 29} 45)" fill="{colors[index % len(colors)]}">{char}</text>' for index, char in enumerate(answer))
image = f'<svg xmlns="http://www.w3.org/2000/svg" width="170" height="64" viewBox="0 0 170 64"><rect width="170" height="64" rx="10" fill="#111827"/>{lines}{chars}</svg>'
return {"captcha_id": captcha_id, "image": image}
@app.post("/api/v1/auth/register", response_model=RegisterResponse, status_code=201) @app.post("/api/v1/auth/register", response_model=RegisterResponse, status_code=201)
def register(payload: RegisterRequest, request: Request, connection: Connection = Depends(get_connection)): def register(payload: RegisterRequest, request: Request, connection: Connection = Depends(get_connection)):
check_origin(request) check_origin(request)
if not consume_captcha(payload.captcha_id, payload.captcha_code, captcha_client_key(request)):
raise error("CAPTCHA_INVALID", "图形验证码错误或已过期", 400)
key = f"register:{request.client.host if request.client else 'unknown'}" key = f"register:{request.client.host if request.client else 'unknown'}"
if not rate_limiter.allow(key, 5, 3600): if not rate_limiter.allow(key, 5, 3600):
raise error("RATE_LIMITED", "请求过于频繁", 429) raise error("RATE_LIMITED", "请求过于频繁", 429)
@ -394,10 +413,10 @@ def register(payload: RegisterRequest, request: Request, connection: Connection
try: try:
user = connection.execute( user = connection.execute(
""" """
INSERT INTO users(email, phone, password_hash) VALUES (%s, %s, %s) INSERT INTO users(username, email, phone, password_hash) VALUES (%s, %s, %s, %s)
RETURNING * RETURNING *
""", """,
(email, phone, hash_password(payload.password)), (payload.username.strip().lower(), email, phone, hash_password(payload.password)),
).fetchone() ).fetchone()
ensure_quota(connection, user["id"], "free") ensure_quota(connection, user["id"], "free")
if payload.verification_channel == "email": if payload.verification_channel == "email":
@ -424,11 +443,13 @@ def register(payload: RegisterRequest, request: Request, connection: Connection
@app.post("/api/v1/auth/login", response_model=AuthResponse) @app.post("/api/v1/auth/login", response_model=AuthResponse)
def login(payload: LoginRequest, request: Request, response: Response, connection: Connection = Depends(get_connection)): def login(payload: LoginRequest, request: Request, response: Response, connection: Connection = Depends(get_connection)):
check_origin(request) check_origin(request)
if not consume_captcha(payload.captcha_id, payload.captcha_code, captcha_client_key(request)):
raise error("CAPTCHA_INVALID", "图形验证码错误或已过期", 400)
identifier = payload.identifier.strip().lower() identifier = payload.identifier.strip().lower()
key = f"login:{request.client.host if request.client else 'unknown'}:{identifier}" key = f"login:{request.client.host if request.client else 'unknown'}:{identifier}"
if not rate_limiter.allow(key, 10, 300): if not rate_limiter.allow(key, 10, 300):
raise error("RATE_LIMITED", "请求过于频繁", 429) raise error("RATE_LIMITED", "请求过于频繁", 429)
user = connection.execute("SELECT * FROM users WHERE lower(email) = %s OR phone = %s", (identifier, identifier)).fetchone() user = connection.execute("SELECT * FROM users WHERE lower(email) = %s OR username = %s OR phone = %s", (identifier, identifier, identifier)).fetchone()
if not user or not verify_password(payload.password, user["password_hash"]): if not user or not verify_password(payload.password, user["password_hash"]):
raise error("LOGIN_FAILED", "邮箱/手机号或密码错误", 401) raise error("LOGIN_FAILED", "邮箱/手机号或密码错误", 401)
if user["status"] != "active": if user["status"] != "active":

View File

@ -6,9 +6,12 @@ from pydantic import BaseModel, EmailStr, Field, field_validator, model_validato
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
username: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_\-]+$")
email: EmailStr | None = None email: EmailStr | None = None
phone: str | None = Field(default=None, min_length=11, max_length=20) phone: str | None = Field(default=None, min_length=11, max_length=20)
verification_channel: Literal["email", "phone"] verification_channel: Literal["email", "phone"]
captcha_id: str = Field(min_length=10, max_length=100)
captcha_code: str = Field(min_length=4, max_length=8)
password: str = Field(min_length=8, max_length=128) password: str = Field(min_length=8, max_length=128)
@field_validator("phone") @field_validator("phone")
@ -36,6 +39,8 @@ class RegisterRequest(BaseModel):
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
identifier: str = Field(min_length=3, max_length=120) identifier: str = Field(min_length=3, max_length=120)
captcha_id: str = Field(min_length=10, max_length=100)
captcha_code: str = Field(min_length=4, max_length=8)
password: str = Field(min_length=1, max_length=128) password: str = Field(min_length=1, max_length=128)
@ -46,6 +51,7 @@ class PasswordChangeRequest(BaseModel):
class UserPublic(BaseModel): class UserPublic(BaseModel):
id: UUID id: UUID
username: str
email: str | None email: str | None
phone: str | None phone: str | None
role: Literal["user", "admin"] role: Literal["user", "admin"]

View File

@ -125,3 +125,32 @@ class RateLimiter:
rate_limiter = RateLimiter() rate_limiter = RateLimiter()
_captcha_lock = threading.Lock()
_captchas: dict[str, dict[str, object]] = {}
_captcha_alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
def new_captcha(client_key: str) -> tuple[str, str]:
captcha_id = secrets.token_urlsafe(18)
answer = "".join(secrets.choice(_captcha_alphabet) for _ in range(5))
now = time.monotonic()
with _captcha_lock:
for key, item in list(_captchas.items()):
if float(item["expires_at"]) <= now:
_captchas.pop(key, None)
_captchas[captcha_id] = {"answer": answer, "client_key": client_key, "expires_at": now + 300, "attempts": 0}
return captcha_id, answer
def consume_captcha(captcha_id: str, answer: str, client_key: str) -> bool:
with _captcha_lock:
item = _captchas.get(captcha_id)
if not item or float(item["expires_at"]) <= time.monotonic() or item["client_key"] != client_key:
return False
item["attempts"] = int(item["attempts"]) + 1
valid = hmac.compare_digest(str(item["answer"]), answer.strip().upper())
if valid or int(item["attempts"]) >= 5:
_captchas.pop(captcha_id, None)
return valid

View File

@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT UNIQUE,
email TEXT UNIQUE, email TEXT UNIQUE,
phone TEXT UNIQUE, phone TEXT UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,

View File

@ -0,0 +1,7 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT;
UPDATE users
SET username = 'user_' || replace(left(id::text, 18), '-', '')
WHERE username IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS users_username_unique_idx ON users(username);