12) 개인 프로젝트) NestJS Auth Refresh Token

Leo·2021년 2월 25일
9

Project-01

목록 보기
12/12
post-thumbnail

Refresh Token

지금까지 작성한 내용은 Token의 만료 시간을 매우 짧게 주어 짧은 만료 시간안에 처리를 해야했습니다. 짧은 만료시간은 토큰이 탈취되더라도 만료 시간이 매우 짧기 때문에 공격자의 해킹에 대하여 보호 받을 수 있습니다.
하지만 이러한 짧은 만료 시간으로 인해 계속하여 Token을 발급 받아야 합니다. 이러한 방법은 email과 password를 다시 넘겨줘야하기 때문에 사용자 측면에서 좋지 않은 경험을 제공합니다. 그렇기 때문에 토큰의 만료시간을 늘려 사용자의 이용에 불편이 없도록 해야합니다. 해당 토큰의 만료 시간을 늘리는 역할을 Refresh Token이 하게 됩니다.

Refresh Token에는 일주일의 만료 시간을 부여하도록 작성해보도록하겠습니다. Refresh Token토큰이 만료되지 않은 경우 해당 Refresh Token을 이용하여 Access Token을 발급 받을 수 있습니다. Refresh Token이 만료된 경우에는 다시 로그인을 해야합니다.

Refresh Token Secret, Expiration Time 환경변수에 저장

Refresh Token을 생성하기 위해서는 먼저 Secret Key가 있어야하기 때문에 환경변수에 추가해줍니다.
환경변수를 추가하며 기존의 JWT_SECRET 항목을 Access Token이라는 이름으로 수정해주도록 하겠습니다.

/.env

JWT_ACCESS_TOKEN_SECRET=AccessTokenSecretTest
JWT_ACCESS_TOKEN_EXPIRATION_TIME=10
JWT_REFRESH_TOKEN_SECRET=RefreshTokenSecretTest
JWT_REFRESH_TOKEN_EXPIRATION_TIME=604800

Refresh Token의 만료시간은 일주일, Access Token의 만료시간은 15분으로 설정합니다.

환경변수 Type 검증을 위하여 App Module 수정

이전 장에서 했던 환경변수 검증을 똑같이 진행해줍니다. 기존의 JWT_SECRET, JWT_EXPIRATION_TIME 부분은 지워야합니다.

/src/app.module.ts

...생략

@Module({
  imports: [
    ConfigModule.forRoot({
      validationSchema: Joi.object({
        DATABASE_HOST: Joi.string().required(),
        DATABASE_PORT: Joi.number().required(),
        DATABASE_USER: Joi.string().required(),
        DATABASE_PASSWORD: Joi.string().required(),
        DATABASE_NAME: Joi.string().required(),

        JWT_ACCESS_TOKEN_SECRET: Joi.string().required(),
        JWT_ACCESS_TOKEN_EXPIRATION_TIME: Joi.string().required(),
        JWT_REFRESH_TOKEN_SECRET: Joi.string().required(),
        JWT_REFRESH_TOKEN_EXPIRATION_TIME: Joi.string().required(),
      }),
    }),
    UsersModule,
    AuthModule,
    DatabaseModule,
  ],
  controllers: [AppController],
  providers: [AppService, { provide: APP_GUARD, useClass: JwtAuthGuard }],
})
export class AppModule {}

Refresh Token을 사용하기 위하여 User Entity에 컬럼 추가

Refresh Token을 사용하기 위하여 DB에 Token 정보를 기입하여야합니다.
User Entity에 Refresh Token 정보를 추가해 보도록합시다.
먼저 직렬화를 하여 응답하기 전에 발생하는 프로세스를 만들어보겠습니다. 해당 설정을 통해 Refresh Token과 같은 민감하 데이터를 응답에서 제외시킬 수 있습니다.

$ npm i class-transformer

/src/users/user.entity.ts

import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { Exclude } from 'class-transformer';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ unique: true })
  email: string;

  @Column({ unique: true })
  username: string;

  @Column()
  password: string;

  @Column({ nullable: true })
  @Exclude()
  currentHashedRefreshToken?: string;
}

