Skip to content

认证系统

IAM 使用 Better Auth 1.7.1 提供邮箱密码、企业微信登录、会话和 OAuth/OIDC Provider 能力。

认证系统使用 Better-Auth,配置在 packages/auth/src/index.ts

import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { admin, genericOAuth, jwt } from 'better-auth/plugins'
import { oauthProvider } from '@better-auth/oauth-provider'
import { authRateLimitConfig } from './rate-limit'
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
schema: schema
}),
trustedOrigins: [env.CORS_ORIGIN],
emailAndPassword: {
enabled: true,
disableSignUp: true,
requireEmailVerification: true,
minPasswordLength: 8,
maxPasswordLength: 64,
resetPasswordTokenExpiresIn: 60 * 60,
revokeSessionsOnPasswordReset: true,
sendResetPassword: async ({ user, url }) => {
await sendAuthEmail(/* ... */)
}
},
emailVerification: {
sendOnSignUp: false,
sendOnSignIn: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => {
await sendAuthEmail(/* ... */)
}
},
advanced: {
ipAddress: {
ipAddressHeaders: ['cf-connecting-ip', 'x-real-ip', 'x-forwarded-for']
}
},
rateLimit: authRateLimitConfig,
plugins: [
admin(),
jwt(),
genericOAuth({ config: [wecomProvider(/* ... */)] }),
oauthProvider({
/* loginPage、consentPage、claims */
})
]
})
  • database: 使用 Drizzle 适配器连接 PostgreSQL
  • trustedOrigins: 允许的跨域来源
  • emailAndPassword: 启用邮箱密码作为企微开户后的备用登录;禁止公开注册;未验证邮箱不能建立密码登录会话
  • emailVerification: 未验证用户尝试密码登录时补发 24 小时有效的验证邮件
  • advanced.ipAddress: 在 Cloudflare Workers 上读取真实客户端 IP,供限流使用
  • rateLimit: 使用 Postgres rate_limit 表做原子计数,覆盖登录、注册、重置密码和 OAuth token/revoke/introspect
  • plugins: 通过插件扩展能力
    • admin(): 提供后台管理与更多模型
    • jwt(): JWT Token 支持
    • genericOAuth(): 当前仅注册企业微信 wecom
    • oauthProvider(): OAuth 2.1 / OIDC Provider 功能,详见 OAuth Provider 指南

认证邮件由 packages/auth/src/email.ts 统一发送。生产通过 Cloudflare EMAIL Binding;本地没有 Binding 时把邮件内容输出到服务端日志。企业微信占位邮箱(@wecom.invalid / @wecom.com)不可投递,会被跳过。

Cloudflare Workers 是多实例环境,Better Auth 默认的内存限流无法全局共享计数。当前实现用单条 SQL UPSERT 写入 rate_limit 表,窗口过期后重置计数。

不要把 Better Auth secondaryStorage 接到 Redis:1.7 的 secondary storage 还会承载 session、verification,启用后会改变存储语义。会话仍保存在 Postgres。

启用认证插件后的数据库更新流程

Section titled “启用认证插件后的数据库更新流程”

当你调整 Better Auth 的插件组合、用户附加字段或 OAuth/OIDC 配置时,需要同步更新数据库结构。

当前项目主要使用:

import { admin, genericOAuth, jwt } from 'better-auth/plugins'
export const auth = betterAuth({
// ...database、trustedOrigins、emailAndPassword、advanced 等配置
plugins: [admin(), jwt(), genericOAuth(/* ... */), oauthProvider(/* ... */)]
})

以上配置变化可能会影响认证相关表结构,因此需要按以下步骤更新数据库:

  1. 根据最新配置生成/更新 Drizzle Schema

在仓库根目录执行:

Terminal window
pnpm dlx auth@1.7.1 generate --yes \
--config packages/auth/src/index.ts \
--output packages/db/src/schema/auth.ts
  • --config:告诉 CLI 配置文件在 packages/auth/src/index.ts
  • --output:将根据插件生成的最新 Schema 输出到 packages/db/src/schema/auth.ts
  • 运行前请在根目录的 .env 中配置好以下环境变量:
    • DATABASE_URL
    • BETTER_AUTH_SECRET
    • BETTER_AUTH_URL
    • CORS_ORIGIN
  1. 生成数据库迁移文件
Terminal window
pnpm db:generate

该命令会通过 Turbo 在 @IAM/db 包中执行 drizzle-kit generate,在 packages/db/src/migrations 目录下生成迁移文件并更新 meta/_journal.json

  1. 应用迁移到数据库
Terminal window
pnpm db:migrate

该命令会在 @IAM/db 包中执行 drizzle-kit migrate,真正把上述变更应用到 DATABASE_URL 所指向的数据库中。

db:push 只用于可丢弃的本地数据库临时验证。正式变更必须生成并提交 migration。

小结:启用/调整 Better-Auth 插件后,一定要先用 CLI 重新生成 Schema,然后通过 db:generate / db:migrate 更新数据库结构。

在 React 组件中使用认证客户端:

import { authClient } from '@/lib/auth-client'
// 注册
await authClient.signUp.email({
email: 'user@example.com',
password: 'password123',
name: 'User Name'
})
// 登录
await authClient.signIn.email({
email: 'user@example.com',
password: 'password123'
})
// 登出
await authClient.signOut()

认证路由在 apps/server/src/index.ts 中配置:

app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw))

