feat: add email or sms registration verification

This commit is contained in:
flym 2026-09-11 16:22:57 +08:00
parent 8a7dd626f8
commit de6fdb02c3
9 changed files with 205 additions and 32 deletions

View File

@ -4,5 +4,5 @@ import { AuthForm } from "@/components/auth-form";
export const metadata = { title: "注册" };
export default function RegisterPage() {
return <div className="container-shell flex min-h-[680px] flex-col items-center justify-center py-16"><div className="mb-8 text-center"><p className="eyebrow eyebrow-cyan">Create account</p><h1 className="mt-5 text-4xl font-semibold tracking-[-0.05em]"></h1><p className="mt-3 text-sm text-muted">线</p></div><AuthForm mode="register" /><Link className="mt-6 text-sm text-muted hover:text-copy" href="/"></Link></div>;
return <div className="container-shell flex min-h-[680px] flex-col items-center justify-center py-16"><div className="mb-8 text-center"><p className="eyebrow eyebrow-cyan">Create account</p><h1 className="mt-5 text-4xl font-semibold tracking-[-0.05em]"></h1><p className="mt-3 text-sm text-muted"></p></div><AuthForm mode="register" /><Link className="mt-6 text-sm text-muted hover:text-copy" href="/"></Link></div>;
}

View File

