본문 바로가기
SpringBoot

[Spring Boot] 34. JWT 인증 갱신처리 (3)

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

이제 JWT를 통한 인증처리의 마지막 단계입니다. Back-End에서 Access Token과 Refresh Token을 발급하여, Front-End를 통해서 개별 사용자 환경에 해당 Token들을 저장하는 로직을 구현하였고, 더 나아가 Front-End에서는 사용자의 Token의 Expiration을 확인하여 조건에 따라서 Token을 전달하는 로직까지 구현하였습니다. 중간에 Refresh Token에 대한 DB에 저장하는 로직 등 추가적인 내용도 여럿 있었습니다. 

 

최종적으로 Access Token의 만료로 Back-End에 Refresh Token을 전달할 때, Back-End에서 처리하는 로직에 대해서 구현해 보도록 하겠습니다. 이를 위해서는 Front-End와 Back-End에 각 각 로직이 모두 구현되어야 합니다. 그 순서를 알아보고 차례로 구현해 보겠습니다. 

 

[Front-End]

0. Access Token의 만료시간(exp)에 따라서 API Request Header에 조건에 맞는 Token을 추가하여 전송

   [조건]

        - Access Token의 만료시간이 1분이상 남았을 때, Header에 Access Token만 전송

        - Access Token이 만료되었거나 1분이하로 남았을 때, Header에 Refresh Token과 Access Token모두 전송

 

[Back-End]

1. Interceptor에서 Header의 값에 따라서 조건에 맞는 로직 수행

   [조건]

        - Header에 Access Token만 있을경우, Access Token에 대한 인증만 수행하고 API Response 진행

        - Header에 Refresh Token이 있을경우, Refresh Token에 대한 인증 수행

        - Refresh Token에 대한 인증 수행 후, Access Token의 Payload에 email을 추출하여 DB에 저장되어있는 Refresh 

          Token값과 Header에 Refresh Token의 값을 비교하여 동일할 경우, Access Token을 재 발행

 

[Front-End]

2. Access Token이 재 발행될 경우, Session Storage에 변경된 Token을 저장

 

저는 따로 Refresh Token에 대해서 만료(exp)가 다가올 때, 재발급하는 로직을 구현하지 않았습니다. web application의 특성에 따라서 다르겠지만 적어도 길게 세팅한 Refresh Token의 만료기간내에는 사용자가 재 로그인을 수행해야하며, 이를 강제화 하기 위함입니다. 

 

 

1. Back-End Interceptor 변경 (email 추출)


Header에 Access Token만 있는 경우에는, 기존로직은 사용하면되지만... Header에 Refresh Token이 있는 경우에는 2가지 로직이 추가가 됩니다. 우선 Refresh Token에 대한 인증은 뒤에 구현하고, 그 뒷단에 진행하는 부분에 대해서 먼저 구현해 보겠습니다. 

 

그렇게 하는 이유는, Refresh Token에 대한 인증이 정상적으로 완료가 된다면...

 

  1. Access Token의 Payload에 email주소를 추출하고
  2. 추출된 email을 가지고 DB를 검색해서 해당 email에 매핑된 Refresh Token을 가져와야합니다.

 

우선 첫번째로 email주소를 jwt에서 추출을 하기위해서는 전체 서명된 Token중에서 Payload부분을 잘라서 decode를 수행해야 합니다. 기본적으로 jwt는 Header와 Payload를 base64로 암호화를 수행하기 때문에... 해당 방식으로 복호화를 해주면 됩니다. 

 

또한, jwt의 구조는 아래와 같이 Header / Payload / Signature가 각 각 "." 으로 구분되어 있기 때문에 String.split로 해줘야 합니다. 

aaaaaaaaaa.bbbbbbbbbb.cccccccccc

decode를 위해서, jwtService에 method를 하나 추가해 줍니다.

 

[JwtService]

	public String decode(String token) {
		
		String[] splitToken = token.split("\\.");
		Decoder decoder = Base64.getDecoder();
		byte[] decodedBytes = decoder.decode(splitToken[1]);
		
		String decodedString = null;
		try {
			decodedString = new String(decodedBytes, "UTF-8");
		} catch (UnsupportedEncodingException e) {
			e.printStackTrace();
		}
		return decodedString;
		
	}

split을 "."으로 해야하기 때문에, 약간 주의를 해서 "\\."로 수행을 해줍니다. 왜냐하면, "."의 경우 메타문자이기 때문에 escape처리를 해줘야 정상적으로 일반문자로 인식하여 split을 수행하게 됩니다. escape없이 진행하면, split결과는 0으로 return되게 됩니다.

 

이렇게 split이 된다면 payload는 두번에 위치에 있기때문에 index[1]에 해당하는 String을 사용하여 decode를 진행하고 그 결과를 리턴해 줍니다. 이렇게 리턴한 결과는, 아래와 같이 JSON의 형식을 띄고 있습니다. 

 

[결과]

