LoginSignup
30
17

More than 1 year has passed since last update.

【NestJS】新規登録とログインを実装する(Salt, Cookie, Guard, Interceptor)

Last updated at Posted at 2021-10-10

はじめに

前回の記事のCRUDとバリデーションに引き続き、今回は新規登録とログイン周りの実装を行いました。

実装

新規登録を実装するにあたり、以下の機能を追加する必要があります。

  • emailが登録済の場合はエラーを返す
  • パスワードをハッシュ化してDBに保存
  • DBに保存されたユーザIDをブラウザのCookieに保存
  • (新規登録後)リクエストにCookieを付加

今回signup()signin()というメソッドが新しく必要になるのですが、UsersServiceの肥大化を避けるため、認証周りのメソッドに関してはAuthServiceに追加していきます。

UsersModuleの構成は改めて以下のようになります。
スクリーンショット 2021-10-10 9.23.25.png

また、パスワードを保存するにあたり、Saltを利用したハッシュ化を行います。
Saltというのはランダムな文字列で、パスワードに連結してハッシュ化する際に使用します。
パスワードをそのままハッシュ化するだけだと元のデータを推測されてしまう可能性が高くなるため、このような工夫が必要となります。
Saltはユーザごとに生成しDBに保存します。
保存したSaltはログイン時のパスワード比較に使用します。

image.png
ITを分かりやすく解説 -【パスワード】ソルト(Salt)とは-

新規登録

AuthServiceと新規登録メソッドsignup()を作成します。
constructorでUsersServiceを呼び出し、emailの使用済確認でfind()、ユーザの作成でcreate()を使用します。

次にrandomBytes(8).toString('hex')でSaltを作成します。
「16進数でエンコードされたString形式で、ランダムで長さが少なくとも8バイト」というSaltの条件を満たしています。

ハッシュ化した後は、salt + '.' + hash.toString('hex')でSaltとハッシュ値を.で連結し、これをパスワードとしてDBに保存します。
ログイン時に同じSaltでパスワードをハッシュ化しないと照合できないため、Saltもあわせて保存するようにします。

auth.service.ts
import { BadRequestException, Injectable } from '@nestjs/common';
import { UsersService } from './users.service';
import { randomBytes, scrypt as _scrypt } from 'crypto';
import { promisify } from 'util';

const scrypt = promisify(_scrypt);

@Injectable()
export class AuthService {
  constructor(private usersService: UsersService) {}

  async siginup(email: string, password: string) {
    // emailが使用済かどうか確認
    const users = await this.usersService.find(email);
    if (users.length) {
      throw new BadRequestException('email in use');
    }

    // Salt生成
    const salt = randomBytes(8).toString('hex');

    // パスワードとSaltを連結してハッシュ化
    const hash = (await scrypt(password, salt, 32)) as Buffer;

    // Saltとハッシュ値を結合
    const result = salt + '.' + hash.toString('hex');

    // 新しいユーザの作成と保存
    const user = await this.usersService.create(email, result);

    // ユーザを返す
    return user;
  }
}

UsersControllerに新規登録用のルートハンドラーを登録します。

users.controller.ts
export class UsersController {
  constructor(
    private usersService: UsersService,
    private authService: AuthService,
  ) {}

  @Post('/signup')
  createUser(@Body() body: CreateUserDto) {
    return this.authService.siginup(body.email, body.password);
  }

リクエストを投げると以下のようなレコードがテーブルに登録されます。

POST http://localhost:3000/auth/signup
content-type: application/json

{
  "email": "asdf10@asdf.com",
  "password": "asdlfkajsd"
}

スクリーンショット 2021-10-09 11.22.14.png

ログイン

AuthServicesignin()メソッドを追加します。
この中では、登録したemailが存在しているかどうかを確認したあとに、パスワードの照合を行います。

新規登録時に"Salt.ハッシュ値"としてパスワードを保存したので、const [salt, storedHash] = user.password.split('.')でSaltとハッシュ値を分割して取り出します。
そして入力したパスワードをSaltでハッシュ化し、保存したハッシュ値と同じ値であればuserを返すようにします。

auth.service.ts
  async signin(email: string, password: string) {
    const [user] = await this.usersService.find(email);
    if (!user) {
      throw new NotFoundException('user not found');
    }

    const [salt, storedHash] = user.password.split('.');

    // 入力したパスワードの照合
    const hash = (await scrypt(password, salt, 32)) as Buffer;

    if (storedHash !== hash.toString('hex')) {
      throw new BadRequestException('bad password');
    }

    return user;
  }

UsersControllerにルートハンドラーを登録します。

users.controller.ts
  @Post('/signin')
  signin(@Body() body: CreateUserDto) {
    return this.authService.signin(body.email, body.password);
  }

CookieによるSession管理

ユーザのSessionデータを保持するには以下の方法があるのですが、今回はcoookie-sessionというライブラリで2の方法を実装します。

