diff --git a/components/auth-form.tsx b/components/auth-form.tsx index cdf4027..ead3a54 100644 --- a/components/auth-form.tsx +++ b/components/auth-form.tsx @@ -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) { + 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 (
认证服务已接入生产环境。邮箱注册、登录和会话已启用,验证渠道暂未启用。 -
+ {verificationId ? +
setVerificationCode(event.target.value)} placeholder="输入 6 位验证码" required value={verificationCode} />
+ {error ?

{error}

: null} + + +
:
setEmail(event.target.value)} placeholder="name@example.com" required type="email" value={email} />
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} {message ?

{message}

: null} -
+ }

{isLogin ? "还没有账户?" : "已有账户?"}{" "}{isLogin ? "去注册" : "去登录"}

); diff --git a/services/api/.env.example b/services/api/.env.example index 117b4e5..fd6f364 100644 --- a/services/api/.env.example +++ b/services/api/.env.example @@ -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 diff --git a/services/api/app/config.py b/services/api/app/config.py index d9268e1..0f440bf 100644 --- a/services/api/app/config.py +++ b/services/api/app/config.py @@ -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", "")), ) diff --git a/services/api/app/main.py b/services/api/app/main.py index 3df86de..e075c72 100644 --- a/services/api/app/main.py +++ b/services/api/app/main.py @@ -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)]) diff --git a/services/api/app/schemas.py b/services/api/app/schemas.py index 0af5364..c9f8072 100644 --- a/services/api/app/schemas.py +++ b/services/api/app/schemas.py @@ -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)