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

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

https://qiita.com/suzuki0430/items/d15c8642b46b57cdf91b

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

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

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

UsersModuleの構成は改めて以下のようになります。
![スクリーンショット 2021-10-10 9.23.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/bd4fafef-c70b-c34a-b326-2a07365de5fd.png)

また、パスワードを保存するにあたり、Saltを利用したハッシュ化を行います。
Saltというのはランダムな文字列で、パスワードに連結してハッシュ化する際に使用します。
パスワードをそのままハッシュ化するだけだと元のデータを推測されてしまう可能性が高くなるため、このような工夫が必要となります。
Saltはユーザごとに生成しDBに保存します。
保存したSaltはログイン時のパスワード比較に使用します。
>![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/e8273300-35ce-a8be-047b-548a685f1b8f.png)
<a href="https://medium-company.com/salt/">ITを分かりやすく解説 -【パスワード】ソルト（Salt）とは-</a>

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

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

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

```ts: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`に新規登録用のルートハンドラーを登録します。

```ts: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/d7282c19-a0d5-c844-249a-d75782bf6705.png)

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

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

```ts: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`にルートハンドラーを登録します。

```ts: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.ts`で`cookie-session`をインポートし、`bootstrap()`内に記述します。

```ts: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)します。

```ts: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/4d1adbbb-6bb6-1420-5554-e56fffb8e02a.png)

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

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

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

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

```ts: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();
  }
}
```

```ts: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の処理が適用されます。

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

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

```ts: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`を返します。

```ts: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`を返すときだけアクセスすることができます。

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

#参考資料

https://www.udemy.com/share/104SA43@2l0J6xZ8sKvvXGn5JpoqYmXVkcgE8kwVjd49NiLAiLzjXpQZ91VfzYs159fpjzPZ/

https://medium-company.com/salt/

https://maku77.github.io/nodejs/async/promisify.html

https://www.infraexpert.com/study/tcpip16.6.html

https://qiita.com/G-awa/items/ebb6e214ed45f27e5de2