본문 바로가기
SpringBoot

[Spring Boot] 33. JWT 인증 갱신처리 (2)

by 청양호박이 2021. 4. 24.

이번에는 저번시간에 이어서 JWT를 통한 인증 갱신처리하는 로직을 마저 구현하겠습니다. 지난번에 알아본 전체 동작 로직을 다시 생각해 보면, 아래와 같습니다. 

  1. 사용자는 Google Signin Button을 통해서 Google OAuth로 인증을 수행하고, 연결되어 있는 API로 verify를 진행하게 됩니다. 이 부분은 지난번에 구현한 부분이기 때문에 생략하기로 하겠습니다. 
  2. Back-End에서는 verification이 성공적으로 끝나면, JWT를 통한 Token을 생성하는데... 기존과 다르게 2가지 Token을 생성합니다. Access Token, Refresh Token이 그것입니다.
  3. Back-End에서는 이 중에서 Refresh Token을 사용자 e-mail 주소와 함께 DBMS에 저장합니다. 저장하는 DB는 어떤 형태던지 상관없습니다. MySQL 등의 RDBMS도 괜찮고, Redis같은 non-RDBMS도 괜찮습니다. 일단 개발의 편의성을 위해서 기존에 관리하는 mariaDB에 저장합니다.
  4. 생성된 2개의 Token을 response Header에 추가하여 Front-End에게 전달합니다.
  5. Front-End는 2가지 Token을 알맞게 저장합니다. 저장하는 방식은 여러가지가 있습니다. Local Storage, Session Storage, Cookies, vue의 경우에는 vuex를 통한 저장도 가능합니다. 역시나 개발의 편의성을 위해서 
    Session Storage에 저장합니다.
  6. axios로 /apis에 서비스를 요청 시, Front-End에서 Access Token의 Expiration여부를 확인하여 조건에 맞는 Header를 추가하여 요청을 합니다.
  7. Interceptor에서는 Header에 담겨온 Token에 따라서 재발급 등의 작업을 수행 후 Front-End로 결과를 전달합니다.
  8. Front-End에서는 Header를 확인해서, 필요할 경우 Token을 업데이트 해 줍니다. 

 

이 중에서 2 ~ 5번은 이미 구현이 되었고, 6 ~ 8번에 대해서 구현해 보겠습니다. 

 

 

1. Front-End에서 JWT controll 환경 구성


Front-End에서도 JWT에 대한 조작을 하기위해서는 관련된 Library를 사용해야 합니다. 저는 vue.js로 개발하고 있기 때문에 관련한 Library를 찾아보겠습니다. 당연히 jwt.io에서 확인합니다. Libraries for Token Signing/Verification에서 Filter by를 Node.js로 선택합니다. 

 

그럼 결과는 아래와 같이 jsonwebtoken과 jose가 있음을 알려줍니다.

 

그럼 바로 npm repository에서 해당 Library를 검색해 봅니다. 저는 앞에있는 jsonwebtoken을 선택하겠습니다. 

[package.json dependencies 추가]

"jsonwebtoken": "8.5.1"

추가하고 npm install을 수행해 주면 정상적으로 설치가 됩니다. node_modules 폴더에서 jsonwebtoken의 확인이 가능합니다. 

 

 

2. Access Token exp 확인하기


이제 설치가 되었으니, 사용법에 대해서 알아보겠습니다. 구현하고자 하는 부분은 Access Token의 expiration을 알아내는 부분에 대한 내용입니다. 이를 기준으로 현재 시간기준으로 이미 만료가 되었거나 만료가 되기전에는 재발행 요청을 Back-End에 해야하기 때문입니다.

npm repository에서 보면, 해당 Library를 사용하기 위한 기본적인 내용을 제공합니다. 이 중에서 현재 암호화된 Token을 복호화해서 Payload부분의 데이터를 확인해야하기 때문에 jwt.decode method를 사용합니다. 가이드에 따르면 decode method는 decode된 Payload결과를 return해주지만, 서명이 정상적으로 되었는지에 대한 적합성은 판단해 주지 않는다고 되어 있습니다.

그렇다면, 우리의 Access Token의 Payload를 살펴보겠습니다. 

 

[vue.js code 추가]

// library 호출
const jwt = require('jsonwebtoken');

// decode method 사용
const decodeAccessToken = jwt.decode(res.headers['at-jwt-access-token']);
console.log('decodeAccessToken data', decodeAccessToken);

고민한거에 비하면 엄청 간단합니다. 그렇다면 해당 결과를 살펴보겠습니다. 

 

[결과]

decodeAccessToken data 

exp: 1619150290
iat: 1619148490
iss: "AyoteraLab"
sub: "AT"
userInfo: 
    email: 
    familyName: 
    givenName: 
    locale: 
    name: 
    pictureUrl: 
    userId: 

정말로 secret key의 존재여부와 상관없이 Payload에 대한 decode가 완료되었습니다. Back-End에서 Access Token생성 시 추가한 정보들과 custom claim까지 정확하게 decode되서 보여줍니다. 그렇다면 이 데이터 중 exp를 가지고 만료여부를 판단해 보겠습니다. 

 

