🌱

NestJS + GraphQL のJWT認証でリフレッシュトークンとログアウトをつくる

2022/01/24に公開

前回の続きで、JWT認証にリフレッシュトークンによるアクセストークンの更新とログアウトをつくってみます。

前回

https://zenn.dev/mseto/articles/nest-graphql-prisma

処理の流れ

メールアドレスとパスワードでの認証

  1. アクセストークンに加えてリフレッシュトークンを生成
  2. ハッシュ化したリフレッシュトークンをUserに保存
  3. アクセストークンとリフレッシュトークンを返却

アクセストークンの有効期限が切れる等のタイミングでリフレッシュトークンによる新規アクセストークンを要求

  1. リフレッシュトークンによる認証
  2. リフレッシュトークンとUserに保存されているハッシュ化されたリフレッシュトークンが正しいか確認
  3. 正しければ新規にアクセストークンとリフレッシュトークンを生成
  4. ハッシュ化したリフレッシュトークンをUserに保存
  5. アクセストークンとリフレッシュトークンを返却

ログアウト処理

  1. リフレッシュトークンによる認証
  2. Userに保存されているハッシュ化されたリフレッシュトークンを削除

UserにhashedRefreshTokenを追加

Userにハッシュ化したリフレッシュトークンを保存するフィールドを追加します。

api/prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

generator nestgraphql {
  provider = "node node_modules/prisma-nestjs-graphql"
  output = "../src/@generated/prisma-nestjs-graphql"
  fields_Validator_from = "class-validator"
  fields_Validator_input = true
  decorate_1_type        = "CreateOne*Args"
  decorate_1_field       = data
  decorate_1_name        = ValidateNested
  decorate_1_from        = "class-validator"
  decorate_1_arguments   = "[]"
  decorate_2_type        = "CreateOne*Args"
  decorate_2_field       = data
  decorate_2_from        = "class-transformer"
  decorate_2_arguments   = "['() => {propertyType.0}']"
  decorate_2_name        = Type
}

model User {
  /// @Field(() => ID)
  id          Int       @id @default(autoincrement())
  /// @Validator.IsEmail()
  email       String    @unique
  /// @Validator.IsNotEmpty()
  name        String
  /// @HideField()
  /// @Validator.MinLength(8)
  password    String
+ /// @HideField({ input: true, output: true })
+ hashedRefreshToken String?
  /// @HideField({ input: true, output: true })
  createdAt DateTime @default(now())
  /// @HideField({ input: true, output: true })
  updatedAt DateTime @updatedAt
}

下記コマンドで変更を反映します。

$ npx prisma generate 
$ npx prisma migrate dev --name add_hashedRefreshToken

JWTのシークレットキーを設定

リフレッシュトークンの設定を追加します。

api/.env
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#using-environment-variables

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server and MongoDB (Preview).
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

DATABASE_URL="mysql://root:dev-lodge@mysql:3306/lodge"
JWT_SECRET=生成したシークレットキー
+ JWT_REFRESH_SECRET=

JWT_REFRESH_SECRETには下記コマンドでシークレットキーを生成し、設定します。
(JWT_SECRETとJWT_REFRESH_SECRETは別のシークレットキーにします。)

node -e "console.log(require('crypto').randomBytes(256).toString('base64'));"

UsersServiceを編集

下記のようにUserの更新を追加します。

api/src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma.service';
import { User } from 'src/@generated/prisma-nestjs-graphql/user/user.model'
import { FindFirstUserArgs } from 'src/@generated/prisma-nestjs-graphql/user/find-first-user.args';
import { CreateOneUserArgs } from 'src/@generated/prisma-nestjs-graphql/user/create-one-user.args';
import { FindUniqueUserArgs } from 'src/@generated/prisma-nestjs-graphql/user/find-unique-user.args';
+ import { UpdateOneUserArgs } from 'src/@generated/prisma-nestjs-graphql/user/update-one-user.args';

@Injectable()
export class UsersService {
    constructor(private readonly prisma: PrismaService) {}

    async findFirst(args: FindFirstUserArgs): Promise<User | null> {
        return this.prisma.user.findFirst(args);
    }

    async findUnique(args: FindUniqueUserArgs): Promise<User | null> {
        return this.prisma.user.findUnique(args);
    }

    async createUser(args: CreateOneUserArgs): Promise<User> {
        return this.prisma.user.create(args);
    }
+ 
+     async update(args: UpdateOneUserArgs): Promise<User> {
+         return this.prisma.user.update(args)
+     }
}

