mcpField
A
auth-server-primitives

auth-server-primitives

github.com/tanstack安全15.3k192 次安装4 天前更新
代码仓库

关于 auth-server-primitives

面向 TanStack Start 开发者的服务端身份验证指南,涵盖会话 Cookie、OAuth、CSRF 防护和速率限制,用于保护函数、路由及账户流程。

主要功能

  1. 01会话 Cookie 处理
  2. 02身份验证中间件
  3. 03OAuth state 与 PKCE
  4. 04CSRF 防护
  5. 05速率限制与会话轮换

适用场景

  1. 01保护服务端函数中的私有数据
  2. 02构建登录与注销流程
  3. 03加强密码重置端点防护
  4. 04限制身份验证尝试

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/tanstack/router --skill auth-server-primitives

安装后在 Claude Code 中输入/auth-server-primitives确认已加载。

详细介绍

Auth Server Primitives

此技能涵盖身份验证的服务端部分:会话存储、Cookie 签发、OAuth 流程、密码重置加固、CSRF、速率限制。有关路由部分(_authenticated 布局、beforeLoad 重定向、RBAC 检查),请参阅 @tanstack/router-core#router-core/auth-and-guards。

关键:首先保护数据/API 边界。访问私有数据的服务端函数、服务端路由及其他 API 端点,必须在处理程序内部或中间件中强制执行身份验证。路由守卫用于路由 UX,不是数据安全边界。 关键:验证客户端提供的标识符的_格式_(z.string().uuid().parse(...))并不等于授权。解析后的 UUID 仍然属于_某个_租户——使用它之前,必须根据会话主体重新检查成员资格。 关键:在 .handler() 或中间件 .server() 中读取会话/Cookie,不要在模块作用域读取。模块级读取发生在请求到来之前(在 Cloudflare Workers 上也无法正常工作)。

生产环境检查清单

  • 对所有读取或写入用户、租户或账户私有数据的服务端函数、服务端路由或 API 端点强制执行身份验证。使用路由 beforeLoad 改善页面 UX,而不要将其作为数据边界。
  • 对所有接受输入的服务端函数使用 .validator()。
  • 将会话存储在 HttpOnly、Secure、SameSite Cookie 中。不要将会话令牌存储在 localStorage 或 sessionStorage 中。
  • 使用 bcrypt、scrypt 或 Argon2 哈希密码。对于不存在的用户,使用虚拟哈希进行验证,并返回相同的登录/重置消息。
  • 对登录、注册和密码重置端点进行速率限制。
  • 对非 GET 服务端函数和服务端路由使用 CSRF 或同源保护。
  • 记录身份验证事件并监控失败情况。
  • 测试对受保护服务端函数的未认证直接调用;它们应在返回数据前拒绝请求。
  • 遵循匿名重定向,并检查其 HTML 和序列化状态。登录页和未授权页面不得提及受保护的用户、租户或记录。

会话 Cookie

推荐使用 HTTP 专用 Cookie 存储会话,其中包含不透明的会话 ID(由服务端查询)或签名/加密令牌。Cookie 标志很重要——请全部设置。

// src/server/session.ts
import {
  deleteCookie,
  getCookie,
  setCookie,
} from '@tanstack/react-start/server'

const SESSION_COOKIE = '__Host-session' // __Host- prefix binds to the exact origin + path '/'
const ONE_DAY = 60 * 60 * 24

const sessionCookieOptions = {
  httpOnly: true, // not readable from JS — defeats XSS exfiltration
  secure: true, // HTTPS only (required for __Host- prefix)
  sameSite: 'lax', // sent on top-level navigations, blocks most CSRF
  path: '/', // required for __Host- prefix
} as const

export function setSessionCookie(token: string) {
  setCookie(SESSION_COOKIE, token, {
    ...sessionCookieOptions,
    maxAge: ONE_DAY,
  })
}

export function clearSessionCookie() {
  deleteCookie(SESSION_COOKIE, sessionCookieOptions)
}

export function readSessionToken(): string | null {
  return getCookie(SESSION_COOKIE) ?? null
}

