🍊

NestJS + graphQL + Prisma + Firebase でToDOリストを作ろう!〜認可機能編〜

2024/07/08に公開

はじめに

firebaseと連携した認可機能を作っていきます。
認証(ログイン)はフロントエンドで行うので、バックエンドではAPIの認可機能のみ実装することにします。
前回の記事はコチラ
https://zenn.dev/ouka031/articles/89eaaad8433743
この記事で制作したコードはコチラ
https://github.com/Shige031/nestjs-graphql-prisma-starter

firebase準備

firebaseプロジェクトのセットアップ

先にfirebaseの設定を行っておきます。以下のurlからfirebaseコンソールへアクセスします。
https://console.firebase.google.com/?hl=ja
プロジェクトを作成したら
左サイドバーの「構築」 > Authentication > 始める > メール/パスワード
からメールアドレスとパスワードによる認証を有効にします。

サービスアカウント設定

次に、firebase SDKに接続するための秘密鍵を作成します。
左上の歯車 > プロジェクトの設定 > サービスアカウント > 新しい秘密鍵の生成
ここで作成されたファイルは二度と落とせないので注意してください。
また、秘密鍵の情報が漏れるとfirebaseプロジェクトを好き勝手にいじられてしまうので漏洩には十分注意が必要です。

認可機能実装

PassportというNode.jsの認証ライブラリを使用します。
Nest.jsでは@nest.js/passportモジュールを使用してこのライブラリを利用することができます。
https://docs.nestjs.com/recipes/passport

必要なパッケージのインストール

今回はトークンをAuthorizationヘッダにBearerとして入れて使うので、passport-http-bearerパッケージも併せてインストールしておきます。
ここで言うトークンとは、firebaseが発行するjwtです。
https://www.passportjs.org/packages/passport-http-bearer/
また、firebase Admin SDKもインストールしておきましょう。
https://firebase.google.com/docs/admin/setup?hl=ja

ターミナル
npm install --save @nestjs/passport passport passport-http-bearer
npm install --save-dev @types/passport-http-bearer
npm install firebase-admin --save

Admin SDKのセットアップ

Admin SDKを初期化し、サーバー内で使用できるようにします。
秘密鍵を作成した時に作成されたファイルの情報はあらかじめ.envに格納しておきます。

src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import admin from 'firebase-admin';

async function bootstrap(): Promise<void> {
  const app = await NestFactory.create(AppModule);
  admin.initializeApp({
    credential: admin.credential.cert({
      type: process.env.FIREBASE_TYPE,
      projectId: process.env.FIREBASE_PROJECT_ID,
      privateKeyId: process.env.FIREBASE_PRIVATE_KEY_ID,
      privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
      clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
      clientId: process.env.FIREBASE_CLIENT_ID,
      authUri: process.env.FIREBASE_AUTH_URI,
      tokenUri: process.env.FIREBASE_TOKEN_URI,
      authProviderX509CertUrl: process.env.FIREBASE_AUTH_PROVIDER_X509_CERT_URL,
      clientX509CertUrl: process.env.FIREBASE_CLIENT_X509_CERT_URL,
      universeDomain: process.env.FIREBASE_UNIVERSE_DOMAIN,
    } as admin.ServiceAccount),
  });
  const port = Number(process.env.APP_PORT || 3000);
  await app.listen(port);
}
bootstrap();

Authモジュール作成

続いてAuthモジュールを作成し、その中に認証処理を行うstrategyとそれを用いて認可処理まで行ってくれるguardを作っていきます。

ターミナル
nest g module auth
src/auth/firebase-auth.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-http-bearer';
import admin from 'firebase-admin';
import { DecodedIdToken } from 'firebase-admin/lib/auth/token-verifier';
import { FindUserService } from 'src/user/service/findUser.service';
import { User } from '@prisma/client';

