Blitz 尚在 beat 阶段! 🎉 预计会在今年的 Q3 季度发布 1.0
Back to Documentation Menu

会话管理

Topics

Jump to a Topic

Blitz 内置了会话管理,可用于身份提供商或任何类型的身份验证。

会话管理包含以下功能:

  1. 跟踪用户是否登录
  2. 将多个请求归于同一个用户,即使他们已注销
  3. 防范 CSRF 攻击

太长不读型

你可以通过 SessionContext 对象登录、注销或以其它方式 修改会话,该对象可在服务器上的任何地方访问

登录

对于登录,你的 UI 中将有可以提交如下所示的登录 mutation 的表单组件。

// app/auth/mutations/login.ts
import { Ctx } from "blitz"

export default async function login(input: SomeTSInputType, ctx: Ctx) {
  // 1. 验证输入的数据
  // 2. 验证用户凭据
  // 3. 获取用户的数据

  // 4. 创建一个新的 session(登录)
await ctx.session.$create({ userId: user.id, role: user.role })
}

注销

对于注销,你的 UI 中将有可以提交如下所示的注销 mutation 的表单组件。

撤销一个会话将立即删除所有客户端查询缓存,从而导致页面的所有查询都需要被重 新加载。这可以确保删除缓存中的任何敏感数据。

// app/auth/muations/logout.ts
import { Ctx } from "blitz"

export default async function logout(_: any, ctx: Ctx) {
  // 1. 撤销当前用户的 session,进行注销
return await ctx.session.$revoke()
}

改变当前用户的会话公共数据

每个会话都有 PublicData,这些数据可用被客户端使用,并且由 于它存储在任何 JavaScript 代码可读取的 cookie 中,有可能被第三方库读取。这 常被用来存储当前的用户 ID、用户角色,可能还有当前的组织 ID。

你可以在任何 query 或 mutation 中更改会话的公共数据,如下所示:

// app/mutations/someMutation.ts
import { Ctx } from "blitz"

export default async function someMutation(input: any, ctx: Ctx) {
  // 这会将输入数据与当前 publicData 中已有的数据合并
await ctx.session.$setPublicData({ orgId: 1 })
}

在服务端访问会话

在 Queries 和 Mutations 中

SessionContext 可以从 ctx 中使用,因为 sessionMiddleware 配置于 blitz.config.js 中,它可以作为第二个参数提供 给所有 queries 和 mutations。

// app/queries/someQuery.ts
import { Ctx } from "blitz"

export default async function someQuery(input: any, ctx: Ctx) {
  // 访问 SessionContext 类
  ctx.session.userId
  ctx.session.role
  ctx.session.$create(/*...*/)

  return
}

getServerSideProps 或 API 路由中

你还可以在 getServerSideProps 或 API 路由中使用 getSession 获取会话上 下文,如下所示:

import { getSession } from "blitz"

export const getServerSideProps = async ({ req, res }) => {
  const session = await getSession(req, res)
  console.log("User ID:", session.userId)

  return { props: {} }
}

在客户端访问会话

Blitz 提供了一个 useSession() Hook,它返回带有 isLoading 属性的 PublicData。这个 Hook 可以在你的应用中的任何地方使用。

注意:useSession() 默认使用 Suspense,所以你需要在包含它的组件树之上有一 个 <Suspense> 组件。或者你可以设置 useSession({ suspense: false }) 来 禁用 Suspense。

import { useSession } from "blitz"

function SomeComponent() {
  const session = useSession()

  session.userId
  session.role

  return /*... */
}

如果你正在使用 getServerSideProps,那么你可以 使用 initialPublicData 选项将会话公共数据传递给 useSession()

import { useSession, GetServerSideProps } from "blitz"

export const getServerSideProps: GetServerSideProps = async ({
  req,
  res,
}) => {
  const session = await getSession(req, res)

  return { props: { initialPublicData: session.$publicData } }
}

const SomePage: BlitzPage = ({ initialPublicData }) => {
  const session = useSession({ initialPublicData })

  return /*... */
}