ログイン時のレスポンスを変更

下記のようにログイン時のレスポンスを変更します。

api/src/auth/dto/login-response.ts
import { Field, ObjectType } from "@nestjs/graphql";
import { User } from "src/@generated/prisma-nestjs-graphql/user/user.model";

@ObjectType()
export class LoginResponse {
  @Field()
  access_token: string;
+ 
+   @Field()
+   refresh_token: string;

  @Field(() => User)
  user: User;
}

Tokensタイプの作成

api/src/auth/typesディレクトリを作成し、下記のようにtokens.type.tsを作成しました。

api/src/auth/types/tokens.type.ts
export type Tokens = {
  access_token: string;
  refresh_token: string;
};

JwtPayloadタイプの作成

前回は作成しませんでしたがついでにJwtPayloadのタイプも作成します。

api/src/auth/types/jwt-payload.type.ts
export type JwtPayload = {
  email: string;
  sub: number;
};

AuthServiceを編集

下記のようにAuthServiceのloginのレスポンスにrefresh_tokenを追加し、refreshToken、logout、updateHashedRefreshToken、getTokensを追加します。

api/src/auth/auth.service.ts
- import { Injectable } from '@nestjs/common';
+ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from 'src/users/users.service';
import { User } from 'src/@generated/prisma-nestjs-graphql/user/user.model';
import * as bcrypt from 'bcrypt';
import { LoginResponse } from 'src/auth/dto/login-response';
+ import { Tokens } from 'src/auth/types/tokens.type';
+ import { JwtPayload } from 'src/auth/types/jwt-payload.type';

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

  async validateUser(email: string, password: string): Promise<User | null> {
    const user = await this.usersService.findUnique({
      where: { email: email },
    });

    if (user && bcrypt.compareSync(password, user.password)) {
      return user;
    }

    return null;
  }

  async login(user: User): Promise<LoginResponse> {
-     const payload = { email: user.email, sub: user.id };
+     const tokens = await this.getTokens(user);
+     await this.updateHashedRefreshToken(user, tokens.refresh_token);

    return {
-       access_token: this.jwtService.sign(payload),
-       user: user
+       ...tokens,
+       user: user,
    };
  }
+ 
+   async refreshToken(
+     user: User,
+     authorization: string,
+   ): Promise<LoginResponse> {
+     const refreshToken = authorization.replace('Bearer', '').trim();
+ 
+     if (!bcrypt.compareSync(refreshToken, user.hashedRefreshToken)) {
+       throw new UnauthorizedException();
+     }
+ 
+     const tokens = await this.getTokens(user);
+     await this.updateHashedRefreshToken(user, tokens.refresh_token);
+ 
+     return {
+       ...tokens,
+       user: user,
+     };
+   }
+ 
+   async logout(user: User): Promise<boolean> {
+     await this.usersService.update({
+       where: { id: user.id },
+       data: { hashedRefreshToken: { set: null } },
+     });
+ 
+     return true;
+   }
+ 
+   async updateHashedRefreshToken(
+     user: User,
+     refreshToken: string,
+   ): Promise<void> {
+     const hashedRefreshToken = bcrypt.hashSync(refreshToken, 10);
+     await this.usersService.update({
+       where: { id: user.id },
+       data: { hashedRefreshToken: { set: hashedRefreshToken } },
+     });
+   }
+ 
+   async getTokens(user: User): Promise<Tokens> {
+     const payload: JwtPayload = { email: user.email, sub: user.id };
+ 
+     const [accessToken, refreshToken] = await Promise.all([
+       this.jwtService.signAsync(payload, {
+         secret: process.env.JWT_SECRET,
+         expiresIn: '15m',
+       }),
+       this.jwtService.signAsync(payload, {
+         secret: process.env.JWT_REFRESH_SECRET,
+         expiresIn: '7d',
+       }),
+     ]);
+ 
+     return {
+       access_token: accessToken,
+       refresh_token: refreshToken,
+     };
+   }
}

loginの戻り値にリフレッシュトークンを追加しています。また、ハッシュ化したリフレッシュトークンをUserに保存しています。

refreshTokenでは送られてきたリフレッシュトークンがDBに保存したハッシュ値と比較して、正しければ新規にアクセストークンとリフレッシュトークンを生成しています。
また、新しく生成したリフレッシュトークンをUserに保存しています。

