カスタム認証ガード

カスタム認証ガードの作成

authパッケージを使用すると、組み込みのガードでは対応できないユースケースのためのカスタム認証ガードを作成できます。このガイドでは、JWTトークンを使用した認証のためのガードを作成します。

認証ガードは次の概念を中心に展開されます。

  • ユーザープロバイダー: ガードはユーザーに依存しないようにする必要があります。データベースからユーザーをクエリして検索するための関数をハードコードすべきではありません。代わりに、ガードはユーザープロバイダーに依存し、その実装をコンストラクタの依存関係として受け入れるべきです。

  • ガードの実装: ガードの実装はGuardContractインターフェイスに準拠する必要があります。このインターフェイスは、ガードをAuthレイヤーの他の部分と統合するために必要なAPIを記述しています。

UserProviderインターフェイスの作成

ガードはUserProviderインターフェイスと、それが含むべきメソッド/プロパティを定義する責任があります。たとえば、セッションガードが受け入れるUserProviderは、アクセストークンガードが受け入れるUserProviderよりもはるかにシンプルです。

したがって、すべてのガードの実装に準拠するUser Providerを作成する必要はありません。各ガードは、受け入れるUserプロバイダーの要件を指定できます。

この例では、user IDを使用してデータベース内のユーザーを検索するためのプロバイダーが必要です。使用するデータベースやクエリの方法は問いません。それは、Userプロバイダーを実装する開発者の責任です。

このガイドで書くすべてのコードは、最初はapp/auth/guardsディレクトリ内に格納された単一のファイルに記述することができます。

app/auth/guards/jwt.ts
import { symbols } from '@adonisjs/auth'
/**
* ユーザープロバイダーとガードの橋渡し
*/
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モデルを使用する実装では、Modelのインスタンスを返します。したがって、RealUserの値はそのインスタンスになります。

  • Prismaを使用する実装では、特定のプロパティを持つユーザーオブジェクトを返します。したがって、RealUserの値はそのオブジェクトになります。

要約すると、JwtUserProviderContractは、ユーザープロバイダーの実装にユーザーのデータ型を決定する権限を委ねています。

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

また、トークンに署名するためにシークレットキーを使用する必要があるため、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を持つ署名付きトークンを作成し、それを返します。

リクエストの認証

リクエストの認証には次の手順が含まれます。

  • リクエストヘッダーまたはクッキーからJWTトークンを読み取る。
  • トークンの正当性を検証する。
  • トークンが生成されたユーザーを取得する。 私たちのガードは、HttpContextにアクセスする必要がありますので、クラスの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ヘッダーから読み取ります。ただし、実装を調整してクッキーもサポートするようにすることもできます。

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]>
{
/**
* Same as authenticate, but does not throw an exception
*/
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

ご覧のように、sessionUserProviderJwtGuardの実装と共に使用しています。これは、JwtUserProviderContractインターフェイスがセッションガードで作成されたユーザープロバイダーと互換性があるためです。

したがって、独自のユーザープロバイダーの実装を作成する代わりに、セッションガードから作成されたものを再利用しています。

最終的な例

実装が完了したら、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())