본문 바로가기
SpringBoot

[Spring Boot] 32. JWT 인증 갱신처리 (1)

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

지금까지 구현한 JWT를 통한 Google OAuth연동 구현과 관련하여, JWT Token을 사용자에게 잘 발급하여 인증체크 및 만료에 대해서 정상적으로 구현이 완료되었습니다. 하지만 현재는 Token의 Expiration기간이 최초에 설정되면 이 Token에 대한 유효기간이 만료되면 다시 로그인하여 Token을 발행해야하는 로직으로 동작하게 됩니다. 

하지만, 이렇게 되면 사용자의 입장에서는 해당 web application이 아무리 좋은 정보를 제공한다고 하더라도 불편함이 더 크게 인식되어 접근을 꺼려하게 됩니다. 따라서, 정해진 절차에 따라서 Token이 만료가 되었을 경우 기간을 연장하여 재 발행하는 로직이 필요합니다.

이를 위해서 보편적으로 사용하는 방식이 Access Token과 Refresh Token에 대한 관리입니다. 우선 사상을 간단하게 설명하자면, 사용자가 최초 Login시 Access Token과 Refresh Token을 동시에 생성하여 사용자에게 전달합니다. 이때, Refresh Token은 상대적으로 Expiration을 길게 설정합니다. 사용자가 API를 요청할 때, Back-End에서는 Refresh Token을 체크하여 Access Token을 재 발급하여 사용자에게 다시 전달하여, 만료기간을 연장해 줍니다.

이제 대한 상세 구현에 대한 동작로직을 알아보겠습니다.

 

 

1. 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. Token 생성


현재 JwtService의 create method의 변경이 필요합니다. 기존에는 단일 Token을 생성해서 사용자에게 전달을 하였지만, 이제는 각기다른 2개의 Token을 생성해서 create method의 결과로 사용자에게 전달을 해야 합니다. 이를 위해서 추가 및 변경되는 코드는 아래와 같습니다. 

 

  • Access Token: Google Oauth를 통해서 전달받은 사용자 정보를 claim에 추가하고, Token의 만료기간은 30분으로 설정
  • Refresh Token: 사용자 정보에 대한 claim은 추가하지 않고, Token의 만료기간만 7일인 10,080분으로 설정

 

[Token DTO 생성]

public class TokenDTO {
	
	private String accessJws;
	private String refreshJws;
    
}

JwtService에서 Token을 create해주고 2개값을 한번에 넘겨주기 위해서, 별도의 DTO를 생성합니다.

 

[JwtService create( ) 변경]

	public TokenDTO create(UserDTO userInfo) {
		
		// Create Access Token
		String accessJws = createJws(accessEncodeKey, accessExpMin, userInfo);
		
		// Create Refresh Token
		String refreshJws = createJws(refreshEncodeKey, refreshExpMin, null);
		
		TokenDTO tokens = new TokenDTO();
		tokens.setAccessJws(accessJws);
		tokens.setRefreshJws(refreshJws);
		
		return tokens;
        
	}
    
	private String createJws(String encodeKey, Integer expMin, UserDTO userInfo) {
		
		SecretKey secretKey = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(encodeKey));
		
		//JWT Builder create
		JwtBuilder builder = Jwts.builder();
		
		// header configuration
		builder.setHeaderParam("typ", "JWT");
		
		// claim configuration
		builder.setIssuer("AyoteraLab");
		builder.setSubject("AT");
		builder.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * expMin));
		builder.setIssuedAt(new Date());
		if(userInfo != null) {
			builder.claim("userInfo", userInfo);
		}
		
		// signature configuration
		builder.signWith(secretKey);
		String jws = builder.compact();
		
		return jws;		
		
	}

기존에 작성한 JwtService의 create method에서 Token을 생성하는 로직이 같고, expiration과 claim의 추가여부에 따라서 다른 Token을 생성해야 하기 때문에 별도의 createJws method를 생성하였습니다. 호출 시 expiration 기간과 claim값 정보만 다르게 넣어서 생성한 결과를 return 합니다.

 

[결과]

