会话

本指南介绍在 AdonisJS 应用中处理 HTTP 会话。你将了解:

  • 安装并配置 session 包
  • 存储和检索会话数据
  • 处理闪现消息
  • 选择正确的存储驱动
  • 实现自定义会话存储

概述

HTTP 是一个无状态协议,意味着每个请求都是独立的,服务器不会在请求之间保留信息。会话通过提供一种跨多个 HTTP 请求持久化状态并将该状态与唯一的会话标识符关联起来,解决了这个问题。

在 AdonisJS 中,会话主要用于 Hypermedia 和 Inertia 应用,以维持用户身份验证状态,并在请求之间传递临时数据(闪现消息)。例如,用户登录后,他们的身份验证状态会存储在会话中,以便他们在后续请求中保持登录状态。类似地,当你在表单提交后重定向时,存储在会话中的闪现消息可以在下一页显示成功或错误通知。

Note

如果你对会话的概念还不熟悉,建议在继续之前阅读这篇 HTTP 会话介绍

安装

运行以下 Ace 命令安装并配置 session 包:

node ace add @adonisjs/session
查看 add 命令执行的步骤
  1. 使用检测到的包管理器安装 @adonisjs/session 包。
  2. adonisrc.ts 文件中注册 @adonisjs/session/session_provider 服务提供者。
  3. 创建 config/session.ts 配置文件。
  4. 定义 SESSION_DRIVER 环境变量。
  5. start/kernel.ts 文件中注册 @adonisjs/session/session_middleware 中间件。

选择存储驱动

session 驱动决定了你的会话数据存储在何处。每个驱动都有不同的特性,使其适合特定的用例:

Note

基于 Cookie 的会话会静默截断超过 4KB 的数据。对于具有较大会话数据的生产应用,请切换到 Redis。

驱动描述最适合
cookie将存储在加密的 Cookie 中(最大约 4KB)简单应用、小数据、无后端存储
file存储在本地文件系统中开发、单服务器部署
redis存储在 Redis 数据库中生产、多服务器、较大数据
dynamodb存储在 AWS DynamoDB 中AWS 基础设施、无服务器应用
database存储在 SQL 数据库中使用 SQL、已有数据库基础设施的生产应用
memory存储在内存中(重启后丢失)仅用于测试

配置

会话配置存储在 config/session.ts 中,该文件在安装期间创建。以下是默认配置:

config/session.ts
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, stores } from '@adonisjs/session'

export default defineConfig({
  enabled: true,
  cookieName: 'adonis-session',
  clearWithBrowser: false,
  age: '2h',

  cookie: {
    path: '/',
    httpOnly: true,
    secure: app.inProduction,
    sameSite: 'lax',
  },

  store: env.get('SESSION_DRIVER'),

  stores: {
    cookie: stores.cookie(),

    file: stores.file({
      location: app.tmpPath('sessions')
    }),

    redis: stores.redis({
      connection: 'main'
    }),

    database: stores.database({
      connection: 'postgres',
      tableName: 'sessions',
    }),

    dynamodb: stores.dynamodb({
      region: env.get('AWS_REGION'),
      endpoint: env.get('AWS_ENDPOINT'),
      tableName: 'sessions',
    }),
  }
})
age

会话过期前的生命周期。接受字符串持续时间如 '2 hours''7 days',或毫秒数。此期限过后,会话过期并删除数据。

age: '2 hours'
// 或
age: 7200000 // 2 小时(毫秒)
clearWithBrowser
boolean

当为 true 时,无论配置的 age 是多少,用户关闭浏览器时都会删除会话 Cookie。当为 false(默认值)时,即使浏览器关闭,会话也会在配置的 age 持续时间内持续存在。

clearWithBrowser: false
store
string

确定使用哪个会话驱动。在 .env 文件中使用 SESSION_DRIVER 环境变量设置此项。该值必须与 stores 对象中定义的键之一匹配。

.env
SESSION_DRIVER=cookie
cookie

控制会话 Cookie 行为的 Cookie 配置对象。这包括 Cookie 名称、域名、路径和安全选项的设置。详细选项请参阅 Cookie 配置 部分。

stores

一个定义所有可用会话存储的对象。每个键代表一个驱动名称,值是该存储的配置。store 属性中指定的驱动必须存在于此对象中。

会话使用 Cookie 来存储会话 ID(或对于 cookie 驱动则是整个会话数据)。使用以下选项配置 Cookie 行为:

