注册时验证邮箱
打开「注册必须验证邮箱」后,验证变成一次独立的服务端动作:验码通过即落一条一次性证明,注册只检查本次请求带没带它。页面代码里没有验证码参数,也没有「已验证」这个布尔值。
站点的注册接口本来不校验邮箱:email 和 name 一样,是一个可选的展示字段。
所以在页面里写「先发码、验过了再调注册」,那个顺序完全在浏览器的控制之下——跳过前两步直接调注册,
不需要任何技巧。要让「验证过」真的拦住注册,得由服务端在建号的同一次调用里检查。
平台的做法是把验证做成一次独立的服务端动作:验码通过后服务端记下一条一次性「证明」, 票据放进 httpOnly cookie;注册时只检查本次请求带没带项目策略要求的证明。注册的调用签名里没有验证码参数。
先打开开关
在编辑器的 Auth → 设置 & OAuth 里打开「注册必须验证邮箱」。 它需要项目已经接入可用的邮件集成(见 使用集成发送邮件与验证码), 否则打开等于把注册关死,平台会直接拒绝并指向集成面板。
没打开这个开关的项目,行为与以前完全一致:注册不需要验证码。 这个开关只作用于页面代码发起的注册;注册方式选「仅后端函数」时它不会出现, 因为那条路的校验全在你的 Func 代码里。
页面代码:三步
需要 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。
改成这个模式之后,页面代码直接调注册、以及直接调发码接口,都会收到 403:注册只能走你的 Func。
此时校验完全由你的代码执行,平台不做检查——所以面板上也不再有「注册必须验证邮箱」
这个开关,没有需要配置的东西。验不过就别往下调 register,顺序就是代码本身:
import type { TalizenFuncContext } from 'talizen/func-runtime'
export function sendCode(input, ctx: TalizenFuncContext) {
const invite = ctx.db.get('invites', input.invite)
if (!invite || invite.used) return { ok: false, reason: 'bad_invite' }
ctx.verify.start({ channel: 'email', to: input.email, purpose: 'register' })
return { ok: true }
}
export function complete(input, ctx: TalizenFuncContext) {
// 这是另一次请求,sendCode 通过了不代表这次也通过,必须重查
const invite = ctx.db.get('invites', input.invite)
if (!invite || invite.used) return { ok: false, reason: 'bad_invite' }
// 先验码。Func 里 confirm 只返回布尔,没有票据也没有 cookie
if (!ctx.verify.confirm({
channel: 'email',
to: input.email,
purpose: 'register',
code: input.code,
})) return { ok: false, reason: 'bad_code' }
// 走到这里就说明验过了。register 的签名里没有 code、没有票据、也没有「已验证」参数
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 }
}
注册成功后的会话 cookie 仍由平台下发,Func 没有签发登录态的能力。改密码是另一件事,Func 可以做——见 找回密码与修改密码。
与 ctx.email.sendCode 的分界
两套东西看起来都在「发验证码」,区别是结果由谁记录:
| 用途 | 结果 | |
|---|---|---|
ctx.email.sendCode / verifyCode | 站点自用:下单确认、退订确认 | 只回到你的代码里,平台不记录任何东西 |
startVerification / ctx.verify | 身份证明 | 平台记录,并被注册消费 |
页面代码发起的注册这条路上,
ctx.email.verifyCode()的true起不到把关作用——注册消费的是平台记录的那条证明。注册收口到 Func 时两者都能用来把关(平台都不检查), 但注册仍应优先用ctx.verify:只有它会顺带做邮箱占用检查。
注册之后
平台不会在用户上留下「这个人验过邮箱」的标记——没有 email_verified_at
这样的字段,也不要自己建一个来模仿它。验证是注册时的一个步骤,不是用户身上的一个状态位。
原因是这个标记没有可用的信息量:开了验证的项目,每个账号都验过;没开的项目,一个都没验过。 它只在「项目中途打开验证」这一种情况下才区分得出新旧账号,而那种情况下真正该做的是给存量用户 补一次验证流程,不是读一个字段去限制他们。
确实需要按用户区分权限时,把它当业务状态放进自己的表(例如 members.status),
由你的 Func 来写和判断——那样它的含义由你定义,也不会被误当成平台的保证。
已经注册过的邮箱
访客用一个已经有账号的邮箱申请注册时,默认行为是:接口返回与正常情况 完全一致的结果,但那个地址收到的邮件换成「你已经注册过了,直接登录」,而不是验证码。
这不是漏洞,是刻意的。开启验证之后 register 已经不是枚举口(拿不到证明就走不到重复检查),
发码接口成了唯一的探测面;如果它对「已占用」返回不同的结果,任何人都能拿它逐个探测
哪些邮箱在你的站点上有账号。攻击者看不到那封邮件,所以枚举不到;真实用户又不会拿到一个注册不成的码。
所以不要在页面代码里自己加一个「这个邮箱是不是已被占用」的接口去做前置检查—— 那正是默认行为要挡住的东西。
不在乎枚举的站点(内部工具、B 端后台)可以在 Auth → 设置 里打开
「提示邮箱已被注册」:此时 startVerification 直接返回 409,
体验更好、也不白发一封信。
错误与边界
| 情况 | 行为 |
|---|---|
| 没验证就注册 / 票据过期 | 403,提示需要先完成验证 |
| 注册填的邮箱与验过的不一致 | 403,明确说不匹配 |
| 验证码不对、已过期、根本没发过 | 统一 400,不区分——否则接口成了枚举探测面 |
| 同一收件人频繁索取验证码 | 429。与集成里的限频、每日上限共用同一套计数 |
| 项目没接邮件集成就打开开关 | 保存被拒绝,指向集成面板 |
| 开着「禁止前端方式注册」时页面直接调注册 | 403,指向你自己的 Func |
验证码本身的位数、有效期、猜错上限等由集成配置,见 使用集成发送邮件与验证码;Func 的其余能力见 使用 Func 构建站点后端能力。