logoutではUserに保存されたリフレッシュトークンのハッシュ値を空にしています。

updateHashedRefreshTokenはリフレッシュトークンのハッシュ値をUserに保存する処理です。

getTokensはUserの情報からアクセストークンとリフレッシュトークンを生成する処理です。
今回アクセストークンの有効期限は15分、リフレッシュトークンの有効期限は7日にしています。

JwtStrategyを編集

下記のようにJwtPayloadタイプを作成したのでJwtStrategyに適用します。

api/src/auth/strategies/jwt.strategy.ts
import { Injectable } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from "passport-jwt";
import { User } from 'src/@generated/prisma-nestjs-graphql/user/user.model';
+ import { JwtPayload } from 'src/auth/types/jwt-payload.type';
import { UsersService } from 'src/users/users.service';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
    constructor(private readonly usersService: UsersService) {
        super({
            jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
            ignoreExpiration: false,
            secretOrKey: process.env.JWT_SECRET,
        })
    }

-     async validate(payload: { email: string, sub: string }): Promise<User | null> {
+     async validate(payload: JwtPayload): Promise<User | null> {
        return this.usersService.findUnique({where: {email: payload.email}});
    }
}

JwtRefreshStrategyの作成

下記のようにリフレッシュトークンで認証するためのJwtRefreshStrategyを作成します。

api/src/auth/strategies/jwt-refresh.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from "passport-jwt";
import { User } from 'src/@generated/prisma-nestjs-graphql/user/user.model';
import { JwtPayload } from 'src/auth/types/jwt-payload.type';
import { UsersService } from 'src/users/users.service';

@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
  constructor(private readonly usersService: UsersService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.JWT_REFRESH_SECRET,
    });
  }

  async validate(payload: JwtPayload): Promise<User | null> {
    const user = this.usersService.findUnique({
      where: { email: payload.email },
    });

    if (!user) {
      throw new UnauthorizedException();
    }

    return user;
  }
}

JwtRefreshAuthGuardの作成

下記のようにリフレッシュトークン用のJwtRefreshAuthGuardを作成します。

api/src/auth/guards/jwt-refresh-auth.guard.ts
import { Injectable, ExecutionContext } from "@nestjs/common";
import { GqlExecutionContext } from "@nestjs/graphql";
import { AuthGuard } from "@nestjs/passport";

@Injectable()
export class JwtRefreshAuthGuard extends AuthGuard('jwt-refresh') {
    getRequest(context: ExecutionContext) {
        const ctx = GqlExecutionContext.create(context);
        return ctx.getContext().req;
    }
}

AuthModuleを編集

アクセストークンとリフレッシュトークンの設定をそれぞれ別のものにするので設定を削除し、providersにJwtRefreshStrategyを追加します。

api/src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from 'src/users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from "@nestjs/jwt";
import { AuthResolver } from './auth.resolver';
import { LocalStrategy } from 'src/auth/strategies/local.strategy';
import { JwtStrategy } from 'src/auth/strategies/jwt.strategy';
+ import { JwtRefreshStrategy } from 'src/auth/strategies/jwt-refresh.strategy';

@Module({
  imports: [
    UsersModule,
    PassportModule.register({ defaultStrategy: 'jwt' }),
-     JwtModule.register({
-       secret: process.env.JWT_SECRET,
-       signOptions: { expiresIn: '1h' }
-     })
+     JwtModule.register({})
  ],
-   providers: [AuthService, AuthResolver, LocalStrategy, JwtStrategy]
+   providers: [AuthService, AuthResolver, LocalStrategy, JwtStrategy, JwtRefreshStrategy]
})
export class AuthModule {}

AuthResolverの編集

下記のようにrefreshTokenとlogoutを追加します。

api/src/auth/auth.resolver.ts
import { UseGuards } from '@nestjs/common';
import { Resolver,  Mutation, Args, Context } from '@nestjs/graphql';
import { AuthService } from 'src/auth/auth.service';
import { LoginResponse } from 'src/auth/dto/login-response';
import { LoginUserInput } from 'src/auth/dto/login-user.input';
import { GqlAuthGuard } from 'src/auth/guards/gql-auth.guard';
import { JwtRefreshAuthGuard } from 'src/auth/guards/jwt-refresh-auth.guard';

@Resolver()
export class AuthResolver {
  constructor(private readonly authService: AuthService) {}