{"iss":"AyoteraLab",
 "sub":"AT",
 "exp":1619407777,
 "iat":1619405977,
 "userInfo":
    {"userId":"1095913980569372",
     "email":"abb@gmail.com",
     "name":"abb",
     "pictureUrl":"https://lh4.googleusercontent.com/aaa/photo.jpg",
     "locale":"ko",
     "familyName":"a",
     "givenName":"bb"}
    }
}

이제 이 중에서 email만 찰떡같이 뽑아내야 합니다. 이를 위해서는 저는 Gson을 사용하겠습니다. 해당 Library는 Google API에 포함되어 있기 때문에, OAuth를 구성하는 중에는 따로 Gson에 해당하는 dependency를 추가할 필요는 없습니다. 저는 해당 코드를 Interceptor에 구성하였습니다. 

 

[JwtInterceptor]

String res = jwtService.decode(atJwtAccessToken);
System.out.println("res : " + res);
Gson gsons = new Gson();
JwtPayloadDTO a = gsons.fromJson(res, JwtPayloadDTO.class);
System.out.println("a : " + a.getUserInfo());

Gson을 위해서는 DTO Class를 하나 추가해 주어야 하며, 저는 JwtPayloadDTO를 추가하였고... UserDTO를 그 안에서 userInfo를 저장하기 위해서 재활용 했습니다. 

 

그 결과로 추출된 JwtPayloadDTO를 보면...

a : UserDTO [userId=109591350569372, 
             email=abb@gmail.com, 
             name=abb, 
             pictureUrl=https://lh4.googleusercontent.com/aaa/photo.jpg, 
             locale=ko, 
             familyName=a, 
             givenName=bb]

아주 깔끔하게 잘 들어갔습니다. 이렇게 email 추출이 완료되었습니다.

 

 

2. Back-End Interceptor 변경 (Refresh Token 비교)


이제는 추출된 email을 가지고 DB에서 기존에 발급된 Refresh Token과 API요청 시 Header를 통해서 전달된 Refresh Token이 일치하는지 비교하는 로직을 구현하겠습니다. 

 

해당 부분은 GoogleOAuthService에 구현을 하였으며... 기본적인 부분이라 생략하겠습니다. 해당 부분이 적용된 곳은 Interceptor에 아래와 같이 구현했습니다.

 

[JwtInterceptor]

String rt = googleOAuthService.selectRefreshToken(a.getUserInfo().getEmail());
System.out.println("rt : " + rt);

if(rt.equals(atJwtRefreshToken)) {
	System.out.println("일치합니다!!!");
}else {
	System.out.println("일치하지않습니다!!!");
}

selectRefreshToken method를 통해서 Refresh Token을 가져왔고, Header를 통해서 받은 Refresh Token과 비교를 수행합니다. 그 결과를 우선 sysout해보면... 

(아참!! 테스트를 위해서 vue.js에 API요청 시 우선 무조건 Header에 Refresh Token을 추가하게 구성해야 합니다.)

 

[결과]

rt : aaaaaaaaaa.bbbbbbbbbb.cccccccccc
일치합니다!!!

그렇다면 이제 JwtInterceptor에 본격적으로 로직을 적용하겠습니다. 

 

[JwtInterceptor]

		if(atJwtRefreshToken == null) {
			// If request header has no Refresh Token
			if(atJwtAccessToken != null && atJwtAccessToken.length() > 0) {
				// If request header has Access Token
				if(jwtService.validate(atJwtAccessToken, 0)) return true;
				else throw new IllegalArgumentException("Access Token Error!!!");
			}else {
				// If request header has no Access Token
				throw new IllegalArgumentException("No Access Token!!!");
			}			
		}else {
			// If request header has Refresh Token
			// 1. Check Refresh Token validation
			if(jwtService.validate(atJwtRefreshToken, 1)) {
				// 2. After validation, check Refresh Token in DBMS with Refresh Token in the Header
				// 2-1. Extract e-mail address from Access Token
				String accessTokenDecode = jwtService.decode(atJwtAccessToken);
				Gson gson = new Gson();
				JwtPayloadDTO jwtPayload = gson.fromJson(accessTokenDecode, JwtPayloadDTO.class);
				
				// 2-2. Extract Refresh Token in DBMS with extracted e-mail address
				String refreshTokenInDBMS = googleOAuthService.selectRefreshToken(jwtPayload.getUserInfo().getEmail());
				
				// 2-3. Refresh Token in DBMS vs Refresh Token in the Header
				if(refreshTokenInDBMS.equals(atJwtRefreshToken)) {
					// 3. recreate access token
					// ???
				}else {
					throw new IllegalArgumentException("Refresh Token Error!!!");
				}
				
				return true;
			}else {
				throw new IllegalArgumentException("Refresh Token Error!!!");
			}
		}

