Nestjsで生成したjwtをNext.jsで永続化したい

komosyu
Nestjs

目次

  1. はじめに
  2. ではどうしたらセキュアにトークンを永続化できるのか
    1. main.ts
    2. auth
      1. auth.module.ts
      2. auth.controller.ts
      3. auth.service.ts
    3. jwt.strategy.ts
    4. フロントエンドでの取得方法
  3. 参考
  4. さいごに

はじめに

Nestjs と Next.js で開発をしていたのですが、ちょっとした認証機能をつけていて Nestjs で作成した signup/singin の処理を行って認証が済まされた際にフロントエンドでトークンを受け取って、それをもとに他の処理をするイメージをしていました。

こんな感じで。

const storeData = async () => {
  const { data } = await axios.get(
    `${process.env.NEXT_PUBLIC_NESTJS_API_URL}/store`,
    {
      headers: {
        Authorization: `Bearer ${jwt}`,
      },
    }
  )
  return data
}

ですが、調べてみると実際に jwt トークンをセキュアに永続化して持っておくのにはちょっと工夫が必要なようでした。

たしかに、ふつうに考えてみたらどうにかしないとリロード時にトークンが消えてしまうのは当たり前ですよね。

でも、一般的にブラウザ内で情報を永続化するためのローカルストレージやセッションストレージではセキュリティに問題があります。
XSS(クロスサイトスクリプティング)などの攻撃を受けてしまうとトークンが攻撃者にアクセスされてしまうリスクがあるのです。

ではどうしたらセキュアにトークンを永続化できるのか

結論から言うと、サーバ側で signup/singin の処理を行なって認証が済まされたタイミングでcookieに jwt トークンを設定するのです。

ただ、その際に少しだけ気をつけるポイントがあるので以下で解説していきますね。

main.ts

まずはここから。

今回はcookie-parserというライブラリを使ってcookieを扱っていきましょう。
以下のコマンドでインストールします。

yarn add cookie-parser

そして、main.tsでミドルウェアとしてcookie-parserを登録していきます。

ここで出てくるenableCorsについて。

CORS(Cross-Origin Resource Sharing)とは別のドメインからリソースを要求できるようにするメカニズムのことで、今回はフロントエンドで Nestjs の API を使うための設定をしていきます。

import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import { ValidationPipe } from '@nestjs/common'
import * as cookieParser from 'cookie-parser'

async function bootstrap() {
  const app = await NestFactory.create(AppModule)
  app.use(cookieParser()) // ここでcookie-parserを登録
  app.enableCors({
    // オリジン間リソース共有
    credentials: true, // セッション情報を維持
    origin: 'http://localhost:3000', // ここにクライアントのオリジンを指定
  })
  await app.listen(3737)
}
bootstrap()

auth

main.tsでアプリケーション全体でcookieを扱えるようになったので、auth ではcookieの設定を進めていきます。

auth.module.ts

imports でPassportModuleで認証方法の選択/JwtModuleで秘密鍵の設定と認証の有効期間を設定します。

import { Module } from '@nestjs/common'
import { AuthController } from './auth.controller'
import { AuthService } from './auth.service'
import { TypeOrmModule } from '@nestjs/typeorm'
import { JwtModule } from '@nestjs/jwt'
import { PassportModule } from '@nestjs/passport'
import { JwtStrategy } from './jwt.strategy'

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }), // jwtで認証を行う
    JwtModule.register({
      secret: 'secretKey', // 秘密鍵を設定
      signOptions: { expiresIn: '1d' }, // 有効期間
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
  exports: [JwtStrategy],
})
export class AuthModule {}

auth.controller.ts

/auth/signinでログイン処理を行う。
例えばemailpasswordなどを受け取って認証処理に必要な値とexpressResponseauth.service.tsに渡してビジネスロジックを構築していきましょう。

import { Body, Controller, Post, Res } from '@nestjs/common'
import { AuthService } from './auth.service'
import { CredentialsDto } from './dto/credentials.dto'
import { Response } from 'express'

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  // ログイン
  @Post('signin')
  async signin(
    @Body() credentialsDto: CredentialsDto, // Bodyから受け取った内容
    @Res() res: Response // レスポンスオブジェクト
  ): Promise<{
    success: boolean // 認証の成功かどうかを返す
  }> {
    return await this.authService.signIn(credentialsDto, res)
  }
}

auth.service.ts