@Injectable()
export class FirebaseAuthStrategy extends PassportStrategy(
  Strategy,
  'firebase-auth',
) {
  constructor(private readonly findUserService: FindUserService) {
    super();
  }

  async validate(token: string): Promise<User> {
    try {
      const firebaseUser: DecodedIdToken = await admin
        .auth()
        .verifyIdToken(token);
      return this.findUserService.findByUId({ uid: firebaseUser.uid });
    } catch (e) {
      throw new UnauthorizedException();
    }
  }
}
src/auth/firebase-auth.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class FirebaseAuthGuard extends AuthGuard('firebase-auth') {
  getRequest(context: ExecutionContext): Request {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req;
  }
}
src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { FirebaseAuthStrategy } from './firebase-auth.strategy';
import { FirebaseAuthGuard } from './firebase-auth.guard';
import { UserModule } from 'src/user/user.module';

@Module({
  imports: [PassportModule, UserModule],
  providers: [FirebaseAuthStrategy, FirebaseAuthGuard],
})
export class AuthModule {}

Guardをリゾルバーにセット

createUser以外のQueryMutation全てにガードをセットしていきます。

src/user/resolver/user.resolver.ts
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { UserModel } from '../model/user.model';
import { FindUserService } from '../service/findUser.service';
import { CreateUserUseCase } from '../usecase/createUser.usecase';
import { UpdateUserService } from '../service/updateUser.service';
import { DeleteUserUseCase } from '../usecase/deleteUser.usecase';
+ import { UseGuards } from '@nestjs/common';
+ import { FirebaseAuthGuard } from 'src/auth/firebase-auth.guard';

@Resolver(() => UserModel)
export class UserResolver {
  constructor(
    private readonly findUserService: FindUserService,
    private readonly createUserUseCase: CreateUserUseCase,
    private readonly updateUserService: UpdateUserService,
    private readonly deleteUserService: DeleteUserUseCase,
  ) {}

  @Query(() => UserModel)
+  @UseGuards(FirebaseAuthGuard)
  async user(@Args('id') id: string) {
    return await this.findUserService.findById({
      id,
    });
  }

  @Mutation(() => UserModel)
  async createUser(
    @Args('name') name: string,
    @Args('uid') uid: string,
  ): Promise<UserModel> {
    return await this.createUserUseCase.handle({
      name,
      uid,
    });
  }

  @Mutation(() => UserModel)
+  @UseGuards(FirebaseAuthGuard)
  async updateUser(
    @Args('id') id: string,
    @Args('name') name: string,
  ): Promise<UserModel> {
    return await this.updateUserService.handle({
      id,
      name,
    });
  }

  @Mutation(() => UserModel)
+ @UseGuards(FirebaseAuthGuard)
  async deleteUser(@Args('id') id: string): Promise<UserModel> {
    return await this.deleteUserService.handle({
      id,
    });
  }
}
src/todo/resolver/todo.resolver.ts
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { TodoModel } from '../model/todo.model';
import { CreateTodoService } from '../service/createTodo.service';
import { UpdateTodoService } from '../service/updateTodo.service';
import { UpdateTodoStatusInput } from '../dto/todo.dto';
import { DeleteTodoService } from '../service/deleteTodo.service';
import { FindManyTodoService } from '../service/findManyTodo.service';
+ import { FirebaseAuthGuard } from 'src/auth/firebase-auth.guard';
+ import { UseGuards } from '@nestjs/common';

@Resolver(() => TodoModel)
export class TodoResolver {
  constructor(
    private readonly findManyTodoService: FindManyTodoService,
    private readonly createTodoService: CreateTodoService,
    private readonly updateTodoService: UpdateTodoService,
    private readonly deleteTodoService: DeleteTodoService,
  ) {}

  @Query(() => [TodoModel])
+  @UseGuards(FirebaseAuthGuard)
  async todos() {
    return await this.findManyTodoService.handle({
      userId: 'xxx',
    });
  }

