Contents

Nest.js : Jwt Refresh Token

๊ฐœ์š”

์œ ์ €๊ฐ€ ๋กœ๊ทธ์ธ ํ–ˆ์„ ๋•Œ, access token ๋งŒ์„ ์ด์šฉํ•ด์„œ ๊ถŒํ•œ์„ ์ธ์ฆํ•˜๊ณ , ๋งŒ๋ฃŒ๊ฐ€ ๋˜๋ฉด ๋‹ค์‹œ ๋กœ๊ทธ์ธํ•˜๋Š” ๊ณผ์ •์„ ๊ฑฐ์นœ๋‹ค๋ฉด ๋ฌด์Šจ ์ผ์ด ์ผ์–ด๋‚ ๊นŒ?
๋งŒ์•ฝ ๋งŒ๋ฃŒ์‹œ๊ฐ„์ด ์งง๋‹ค๋ฉด, ์œ ์ €๋Š” ์žฌ๋กœ๊ทธ์ธํ•ด์•ผ ํ•˜๋Š” ์ƒํ™ฉ์ด ๊ณ„์† ์˜ฌ ์ˆ˜ ๋ฐ–์— ์—†๋‹ค. ๊ทธ๋Ÿฌ๋ฉด ์œ ์ €์ž…์žฅ์—์„œ ๋ถˆํŽธํ•  ์ˆ˜ ์žˆ๋‹ค.
๋งŒ์•ฝ access token์ด ๊ณต๊ฒฉ์ž์—๊ฒŒ ํƒˆ์ทจ๋œ๋‹ค๋ฉด, ๊ณต๊ฒฉ์ž๋Š” ๋กœ๊ทธ์ธ ์—†์ด๋„ ๋ชจ๋“  ์„œ๋น„์Šค์— ์ ‘๊ทผ ๊ถŒํ•œ์„ ์–ป์„ ์ˆ˜ ์žˆ๋‹ค. ์ด๊ฒƒ์€ ์ƒ๋‹นํ•œ ๋ฌธ์ œ์ด๋‹ค.

์ด๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด์„  refresh-token ์„ ํ•˜๋‚˜๋” ์ƒ์„ฑํ•˜๋ฉด ๋œ๋‹ค. refresh-token ์€ access-token ์„ ์žฌ๋ฐœ๊ธ‰ํ•  ์ˆ˜ ์žˆ๋Š” ํ† ํฐ์ด๋‹ค.

๊ทธ๋Ÿผ, refresh-token ์ด ํƒˆ์ทจ๋œ๋‹ค๋ฉด ์–ด๋–ป๊ฒŒ ํ•ด์•ผํ• ๊นŒ?
refresh-token ์€ stateless ์ด๊ธฐ ๋•Œ๋ฌธ์— ๊ฐ€์žฅ ๊ฐ„๋‹จํ•˜๊ฒŒ ์ƒ๊ฐํ•ด๋ณผ ์ˆ˜ ์žˆ๋Š” ๋ฐฉ๋ฒ•์œผ๋กœ๋Š”, secret ๊ฐ’์„ ๋ณ€๊ฒฝํ•ด์„œ ๋ชจ๋“  ํ† ํฐ์„ ๋ฌดํšจํ™” ํ•  ์ˆ˜ ์žˆ๋‹ค. ํ•˜์ง€๋งŒ ์ด ๋ฐฉ๋ฒ•์€ ๋ชจ๋“  ์œ ์ €์—๊ฒŒ ๋‹ค์‹œ ๋กœ๊ทธ์ธํ•˜๊ฒŒ ํ•˜๋Š” ๋Œ€์ฐธ์‚ฌ๊ฐ€ ์ผ์–ด๋‚  ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ํ˜„๋ช…ํ•œ ๋ฐฉ๋ฒ•์€ ์•„๋‹ˆ๋‹ค.

