블로그 이미지
윤영식
Full Stacker, Application Architecter, KnowHow Dispenser and Bike Rider

Publication

Category

Recent Post

2021. 9. 30. 13:22 React/Architecture

Login Auth Token이 만료되었을 때 Refresh Token을 통하여 다시 Auth Token을 생성토록한다. 

  • Refresh Token을 서버에 저장한다. 다른 기기에서 로그인하면 기존 로그인 기기의 Refresh Token과 비교하여 틀리므로 여러 기기의 로그인을 방지한다. 
  • 서버에 여러개의 Refresh Token을 저장할 수 있다면 기기 제한을 할 수 있겠다. 또한 변조된 refresh token의 사용을 막을 수 있다.

 

User Entity 업데이트

libs/domain/src/lib/entities/user/user.entity.ts 에 refreshToken 컬럼을 추가한다. 

import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm';

@Entity('user_iot')
export class UserEntity {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({ unique: true, length: 50 })
  username!: string;

  @Column()
  password!: string;

  @Column({ length: 255 })
  email!: string;

  @Column({ name: 'first_name', length: 100 })
  firstName!: string;

  @Column({ name: 'last_name', length: 100 })
  lastName!: string;

  @Column({ default: 'GUEST' })
  role!: string;

  @CreateDateColumn({ name: 'created_at', select: false })
  createdAt?: Date;

  @CreateDateColumn({ name: 'updated_at', select: false })
  updatedAt?: Date;

  // refresh token 저장
  @Column({
    name: 'current_hashed_refresh_token',
    nullable: true
  })
  currentHashedRefreshToken?: string;

}

 

 

User Service에 refreshToken 매칭

Cookie의 REFRESH_TOKEN이 서버에 저장된 값과 맞으면 해당 user정보를 반환하는 코드를 libs/domain/src/lib/entities/user/user.service.ts 에 추가한다.

// user.service.ts 일부

  async getUserIfRefreshTokenMatches(refreshToken: string, id: number): Promise<User | undefined> {
    const user = await this.findOneById(id);

    const isRefreshTokenMatching = await bcryptCompare(
      refreshToken,
      user.currentHashedRefreshToken as string
    );

    if (isRefreshTokenMatching) {
      return user;
    }
    return;
  }

 

 

JWT Refresh Strategy와 Guard 추가

Guard에서 사용할 Refresh Strategy를 libs/domain/src/lib/auth/strategies/jwt-refresh.strategy.ts 파일 생성후 추가한다.

import { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';

import { UserService } from '@rnm/domain';
import { loadConfigJson } from '@rnm/shared';
import { TokenPayload, User } from '@rnm/model';

const config: any = loadConfigJson();

@Injectable()
export class JwtRefreshTokenStrategy extends PassportStrategy(Strategy, 'jwt-refresh-token') {
  constructor(
    private readonly userService: UserService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([(request: Request) => {
        return request?.cookies?.REFRESH_LOGIN_TOKEN;
      }]),
      secretOrKey: config?.AUTH?.REFRESH_SECRET,
      passReqToCallback: true,
    });
  }

  async validate(request: Request, payload: TokenPayload): Promise<User | undefined> {
    const refreshToken = request.cookies?.REFRESH_LOGIN_TOKEN;
    return this.userService.getUserIfRefreshTokenMatches(refreshToken, payload.id as number);
  }
}

Refresh Guard도 libs/domain/src/lib/auth/guards/jwt-auth-refresh.guard.ts 파일 생성하고 추가한다. 

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

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

파일 추가후에는 항시 libs/domain/src/index.ts 안에 export를 해야 한다. 

export * from './lib/constants/core.contant';
export * from './lib/entities/user/user.entity';
export * from './lib/entities/user/user.service';
export * from './lib/entities/entity.module';

export * from './lib/models/request.model';

export * from './lib/auth/auth.service';
export * from './lib/auth/auth.middleware';
export * from './lib/auth/auth.module';
export * from './lib/auth/guards/local-auth.guard';
export * from './lib/auth/guards/jwt-auth.guard';
export * from './lib/auth/guards/jwt-auth-refresh.guard'; // <== 요기

export * from './lib/auth/strategies/local.strategy';
export * from './lib/auth/strategies/jwt.strategy';
export * from './lib/auth/strategies/jwt-refresh.strategy'; // <== 요기

export * from './lib/service/gateway/api/service/gateway-api-app.service';
export * from './lib/service/dashboard/api/service/dashboard-api-app.service';
export * from './lib/configuration/api/service/configuration-api-app.service';
export * from './lib/service/back-office/api/service/backoffice-api-app.service';

 

 

RefreshToken과 AuthToken을 Cookie에 실어 보내기

두가지 Token을 response cookie에 실어 보내기위해 먼저 cookie 생성하는 코드를  libs/domain/src/lib/auth/auth.service.ts 에 추가한다. 

