Session 守卫

本指南介绍 AdonisJS 中基于 session 的身份验证。你将学习:

  • 如何配置 session 守卫
  • 如何登录和登出用户
  • 如何保护路由免受未身份验证用户的访问
  • 如何访问已身份验证的用户
  • 如何实现「记住我」功能
  • 如何防止已登录用户访问仅限访客的页面

概述

session 守卫使用 @adonisjs/session 包来跟踪已登录用户。当用户登录时,其标识符会被存储在 session 中,并向浏览器发送一个 cookie。在后续请求中,session 中间件会读取该 cookie 并恢复身份验证状态。

几十年来,session 和 cookie 一直是 Web 身份验证的标准方案。当你构建服务端渲染的应用,或托管在与 API 同一顶级域名下的 SPA 时(例如你的应用位于 example.com,API 位于 api.example.com),请使用 session 守卫。

配置守卫

身份验证守卫在 config/auth.ts 中定义。以下示例展示了一个 session 守卫的配置:

config/auth.ts
import { defineConfig } from '@adonisjs/auth'
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'

const authConfig = defineConfig({
  default: 'web',
  guards: {
    web: sessionGuard({
      useRememberMeTokens: false,
      provider: sessionUserProvider({
        model: () => import('#models/user'),
      }),
    }),
  },
})

export default authConfig

sessionGuard 方法创建了 SessionGuard 类的一个实例。它接受一个用户提供程序以及一个可选的「记住我」令牌配置对象。

sessionUserProvider 方法创建了 SessionLucidUserProvider 的实例,该类使用 Lucid 模型在身份验证过程中查找用户。

登录

使用 auth.use('web').login() 方法为用户创建一个 session。该方法接受一个 User 模型实例,并将其标识符存储在 session 中。

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

export default class SessionController {
  async store({ request, auth, response }: HttpContext) {
    /**
     * 从请求体中获取凭据
     */
    const { email, password } = request.only(['email', 'password'])

    /**
     * 使用 AuthFinder mixin 验证凭据
     */
    const user = await User.verifyCredentials(email, password)

    /**
     * 为用户创建 session
     */
    await auth.use('web').login(user)

    /**
     * 重定向到一个受保护的页面
     */
    return response.redirect('/dashboard')
  }
}

auth.use('web') 方法返回在 config/auth.ts 文件中以 web 名称配置的守卫实例。

登出

使用 auth.use('web').logout() 方法销毁用户的 session。如果用户拥有活跃的「记住我」令牌,该令牌也会被删除。

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

export default class SessionController {
  async destroy({ auth, response }: HttpContext) {
    await auth.use('web').logout()
    return response.redirect('/login')
  }
}

保护路由

使用 auth 中间件来保护路由,防止未身份验证的用户访问。该中间件在 start/kernel.ts 的命名中间件集合中注册:

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

export const middleware = router.named({
  auth: () => import('#middleware/auth_middleware'),
})

将中间件应用到需要身份验证的路由上:

start/routes.ts
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

router
  .get('dashboard', ({ auth }) => {
    return `Welcome ${auth.user!.fullName}`
  })
  .use(middleware.auth())

默认情况下,auth 中间件使用配置中的 default 守卫进行身份验证。若要显式指定守卫,可将它们作为参数传入:

start/routes.ts
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

router
  .get('dashboard', ({ auth }) => {
    return `Welcome ${auth.user!.fullName}`
  })
  .use(
    middleware.auth({
      guards: ['web', 'api'],
    })
  )

当指定多个守卫时,只要其中任意一个成功验证请求,身份验证即视为成功。

处理身份验证错误

当 auth 中间件无法对请求进行身份验证时,会抛出 E_UNAUTHORIZED_ACCESS 异常。该异常会通过内容协商转换为一个 HTTP 响应:

  • 带有 Accept: application/json 的请求会收到一个错误对象数组。
  • 带有 Accept: application/vnd.api+json 的请求会收到按 JSON API 规范格式化的错误。
  • 服务端渲染的应用会重定向到 /login。你可以在 app/middleware/auth_middleware.ts 中自定义此路径。

访问已身份验证的用户

身份验证完成后,可以通过 auth.user 访问用户实例。当使用 auth 中间件、silent_auth 中间件,或手动调用 auth.authenticate()auth.check() 时,该属性会被填充。

start/routes.ts
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

router
  .get('dashboard', async ({ auth }) => {
    const user = auth.user!
    return await user.getAllMetrics()
  })
  .use(middleware.auth())

避免使用非空断言

如果你不想使用非空断言操作符(!),可以改用 auth.getUserOrFail() 方法。它会返回用户,或在用户不存在时抛出 E_UNAUTHORIZED_ACCESS 异常:

start/routes.ts
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

router
  .get('dashboard', async ({ auth }) => {
    const user = auth.getUserOrFail()
    return await user.getAllMetrics()
  })
  .use(middleware.auth())

检查身份验证状态

使用 auth.isAuthenticated 属性来检查当前请求是否已通过身份验证:

start/routes.ts
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

router
  .get('dashboard', async ({ auth }) => {
    if (auth.isAuthenticated) {
      return await auth.user!.getAllMetrics()
    }
  })
  .use(middleware.auth())

静默身份验证

silent_auth 中间件的工作方式与 auth 中间件类似,但在用户未通过身份验证时不会抛出异常。请求会正常继续,使你可以在有可用身份验证数据时选择性地使用它。

这对于同时适用于访客和已身份验证用户的页面非常有用,例如一个为已登录用户显示个性化内容的主页。