  @Mutation(() => TodoModel)
+  @UseGuards(FirebaseAuthGuard)
  async createTodo(
    @Args('title') title: string,
    @Args('description') description: string,
  ): Promise<TodoModel> {
    return await this.createTodoService.handle({
      userId: 'xxx',
      title,
      description,
    });
  }

  @Mutation(() => TodoModel)
+  @UseGuards(FirebaseAuthGuard)
  async updateTodoContent(
    @Args('id') id: string,
    @Args('title') title: string,
    @Args('description') description: string,
  ): Promise<TodoModel> {
    return await this.updateTodoService.updateContent({
      id,
      title,
      description,
    });
  }

  @Mutation(() => TodoModel)
+  @UseGuards(FirebaseAuthGuard)
  async updateTodoStatus(
    @Args('input') input: UpdateTodoStatusInput,
  ): Promise<TodoModel> {
    return await this.updateTodoService.updateStatus({
      ...input,
    });
  }

  @Mutation(() => TodoModel)
+  @UseGuards(FirebaseAuthGuard)
  async deleteTodo(@Args('id') id: string): Promise<TodoModel> {
    return await this.deleteTodoService.handle({ id });
  }
}

動作確認

ちゃんとGuardが動作しているか確認してみましょう。
Unauthorizedが返ってきたらOKです。

次に、認可機能が正しく動作しているかチェックします。
まず、firebaseコンソールでユーザーを作成します。

このuidを使用し、createUserを叩きます。

これでfirebase、DB共にユーザーの情報が入ったので、ログインしてトークンを取得します。

ターミナル
curl 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=[API_KEY]' \
-H 'Content-Type: application/json' \
--data-binary '{"email":"[user@example.com]","password":"[PASSWORD]","returnSecureToken":true}'

すると以下のようなレスポンスが返ってきます。

{
  "localId": "ZY1rJK0eYLg...",
  "email": "[user@example.com]",
  "displayName": "",
  "idToken": "[ID_TOKEN]",
  "registered": true,
  "refreshToken": "[REFRESH_TOKEN]",
  "expiresIn": "3600"
}

このidTokenをAuthorizationヘッダーのBearerに入れてもう一度APIを叩きます。


すると次はupdateが通りました!これで認可機能の実装は完了です。

ユースケースにfirebase Authenticationへの処理を追加する

それでは次に、CreateUserUseCaseDeleteUserUseCaseにfirebase Authenticationへの処理を追加していきます。

utilモジュール作成

ターミナル
$ nest g module util

utilモジュールの中にfirebaseディレクトリを切り、その中にfirebase Authenticationへの処理を書いていきます。

util/firebase/firebase.service.ts
import { Injectable } from '@nestjs/common';
import admin from 'firebase-admin';
import { UserRecord } from 'firebase-admin/lib/auth/user-record';

@Injectable()
export class FirebaseService {
  constructor() {}

  async findByUid({ uid }: { uid: string }): Promise<UserRecord | null> {
    try {
      return await admin.auth().getUser(uid);
    } catch (e) {
      return null;
    }
  }

  async delete({ uid }: { uid: string }): Promise<void> {
    await admin.auth().deleteUser(uid);
  }
}

ユースケース修正

src/user/usecase/createUserUseCase.ts
import { Injectable } from '@nestjs/common';
import { User } from '@prisma/client';
import { CreateUserService } from '../service/createUser.service';
import { FirebaseService } from 'src/util/firebase/firebase.service';

@Injectable()
export class CreateUserUseCase {
  constructor(
    private readonly createUserService: CreateUserService,
    private readonly firebaseService: FirebaseService,
  ) {}

  async handle({ name, uid }: { name: string; uid: string }): Promise<User> {
    const firebaseUser = await this.firebaseService.findByUid({ uid });

    if (!firebaseUser) throw new Error('存在しないユーザーです');

    return this.createUserService.handle({
      firebaseUId: uid,
      name,
    });
  }
}
src/user/usecase/deleteUserUseCase.ts
import { Injectable } from '@nestjs/common';
import { User } from '@prisma/client';
import { DeleteUserService } from '../service/deleteUser.service';
import { FindUserService } from '../service/findUser.service';
import { FirebaseService } from 'src/util/firebase/firebase.service';

