개발 일지

Refresh Token - JWT

hou27 2022. 4. 4. 02:46

사용자를 인증하는 부분은 거의 모든 서비스에서 가장 중요하다고 할 수 있을 정도로

핵심적인 부분이다.

 

사용자를 인증하는 방법 중 하나인 JWT 기법과 함께 refresh token라는 장치에 대해 알아보고 구현해보도록 하겠다.

 

JWT

JWT란, JSON Web Token의 약자로, 사용자 인증을 위해 사용하는 암호화된 토큰을 의미한다.

 

기본적으로 JWT는 ' . ' 으로 구분되어

header, payload, signature

3파트로 나뉜다.

11111111111.22222222222222.333333333333

예를 들면 위와 같은 형식인 것이다.

 

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoienhjengiLCJzdWIiOjE4LCJpYXQiOjE2NDkwNjM3MjUsImV4cCI6MTY0OTA2NzMyNX0.643mphFGxoE8UDRBQTATOrVSBkCrOI4hF9okaIANg-s

위 토큰은 실제 JWT인데,

https://jwt.io/

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

jwt 사이트에서 디코딩을 통해 안에 어떤 정보가 들어있는지 확인해볼 수 있다.

 

헤더에 token의 타입과 암호화 알고리즘이 무엇인지에 대한 정보가 담겨있으며,

페이로드에 토큰을 생성할 때 담은 정보가 담겨있다.

 

그리고 조금 전 제공한 실제 토큰을 디코딩해보면 좌측 하단에 

 

위처럼 invalid signature라고 뜨는 것을 확인할 수 있다.

이는 256-bit-secret-key를 기입해주지 않아서 그런 것인데,

 

보통 백엔드에서 JWT를 발행할 때 Payload와 SECRET_KEY를 함께 서명하여 암호화한다.

후에 토큰을 검증할 때 SECRET_KEY를 사용하여 토큰이 유효한지 검증하게 되는 것이다.


흔히 알고 있는 세션 인증 방식과의 차이점은 무엇일까?

 

세션 인증 방식의 시스템은 어플리케이션의 서버가 유저의 정보를 담고 기억하고 있어야만 한다.

그러나 JWT 인증 방식은 서버에 세션을 저장하던 방식과 달리 유저를 인증해주는 정보가 유저 측에 존재하여

별도의 인증 저장소가 필요하지 않다.

 

Refresh Token

refresh token 사용하는 이유는 간단하게

 

'보안적 측면을 강화하기 위해'

 

이다.

 

지금부터 그 보안적 측면이 왜, 어떻게 강화되는지 알아보도록 하겠다.

 

JWT 토큰을 사용한 인증 방식은 서버에서 통제가 불가능하며 토큰의 유효성만 판단하여 동작하는 인증 방식이다.

서버에 부담가지 않고 확장성이 뛰어나 많이 사용하는 방식이지만,

단일 토큰만 사용하게 될 경우 보안적으로 매우 위험하다.

 

왜 위험할까?

 

단일 토큰을 사용하는 환경에서 만약 해당 토큰이 탈취당하게 되면,

서버 입장에서는 해당 토큰을 즉시 무효화하기 어렵기 때문에 서버에 보관이 가능한 별도의 토큰을 발급함으로써

서버 측에서 컨트롤할 수 있도록 하는 것이다.

 

여기서 의문이 들 수 있다.

 

1. 단일 토큰. 즉 Access Token의 유효기간을 짧게 하여 탈취당하더라도 위험을 최소화하면 되지 않는가?

2. Refresh Token도 결국 똑같이 탈취당할 수 있지 않는가?

3. Refresh Token을 서버 측에 저장하는 이유는 무엇인가?

 

사실 필자가 떠올렸던 의문들인데, 지금부터 하나하나 풀어나가도록 하겠다.

 

1. access token이 짧은 시간을 가지면 보안을 강화했다고 볼 수 있다. 그러나 잦은 토큰 만료로 인해사용자 입장에서 재로그인이 너무 빈번하여 좋은 서비스 환경이라고 할 수 없다.때문에 유효 기간이 짧은 access token과 유효 기간이 긴 refresh token을 함께 사용함으로써슬라이딩 세션 전략을 구현할 수 있게 되는 것이다.

 

2. 물론 refresh token도 탈취당할 위험은 언제나 존재하지만, access token과 달리refresh token을 사용하여 토큰을 재발급할 때, 유효성을 확인하는 단계에서 서버 측에 저장된 refresh token과 요청에 담겨 넘어온 것이같은지 비교하는 과정을 거치게 된다.결국 refresh token이 탈취되었을 때에는 서버 측에서 저장된 토큰을 날려 무효화가 가능하기 때문에access token이 탈취되었을 때와는 다르다고 할 수 있다.

 

3. 그래서 서버에 refresh token을 저장해야 하는 것이다.


Implementation

backend는 NestJS, frontend는 React로 간단하게 구현하였다.

 

흐름도 사진 첨부 예정

 

우선 Backend 코드이다.

User Entity

@Injectable()
@Entity()
export class User extends CoreEntity {
  @Column()
  @IsString()
  name: string;

  @Column({ select: false })
  @IsString()
  password: string;

  @Column({ nullable: true })
  @IsString()
  refresh_token?: string;

  ...
}