生产环境要求

生产环境中,你必须提供其值至少 32 个字符的 SESSION_SECRET_KEY 环境变量。 这是你用于签署 JWT 令牌的秘钥。

在 macOS 和 Linux 上,你可以通过在终端运行 openssl rand -hex 16 来生成它 。

匿名会话

如果用户没有登录,将自动为他们创建一个匿名会话。你可以和登录用户类似地将 ctx.session.$setPublicData()ctx.session.$setPrivateData() 用于匿名 会话。当用户登录时,你为匿名会话设置的任何数据都将自动传输到身份验证过的会 话中。

匿名会话通过将 JWT 令牌存储到客户端上实现永不过期的 httpOnly cookie。

匿名会话的 PublicData 保存到会话 JWT 中,不存储在数据库中。只有你调用 session.$setPrivateData(),匿名会话才会保存在你的数据库中。

匿名会话将在第一次网络请求时创建,无论这次请求是 SSR 还是 API。只要 sessionMiddleware 位于该请求的中间件中就会执行这种情况。

一个用例是为匿名用户保存购物车内容。如果匿名用户稍后注册或登录,匿名会话数 据可以合并到为其新生成的经过身份验证的会话中。

匿名会话 PublicData 看起来会是这样:

{
  userId: null,
}

在 TypeScript 中自定义会话公共数据

如果使用 TypeScript,第一次在 types.ts 中更新 Session.PublicData 会像 这样:

import {DefaultCtx, SessionContext, SimpleRolesIsAuthorized} from "blitz"
import {User} from "db"

// 注意:你应该切换到 Postgres 并且给角色类型使用一个 DB 枚举类型
export type Role = "ADMIN" | "USER"

declare module "blitz" {
  export interface Ctx extends DefaultCtx {
    session: SessionContext
  }
  export interface Session {
    isAuthorized: SimpleRolesIsAuthorized<Role>
    PublicData: {
      userId: User["id"]
      role: Role
+     orgId: number
    }
  }
}

接着改变所有使用到 ctx.session.$create() 的地方为其添加新的字段:

ctx.session.$create({ userId: 1, role: "ADMIN", orgId: 1 })

你也可以使用 ctx.session.$setPublicData() 来为已登录的用户更新会话数据。 这将与已存在的公共数据进行 合并

ctx.session.$setPublicData({ orgId: 1 })

要访问客户端上的公共数据:

import { useSession } from "blitz"

function SomeComponent() {
  const session = useSession()

  session.orgId

  return /*... */
}

在服务端上访问公共数据:

// app/queries/someQuery.ts
import { Ctx } from "blitz"

export default async function someQuery(input: any, ctx: Ctx) {
  // 访问 SessionContext 类
  ctx.session.orgId

  return
}

会话配置

你可以通过将对象传递给 sessionMiddleware 工厂函数来自定义会话管理。

// blitz.config.js
const { sessionMiddleware, simpleRolesIsAuthorized } = require("blitz")

module.exports = {
  middleware: [
    sessionMiddleware({
cookiePrefix: "my-app", sessionExpiryMinutes: 1234, isAuthorized: simpleRolesIsAuthorized,
}), ], }

可用选项:

type SessionConfig = {
  cookiePrefix?: string /* 默认:'blitz' */
  sessionExpiryMinutes?: number /* 默认:30 days */
  sameSite?: "strict" | "lax" | "none" /* 默认:'lax' */
  domain?: string /* 默认:undefined。可以被设置为 `.yourDomain.com` 来工作在子域名上 */
  publicDataKeysToSyncAcrossSessions?: string[] /* 默认:['role', 'roles'] */
  getSession: (handle: string) => Promise<SessionModel | null>
  getSessions: (userId: string | number) => Promise<SessionModel[]>
  createSession: (session: SessionModel) => Promise<SessionModel>
  updateSession: (
    handle: string,
    session: Partial<SessionModel>
  ) => Promise<SessionModel>
  deleteSession: (handle: string) => Promise<SessionModel>
  isAuthorized: ({ ctx: any, args: [...unknown] }) => boolean
}
interface SessionModel extends Record<any, any> {
  handle: string
  userId?: string | number
  expiresAt?: Date
  hashedSessionToken?: string
  antiCSRFToken?: string
  publicData?: string
  privateData?: string
}