cookie.name
string

存储会话 ID 的 Cookie 名称。仅当它应用中的其他 Cookie 冲突时才更改。

cookie: {
  name: 'adonis-session'
}
cookie.domain
string

Cookie 有效的域名。留空则默认为当前域名。设置为 '.example.com'(带前导点)可在子域名间共享 Cookie,如 app.example.comapi.example.com

cookie: {
  domain: '' // 仅当前域名
  // 或
  domain: '.example.com' // 所有子域名
}
cookie.path
string

Cookie 有效的 URL 路径。将其设置为 '/' 可使 Cookie 在你的整个应用中可用。

cookie: {
  path: '/'
}
cookie.httpOnly
boolean

当为 true 时,阻止 JavaScript 通过 document.cookie 访问 Cookie,防止恶意脚本试图窃取会话 ID 的 XSS 攻击。为了安全,请保持为 true

cookie: {
  httpOnly: true
}
cookie.secure
boolean

当为 true 时,确保 Cookie 仅通过 HTTPS 连接发送,防止在不安全的网络上进行会话劫持。起步套件使用 app.inProduction 在生产环境中自动启用此选项,而在本地通过 HTTP 开发期间保持禁用。

cookie: {
  secure: app.inProduction
}
cookie.sameSite
'lax' | 'strict' | 'none'

控制浏览器何时随跨站请求发送 Cookie,防止 CSRF 攻击。

  • 'lax':Cookie 在顶级导航(点击链接)时发送。默认且推荐用于大多数应用。
  • 'strict':Cookie 从不在跨站请求时发送。最安全,但可能破坏合法流程。
  • 'none':Cookie 总是发送。需要 secure: true,很少需要。
cookie: {
  sameSite: 'lax'
}

Redis 驱动

Redis 驱动将会话数据存储在 Redis 数据库中,使其非常适合具有多服务器或较大会话数据的生产应用。

你可以使用以下命令添加 redis 包。完整的设置说明请参阅 Redis 指南

node ace add @adonisjs/redis
stores.redis.connection
string

用于会话存储的 Redis 连接名称。该连接必须在你的 config/redis.ts 文件中配置。

stores: {
  redis: stores.redis({
    connection: 'main'
  })
}

Database 驱动

Database 驱动将会话数据存储在 SQL 数据库中,非常适合已经使用 SQL 数据库基础设施的生产应用。

stores.database.connection
string

用于会话存储的数据库连接名称。该连接必须在你的 config/database.ts 文件中配置。

stores: {
  database: stores.database({
    connection: 'postgres'
  })
}
stores.database.tableName
string

存储会话数据的数据库表名。默认为 'sessions'。请务必使用 node ace make:session-table 命令为会话表创建迁移。

stores: {
  database: stores.database({
    tableName: 'sessions'
  })
}

DynamoDB 驱动

DynamoDB 驱动将会话数据存储在 AWS DynamoDB 中,非常适合无服务器应用和 AWS 基础设施。

安装:首先安装 AWS SDK:

npm install @aws-sdk/client-dynamodb
stores.dynamodb.region
string

你的 DynamoDB 表所在的 AWS 区域。

stores: {
  dynamodb: stores.dynamodb({
    region: env.get('AWS_REGION')
  })
}
stores.dynamodb.endpoint
string

DynamoDB 的可选自定义端点 URL。在使用 DynamoDB Local 进行本地开发或使用自定义端点时很有用。

stores: {
  dynamodb: stores.dynamodb({
    endpoint: env.get('AWS_ENDPOINT')
  })
}
stores.dynamodb.tableName
string

存储会话数据的 DynamoDB 表名。

stores: {
  dynamodb: stores.dynamodb({
    tableName: 'sessions'
  })
}

File 驱动

File 驱动将会话数据存储在本地文件系统中,适合开发和单服务器部署。

stores.file.location
string

存储会话文件的目录路径。默认是你的应用根目录下的 tmp/sessions

stores: {
  file: stores.file({
    location: app.tmpPath('sessions')
  })
}

基础用法

安装后,你可以通过 HTTP 上下文的 session 属性访问会话。会话存储提供了一个简单的键值 API 用于读写数据。

让我们构建一个简单的购物车示例,演示核心的会话方法:

start/routes.ts
import router from '@adonisjs/core/services/router'
import Product from '#models/product'