使用 setCookie 写入 Cookie(如果 Cookie 已由某个库序列化,则使用 appendResponseHeader('set-cookie', serialized))。不要为此使用 setResponseHeader('Set-Cookie', ...):它会替换传出响应中的所有 Cookie,包括其他中间件设置的 Cookie 或处理程序返回的 Cookie。

标志说明:

  • HttpOnly — JavaScript 无法读取 Cookie,因此 XSS 漏洞无法窃取会话。
  • Secure — 仅限 HTTPS。使用 __Host- 前缀时必须设置。
  • SameSite=Lax — 阻止大多数跨源 POST/PUT/DELETE 请求中的 CSRF。在允许丢失跨站 GET 导航的高安全性流程中,可使用 Strict。
  • __Host- 前缀 — 将 Cookie 绑定到确切的源(不得设置 Domain 属性,Path 必须为 /,且必须设置 Secure)。防止子域接管者伪造会话 Cookie。
  • Path=/ — 使用 __Host- 时必须设置。
  • Max-Age — 设置有限的有效期,避免被盗 Cookie 永远有效。应配合服务端会话轮换。

将会话查询作为中间件

使用中间件集中加载会话,使每个受保护的处理程序都能获取类型化会话:

// src/server/auth-middleware.ts
import { createMiddleware } from '@tanstack/react-start'
import { readSessionToken } from './session'

export const authMiddleware = createMiddleware({ type: 'function' }).server(
  async ({ next }) => {
    const token = readSessionToken()
    const session = token ? await db.sessions.findValid(token) : null
    if (!session) throw new Error('Unauthorized')
    return next({ context: { session } })
  },
)

将其附加到所有需要已登录用户的服务端函数:

import { createServerFn } from '@tanstack/react-start'
import { authMiddleware } from '~/server/auth-middleware'

export const getMyOrders = createServerFn({ method: 'GET' })
  .middleware([authMiddleware])
  .handler(async ({ context }) => {
    return db.orders.findMany({ where: { userId: context.session.userId } })
  })

路由守卫无法保护这些函数。 带有 beforeLoad 重定向的 createFileRoute('/_authenticated/orders') 并不能保护 getMyOrders——无论用户是否访问过该路由,都可以直接调用 RPC。对每个需要身份验证的服务端函数应用 authMiddleware(或在 .handler() 内重新检查)。

登录时签发会话

// src/server/login.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { setSessionCookie } from './session'

export const login = createServerFn({ method: 'POST' })
  .validator(z.object({ email: z.string().email(), password: z.string() }))
  .handler(async ({ data }) => {
    const user = await db.users.findByEmail(data.email)
    // Always run verifyPasswordHash — even when the user doesn't exist —
    // so the user-not-found branch takes the same time as wrong-password.
    // DUMMY_PASSWORD_HASH is a hash of any throwaway password computed once
    // at startup with the same algorithm/cost as real password hashes.
    const hashToCheck = user?.passwordHash ?? DUMMY_PASSWORD_HASH
    const passwordMatches = await verifyPasswordHash(hashToCheck, data.password)
    const ok = user != null && passwordMatches
    if (!ok) throw new Error('Invalid email or password')

    // ROTATE on privilege change: destroy any existing session, then issue fresh.
    await db.sessions.revokeAllForUser(user.id)
    const token = await db.sessions.create({ userId: user.id })
    setSessionCookie(token)
    return { ok: true }
  })

登出

import { createServerFn } from '@tanstack/react-start'
import { authMiddleware } from '~/server/auth-middleware'
import { clearSessionCookie } from '~/server/session'

export const logout = createServerFn({ method: 'POST' })
  .middleware([authMiddleware])
  .handler(async ({ context }) => {
    await db.sessions.revoke(context.session.id)
    clearSessionCookie()
    return { ok: true }
  })

OAuth:state + PKCE

对于 OAuth 授权码流程,生成一次性 state(用于防范 CSRF)和 PKCE 验证器(用于防范授权码拦截)。将两者存储在短时有效的签名 Cookie 中,并将其关联到这次确切的登录尝试。

