feat: add captcha and unique username login
This commit is contained in:
parent
de6fdb02c3
commit
c439ccb2d1
@ -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}
|
||||||
|
|||||||
@ -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]}")
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
7
services/api/migrations/006_usernames.sql
Normal file
7
services/api/migrations/006_usernames.sql
Normal 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);
|
||||||
Loading…
Reference in New Issue
Block a user