at-jwt-access-token : eyJ0eXAiOiJKV1QiLCJhiJIUzUxMiJ9.eyJpc3MiOiJBeW90ZXJhTGFiIiwic3LCJleHAiOjE2MTkwNDg3MzcsImlhdCI6MTYxOTA0NjkzNywidXNlckluZm8iOnsiZW1haWwiOiJzc2F5b3VuZzJAZ21haWwuY29tIiwibmFtZSI6IuuwleywrOyYgSIsInBpY3R1cmVVcmwiOiJodHRwczovL2xoNC5nb
at-jwt-refresh-token : eyJ0eXAiOiJKV1QiLCJhiJIUzUxMiJ9.eyJpc3MiOiJBeW90ZXJhTGFiIiwic3LCJleHAiOjE2MTk2NTE3MzcsImlhdCI6MTYxOTA0NjkzN30.lEIoAiKldzR5xz1AC48lGrl0CUDy8F-dz5VXKbNblP7mMCFwIHoVhZyssFvgGWbPYcq-kb4r4AMx-

정상적으로 생성이 되었으며, 해당 Token정보가 정상인지 jwt.io에서 decode를 해서 확인해 보겠습니다. 

 

[Token decode]

Access Token의 경우 iat(생성시간)과 exp(만료시간)의 차이가 1800s 이기 때문에, 30분이 맞게 설정되어 있습니다. 

 

Refresh Token의 경우 iat(생성시간)과 exp(만료시간)의 차이가 604800s 이기 때문에, 7일이 맞게 설정되어 있습니다.  

 



 

3. Front-End로 생성된 Tokens 전달


이제 2가지 Token이 정상적으로 생성이 완료되었기 때문에, API요청의 response Header에 추가하여 Front-End로 전달하겠습니다. 이를 위해서는 JwtService로 요청을 해서 2가지 Token을 받는로직과, Controller에서 해당 API의 response Header의 수정, addCorsMappings에서 header 추가 등이 있습니다. 그럼 차례로 알아보겠습니다. 

 

[API Response Header 추가]

	@RequestMapping(value = "tokenVerify", method = RequestMethod.POST)
	public ResponseEntity<UserDTO> tokenVerify(String idToken, HttpServletResponse res){
		System.out.println("RequestBody value : " + idToken);
		
		// Google idToken에 대한 verification 진행
		UserDTO userInfo = googleOAuthService.tokenVerify(idToken);
		
		System.out.println(userInfo);
		
		if(userInfo.getEmail() != null) {
			// email 기준으로 user_info에 없으면 추가, 있으면 update
			googleOAuthService.insertUpdateUserInfo(userInfo);
			
			TokenDTO tokens = jwtService.create(userInfo);
			System.out.println("at-jwt-access-token : " + tokens.getAccessJws());
			System.out.println("at-jwt-refresh-token : " + tokens.getRefreshJws());
			res.addHeader("at-jwt-access-token", tokens.getAccessJws());
			res.addHeader("at-jwt-refresh-token", tokens.getRefreshJws());
		}
		
		return ResponseEntity.ok(userInfo);
	}

Access-Token과 Refresh-Token을 각각 Header에 추가하여 전달하도록 구성하였습니다. 하지만, 이렇게 추가만 하면 Front-End에서는 아무것도 보이지 않을 것 입니다. 왜냐하면, 기존에 구성한건 Access-Token 1개만 전달을하고 기존과 다른 이름으로 Header를 구성해서 보냈기 때문입니다. 때문에 아래 추가 변경 구성이 필요합니다.


[WebMvcConfigurer]

	@Override
	public void addCorsMappings(CorsRegistry registry) {
		registry.addMapping("/**")
			.allowedOrigins("http://localhost:8080")
        	.allowedMethods("GET", "POST")
        	.exposedHeaders("at-jwt-access-token", "at-jwt-refresh-token")
	}

exposedHeaders에 2가지 Header명을 추가하였기 때문에, axios를 통해서 response에 대한 console.log를 확인해 보면 두가지 값이 보이게 됩니다. 