우선 Refresh Token이 null일 경우, 바로 Access Token을 체크하는 로직을 적용합니다. 그리고 Refresh Token이 null이 아닐경우에는... Refresh Token의 validation을 1차로 체크하고 다음로직을 적용합니다. validation을 통과 했다면... 이전에 구현한 DB내 저장되어있는 Refresh Token과 비교하고 같을경우... Access Token을 재 발급합니다.

 

세부적인 절차는 코드안에 주석을 달았으니 참조 부탁드립니다.

(저는 한글로 주석을 쓰면, eclipse에서 이쁘게 나오지가 않아서 영어서 작성하는 것을 선호합니다.)

 



 

3. Back-End Interceptor 변경 (Access Token 재발급)


이제 JwtInterceptor에서 Refresh Token이 있고, validation이 정상적으로 완료된다면... Access Token을 본격적으로 재발급해 줍니다. 방법은 은근히 엄청 간단합니다. 기존에 사용했던 method를 재활용하여 Token을 생성하고 Response를 할때, HttpServletResponse에 Header를 추가하여 진행하면 됩니다.

 

[JwtInterceptor 에 해당 부분 추가]

// 3. recreate access token
String accessJws = jwtService.createJws(accessEncodeKey, accessExpMin, jwtPayload.getUserInfo());
response.addHeader("at-jwt-access-token", accessJws);

지금까지 한 부분이라 별도의 설명없이도 이해가 가능하실 것 같습니다. 이제 남은 부분은... Front-End에서 Access Token의 교체가 발생하면, 기존의 Access Token을 걷어내고 신규 Token으로 교체하는 로직입니다.

 

 

4. Front-End 변경


 

[Vue.js]

axios.get('/qss/list', {
  headers: headers,
}).then((res) => {
  // eslint-disable-next-line
  console.log(res);
  console.log('response header', res.headers);
  if(res.headers['at-jwt-access-token'] != storage.getItem('at-jwt-access-token')){
    storage.setItem('at-jwt-access-token', "");
    storage.setItem('at-jwt-access-token', res.headers['at-jwt-access-token']);
    console.log("Access Token을 교체합니다!!!")
  }
})

의외로 간단합니다. Header에서 Access Token값을 찾아서, Session Storage의 값과 다르면 해당 값을 Session Storage에 넣어주면 끝 입니다. 그렇다면, 결과를 확인해 볼까요??

 

[결과]

Login.vue?03db:100 headers :  
{at-jwt-access-token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJBe…PIY65mkAInAjPJbdpZIjWULM_I8YutTthIUyqoDZKRVvzMcdw", at-jwt-refresh-token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJBe…zXEcT_cpCFG9k0sbGqPNZrq9eBpPaKEufDRjidbYQFUGFUYPg"}
Login.vue?03db:106 
{data: Array(452), status: 200, statusText: "", headers: {…}, config: {…}, …}
Login.vue?03db:107 response header 
{at-jwt-access-token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJBe…hfMoC5wgV6xqV9ZfqNGRIeGhkQgvOoELcP2cP_qdzaSAMp6ow", content-type: "application/json"}
Login.vue?03db:111 Access Token을 교체합니다!!!
Login.vue?03db:118 getQSSList End!!

Login.vue?03db:100 headers :  
{at-jwt-access-token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJBe…hfMoC5wgV6xqV9ZfqNGRIeGhkQgvOoELcP2cP_qdzaSAMp6ow", at-jwt-refresh-token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJBe…zXEcT_cpCFG9k0sbGqPNZrq9eBpPaKEufDRjidbYQFUGFUYPg"}
Login.vue?03db:106 
{data: Array(452), status: 200, statusText: "", headers: {…}, config: {…}, …}
Login.vue?03db:107 response header 
{at-jwt-access-token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJBe…CpzC7YQMi6iPwaBUR_3bmuIWzHhWmOKMmCSLl-dKIWZoMkMYQ", content-type: "application/json"}
Login.vue?03db:111 Access Token을 교체합니다!!!
Login.vue?03db:118 getQSSList End!!

Login.vue?03db:100 headers :  
{at-jwt-access-token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJBe…CpzC7YQMi6iPwaBUR_3bmuIWzHhWmOKMmCSLl-dKIWZoMkMYQ", at-jwt-refresh-token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJBe…zXEcT_cpCFG9k0sbGqPNZrq9eBpPaKEufDRjidbYQFUGFUYPg"}
Login.vue?03db:106 
{data: Array(452), status: 200, statusText: "", headers: {…}, config: {…}, …}
Login.vue?03db:107 response header 
{at-jwt-access-token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJBe…7-xvvyQUK-u5ihd0UUQcXvAAgShyT6pJ9sdr7_PEygoCGg8Mw", content-type: "application/json"}
Login.vue?03db:111 Access Token을 교체합니다!!!
Login.vue?03db:118 getQSSList End!!

이렇게 정상적으로 교체가 되었습니다. 이제 남은 부분은 효율적인 Front-End에서의 Token관리를 위한 axios.interceptor 와 Vuex적용입니다. 이건 좀더 고민한 후에 올려보겠습니다.

댓글