Refresh Token은 로그아웃시 Null 이 되기 때문에 Null값을 허용해야합니다.

Auth Service에서 쿠키 생성에 Refresh Token 추가

Refresh Token 또한 토큰을 생성 후 쿠키로 반환해야합니다.

이 과정에서 전에 사용하던 Login과 Logout은 삭제합니다. 이름이 해당 기능에 너무 한정적이기 때문에 새로운 이름으로 메소드를 만들어주려고 합니다.

/src/auth/auth.service.ts

import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { User } from 'src/users/user.entity';
import { UsersService } from '../users/users.service';
import { compare, hash } from 'bcrypt';
import { ConfigService } from '@nestjs/config';

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

  async vaildateUser(email: string, plainTextPassword: string): Promise<any> {
    try {
      const user = await this.usersService.getByEmail(email);
      await this.verifyPassword(plainTextPassword, user.password);
      const { password, ...result } = user;
      return result;
    } catch (error) {
      throw new HttpException(
        'Wrong credentials provided',
        HttpStatus.BAD_REQUEST,
      );
    }
  }

  private async verifyPassword(
    plainTextPassword: string,
    hashedPassword: string,
  ) {
    const isPasswordMatch = await compare(plainTextPassword, hashedPassword);
    if (!isPasswordMatch) {
      throw new HttpException(
        'Wrong credentials provided',
        HttpStatus.BAD_REQUEST,
      );
    }
  }

  async register(user: User) {
    const hashedPassword = await hash(user.password, 10);
    try {
      const { password, ...returnUser } = await this.usersService.create({
        ...user,
        password: hashedPassword,
      });

      return returnUser;
    } catch (error) {
      if (error?.code === 'ER_DUP_ENTRY') {
        throw new HttpException(
          'User with that email already exists',
          HttpStatus.BAD_REQUEST,
        );
      }
    }
  }

  getCookieWithJwtAccessToken(id: number) {
    const payload = { id };
    const token = this.jwtService.sign(payload, {
      secret: this.configService.get('JWT_ACCESS_TOKEN_SECRET'),
      expiresIn: `${this.configService.get(
        'JWT_ACCESS_TOKEN_EXPIRATION_TIME',
      )}s`,
    });

    return {
      accessToken: token,
      domain: 'localhost',
      path: '/',
      httpOnly: true,
      maxAge:
        Number(this.configService.get('JWT_ACCESS_TOKEN_EXPIRATION_TIME')) *
        1000,
    };
  }

  getCookieWithJwtRefreshToken(id: number) {
    const payload = { id };
    const token = this.jwtService.sign(payload, {
      secret: this.configService.get('JWT_REFRESH_TOKEN_SECRET'),
      expiresIn: `${this.configService.get(
        'JWT_REFRESH_TOKEN_EXPIRATION_TIME',
      )}s`,
    });

    return {
      refreshToken: token,
      domain: 'localhost',
      path: '/',
      httpOnly: true,
      maxAge:
        Number(this.configService.get('JWT_REFRESH_TOKEN_EXPIRATION_TIME')) *
        1000,
    };
  }

  getCookiesForLogOut() {
    return {
      accessOption: {
        domain: 'localhost',
        path: '/',
        httpOnly: true,
        maxAge: 0,
      },
      refreshOption: {
        domain: 'localhost',
        path: '/',
        httpOnly: true,
        maxAge: 0,
      },
    };
  }
}

getCookieWithJwtAccessToken 메소드는 Access Token을 발급 받습니다. configService를 사용하여 환경 변수에 있는 키와 만료시간을 jwtService에 넣어주면 Token을 발급받을 수 있습니다.
생성된 토큰을 Cookie정보와 함께 반환해줍니다.
쿠키 정보에는 설정한 만료시간을 넣어줍니다.

getCookieWithJwtRefreshToken 메소드는 Refresh Token을 발급 받습니다. Access Token과 같은 방법으로 생성할 수 있습니다. 단지 환경변수의 값을 Refresh에 해당하는 것을 넣어줬습니다.