// src/server/oauth.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { redirect } from '@tanstack/react-router'
import { setCookie } from '@tanstack/react-start/server'
import crypto from 'node:crypto'

const OAUTH_STATE_COOKIE = '__Host-oauth' // expires fast; one-shot

function base64url(buf: Buffer) {
  return buf
    .toString('base64')
    .replace(/=/g, '')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
}

export const startOAuth = createServerFn({ method: 'GET' }).handler(
  async () => {
    const state = base64url(crypto.randomBytes(32))
    const verifier = base64url(crypto.randomBytes(32))
    const challenge = base64url(
      crypto.createHash('sha256').update(verifier).digest(),
    )

    setCookie(OAUTH_STATE_COOKIE, signed({ state, verifier }), {
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
      path: '/',
      maxAge: 600,
    })

    throw redirect({
      href:
        `https://provider.example/authorize` +
        `?response_type=code` +
        `&client_id=${process.env.OAUTH_CLIENT_ID}` +
        `&redirect_uri=${encodeURIComponent(process.env.OAUTH_REDIRECT_URI!)}` +
        `&state=${state}` +
        `&code_challenge=${challenge}` +
        `&code_challenge_method=S256`,
    })
  },
)

在回调处理器中,验证 Cookie 中的 state 与返回的 state 匹配,并使用验证器交换授权码。如果 state 缺失或不匹配,则中止操作——该请求并非来自你的 startOAuth。

密码重置:防止用户枚举

当用户请求重置密码时,不要让响应形式或响应时间泄露该邮箱是否已注册。

import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'

export const requestPasswordReset = createServerFn({ method: 'POST' })
  .validator(z.object({ email: z.string().email() }))
  .handler(async ({ data }) => {
    const user = await db.users.findByEmail(data.email)
    if (user) {
      const token = await db.passwordResets.issue(user.id)
      await sendResetEmail(user.email, token)
    }
    // Always 200, always the same body, regardless of whether the user exists.
    // The user is told to check their inbox; no confirmation either way.
    return { ok: true }
  })

切勿:

  • 如果用户存在则返回 200,不存在则返回 404。
  • 使用不同的消息(“我们已发送链接”与“未找到帐户”)。
  • 用户不存在时跳过处理(这会造成时间泄露,可通过网络测量)。

非 GET RPC 的 CSRF 防护

会话 Cookie 上的 SameSite=Lax 可阻止大多数跨站 CSRF POST/PUT/DELETE 请求。有两种情况需要额外防护:

  1. 会修改数据的顶级 GET 导航——绝不要这样做。修改数据时始终使用 POST/PUT/DELETE。
  2. 来自同级子域页面的 POST 请求——SameSite=Lax 无法阻止此类请求;请在中间件中验证 Origin 标头与应用的源一致。
import { createMiddleware } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'

export const csrfMiddleware = createMiddleware().server(async ({ next }) => {
  const request = getRequest()
  if (request.method !== 'GET' && request.method !== 'HEAD') {
    const origin = request.headers.get('origin')
    // Compare the FULL origin (scheme + host + port) — host alone lets
    // http://example.com pass a check meant for https://example.com.
    if (!origin || new URL(origin).origin !== process.env.APP_ORIGIN) {
      throw new Error('Origin check failed')
    }
  }
  return next()
})

将其附加到 src/start.ts 中的全局请求中间件,以覆盖每个非 GET 请求,包括服务器路由和 SSR。

对身份验证端点进行速率限制

没有速率限制的登录端点会成为凭证填充攻击的目标。按 IP 限制(最好也按帐户限制),并使用滑动窗口。

import { createMiddleware } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'

function rateLimitMiddleware(opts: {
  key: string
  max: number
  windowMs: number
}) {
  return createMiddleware().server(async ({ next }) => {
    const request = getRequest()
    const ip =
      request.headers.get('cf-connecting-ip') ??
      request.headers.get('x-forwarded-for')?.split(',')[0] ??
      'unknown'
    const bucketKey = `rl:${opts.key}:${ip}`
    const allowed = await rateLimiter.consume(
      bucketKey,
      opts.max,
      opts.windowMs,
    )
    if (!allowed) throw new Error('Too many requests')
    return next()
  })
}

