注册时验证邮箱
打开「注册必须验证邮箱」后,验证变成一次独立的服务端动作:验码通过即落一条一次性证明,注册只检查本次请求带没带它。页面代码里没有验证码参数,也没有「已验证」这个布尔值。
站点的注册接口本来不校验邮箱:email 和 name 一样,是一个可选的展示字段。
所以在页面里写「先发码、验过了再调注册」,那个顺序完全在浏览器的控制之下——跳过前两步直接调注册,
不需要任何技巧。要让「验证过」真的拦住注册,得由服务端在建号的同一次调用里检查。
平台的做法是把验证做成一次独立的服务端动作:验码通过后服务端记下一条一次性「证明」, 票据放进 httpOnly cookie;注册时只检查本次请求带没带项目策略要求的证明。注册的调用签名里没有验证码参数。
先打开开关
在编辑器的 Auth → 设置 & OAuth 里打开「注册必须验证邮箱」。 它需要项目已经接入可用的邮件集成(见 使用集成发送邮件与验证码), 否则打开等于把注册关死,平台会直接拒绝并指向集成面板。
没打开这个开关的项目,行为与以前完全一致:注册不需要验证码,
user.email_verified_at恒为null。所以不要把这个字段当成「邮箱填了没有」来用。
页面代码:三步
需要 talizen 客户端 0.2.35 及以上。
import { useAuth, startVerification, confirmVerification } from 'talizen/auth'
const { register } = useAuth()
// ① 发码
await startVerification({ channel: 'email', to: email, purpose: 'register' })
// ② 验码:通过后证明落在服务端,浏览器只拿到一个不可伪造的票据(httpOnly cookie)
await confirmVerification({ channel: 'email', to: email, purpose: 'register', code })
// ③ 注册:没有 code 参数,也不需要传票据——浏览器自动带上,服务端按项目策略校验
await register({ account: email, email, password })
票据你读不到也不需要读。页面代码里不存在「验证过了」这个布尔值,因此也就没有「忘记检查」这回事。
证明的四条规则
| 规则 | 为什么 |
|---|---|
绑定收件人:注册时填的 email 必须与验过的地址逐字相同 | 否则攻击者验证自己的邮箱、注册时填别人的,前面全部白做 |
绑定用途:purpose 不同的证明不能互用 | 为「订阅邮件列表」索取的码不该能完成一次注册 |
| 一次性:注册成功即消费掉 | 同一次验证不该完成两件事 |
| 10 分钟过期 | 够走完「填密码、提交」,短到捡到票据也没用 |
channel 目前只支持 email;写 sms 会明确报错,而不是悄悄换成邮件发出去。
站点自己的规则:邀请码、域名白名单
邮箱验证由平台强制,但邀请码、@company.com 域名白名单、注册赠额这类规则平台管不了。
写在页面代码里它们只是建议——攻击者直接调注册接口就绕过去了。要让它们真的生效,
在同一个设置面板里打开「禁止前端方式注册」,然后把注册放进 Func:
import type { TalizenFuncContext } from 'talizen/func-runtime'
export async function complete(input, ctx: TalizenFuncContext) {
const invite = ctx.db.get('invites', input.invite)
if (!invite || invite.used) return { ok: false, reason: 'bad_invite' }
// 同样没有 code / proof 参数:证明由平台从本次请求里读,Func 代码碰不到票据
const user = ctx.auth.register({
account: input.email,
email: input.email,
password: input.password,
profile: { invited_by: invite.owner },
})
ctx.db.update('invites', invite.id, { used: true, used_by: user.id })
return { ok: true }
}
打开之后,页面代码直接调注册会收到 403,注册只能走你的 Func。发码/验码在 Func 里对应
ctx.verify.start() 与 ctx.verify.confirm();注册成功后的会话 cookie 由平台下发,
Func 没有签发登录态或直接改密码的能力。
与 ctx.email.sendCode 的分界
两套东西看起来都在「发验证码」,区别是结果由谁记录:
| 用途 | 结果 | |
|---|---|---|
ctx.email.sendCode / verifyCode | 站点自用:下单确认、退订确认 | 只回到你的代码里,平台不记录任何东西 |
startVerification / ctx.verify | 身份证明 | 平台记录,并被注册消费 |
所以不要用
ctx.email.verifyCode()去做注册前的把关:它返回的true只是一个值,服务端不会因此认为这个邮箱被证明过。
注册之后
验证过的注册会写下 user.email_verified_at。想让未验证的老用户功能受限,
在 Func 里判断它,不要在登录时强制——那会把存量用户全部锁在门外:
export async function createPost(input, ctx: TalizenFuncContext) {
const user = ctx.auth.requireUser()
if (!user.email_verified_at) return { ok: false, reason: 'verify_email_first' }
// ...
}
已经注册过的邮箱
访客用一个已经有账号的邮箱申请注册时,默认行为是:接口返回与正常情况 完全一致的结果,但那个地址收到的邮件换成「你已经注册过了,直接登录」,而不是验证码。
这不是漏洞,是刻意的。开启验证之后 register 已经不是枚举口(拿不到证明就走不到重复检查),
发码接口成了唯一的探测面;如果它对「已占用」返回不同的结果,任何人都能拿它逐个探测
哪些邮箱在你的站点上有账号。攻击者看不到那封邮件,所以枚举不到;真实用户又不会拿到一个注册不成的码。
所以不要在页面代码里自己加一个「这个邮箱是不是已被占用」的接口去做前置检查—— 那正是默认行为要挡住的东西。
不在乎枚举的站点(内部工具、B 端后台)可以在 Auth → 设置 里打开
「提示邮箱已被注册」:此时 startVerification 直接返回 409,
体验更好、也不白发一封信。
错误与边界
| 情况 | 行为 |
|---|---|
| 没验证就注册 / 票据过期 | 403,提示需要先完成验证 |
| 注册填的邮箱与验过的不一致 | 403,明确说不匹配 |
| 验证码不对、已过期、根本没发过 | 统一 400,不区分——否则接口成了枚举探测面 |
| 同一收件人频繁索取验证码 | 429。与集成里的限频、每日上限共用同一套计数 |
| 项目没接邮件集成就打开开关 | 保存被拒绝,指向集成面板 |
| 开着「禁止前端方式注册」时页面直接调注册 | 403,指向你自己的 Func |
验证码本身的位数、有效期、猜错上限等由集成配置,见 使用集成发送邮件与验证码;Func 的其余能力见 使用 Func 构建站点后端能力。
