创建自定义 auth 守卫

本指南介绍如何在 AdonisJS 中构建自定义的身份验证守卫。你将学习:

  • 何时应该创建自定义守卫,而不是使用内置选项
  • 如何为你的守卫设计用户提供者接口
  • 如何实现守卫契约(guard contract)
  • 如何生成和验证令牌
  • 如何注册并使用你的自定义守卫

概述

AdonisJS 内置了 session、access token 和 basic auth 守卫,可以满足大多数身份验证需求。然而,对于某些特定需求,例如 JWT 身份验证、API key,或与外部身份提供商集成,你可能需要一个自定义守卫。

一个自定义守卫由两部分组成:一个定义如何查找用户的用户提供者接口,以及一个处理身份验证逻辑的守卫实现。这种分离使得同一个守卫可以通过替换用户提供者来配合不同的数据源(Lucid 模型、Prisma、外部 API)工作。

本指南将以构建一个 JWT 身份验证守卫作为实践示例。其中的概念适用于任何自定义身份验证机制。

Note

这是进阶内容。在构建自定义守卫之前,请先确认 session 守卫access tokens 守卫basic auth 守卫 无法满足你的需求。

项目结构

本指南中的所有代码都会放入一个单独的文件中,你可以在后续进行扩展。在 app/auth/guards/jwt.ts 处创建该文件:

mkdir -p app/auth/guards
touch app/auth/guards/jwt.ts

定义用户提供者接口

守卫不应将如何从数据库中获取用户的逻辑硬编码。相反,它们会定义一个用户提供者接口,描述进行身份验证所需的方法。这使得开发者可以根据他们的数据层提供自己的实现。

对于 JWT 守卫,提供者需要根据 ID(从令牌载荷中提取)查找用户。先定义接口:

app/auth/guards/jwt.ts
import { symbols } from '@adonisjs/auth'

/**
 * 用户提供者与守卫之间的桥梁。
 * 用守卫所需的方法包裹实际的用户对象。
 */
export type JwtGuardUser<RealUser> = {
  getId(): string | number | BigInt
  getOriginal(): RealUser
}

/**
 * 用户提供者必须实现的接口,
 * 以便与 JWT 守卫配合工作。
 */
export interface JwtUserProviderContract<RealUser> {
  /**
   * 供 TypeScript 推断实际用户类型的属性。
   * 运行时不会使用。
   */
  [symbols.PROVIDER_REAL_USER]: RealUser

  /**
   * 从实际的用户对象创建供守卫使用的用户实例。
   */
  createUserForGuard(user: RealUser): Promise<JwtGuardUser<RealUser>>

  /**
   * 根据 ID 查找用户。
   */
  findById(identifier: string | number | BigInt): Promise<JwtGuardUser<RealUser> | null>
}

JwtGuardUser 类型充当你的实际用户对象(一个 Lucid 模型、Prisma 对象或普通对象)与守卫之间的桥梁。守卫使用 getId() 获取用于令牌载荷的用户标识符,并使用 getOriginal() 在身份验证后返回用户对象。

RealUser 泛型参数使该接口能够与任何用户类型配合。基于 Lucid 的提供程序会返回一个模型实例,而基于 Prisma 的提供程序会返回一个 Prisma 用户对象。

实现守卫

守卫必须实现 @adonisjs/auth 中的 GuardContract 接口。该接口定义了将守卫与 AdonisJS 身份验证集成所需的方法和属性。

先从类结构和必需的属性开始:

app/auth/guards/jwt.ts
import { symbols } from '@adonisjs/auth'
import type { GuardContract } from '@adonisjs/auth/types'

export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
  implements GuardContract<UserProvider[typeof symbols.PROVIDER_REAL_USER]>
{
  /**
   * 由该守卫发出的事件。JWT 守卫不发出事件,
   * 但该属性是接口所要求的。
   */
  declare [symbols.GUARD_KNOWN_EVENTS]: {}

  /**
   * 该守卫类型的唯一标识符。
   */
  driverName: 'jwt' = 'jwt'

  /**
   * 在本次请求期间是否已尝试进行身份验证。
   */
  authenticationAttempted: boolean = false

  /**
   * 当前请求是否已通过身份验证。
   */
  isAuthenticated: boolean = false

  /**
   * 已身份验证的用户(如果有)。
   */
  user?: UserProvider[typeof symbols.PROVIDER_REAL_USER]

  async generate(user: UserProvider[typeof symbols.PROVIDER_REAL_USER]) {
    // TODO: 实现
  }

  async authenticate(): Promise<UserProvider[typeof symbols.PROVIDER_REAL_USER]> {
    // TODO: 实现
  }

  async check(): Promise<boolean> {
    // TODO: 实现
  }

  getUserOrFail(): UserProvider[typeof symbols.PROVIDER_REAL_USER] {
    // TODO: 实现
  }

  async authenticateAsClient(
    user: UserProvider[typeof symbols.PROVIDER_REAL_USER]
  ): Promise<AuthClientResponse> {
    // TODO: 实现
  }
}