ํ•˜๋‚˜์˜ ํ•ด๊ฒฐ์ฑ…์€ refresh-token ์„ db์— ์ €์žฅํ•ด์„œ ๊ด€๋ฆฌํ•˜๊ณ  ๋กœ๊ทธ์ธํ•  ๋•Œ๋งˆ๋‹ค ๋ณ€๊ฒฝํ•˜๋ฉด ๋œ๋‹ค. ์ด๋Ÿฐ ๋ฐฉ๋ฒ•์„ ์ฑ„ํƒํ•˜๋ฉด, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋ฌธ์ œ๋„ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ๋‹ค.
์˜ˆ๋ฅผ ๋“ค์–ด, ์—ฌ๋Ÿฌ ์‚ฌ์šฉ์ž๊ฐ€ ํ•˜๋‚˜์˜ ๊ณ„์ •์„ ๊ณต์œ ํ•œ๋‹ค๊ณ  ํ–ˆ์„ ๋•Œ, ๋ชจ๋“  ์‚ฌ๋žŒ์ด ๊ฐ‘์ž๊ธฐ ํ•ด๋‹น ๊ณ„์ •์œผ๋กœ ๋™์‹œ์— ์ ‘์†ํ•ด ์–ดํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ์ด์šฉํ•œ๋‹ค๋ฉด, ๋น„์ง€๋‹ˆ์Šค์— ์•ˆ์ข‹์€ ์˜ํ–ฅ์„ ๋ผ์น  ์ˆ˜ ์žˆ๋‹ค. refresh-token ์„ ๋กœ๊ทธ์ธํ•  ๋•Œ๋งˆ๋‹ค ๋ณ€๊ฒฝํ•œ๋‹ค๋ฉด, ์ด์ „์— ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž๋Š” ์œ ํšจํ•˜์ง€ ์•Š์€ refresh-token์„ ๊ฐ–๊ฒŒ ๋  ๊ฒƒ์ด๊ณ , ์žฌ๋กœ๊ทธ์ธ์„ ์œ ๋„ํ•  ๊ฒƒ์ด๋‹ค.

๊ทธ๋Ÿผ refresh-token์ด db์—์„œ ์œ ์ถœ๋˜๋Š” ๋ฌธ์ œ๋Š” ์–ด๋–ป๊ฒŒ ํ•ด๊ฒฐํ•ด์•ผ ํ• ๊นŒ?
password ์ฒ˜๋Ÿผ hash ํ•ด์„œ ์ €์žฅํ•˜๋ฉด ๋œ๋‹ค.

/images/JWT-Refresh-Token-workflow-diagram-aspnet-core_637779427789390135.jpg

๊ตฌํ˜„

configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// .env
JWT_ACCESS_TOKEN_SECRET=access-secret
JWT_ACCESS_TOKEN_EXPIRATION_TIME=3600
JWT_REFRESH_TOKEN_SECRET=refresh-secret
JWT_REFRESH_TOKEN_EXPIRATION_TIME=3600

// app.module.ts
      ....
    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(),
    ....

์œ„์™€ ๊ฐ™์ด access token / refresh token ์— ๋Œ€ํ•ด ๋ณ€์ˆ˜๋ฅผ ์ƒ์„ฑํ•ด์ฃผ๊ณ , ํ•„์š”ํ•˜๋‹ค๋ฉด ์ด๋ฅผ validation๊นŒ์ง€ ํ•˜๋Š” ๋กœ์ง์„ ๊ตฌํ˜„ํ•ด์ฃผ๋ฉด ๋œ๋‹ค.

/login

access token ๋ฐœ๊ธ‰
1
2
3
4
5
6
7
async getJwtAccessToken(userId: number){
  const payload : TokenPayload = {userId};
  const token = await jwtService.sign(payload, {
    secret : configService.get("JWT_ACCESS_TOKEN_SECRET"),
    expiresIn : configService.get("JWT_ACCESS_TOKEN_EXPIRATION_TIME")
  });
}
refresh token ๋ฐœ๊ธ‰
1
2
3
4
5
6
7
async getJwtRefreshToken(userId: number){
  const payload : TokenPayload = {userId};
  const token = await jwtService.sign(payload, {
    secret : configService.get("JWT_REFRESH_TOKEN_SECRET"),
    expiresIn : configService.get("JWT_REFRESH_TOKEN_EXPIRATION_TIME")
  });
}
signOptions
ํ˜„์žฌ ์œ„ ๋‘ ๋กœ์ง์—์„œ sign() ๋ฉ”์„œ๋“œ๋ฅผ ์‹คํ–‰ํ•  ๋•Œ signOption์— secret ๊ฐ’์ด ๋“ค์–ด๊ฐ€์žˆ๋Š” ๊ฑธ ๋ณผ ์ˆ˜ ์žˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•จ์œผ๋กœ์จ ๋‹ค๋ฅธ secret๊ฐ’์— ๋”ฐ๋ผ ๋‹ค๋ฅด๊ฒŒ ํ† ํฐ์„ ์ƒ์„ฑํ•˜๋Š”๋ฐ ๊ด€๋ จ ๊ธฐ๋Šฅ์€ @nest/jwt@^7.1.0 ์—์„œ๋ถ€ํ„ฐ ๊ฐ€๋Šฅํ•˜๋‹ค.
user db์— ํ˜„์žฌ ํ† ํฐ์„ hash ํ•˜๊ณ  ์ €์žฅ

๋‹ค์Œ๊ณผ ๊ฐ™์ด ํ˜„์žฌ ๋“ค์–ด์˜จ refreshToken์„ ์ƒˆ๋กœ ์•”ํ˜ธํ™”ํ•˜๊ณ  ์ €์žฅํ•˜๋ฏ€๋กœ, refresh token์ด ํƒˆ์ทจ๋˜์—ˆ์„ ๋•Œ ๋ฐœ์ƒํ•˜๋Š” ๋ฌธ์ œ๋ฅผ ์–ด๋Š์ •๋„ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ๋‹ค.

