diff --git a/components/auth-form.tsx b/components/auth-form.tsx index 5d4e41b..b96e2dd 100644 --- a/components/auth-form.tsx +++ b/components/auth-form.tsx @@ -10,12 +10,16 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) { const isLogin = mode === "login"; const [csrf, setCsrf] = useState(""); const [email, setEmail] = useState(""); + const [username, setUsername] = useState(""); const [phone, setPhone] = useState(""); const [channel, setChannel] = useState<"email" | "phone">("email"); const [password, setPassword] = useState(""); const [confirmation, setConfirmation] = useState(""); const [verificationId, setVerificationId] = useState(""); const [verificationCode, setVerificationCode] = useState(""); + const [captchaId, setCaptchaId] = useState(""); + const [captchaImage, setCaptchaImage] = useState(""); + const [captchaCode, setCaptchaCode] = useState(""); const [message, setMessage] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); @@ -27,6 +31,16 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) { .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) { event.preventDefault(); setError(""); @@ -41,7 +55,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) { method: "POST", credentials: "include", 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(() => ({})); if (!response.ok) throw new Error(body.error?.message ?? "请求失败,请稍后重试"); @@ -53,6 +67,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) { } } catch (submitError) { setError(submitError instanceof Error ? submitError.message : "请求失败,请稍后重试"); + refreshCaptcha().catch(() => undefined); } finally { setBusy(false); } @@ -103,9 +118,12 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) { :
{isLogin ?
setEmail(event.target.value)} placeholder="name@example.com / 13800138000" required value={email} />
: <>
+
setUsername(event.target.value)} placeholder="3-32 位字母、数字、下划线或短横线" required value={username} />
setEmail(event.target.value)} placeholder="name@example.com" required={channel === "email"} type="email" value={email} />
setPhone(event.target.value)} placeholder="中国大陆手机号" required={channel === "phone"} type="tel" value={phone} />
} +
图形验证码
+ setCaptchaCode(event.target.value)} placeholder="输入图形验证码" required value={captchaCode} />
setPassword(event.target.value)} placeholder="至少 8 位字符" required type="password" value={password} />
{!isLogin ?
setConfirmation(event.target.value)} placeholder="再次输入密码" required type="password" value={confirmation} />
: null} {error ?

{error}

: null} diff --git a/services/api/app/init_admin.py b/services/api/app/init_admin.py index a708212..54582dc 100644 --- a/services/api/app/init_admin.py +++ b/services/api/app/init_admin.py @@ -20,11 +20,11 @@ def main() -> None: raise SystemExit("admin email already exists; refusing to overwrite") 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) RETURNING id """, - (email, hash_password(password)), + (f"admin_{email.split('@', 1)[0]}", email, hash_password(password)), ).fetchone() connection.commit() print(f"created admin {row[0]}") diff --git a/services/api/app/main.py b/services/api/app/main.py index 63de2af..eb58781 100644 --- a/services/api/app/main.py +++ b/services/api/app/main.py @@ -44,9 +44,11 @@ from .security import ( check_origin, clear_session_cookie, code_digest, + consume_captcha, current_user, hash_password, new_csrf_token, + new_captcha, new_token, password_hasher, rate_limiter, @@ -184,7 +186,7 @@ def effective_plan(connection: Connection, user: dict) -> str: def user_public(connection: Connection, user: dict) -> 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"], 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} +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'' for color in colors) + chars = "".join(f'{char}' for index, char in enumerate(answer)) + image = f'{lines}{chars}' + return {"captcha_id": captcha_id, "image": image} + + @app.post("/api/v1/auth/register", response_model=RegisterResponse, status_code=201) def register(payload: RegisterRequest, request: Request, connection: Connection = Depends(get_connection)): 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'}" if not rate_limiter.allow(key, 5, 3600): raise error("RATE_LIMITED", "请求过于频繁", 429) @@ -394,10 +413,10 @@ def register(payload: RegisterRequest, request: Request, connection: Connection try: 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 * """, - (email, phone, hash_password(payload.password)), + (payload.username.strip().lower(), email, phone, hash_password(payload.password)), ).fetchone() ensure_quota(connection, user["id"], "free") 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) def login(payload: LoginRequest, request: Request, response: Response, connection: Connection = Depends(get_connection)): 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() key = f"login:{request.client.host if request.client else 'unknown'}:{identifier}" if not rate_limiter.allow(key, 10, 300): 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"]): raise error("LOGIN_FAILED", "邮箱/手机号或密码错误", 401) if user["status"] != "active": diff --git a/services/api/app/schemas.py b/services/api/app/schemas.py index 19a57f8..cf4f7a2 100644 --- a/services/api/app/schemas.py +++ b/services/api/app/schemas.py @@ -6,9 +6,12 @@ from pydantic import BaseModel, EmailStr, Field, field_validator, model_validato class RegisterRequest(BaseModel): + username: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_\-]+$") email: EmailStr | None = None phone: str | None = Field(default=None, min_length=11, max_length=20) 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) @field_validator("phone") @@ -36,6 +39,8 @@ class RegisterRequest(BaseModel): class LoginRequest(BaseModel): 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) @@ -46,6 +51,7 @@ class PasswordChangeRequest(BaseModel): class UserPublic(BaseModel): id: UUID + username: str email: str | None phone: str | None role: Literal["user", "admin"] diff --git a/services/api/app/security.py b/services/api/app/security.py index 50d51f0..2110d6b 100644 --- a/services/api/app/security.py +++ b/services/api/app/security.py @@ -125,3 +125,32 @@ class 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 diff --git a/services/api/migrations/001_initial.sql b/services/api/migrations/001_initial.sql index efcc686..2c0c9dd 100644 --- a/services/api/migrations/001_initial.sql +++ b/services/api/migrations/001_initial.sql @@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations ( CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT UNIQUE, email TEXT UNIQUE, phone TEXT UNIQUE, password_hash TEXT NOT NULL, diff --git a/services/api/migrations/006_usernames.sql b/services/api/migrations/006_usernames.sql new file mode 100644 index 0000000..587c5c8 --- /dev/null +++ b/services/api/migrations/006_usernames.sql @@ -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);