// On the login server function:
export const login = createServerFn({ method: 'POST' }).middleware([
  rateLimitMiddleware({ key: 'login', max: 5, windowMs: 60_000 }),
])
// ...

权限变更时轮换会话

每当用户权限发生变化——登录、注销、角色变更、密码变更——销毁旧会话并签发新会话。这可以消除会话固定攻击:攻击者会在受害者登录前,将自己的会话 ID 植入受害者的浏览器。

// In the login handler (already shown above): destroy any pre-login session, then create a fresh one.
await db.sessions.revokeAllForUser(user.id)
const token = await db.sessions.create({ userId: user.id })
setSessionCookie(token)
// On password change / role grant:
await db.sessions.revokeAllForUser(user.id) // destroy existing
const token = await db.sessions.create({ userId: user.id }) // issue fresh
setSessionCookie(token)

常见错误

严重:将路由守卫视为服务器函数的身份验证机制
// WRONG — the RPC is callable directly via POST regardless of the route
export const Route = createFileRoute('/_authenticated/orders')({
  beforeLoad: ({ context }) => {
    if (!context.auth.isAuthenticated) throw redirect({ to: '/login' })
  },
})
const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
  return db.orders.findMany() // ← anyone can hit the RPC and get all orders
})

// CORRECT — auth enforced on the handler itself
const getMyOrders = createServerFn({ method: 'GET' })
  .middleware([authMiddleware])
  .handler(async ({ context }) => {
    return db.orders.findMany({ where: { userId: context.session.userId } })
  })
严重:将格式验证视为授权

解析后的 UUID 代表_某个_工作区,并不代表_已授权的_工作区。

// WRONG — UUID is well-formed but the user may not be a member
const getWorkspaceData = createServerFn({ method: 'GET' })
  .middleware([authMiddleware])
  .validator(z.object({ workspaceId: z.string().uuid() }))
  .handler(async ({ context, data }) => {
    return db.workspaces.findById(data.workspaceId) // missing membership check!
  })

// CORRECT — verify the session principal has access to that workspace
const getWorkspaceData = createServerFn({ method: 'GET' })
  .middleware([authMiddleware])
  .validator(z.object({ workspaceId: z.string().uuid() }))
  .handler(async ({ context, data }) => {
    const member = await db.memberships.find({
      userId: context.session.userId,
      workspaceId: data.workspaceId,
    })
    if (!member) throw new Error('Not a member of this workspace')
    return db.workspaces.findById(data.workspaceId)
  })
高:根据邮箱是否存在返回不同响应

上文已提及——无论邮箱是否匹配某个用户,requestPasswordReset 都必须返回相同的响应正文。

高:在模块作用域读取 Cookie/env
// WRONG — module-load time, before any request exists
const SESSION_SECRET = process.env.SESSION_SECRET
export function signSession(payload) {
  return sign(payload, SESSION_SECRET)
}

// CORRECT — read inside per-request callback
export function signSession(payload) {
  return sign(payload, process.env.SESSION_SECRET)
}

在 Cloudflare Workers 和其他边缘运行时中,由于 env 是按请求注入的,即使在服务器上,模块级读取也会得到 undefined。参见 start-core/execution-model。

中:会话长期有效且不轮换

永不轮换的会话令牌,实际上就是长期有效的凭证。请在登录、注销、密码变更以及角色/权限变更时轮换会话。

交叉引用

  • @tanstack/router-core#router-core/auth-and-guards — 路由方面:_authenticated 布局、beforeLoad、redirect、RBAC 检查。
  • start-core/server-functions — 如何公开 RPC(以及路由守卫为何无法覆盖这些 RPC)。
  • start-core/middleware — 如何组合 authMiddleware 等中间件。
  • start-core/execution-model — 为什么在模块级读取 env/密钥是错误的。

评论

支持 Markdown