验证码流程
验证码(OTP)是 Tripo 业务侧最推荐的登录入口——一套表单同时覆盖已注册用户登录与新用户注册。v2 SDK 把它收敛成两个对称方法 + 一个透传字段:
auth.login.sendCode({ email })/auth.login.sendCode({ phone })— 发码 + 自动分流(identifier-first)auth.login.submitCode({ code, email }, { params? })/auth.login.submitCode({ code, phone }, { params? })— 提交验证码完成登录或注册LoginParams— 可选透传字段;submitCode 成功后写入登录信号 cookie,onLoginhandler 拿到
sendCode / submitCode 通过重载同时支持邮箱与手机号两条并列签名:传 { email } 走邮箱、传 { phone } 走手机号,二选一即可,调用方无需额外开关。手机号为完整号码串(如 +8613800138000),区号由调用方拼接,SDK 国家中立——详见下文 手机号验证码。
底层走 ory method: 'code',SDK 内部维护「当前活跃的验证码会话」串起 sendCode 与 submitCode。业务侧不需要手动管 flowId / csrf_token,也不需要为登录/注册各搞一套表单。
最短链路
import { TripoAuth, TripoAuthError } from '@tripo3d/auth';
const auth = new TripoAuth({
basePath: 'https://auth.tripo3d.ai',
});
// 1. 发码:SDK 内部 checkIdentifier 决定走 login 还是 registration,并创建对应 ory flow + 发码
await auth.login.sendCode({ email: 'user@example.com' });
// 2. 提交验证码:复用 sendCode 缓存的 flow id + csrf;params 透传到 onLogin handler
await auth.login.submitCode(
{ code: '123456', email: 'user@example.com' },
{ params: { fromBanner: 'home_v2' } },
);
// 3. 成功 → SDK 写 __tripo_login_signal__ cookie;同根域的一个实例认领并触发 onLogin注:v2 的 sendCode 不再返回 kind——「当前 identifier 走 login 还是 registration」属于 SDK 内部细节,业务方按统一 UI 文案处理即可(即便要切换 UI,建议改成 UI 层独立调 /idp-ext/auth/check-identifier,避免双重职责)。
设计要点
1. sendCode → submitCode 是有状态序列
sendCode 内部创建 ory login 或 registration flow,把 { flow, csrf_token, kind } 写入实例级闭包(CodeSessionHolder)。submitCode 复用同一份缓存提交。
[sendCode]
probeFlowType(email) ─► 'login' | 'registration'
ory createBrowser{Login|Registration}Flow → flow id + ui.nodes
ory updateFlow(method='code', identifier) → 邮件下发
holder.set({ flow, csrf_token, kind })
[submitCode]
holder.get() ─► cached
ory updateFlow(method='code', identifier, code) → 通过
writeSignal({ provider: 'code', ts, params })
holder.clear()注意:
- 两次调用必须在同一个
TripoAuth实例上完成,跨实例 holder 不通 - 提交成功后 SDK 自动清掉 holder;再发码只需再调一次
sendCode - 多实例 / SSR 并发场景下 holder 互不串扰
2. 邮箱变更要重新 sendCode
业务 UI 通常监听邮箱输入框,一旦值变更就清掉「已发码」状态,并 disable 提交按钮直到用户重新发码:
import { ref, watch } from 'vue';
const email = ref('');
const codeSent = ref(false);
watch(email, () => {
codeSent.value = false; // 标记需要重新发码
});
async function onSendCode() {
await auth.login.sendCode({ email: email.value });
codeSent.value = true;
startCountdown(60); // 业务 UI 自己的发码冷却
}如果用户改完邮箱直接调 submitCode,SDK 仍会用旧 flow 提交——ory 会以「identifier 不匹配」拒绝并抛 TripoAuthError(name: 'FLOW')。
3. params 透传到 onLogin handler
submitCode 第二参的 params 字段以 LoginParams(即 unknown)类型透传:JSON 化后塞进登录信号 cookie 的 payload,onLogin handler 拿到的 LoginSignal.params 即原值:
// 触发端
await auth.login.submitCode(
{ code, email },
{ params: { source: 'landing-v3', utm_campaign: 'spring-sale' } },
);
// 订阅端(可能是同 tab,也可能是其他子域 tab)
auth.signal.onLogin(({ signal, context }) => {
console.log(signal.provider); // 'code'
console.log(signal.params); // { source: 'landing-v3', utm_campaign: 'spring-sale' }
console.log(context.isRedirect); // 业务页冷启路径 = true,popup/dialog 路径 = false
});params 不会写入 ory traits、不会被 SDK 解析;纯透传,不要依赖它做安全决策。编码失败(非 JSON-serializable 输入)会被静默丢弃,信号仍会发出去。
4. 自动分流的判定与降级
sendCode 第一步是调业务端 ${basePath}/idp-ext/auth/check-identifier:
POST /idp-ext/auth/check-identifier
Body: { identifier: 'user@example.com' }
Response: { success: true, data: { flow_type: 'login' | 'registration' } }fetch 失败 → TripoAuthError(name: 'NETWORK');HTTP 非 2xx 或返回 success !== true → TripoAuthError(name: 'FLOW')。业务侧通常按 error.name 分流(详见 错误处理)。
手机号验证码
sendCode / submitCode 是重载方法,把入参从 { email } 换成 { phone } 即切到手机号链路,其余行为(自动分流、会话复用、params 透传、错误模型)与邮箱完全一致:
// 区号由调用方拼接,SDK 收到的是完整号码串(国家中立)
const fullPhone = `+86${'13800138000'}`;
// 1. 发码:内部 check-identifier 用手机号判定 login / registration
await auth.login.sendCode({ phone: fullPhone });
// 2. 提交验证码:登录走 identifier、注册走 traits.phone(SDK 自动处理)
await auth.login.submitCode(
{ code: '123456', phone: fullPhone },
{ params: { source: 'phone-login' } },
);
// 3. 成功 → 同样写 __tripo_login_signal__ cookie(provider 仍为 'code');onLogin 被触发要点:
- 区号在调用方拼:SDK 不内置任何国家码逻辑,
{ phone }必须是完整号码串。UI 套件固定+86(仅中国大陆),自建表单可自行决定。 - 手机号只有验证码链路:没有
submitPassword({ phone });密码登录仅邮箱。 - 后端前置:ory identity schema 需含
phonetrait 且开放codemethod,/idp-ext/auth/check-identifier需接受手机号。 - 邮箱与手机号是互斥的两条入口;不要在同一次 sendCode → submitCode 序列里混用(identifier 不一致 ory 会拒绝)。
重载 vs 联合
{ email } 调用方完全不受影响(签名向后兼容)。注意 TypeScript 重载不接受「联合实参一次命中不同重载」,所以调用时要按身份用具体形状分别调用(sendCode({ phone }) 而非 sendCode(emailOrPhone)),这也正好让两条链路在调用点一一对照。
重试与重发
验证码错误
submitCode 失败时不自动清缓存——业务直接让用户改输入再调一次:
try {
await auth.login.submitCode({ code: input, email });
} catch (error) {
if (error instanceof TripoAuthError && error.name === 'FLOW') {
toast.error(error.message);
// 用户改输入后再调 submitCode 即可,无需 sendCode
}
}验证码过期
Ory 默认 5 分钟过期;过期后 submitCode 会抛 TripoAuthError(name: 'FLOW'),业务侧需要重新 sendCode:
async function resend() {
await auth.login.sendCode({ email });
startCountdown(60);
}没先发码就提交
await auth.login.submitCode({ code, email });
// TripoAuthError(name: 'VALIDATION', message: 'submitCode requires a prior sendCode call in the same session')业务侧通常用 UI 状态卡住「未发码就 disable 提交按钮」,触发这条错误说明 UI 状态出 bug 了。
频率限制
Ory 后端配置了发码频率(默认 30s 一次)。前端短时间连续 sendCode 会拿到 TripoAuthError(name: 'FLOW'),error.message 透传 ory 的限流文案:
try {
await auth.login.sendCode({ email });
} catch (error) {
if (error instanceof TripoAuthError && /too many|rate/i.test(error.message)) {
toast.error('发送过于频繁,请稍后再试');
}
}建议 UI 上显示倒计时按钮,前端先卡一道,避免无效请求。
端到端示例(Vue + UI 层)
下面这段不依赖 UI 子路径,业务方自己渲染表单 + 调 core API:
<script setup lang="ts">
import { ref } from 'vue';
import { TripoAuthError } from '@tripo3d/auth';
import { auth } from '~/plugins/auth';
const email = ref('');
const code = ref('');
const codeSent = ref(false);
const cooldown = ref(0);
const submitting = ref(false);
// onLogin handler 在 app 启动时已统一注册;本组件只负责表单
async function onSendCode() {
try {
await auth.login.sendCode({ email: email.value });
codeSent.value = true;
startCooldown(60);
} catch (err) {
handleAuthError(err);
}
}
async function onSubmit() {
if (!codeSent.value) return;
submitting.value = true;
try {
await auth.login.submitCode(
{ code: code.value, email: email.value },
{ params: { source: 'inline-form-v1' } },
);
// 不需要在这里跳转:onLogin handler 已经在 app 级别统一处理 token.refresh + initUser + 关 dialog
} catch (err) {
handleAuthError(err);
} finally {
submitting.value = false;
}
}
function handleAuthError(err: unknown) {
if (err instanceof TripoAuthError) {
toast.error(err.message);
return;
}
toast.error('网络异常,请稍后重试');
}
function startCooldown(seconds: number) {
cooldown.value = seconds;
const t = setInterval(() => {
cooldown.value -= 1;
if (cooldown.value <= 0) clearInterval(t);
}, 1000);
}
</script>
<template>
<form @submit.prevent="onSubmit">
<input v-model="email" type="email" placeholder="邮箱" required>
<div>
<input v-model="code" type="text" inputmode="numeric" placeholder="验证码" maxlength="6">
<button type="button" :disabled="cooldown > 0 || !email" @click="onSendCode">
{{ cooldown > 0 ? `${cooldown}s` : '发送验证码' }}
</button>
</div>
<button type="submit" :disabled="!codeSent || submitting">
登录
</button>
</form>
</template>API 参考
| 方法 | 签名 | 说明 |
|---|---|---|
auth.login.sendCode | (args: { email } | { phone }) => Promise<void>(重载) | 发码 + 自动分流;内部先调 /idp-ext/auth/check-identifier 决定走 login 还是 registration。phone 为完整号码串(区号调用方拼) |
auth.login.submitCode | (args: { code, email } | { code, phone }, options?: { params? }) => Promise<void>(重载) | 提交验证码完成登录或注册;成功后写 __tripo_login_signal__ cookie(provider=code) |
submitCode 的 options
| 字段 | 类型 | 说明 |
|---|---|---|
params | LoginParams (= unknown) | 透传到 onLogin handler;JSON-serialize 后写入信号 cookie payload;编码失败静默丢弃 |
常见错误
完整错误分类见 错误处理。验证码流程常见的 error.name:
FLOW— 验证码错 / 过期 / identifier 不匹配 / 频控(业务直接 toasterror.message)NETWORK—check-identifier或 ory flow 调用 fetch 抛错VALIDATION—submitCode在没先sendCode的情况下被调用INTERNAL— flow ui 节点里取不到csrf_token(理论上 ory 不应出现)
下一步
- OIDC 登录 — Google / Apple / Discord 等第三方登录
- 密码重置 — 忘记密码时通过验证码恢复
- 登录信号 (cookie) —
onLoginhandler 的双通道机制 - 错误处理 —
TripoAuthError完整分类