Nest.jsで認証を導入する
1.Webアプリケーションでの認証
どうも。
Nest.jsはnode製のサーバーサイドフレームワークです。
近年のWebアプリケーションでは、一般的にデータのやり取りはAPIを介して行われることが多くなっています。
フロントエンドからAPIを経由してバックエンドやデータベースにアクセスし、データを取得または変更して、それをクライアント側に返すという流れが一般的です。
当然ながら、フロントエンドから自由にデータを取得したり書き換えられることはセキュリティ上の大問題です。
特定のユーザーのプライベートな情報が含まれるページは、そのユーザー自身だけがアクセス可能であるべきです。
また、ログインが必要なページには認証データが必要となります。
そこで、今回はNest.jsで認証を導入したいと考えています。
認証にはFirebase Authenticationを使用します。
なお、Auth0などのIDaaS(Identity as a Service)も利用できますが、実装の概念はほぼ同じです。
2.プロジェクトを作っていく
まずはプロジェクトを作成していきます。
本質的でない部分は省略しながら、フロントエンドとバックエンドの両方を作成していきます。
3.フロントを構築
Firebaseをインストールしたら、適当にログイン画面を作成します。
ユーザーがFirebaseでログインすると、IDトークンを取得し、そのトークンを使用してバックエンド側にfetchリクエストを送信するだけです。
import { useEffect } from "react";
import { firebase } from'../services/index'
const Page = () => {
// Fetchする関数
const getfetch = async () => {
const token = localStorage.getItem('token');
if(token) {
const response = await fetch('http://localhost:3000', {
headers: {
Authorization: token
}
})
const datas = await response.json()
return datas;
}
}
// ログイン関数
const onClickSignIn = async (): Promise<void> => {
const provider = new firebase.auth.GoogleAuthProvider().setCustomParameters({
prompt: 'select_account',
})
await firebase.auth().signInWithRedirect(provider)
}
// ログアウト関数
const onClickSignOut = async (): Promise<void> => {
await firebase.auth().signOut()
window.location.reload()
}
// ログインしている場合はIdTokenを取得する
useEffect(() => {
const unsubscribe = firebase.auth().onAuthStateChanged(async (user) => {
if(user) {
const token = await user.getIdToken()
console.log(token);
localStorage.setItem('token', token);
await getfetch().then((data) => {
console.log(data)
})
} else {
localStorage.removeItem('token')
}
})
return () => {
unsubscribe()
}
}, [])
return (
<>
<h1>Hello World!</h1>
<button onClick={onClickSignIn}>ログイン</button>
<button onClick={onClickSignOut}>ログアウト</button>
</>
);
};
export default Page;
4.バックエンドを構築
Nest.jsで認証を導入する場合、Guardを使用します。
これはControllerに処理が到達する前の段階で、MiddlewareとしてGuardを挟み込み、リクエストが認証されているかどうかを判定します。
$ nest g gu guard/auth
コマンドを入力すると、guardディレクトリが作成されます。
その後、以下の3つのファイルを作成します。
$ touch auth.guard.ts auth.service.ts auth.module.ts
作成が完了したら、実際の処理を書いていきます。
// ./src/guard/auth.service.ts
import {
HttpException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import admin from 'firebase-admin';
import * as serviceAccount from './firebaseServiceAccount.json';
const firebase_params = {
type: serviceAccount.type,
projectId: serviceAccount.project_id,
privateKeyId: serviceAccount.private_key_id,
privateKey: serviceAccount.private_key,
clientEmail: serviceAccount.client_email,
clientId: serviceAccount.client_id,
authUri: serviceAccount.auth_uri,
tokenUri: serviceAccount.token_uri,
authProviderX509CertUrl: serviceAccount.auth_provider_x509_cert_url,
clientC509CertUrl: serviceAccount.client_x509_cert_url,
};
admin.initializeApp(firebase_params);
@Injectable()
export class AuthService {
async validateUser(idToken: string): Promise<any> {
if (!idToken) throw new UnauthorizedException('認証されていません');
try {
const user = await admin.auth().verifyIdToken(idToken);
return user;
} catch (e) {
throw new HttpException('Forbidden', e);
}
}
}
// ./src/guard/auth.guard.ts
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { AuthService } from './auth.service';
export const AUTHORIZATION_HEADER_KEY = 'authorization';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly firebaseAuth: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const idToken = request.headers[AUTHORIZATION_HEADER_KEY] as
| string
| undefined;
try {
request['user'] = await this.firebaseAuth.validateUser(idToken);
return true; // returnだけだとthrowされるのでtrueを返しておく
} catch (error) {
throw new UnauthorizedException('認証情報が正しくありません');
}
}
}
// ./src/guard/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthGuard } from './auth.guard';
@Module({
providers: [AuthService, AuthGuard],
imports: [],
exports: [AuthService, AuthGuard],
})
export class AuthModule {}
モジュールに関しては、authで作成したファイルをまとめるだけですね。
サービスでは、firebase-adminを導入します。
Firebaseを初期化し、Guardから渡されたIDトークンを検証する関数処理を作成しています。
5.Guardをみてみる
// ./src/guard/auth.guard.ts
export class AuthGuard implements CanActivate {
constructor(private readonly firebaseAuth: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const idToken = request.headers[AUTHORIZATION_HEADER_KEY] as
| string
| undefined;
try {
request['user'] = await this.firebaseAuth.validateUser(idToken);
return true; // returnだけだとthrowされるのでtrueを返しておく
} catch (error) {
throw new UnauthorizedException('認証情報が正しくありません');
}
}
}
canActivate関数では、引数としてcontextを受け取ることができます。
このcontextには、フロントエンドからのリクエストヘッダーを含む情報が渡されます。
以下の2行のコードでは、contextのrequestからidTokenを取得しています。
// ./src/guard/auth.guard.ts
const request = context.switchToHttp().getRequest();
const idToken = request.headers[AUTHORIZATION_HEADER_KEY] as | string | undefined;
idTokenが取得できたら、次はそのidTokenを検証し、リクエストがログインユーザーからのものかどうかを判定するためにvalidateUser関数を実行しています。
// ./src/guard/auth.guard.ts
try {
request['user'] = await this.firebaseAuth.validateUser(idToken);
return true; // returnだけだとthrowされるのでtrueを返しておく
} catch (error) {
throw new UnauthorizedException('認証されていません');
}
次に、service側のvalidateUser関数を見てみましょう。
この関数では、firebase-adminのverifyIdToken関数を使用してidTokenを検証し、検証が成功するとユーザー情報が返されるようになっています。
// ./src/guard/auth.service.ts
async validateUser(idToken: string): Promise<any> {
if (!idToken) throw new UnauthorizedException('認証されていません');
try {
const user = await admin.auth().verifyIdToken(idToken);
return user;
} catch (e) {
throw new HttpException('Forbidden', e);
}
}
検証されたデータは最終的に、こちらのrequestに代入されます。
request['user'] = await this.firebaseAuth.validateUser(idToken);
このようにすることで、Controllerの@Reqデコレーターを使用してuser情報を取得することができるようになります。
app.module.tsには、先ほど作成したauth.module.tsをimportsしておきます。
// ./src/guard/auth.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './guard/auth.module';
@Module({
imports: [AuthModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
それでは最後にControllerを実装します。
// ./src/app.controller.ts
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from './guard/auth.guard';
import { AppService } from './app.service';
import { Request } from 'express';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
@UseGuards(AuthGuard)
getHello(@Req() req: Request): any {
console.log(req.user);
return req.user;
}
}
@UseGuards(AuthGuard)デコレーターの引数には、先程作成したAuthGuardを渡すことで、Controllerが呼び出される前に認証処理を挟むことができます。認証が成功した場合、@Reqデコレーターのreq.userからユーザーの情報を取得できるようになります。
@Reqデコレーターで受け取る情報は粒度が大きすぎるため、ユーザー情報のみを取り出すカスタムなデコレーターを作成しましょう。
5.Decoratorを作る
// decorators/UserGuardDecorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const UserGuardDecorator = createParamDecorator(
(property: string, context: ExecutionContext) => {
const request = context.switchToHttp().getRequest();
const data = request['user'];
return property ? data?.[property] : data;
}
);
Contextからリクエストを取得し、AuthGuardで取得したリクエスト内のユーザー情報を返すデコレーターが作成されました。
次に、このデコレーターを使用して情報を受け取るために、次のControllerで試してみましょう。
// ./src/app.controller.ts
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from './guard/auth.guard';
import { AppService } from './app.service';
import { UserGuardDecorator } from 'src/decorators/UserGuardDecorator';
@Controller()
@UseGuards(AuthGuard) // ここでAuthGuard
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(@UserGuardDecorator() user: User): any {
console.log(user);
return user;
}
}
こちらの方法が便利ですね。
確認する場合は、ローカル環境で以下のコマンドを使用して両方を同時に起動しておきましょう。
異なるドメイン間でのリクエストを行う場合、ブラウザからAPIへのリクエストでCORSエラーが発生する可能性があります。
しかし、Next.jsのmain.tsを以下のように修正することで対処できます。
全てのドメインに対してCORSを許可しているため、本番環境では以下の設定を行わないようにしてください。
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { cors: true });
app.enableCors();
await app.listen(3000);
}
bootstrap();
これにより、認証に加えてControllerからユーザー情報を取得することができるようになりました。
ローカル環境でフロントからログインすると、ユーザー情報がコンソールに表示されるはずです。
いかがでしょうか?フロントエンドとバックエンド、そして認証を簡単かつセキュアに結びつけることができました。
今回はFirebase Authenticationを使用しましたが、Auth0などでも概念は同じです。基本的には、HeadersのAuthorizationにトークンを含めてバックエンドに送信し、バックエンドはトークンを検証し、認可を行って後続の処理を行うかどうかを判断します。
これはNest.jsを始めたばかりの方にとって参考になるかと思います。
それでは。