From de6fdb02c34e272a008d4037caf2a00be2bd929b Mon Sep 17 00:00:00 2001 From: flym Date: Fri, 11 Sep 2026 16:22:57 +0800 Subject: [PATCH] feat: add email or sms registration verification --- app/register/page.tsx | 2 +- components/auth-form.tsx | 18 ++- docs/sms-verification.md | 28 ++++ services/api/.env.example | 6 + services/api/app/config.py | 10 ++ services/api/app/main.py | 135 ++++++++++++++++--- services/api/app/schemas.py | 30 ++++- services/api/migrations/005_sms_provider.sql | 7 + services/api/requirements.txt | 1 + 9 files changed, 205 insertions(+), 32 deletions(-) create mode 100644 docs/sms-verification.md create mode 100644 services/api/migrations/005_sms_provider.sql diff --git a/app/register/page.tsx b/app/register/page.tsx index fe36e4c..cdfe139 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -4,5 +4,5 @@ import { AuthForm } from "@/components/auth-form"; export const metadata = { title: "注册" }; export default function RegisterPage() { - return

Create account

建立你的账户。

注册服务已上线,邮箱验证已启用,手机验证将在渠道接入后启用。

返回首页
; + return

Create account

建立你的账户。

邮箱验证与手机短信验证二选一。

返回首页
; } diff --git a/components/auth-form.tsx b/components/auth-form.tsx index 2f8e5f5..5d4e41b 100644 --- a/components/auth-form.tsx +++ b/components/auth-form.tsx @@ -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 (
- 认证服务已接入生产环境。邮箱注册、登录和邮箱验证码验证已启用。 + 支持邮箱或手机二选一验证。验证码只由服务端发送和校验。 {verificationId ?
-
setVerificationCode(event.target.value)} placeholder="输入 6 位验证码" required value={verificationCode} />
+
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} />
+ {isLogin ?
setEmail(event.target.value)} placeholder="name@example.com / 13800138000" required value={email} />
: <> +
+
setEmail(event.target.value)} placeholder="name@example.com" required={channel === "email"} type="email" value={email} />
+
setPhone(event.target.value)} placeholder="中国大陆手机号" required={channel === "phone"} type="tel" value={phone} />
+ }
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} diff --git a/docs/sms-verification.md b/docs/sms-verification.md new file mode 100644 index 0000000..ccaaf49 --- /dev/null +++ b/docs/sms-verification.md @@ -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`。 diff --git a/services/api/.env.example b/services/api/.env.example index fd6f364..4667b49 100644 --- a/services/api/.env.example +++ b/services/api/.env.example @@ -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 diff --git a/services/api/app/config.py b/services/api/app/config.py index 0f440bf..91c4097 100644 --- a/services/api/app/config.py +++ b/services/api/app/config.py @@ -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", ""), ) diff --git a/services/api/app/main.py b/services/api/app/main.py index e075c72..63de2af 100644 --- a/services/api/app/main.py +++ b/services/api/app/main.py @@ -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"} diff --git a/services/api/app/schemas.py b/services/api/app/schemas.py index c9f8072..19a57f8 100644 --- a/services/api/app/schemas.py +++ b/services/api/app/schemas.py @@ -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) diff --git a/services/api/migrations/005_sms_provider.sql b/services/api/migrations/005_sms_provider.sql new file mode 100644 index 0000000..5aa0a66 --- /dev/null +++ b/services/api/migrations/005_sms_provider.sql @@ -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; diff --git a/services/api/requirements.txt b/services/api/requirements.txt index fb9664d..a4e049e 100644 --- a/services/api/requirements.txt +++ b/services/api/requirements.txt @@ -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