实现找回密码与修改密码
站点的「忘记密码」和「修改密码」写在 Func 里:ctx.users.find 决定要不要发验证码,ctx.email.verifyCode 验码,ctx.users.setPassword 改密码。含两个场景的完整代码、三条不写就会出问题的规则,以及哪些事不用自己实现。
在站点上做「忘记密码」和「修改密码」:Creght 没有现成的找回密码接口,
客户端 SDK 里也没有 resetPassword()。你在 Func 里写这两个方法,页面用 invoke() 调它们。
密码哈希、验证码的一次性与限频、改完清掉旧会话、密码爆破限流由平台保证,你只写「凭什么允许改」。
准备
- 项目已连接邮件集成(编辑器 后端 → 集成),见 使用集成发送邮件与验证码。没有它就发不出验证码。
talizen客户端 0.2.37 或更高,只影响编辑器里的类型提示。
与注册设置无关:Auth 面板里的「开放注册」「注册必须验证邮箱」「仅后端函数」怎么设, 都不影响找回密码。不用去改它们。
文件与调用名
Func 是站点源文件,路径去掉扩展名就是调用 key,. 后面是方法名:
| 文件 | 页面里怎么调 |
|---|---|
/backend/func/auth/reset-password.ts | invoke('auth/reset-password.requestCode', …)invoke('auth/reset-password.confirm', …) |
/backend/func/auth/change-password.ts | invoke('auth/change-password', …)(导出名为 main 时不写方法名) |
指自己用 ctx.auth,指别人用 ctx.users
| 要做的事 | 用哪个 |
|---|---|
| 读当前登录的人 | ctx.auth.currentUser() / ctx.auth.requireUser() |
| 按邮箱/账号/id 找某个用户 | ctx.users.find({ email }),找不到返回 null |
| 验某个用户的现有密码 | ctx.users.checkPassword({ userId, password }) |
| 改某个用户的密码 | ctx.users.setPassword({ email, password }) |
ctx.users 的三个方法都能指到项目里任何一个用户,所以指错人就是改错账号。
email、account、userId 只能填一个,同时填会报错。
场景一:未登录,用邮箱验证码找回密码
分两次请求:先要码,再拿码换新密码。
// /backend/func/auth/reset-password.ts
import type { TalizenFuncContext } from 'talizen/func-runtime'
const SCENE = 'reset_password'
const MIN_PASSWORD = 8
// 第一步:要验证码
export function requestCode(input: { email?: string }, ctx: TalizenFuncContext) {
const email = (input.email ?? '').trim()
if (!email) throw new Error('请填写邮箱')
// 先查有没有这个账号,再决定要不要发信
const user = ctx.users.find({ email })
if (user) {
try {
ctx.email.sendCode({ to: email, scene: SCENE })
} catch (err) {
// 发信失败(限频、集成异常)也不要抛给浏览器,写进日志自己看
console.error('send reset code failed', String(err))
}
}
// 无论账号是否存在,返回同一个结果
return { ok: true }
}
// 第二步:验码并改密码,同一次请求内完成
export function confirm(
input: { email?: string; code?: string; password?: string },
ctx: TalizenFuncContext,
) {
const email = (input.email ?? '').trim()
const code = (input.code ?? '').trim()
const password = input.password ?? ''
if (!email || !code) throw new Error('请填写邮箱和验证码')
if (password.length < MIN_PASSWORD) throw new Error('密码至少 8 位')
// 先确认账号存在,再验码:验证码命中即失效,对不存在的邮箱验码会白白
// 消耗掉真实用户的一次机会
const user = ctx.users.find({ email })
if (!user) throw new Error('验证码错误或已过期')
if (!ctx.email.verifyCode({ to: email, scene: SCENE, code }))
throw new Error('验证码错误或已过期')
// 验过了才走到这一行。setPassword 不接收验证码,能不能改由你上面的
// 代码决定
ctx.users.setPassword({ email, password })
return { ok: true }
}
scene 把不同用途的验证码隔开,只能用字母、数字、下划线、连字符,1–32 个字符。
用户在「登录」场景拿到的码,改密码时用不了。
三条必须遵守的规则
这三条都不会报错,写漏了站点照样能跑,但会留下真实的问题:
| 必须这么做 | 否则 |
|---|---|
发码之前先 ctx.users.find | 任何人都能用你的「忘记密码」表单,让你的域名往任意邮箱发信——花你的钱,也伤发件域名的信誉 |
| 账号存在与不存在,返回同一个结果 | 返回 { found: false } 就等于提供了一个查询接口,任何人都能逐个探测哪些邮箱在你站点上注册过 |
捕获 setPassword 的报错,换成统一提示 | 同上:404「用户不存在」直接回给浏览器,效果和上一条一样 |
页面上配合这一点:无论接口返回什么,都显示同一句提示,比如「如果该邮箱已注册, 我们已经发送了验证码」,不要根据返回值分支。
场景二:登录状态下修改密码
先用 ctx.users.checkPassword 验原密码,再改:
// /backend/func/auth/change-password.ts
import type { TalizenFuncContext } from 'talizen/func-runtime'
export function main(
input: { oldPassword: string; password: string },
ctx: TalizenFuncContext,
) {
const user = ctx.auth.requireUser()
if (input.password.length < 8) throw new Error('密码至少 8 位')
if (!ctx.users.checkPassword({ userId: user.id, password: input.oldPassword }))
throw new Error('原密码不正确')
ctx.users.setPassword({ userId: user.id, password: input.password })
return { ok: true }
}
checkPassword 返回布尔值:密码不对返回 false,不抛错。账号是用 Google 等
第三方登录注册的、没有设过密码时也返回 false——这类账号要改密码,改用上面场景一那套
邮箱验证码流程(收件人用 ctx.auth.requireUser().email)。
同一个人连续验错密码太多次,
checkPassword会抛 429(锁 1 小时)。 这个额度与登录接口是同一份,所以刚才登录失败几次的人,改密码时也会更早被锁。 把 429 的提示文案单独写一下(「尝试次数过多,请稍后再试」),别混进「原密码不正确」里, 否则用户会一直重试到锁死。
页面代码
import { useState } from 'react'
import { invoke } from 'talizen/func'
import { useAuth } from 'talizen/auth'
export function ForgotPasswordForm() {
const [step, setStep] = useState<'email' | 'code'>('email')
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
const [password, setPassword] = useState('')
const [hint, setHint] = useState('')
async function requestCode() {
await invoke('auth/reset-password.requestCode', { email })
// 固定文案:不要根据返回值判断这个邮箱有没有注册
setHint('如果该邮箱已注册,我们已经发送了验证码')
setStep('code')
}
async function confirm() {
try {
await invoke('auth/reset-password.confirm', { email, code, password })
window.location.href = '/login'
} catch (err) {
setHint(err instanceof Error ? err.message : '重置失败,请重试')
}
}
// ...表单略
}
// 登录状态下修改密码
async function changePassword(oldPassword: string, password: string) {
await invoke('auth/change-password', { oldPassword, password })
await useAuth().logout() // 会话已在服务端失效,本地状态也清掉
window.location.href = '/login'
}
不要自己实现的部分
下面这些已经由平台保证,在 Func 里重写一遍只会更弱:
| 不要写 | 已经有的行为 |
|---|---|
用 Math.random() 生成验证码、用 ctx.db 或 ctx.cache 存验证码 | sendCode 生成的码默认 6 位、10 分钟有效、命中即失效、常数时间比对(位数与有效期在集成里配) |
| 自己数「验证码错了几次」 | 同一个码猜错 5 次自动作废 |
| 自己限制「多久能再发一封」 | 同一收件人 10 分钟最多 5 封,单项目每天 500 封 |
| 自己给密码做哈希,或把密码存进 JSON 表 | setPassword 用 bcrypt,明文不落库、不进日志 |
| 改完密码后自己去清会话 | setPassword 自动清掉该用户全部会话(你也没有会话表的入口) |
| 自己数「密码猜错几次」做锁定 | checkPassword 与登录接口共用同一份额度,错 5 次锁 1 小时 |
要你自己写的只有三件:密码长度与强度规则(平台只拒绝空密码)、 凭什么允许改(验证码、原密码,或你自己的风控),以及提示文案与跳转。
错误对照
| 情况 | 行为 |
|---|---|
find 没找到用户 | 返回 null,不抛错 |
| 验证码不对、已过期、根本没发过 | verifyCode 一律返回 false,不区分 |
| 同一收件人频繁要码 | sendCode 抛错(限频) |
| 项目没连邮件集成 | sendCode 抛错,提示去集成面板配置 |
setPassword 的用户不存在 | 抛错(404)。捕获它,不要原样回给浏览器 |
| 账号只用第三方登录,没有密码 | setPassword 抛 404、checkPassword 返回 false。平台不会给它新建一条密码登录方式 |
| 账号被停用 | 抛错(403)。改了密码也登不进去,所以直接失败 |
| 同一个邮箱对应多个账号 | setPassword({ email }) 抛 409,不会改「第一个」。改用 account 或 userId |
checkPassword 连续验错太多次 | 抛 429,锁 1 小时,与登录接口共用额度 |
| 三个字段填了两个以上 | 抛错(400)。email、account、userId 只能填一个 |
自测
不用先发布站点。在编辑器的 Func 编辑器里直接运行方法并填入示例输入,或者用 CLI:
echo '{"email":"you@example.com"}' > input.json
creght func run --site_id=<project_id>/<site_id> \
--key=auth/reset-password.requestCode \
--input=input.json
先用一个真实注册过的邮箱跑 requestCode,确认收到码;再用一个没注册过的邮箱跑一次,
确认返回值一模一样而且没有收到信——这就验证了上面第二条规则。
