feat: add email or sms registration verification
This commit is contained in:
parent
8a7dd626f8
commit
de6fdb02c3
@ -4,5 +4,5 @@ import { AuthForm } from "@/components/auth-form";
|
|||||||
export const metadata = { title: "注册" };
|
export const metadata = { title: "注册" };
|
||||||
|
|
||||||
export default function RegisterPage() {
|
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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,8 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
|
|||||||
const isLogin = mode === "login";
|
const isLogin = mode === "login";
|
||||||
const [csrf, setCsrf] = useState("");
|
const [csrf, setCsrf] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [channel, setChannel] = useState<"email" | "phone">("email");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirmation, setConfirmation] = useState("");
|
const [confirmation, setConfirmation] = useState("");
|
||||||
const [verificationId, setVerificationId] = useState("");
|
const [verificationId, setVerificationId] = useState("");
|
||||||
@ -39,7 +41,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json", ...(csrf ? { "X-CSRF-Token": csrf } : {}) },
|
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(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) throw new Error(body.error?.message ?? "请求失败,请稍后重试");
|
if (!response.ok) throw new Error(body.error?.message ?? "请求失败,请稍后重试");
|
||||||
@ -47,7 +49,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
|
|||||||
window.location.assign("/account");
|
window.location.assign("/account");
|
||||||
} else {
|
} else {
|
||||||
setVerificationId(body.challenge_id ?? "");
|
setVerificationId(body.challenge_id ?? "");
|
||||||
setMessage("注册成功,验证码已发送到你的邮箱");
|
setMessage(`注册成功,验证码已发送到你的${channel === "email" ? "邮箱" : "手机"}`);
|
||||||
}
|
}
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError(submitError instanceof Error ? submitError.message : "请求失败,请稍后重试");
|
setError(submitError instanceof Error ? submitError.message : "请求失败,请稍后重试");
|
||||||
@ -67,7 +69,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
|
|||||||
});
|
});
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) throw new Error(body.error?.message ?? "验证码错误,请重试");
|
if (!response.ok) throw new Error(body.error?.message ?? "验证码错误,请重试");
|
||||||
setMessage("邮箱验证成功,请登录");
|
setMessage("验证成功,请登录");
|
||||||
window.setTimeout(() => window.location.assign("/login"), 500);
|
window.setTimeout(() => window.location.assign("/login"), 500);
|
||||||
} catch (confirmError) {
|
} catch (confirmError) {
|
||||||
setError(confirmError instanceof Error ? confirmError.message : "验证失败,请重试");
|
setError(confirmError instanceof Error ? confirmError.message : "验证失败,请重试");
|
||||||
@ -92,14 +94,18 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
|
|||||||
|
|
||||||
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>
|
||||||
{verificationId ? <form className="mt-8 space-y-5" onSubmit={confirmVerification}>
|
{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}
|
{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="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>
|
<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}>
|
</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>
|
<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}
|
||||||
|
|||||||
28
docs/sms-verification.md
Normal file
28
docs/sms-verification.md
Normal 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`。
|
||||||
@ -18,4 +18,10 @@ SMTP_HOST=smtp.exmail.qq.com
|
|||||||
SMTP_PORT=465
|
SMTP_PORT=465
|
||||||
SMTP_USER=tech@kaotings.com
|
SMTP_USER=tech@kaotings.com
|
||||||
SMTP_FROM=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
|
SMTP_PASSWORD=replace-with-secret
|
||||||
|
|||||||
@ -31,6 +31,11 @@ class Settings:
|
|||||||
smtp_user: str
|
smtp_user: str
|
||||||
smtp_password: str
|
smtp_password: str
|
||||||
smtp_from: 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
|
@classmethod
|
||||||
def from_env(cls) -> "Settings":
|
def from_env(cls) -> "Settings":
|
||||||
@ -60,6 +65,11 @@ class Settings:
|
|||||||
smtp_user=os.getenv("SMTP_USER", ""),
|
smtp_user=os.getenv("SMTP_USER", ""),
|
||||||
smtp_password=os.getenv("SMTP_PASSWORD", ""),
|
smtp_password=os.getenv("SMTP_PASSWORD", ""),
|
||||||
smtp_from=os.getenv("SMTP_FROM", os.getenv("SMTP_USER", "")),
|
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", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -84,6 +84,75 @@ def create_email_challenge(connection: Connection, user_id: UUID, email: str) ->
|
|||||||
return row["id"], code
|
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:
|
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})
|
||||||
|
|
||||||
@ -320,41 +389,52 @@ def register(payload: RegisterRequest, request: Request, connection: Connection
|
|||||||
key = f"register:{request.client.host if request.client else 'unknown'}"
|
key = f"register:{request.client.host if request.client else 'unknown'}"
|
||||||
if not rate_limiter.allow(key, 5, 3600):
|
if not rate_limiter.allow(key, 5, 3600):
|
||||||
raise error("RATE_LIMITED", "请求过于频繁", 429)
|
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:
|
try:
|
||||||
user = connection.execute(
|
user = connection.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO users(email, password_hash) VALUES (%s, %s)
|
INSERT INTO users(email, phone, password_hash) VALUES (%s, %s, %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
(email, hash_password(payload.password)),
|
(email, phone, 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)
|
if payload.verification_channel == "email":
|
||||||
connection.commit()
|
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:
|
except psycopg.errors.UniqueViolation:
|
||||||
connection.rollback()
|
connection.rollback()
|
||||||
raise error("REGISTRATION_FAILED", "注册信息不可用", 409)
|
raise error("REGISTRATION_FAILED", "注册信息不可用", 409)
|
||||||
try:
|
|
||||||
send_verification_email(email, code)
|
|
||||||
except Exception:
|
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}
|
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)
|
||||||
def login(payload: LoginRequest, request: Request, response: Response, connection: Connection = Depends(get_connection)):
|
def login(payload: LoginRequest, request: Request, response: Response, connection: Connection = Depends(get_connection)):
|
||||||
check_origin(request)
|
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):
|
if not rate_limiter.allow(key, 10, 300):
|
||||||
raise error("RATE_LIMITED", "请求过于频繁", 429)
|
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"]):
|
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":
|
if user["status"] != "active":
|
||||||
raise error("ACCOUNT_DISABLED", "账户不可用", 403)
|
raise error("ACCOUNT_DISABLED", "账户不可用", 403)
|
||||||
if not user["email_verified"]:
|
if not (user["email_verified"] or user["phone_verified"]):
|
||||||
raise error("EMAIL_NOT_VERIFIED", "请先完成邮箱验证", 403)
|
raise error("CONTACT_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()
|
||||||
@ -421,33 +501,44 @@ def verification_send(payload: VerificationSendRequest, request: Request, connec
|
|||||||
@app.post("/api/v1/auth/verification/resend")
|
@app.post("/api/v1/auth/verification/resend")
|
||||||
def verification_resend(payload: VerificationResendRequest, request: Request, connection: Connection = Depends(get_connection)):
|
def verification_resend(payload: VerificationResendRequest, request: Request, connection: Connection = Depends(get_connection)):
|
||||||
check_origin(request)
|
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:
|
if not challenge:
|
||||||
raise error("VERIFICATION_INVALID", "验证挑战无效", 400)
|
raise error("VERIFICATION_INVALID", "验证挑战无效", 400)
|
||||||
if not rate_limiter.allow(f"verification:{challenge['target']}", 3, 900):
|
if not rate_limiter.allow(f"verification:{challenge['target']}", 3, 900):
|
||||||
raise error("RATE_LIMITED", "验证码发送过于频繁", 429)
|
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:
|
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:
|
except Exception:
|
||||||
raise error("VERIFICATION_EMAIL_FAILED", "验证码邮件发送失败,请稍后重试", 503)
|
connection.rollback()
|
||||||
|
raise error("VERIFICATION_SEND_FAILED", "验证码发送失败,请稍后重试", 503)
|
||||||
return {"challenge_id": payload.challenge_id, "status": "sent"}
|
return {"challenge_id": payload.challenge_id, "status": "sent"}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/auth/verification/confirm-registration", dependencies=[Depends(require_csrf)])
|
@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)):
|
def verification_confirm_registration(payload: VerificationConfirmRequest, request: Request, connection: Connection = Depends(get_connection)):
|
||||||
check_origin(request)
|
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:
|
if not challenge or challenge["consumed_at"] or challenge["expires_at"] <= utc_now() or challenge["attempt_count"] >= 5:
|
||||||
raise error("VERIFICATION_INVALID", "验证挑战无效或已过期", 400)
|
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.execute("UPDATE verification_challenges SET attempt_count = attempt_count + 1 WHERE id = %s", (payload.challenge_id,))
|
||||||
connection.commit()
|
connection.commit()
|
||||||
raise error("VERIFICATION_INVALID", "验证码错误", 400)
|
raise error("VERIFICATION_INVALID", "验证码错误", 400)
|
||||||
connection.execute("UPDATE verification_challenges SET consumed_at = now() WHERE id = %s", (payload.challenge_id,))
|
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()
|
connection.commit()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|||||||
@ -2,16 +2,40 @@ from datetime import datetime
|
|||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
from uuid import UUID
|
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):
|
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)
|
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):
|
class LoginRequest(BaseModel):
|
||||||
email: EmailStr
|
identifier: str = Field(min_length=3, max_length=120)
|
||||||
password: str = Field(min_length=1, max_length=128)
|
password: str = Field(min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
7
services/api/migrations/005_sms_provider.sql
Normal file
7
services/api/migrations/005_sms_provider.sql
Normal 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;
|
||||||
@ -5,3 +5,4 @@ argon2-cffi==25.1.0
|
|||||||
pydantic==2.11.7
|
pydantic==2.11.7
|
||||||
email-validator==2.2.0
|
email-validator==2.2.0
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
|
alibabacloud_dypnsapi20170525==2.0.0
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user