1
2
3
4
5
6
7
// userService.ts
async setCurrentRefreshToken(refreshToken: string, userId: number) {
    const currentHashedRefreshToken = await bcrypt.hash(refreshToken, 10);
    await this.usersRepository.update(userId, {
      currentHashedRefreshToken
    });
  }
cookie์— access token, refresh token์„ ๋„ฃ๊ณ  response
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@HttpCode(200)
  @UseGuards(LocalAuthenticationGuard)
  @Post('log-in')
  async logIn(@Req() request: RequestWithUser) {
    const {user} = request;
    const accessTokenCookie = this.authenticationService.getCookieWithJwtAccessToken(user.id);
    const refreshTokenCookie = this.authenticationService.getCookieWithJwtRefreshToken(user.id);
 
    await this.usersService.setCurrentRefreshToken(refreshToken, user.id);
 
    request.res.setHeader('Set-Cookie', [accessTokenCookie, refreshTokenCookie]);
    return user;
  }

/refresh

refresh token validation

ํ˜„์žฌ refresh token์ด validate ํ•˜๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด์„œ, ์ƒˆ๋กœ์šด strategy๋ฅผ ์ƒ์„ฑํ•ด์•ผ ํ•œ๋‹ค. ์ด strategy๋ฅผ JwtRefreshStrategy ๋ผ ํ•œ๋‹ค๋ฉด, ์ด strategy์—์„œ๋Š” user service์— ํ˜„์žฌ ๋“ค์–ด์˜จ token์ด ์œ ์ € db์— ์ €์žฅ๋œ ํ† ํฐ๊ณผ ๋˜‘๊ฐ™์€ ์ง€ ๋น„๊ตํ•œ๋‹ค. ์ด ๊ณผ์ •์„ ํ†ตํ•ด์„œ, token์ด ๊ฐˆ์ทจ๋˜๊ฑฐ๋‚˜ ๋™์ผํ•œ ์‚ฌ์šฉ์ž๊ฐ€ ๋™์‹œ์— ์ ‘์†ํ•˜๋Š” ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ๋‹ค.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 
@Injectable()
export class JwtRefreshTokenStrategy extends PassportStrategy(
  Strategy,
  'jwt-refresh-token'
) {
  constructor(
    private readonly configService: ConfigService,
    private readonly userService: UsersService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([(request: Request) => {
        return request?.cookies?.Refresh;
      }]),
      secretOrKey: configService.get('JWT_REFRESH_TOKEN_SECRET'),
      passReqToCallback: true,
    });
  }
 
  async validate(request: Request, payload: TokenPayload) {
    const refreshToken = request.cookies?.Refresh;
    return this.userService.getUserIfRefreshTokenMatches(refreshToken, payload.userId);
  }
}

passReqToCallback ์„ ํ†ตํ•ด validate ๋ฉ”์„œ๋“œ์—์„œ cookie์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋˜์—ˆ๋‹ค.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// user service 
async getUserIfRefreshTokenMatches(refreshToken: string, userId: number) {
    const user = await this.getById(userId);
 
    const isRefreshTokenMatching = await bcrypt.compare(
      refreshToken,
      user.currentHashedRefreshToken
    );
 
    if (isRefreshTokenMatching) {
      return user;
    }
  }
1
2
3
4
5
6
7
8
@UseGuards(AuthGuard('jwt-refresh-token'))
  @Get('refresh')
  refresh(@Req() request: RequestWithUser) {
    const accessTokenCookie = this.authenticationService.getCookieWithJwtAccessToken(request.user.id);
 
    request.res.setHeader('Set-Cookie', accessTokenCookie);
    return request.user;
  }

/logout

๋กœ๊ทธ์•„์›ƒ์€ ์œ ์ €db์— ์ €์žฅ๋œ refresh token์„ ์—†์• ๊ณ , response๋กœ token์„ ๋ฌดํšจํ™”ํ•˜๋Š” ์ •๋ณด๋ฅผ ์ €์žฅํ•ด ์‘๋‹ตํ•œ๋‹ค.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// authentication service 
public getCookiesForLogOut() {
    return [
      'Authentication=; HttpOnly; Path=/; Max-Age=0',
      'Refresh=; HttpOnly; Path=/; Max-Age=0'
    ];
  }

// user service 
async removeRefreshToken(userId: number) {
    return this.usersRepository.update(userId, {
      currentHashedRefreshToken: null
    });
  }

// authentication controller 
@UseGuards(JwtAuthenticationGuard)
  @Post('log-out')
  @HttpCode(200)
  async logOut(@Req() request: RequestWithUser) {
    await this.usersService.removeRefreshToken(request.user.id);
    request.res.setHeader('Set-Cookie', this.authenticationService.getCookiesForLogOut());
  }