自定义守卫

创建自定义身份认证守卫

auth 软件包允许你为内置守卫无法满足的使用场景创建自定义身份认证守卫。在本指南中,我们将创建一个使用 JWT 令牌进行认证的守卫。

身份认证守卫围绕以下概念展开。

  • 用户提供器(User Provider):守卫必须与用户无关。它们不应将查询和查找数据库用户的函数硬编码进去。相反,守卫应该依赖一个用户提供器,并将其实现作为构造函数依赖接受。

  • 守卫实现(Guard implementation):守卫实现必须遵循 GuardContract 接口。该接口描述了将守卫与 Auth 层其余部分集成所需的 API。

创建 UserProvider 接口

守卫负责定义 UserProvider 接口以及它应包含的方法/属性。例如,会话守卫 接受的 UserProvider 要比访问令牌守卫 接受的 UserProvider 简单得多。

因此,无需创建能满足每个守卫实现的 User Provider。每个守卫都可以规定它们所接受的 User Provider 的要求。

在本示例中,我们需要一个提供器来使用 user ID 在数据库中查找用户。我们并不关心使用的是哪个数据库,也不关心查询是如何执行的。那是实现 User Provider 的开发者的责任。

本指南中编写的所有代码最初都可以存放在 app/auth/guards 目录下的单个文件中。

app/auth/guards/jwt.ts
import { symbols } from '@adonisjs/auth'
/**
* User provider 与 Guard 之间的桥接
*/
export type JwtGuardUser<RealUser> = {
/**
* 返回用户的唯一 ID
*/
getId(): string | number | BigInt
/**
* 返回原始的用户对象
*/
getOriginal(): RealUser
}
/**
* JWT 守卫所接受的 UserProvider 接口
*/
export interface JwtUserProviderContract<RealUser> {
/**
* 守卫实现可以使用该属性来推断
* 实际用户(即 RealUser)的数据类型
*/
[symbols.PROVIDER_REAL_USER]: RealUser
/**
* 创建一个充当守卫与真实用户值之间
* 适配器的用户对象。
*/
createUserForGuard(user: RealUser): Promise<JwtGuardUser<RealUser>>
/**
* 通过用户 id 查找用户。
*/
findById(identifier: string | number | BigInt): Promise<JwtGuardUser<RealUser> | null>
}

在上述示例中,JwtUserProviderContract 接口接受了一个名为 RealUser 的泛型用户属性。由于该接口并不知道实际用户(我们从数据库获取的那个)长什么样,所以它将用户作为泛型接受。例如:

  • 使用 Lucid 模型的实现会返回模型的一个实例。因此,RealUser 的值就是该实例。

  • 使用 Prisma 的实现会返回一个带有特定属性的用户对象;因此,RealUser 的值就是该对象。

总而言之,JwtUserProviderContract 将用户的数据类型决定权留给了 User Provider 的实现。

理解 JwtGuardUser 类型

JwtGuardUser 类型充当用户提供器与守卫之间的桥梁。守卫使用 getId 方法获取用户的唯一 ID,并使用 getOriginal 方法在认证请求后获取用户的对象。

实现守卫

让我们创建 JwtGuard 类并定义 GuardContract 接口所需的方法/属性。最初,这个文件里会出现很多错误,但这没关系;随着我们的推进,所有错误都会消失。

请花些时间阅读下面的示例中每个属性/方法旁边的注释。

import { symbols } from '@adonisjs/auth'
import { AuthClientResponse, GuardContract } from '@adonisjs/auth/types'
export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
implements GuardContract<UserProvider[typeof symbols.PROVIDER_REAL_USER]>
{
/**
* 守卫发出的事件及其类型的列表。
*/
declare [symbols.GUARD_KNOWN_EVENTS]: {}
/**
* 守卫驱动的唯一名称
*/
driverName: 'jwt' = 'jwt'
/**
* 一个标志,用于判断当前 HTTP 请求期间
* 是否尝试过认证
*/
authenticationAttempted: boolean = false
/**
* 一个布尔值,用于判断当前请求是否已
* 通过认证
*/
isAuthenticated: boolean = false
/**
* 对当前已认证用户的引用
*/
user?: UserProvider[typeof symbols.PROVIDER_REAL_USER]
/**
* 为给定用户生成 JWT 令牌。
*/
async generate(user: UserProvider[typeof symbols.PROVIDER_REAL_USER]) {
}
/**
* 认证当前 HTTP 请求,如果存在有效的 JWT 令牌,
* 则返回用户实例,否则抛出异常
*/
async authenticate(): Promise<UserProvider[typeof symbols.PROVIDER_REAL_USER]> {
}
/**
* 与 authenticate 相同,但不抛出异常
*/
async check(): Promise<boolean> {
}
/**
* 返回已认证的用户,否则抛出错误
*/
getUserOrFail(): UserProvider[typeof symbols.PROVIDER_REAL_USER] {
}
/**
* 在测试期间使用 "loginAs" 方法登录用户时,
* 由 Japa 调用此方法。
*/
async authenticateAsClient(
user: UserProvider[typeof symbols.PROVIDER_REAL_USER]
): Promise<AuthClientResponse> {
}
}

