NestJSでBasic認証ダイアログを出す方法

最近、TypeScriptの良さに目覚めてしまったので、サーバサイドでも使ってみようとNestJSで簡単なシステムを作って遊んでみました。
NestJSはTypeScript製のNode.jsフレームワークで、ここ最近すごい盛り上がりを見せていて
expressの次に有名なフレームワークになるんじゃないかと思ったので使ってみました。
nestjs.com
そんな中、管理画面にBasic認証をかけてみようとしたら大いにハマりました。
NestJSはまだ知名度では発展途上ということもあり、日本語の情報が少ないので
解決させた方法を紹介します。



passport

NestJSでBasic認証を調べると@nestjs/passportが一般的ぽかったので
リンクのようにインストールして、Overview & Tutorialを参考に実装してみました。
github.com

passport-http

Overview & Tutorialではpassport-http-bearerを使った例ですが、
passportのBasic認証はpassport-httpにあるようなので、こちらをインストールします。

> npm install --save @nestjs/passport passport passport-http

basic.strategy.ts

新規にbasic.strategy.tsを作成して、以下のようにpassport-httpのBasicStrategyを
@nestjs/passportに従って作ります。

import { BasicStrategy } from 'passport-http';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';

// Basic認証
@Injectable()
export class BasicAuthStrategy extends PassportStrategy(BasicStrategy, 'basic-auth') {
  constructor() {
    super();
  }

  async validate(username: string, password: string) {
    // ユーザとパスワード
    if((username != 'test') || (password != 'test')){
      return false;
    }
    return true;
  }
}

app.module.ts

Overview & Tutorialでは別のauth.module.tsを作っていますが
今回は手抜きで、app.module.tsのprovidersにBasicAuthStrategyを追加しました。

import { Module } from '@nestjs/common';
import { AppController} from './app.controller';
import { AppService} from './app.service';
import { BasicAuthStrategy } from './basic.strategy';

@Module({
  controllers: [AppController],
  providers: [BasicAuthStrategy, AppService],
})
export class AppModule {}

app.controller.ts

そしてコントローラにデコレータ@UseGuards(AuthGuard('basic-auth'))を追加します。
これでgetIndex()実行前に、BasicAuthStrategyが実行されるようになります。

import { Controller, Get, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  @UseGuards(AuthGuard('basic-auth'))
  getIndex() {
    return this.appService.getHello();
  }
}

しかし動かない

これでブラウザからアクセス、401 Unauthorizedとガードはされるのですが
あのBasic認証時のダイアログが出てこないので、ユーザ・パスワードが入力できず認証されません。
困りました。
f:id:MSitter29:20190313103728j:plain

Exception filters

@nestjs/passportとpassport-httpのソースを追いかけてわかったのですが
passport-httpは認証チェックはしてくれるが、認証ダイアログに必要なレスポンスヘッダWWW-Authenticateを付与してくれない。
そこまではしないライブラリだということがわかりました。

また、AuthGuardが認証NG時にUnauthorizedExceptionを実行していることもわかりました。

auth-exception.filter.ts

そこでauth-exception.filter.tsを作り、Exception filterでUnauthorizedExceptionを捕まえてレスポンスヘッダにWWW-Authenticateを付与するようにしました。

import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus } from '@nestjs/common';
import { HttpException } from '@nestjs/common';

// Basic認証用にエクセプションを加工する.
@Catch(HttpException)
export class AuthExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const status = exception.getStatus();

    if(HttpStatus.UNAUTHORIZED == status){
      // Basic認証ダイアログ用に付与.
      response.set('WWW-Authenticate', 'Basic realm="aaa"');
    }
    response
      .status(status)
      .json({
        statusCode: status,
      });
  }
}

app.controller.ts

そして、デコレータで@UseFilters(new AuthExceptionFilter())をコントローラに追加します。

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  @UseFilters(new AuthExceptionFilter())
  @UseGuards(AuthGuard('basic-auth'))
  getIndex() {
    return this.appService.getHello();
  }
}

成功!

認証ダイアログの表示に成功しました。
これでユーザ・パスワードを入力すれば認証が通って正常に表示されるようになりました。
f:id:MSitter29:20190313105202j:plain


@nestjs/passport、passport-httpの使い方も情報が少なく
ここまでたどり着くのに試行錯誤でしたが、これで無事解決しました。