  @Mutation(() => LoginResponse)
  @UseGuards(GqlAuthGuard)
  async login(
    @Args('loginUserInput') loginUserInput: LoginUserInput,
    @Context() context
  ) {
    return this.authService.login(context.user);
  }

+   @Mutation(() => LoginResponse)
+   @UseGuards(JwtRefreshAuthGuard)
+   async refreshToken(@Context() context) {
+     return this.authService.refreshToken(
+       context.req.user,
+       context.req.headers.authorization,
+     );
+   }
+ 
+   @Mutation(() => Boolean)
+   @UseGuards(JwtRefreshAuthGuard)
+   async logout(@Context() context) {
+     return this.authService.logout(
+       context.req.user
+     );
+   }
}

refreshTokenにはヘッダー情報のauthorizationを渡しています。
refreshTokenとlogoutはともにリフレッシュトークンで認証しています。

動作確認

下記コマンドでNestJSのサーバーを起動させます。

$ npm run start

サーバーが起動後http://localhost:3000/graphqlにアクセスするとGraphQL playgroundが表示されます。
下記のようにrefresh_tokenを追加して前回登録したUserでログインします。

mutation {
  login(
    loginUserInput: {
      email: "test@example.com", 
      password: "password12345"
    }
  ) {
    user {
      name,
    	email
    },
    access_token,
    refresh_token
  }
}

下記のようにrefresh_tokenトークンが返ってることを確認します。

{
  "data": {
    "login": {
      "user": {
        "name": "test",
        "email": "test@example.com"
      },
      "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJzdWIiOjEsImlhdCI6MTY0MzAxNjY4MCwiZXhwIjoxNjQzMDE3NTgwfQ.kCLRhnHRTcBG1uoenB-flImbRcjm4fgB-4QFjV7T0cY",
      "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJzdWIiOjEsImlhdCI6MTY0MzAxNjY4MCwiZXhwIjoxNjQzNjIxNDgwfQ.Hxvzpw2mutbQ3sJcHsTYPFijHldvtzGBpuSDMhwirMg"
    }
  }
}

下記コマンドでPrisma Studioを起動します。

$ npx prisma studio

Prisma Studio起動後、http://localhost:5556/にアクセスし、ログインしたUserのhashedRefreshTokenに文字列が入っていることを確認します。

次にリフレッシュトークンによるアクセストークンの更新を確認します。
下記を入力します。

mutation {
  refreshToken {
    user {
      name,
    	email
    },
    access_token,
    refresh_token
  }
}

GraphQL playgroundの左下にあるHTTP HEADERSをクリックして下記のように入力して実行します。

{
  "Authorization": "Bearer loginで取得したrefresh_token"
}

下記のように表示されることを確認します。

{
  "data": {
    "refreshToken": {
      "user": {
        "name": "test",
        "email": "test@example.com"
      },
      "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJzdWIiOjEsImlhdCI6MTY0MzAyMTY3NiwiZXhwIjoxNjQzMDIyNTc2fQ.f6QzKxTKQfzSZYZG1sVMUh9SPEUg7290oAAyfhszQjU",
      "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJzdWIiOjEsImlhdCI6MTY0MzAyMTY3NiwiZXhwIjoxNjQzNjI2NDc2fQ.IKLpi2qY_A6cTL2tD6rB1mxiMkfZITIoiaQ5I_ZiRjM"
    }
  }
}

Prisma StudioでUserの表示を更新し、hashedRefreshTokenに入っている文字列が変わったことを確認します。

最後にログアウトを確認します。
下記のように入力します。

mutation {
  logout
}

HTTP HEADERSに下記のように入力して実行します。

{
  "Authorization": "Bearer refreshTokenで取得したrefresh_token"
}

下記のように表示されるのを確認します。

{
  "data": {
    "logout": true
  }
}

Prisma StudioでUserの表示を更新し、hashedRefreshTokenが空になっていることを確認します。

まとめ

JWTトークンには有効期限しかなく、ログアウト自体がありませんが、ハッシュ化したトークンを使用することでトークンを無効化することができました。
今回、アクセストークンの有効期限を15分と短く設定しているのでリフレッシュトークンのみハッシュ化していますが、アクセストークンもリアルタイムでログアウトさせたい場合は同様の処理を追加することでできると思います。

また、複数端末に同時ログインを許可する場合はリフレッシュトークンのハッシュ値を保存するテーブルをわけて管理する等でできるかと思います。

Discussion