getCookiesForLogOut 메소드는 로그아웃시 사용됩니다. 로그아웃 요청이 오면 현재 쿠키에 빈 쿠키를 기입하기 위한 값들을 반환합니다. 추후 저장될 쿠키는 Authentication, Refresh 두가지 이므로 두가지에 해당되는 쿠키 옵션들을 반환해줍니다.

User Service에 Refresh토큰을 관리하는 기능 추가

DB에 Refresh Token을 저장하기 때문에 해당 토큰을 가져오고 갱신하고 삭제도 해야합니다.

/src/users/users.service.ts

import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
import { compare, hash } from 'bcrypt';

@Injectable()
export class UsersService {
  ...생략
  async setCurrentRefreshToken(refreshToken: string, id: number) {
    const currentHashedRefreshToken = await hash(refreshToken, 10);
    await this.usersRepository.update(id, { currentHashedRefreshToken });
  }

  async getUserIfRefreshTokenMatches(refreshToken: string, id: number) {
    const user = await this.getById(id);

    const isRefreshTokenMatching = await compare(
      refreshToken,
      user.currentHashedRefreshToken,
    );

    if (isRefreshTokenMatching) {
      return user;
    }
  }

  async removeRefreshToken(id: number) {
    return this.usersRepository.update(id, {
      currentHashedRefreshToken: null,
    });
  }
}

setCurrentRefreshToken은 DB에 발급받은 Refresh Token을 암호화 하여 저장합니다.

getUserIfRefreshTokenMatches은 유저의 고유 번호를 이용하여 데이터를 조회하고 Refresh Token이 유효한지 확인합니다. 이 과정에서 DB에 저장된 토큰은 암호화가 되어있기 때문에 bcrypt의 compare 메소드를 이용하여 비교하고 일치한다면 해당 유저 정보를 반환합니다.

removeRefreshToken은 Refresh Token의 값을 Null만듭니다. 유저가 로그아웃 할 때 사용합니다.

Refresh Guard 생성

Refresh Token이 유효한지 확인을 해야하기 때문에 Access Token과 같이 가드를 생성하여 UseGuard 데코레이터로 관리합니다.

일단 가드를 먼저 생성해야합니다.

/src/auth/guards/jwt-refresh.guard.ts

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtRefreshGuard extends AuthGuard('jwt-refresh-token') {}

생성된 가드의 이름은 jwt-refresh-token

가드를 생성 하였으므로 해당 가드에 대한 전략을 작성해야합니다.

/src/auth/strategies/jwt-refresh.strategy.ts

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UsersService } from '../../users/users.service';

@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(
  Strategy,
  'jwt-refresh-token',
) {
  constructor(
    private readonly configService: ConfigService,
    private readonly usersService: UsersService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([
        (request) => {
          return request?.cookies?.Refresh;
        },
      ]),
      secretOrKey: configService.get('JWT_REFRESH_TOKEN_SECRET'),
      passReqToCallback: true,
    });
  }

  async validate(req, payload: any) {
    const refreshToken = req.cookies?.Refresh;
    return this.usersService.getUserIfRefreshTokenMatches(
      refreshToken,
      payload.id,
    );
  }
}

쿠키에 있는 JWT 값을 확인합니다. validate 메소드에서는 해당 Refresh Token이 유효한지 확인하고 유효한 경우 유저 정보를 반환합니다.

Auth Module의 Provider에 JwtRefreshStrategy 추가

해당 전략을 사용하기 위해서는 Auth Module에 제공자로서
기입을 해줘야합니다.

/src/auth/auth.module.ts

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { UsersModule } from 'src/users/users.module';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
import { AuthController } from './auth.controller';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy';

@Module({
  imports: [
    UsersModule,
    PassportModule,
    ConfigModule,
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get('JWT_ACCESS_TOKEN_SECRET'),
        signOptions: {
          expiresIn: `${configService.get(
            'JWT_ACCESS_TOKEN_EXPIRATION_TIME',
          )}s`,
        },
      }),
    }),
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy, JwtRefreshStrategy],
  exports: [AuthService, JwtModule],
  controllers: [AuthController],
})
export class AuthModule {}

제공자로서 추가해주면서 기존에 있던 Key와 만료 시간의 코드 또한 변경하였습니다.

나머지 전략에 이름 부여