@ -10,6 +10,8 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
const isLogin = mode === "login";
const [csrf, setCsrf] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [channel, setChannel] = useState<"email" | "phone">("email");
const [password, setPassword] = useState("");
const [confirmation, setConfirmation] = useState("");
const [verificationId, setVerificationId] = useState("");
@ -39,7 +41,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({ email, password }),
body: JSON.stringify(isLogin ? { identifier: email, password } : { email: channel === "email" ? email : email || null, phone: phone || null, verification_channel: channel, password }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error?.message ?? "请求失败,请稍后重试");
@ -47,7 +49,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
window.location.assign("/account");
} else {
setVerificationId(body.challenge_id ?? "");
setMessage("注册成功,验证码已发送到你的邮箱");
setMessage(`注册成功,验证码已发送到你的${channel === "email" ? "邮箱" : "手机"}`);
}
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "请求失败,请稍后重试");
@ -67,7 +69,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error?.message ?? "验证码错误,请重试");
setMessage("邮箱验证成功,请登录");
setMessage("验证成功,请登录");
window.setTimeout(() => window.location.assign("/login"), 500);
} catch (confirmError) {
setError(confirmError instanceof Error ? confirmError.message : "验证失败,请重试");
@ -92,14 +94,18 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
return (
<div className="w-full max-w-md rounded-2xl border border-line bg-panel/70 p-6 sm:p-8">
<PreviewNotice></PreviewNotice>
<PreviewNotice></PreviewNotice>
{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>
<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>
{isLogin ? <div><label className="field-label" htmlFor="email"></label><input className="field-input" id="email" onChange={(event) => setEmail(event.target.value)} placeholder="name@example.com / 13800138000" required value={email} /></div> : <>
<div className="grid grid-cols-2 gap-2 rounded-lg border border-line p-1"><button className={`rounded-md px-3 py-2 text-sm ${channel === "email" ? "bg-cyan text-slate-950" : "text-muted"}`} onClick={() => setChannel("email")} type="button"></button><button className={`rounded-md px-3 py-2 text-sm ${channel === "phone" ? "bg-cyan text-slate-950" : "text-muted"}`} onClick={() => setChannel("phone")} type="button"></button></div>
<div><label className="field-label" htmlFor="email">{channel === "email" ? "(必填)" : "(可选)"}</label><input className="field-input" id="email" onChange={(event) => setEmail(event.target.value)} placeholder="name@example.com" required={channel === "email"} type="email" value={email} /></div>
<div><label className="field-label" htmlFor="phone">{channel === "phone" ? "(必填)" : "(可选)"}</label><input className="field-input" id="phone" onChange={(event) => setPhone(event.target.value)} placeholder="中国大陆手机号" required={channel === "phone"} type="tel" value={phone} /></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}

28
docs/sms-verification.md Normal file
View File

@ -0,0 +1,28 @@
# 手机短信验证配置
本项目使用阿里云号码认证服务PNVS的服务端短信认证 API
- `SendSmsVerifyCode` 发送并由阿里云生成验证码
- `CheckSmsVerifyCode` 校验验证码
注册页支持邮箱验证和短信验证二选一。短信验证用户可以使用手机号和密码登录;邮箱验证用户可以使用邮箱和密码登录。
## 阿里云控制台准备
1. 开通号码认证服务中的短信认证功能。
2. 创建专用 RAM 用户 AccessKey只授予 `dypns:SendSmsVerifyCode``dypns:CheckSmsVerifyCode` 权限。
3. 在短信认证配置中取得系统赠送的签名名称和验证码模板 Code。签名和模板必须来自同一套 PNVS 资源。
4. 确认短信认证服务有可用套餐或余额。
## 服务端环境变量
```env
PHONE_VERIFICATION_ENABLED=true
ALIBABA_CLOUD_ACCESS_KEY_ID=...
ALIBABA_CLOUD_ACCESS_KEY_SECRET=...
ALIYUN_PNVS_SCHEME_NAME=
ALIYUN_PNVS_SIGN_NAME=控制台中的签名名称
ALIYUN_PNVS_TEMPLATE_CODE=控制台中的模板Code
```
AccessKey 只能写入服务器受保护的环境文件,不要提交到 Git也不要放入前端环境变量。当前实现使用中国大陆手机号和国家码 `86`

View File

@ -18,4 +18,10 @@ SMTP_HOST=smtp.exmail.qq.com
SMTP_PORT=465
SMTP_USER=tech@kaotings.com
SMTP_FROM=tech@kaotings.com
PHONE_VERIFICATION_ENABLED=false
ALIBABA_CLOUD_ACCESS_KEY_ID=
ALIBABA_CLOUD_ACCESS_KEY_SECRET=
ALIYUN_PNVS_SCHEME_NAME=
ALIYUN_PNVS_SIGN_NAME=
ALIYUN_PNVS_TEMPLATE_CODE=
SMTP_PASSWORD=replace-with-secret

View File

@ -31,6 +31,11 @@ class Settings:
smtp_user: str
smtp_password: str
smtp_from: str
aliyun_access_key_id: str
aliyun_access_key_secret: str
aliyun_scheme_name: str
aliyun_sign_name: str
aliyun_template_code: str
@classmethod
def from_env(cls) -> "Settings":
@ -60,6 +65,11 @@ class Settings:
smtp_user=os.getenv("SMTP_USER", ""),
smtp_password=os.getenv("SMTP_PASSWORD", ""),
smtp_from=os.getenv("SMTP_FROM", os.getenv("SMTP_USER", "")),
aliyun_access_key_id=os.getenv("ALIBABA_CLOUD_ACCESS_KEY_ID", ""),
aliyun_access_key_secret=os.getenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET", ""),
aliyun_scheme_name=os.getenv("ALIYUN_PNVS_SCHEME_NAME", ""),
aliyun_sign_name=os.getenv("ALIYUN_PNVS_SIGN_NAME", ""),
aliyun_template_code=os.getenv("ALIYUN_PNVS_TEMPLATE_CODE", ""),
)

View File

@ -84,6 +84,75 @@ def create_email_challenge(connection: Connection, user_id: UUID, email: str) ->
return row["id"], code
def send_sms_verification(phone: str) -> str:
if not all((settings.aliyun_access_key_id, settings.aliyun_access_key_secret, settings.aliyun_sign_name, settings.aliyun_template_code)):
raise RuntimeError("ALIYUN_PNVS_NOT_CONFIGURED")
from alibabacloud_dypnsapi20170525.client import Client
from alibabacloud_dypnsapi20170525 import models as dypns_models
from alibabacloud_tea_openapi import models as openapi_models
from alibabacloud_tea_util import models as util_models
client_config = openapi_models.Config(
access_key_id=settings.aliyun_access_key_id,
access_key_secret=settings.aliyun_access_key_secret,
endpoint="dypnsapi.aliyuncs.com",
)
client = Client(client_config)
out_id = secrets.token_urlsafe(24)
request = dypns_models.SendSmsVerifyCodeRequest(
phone_number=phone,
sign_name=settings.aliyun_sign_name,
template_code=settings.aliyun_template_code,
scheme_name=settings.aliyun_scheme_name or None,
country_code="86",
template_param='{"code":"##code##","min":"5"}',
out_id=out_id,
return_verify_code=False,
)
response = client.send_sms_verify_code_with_options(request, util_models.RuntimeOptions())
body = getattr(response, "body", response)
if getattr(body, "code", "") != "OK" or not getattr(body, "success", False):
raise RuntimeError(f"ALIYUN_PNVS_SEND_FAILED:{getattr(body, 'code', 'UNKNOWN')}")
return out_id
def check_sms_verification(phone: str, code: str, out_id: str) -> bool:
from alibabacloud_dypnsapi20170525.client import Client
from alibabacloud_dypnsapi20170525 import models as dypns_models
from alibabacloud_tea_openapi import models as openapi_models
from alibabacloud_tea_util import models as util_models
client = Client(openapi_models.Config(
access_key_id=settings.aliyun_access_key_id,
access_key_secret=settings.aliyun_access_key_secret,
endpoint="dypnsapi.aliyuncs.com",
))
request = dypns_models.CheckSmsVerifyCodeRequest(
phone_number=phone,
verify_code=code,
country_code="86",
scheme_name=settings.aliyun_scheme_name or None,
out_id=out_id,
case_auth_policy=1,
)
response = client.check_sms_verify_code_with_options(request, util_models.RuntimeOptions())
body = getattr(response, "body", response)
model = getattr(body, "model", None)
return getattr(model, "verify_result", "") == "PASS"
def create_sms_challenge(connection: Connection, user_id: UUID, phone: str) -> UUID:
out_id = send_sms_verification(phone)
row = connection.execute(
"""
INSERT INTO verification_challenges(user_id, channel, purpose, target, code_digest, provider, provider_request_id, expires_at)
VALUES (%s, 'phone', 'registration', %s, %s, 'aliyun_pnvs', %s, now() + interval '10 minutes') RETURNING id
""",
(user_id, phone, code_digest(out_id), out_id),
).fetchone()
return row["id"]
def error(code: str, message: str, http_status: int) -> HTTPException:
return HTTPException(status_code=http_status, detail={"code": code, "message": message})
@ -320,41 +389,52 @@ def register(payload: RegisterRequest, request: Request, connection: Connection
key = f"register:{request.client.host if request.client else 'unknown'}"
if not rate_limiter.allow(key, 5, 3600):
raise error("RATE_LIMITED", "请求过于频繁", 429)
email = normalize_email(str(payload.email))
email = normalize_email(str(payload.email)) if payload.email else None
phone = payload.phone
try:
user = connection.execute(
"""
INSERT INTO users(email, password_hash) VALUES (%s, %s)
INSERT INTO users(email, phone, password_hash) VALUES (%s, %s, %s)
RETURNING *
""",
(email, hash_password(payload.password)),
(email, phone, hash_password(payload.password)),
).fetchone()
ensure_quota(connection, user["id"], "free")
challenge_id, code = create_email_challenge(connection, user["id"], email)
connection.commit()
if payload.verification_channel == "email":
challenge_id, code = create_email_challenge(connection, user["id"], email)
else:
challenge_id = create_sms_challenge(connection, user["id"], phone)
code = None
except psycopg.errors.UniqueViolation:
connection.rollback()
raise error("REGISTRATION_FAILED", "注册信息不可用", 409)
try:
send_verification_email(email, code)
except Exception:
raise error("VERIFICATION_EMAIL_FAILED", "验证码邮件发送失败,请稍后重试", 503)
connection.rollback()
raise error("VERIFICATION_SEND_FAILED", "验证码发送失败,请稍后重试", 503)
try:
if payload.verification_channel == "email":
send_verification_email(email, code)
connection.commit()
except Exception:
connection.rollback()
raise error("VERIFICATION_SEND_FAILED", "验证码发送失败,请稍后重试", 503)
return {"user": user_public(connection, user), "verification_required": True, "challenge_id": challenge_id}
@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)
key = f"login:{request.client.host if request.client else 'unknown'}:{normalize_email(str(payload.email))}"
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 email = %s", (normalize_email(str(payload.email)),)).fetchone()
user = connection.execute("SELECT * FROM users WHERE lower(email) = %s OR phone = %s", (identifier, identifier)).fetchone()
if not user or not verify_password(payload.password, user["password_hash"]):
raise error("LOGIN_FAILED", "邮箱或密码错误", 401)
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)
if not (user["email_verified"] or user["phone_verified"]):
raise error("CONTACT_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()
@ -421,33 +501,44 @@ def verification_send(payload: VerificationSendRequest, request: Request, connec
@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()
challenge = connection.execute("SELECT * FROM verification_challenges WHERE id = %s 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)
if challenge["channel"] == "email":
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()
send_verification_email(challenge["target"], code)
else:
out_id = send_sms_verification(challenge["target"])
connection.execute("UPDATE verification_challenges SET code_digest = %s, provider_request_id = %s, expires_at = now() + interval '10 minutes', attempt_count = 0 WHERE id = %s", (code_digest(out_id), out_id, payload.challenge_id))
connection.commit()
except Exception:
raise error("VERIFICATION_EMAIL_FAILED", "验证码邮件发送失败,请稍后重试", 503)
connection.rollback()
raise error("VERIFICATION_SEND_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()
challenge = connection.execute("SELECT * FROM verification_challenges WHERE id = %s 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)):
try:
valid = check_sms_verification(challenge["target"], payload.code, challenge["provider_request_id"]) if challenge["channel"] == "phone" else __import__("hmac").compare_digest(challenge["code_digest"], code_digest(payload.code))
except Exception:
raise error("VERIFICATION_PROVIDER_FAILED", "验证码服务暂时不可用,请稍后重试", 503)
if not valid:
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"],))
column = "email_verified" if challenge["channel"] == "email" else "phone_verified"
connection.execute(f"UPDATE users SET {column} = true, updated_at = now() WHERE id = %s", (challenge["user_id"],))
connection.commit()
return {"status": "ok"}

View File

@ -2,16 +2,40 @@ from datetime import datetime
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field, field_validator
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
class RegisterRequest(BaseModel):
email: EmailStr
email: EmailStr | None = None
phone: str | None = Field(default=None, min_length=11, max_length=20)
verification_channel: Literal["email", "phone"]
password: str = Field(min_length=8, max_length=128)
@field_validator("phone")
@classmethod
def normalize_phone(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().replace(" ", "").replace("-", "")
if normalized.startswith("+86"):
normalized = normalized[3:]
if not normalized.isdigit() or len(normalized) != 11 or not normalized.startswith("1"):
raise ValueError("请输入有效的中国大陆手机号")
return normalized
@model_validator(mode="after")
def validate_channel(self) -> "RegisterRequest":
if not self.email and not self.phone:
raise ValueError("邮箱或手机号至少填写一项")
if self.verification_channel == "email" and not self.email:
raise ValueError("邮箱验证需要填写邮箱")
if self.verification_channel == "phone" and not self.phone:
raise ValueError("短信验证需要填写手机号")
return self
class LoginRequest(BaseModel):
email: EmailStr
identifier: str = Field(min_length=3, max_length=120)
password: str = Field(min_length=1, max_length=128)

View File

@ -0,0 +1,7 @@
ALTER TABLE verification_challenges
ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'local',
ADD COLUMN IF NOT EXISTS provider_request_id TEXT;
CREATE INDEX IF NOT EXISTS verification_provider_request_idx
ON verification_challenges(provider, provider_request_id)
WHERE provider_request_id IS NOT NULL;

View File

@ -5,3 +5,4 @@ argon2-cffi==25.1.0
pydantic==2.11.7
email-validator==2.2.0
httpx==0.28.1
alibabacloud_dypnsapi20170525==2.0.0