feat: require email verification for registration
This commit is contained in:
parent
b805887882
commit
ef2cae59ad
@ -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>
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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", "")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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)])
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user