所有 /api/auth/* 路径的请求都会被 Better-Auth 处理。

Better-Auth 自动管理用户会话:

  • HTTP-only cookies: 使用安全的 HTTP-only cookies 存储会话
  • 跨域支持: 支持跨域请求(CORS 配置)
  • 自动刷新: 自动处理会话刷新
import { authClient } from '@/lib/auth-client'
const session = await authClient.getSession()
if (session) {
console.log('User:', session.user)
}

在 tRPC 上下文中:

packages/api/src/context.ts
export async function createContext({ context }: CreateContextOptions) {
const session = await auth.api.getSession({
headers: context.req.raw.headers
})
return { session }
}

apps/web 不直接导入 @IAM/auth。当前项目使用 apps/web/src/proxy.ts,通过同源 get-session 请求和 AUTH_API Service Binding 保护 /admin/profile/apps 和资料补全页面,并根据平台角色做管理端白名单判断。

使用 protectedProcedure 创建受保护的过程:

import { protectedProcedure } from '@IAM/api'
export const appRouter = router({
privateData: protectedProcedure.query(({ ctx }) => {
// ctx.session 包含当前用户会话
return {
message: 'This is private',
user: ctx.session.user
}
})
})

在受保护的过程中检查用户权限:

protectedProcedure.query(({ ctx }) => {
if (!ctx.session) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Authentication required'
})
}
// 使用 ctx.session.user 访问用户信息
return { userId: ctx.session.user.id }
})
  1. 用户在前端填写注册表单
  2. 调用 authClient.signUp.email()
  3. Better-Auth 验证输入并创建用户
  4. 自动创建会话并设置 cookie
  5. 用户被重定向到受保护页面

注册时会发送邮箱验证邮件。未验证邮箱的用户会被引导到完善资料页完成验证;验证通过前不会向 CRM 等下游系统签发可用于首次绑定的邮箱 claims。

  1. 用户在 /forgot-password 提交邮箱
  2. Better Auth 生成 1 小时有效的重置链接
  3. 用户在 /reset-password 设置至少 8 位的新密码
  4. 密码更新成功后,旧会话自动失效

为防止账号枚举,无论邮箱是否存在,请求接口都会返回相同的成功提示。企微占位邮箱 (@wecom.invalid / @wecom.com)不可接收重置邮件;用户需先通过企业微信登录并完善真实邮箱。 生产环境通过 Cloudflare Email Binding 发送邮件,发送任务使用 Worker waitUntil 执行,并在日志中记录 requestId、Cloudflare messageId、收件域名和主题,便于排查投递问题且不记录完整收件地址。

  1. 用户在前端填写登录表单
  2. 调用 authClient.signIn.email()
  3. Better-Auth 验证凭据
  4. 创建会话并设置 cookie
  5. 用户被重定向到受保护页面
  1. 用户点击登出按钮
  2. 调用 authClient.signOut()
  3. Better-Auth 清除会话
  4. 用户被重定向到登录页面

企业微信只提供 CorpID + UserID,不把通讯录邮箱当作可信绑定依据。首次登录必须走完善资料页:

  1. 用户使用企业微信登录,IAM 以 CorpID + UserID 建立本地账号
  2. IAM 要求填写可投递的企业邮箱并设置密码
  3. 发送验证邮件;验证成功前不能续接 OAuth
  4. 验证通过后,ID Token / UserInfo 返回 emailemail_verified=true
  5. CRM 等业务系统仅在 email_verified === true 时按邮箱做一次首次绑定,并立即写入 iam_subject(即 IAM sub
  6. 后续登录只按 iam_subject 识别用户,邮箱变更不再导致账号漂移

IAM 身份主键始终是用户 id(OIDC sub),不是邮箱。占位地址 @wecom.invalid / @wecom.com 不会出现在 OAuth claims 中。

  1. 密码强度: 确保密码符合安全要求
  2. HTTPS: 在生产环境使用 HTTPS
  3. Cookie 安全: 使用 secure 和 httpOnly cookies
  4. CORS 配置: 正确配置允许的来源
  5. 会话过期: 设置合理的会话过期时间

apps/web/src/lib/auth-client.ts 中配置认证客户端:

import { oauthProviderClient } from '@better-auth/oauth-provider/client'
import { createAuthClient } from 'better-auth/react'
import { adminClient, customSessionClient } from 'better-auth/client/plugins'
export const authClient = createAuthClient({
// 省略 baseURL,浏览器同源访问 https://iam.cq-i.cn/api/auth。
// Web Worker 会将 /api/* 代理到 Hono 内部上游。
plugins: [adminClient(), oauthProviderClient(), customSessionClient()]
})
  • adminClient(): 管理端 API(用户管理、会话管理等)
  • oauthProviderClient(): OAuth Provider API(客户端管理、授权管理等)
  • customSessionClient(): 推断自定义 session 字段,例如 isAdminprofileCompleted
  • 平台角色为 admin / user,控制 IAM 控制台权限。
  • BOOTSTRAP_ADMIN_WECOM_USER_IDS 非空时,是企微用户平台角色的权威白名单;名单外企微用户会降为 user
  • OAuth 客户端可以独立定义应用角色,并按用户或部门分配。
  • 应用角色通过 ID Token/UserInfo 的 app_role 下发,不等同于平台管理员角色。

当 IAM 系统作为 OAuth Provider 时,授权码流程的核心链路如下:

  1. 第三方客户端将用户重定向到 IAM 的授权端点
  2. 若用户未登录,IAM 先引导用户完成本地登录,再恢复 OIDC 流程
  3. 用户确认授权(或自动跳过),IAM 签发 authorization_code
  4. 第三方客户端用 code 在后端换取 access_token

完整流程详解(含时序图、每一步的实现细节和常见误区)请参阅 OAuth Provider 授权流程