/**
 * 显示购物车中的商品。
 * 使用 get() 并设置默认值为空数组。
 */
router.get('/cart', ({ session }) => {
  const cartItems = session.get('cart', [])
  return { items: cartItems, total: cartItems.length }
})

/**
 * 将商品添加到购物车。
 * 演示使用 put() 存储更新后的购物车数据。
 */
router.post('/cart', async ({ request, session }) => {
  const productId = request.input('product_id')
  const product = await Product.findOrFail(productId)
  
  const cartItems = session.get('cart', [])
  cartItems.push({ 
    id: product.id, 
    name: product.name, 
    quantity: 1 
  })
  
  session.put('cart', cartItems)
  return { message: 'Item added', totalItems: cartItems.length }
})

/**
 * 从购物车中移除特定商品。
 * 展示如何更新并存储已修改的数据。
 */
router.delete('/cart/:productId', ({ params, session }) => {
  const cartItems = session.get('cart', [])
  const updatedCart = cartItems.filter(item => item.id !== params.productId)
  
  session.put('cart', updatedCart)
  return { message: 'Item removed' }
})

/**
 * 清空整个购物车。
 * 使用 forget() 移除特定键。
 */
router.delete('/cart', ({ session }) => {
  session.forget('cart')
  return { message: 'Cart cleared' }
})

检查值是否存在

在尝试检索之前,你可以检查会话中是否存在某个值。

start/routes.ts
router.get('/checkout', ({ session, response }) => {
  /**
   * 在继续之前检查购物车是否存在且有商品。
   * has() 方法在键存在时返回 true。
   */
  if (!session.has('cart')) {
    return response.redirect('/cart')
  }
  
  const cartItems = session.get('cart')
  return { items: cartItems }
})

检索并移除值

有时你需要获取一个值并立即将其从会话中移除。pull() 方法将两种操作合二为一。

start/routes.ts
router.post('/process-payment', ({ session }) => {
  /**
   * 在一个操作中获取购物车数据并将其移除。
   * 这在处理一次性数据时很有用。
   */
  const cartItems = session.pull('cart', [])
  
  // 使用 cartItems 处理支付
  // 购物车现在自动从会话中移除
  
  return { processed: cartItems.length }
})

处理数值

会话提供了方便的方法来递增和递减数值,对计数器或跟踪数值状态很有用。

start/routes.ts
router.post('/visit', ({ session }) => {
  /**
   * 访问计数器加 1。
   * 如果 'visits' 不存在,则初始化为 1。
   */
  session.increment('visits')
  
  const totalVisits = session.get('visits')
  return { visits: totalVisits }
})

router.post('/undo', ({ session }) => {
  /**
   * 递减计数器。
   * 如果 'actions' 不存在,则初始化为 -1。
   */
  session.decrement('actions')
  
  return { actionsRemaining: session.get('actions', 0) }
})

检索所有会话数据

你可以使用 all() 方法将所有会话数据作为对象检索。

start/routes.ts
router.get('/debug/session', ({ session }) => {
  /**
   * 将所有会话数据作为对象返回。
   * 对调试或显示会话状态很有用。
   */
  const allData = session.all()
  return allData
})

清空整个会话

要移除会话存储中的所有数据,使用 clear() 方法。

start/routes.ts
router.post('/logout', ({ session, auth, response }) => {
  // 清除身份验证
  await auth.logout()
  
  /**
   * 移除所有会话数据。
   * 这在退出登录时清理所有用户状态很有用。
   */
  session.clear()
  
  return response.redirect('/login')
})

闪现消息

闪现消息是存储在会话中的临时数据,仅对下一个 HTTP 请求可用。它们在被访问一次后自动删除,非常适合在重定向后显示一次性通知。

基础闪现消息

使用 flash() 方法为下一个请求存储一条消息。第一个参数是消息类型(用于对消息进行分类的约定),第二个是消息内容。

start/routes.ts
router.post('/cart', async ({ session, response }) => {
  // 将商品添加到购物车...
  
  /**
   * 为下一个请求闪现一条成功消息。
   * 可在模板中通过 flashMessages.get('success') 访问。
   */
  session.flash('success', 'Item added to the cart')
  
  return response.redirect().back()
})

AdonisJS 使用 successerror 作为标准消息类型约定。虽然你可以使用任何字符串作为消息类型,但这些约定有助于组织不同类型的通知。