@Injectable()
export class DeleteUserUseCase {
  constructor(
    private readonly findUserService: FindUserService,
    private readonly deleteUserService: DeleteUserService,
    private readonly firebaseService: FirebaseService,
  ) {}

  async handle({ id }: { id: string }): Promise<User> {
    const user = await this.findUserService.findById({ id });

    // firebaseからユーザーを削除
    await this.firebaseService.delete({ uid: user.firebaseUId });

    // dbからユーザーを削除
    return this.deleteUserService.handle({
      id,
    });
  }
}

動作確認

書き終わったら、deleteUserを叩いてみましょう。

firebaseからもユーザーが削除されていればOKです。

デコレータ実装

次に、認可の過程でリクエストコンテキストに入れたユーザーの情報をリゾルバーで取り出すためのカスタムデコレータを実装していきます。
https://docs.nestjs.com/custom-decorators
ここで少し、どうやってリクエストコンテキストにユーザー情報が格納されるのかを解説します。
guardが実行されると、以下のcanActivateメソッドが動きます。
中を見てみるとPassportFunctionを実行し、その返り値をrequest.userに詰めているのが分かります。

auth.guard.js
async canActivate(context) {
            const options = {
                ...options_1.defaultOptions,
                ...this.options,
                ...(await this.getAuthenticateOptions(context))
            };
            const [request, response] = [
                this.getRequest(context),
                this.getResponse(context)
            ];
            const passportFn = createPassportContext(request, response);
            const user = await passportFn(type || this.options.defaultStrategy, options, (err, user, info, status) => this.handleRequest(err, user, info, context, status));
            request[options.property || options_1.defaultOptions.property] = user;
            return true;
        }

今回コンストラクタにセットしているpassportFnは以下でしたね。

arc/auth/firebase-auth.strategy.ts
export class FirebaseAuthStrategy extends PassportStrategy(
  Strategy,
  'firebase-auth',
) {
  constructor(private readonly findUserService: FindUserService) {
    super();
  }

  async validate(token: string): Promise<User> {
    try {
      const firebaseUser: DecodedIdToken = await admin
        .auth()
        .verifyIdToken(token);
      return this.findUserService.findByUId({ uid: firebaseUser.uid });
    } catch (e) {
      throw new UnauthorizedException();
    }
  }
}

ざっくりいうとguardが動く -> canActivateが実行される -> FirebaseAuthStrategyが動き、ユーザーの情報を返す -> request.userへその値が格納される。というわけです。

decoratorでは、request.userを取り出す処理を書いていきます。

src/decorator/user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';

export const UserEntity = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const req = GqlExecutionContext.create(ctx).getContext().req;
    return req.user ? req.user : undefined;
  },
);

あとはセットするだけでOKです。

src/todo/resolver/todo.resolver.ts
  @Query(() => [TodoModel])
  @UseGuards(FirebaseAuthGuard)
+  async todos(@UserEntity('user') user: User) {
    return await this.findManyTodoService.handle({
      userId: user.id,
    });
  }

  @Mutation(() => TodoModel)
  @UseGuards(FirebaseAuthGuard)
  async createTodo(
+   @UserEntity('user') user: User,
    @Args('title') title: string,
    @Args('description') description: string,
  ): Promise<TodoModel> {
    return await this.createTodoService.handle({
      userId: user.id,
      title,
      description,
    });
  }

動作確認

firebaseでユーザー作成 -> createUser -> ログインしてから、createTodoを叩きます。

それっぽいUserIdが入っていますね。DBを見て確認してみましょう。

OKですね!

今回はここまでです。次回はユニットテストとe2eテストを実装していきます。

https://zenn.dev/ouka031/articles/964127a37793f3

Discussion