ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

TanStack Start 服务端认证原语实战:会话 Cookie、OAuth、CSRF 与限流的完整实现指南

TanStack Start 服务端认证原语实战:会话 Cookie、OAuth、CSRF 与限流的完整实现指南 TanStack Start 服务端认证原语实战会话 Cookie、OAuth、CSRF 与限流的完整实现指南【免费下载链接】router A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router本指南围绕 TanStack Starttanstack/react-start的服务端认证原语展开系统讲解会话存储与 Cookie 签发、基于createServerFn与中间件的会话校验、OAuth 授权码 PKCE 流程、密码重置防枚举、非 GET RPC 的 CSRF 防护、认证端点限流以及权限变更时的会话轮换。读完本文你将掌握在 TanStack Start 中从零构建一套生产级服务端认证体系的能力并理解路由守卫与数据边界之间必须严格区分的安全模型。本文内容以仓库中的技能文档 auth-server-primitives/SKILL.md 为主体骨架结合 start-client-core 与 start-server-core 的源码实现和 basic-auth 端到端示例 进行纵深展开。认证的服务端一半与路由一半TanStack Start 的认证体系由两半组成二者职责严格分离服务端一半本文主题会话存储、Cookie 签发与读取、OAuth 流程、密码重置加固、CSRF、限流。全部运行在服务器上属于数据安全边界。路由一半_authenticated布局路由、beforeLoad重定向、RBAC 检查等页面级 UX 控制详见 router-core/auth-and-guards/SKILL.md。CRITICAL优先保护数据/API 边界。凡是读写私有数据的服务端函数createServerFn、服务端路由和其他 API 端点必须在handler 内部或中间件中强制鉴权。路由守卫是路由层的 UX不是数据安全边界。CRITICAL校验形状不等于授权。z.string().uuid().parse(...)只能证明一个 UUID 格式合法它仍然是某个租户的 ID——在使用该 ID 之前必须回到会话主体session principal重新校验成员关系。CRITICAL会话/Cookie 的读取必须放在.handler()或中间件.server()中而不是模块顶层。模块级读取发生在任何请求存在之前在 Cloudflare Workers 等边缘运行时上还会得到undefined。在生产环境上线前逐条核对以下检查清单所有读取/写入私有用户、租户或账户数据的服务端函数、服务端路由或 API 端点都强制鉴权beforeLoad只用于页面 UX不作为数据边界。每个接收输入的服务端函数都使用.validator()。会话存放在HttpOnly、Secure、SameSiteCookie 中绝不将会话令牌放进localStorage或sessionStorage。密码使用 bcrypt、scrypt 或 Argon2 哈希对不存在的用户用假哈希dummy hash校验并返回完全相同的登录/重置消息。登录、注册、密码重置端点都做限流。非 GET 的服务端函数与服务端路由启用 CSRF 或同源保护。记录认证事件并监控失败。直接测试对受保护服务端函数的未认证直连调用——它应在返回任何数据之前被拒绝。跟随匿名重定向并检查其 HTML 与序列化状态登录页和未授权页不得泄露受保护的用户、租户或记录信息。会话 Cookie把每一个标志位都设置到位推荐的会话存储方式是一个仅 HTTP 的 Cookie其中存放不透明会话 ID服务端查表或签名/加密令牌。Cookie 标志位直接决定安全性必须全部设置// src/server/session.ts import { getRequestHeader, setResponseHeader, } from tanstack/react-start/server const SESSION_COOKIE __Host-session // __Host- 前缀将 Cookie 绑定到精确源 路径 / const ONE_DAY 60 * 60 * 24 export function setSessionCookie(token: string) { setResponseHeader( Set-Cookie, [ ${SESSION_COOKIE}${token}, HttpOnly, // JS 无法读取——抵御 XSS 窃取 Secure, // 仅 HTTPS__Host- 前缀的强制要求 SameSiteLax, // 顶层导航会携带挡住大部分 CSRF Path/, // __Host- 前缀的强制要求 Max-Age${ONE_DAY}, ].join(; ), ) } export function clearSessionCookie() { setResponseHeader( Set-Cookie, ${SESSION_COOKIE}; HttpOnly; Secure; SameSiteLax; Path/; Max-Age0, ) } export function readSessionToken(): string | null { const header getRequestHeader(cookie) if (!header) return null for (const part of header.split(/;\s*/)) { // 只在第一个 处切分——签名/base64 值中常含 。 const eq part.indexOf() if (eq -1) continue if (part.slice(0, eq) SESSION_COOKIE) return part.slice(eq 1) } return null }各标志位的作用原理HttpOnly— JavaScript 无法读取该 Cookie即使存在 XSS 漏洞也无法窃取会话。Secure— 仅允许 HTTPS 传输使用__Host-前缀时强制要求。SameSiteLax— 阻止绝大多数跨站 POST/PUT/DELETE 型 CSRF对安全要求最高的流程可接受丢失跨站 GET 导航可使用Strict。__Host-前缀— 将 Cookie 绑定到精确源禁止Domain属性、Path必须是/、必须设置Secure。防止子域接管者伪造会话 Cookie。Path/—__Host-前缀的强制要求。Max-Age— 有限生命周期防止被盗 Cookie 永久有效配合服务端会话轮换使用。getRequestHeader/setResponseHeader等工具在 start-server-core/src/request-response.ts 中实现底层基于 h3-v2 的H3Event与AsyncLocalStorage全局符号tanstack-start:event-storage确保跨 bundle 共享同一存储实例这正是请求/响应上下文只在请求生命周期内可读的实现根基也解释了为何模块级读取会失效。如果你更倾向于框架内置的密封会话sealed sessiontanstack/react-start/server还提供useSession()其配置项定义于 start-server-core/src/session.tspassword加密会话令牌的私钥、maxAge过期秒数、name默认start、cookie默认secure, httpOnly, /、sessionHeader与seal选项。仓库的 basic-auth 示例 正是用useSession 服务端函数实现的完整登录闭环。用中间件集中化会话加载把会话加载收敛到中间件里让每个受保护的 handler 都拿到带类型的会话// 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 } }) }, )把它挂到每一个需要登录用户的createServerFn上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()内自行复查。中间件的完整组合规则方法顺序middleware()→validator()→client()→server()、上下文透传、全局中间件createStart详见 start-core/middleware/SKILL.md。登录时签发会话恒定时间校验 权限变更轮换// 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) // 即使用户不存在也始终执行 verifyPasswordHash—— // 让用户不存在分支与密码错误分支耗时一致。 // DUMMY_PASSWORD_HASH 是在启动时用与真实哈希相同的算法/成本 // 对某个一次性口令计算得到的哈希。 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) // 权限变化时轮换销毁已有会话然后签发全新会话。 await db.sessions.revokeAllForUser(user.id) const token await db.sessions.create({ userId: user.id }) setSessionCookie(token) return { ok: true } })在仓库的 basic-auth 示例 中可以看到同样的模式loginFn是一个method: POST的createServerFn先查库、校验密码示例用 PBKDF2 实现hashPassword见 utils/prisma.ts生产环境应改用 bcrypt/scrypt/Argon2再通过useAppSession()更新会话数据。示例的根路由__root.tsx用服务端函数fetchUser在服务器上读取安全 Cookie 来判断登录态这正是读 Cookie 必须发生在请求回调内部的落地范本。登出撤销会话并清除 Cookieimport { 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 授权码流程中需要生成一次性stateCSRF 防御和 PKCEverifier防授权码被截获。两者都存入一个短生命周期、签名、一次性的 Cookie与本次登录尝试严格绑定// src/server/oauth.functions.ts import { createServerFn } from tanstack/react-start import { redirect } from tanstack/react-router import { getRequestHeader, setResponseHeader, } from tanstack/react-start/server import crypto from node:crypto const OAUTH_STATE_COOKIE __Host-oauth // 快速过期一次性 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(), ) setResponseHeader( Set-Cookie, ${OAUTH_STATE_COOKIE}${signed({ state, verifier })}; HttpOnly; Secure; SameSiteLax; Path/; Max-Age600, ) throw redirect({ href: https://provider.example/authorize ?response_typecode client_id${process.env.OAUTH_CLIENT_ID} redirect_uri${encodeURIComponent(process.env.OAUTH_REDIRECT_URI!)} state${state} code_challenge${challenge} code_challenge_methodS256, }) }, )在回调 handler 中必须验证 Cookie 中的 state 与返回的 state 一致并用 verifier 兑换授权码。如果 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) } // 无论用户是否存在始终返回 200 与相同的响应体。 // 只告知用户请检查收件箱不给出任何确认或否认。 return { ok: true } })禁止的做法存在返回 200、不存在返回 404。使用不同文案我们已发送链接 vs 未找到账号。用户不存在时跳过工作产生可从网络测量的时序泄漏。非 GET RPC 的 CSRF 防护会话 Cookie 上的SameSiteLax能挡住大多数跨站 POST/PUT/DELETE CSRF。但有两种情况需要额外防御会变更状态的顶层 GET 导航——永远不要这样做变更操作一律使用 POST/PUT/DELETE。来自兄弟子域页面的 POST——SameSiteLax挡不住这种情况需要在中间件中校验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) // 比较完整的源scheme host port——仅比较 host 会让 // http://example.com 通过本应为 https://example.com 设计的检查。 if (!origin || new URL(origin).origin ! process.env.APP_ORIGIN) { throw new Error(Origin check failed) } } return next() })把它挂到src/start.ts的全局请求中间件上即可覆盖包括服务端路由和 SSR 在内的所有非 GET 请求。值得指出的是仓库还内置了一个开箱即用的createCsrfMiddleware实现在 start-client-core/src/createCsrfMiddleware.ts优先校验Sec-Fetch-Site默认same-origin缺失时回退校验Origin默认与请求 URL 同源再回退校验Referer默认true支持filter过滤、origin/secFetchSite自定义匹配器值、数组或函数、allowRequestsWithoutOriginCheck与failureResponse默认403 Forbidden校验失败路径与上述手写中间件语义一致但覆盖了更完整的浏览器头协商场景。认证端点的手写中间件与框架内置 CSRF 中间件可以组合使用前者负责业务鉴权后者负责跨站请求防护。认证端点限流没有限流的登录端点就是撞库credential-stuffing的目标。按 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() }) } // 挂到登录服务端函数上 export const login createServerFn({ method: POST }).middleware([ rateLimitMiddleware({ key: login, max: 5, windowMs: 60_000 }), ]) // ...权限变化时的会话轮换用户的权限一旦变化——登录、登出、角色变更、密码变更——销毁旧会话并签发新会话。这能中和会话固定session-fixation攻击攻击者在受害者登录前把自己的会话 ID 植入其浏览器。// 登录 handler 中上文已展示销毁登录前存在的任何会话然后创建全新会话。 await db.sessions.revokeAllForUser(user.id) const token await db.sessions.create({ userId: user.id }) setSessionCookie(token)// 密码变更 / 授权提权时 await db.sessions.revokeAllForUser(user.id) // 销毁已有会话 const token await db.sessions.create({ userId: user.id }) // 签发全新会话 setSessionCookie(token)常见错误清单CRITICAL把路由守卫当作服务端函数的鉴权// 错误 —— 无论路由如何该 RPC 都可以通过 POST 直接调用 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() // ← 任何人都能直连 RPC 拿到全部订单 }) // 正确 —— 在 handler 本身上强制鉴权 const getMyOrders createServerFn({ method: GET }) .middleware([authMiddleware]) .handler(async ({ context }) { return db.orders.findMany({ where: { userId: context.session.userId } }) })这与 server-functions/SKILL.md 中服务端函数是可独立到达的 API 端点的警告完全一致路由beforeLoad是 UX端点鉴权才是数据安全边界。CRITICAL把形状校验当成授权一个解析成功的 UUID 是某个工作区而不是被授权访问的工作区// 错误 —— UUID 格式合法但用户可能不是成员 const getWorkspaceData createServerFn({ method: GET }) .middleware([authMiddleware]) .validator(z.object({ workspaceId: z.string().uuid() })) .handler(async ({ context, data }) { return db.workspaces.findById(data.workspaceId) // 缺少成员关系检查 }) // 正确 —— 校验会话主体对该工作区是否有访问权 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) })这条规则同样适用于客户端中间件sendContext传来的任何标识符——客户端能发送的客户端就能伪造会话必须来自 Cookie DB 查表这一服务端可信来源。HIGH根据邮箱是否存在返回不同响应上文已覆盖——requestPasswordReset无论邮箱是否匹配用户都必须返回相同的响应体。HIGH在模块顶层读取 Cookie / 环境变量// 错误 —— 模块加载时执行此时还没有任何请求 const SESSION_SECRET process.env.SESSION_SECRET export function signSession(payload) { return sign(payload, SESSION_SECRET) } // 正确 —— 在每个请求的回调内部读取 export function signSession(payload) { return sign(payload, process.env.SESSION_SECRET) }在 Cloudflare Workers 等边缘运行时上即使是在服务端模块级读取也会得到undefined——因为 env 是按请求注入的。参见 start-core/execution-model/SKILL.md。MEDIUM会话长期有效且从不轮换从不轮换的会话令牌在功能上等同于长期凭据。必须在登录、登出、密码变更、角色/权限变更时轮换。路由侧的配套实现本技能文档专注于服务端原语路由侧的配套模式_authenticated布局、beforeLoadredirect、RBAC 检查、isRedirect处理请参阅 router-core/auth-and-guards/SKILL.md。二者配合的典型形态是路由守卫负责页面导航体验未登录重定向到/login并携带redirect回跳参数服务端函数与中间件负责真正的数据安全。交叉参考router-core/auth-and-guards/SKILL.md — 路由侧_authenticated布局、beforeLoad、redirect、RBAC 检查。start-core/server-functions/SKILL.md — 如何暴露 RPC以及为什么路由守卫覆盖不到它们。start-core/middleware/SKILL.md — 组合authMiddleware及其他中间件。start-core/execution-model/SKILL.md — 为什么模块级 env/secret 读取是错的。basic-auth 示例 — 一个完整的、可运行的密码认证 会话 受保护路由闭环实现。【免费下载链接】router A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表