今作ってるAPIでは初めてNest.jsを使ってるんだけど、認可処理をどうしようかと考えたのでそのメモ。
ちなみにこの投稿では簡単な定義として認証(Authentication)とは利用者の本人確認、つまり通信の相手が誰であるかの確認とする。一方、認可(Authorization)とは利用者がシステム内、サービス内で実行できる操作の権限とする。
前提
- TypeScript
- Nest.js
- Prisma
- Firebase Authentication
認証自体はFirebase Authenticationを使っているので、認可をどうするかが話の主眼。
なお、前提として認証はクライアントサイドでFirebase Authenticationが認証時に発行するJWTのトークンを取得してAuthorizationヘッダにBearerトークンとして渡すよくあるやつで対応しますが、ここに関しては本投稿の主題ではないので割愛。
また、このJWTの検証をどこでやるかって話もあるんだけどこれについてはいくつかの事情によりAPIサーバの手前にあるAmazon API Gatewayで処理してます。Lambda Authorizerってやつですね。ここでトークンからuidだけを取り出してAPIサーバに渡してます。このあたりの話も今回は割愛。
認可する対象はユーザが作成したリソースに対するCRUDのアクセス権管理というオーソドックスなものだが、マルチテナントかつグルーピングやユーザ側のロールが複数あることに加えて扱うリソースも複数あることが少し話がややこしいところか。
Interceptor
実はNest.jsには後述するGuardって仕組みがあるんだけど、それを知らずに当初は大抵どのフレームワークにでもあるInterceptorとか呼ばれる仕組みで実装することを考えていた。
つまり、すべてのリクエストをキャプチャして認可処理してOKだった場合に後続の処理へ通すってのを考えていた。ある意味古き良き方法。というわけで見ていく。
Nest.jsにはこの仕組みがもちろん用意されている。
Interceptors | NestJS - A progressive Node.js framework
これはNestInterceptorをimplementsする形で用意するようだ。実際に用意するメソッドはintercept()でこれはExecutionContextとCallHandlerを引数に取る形で実装すればよい。
中身はまさにリクエストとレスポンスをインターセプトして行いたい処理を実装すればいい。例えばロギングとか。そしてCallHandlerのhandle()メソッドを呼ぶ形でreturnする。
実際にはコントローラクラスに対して@UseInterceptors(LoggingInterceptor)という風にデコレータをセットする。これはメソッドレベルのスコープにすることも可能らしい。
そしてクラスやメソッドごとに使い分けなどがない場合にグローバルレベルでセットすることも可能。その場合はmain.tsで以下のように指定する。
app.useGlobalInterceptors(new SomeInterceptor());
そしてモジュール外部でセットされたインターセプタはインジェクションされないということでapp_module.tsに以下のような設定を行う。
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: SomeInterceptor,
},
],
})
export class AppModule {}
今回自分がやりたいのは認可だ。先述の通り認証自体はFirebase Authでやっていて、JWTの検証はアプリの前段にいるAmazon API GatewayのLambda Authorizerを使っている。
このLambda AuthorizerではJWTの検証をして問題なければとあるヘッダを付与した上でバックエンドへとリクエストを渡すようになっている。
このヘッダの値をInterceptorで取り出して認可を行うということを考えていた。というわけで実装してみる。
今回は例としてx-auth-sampleというヘッダにhogehogeと設定されていればOK、セットされていなければNGとしてステータスコード403でエラーを返すくらいのシンプルなものだ
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
ForbiddenException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class AuthInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const ctx = context.switchToHttp();
const request = ctx.getRequest<Request>();
if (request.headers['x-auth-sample'] === 'hogehoge') {
return next.handle();
} else {
throw new ForbiddenException();
}
}
}
そして今回はグローバルに設定してみた。
app.useGlobalInterceptors(new AuthInterceptor());
そしてこれに対して以下のような感じでリクエストしてみる。問題なく動く。なお、ここでリクエストしている/samplesはもちろん事前に用意してある
curl --request GET \ --url http://localhost:3000/samples \ --header 'x-auth-sample: hogehoge'
ヘッダの値を変更するとちゃんとエラーレスポンスが帰ってくる。
{
"statusCode": 403,
"message": "Forbidden"
}
じゃ、さっそくこれで実装しようかと思いきやここでNest.jsには別の仕組みがあることを知る。
Guard
というわけでGuardである。同じくデコレータで指定して使うものだがInterceptorと異なり目的はシンプルでズバリ認可のためのものだ。Interceptorが文字通りリクエストとレスポンスをインターセプトして処理をするイメージなのに対して、Guardは文字通り防御的にルートハンドラの実行の手前に立ちはだかり、処理をさせるかどうかを判断する。
したがって実行順序としてもInterceptorの前である。というかすべての前らしい。
というわけでこちらも試しに実装してみる。
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest<Request>();
if (request.headers['x-auth-sample'] === 'hogehoge') {
return true;
} else {
return false;
}
}
}
Interceptorとは異なりCanActivateをimplementする形でcanActivate()関数を実装する。そしてInterceptorと異なるのはInterceptorの場合はreturnではCallHandlerで次を呼び出す形だったが、Guardの場合はbooleanの値だ。つまり認可OKならtrue、NGならfalseをreturnする。そしてtrueだったらルートハンドラに処理が渡される。
内容的には先ほどのInterceptorと同様でx-auth-sampleにとある値があればOKとしている。実際にはこんなことはありえないが。
trueだとリクエストは処理され、falseだとリクエストが拒否される。
Guardを実装したら実際に適用する。こちらもコントローラ・メソッド・グローバルのいずれかに設定可能。コントローラやメソッドに指定する場合は@UseGuards()というデコレータを利用する。
グローバルに設定するならばInterceptor同様にmain.tsに以下を追記する。
app.useGlobalGuards(new AuthGuard());
セットアップしたら先ほどと同様のリクエストをなげてみる。成功時の見た目は変わらない。失敗時にはForbiddenExceptionが発生する。
{
"statusCode": 403,
"message": "Forbidden resource",
"error": "Forbidden"
}
実際にはDBにある情報を使って認可を行うのでここで作ったAuthGuardでprismaを使えるように
constructor(private prisma: PrismaService) {}
としたものの
An argument for 'prisma' was not provided.
と出てしまった。どうやらグローバルレベルで設定するとモジュールの外で設定されることからInjectionに関して問題が出るようだ。
というわけでapp.module.tsで宣言してあげる。既存のものがあるので適宜読み替えて欲しいが自分の場合こんな感じ
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module';
import { APP_GUARD } from '@nestjs/core';
@Module({
imports: [
PrismaModule,
XXModule
],
controllers: [AppController],
providers: [
AppService,
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
})
ポイントはprovidersの中身。もともとAppServiceのみ登録していたがここにAuthGuardを登録してあげれば良い。
Guardの悩ましさ
と、Guardで実装しようとしたもののいざ自分の環境でやってみようとすると悩ましい。何が悩ましいかというと例えばGET /resouce/:idというAPIがあるとして、チェック対象のリソースをGuardの中で一度DBから取得してuser_idチェックとかして問題なければtrueを返す必要がある。そして、コントローラでもレスポンスのためにまた同じidでクエリすることになる。なんか無駄な気がする。
これだと、Guard使わずに各メソッドの中でベタにチェックするのと変わらない気がしてきた。メソッドごとにチェックを実装する必要があるのでそこも変わらない。
Guardって一番最初に処理されるっぽいのでDTOの中身にアクセスする場合もbodyから取り出して自分でparseする必要あるのもなんとも言えない。
また、今回のユースケースはいわゆるマルチテナント型のアプリケーションであることからテナントのチェックなども動的に行っていく必要がある。これらは当然ながらDBに情報が存在しているのはもちろん、APIエンドポイントによって取得するリソースを切り替えたりする必要もあってなかなか共通化・汎用化は難しそうだ。
CASLを使う
という感じで悩みながらNest.jsのドキュメントを眺めていたらCASLを使った認可処理の実装が紹介されていた。
Authorization | NestJS - A progressive Node.js framework
CASLというのはIsomorphicなJavaScriptの認可ライブラリ。アクションと対象クラスを指定して権限のあるなしを実装していきそれを呼び出して判定することで認可を実現していく。
では実際に試していく。ここはシンプルにNest.jsのドキュメントにあったサンプルをそのまま実装してみた。
まずはアクションをenumで用意する。これはつまり権限だ。
export enum Action {
Manage = 'manage',
Create = 'create',
Read = 'read',
Update = 'update',
Delete = 'delete',
}
CASLではチェック対象をSubjectと呼ぶ。このSubjectは通常エンティティクラスであったり、モデルだったりそういったものを使う。
サンプルどおりにUserとArticleの2種類のエンティティを用意する。SubjectとなるのはArticleのほう。
class User {
id: number;
isAdmin: boolean;
}
class Article {
id: number;
isPublished: boolean;
authorId: number;
}
さて、次はモジュールとなるCaslModuleとFactoryクラスとなるCaslAbilityFactoryの実装。
まずはCaslAbilityFactoryから。
type Subjects = InferSubjects<typeof Article | typeof User> | 'all';
export type AppAbility = Ability<[Action, Subjects]>;
@Injectable()
export class CaslAbilityFactory {
createForUser(user: User) {
const { can, cannot, build } = new AbilityBuilder<
Ability<[Action, Subjects]>
>(Ability as AbilityClass<AppAbility>);
if (user.isAdmin) {
can(Action.Manage, 'all');
} else {
can(Action.Read, 'all');
}
can(Action.Update, Article, { authorId: user.id });
cannot(Action.Delete, Article, { isPublished: true });
return build({
// Read https://casl.js.org/v5/en/guide/subject-type-detection#use-classes-as-subject-types for details
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
}
}
お作法的なところも多い。少しずつ見ていくと、
type Subjects = InferSubjects<typeof Article | typeof User> | 'all';
ここはSubjectとなる対象のエンティティクラスをここで登録する。ここでは先ほど作成したUserとArticleが登録されている。あとはall。これはCASLを使う上でお特別な定義で文字通りすべてを指す。
if (user.isAdmin) {
can(Action.Manage, 'all');
} else {
can(Action.Read, 'all');
}
can(Action.Update, Article, { authorId: user.id });
cannot(Action.Delete, Article, { isPublished: true });
このあたりがチェック処理そのもの。実際にはcanとcannotというものを必要な分だけ用意していく。構造としてはアクション、サブジェクト、条件。これだけなのでとてもシンプルと言える。
ここで用意したcanとcannotを呼び出して指定したリソースに対して特定の操作 (アクション)を行う権限があるかを条件によってチェックしていく。この条件は例えばチェック対象のエンティティの特定のプロパティの値がどうか、などを指定する。
今回の例で言えば、authorIdがリクエストをしてきたユーザのuser.idと一致していればUpdate可能となる。つまり自分が作成したArticleだったらUpdateできる、ということ。
それ以外にも特定プロパティの値が特定の値となっているかどうかでチェックすることも可能。
この辺りの条件はMongoDBのクエリがベースになっていてかなり柔軟な表現が可能。
そしてCaslModule。
import { Module } from '@nestjs/common';
import { CaslAbilityFactory } from './casl-ability.factory';
@Module({
providers: [CaslAbilityFactory],
exports: [CaslAbilityFactory],
})
export class CaslModule {}
ここまで実装したら呼び出し側を実装する。
基本的には適用したい箇所でFactoryで定義したcan/cannotを呼び出して判定=>処理を実施という形になる。
まずは使用したいクラスに先ほど作ったCaslAbilityFactoryをinjectionするための設定。
contextに先ほど作ったCaslModuleがimportされてる必要があるがこの辺りの話はNest.jsのDIに関するお作法を確認してもらえればいい。
constructor(private caslAbilityFactory: CaslAbilityFactory) {}
次にそのクラスでは各メソッド等で以下のようにしていく。
const user = new User();
user.isAdmin = false;
const ability = this.caslAbilityFactory.createForUser(user);
if(ability.can(Action.Read, Article)){
// 実際の処理
};
ここでは認可する人をUserとして用意して、アクセス対象のリソースとしてArticle、チェックする権限としてReadが指定されている。
つまり、このユーザが特定の記事を参照できるかをチェックしている。
そして問題なければtrueが返ってくるので実際の処理を実装する。
自分の場合はこのUserのオブジェクトはAPIリクエストに付与されたAuthorizationヘッダのトークンから取り出したIDをもとにDBからユーザ情報を引いて渡している。
実装はシンプルなもののちょっと挙動をすっきり理解するまでに時間がかかってしまった。
特に自分の場合、ORMとしてPrismaを使っているのだけどPrismaで取得した結果のオブジェクトを渡すとうまく動いてくれなかったのはかなり時間を浪費した。 これは結果的には新しくモデルと同等のオブジェクトをnewして値を詰め替えて渡すというイマイチな感じで落ち着いている。
渡すオブジェクトの型を推測してくれるんだがここが思ったようにいかなくて苦労している。subject()っていうヘルパー関数があるのでそれを使ったりもしているがあまり芳しい結果は得られていない。
また、自分のユースケースの場合はこの例のようにシンプルな構造ではなく、マルチテナントなアプリケーションであることからリソースの所有者やグループの概念があってそれがリクエストによって異なるため、どうしてもDBから情報を引く必要があるのも悩ましいところ。
なお、casl/prismaというパッケージが存在しているのは知っているがやりたいことが少し違いそうなのと、prismaの部分以外が使いにくくなりそうだったので今回はひとまず使っていない。
いずれにせよ、CASLを使う場合の肝はcan/cannotを判定する条件をいかに定義するかだ。
書き方がシンプルが故にユースケースを満たそうとするとどう表現すればいいのか悩んでしまう。
課題
自分が感じてる課題は二つ。
一つはサンプルのようにif文で囲う方式なのでどうしても実装に侵入してきてしまうのが個人的に気になる。
ただし、ここに関してはカスタムデコレータを使う形でうまく処理することもできそうなのでいずれチャレンジしよう。
もう一点は同じくif分で囲う、デコレータを作って指定するといった形式なのでdefaultでdenyするといった実装の方法がパッと思いつかないこと。
これは認可の実装漏れを防ぐことが工夫しないと難しいということになる。要は新しいAPIを作ったとして考慮が漏れたら認可の処理が行われず素通りになってしまうということ。もちろんテストやレビューで発見することもできるだろうが100%ではないので。この辺は入り口で絞れるGuardとかの仕組みのほうが良さそう。
まとめ
というわけで、いくつか課題はあるもののGuardで実装することを考えると繰り返しコードを書く量はだいぶ減りそうだし、DBへのアクセスも減りそうなのでCASLを採用することにした。そして今の所概ねうまく行っている。
また、実際にはCASLだけでなくGuardと併用している。Guardではシンプルにそもそも認証されたリクエストかどうかなどをチェックしていてここがNGだとその時点で403を返す。通ったらユーザハンドラに渡ってきて実際のメソッド内でCASLを用いたチェックを行うという流れになっている。さっきのフェイルセーフ的な機構もGuardで入れたいなと思っている。