app/controllers/orders_controller.ts
export default class OrdersController {
  async store({ session, response }: HttpContext) {
    try {
      // 处理订单...
      session.flash('success', 'Order placed successfully!')
      return response.redirect('/orders')
    } catch (error) {
      session.flash('error', 'Payment failed. Please try again.')
      return response.redirect().back()
    }
  }
}

显示闪现消息

如果你使用的是 Hypermedia 或 Inertia 起步套件,闪现消息已经在布局文件中自动显示。起步套件会检查 successerror 消息,并以适当的样式渲染它们。

对于自定义布局或应用,在你的 Edge 模板中使用全局 flashMessages 辅助函数来显示闪现消息。

resources/views/layouts/main.edge
@if(flashMessages.has('success'))
  <div class="alert alert-success">
    {{ flashMessages.get('success') }}
  </div>
@end

@if(flashMessages.has('error'))
  <div class="alert alert-error">
    {{ flashMessages.get('error') }}
  </div>
@end

自定义闪现消息

除了标准消息类型之外,你还可以为特定用例创建自定义消息类型。

start/routes.ts
router.post('/newsletter/subscribe', ({ session, response }) => {
  // 订阅用户...
  
  session.flash('newsletter', 'Check your email to confirm subscription')
  return response.redirect().back()
})

在你的模板中显示自定义消息。

@if(flashMessages.has('newsletter'))
  <div class="alert alert-newsletter">
    {{ flashMessages.get('newsletter') }}
  </div>
@end

闪现表单数据

当表单处理期间发生错误并且你需要将用户重定向回表单时,你可以闪现已提交的表单数据以预填表单字段。

app/controllers/posts_controller.ts
export default class PostsController {
  async store({ request, session, response }: HttpContext) {
    try {
      // 尝试创建文章...
      throw new Error('Unable to publish post')
    } catch (error) {
      /**
       * 闪现表单数据以重新填充表单。
       * 用户无需重新输入他们的数据。
       */
      session.flashAll()
      session.flash('error', 'Failed to create post. Please try again.')
      
      return response.redirect().back()
    }
  }
}

你也可以只闪现特定字段或排除敏感字段。

// 只闪现特定字段
session.flashOnly(['title', 'content', 'category'])

// 闪现除敏感字段之外的所有内容
session.flashExcept(['password', 'credit_card'])

在模板中使用 old() 辅助函数访问闪现的表单数据。

resources/views/posts/create.edge
<input type="text" name="title" value="{{ old('title') }}" />
<textarea name="content">{{ old('content') }}</textarea>
Note

使用 AdonisJS 验证器时,验证错误和表单数据会在验证失败时自动闪现。你无需手动闪现它们。

重新闪现消息

有时你需要为额外的请求保留闪现消息。使用 reflash() 为再一个周期保留来自上一个请求的所有消息。

app/middleware/check_subscription_middleware.ts
export default class CheckSubscriptionMiddleware {
  async handle({ auth, session, response }: HttpContext, next: NextFn) {
    const user = auth.getUserOrFail()
    
    if (!user.hasActiveSubscription) {
      /**
       * 为再一个请求保留所有闪现消息。
       */
      session.reflash()
      session.flash('error', 'Please subscribe to continue')
      
      return response.redirect('/subscribe')
    }
    
    await next()
  }
}

你也可以选择性地只重新闪现特定的消息类型。

// 只重新闪现错误消息
session.reflashOnly(['error'])

// 重新闪现除 success 之外的所有消息
session.reflashExcept(['success'])

预期 URL

预期 URL 功能允许你存储一个用户完成中间步骤后应该被重定向到的 URL。例如,你可以在将用户重定向到登录页面之前存储当前页面 URL,登录后,将他们重定向回他们所在的页面。