自定义会话持久性和数据库访问

默认情况下,会话持久性是 Prisma 的零配置。但你可以自定义它来将会话保存在其 他地方,例如 Redis。如果你有 Prisma 但想要自定义用户或会话模型上的属性名称 ,你也可以自定义它。

可以通过重写上面在 SessionConfig 中定义的数据库访问函数来自定义会话持久 性。这个函数可以做任何事情,但必须符合定义的输入和输出类型。

作为参考,这里是 适用于 Prisma 的默认配置

手动 API 请求

当从客户端向 API 端口发出请求时,你需要在 anti-csrf header 中包含 anti-CSRF 令牌,如下所示:

import { getAntiCSRFToken } from "blitz"

const antiCSRFToken = getAntiCSRFToken()

if (antiCSRFToken) {
  // 设置获取请求的 header["anti-csrf"] = antiCSRFToken
}

然后你可以像这样在 API 路由中获取 sessionContext:

import { getSession } from "blitz"

export default async function ({ req, res }) {
  const session = await getSession(req, res)
  console.log("User ID:", session.userId)

  res.json({ userId })
}

工作原理的技术细节

经过身份验证的会话使用存储在数据库中的不透明令牌。

实现细节

会话创建

  • 登录时,服务端创建两个不透明令牌:
    • 一个访问令牌(access token)。
    • 一个 anti-csrf 令牌(anti-csrf 令牌)。
  • 每个都有 32 个长度的字符串 string
  • 访问令牌通过 httpOnly安全 的 cookie 发送给前端。
  • Anti-csrf 令牌通过可以被 JavaScript 读取的正常、安全的 cookie 发送给前 端。
  • 访问令牌的 SHA256 哈希值存储在数据库中。此令牌具有能映射到它的以下属性:
    • userId
    • 过期时间
    • 会话数据
  • Anti-csrf 令牌和访问令牌存储在一起。
  • 在另一个会话存在时创建一个新的会话,会导致 headers/cookies 改变。但是较 旧的会话仍将有效。
  • 对于严肃的生产环境应用,需要定期删除所有过期的令牌。

会话验证

  • 对于每个需要 CSRF 保护的请求,前端在请求头中发送 anti-csrf 令牌。
  • 传入的访问令牌通过检查它存在于数据库中并且没有过期来通过验证。每次验证后 ,都会更新访问令牌的到期时间。
  • 通过检查传入的 anti-csrf 令牌(来自 header)是否与会话相关联来防止 CSRF 攻击。

会话撤销/注销

  • 这是通过从数据库中删除会话来完成的。
  • 注销还会清除 cookies,并发送一个 header 来通知前端从本地存储中删除 anti-csrf 令牌。

类型

SessionContext

interface SessionContext extends PublicData {
  /**
   * 如果是匿名则为 null
   */
  userId: unknown
  $handle: string | null
  $publicData: PublicData
  $authorize(
    ...args: IsAuthorizedArgs
  ): asserts this is AuthenticatedSessionContext
  $isAuthorized: (
    ...args: IsAuthorizedArgs
  ) => this is AuthenticatedSessionContext
  $create: (
    publicData: PublicData,
    privateData?: Record<any, any>
  ) => Promise<void>
  $revoke: () => Promise<void>
  $revokeAll: () => Promise<void>
  $getPrivateData: () => Promise<Record<any, any>>
  $setPrivateData: (data: Record<any, any>) => Promise<void>
  $setPublicData: (
    data: Partial<Omit<PublicData, "userId">>
  ) => Promise<void>
}

Idea for improving this page? Edit it on GitHub.