接受用户提供器

守卫必须接受一个用户提供器,以便在认证期间查找用户。你可以将其作为构造函数参数接受并存储一个私有引用。

export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
implements GuardContract<UserProvider[typeof symbols.PROVIDER_REAL_USER]>
{
#userProvider: UserProvider
constructor(
userProvider: UserProvider
) {
this.#userProvider = userProvider
}
}

生成令牌

让我们实现 generate 方法,并为给定用户创建一个令牌。我们将从 npm 安装并使用 jsonwebtoken 软件包来生成令牌。

npm i jsonwebtoken @types/jsonwebtoken

此外,我们需要使用一个密钥(secret key) 来对令牌进行签名,所以让我们更新 constructor 方法,并通过选项对象接受该密钥作为选项。

import jwt from 'jsonwebtoken'
export type JwtGuardOptions = {
secret: string
}
export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
implements GuardContract<UserProvider[typeof symbols.PROVIDER_REAL_USER]>
{
#userProvider: UserProvider
#options: JwtGuardOptions
constructor(
userProvider: UserProvider
options: JwtGuardOptions
) {
this.#userProvider = userProvider
this.#options = options
}
/**
* 为给定用户生成 JWT 令牌。
*/
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
}
}
}
  • 首先,我们使用 userProvider.createUserForGuard 方法创建提供器用户的实例(即真实用户与守卫之间的桥梁)。

  • 接着,我们使用 jwt.sign 方法创建一个已签名的令牌,将 userId 放入载荷(payload)中并返回它。

认证请求

认证一个请求包括:

  • 从请求头或 cookie 中读取 JWT 令牌。
  • 验证其真实性。
  • 获取生成该令牌所针对的用户。

我们的守卫需要访问 HttpContext 才能读取请求头和 cookie,所以让我们更新类的 constructor 并将其作为参数接受。

import type { HttpContext } from '@adonisjs/core/http'
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
}
}

在本示例中,我们将从 authorization 请求头中读取令牌。不过,你也可以调整实现以支持 cookie。

import {
symbols,
errors
} from '@adonisjs/auth'
export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
implements GuardContract<UserProvider[typeof symbols.PROVIDER_REAL_USER]>
{
/**
* 认证当前 HTTP 请求,如果存在有效的 JWT 令牌,
* 则返回用户实例,否则抛出异常
*/
async authenticate(): Promise<UserProvider[typeof symbols.PROVIDER_REAL_USER]> {
/**
* 如果已经对该请求做过认证,则避免重复认证
*/
if (this.authenticationAttempted) {
return this.getUserOrFail()
}
this.authenticationAttempted = true
/**
* 确保 auth 请求头存在
*/
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,
})
}
/**
* 通过用户 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()
return this.getUserOrFail()
}
}

实现 check 方法

check 方法是 authenticate 方法的静默版本,你可以按如下方式实现它。

export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
implements GuardContract<UserProvider[typeof symbols.PROVIDER_REAL_USER]>
{
/**
* 与 authenticate 相同,但不抛出异常
*/
async check(): Promise<boolean> {
try {
await this.authenticate()
return true
} catch {
return false
}
}
}

实现 getUserOrFail 方法

最后,让我们实现 getUserOrFail 方法。它应该返回用户实例,或者在用户不存在时抛出错误。

export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
implements GuardContract<UserProvider[typeof symbols.PROVIDER_REAL_USER]>
{
/**
* 返回已认证的用户,否则抛出错误
*/
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 方法

authenticateAsClient 方法在测试期间使用,当你想通过 loginAs 方法 在测试中登录用户时。对于 JWT 实现,此方法应返回包含 JWT 令牌的 authorization 请求头。

export class JwtGuard<UserProvider extends JwtUserProviderContract<unknown>>
implements GuardContract<UserProvider[typeof symbols.PROVIDER_REAL_USER]>
{
/**
* 在测试期间使用 "loginAs" 方法登录用户时,
* 由 Japa 调用此方法。
*/
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,并在 guards 列表中注册该守卫。

import { defineConfig } from '@adonisjs/auth'
import { sessionUserProvider } from '@adonisjs/auth/session'
import env from '#start/env'
import { JwtGuard } from '../app/auth/jwt/guard.js'
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

正如你所注意到的,我们在 JwtGuard 实现中使用了 sessionUserProvider。这是因为 JwtUserProviderContract 接口与会话守卫创建的 User Provider 兼容。

因此,我们没有创建自己的 User Provider 实现,而是复用了会话守卫中的一个。

最终示例

实现完成后,你可以像使用其他内置守卫一样使用 jwt 守卫。以下是一个如何生成和验证 JWT 令牌的示例。

import User from '#models/user'
import router from '@adonisjs/core/services/router'
import { middleware } from './kernel.js'
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('/', async ({ auth }) => {
return auth.getUserOrFail()
})
.use(middleware.auth())