[axios response 추가]

      axios.post(url, params).then((res) => {
        // eslint-disable-next-line
        console.log('response data', res.data);
        // eslint-disable-next-line
        console.log('response header', res.headers);
        storage.setItem('at-jwt-access-token', res.headers['at-jwt-access-token']);
        storage.setItem('at-jwt-refresh-token', res.headers['at-jwt-refresh-token']);

단순하게... Session Storage에 2개를 각각 추가해 주었습니다.

 

[결과]

이렇게 정상적으로 저장이 되었습니다. 현재 보이는 값이 같은거 아닐까?? 라는 생각을 할 수 있지만... 2개의 Token의 Header부분의 값이 동일하기 때문입니다. 뒤에 Claims 및 Signature는 내용도 다르고 secret key도 다르기 때문에 다른 값이 보입니다.

 

 

4. Refresh Token의 DB 저장


Google OAuth를 통해서 사용자가 처음 인증을 진행하고, Back-End로 Verification을 진행하게 됩니다. Back-End에서는 정상적으로 API를 통해서 확인이 완료되면 함께 전달되는 Payload를 가지고, 자체 user_info table에 정보를 저장하게 됩니다. 

 

해당 user_info는 아래와 같이 구성을 하겠습니다. 

 

[user_info table]

CREATE TABLE user_info (
  user_id VARCHAR(50) primary key,
  email VARCHAR(100),
  name VARCHAR(60),
  picture_url VARCHAR(200),
  locale VARCHAR(30),
  family_name VARCHAR(30),
  given_name VARCHAR(30),
  refresh_token VARCHAR(1000)
);

Payload로부터 전달되는 대부분의 정보가 포함되어 있으며, 추가적으로 refresh_token을 저장하기 위한 공간이 있습니다. 앞으로 JWT 중 Refresh Token이 발급되면 해당 필드에 update를 수행하면됩니다. 해당 로직들은 SignIn을 수행하는 로직에 포함되기 때문에... 관련 Controller를 수정하겠습니다. 

 

[GoogleOAuthController]

	@RequestMapping(value = "tokenVerify", method = RequestMethod.POST)
	public ResponseEntity<UserDTO> tokenVerify(String idToken, HttpServletResponse res){
		System.out.println("RequestBody value : " + idToken);
		
		// Google idToken에 대한 verification 진행
		UserDTO userInfo = googleOAuthService.tokenVerify(idToken);
		
		System.out.println(userInfo);
		
		if(userInfo.getEmail() != null) {
			// email 기준으로 user_info에 없으면 추가, 있으면 update
			googleOAuthService.insertUpdateUserInfo(userInfo);
			
			TokenDTO tokens = jwtService.create(userInfo);
			System.out.println("at-jwt-access-token : " + tokens.getAccessJws());
			System.out.println("at-jwt-refresh-token : " + tokens.getRefreshJws());
			res.addHeader("at-jwt-access-token", tokens.getAccessJws());
			res.addHeader("at-jwt-refresh-token", tokens.getRefreshJws());
			
			// email 기준으로 대상 user에게 할당된 refresh token update
			googleOAuthService.updateRefreshToken(userInfo.getEmail(), tokens.getRefreshJws());
		}
		
		return ResponseEntity.ok(userInfo);
	}

 

위에서 Token을 Front-End로 전달하는 Controller에서 한가지만 추가되었습니다. updateRefreshToken method를 통해서 사용자의 email을 기준으로 발급된 Refresh Token을 update하는 것 입니다.

 

기본적인 Controller - Service - Mapper구조이기 때문에, 가장 최단 부분의 mapper.xml만 확인해 보겠습니다. 

 

[mapper.xml]

<update id="updateRefreshToken">
<![CDATA[
	UPDATE user_info
	SET refresh_token = #{refreshJws}
	WHERE email = #{email};
]]>
</update>

[결과]

이렇게 2 ~ 5번 과정에 대한 구현이 완료되었습니다. 다음에 나머지 6 ~ 8번 과정에 대해서 구현해 보겠습니다. 

 

- Ayotera Lab -

댓글