이름과 비밀번호를 통해 로그인하게 할 것이며,

refresh token을 사용할 것이기 때문에 user에 column을 추가하여 공간을 마련하였다.

 

User Controller

@UseFilters(HttpExceptionFilter)
@Controller('user')
export class UsersController {
  constructor(
    private readonly usersService: UsersService,
    private readonly authService: AuthService,
  ) {}

  ...

  @Post('register')
  async register(
    @Body() createAccountBody: CreateAccountBodyDto,
  ): Promise<CreateAccountOutput> {
    console.log(createAccountBody);
    return await this.usersService.register(createAccountBody);
  }

  @Post('login')
  async login(@Body() loginBody: LoginBodyDto): Promise<LoginOutput> {
    return await this.authService.jwtLogin(loginBody);
  }
  
  @UseGuards(JwtAuthGuard)
  @Get('logout')
  async logout(@AuthUser() user: User): Promise<LoginOutput> {
    ...
  }

  @Post('token')
  async regenerateToken(
    @Body() regenerateBody: RefreshTokenDto,
  ): Promise<RefreshTokenOutput> {
    return await this.authService.regenerateToken(regenerateBody);
  }
}

User Controller의 모습이다.

테스트에 필요한 Login, Logout, Register, 그리고 Token 재발급 총 4가지 기능을 구현해두었다.

 

 

 

다음으로 Frontend 코드이다.

 

Axios Interceptors

매 요청마다 헤더를 세팅해주고, 에러 발생 시 재발급받아 다시 요청하기 너무 번거롭기 때문에

axios의 interceptor를 사용할 것이다.

 

axios의 interceptor는 axios 요청이 then 또는 catch로 넘어가기 전

request, response를 가로채 원하는 작업을 할 수 있도록 해주는 친구이다.

Interceptor

import axios from "axios";
import { ACCESS_TOKEN, REFRESH_TOKEN } from "../localKey";
import { getCookie, removeCookie, setCookie } from "../utils/cookie";

export const instance = axios.create({
  baseURL: "http://localhost:4000",
  headers: { Authorization: `Bearer ${default_access_token}` },
});

instance.interceptors.response.use(
  (res) => {
    return res;
  },
  async (error) => {
    // response에서 error가 발생했을 경우 catch로 넘어가기 전에 처리
    try {
      const errResponseStatus = error.response.status;
      const errResponseData = error.response.data;
      const prevRequest = error.config;

      // access token이 만료되어 발생하는 에러인 경우
      if (
        (errResponseData.error?.message === "jwt expired" ||
          errResponseStatus === 401)
      ) {
        const preRefreshToken = getCookie(REFRESH_TOKEN);
        if (preRefreshToken) {
          // refresh token을 이용하여 access token 재발급
          async function regenerateToken() {
            return await axios
              .post("api/user/token", {
                refresh_token: preRefreshToken,
              })
              .then(async (res) => {
                const { access_token, refresh_token } = res.data;
                // 새로 받은 token들 저장
                setCookie(ACCESS_TOKEN, access_token, {
                  path: "/" /*httpOnly: true */,
                });
                setCookie(REFRESH_TOKEN, refresh_token, {
                  path: "/" /*httpOnly: true */,
                });

                // header 새로운 token으로 재설정
                prevRequest.headers.Authorization = `Bearer ${access_token}`;

                // 실패했던 기존 request 재시도
                return await axios(prevRequest);
              })
              .catch((e) => {
                /*
                 token 재발행 또는 기존 요청 재시도 실패 시
                 기존 token 제거
                 */
                removeCookie(ACCESS_TOKEN);
                removeCookie(REFRESH_TOKEN);
                window.location.href = "/";

                return new Error(e);
              });
          }
          return await regenerateToken();
        } else {
          throw new Error("There is no refresh token");
        }
      }
    } catch (e) {
      // 오류 내용 출력 후 요청 거절
      return Promise.reject(e);
    }
  }
);

사실 frontend 쪽 코드는 위 코드가 핵심이고 전부이다.

 

우리는 access token이 만료되었을 때 토큰을 재발급받아야 하므로

// access token이 만료되어 발생하는 에러인 경우
if (
    (errResponseData.error?.message === "jwt expired" ||
      errResponseStatus === 401)
) {
	...
}

request에 대한 response가 특정 Error일 경우에

axios
  .post("api/user/token", {
    refresh_token: preRefreshToken,
  })

재발급 요청을 보내고,

.then(async (res) => {
    const { access_token, refresh_token } = res.data;
    // 새로 받은 token들 저장
    setCookie(ACCESS_TOKEN, access_token, {
      path: "/" /*httpOnly: true */,
    });
    setCookie(REFRESH_TOKEN, refresh_token, {
      path: "/" /*httpOnly: true */,
    });

    // header 새로운 token으로 재설정
    prevRequest.headers.Authorization = `Bearer ${access_token}`;

    // 실패했던 기존 request 재시도
    return await axios(prevRequest);
})

발급받은 토큰을 세팅해주고, 실패했던 기존의 요청을 다시 시도해준다.

 

이렇게 되면 사용자 입장에선 사실상 access token이 만료되어도 refresh token이 유효하다면

마치 계속 access token이 유효한 것처럼 느끼는 최적의 사용자 환경을 조성해 줄 수 있다.

 

성공!!