// auth.service.ts 일부
  getCookieWithJwtAccessToken(payload: TokenPayload, hasAuthorization = false) {
    const token = this.jwtService.sign(payload, {
      secret: config?.AUTH?.SECRET || 'iot_app',
      expiresIn: config?.AUTH?.EXPIRED_ON || '1d'
    });
    if (hasAuthorization) {
      return [`LOGIN_TOKEN=${token}; HttpOnly; Path=/; Max-Age=${config?.AUTH?.EXPIRED_ON}`, `Authorization=${token}; HttpOnly; Path=/; Max-Age=${config?.AUTH?.EXPIRED_ON}`];
    } else {
      return [`LOGIN_TOKEN=${token}; HttpOnly; Path=/; Max-Age=${config?.AUTH?.EXPIRED_ON}`];
    }
  }

  getCookieWithJwtRefreshToken(payload: TokenPayload) {
    const token = this.jwtService.sign(payload, {
      secret: config?.AUTH?.REFRESH_SECRET || 'io_app_refresh',
      expiresIn: config?.AUTH?.REFRESH_EXPIRED_ON || '7d'
    });
    const cookie = `REFRESH_LOGIN_TOKEN=${token}; HttpOnly; Path=/; Max-Age=${config?.AUTH?.REFRESH_EXPIRED_ON}`;
    return {
      cookie,
      token
    }
  }

  getCookiesForLogOut() {
    return [
      'LOGIN_TOKEN=; HttpOnly; Path=/; Max-Age=0',
      'REFRESH_LOGIN_TOKEN=; HttpOnly; Path=/; Max-Age=0'
    ];
  }

로apps/gateway/api/src/app/auth/auth.controller.ts 안에 하기 로직을 추가한다.

  • 로그인 했을 때 해당 Cookie를 등록한다. 
  • 로그아웃할 때 해당 Cookie 내용을 삭제한다. 
  • Auth Token (Forbidden)오류 발생시 RefreshToken을 통해 Auth Token 재생성한다. 
import { Controller, Get, HttpCode, Post, Req, Res, UnauthorizedException, UseGuards } from '@nestjs/common';

import { AuthService, JwtAuthGuard, LocalAuthGuard, JwtRefreshGuard, RequestWithUser, UserService } from '@rnm/domain';
import { TokenPayload } from '@rnm/model';

@Controller('api/auth')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly userService: UserService
  ) { }

  @UseGuards(LocalAuthGuard)
  @Post('login')
  async login(@Req() req: RequestWithUser): Promise<any> {
    const { user } = req;
    if (user) {
      const payload: TokenPayload = { username: user.username, sub: user.id, email: user.email, role: user.role };
      const accessTokenCookie = this.authService.getCookieWithJwtAccessToken(payload);
      const {
        cookie: refreshTokenCookie,
        token: refreshToken
      } = this.authService.getCookieWithJwtRefreshToken(payload);
      const loginUsernameCookie = this.authService.getCookieWithLoginUsername(payload);

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

      // 반드시 req.res로 쿠키를 설정
      req.res.setHeader('Set-Cookie', [...accessTokenCookie, refreshTokenCookie, loginUsernameCookie]);
      return {
        payload,
        accessTokenCookie,
        refreshTokenCookie
      };
    } else {
      throw new UnauthorizedException({
        error: 'User does not exist'
      });
    }
  }

  @UseGuards(JwtAuthGuard)
  @Post('logout')
  @HttpCode(200)
  async logout(@Req() req: RequestWithUser, @Res() res): Promise<any> {
    await this.userService.removeRefreshToken(req.user.id);
    res.setHeader('Set-Cookie', this.authService.getCookiesForLogOut());
  }

  @UseGuards(JwtAuthGuard)
  @Get()
  authenticate(@Req() req: RequestWithUser) {
    const user = req.user;
    return user;
  }

  // Refresh Guard를 적용한다.
  @UseGuards(JwtRefreshGuard)
  @Get('refresh')
  refresh(@Req() request: RequestWithUser) {
    const accessTokenCookie = this.authService.getCookieWithJwtAccessToken(request.user);

    request.res.setHeader('Set-Cookie', accessTokenCookie);
    return request.user;
  }
}

 

 

테스트하기 

Postman으로 테스트를 하면 accessTokenCookie가 나온다. 

로그인 결과값

accessTokenCookie를 복사하여 다른 명령 전송시에 Headers에 Cookie를 등록하여 사용한다. 

복사한 accessTokenCookie 사용

소스: https://github.com/ysyun/rnm-stack/releases/tag/ms-7

 

Release ms-7 · ysyun/rnm-stack

[ms-8] added role guard for authorization

github.com

 

 

<참조>

- Refresh Token 만들기
   소스: https://github.com/mwanago/nestjs-typescript

   문서: https://wanago.io/2020/09/21/api-nestjs-refresh-tokens-jwt/

 

API with NestJS #13. Implementing refresh tokens using JWT

It leaves quite a bit of room for improvement. In this article, we look into refresh tokens.

wanago.io

 

posted by 윤영식