接收依赖

守卫需要一个用户提供者来查找用户,以及一个 HTTP 上下文来读取请求头。它还需要像 JWT secret 这样的配置选项。将这些作为构造函数参数传入:

app/auth/guards/jwt.ts
import type { HttpContext } from '@adonisjs/core/http'

export type JwtGuardOptions = {
  secret: string
}

export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
  implements GuardContract<UserProvider[typeof symbols.PROVIDER_REAL_USER]>
{
  #ctx: HttpContext
  #userProvider: UserProvider
  #options: JwtGuardOptions

  constructor(
    ctx: HttpContext,
    userProvider: UserProvider,
    options: JwtGuardOptions
  ) {
    this.#ctx = ctx
    this.#userProvider = userProvider
    this.#options = options
  }

  // ... 类的其余部分
}

生成令牌

安装 jsonwebtoken 包来处理 JWT 的创建和验证:

npm i jsonwebtoken @types/jsonwebtoken

实现 generate 方法,以创建一个包含用户 ID 的已签名 JWT:

app/auth/guards/jwt.ts
import jwt from 'jsonwebtoken'

export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
  implements GuardContract<UserProvider[typeof symbols.PROVIDER_REAL_USER]>
{
  // ... 构造函数和属性

  async generate(user: UserProvider[typeof symbols.PROVIDER_REAL_USER]) {
    const providerUser = await this.#userProvider.createUserForGuard(user)
    const token = jwt.sign({ userId: providerUser.getId() }, this.#options.secret)

    return {
      type: 'bearer',
      token: token,
    }
  }
}

该方法使用用户提供者获取用户的 ID,然后用该 ID 作为载荷签署一个 JWT。

对请求进行身份验证

authenticate 方法从请求中读取 JWT、验证它,并获取相应的用户:

app/auth/guards/jwt.ts
import { errors, symbols } from '@adonisjs/auth'

export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
  implements GuardContract<UserProvider[typeof symbols.PROVIDER_REAL_USER]>
{
  // ... 构造函数和属性

  async authenticate(): Promise<UserProvider[typeof symbols.PROVIDER_REAL_USER]> {
    /**
     * 如果本次请求期间已经进行过身份验证,则跳过
     */
    if (this.authenticationAttempted) {
      return this.getUserOrFail()
    }
    this.authenticationAttempted = true

    /**
     * 读取 authorization 头
     */
    const authHeader = this.#ctx.request.header('authorization')
    if (!authHeader) {
      throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
        guardDriverName: this.driverName,
      })
    }

    /**
     * 从 "Bearer <token>" 中提取令牌
     */
    const [, token] = authHeader.split('Bearer ')
    if (!token) {
      throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
        guardDriverName: this.driverName,
      })
    }

    /**
     * 验证令牌并提取载荷
     */
    const payload = jwt.verify(token, this.#options.secret)
    if (typeof payload !== 'object' || !('userId' in payload)) {
      throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
        guardDriverName: this.driverName,
      })
    }

    /**
     * 根据令牌载荷中的 ID 查找用户
     */
    const providerUser = await this.#userProvider.findById(payload.userId)
    if (!providerUser) {
      throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
        guardDriverName: this.driverName,
      })
    }

    /**
     * 存储已身份验证的用户并返回
     */
    this.user = providerUser.getOriginal()
    this.isAuthenticated = true
    return this.user
  }
}

实现辅助方法

check 方法是 authenticate 的不抛出版本:

app/auth/guards/jwt.ts
async check(): Promise<boolean> {
  try {
    await this.authenticate()
    return true
  } catch {
    return false
  }
}

getUserOrFail 方法返回已身份验证的用户,否则抛出异常:

app/auth/guards/jwt.ts
getUserOrFail(): UserProvider[typeof symbols.PROVIDER_REAL_USER] {
  if (!this.user) {
    throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
      guardDriverName: this.driverName,
    })
  }

  return this.user
}

支持测试身份验证

authenticateAsClient 方法由 Japa 的 loginAs 辅助函数在测试期间使用。它返回测试客户端应包含的请求头:

app/auth/guards/jwt.ts
import type { AuthClientResponse } from '@adonisjs/auth/types'

async authenticateAsClient(
  user: UserProvider[typeof symbols.PROVIDER_REAL_USER]
): Promise<AuthClientResponse> {
  const token = await this.generate(user)

  return {
    headers: {
      authorization: `Bearer ${token.token}`,
    },
  }
}

完整实现

以下是完整的守卫实现:

app/auth/guards/jwt.ts
import jwt from 'jsonwebtoken'
import { symbols, errors } from '@adonisjs/auth'
import type { HttpContext } from '@adonisjs/core/http'
import type { AuthClientResponse, GuardContract } from '@adonisjs/auth/types'

/**
 * 用户提供者与守卫之间的桥梁。
 */
