NestJS + graphQL + Prisma + Firebase でToDOリストを作ろう!〜認可機能編〜
はじめに
firebaseと連携した認可機能を作っていきます。
認証(ログイン)はフロントエンドで行うので、バックエンドではAPIの認可機能のみ実装することにします。
前回の記事はコチラ
この記事で制作したコードはコチラ
firebase準備
firebaseプロジェクトのセットアップ
先にfirebaseの設定を行っておきます。以下のurlからfirebaseコンソールへアクセスします。
左サイドバーの「構築」 > Authentication > 始める > メール/パスワード
からメールアドレスとパスワードによる認証を有効にします。
サービスアカウント設定
次に、firebase SDKに接続するための秘密鍵を作成します。
左上の歯車 > プロジェクトの設定 > サービスアカウント > 新しい秘密鍵の生成
ここで作成されたファイルは二度と落とせないので注意してください。
また、秘密鍵の情報が漏れるとfirebaseプロジェクトを好き勝手にいじられてしまうので漏洩には十分注意が必要です。
認可機能実装
PassportというNode.jsの認証ライブラリを使用します。
Nest.jsでは@nest.js/passportモジュールを使用してこのライブラリを利用することができます。
必要なパッケージのインストール
今回はトークンをAuthorizationヘッダにBearerとして入れて使うので、passport-http-bearer
パッケージも併せてインストールしておきます。
ここで言うトークンとは、firebaseが発行するjwtです。
また、firebase Admin SDKもインストールしておきましょう。
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
に格納しておきます。
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
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();
}
}
}
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;
}
}
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
以外のQuery
とMutation
全てにガードをセットしていきます。
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,
});
}
}
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への処理を追加する
それでは次に、CreateUserUseCase
とDeleteUserUseCase
にfirebase Authenticationへの処理を追加していきます。
utilモジュール作成
$ nest g module util
utilモジュールの中にfirebaseディレクトリを切り、その中にfirebase Authenticationへの処理を書いていきます。
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);
}
}
ユースケース修正
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,
});
}
}
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です。
デコレータ実装
次に、認可の過程でリクエストコンテキストに入れたユーザーの情報をリゾルバーで取り出すためのカスタムデコレータを実装していきます。
guardが実行されると、以下のcanActivateメソッドが動きます。
中を見てみるとPassportFunctionを実行し、その返り値をrequest.user
に詰めているのが分かります。
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は以下でしたね。
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を取り出す処理を書いていきます。
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です。
@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テストを実装していきます。
Discussion