feat: require email verification for registration

This commit is contained in:
flym 2026-09-11 15:17:02 +08:00
parent b805887882
commit ef2cae59ad
5 changed files with 156 additions and 9 deletions

View File

@ -12,6 +12,8 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmation, setConfirmation] = useState("");
const [verificationId, setVerificationId] = useState("");
const [verificationCode, setVerificationCode] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
@ -44,8 +46,8 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
if (isLogin) {
window.location.assign("/account");
} else {
setMessage("注册成功,请使用新账户登录");
window.setTimeout(() => window.location.assign("/login"), 500);
setVerificationId(body.challenge_id ?? "");
setMessage("注册成功,验证码已发送到你的邮箱");
}
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "请求失败,请稍后重试");
@ -54,17 +56,56 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
}
}
async function confirmVerification(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError("");
setBusy(true);
try {
const response = await fetch(`${apiBase}/auth/verification/confirm-registration`, {
method: "POST", credentials: "include", headers: { "Content-Type": "application/json", ...(csrf ? { "X-CSRF-Token": csrf } : {}) },
body: JSON.stringify({ challenge_id: verificationId, code: verificationCode }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error?.message ?? "验证码错误,请重试");
setMessage("邮箱验证成功,请登录");
window.setTimeout(() => window.location.assign("/login"), 500);
} catch (confirmError) {
setError(confirmError instanceof Error ? confirmError.message : "验证失败,请重试");
} finally { setBusy(false); }
}
async function resendVerification() {
setError("");
setBusy(true);
try {
const response = await fetch(`${apiBase}/auth/verification/resend`, {
method: "POST", credentials: "include", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ challenge_id: verificationId }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error?.message ?? "验证码发送失败,请稍后重试");
setMessage("新的验证码已发送");
} catch (resendError) {
setError(resendError instanceof Error ? resendError.message : "验证码发送失败,请稍后重试");
} finally { setBusy(false); }
}
return (
<div className="w-full max-w-md rounded-2xl border border-line bg-panel/70 p-6 sm:p-8">
<PreviewNotice></PreviewNotice>
<form className="mt-8 space-y-5" onSubmit={submit}>
{verificationId ? <form className="mt-8 space-y-5" onSubmit={confirmVerification}>
<div><label className="field-label" htmlFor="verification-code"></label><input className="field-input" id="verification-code" inputMode="numeric" maxLength={6} onChange={(event) => setVerificationCode(event.target.value)} placeholder="输入 6 位验证码" required value={verificationCode} /></div>
{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}
<button className="button-primary w-full" disabled={busy || !csrf} type="submit">{busy ? "验证中…" : "验证邮箱"}</button>
<button className="w-full text-sm text-muted hover:text-copy" disabled={busy} onClick={resendVerification} type="button"></button>
</form> : <form className="mt-8 space-y-5" onSubmit={submit}>
<div><label className="field-label" htmlFor="email"></label><input className="field-input" id="email" onChange={(event) => setEmail(event.target.value)} placeholder="name@example.com" required type="email" value={email} /></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}
{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}
{message ? <p className="rounded-lg border border-success/30 bg-success/10 px-3 py-2 text-sm leading-6 text-success" role="status">{message}</p> : null}
<button className="button-primary w-full" disabled={busy || !csrf} type="submit">{busy ? "处理中…" : isLogin ? "登录" : "注册"}</button>
</form>
</form>}
<p className="mt-6 text-center text-sm text-muted">{isLogin ? "还没有账户?" : "已有账户?"}{" "}<Link className="text-cyan hover:text-copy" href={isLogin ? "/register" : "/login"}>{isLogin ? "去注册" : "去登录"}</Link></p>
</div>
);

View File

@ -5,7 +5,7 @@ SESSION_COOKIE_NAME=kaotings_session
SESSION_COOKIE_SECURE=false
SESSION_TTL_SECONDS=2592000
CSRF_SECRET=replace-with-a-random-secret
EMAIL_VERIFICATION_ENABLED=false
EMAIL_VERIFICATION_ENABLED=true
PHONE_VERIFICATION_ENABLED=false
TEST_FREE_PERIOD_LIMIT=10000
TEST_VIP_PERIOD_LIMIT=100000
@ -14,3 +14,8 @@ TTS_API_KEY=
TTS_TIMEOUT_SECONDS=120
AUDIO_STORAGE_DIR=/home/flym/kaotings-audio
AUDIO_RETENTION_SECONDS=604800
SMTP_HOST=smtp.exmail.qq.com
SMTP_PORT=465
SMTP_USER=tech@kaotings.com
SMTP_FROM=tech@kaotings.com
SMTP_PASSWORD=replace-with-secret

View File

@ -26,6 +26,11 @@ class Settings:
tts_timeout_seconds: int
audio_storage_dir: str
audio_retention_seconds: int
smtp_host: str
smtp_port: int
smtp_user: str
smtp_password: str
smtp_from: str
@classmethod
def from_env(cls) -> "Settings":
@ -50,6 +55,11 @@ class Settings:
tts_timeout_seconds=int(os.getenv("TTS_TIMEOUT_SECONDS", "120")),
audio_storage_dir=os.getenv("AUDIO_STORAGE_DIR", "./data/audio"),
audio_retention_seconds=int(os.getenv("AUDIO_RETENTION_SECONDS", "604800")),
smtp_host=os.getenv("SMTP_HOST", ""),
smtp_port=int(os.getenv("SMTP_PORT", "465")),
smtp_user=os.getenv("SMTP_USER", ""),
smtp_password=os.getenv("SMTP_PASSWORD", ""),
smtp_from=os.getenv("SMTP_FROM", os.getenv("SMTP_USER", "")),
)