import { Injectable, UnauthorizedException } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { User } from 'src/_entities/user.entity'
import { Repository } from 'typeorm'
import * as bcrypt from 'bcrypt'
import { JwtService } from '@nestjs/jwt'

@Injectable()
export class AuthService {
  constructor(
    private readonly userRepository: Repository<User>,
    private readonly jwtService: JwtService
  ) {}

  // ログイン
  async signIn(credentialsDto, res): Promise<{ success: boolean }> {
    const { username, password } = credentialsDto // Bodyから受け取った内容からusernameとpasswordを取ってくる
    const user = await this.userRepository.findOne({
      // DBの中からusernameが一致するユーザーを取得
      where: { username },
    })
    if (user && (await bcrypt.compare(password, user.password))) {
      const payload = { id: user.id } // ユーザー情報からJWTを生成
      const accessToken = await this.jwtService.sign(payload) // jwtServiceにpayloadを渡してaccessTokenを生成
      res.cookie('jwt', accessToken, {
        httpOnly: true,
        secure: true,
        sameSite: 'strict',
      }) // cookieにkey:'jwt', value:accessTokenを設定
      return { success: true }
    }
    throw new UnauthorizedException(
      'ユーザー名またはパスワードを確認してください'
    )
  }
}

cookieでの設定に関してですが、key,value の設定だけでなくオプションの設定によってセキュアに cookie を管理することができるのです。

  • httpOnly: JavaScript から cookie を読み取ることができなくなり XSS 攻撃からブラウザに設定した jwt トークンを保護することができます
  • secure: 上記コードではtrueとしていますが、暗号化された https でのみcookieをブラウザからサーバーに送信するようにできます
  • sameSite: cookieがどのようなリクエストで送信されるべきかをブラウザに指示することができます("none", "strict", "lax")

jwt.strategy.ts

Nestjs と passport を連携して jwt を用いた認証を行うための戦略を設定します。

import { Injectable, UnauthorizedException } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { InjectRepository } from '@nestjs/typeorm'
import { ExtractJwt, Strategy } from 'passport-jwt'
import { User } from 'src/_entities/user.entity'
import { Repository } from 'typeorm'

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>
  ) {
    super({
      // ※1:ここが要注意ポイント
      // jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      jwtFromRequest: ExtractJwt.fromExtractors([
        // jwtからrequestを抽出する
        (request) => {
          return request?.cookies?.jwt
        },
      ]),
      ignoreExpiration: false, // jwtの有効期限をチェック(trueだと有効期限を無視)
      secretOrKey: 'secretKey',
    })
  }

  async validate(payload: { id: string }) {
    const { id } = payload
    const user = await this.userRepository.findOne({
      where: { id },
    })
    if (user) {
      return user
    }
    throw new UnauthorizedException()
  }
}

※1 ここが一番大変だったところでした。
cookieでの認証の前、フロントエンドでは header でAuthorization認証をしていたので、コメントアウトしてあるjwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),としていましたが、cookieを使って認証を行うようにしたのでjwtFromRequest: ExtractJwt.fromExtractors([ (request) => { return request?.cookies?.jwt }, ]),と書き直さないといけません。

しばらく気が付かずにこんなエラーがでっぱなしでした...

Unhandled Runtime Error
AxiosError: Request failed with status code 401

フロントエンドでの取得方法

取得のやり方はreact-queryなどそれぞれですが今回は中身だけ取り扱います。

冒頭で扱ったAuthorizationでの取得はこんな感じでしたが、

const storeData = async () => {
  const { data } = await axios.get(
    `${process.env.NEXT_PUBLIC_NESTJS_API_URL}/store`,
    {
      headers: {
        Authorization: `Bearer ${jwt}`,
      },
    }
  )
  return data
}

cookieではこうなります。

const storeData = async () => {
  const { data } = await axios.get(
    `${process.env.NEXT_PUBLIC_NESTJS_API_URL}/store`,
    {
      withCredentials: true,
    }
  )
  return data
}

withCredentials: trueと設定してリクエストを行うことで cross-originのリクエストにもcookieを含めることができるようになるのです。

参考

さいごに

フロントエンドにしか触れてこなくてバックエンド初心者だとセキュリティ周りのことなんてまるでわからないので、認証周りの処理を学ぶとかなり理解が深まるので一緒に頑張りましょう。

Nestjs 初心者ながら楽しくコードを書けているので、今後も Nestjs の情報を共有していきますのでお楽しみに。