[vue.js exp 만료여부 판단 로직]

    sendToken() {
      const decodeAccessToken = jwt.decode(storage.getItem('at-jwt-access-token'));
      let headers = null;
      if(decodeAccessToken.exp < Date.now()/1000 + 60){
        console.log('만료됨!!');
        headers = {
          'at-jwt-access-token': storage.getItem('at-jwt-access-token'),
          'at-jwt-refresh-token': storage.getItem('at-jwt-refresh-token'),
        }
        console.log('headers : ', headers);
      }else{
        console.log('만료되지않음!!');
        headers = {
          'at-jwt-access-token': storage.getItem('at-jwt-access-token'),
        }
        console.log('headers : ', headers);
      }
      axios.get('/qss/list', {
        headers: headers,
      }).then((res) => {
        // eslint-disable-next-line
        console.log(res);
      }).catch((error) => {
        // eslint-disable-next-line
        console.log(error);
      }).then(() => {
        // eslint-disable-next-line
        console.log('getQSSList End!!');
      });
    },

decode되서 나온 expiration은 millisecond단위로 결과를 뽑아 줍니다. 따라서 결과에 1/1000을 해주어야 합니다. 그리고, 요청시점에 만료가 아니더라도 간발의 차로 Back-End로 넘어가는 시간동안 만료가 될 수 있기때문에, 1분의 term을 주어서 expiration을 판단하였습니다. 

 

 

3. Back-End에서 전달된 Token확인


위에서 아무생각없이 axios.get을 통해서 header를 추가하고 보내주면... 아래와 같이 Error가 발생합니다.

 

Access to XMLHttpRequest at 'http://localhost:8888/qss/list' from origin 'http://localhost:8080' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: It does not have HTTP ok status.

 

이 문제는 CORS환경에서 발생합니다. 사용자의 Browser에서는 API를 요청 시, 해당 web application의 Back-End가 요청을 받을 수 있는지 header의 request method에 OPTIONS을 사용하여 먼저 서버에 호출을 진행합니다. 하지만 이 경우에는 header에 넣어서 보낸 Access Token을 받을 수 없기 때문에 Front-End에서는 해당 오류를 발생시키며, Back-End에서는 아래의 오류를 발생시키게 됩니다. 

Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception 
[Request processing failed; nested exception is java.lang.IllegalArgumentException: No Token!!!] 
with root cause

따라서, Back-End에서는 request method가 OPTIONS일 경우에는 jwtService를 통한 validation을 통과하지 않고 강제로 통과를 시켜주어야 합니다. 최종 적용되는 소스는 아래와 같습니다. 

 

[JwtInterceptor]

	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
			throws Exception {
		
		System.out.println("####### Interceptor preHandle Start!!!");
		
		String atJwtToken = request.getHeader("at-jwt-access-token");
		
		System.out.println("at-jwt-access-token : " + atJwtToken);
		System.out.println("request method : " + request.getMethod());
		
		if("OPTIONS".equals(request.getMethod())) {
			System.out.println("request method is OPTIONS!!");
			return true;
		}
		
		if(atJwtToken != null && atJwtToken.length() > 0) {			
			if(jwtService.validate(atJwtToken)) return true;
			else throw new IllegalArgumentException("Token Error!!!");
		}else {
			throw new IllegalArgumentException("No Token!!!");
		}
	}

추가된 부분은 request.getMethod( )를 확인해서 OPTIONS일 경우는 그냥 통과시켜버리는 것 입니다. 전체 흐름을 보기위해서 API요청에 대한 로그를 찍어봤습니다.

at-jwt-access-token : eyJ0eXAiOiJKV1QiLCJhbGc.eyJpc3MiOiJBeW90ZXJhTaVItMzVIjoi7LCs7JiBIn19.4H6-TpKJP5IQBy1DE2u3je4
request method : OPTIONS

at-jwt-access-token : eyJ0eXAiOiJKV1QiLCJhbGc.eyJpc3MiOiJBeW90ZXJhTaVItMzVIjoi7LCs7JiBIn19.4H6-TpKJP5IQBy1DE2u3je4
request method : GET

[결과]

{data: Array(452), status: 200, statusText: "", headers: {…}, config: {…}, …}
config: {url: "/qss/list", method: "get", headers: {…}, baseURL: "http://localhost:8888/", transformRequest: Array(1), …}
data: (452) [{…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…},  …]
headers: {content-type: "application/json"}
request: XMLHttpRequest {readyState: 4, timeout: 0, withCredentials: false, upload: XMLHttpRequestUpload, onreadystatechange: ƒ, …}
status: 200
statusText: ""

정상적으로 결과를 가져왔습니다. 왠지 이어서 작성하면 글이 너무 길어질거 같아서, Access Token의 만료로 Back-End에 Refresh Token을 전달할때 로직은 바로 다음에 이어서 작성하겠습니다. 

 

- Ayotera Lab -

댓글