View File

@ -1,9 +1,13 @@
import asyncio
import hashlib
import json
import secrets
import smtplib
import ssl
import time
import unicodedata
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from pathlib import Path
from typing import Any
from uuid import UUID
@ -25,10 +29,12 @@ from .schemas import (
PasswordChangeRequest,
QuotaAdjustmentRequest,
RegisterRequest,
RegisterResponse,
StatusRequest,
UserPublic,
VerificationConfirmRequest,
VerificationSendRequest,
VerificationResendRequest,
TtsTaskRequest,
TtsTaskPublic,
TtsVoicePublic,
@ -56,6 +62,28 @@ from .security import (
app = FastAPI(title="Kaotings Business API", version="0.2.0", docs_url=None if settings.app_env == "production" else "/docs")
def send_verification_email(target: str, code: str) -> None:
if not all((settings.smtp_host, settings.smtp_user, settings.smtp_password, settings.smtp_from)):
raise RuntimeError("SMTP_NOT_CONFIGURED")
message = EmailMessage()
message["From"] = settings.smtp_from
message["To"] = target
message["Subject"] = "考町科技邮箱验证码"
message.set_content(f"您好,您的考町科技邮箱验证码是:{code}\n\n验证码 10 分钟内有效。如非本人操作,请忽略此邮件。")
with smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port, context=ssl.create_default_context(), timeout=20) as smtp:
smtp.login(settings.smtp_user, settings.smtp_password)
smtp.send_message(message)
def create_email_challenge(connection: Connection, user_id: UUID, email: str) -> tuple[UUID, str]:
code = f"{secrets.randbelow(1_000_000):06d}"
row = connection.execute(
"INSERT INTO verification_challenges(user_id, channel, purpose, target, code_digest, expires_at) VALUES (%s, 'email', 'registration', %s, %s, now() + interval '10 minutes') RETURNING id",
(user_id, email, code_digest(code)),
).fetchone()
return row["id"], code
def error(code: str, message: str, http_status: int) -> HTTPException:
return HTTPException(status_code=http_status, detail={"code": code, "message": message})
@ -286,7 +314,7 @@ def csrf(response: Response, request: Request):
return {"csrf_token": token}
@app.post("/api/v1/auth/register", response_model=AuthResponse, 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)):
check_origin(request)
key = f"register:{request.client.host if request.client else 'unknown'}"
@ -302,11 +330,16 @@ def register(payload: RegisterRequest, request: Request, connection: Connection
(email, hash_password(payload.password)),
).fetchone()
ensure_quota(connection, user["id"], "free")
challenge_id, code = create_email_challenge(connection, user["id"], email)
connection.commit()
except psycopg.errors.UniqueViolation:
connection.rollback()
raise error("REGISTRATION_FAILED", "注册信息不可用", 409)
return {"user": user_public(connection, user)}
try:
send_verification_email(email, code)
except Exception:
raise error("VERIFICATION_EMAIL_FAILED", "验证码邮件发送失败,请稍后重试", 503)
return {"user": user_public(connection, user), "verification_required": True, "challenge_id": challenge_id}
@app.post("/api/v1/auth/login", response_model=AuthResponse)
@ -320,6 +353,8 @@ def login(payload: LoginRequest, request: Request, response: Response, connectio
raise error("LOGIN_FAILED", "邮箱或密码错误", 401)
if user["status"] != "active":
raise error("ACCOUNT_DISABLED", "账户不可用", 403)
if not user["email_verified"]:
raise error("EMAIL_NOT_VERIFIED", "请先完成邮箱验证", 403)
token = new_token()
connection.execute("INSERT INTO sessions(user_id, token_hash, expires_at) VALUES (%s, %s, %s)", (user["id"], token_digest(token), utc_now() + timedelta(seconds=settings.session_ttl_seconds)))
user = connection.execute("UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = %s RETURNING *", (user["id"],)).fetchone()
@ -362,12 +397,59 @@ def account_usage(user: dict = Depends(current_user), connection: Connection = D
@app.post("/api/v1/auth/verification/send")
def verification_send(payload: VerificationSendRequest, request: Request, user: dict = Depends(current_user)):
def verification_send(payload: VerificationSendRequest, request: Request, connection: Connection = Depends(get_connection), user: dict = Depends(current_user)):
check_origin(request)
enabled = settings.email_verification_enabled if payload.channel == "email" else settings.phone_verification_enabled
if not enabled:
raise error("VERIFICATION_NOT_ENABLED", "验证渠道尚未启用", 503)
raise error("VERIFICATION_PROVIDER_UNAVAILABLE", "验证渠道尚未配置", 503)
if payload.channel != "email" or payload.purpose != "registration":
raise error("VERIFICATION_PROVIDER_UNAVAILABLE", "该验证流程尚未配置", 503)
code = f"{secrets.randbelow(1_000_000):06d}"
challenge = user
row = connection.execute("SELECT id FROM verification_challenges WHERE user_id = %s AND channel = 'email' AND purpose = 'registration' AND consumed_at IS NULL ORDER BY created_at DESC LIMIT 1", (challenge["id"],)).fetchone()
if not row:
raise error("VERIFICATION_INVALID", "验证挑战无效", 400)
connection.execute("UPDATE verification_challenges SET code_digest = %s, expires_at = now() + interval '10 minutes', attempt_count = 0 WHERE id = %s", (code_digest(code), row["id"]))
connection.commit()
try:
send_verification_email(challenge["email"], code)
except Exception:
raise error("VERIFICATION_EMAIL_FAILED", "验证码邮件发送失败,请稍后重试", 503)
return {"challenge_id": row["id"], "status": "sent"}
@app.post("/api/v1/auth/verification/resend")
def verification_resend(payload: VerificationResendRequest, request: Request, connection: Connection = Depends(get_connection)):
check_origin(request)
challenge = connection.execute("SELECT * FROM verification_challenges WHERE id = %s AND channel = 'email' AND purpose = 'registration' AND consumed_at IS NULL", (payload.challenge_id,)).fetchone()
if not challenge:
raise error("VERIFICATION_INVALID", "验证挑战无效", 400)
if not rate_limiter.allow(f"verification:{challenge['target']}", 3, 900):
raise error("RATE_LIMITED", "验证码发送过于频繁", 429)
code = f"{secrets.randbelow(1_000_000):06d}"
connection.execute("UPDATE verification_challenges SET code_digest = %s, expires_at = now() + interval '10 minutes', attempt_count = 0 WHERE id = %s", (code_digest(code), payload.challenge_id))
connection.commit()
try:
send_verification_email(challenge["target"], code)
except Exception:
raise error("VERIFICATION_EMAIL_FAILED", "验证码邮件发送失败,请稍后重试", 503)
return {"challenge_id": payload.challenge_id, "status": "sent"}
@app.post("/api/v1/auth/verification/confirm-registration", dependencies=[Depends(require_csrf)])
def verification_confirm_registration(payload: VerificationConfirmRequest, request: Request, connection: Connection = Depends(get_connection)):
check_origin(request)
challenge = connection.execute("SELECT * FROM verification_challenges WHERE id = %s AND channel = 'email' AND purpose = 'registration'", (payload.challenge_id,)).fetchone()
if not challenge or challenge["consumed_at"] or challenge["expires_at"] <= utc_now() or challenge["attempt_count"] >= 5:
raise error("VERIFICATION_INVALID", "验证挑战无效或已过期", 400)
if not __import__("hmac").compare_digest(challenge["code_digest"], code_digest(payload.code)):
connection.execute("UPDATE verification_challenges SET attempt_count = attempt_count + 1 WHERE id = %s", (payload.challenge_id,))
connection.commit()
raise error("VERIFICATION_INVALID", "验证码错误", 400)
connection.execute("UPDATE verification_challenges SET consumed_at = now() WHERE id = %s", (payload.challenge_id,))
connection.execute("UPDATE users SET email_verified = true, updated_at = now() WHERE id = %s", (challenge["user_id"],))
connection.commit()
return {"status": "ok"}
@app.post("/api/v1/auth/verification/confirm", dependencies=[Depends(require_csrf)])

View File

@ -37,6 +37,11 @@ class AuthResponse(BaseModel):
user: UserPublic
class RegisterResponse(AuthResponse):
verification_required: bool
challenge_id: UUID
class MembershipRequest(BaseModel):
plan: Literal["free", "vip"]
starts_at: datetime | None = None
@ -86,6 +91,10 @@ class VerificationSendRequest(BaseModel):
purpose: Literal["registration", "contact_binding", "contact_change", "password_reset"]
class VerificationResendRequest(BaseModel):
challenge_id: UUID
class TtsTaskRequest(BaseModel):
text: str = Field(min_length=1, max_length=10000)
voice_id: str = Field(min_length=1, max_length=120)