URL 在存储前会经过验证,以防止开放重定向攻击。无效或不安全的 URL(如协议相对的 //evil.com)会被静默忽略。

app/controllers/session_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class SessionController {
  async show({ request, session, view }: HttpContext) {
    /**
     * 如果登录页面是通过 ?intended 查询参数访问的,
     * 将预期 URL 存储在会话中,以便登录后使用
     */
    const intended = request.input('intended')
    if (intended) {
      session.setIntendedUrl(intended)
    }

    return view.render('auth/login')
  }

  async store({ request, auth, response }: HttpContext) {
    const user = await User.verifyCredentials(
      request.input('email'),
      request.input('password')
    )
    await auth.use('web').login(user)

    /**
     * 如果存储了预期 URL,则重定向到该 URL,
     * 否则回退到 dashboard
     */
    return response.redirect().toIntendedRoute('dashboard')
  }
}

你也可以直接读取、消费或清除预期 URL。

start/routes.ts
import router from '@adonisjs/core/services/router'

router.get('/example', ({ session }) => {
  /**
   * 读取但不消费
   */
  const url = session.getIntendedUrl()

  /**
   * 读取并一步移除
   */
  const consumed = session.pullIntendedUrl()

  /**
   * 不读取直接移除
   */
  session.clearIntendedUrl()
})

另请参阅: 登录后重定向到预期 URL

会话再生

会话再生会创建一个新的会话 ID,同时保留所有现有的会话数据。这是防止 会话固定攻击(session fixation attacks) 的关键安全措施,在这种攻击中,攻击者诱骗用户使用由攻击者控制的会话 ID。

当用户登录时,他们的身份验证状态从未认证变为已认证。如果保持相同的会话 ID,在登录前就知道该会话 ID 的攻击者可能会劫持已认证的会话。登录后再生会话 ID 可以防止这种攻击。

AdonisJS 在使用官方 Auth 包时会自动处理会话再生。但是,如果你正在实现自定义身份验证,必须在登录成功后手动调用 session.regenerate()

app/controllers/auth_controller.ts
export default class AuthController {
  async login({ request, session, response }: HttpContext) {
    const { email, password } = request.only(['email', 'password'])
    
    // 验证凭据...
    const user = await User.verifyCredentials(email, password)
    
    /**
     * 在保留数据的同时生成新的会话 ID。
     * 这可以防止会话固定攻击。
     */
    await session.regenerate()
    
    // 在新的会话中存储用户信息
    session.put('user_id', user.id)
    
    return response.redirect('/dashboard')
  }
}

创建自定义会话存储

如果内置驱动都无法满足你的需求,你可以通过实现 SessionStoreContract 接口来创建自定义会话存储。这对于集成 MongoDB 等数据库或自定义存储解决方案很有用。

实现存储契约

创建一个实现了四个必需方法的类。

app/session_stores/mongodb_store.ts
import {
  SessionData,
  SessionStoreFactory,
  SessionStoreContract,
} from '@adonisjs/session/types'

/**
 * 你想要接受的配置
 */
export type MongoDBConfig = {
  collection: string;
  database: string
}

/**
 * MongoDbStore 类处理实际的存储操作。
 * 它实现了四个必需方法:read、write、destroy 和 touch。
 */
export class MongoDbStore implements SessionStoreContract {
  constructor(protected config: MongoDBConfig) {}

  /**
   * 读取给定会话 ID 的会话数据。
   * 如果会话不存在则返回 null。
   */
  async read(sessionId: string): Promise<SessionData | null> {
    // 查询你的存储并返回会话数据
  }

  /**
   * 写入给定会话 ID 的会话数据。
   */
  async write(
    sessionId: string,
    data: SessionData,
    expiresAt: Date
  ): Promise<void> {
    // 将会话数据保存到你的存储
  }

  /**
   * 删除给定会话 ID 的会话数据。
   */
  async destroy(sessionId: string): Promise<void> {
    // 从你的存储中移除会话
  }

  /**
   * 在不更改数据的情况下更新会话的过期时间。
   */
  async touch(sessionId: string, expiresAt: Date): Promise<void> {
    // 更新你的存储中的过期时间戳
  }
}

/**
 * 工厂函数接受配置并返回一个驱动
 * 函数。当会话管理器调用驱动函数时,它会创建
 * 存储类的新实例。
 */
export function mongoDbStore(config: MongoDbConfig): SessionStoreFactory {
  return (ctx, sessionConfig) => {
    return new MongoDBStore(config)
  }
}

注册你的自定义存储

使用工厂函数在 config/session.ts 中注册你的自定义存储。

config/session.ts
import { defineConfig } from '@adonisjs/session'
import { mongoDbStore } from '#session_stores/mongodb_store'

export default defineConfig({
  store: env.get('SESSION_DRIVER'),
  
  stores: {
    mongodb: mongoDbStore({
      collection: 'sessions',
      database: 'myapp'
    })
  }
})

设置你的环境变量以使用自定义存储。

.env
SESSION_DRIVER=mongodb

完整的实现示例,请参阅 GitHub 上的内置会话存储