Refresh 전략에만 이름을 부여했었지만 나머지 코드에도 이름을 각각 넣어주도록 하겠습니다. 추가로 jwt.strategy.ts의 SecretKey 영역또한 수정해줘야합니다.

/src/auth/strategies/jwt.strategy.ts

...생략
export class JwtStrategy extends import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UsersService } from '../../users/users.service';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(
    private readonly configService: ConfigService,
    private readonly usersService: UsersService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([
        (request) => {
          return request?.cookies?.Authentication;
        },
      ]),
      secretOrKey: configService.get('JWT_ACCESS_TOKEN_SECRET'),
    });
  }

  async validate(payload: any) {
    return this.usersService.getById(payload.id);
  }
}

...생략

/src/auth/strategies/local.strategy.ts

...생략
export class LocalStrategy extends PassportStrategy(Strategy, 'local')
...생략

Auth Controller에 Login, Logout 수정 및 Refresh 추가

앞서 Auth Service를 수정하였습니다. 해당 내용이 들어가 있는 Login과 Logout 부분을 수정해야하며 추가한 기능인 Refresh 경로를 추가해줍니다.

/src/auth/auth.controller.ts

constructor(
    private authService: AuthService,
    private readonly usersService: UsersService,
  ) {}

UserService의 추가한 내용을 사용하기 위하여 생성자 부분에 추가해줍니다. 이러면 의존성 주입을 합니다.

Public()
  @UseGuards(LocalAuthGuard)
  @Post('login')
  async login(@Req() req, @Res({ passthrough: true }) res: Response) {
    const user = req.user;
    const {
      accessToken,
      ...accessOption
    } = this.authService.getCookieWithJwtAccessToken(user.id);

    const {
      refreshToken,
      ...refreshOption
    } = this.authService.getCookieWithJwtRefreshToken(user.id);

    await this.usersService.setCurrentRefreshToken(refreshToken, user.id);

    res.cookie('Authentication', accessToken, accessOption);
    res.cookie('Refresh', refreshToken, refreshOption);

    return user;
  }

login은 LocalGuard에 의해 사용자의 요청한 Email과 password가 존재하며 알맞는지 확인 후 들어옵니다. 해당 유저에 해당되는 아이디 값을 통해 AccessToken을 새로 발급합니다.

같은 방식으로 Refresh Token을 발급 받습니다. 발급 받은 Refresh Token은 usersService의 setCurrentRefreshToken 메소드를 이용하여 DB에 기록합니다.

발급된 각각의 토큰과 옵션을 쿠키에 저장합니다.

 @Public()
 @UseGuards(JwtRefreshGuard)
 @Post('logout')
  async logOut(@Req() req, @Res({ passthrough: true }) res: Response) {
    const {
      accessOption,
      refreshOption,
    } = this.authService.getCookiesForLogOut();

    await this.usersService.removeRefreshToken(req.user.id);

    res.cookie('Authentication', '', accessOption);
    res.cookie('Refresh', '', refreshOption);
  }

public 데코레이터의 의하여 Access Token 검증을 스킵한 후 JwtRefreshGuard 에 의해 현재 쿠키에 있는 Refresh Token이 유효한지 확인 한 후 유효 하다면 User 정보를 가져옵니다.

getCookiesForLogOut 메소드를 통해 초기화된 쿠키의 옵션을 가져옵니다. 로그아웃을 하기 때문에 DB에 해당 유저의 Refresh Token을 초기화 합니다. 마지막으로 초기화된 쿠키옵션을 적용하여 쿠키에 저장합니다.

  @Public()
  @UseGuards(JwtRefreshGuard)
  @Get('refresh')
  refresh(@Req() req, @Res({ passthrough: true }) res: Response) {
    const user = req.user;
    const {
      accessToken,
      ...accessOption
    } = this.authService.getCookieWithJwtAccessToken(user.id);
    res.cookie('Authentication', accessToken, accessOption);
    return user;
  }

JwtRefreshGuard에 의해 쿠키의 Refresh Token이 유저의 DB Table의 Refresh Token과 일치하는지 확인 후 들어옵니다.
Access Token을 발급 받습니다. 발급 받은 토큰을 쿠키에 저장합니다.

Request & Response

