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 [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirmation, setConfirmation] = useState("");
|
const [confirmation, setConfirmation] = useState("");
|
||||||
|
const [verificationId, setVerificationId] = useState("");
|
||||||
|
const [verificationCode, setVerificationCode] = 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);
|
||||||
@ -44,8 +46,8 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
|
|||||||
if (isLogin) {
|
if (isLogin) {
|
||||||
window.location.assign("/account");
|
window.location.assign("/account");
|
||||||
} else {
|
} else {
|
||||||
setMessage("注册成功,请使用新账户登录");
|
setVerificationId(body.challenge_id ?? "");
|
||||||
window.setTimeout(() => window.location.assign("/login"), 500);
|
setMessage("注册成功,验证码已发送到你的邮箱");
|
||||||
}
|
}
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError(submitError instanceof Error ? submitError.message : "请求失败,请稍后重试");
|
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 (
|
return (
|
||||||
<div className="w-full max-w-md rounded-2xl border border-line bg-panel/70 p-6 sm:p-8">
|
<div className="w-full max-w-md rounded-2xl border border-line bg-panel/70 p-6 sm:p-8">
|
||||||
<PreviewNotice>认证服务已接入生产环境。邮箱注册、登录和会话已启用,验证渠道暂未启用。</PreviewNotice>
|
<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="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>
|
<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}
|
||||||
{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}
|
{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>
|
<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>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,7 +5,7 @@ SESSION_COOKIE_NAME=kaotings_session
|
|||||||
SESSION_COOKIE_SECURE=false
|
SESSION_COOKIE_SECURE=false
|
||||||
SESSION_TTL_SECONDS=2592000
|
SESSION_TTL_SECONDS=2592000
|
||||||
CSRF_SECRET=replace-with-a-random-secret
|
CSRF_SECRET=replace-with-a-random-secret
|
||||||
EMAIL_VERIFICATION_ENABLED=false
|
EMAIL_VERIFICATION_ENABLED=true
|
||||||
PHONE_VERIFICATION_ENABLED=false
|
PHONE_VERIFICATION_ENABLED=false
|
||||||
TEST_FREE_PERIOD_LIMIT=10000
|
TEST_FREE_PERIOD_LIMIT=10000
|
||||||
TEST_VIP_PERIOD_LIMIT=100000
|
TEST_VIP_PERIOD_LIMIT=100000
|
||||||
@ -14,3 +14,8 @@ TTS_API_KEY=
|
|||||||
TTS_TIMEOUT_SECONDS=120
|
TTS_TIMEOUT_SECONDS=120
|
||||||
AUDIO_STORAGE_DIR=/home/flym/kaotings-audio
|
AUDIO_STORAGE_DIR=/home/flym/kaotings-audio
|
||||||
AUDIO_RETENTION_SECONDS=604800
|
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
|
tts_timeout_seconds: int
|
||||||
audio_storage_dir: str
|
audio_storage_dir: str
|
||||||
audio_retention_seconds: int
|
audio_retention_seconds: int
|
||||||
|
smtp_host: str
|
||||||
|
smtp_port: int
|
||||||
|
smtp_user: str
|
||||||
|
smtp_password: str
|
||||||
|
smtp_from: str
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> "Settings":
|
def from_env(cls) -> "Settings":
|
||||||
@ -50,6 +55,11 @@ class Settings:
|
|||||||
tts_timeout_seconds=int(os.getenv("TTS_TIMEOUT_SECONDS", "120")),
|
tts_timeout_seconds=int(os.getenv("TTS_TIMEOUT_SECONDS", "120")),
|
||||||
audio_storage_dir=os.getenv("AUDIO_STORAGE_DIR", "./data/audio"),
|
audio_storage_dir=os.getenv("AUDIO_STORAGE_DIR", "./data/audio"),
|
||||||
audio_retention_seconds=int(os.getenv("AUDIO_RETENTION_SECONDS", "604800")),
|
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 asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import secrets
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
import time
|
import time
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from email.message import EmailMessage
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@ -25,10 +29,12 @@ from .schemas import (
|
|||||||
PasswordChangeRequest,
|
PasswordChangeRequest,
|
||||||
QuotaAdjustmentRequest,
|
QuotaAdjustmentRequest,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
|
RegisterResponse,
|
||||||
StatusRequest,
|
StatusRequest,
|
||||||
UserPublic,
|
UserPublic,
|
||||||
VerificationConfirmRequest,
|
VerificationConfirmRequest,
|
||||||
VerificationSendRequest,
|
VerificationSendRequest,
|
||||||
|
VerificationResendRequest,
|
||||||
TtsTaskRequest,
|
TtsTaskRequest,
|
||||||
TtsTaskPublic,
|
TtsTaskPublic,
|
||||||
TtsVoicePublic,
|
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")
|
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:
|
def error(code: str, message: str, http_status: int) -> HTTPException:
|
||||||
return HTTPException(status_code=http_status, detail={"code": code, "message": message})
|
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}
|
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)):
|
def register(payload: RegisterRequest, request: Request, connection: Connection = Depends(get_connection)):
|
||||||
check_origin(request)
|
check_origin(request)
|
||||||
key = f"register:{request.client.host if request.client else 'unknown'}"
|
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)),
|
(email, hash_password(payload.password)),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
ensure_quota(connection, user["id"], "free")
|
ensure_quota(connection, user["id"], "free")
|
||||||
|
challenge_id, code = create_email_challenge(connection, user["id"], email)
|
||||||
connection.commit()
|
connection.commit()
|
||||||
except psycopg.errors.UniqueViolation:
|
except psycopg.errors.UniqueViolation:
|
||||||
connection.rollback()
|
connection.rollback()
|
||||||
raise error("REGISTRATION_FAILED", "注册信息不可用", 409)
|
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)
|
@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)
|
raise error("LOGIN_FAILED", "邮箱或密码错误", 401)
|
||||||
if user["status"] != "active":
|
if user["status"] != "active":
|
||||||
raise error("ACCOUNT_DISABLED", "账户不可用", 403)
|
raise error("ACCOUNT_DISABLED", "账户不可用", 403)
|
||||||
|
if not user["email_verified"]:
|
||||||
|
raise error("EMAIL_NOT_VERIFIED", "请先完成邮箱验证", 403)
|
||||||
token = new_token()
|
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)))
|
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()
|
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")
|
@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)
|
check_origin(request)
|
||||||
enabled = settings.email_verification_enabled if payload.channel == "email" else settings.phone_verification_enabled
|
enabled = settings.email_verification_enabled if payload.channel == "email" else settings.phone_verification_enabled
|
||||||
if not enabled:
|
if not enabled:
|
||||||
raise error("VERIFICATION_NOT_ENABLED", "验证渠道尚未启用", 503)
|
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)])
|
@app.post("/api/v1/auth/verification/confirm", dependencies=[Depends(require_csrf)])
|
||||||
|
|||||||
@ -37,6 +37,11 @@ class AuthResponse(BaseModel):
|
|||||||
user: UserPublic
|
user: UserPublic
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterResponse(AuthResponse):
|
||||||
|
verification_required: bool
|
||||||
|
challenge_id: UUID
|
||||||
|
|
||||||
|
|
||||||
class MembershipRequest(BaseModel):
|
class MembershipRequest(BaseModel):
|
||||||
plan: Literal["free", "vip"]
|
plan: Literal["free", "vip"]
|
||||||
starts_at: datetime | None = None
|
starts_at: datetime | None = None
|
||||||
@ -86,6 +91,10 @@ class VerificationSendRequest(BaseModel):
|
|||||||
purpose: Literal["registration", "contact_binding", "contact_change", "password_reset"]
|
purpose: Literal["registration", "contact_binding", "contact_change", "password_reset"]
|
||||||
|
|
||||||
|
|
||||||
|
class VerificationResendRequest(BaseModel):
|
||||||
|
challenge_id: UUID
|
||||||
|
|
||||||
|
|
||||||
class TtsTaskRequest(BaseModel):
|
class TtsTaskRequest(BaseModel):
|
||||||
text: str = Field(min_length=1, max_length=10000)
|
text: str = Field(min_length=1, max_length=10000)
|
||||||
voice_id: str = Field(min_length=1, max_length=120)
|
voice_id: str = Field(min_length=1, max_length=120)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user