登录与用户实现找回密码与修改密码

实现找回密码与修改密码

站点的「忘记密码」和「修改密码」写在 Func 里:ctx.users.find 决定要不要发验证码,ctx.email.verifyCode 验码,ctx.users.setPassword 改密码。含两个场景的完整代码、三条不写就会出问题的规则,以及哪些事不用自己实现。

在站点上做「忘记密码」和「修改密码」:Creght 没有现成的找回密码接口, 客户端 SDK 里也没有 resetPassword()。你在 Func 里写这两个方法,页面用 invoke() 调它们。 密码哈希、验证码的一次性与限频、改完清掉旧会话、密码爆破限流由平台保证,你只写「凭什么允许改」。

准备

  1. 项目已连接邮件集成(编辑器 后端 → 集成),见 使用集成发送邮件与验证码。没有它就发不出验证码。
  2. talizen 客户端 0.2.37 或更高,只影响编辑器里的类型提示。

与注册设置无关:Auth 面板里的「开放注册」「注册必须验证邮箱」「仅后端函数」怎么设, 都不影响找回密码。不用去改它们。

文件与调用名

Func 是站点源文件,路径去掉扩展名就是调用 key,. 后面是方法名:

文件页面里怎么调
/backend/func/auth/reset-password.tsinvoke('auth/reset-password.requestCode', …)
invoke('auth/reset-password.confirm', …)
/backend/func/auth/change-password.tsinvoke('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 的三个方法都能指到项目里任何一个用户,所以指错人就是改错账号。 emailaccountuserId 只能填一个,同时填会报错。

场景一:未登录,用邮箱验证码找回密码

分两次请求:先要码,再拿码换新密码。

// /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.dbctx.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,不会改「第一个」。改用 accountuserId
checkPassword 连续验错太多次抛 429,锁 1 小时,与登录接口共用额度
三个字段填了两个以上抛错(400)。emailaccountuserId 只能填一个

自测

不用先发布站点。在编辑器的 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,确认收到码;再用一个没注册过的邮箱跑一次, 确认返回值一模一样而且没有收到信——这就验证了上面第二条规则。

相关:注册时验证邮箱使用集成发送邮件与验证码使用 Func 构建站点后端能力

Render diagnostics