在路由中间件栈中注册该中间件:

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

router.use([
  // ...其他中间件
  () => import('#middleware/silent_auth_middleware'),
])

在 Edge 模板中访问用户

初始化 auth 中间件 会将 ctx.auth 对象共享给 Edge 模板。可通过 auth.user 访问已身份验证的用户:

@if(auth.isAuthenticated)
  <p>Hello {{ auth.user.email }}</p>
@end

在未被 auth 中间件保护的公共页面上,在访问 auth.user 之前先调用 auth.check() 来尝试身份验证:

{{-- 在不要求身份验证的情况下检查用户是否已登录 --}}
@eval(await auth.check())

<header>
  @if(auth.isAuthenticated)
    <p>Hello {{ auth.user.email }}</p>
  @else
    <a href="/login">Sign in</a>
  @end
</header>

记住我

「记住我」功能通过在 cookie 中存储一个长期有效的令牌,使会话过期后用户依然保持登录状态。当 session 过期时,AdonisJS 会使用该令牌自动重建 session。

创建令牌表

记住我令牌存储在数据库中。为令牌表创建一个迁移:

node ace make:migration remember_me_tokens
database/migrations/TIMESTAMP_create_remember_me_tokens_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
  protected tableName = 'remember_me_tokens'

  async up() {
    this.schema.createTable(this.tableName, (table) => {
      table.increments()
      table
        .integer('tokenable_id')
        .notNullable()
        .unsigned()
        .references('id')
        .inTable('users')
        .onDelete('CASCADE')

      table.string('hash').notNullable().unique()
      table.timestamp('created_at').notNullable()
      table.timestamp('updated_at').notNullable()
      table.timestamp('expires_at').notNullable()
    })
  }

  async down() {
    this.schema.dropTable(this.tableName)
  }
}

配置令牌提供者

DbRememberMeTokensProvider 分配给你的 User 模型:

app/models/user.ts
import { BaseModel } from '@adonisjs/lucid/orm'
import { DbRememberMeTokensProvider } from '@adonisjs/auth/session'

export default class User extends BaseModel {
  // ...其他模型属性

  static rememberMeTokens = DbRememberMeTokensProvider.forModel(User)
}

启用记住我令牌

在守卫配置中启用该功能:

config/auth.ts
import { defineConfig } from '@adonisjs/auth'
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'

const authConfig = defineConfig({
  default: 'web',
  guards: {
    web: sessionGuard({
      useRememberMeTokens: true,
      rememberMeTokensAge: '2 years',
      provider: sessionUserProvider({
        model: () => import('#models/user'),
      }),
    }),
  },
})

export default authConfig

在登录时生成令牌

login() 传入第二个布尔参数,以生成「记住我」令牌:

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

export default class SessionController {
  async store({ request, auth, response }: HttpContext) {
    const { email, password } = request.only(['email', 'password'])
    const user = await User.verifyCredentials(email, password)

    await auth.use('web').login(
      user,
      !!request.input('remember_me')
    )

    return response.redirect('/dashboard')
  }
}

重定向到目标 URL

当未身份验证的用户尝试访问受保护的页面时,auth 中间件会将其重定向到登录页面。登录后,你会希望将他们送回最初请求的页面,而不是一个通用的仪表盘。

强制登录流程

当 auth 中间件拒绝一个未身份验证的请求时,它会在重定向到登录页之前,自动将当前 URL 存储到 session 中。这适用于匹配到路由的 GET、导航类(非 AJAX)请求。你无需对 auth 中间件做任何修改。

在你的登录控制器中,使用 toIntendedRoute() 在身份验证成功后重定向。它会从 session 中读取存储的 URL,并在没有存储 URL 时回退到提供的默认值。

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

export default class SessionController {
  async store({ request, auth, response }: HttpContext) {
    const { email, password } = request.only(['email', 'password'])
    const user = await User.verifyCredentials(email, password)

    await auth.use('web').login(user)

    /**
     * 重定向到用户试图访问的页面,
     * 如果没有存储目标 URL,则重定向到仪表盘
     */
    return response.redirect().toIntendedRoute('dashboard')
  }
}

自愿登录流程

当用户从公共页面主动点击登录链接时,可以将当前 URL 作为查询参数传递。登录页处理器使用 session.setIntendedUrl() 将其存储在 session 中,该方法会校验 URL 以防止开放重定向(open redirect)。

resources/views/partials/header.edge
<a href="{{ route('auth.login', {}, { qs: { intended: request.url(true) } }) }}">
  Login
</a>
app/controllers/session_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class SessionController {
  async show({ request, session, view }: HttpContext) {
    const intended = request.input('intended')
    if (intended) {
      session.setIntendedUrl(intended)
    }

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

登录后,toIntended() 的工作方式相同,无论目标 URL 是如何存储的。

访客中间件

访客中间件会将已身份验证的用户从 /login/register 这类页面重定向离开。这可以防止用户在单一设备上创建多个 session。

start/routes.ts
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

router
  .get('/login', () => {
    // 显示登录表单
  })
  .use(middleware.guest())

默认情况下,该中间件使用 default 守卫检查身份验证。若要显式指定守卫:

start/routes.ts
router
  .get('/login', () => {
    // 显示登录表单
  })
  .use(
    middleware.guest({
      guards: ['web', 'admin_web'],
    })
  )

你可以在 app/middleware/guest_middleware.ts 中配置已身份验证用户的重定向路径。

事件

session 守卫会在身份验证过程中发出事件。完整列表请参阅 事件参考指南