  1. サーバでSessionデータ、クライアントではSession IDのみ保持。
  2. クライアントでSessionデータを保持

cookie-sessionでSessionデータを管理する流れは以下のようになります。

  • クライアントが暗号化されたCookieヘッダーを付加したリクエストをサーバへなげる
  • cookie-sessionがCookieヘッダーを復号してSessionオブジェクトに変換する
  • @Session()デコレータでリクエストハンドラー内でSessionオブジェクトにアクセスする
  • Sessionオブジェクトを操作(CRUD)する
  • cookie-sessionが更新されたSessionオブジェクトを暗号化する
  • 暗号化されたSessionをSet-Cookieヘッダーに付加してクライアントにレスポンスとして返す(Sessionに変更がなければSet-Cookieは返さない)

実装のはじめに、以下のパッケージをインストールします。

npm install cookie-session @types/cookie-session

main.tscookie-sessionをインポートし、bootstrap()内に記述します。

main.ts
const cookieSession = require('cookie-session');

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.use(
    cookieSession({
      keys: ['key1'],
    }),
  );

次にリクエストのCookieヘッダーをSessionオブジェクトに変換してアクセスするために、リクエストハンドラーcreateUser() signin() signout()に、@Session()デコレータを付加した引数sessionを記述します。
そして、createUser() signin()ではSessionデータにユーザIDを書き込み、signout()ではユーザIDを削除(null)します。

users.controller.ts
  @Post('/signup')
  async createUser(@Body() body: CreateUserDto, @Session() session: any) {
    const user = await this.authService.siginup(body.email, body.password);
    session.userId = user.id;
    return user;
  }

  @Post('/signin')
  async signin(@Body() body: CreateUserDto, @Session() session: any) {
    const user = await this.authService.signin(body.email, body.password);
    session.userId = user.id;
    return user;
  }

  @Post('/signout')
  signOut(@Session() session: any) {
    session.userId = null;
  }

試しにログインしてみると、以下のようにSet-Cookieヘッダーを付加したレスポンスが返ってきています。

POST http://localhost:3000/auth/signin
content-type: application/json

{
  "email": "test1@test.com",
  "password": "12345"
}

スクリーンショット 2021-10-10 13.42.56.png

Interceptor + Decoratorでルートハンドラーに現在のログインユーザを伝える

どのユーザかログイン中であるかを返すwhoAmI()というルートハンドラーを新しく作成します。
この際、ユーザ情報userを返す@CurrentUser()というカスタムデコレータを作成します。

users.controller.ts
  @Get('/whoami')
  whoAmI(@CurrentUser() user: User) {
    return user;
  }

@CurrentUser()内でユーザ情報をDBから取得したいのですが、DecoratorはDIの外にあるためUsersServiceのインスタンスを読み込めず、find()メソッドを使用することができません。

この問題を解消するために、Decoratorでリクエストを受ける前にInterceptorを置き、Interceptorでユーザ情報を取得してからDecoratorに渡すようにします。

current-user.interceptor.ts
import {
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  Injectable,
} from '@nestjs/common';
import { UsersService } from '../users.service';

@Injectable()
export class CurrentUserInterceptor implements NestInterceptor {
  constructor(private usersService: UsersService) {}

  async intercept(context: ExecutionContext, handler: CallHandler) {
    const request = context.switchToHttp().getRequest();
    const { userId } = request.session || {};

    if (userId) {
      const user = await this.usersService.findOne(userId);
      request.currentUser = user;
    }

    return handler.handle();
  }
}
current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const CurrentUser = createParamDecorator(
  (data: never, context: ExecutionContext) => {
    const request = context.switchToHttp().getRequest();
    return request.currentUser;
  },
);

Interceptorをコントローラ自体にデコレートすることで、すべてのルートハンドラーにInterceptorの処理が適用されます。

users.controller.ts
@UseInterceptors(CurrentUserInterceptor)
export class UsersController {

コントローラごとにデコレータを付加するのが面倒な場合には、Module内で以下を記述してあげることでModule内のすべてのルートハンドラーにInterceptorを適用することができます。

users.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [
    UsersService,
    AuthService,
    {
      provide: APP_INTERCEPTOR,
      useClass: CurrentUserInterceptor,
    },
  ],
})

Guardで未ログインユーザに特定の操作を許可しない

Guardを利用することで、ユーザへ特定のルート(Controllerもしくはルートハンドラーごと)へのアクセスを制限することができます。

例えば、以下のAuthGuard()ではリクエストのCookie(Sessionオブジェクト)にユーザIDが含まれていればtrueを返します。

auth.guard.ts
import { CanActivate, ExecutionContext } from '@nestjs/common';

export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest();

    return request.session.userId;
  }
}

ルートハンドラーに@UseGuards(AuthGuard)デコレータを付加することで、AuthGuard()trueを返すときだけアクセスすることができます。

users.controller.ts
  @Get('/whoami')
  @UseGuards(AuthGuard)
  whoAmI(@CurrentUser() user: User) {
    return user;
  }

参考資料

30
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
17