먼저 DB에 User 테이블을 삭제합니다.

POST http://localhost:3000/auth/register

Request

{
    "email": "test@test.com",
    "username": "test",
    "password": "qwer1234@"
}

Response

{
    "email": "test@test.com",
    "username": "test",
    "currentHashedRefreshToken": null,
    "id": 1
}

POST http://localhost:3000/auth/login

Request

{
    "email": "test@test.com",
    "password": "qwer1234@"
}

Response

{
    "id": 1,
    "email": "test@test.com",
    "username": "test",
    "currentHashedRefreshToken": null
}

Access Token의 만료시간이 10초이기 때문에 10초 이전에 요청을 보내봅니다.

GET http://localhost:3000/users/1

Response

{
    "id": 1,
    "email": "test@test.com",
    "username": "test",
    "password": "$2b$10$2sYB3XJQ8RVZnhOgghAYNOQ6dx6iNjOo1aLbdJr5yxnc3jPdIgmX2",
    "currentHashedRefreshToken": "$2b$10$6rlXfa86KNPBvqv06MK66.haXfDnET5a4ivfOAT25fkmSRRb4RnDS"
}

10초 이후 만료시간이 지나서 요청을 보내봅니다.

Response

{
    "statusCode": 401,
    "message": "Unauthorized"
}

에러 메시지가 응답됩니다. 이제 앞서 만든 Refresh Token을 이용하여 Access Token 재발급 받을 수 있습니다.

GET http://localhost:3000/auth/refresh

Response

{
    "id": 1,
    "email": "test@test.com",
    "username": "test",
    "password": "$2b$10$2sYB3XJQ8RVZnhOgghAYNOQ6dx6iNjOo1aLbdJr5yxnc3jPdIgmX2",
    "currentHashedRefreshToken": "$2b$10$6rlXfa86KNPBvqv06MK66.haXfDnET5a4ivfOAT25fkmSRRb4RnDS"
}

다시 갱신이 되었으므로 요청을 다시보내봅니다.

Access Token의 만료시간이 10초이기 때문에 10초 이전에 요청을 보내봅니다.

GET http://localhost:3000/users/1

Response

{
    "id": 1,
    "email": "test@test.com",
    "username": "test",
    "password": "$2b$10$2sYB3XJQ8RVZnhOgghAYNOQ6dx6iNjOo1aLbdJr5yxnc3jPdIgmX2",
    "currentHashedRefreshToken": "$2b$10$6rlXfa86KNPBvqv06MK66.haXfDnET5a4ivfOAT25fkmSRRb4RnDS"
}

마지막으로 LogOut을 통해 쿠키를 삭제해보도록 하겠습니다.

POST http://localhost:3000/auth/logout

Refresh Token이 삭제가 되었는지 확인을 해보겠습니다.

GET http://localhost:3000/users

Response

[
    {
        "id": 1,
        "email": "test@test.com",
        "username": "test",
        "password": "$2b$10$2sYB3XJQ8RVZnhOgghAYNOQ6dx6iNjOo1aLbdJr5yxnc3jPdIgmX2",
        "currentHashedRefreshToken": null
    }
]

현재 테스트한 내용의 Refresh Token은 로그인을 할때마다 새로 갱신되며 매번 다른 값을 가집니다.
Refresh Token은 탈취 당했을 때 매우 치명적이므로 현재 코드 처럼 Response 내용에 존재하면 안됩니다.

profile
개발자

3개의 댓글

comment-user-thumbnail
2022년 3월 22일

안녕하세요. 혹시 깃허브에서 이 자료를 볼 수 있을까요?

답글 달기
comment-user-thumbnail
2022년 5월 18일

대부분 다른 블로그 번역인데, 출처를 남기는게...

답글 달기
comment-user-thumbnail
2023년 2월 24일

안녕하세용 ㅠㅠ nestjs 공부하는 프론트 개발자인데영 jwt 적용안할때는 잘되던게 글을 보고 적용시키니깐.. 안되는 부분이있어서 질문드립니당.. 혹시 시크릿키를 따로 빼놓던데 그시크릿키는 개발자가 임의에 값을 넣으면 되는 부분일까요..??

답글 달기