export type JwtGuardUser<RealUser> = {
  getId(): string | number | BigInt
  getOriginal(): RealUser
}

/**
 * 与 JWT 守卫兼容的用户提供者接口。
 */
export interface JwtUserProviderContract<RealUser> {
  [symbols.PROVIDER_REAL_USER]: RealUser
  createUserForGuard(user: RealUser): Promise<JwtGuardUser<RealUser>>
  findById(identifier: string | number | BigInt): Promise<JwtGuardUser<RealUser> | null>
}

/**
 * JWT 守卫的配置选项。
 */
export type JwtGuardOptions = {
  secret: string
}

/**
 * JWT 身份验证守卫实现。
 */
export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
  implements GuardContract<UserProvider[typeof symbols.PROVIDER_REAL_USER]>
{
  declare [symbols.GUARD_KNOWN_EVENTS]: {}

  driverName: 'jwt' = 'jwt'
  authenticationAttempted: boolean = false
  isAuthenticated: boolean = false
  user?: UserProvider[typeof symbols.PROVIDER_REAL_USER]

  #ctx: HttpContext
  #userProvider: UserProvider
  #options: JwtGuardOptions

  constructor(
    ctx: HttpContext,
    userProvider: UserProvider,
    options: JwtGuardOptions
  ) {
    this.#ctx = ctx
    this.#userProvider = userProvider
    this.#options = options
  }

  async generate(user: UserProvider[typeof symbols.PROVIDER_REAL_USER]) {
    const providerUser = await this.#userProvider.createUserForGuard(user)
    const token = jwt.sign({ userId: providerUser.getId() }, this.#options.secret)

    return {
      type: 'bearer',
      token: token,
    }
  }

  async authenticate(): Promise<UserProvider[typeof symbols.PROVIDER_REAL_USER]> {
    if (this.authenticationAttempted) {
      return this.getUserOrFail()
    }
    this.authenticationAttempted = true

    const authHeader = this.#ctx.request.header('authorization')
    if (!authHeader) {
      throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
        guardDriverName: this.driverName,
      })
    }

    const [, token] = authHeader.split('Bearer ')
    if (!token) {
      throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
        guardDriverName: this.driverName,
      })
    }

    const payload = jwt.verify(token, this.#options.secret)
    if (typeof payload !== 'object' || !('userId' in payload)) {
      throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
        guardDriverName: this.driverName,
      })
    }

    const providerUser = await this.#userProvider.findById(payload.userId)
    if (!providerUser) {
      throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
        guardDriverName: this.driverName,
      })
    }

    this.user = providerUser.getOriginal()
    this.isAuthenticated = true
    return this.user
  }

  async check(): Promise<boolean> {
    try {
      await this.authenticate()
      return true
    } catch {
      return false
    }
  }

  getUserOrFail(): UserProvider[typeof symbols.PROVIDER_REAL_USER] {
    if (!this.user) {
      throw new errors.E_UNAUTHORIZED_ACCESS('Unauthorized access', {
        guardDriverName: this.driverName,
      })
    }

    return this.user
  }

  async authenticateAsClient(
    user: UserProvider[typeof symbols.PROVIDER_REAL_USER]
  ): Promise<AuthClientResponse> {
    const token = await this.generate(user)

    return {
      headers: {
        authorization: `Bearer ${token.token}`,
      },
    }
  }
}

注册守卫

config/auth.ts 中注册你的自定义守卫。JwtUserProviderContract 接口与 session 守卫的用户提供者兼容,因此你可以复用它:

config/auth.ts
import { defineConfig } from '@adonisjs/auth'
import { sessionUserProvider } from '@adonisjs/auth/session'
import env from '#start/env'
import { JwtGuard } from '#auth/guards/jwt'

const jwtConfig = {
  secret: env.get('APP_KEY'),
}

const userProvider = sessionUserProvider({
  model: () => import('#models/user'),
})

const authConfig = defineConfig({
  default: 'jwt',
  guards: {
    jwt: (ctx) => {
      return new JwtGuard(ctx, userProvider, jwtConfig)
    },
  },
})

export default authConfig

守卫工厂函数接收 HTTP 上下文,并为每个请求返回一个新的守卫实例。

使用守卫

注册好守卫后,就可以像使用任何内置守卫一样使用它:

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

router.post('/login', async ({ request, auth }) => {
  const { email, password } = request.all()
  const user = await User.verifyCredentials(email, password)

  return await auth.use('jwt').generate(user)
})

router
  .get('/profile', async ({ auth }) => {
    return auth.getUserOrFail()
  })
  .use(middleware.auth({ guards: ['jwt'] }))

下一步

该实现提供了一个 JWT 身份验证的基础框架。你可以考虑通过以下方式进行扩展:

  • 令牌过期时间(JWT 载荷中的 exp 声明)
  • 用于获取新 access token 的刷新令牌
  • 使用黑名单机制撤销令牌
  • 角色或权限等附加声明
  • 针对不同失败场景的自定义错误消息