Backend/Java

Spring Security JWT Token

비비빅B 2021. 3. 22. 20:33

서론

스프링 시큐리티 볼 때마다 이해하는데 시간 엄청 들이고 넘겼는데, 다시 만나면 제자리다...ㅠ 이번에 프로젝트 샘플코드를 복습하면서 블로그에 정리하려 한다.


Session

HTTP의 기본적인 상태는 Stateless(무상태), 즉 아무 정보도 저장하지 않는다. request를 같은 사용자가 하더라도, 할 때마다 누가 누군지 모른다는 것이다. 하지만 실제로는 대부분의 웹사이트가 그렇게 작동하지 않는다. 로그인하면 한동안 로그인이 유지되어 있고, 장바구니 같이 회원이 누군지 알아야만 서비스 할 수 있는 기능도 있다.

 

이를 위해 보통 로그인시 서버 세션에 회원의 정보를 저장해놓고, 요청과 응답에 회원 기본키를 포함시켜 회원을 구분한다. 하지만 이 방법은 서버의 자원을 사용하기 때문에 접속자가 많을 경우 서버 자원이 부족해질 수도 있고, 서버 장애시정보가 소실되면서 사용자에게 부정적인 경험을 주기 쉽다.

 

이런 단점을 보완하기 위해, IMDG(In-Memory-Data-Grid)라는 메모리 DB를 이용해 서버들의 세션들을 한 곳에서 모아 관리한다. 이를 Session-Cluster라 부른다.

여러 API Server들의 세션을 Redis Cache Cluster가 복제본과 함께 통합 관리해 준다.

이제, 특정 서버에서 문제가 생기더라도 세션 클러스터에서 조회할 수 있기 때문에 정상적으로 서비스할 수 있다.

다만 별도의 IMDG를 관리해야하고 세션 클러스터 자체에 문제가 발생시 전체 서버가 먹통이 된다는 단점이 있다.


JWT

Session말고 다른 방법은 없을까? 해싱 알고리즘을 이용해 인증 토큰을 만드는 방식이 있다.

 

회원 정보가 WAS와 독립적이기 때문에 WAS에 장애가 발생해도 다른 WAS를 통해 정상적으로 서비스를 제공할 수 있다. JWT는 JSON객체를 이용해 가볍게 통신이 가능하며, 별도의 데이터레이어없이 유효성 검사 및 사용자 식별이 가능하다. HTTP Header에 해싱된 인증토큰을 담아 전송하면 서버는 이를 식별한다.

 

JWT는 HEADERPAYLOAD, 그리고 SIGNATURE 총 3부분이 (.)으로 구분되어있다.

 

헤더에는 JWT를 어떻게 검증하는가에 대한 정보로, 암호화 알고리즘 정보다 페이로드에는 기존에 세션에 담기던 회원들의 정보같은 것들이 있지만 여기에 남들이 봐선 안되는 회원정보까지 있어서는 안된다. 왜냐하면 헤더와 페이로드는 BASE64URL-SAFE방식으로 한번 암호화가 되는데, 이 암호화 방식은 사실상 암호라고 부르기도 힘든 것이, 웹사이트로 쉽게 복호화가 가능하다. 

서명 부분은 헤더와 페이로드를 합친 문자열을 서명한 값이다. 여기서 서명이란 서버만 알고있는 비밀키를 이용해 헤더에 정의한 알고리즘으로 해싱한 후 BASE64URL-SAFE방식으로 암호화한다.

 

JWT 해싱을 위한 방식으로는 HMAC, RSA 방식이 있고 방식에 따라 인증 절차가 조금 다르다. HMAC은 비밀키만 사용하고 RSA는 공개키와 비밀키를 둘 다 사용한다. 이 글에서는 HMAC 방식으로 진행한다.

 

참고로 BASE64와  BASE64URL-SAFE의 차이는 URL에 문제가 될 수 있는 +와 /를 -와_로 대체한 것이다.

 

 

 

만약 첫 로그인 요청이 유효하다면 서버는 사용자 정보를 JWT토큰으로 만들어서 응답객체에 담아 사용자에게 반환한다. 그 이후로의 HTTP상의 통신은 무상태이지만 HEADER에 담긴 JWT토큰 정보로 사용자를 식별하는 것이다.

 

그럼 JWT 토큰을 가지고 어떻게 사용자를 식별하는 것일까?

해싱은 알다시피 단방향이다. 해싱된 값을 다시 원복하는 것을 거의 불가능하다. 하지만 똑같은 값을 똑같은 알고리즘으로 해싱한다면 그 결과 값을 같을 것이다. 이 방법을 이용해 정보의 위변조가 없음을 확인하여 사용자를 식별하는 것이다. 아까 위에서 말했듯이 JWT의 HEADER와 PAYLOAD는 BASE64URL-SAFE로 암호화 되어 있지만 쉽게 복호화 가능하다. 이 값을 이용해, 서명을 다시 한번 만들고 비교함으로써 사용자를 인증한다.


JWT와 MSA

Access Token in MSA

일반적으로 인증은 로그인은 통해 이뤄지고, 인증을 통과하면 액세스 토큰(Access Token)을 발행한다. 보통 액세스 토큰은 권한을 가르키는 임의의 문자열로 구성되어 있는데, 권한을 참조한다는 의미에서 참조 토큰(By Reference Token)이라 부른다. 모놀리스 아키텍처에서는 참조 토큰을 액세스 토큰으로 사용해도 큰 문제가 없다. 하지만 수많은 서비스 간 API 호출이 발생하는 MSA 아키텍처에서는 서버가 받는 부하는 기하급수적으로 늘어날 수 있다.

 

JWT as Access Token in MSA

JWT를 액세스 토큰으로 사용할 수 있다. JWT는 자체적으로 필요한 정보를 모두 담을 수 있기 때문에 값 토큰(By Value Token)이라 한다. JWT는 단순한 문자열이기 때문에 통신비용이 훨씬 적고 서버에 부담이 적다. 하지만 사용자에 대한 권한이나 정보가 변경되는 경우 JWT를 새로 발급해야 한다. 또한, 위에서 말했듯이 헤더와 페이로드의 내용은 노출된다면 누구나 쉽게 디코딩할 수 있으므로 민감한 정보가 JWT에 담기지 않게 유의해야 한다.


JWT in Spring Security

 

AuthenicatioinRestController에서 로그인 요청을 처리한다. Client는 AuthenticationRequest에 회원 정보를 담아 로그인을 요청하면 AuthenticationManager가 authenticate 메소드로 인증처리를 한다. authenticate 메소드는 JwtAuthenticationProvider에 정의되어 있으며, 여기서 실질적인 사용자 인증 절차가 이뤄진다.

 

public class JwtAuthenticationProvider implements AuthenticationProvider {
	
	private final Jwt jwt;
	
	private final UserService userService;
	
	public JwtAuthenticationProvider(Jwt jwt, UserService userService) {
		this.jwt = jwt;
		this.userService = userService;
	}

	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		JwtAuthenticationToken authenticationToken = (JwtAuthenticationToken) authentication;
		return processUserAuthentication(authenticationToken.authenticationRequest());
	}

	private Authentication processUserAuthentication(AuthenticationRequest request) {
		User user = userService.login(new Email(request.getPrincipal()), request.getCredentials());
		JwtAuthenticationToken authenticated = new JwtAuthenticationToken(
				new JwtAuthentication(user.getSeq(), user.getEmail()),
				null,
				AuthorityUtils.createAuthorityList(Role.USER.value()));
		String apiToken = user.newApiToken(jwt, new String[] {Role.USER.value()});
		authenticated.setDetails(new AuthenticationResult(apiToken, user));
		return authenticated;
	}

	@Override
	public boolean supports(Class<?> authentication) {
		return isAssignable(JwtAuthenticationToken.class, authentication);
	}

}

 

  1. 사용자의 로그인 요청이 담긴 AuthenticationRequest를 이용해, Authentication 객체를 만든다.
  2. 로그인 절차를 수행하고 반환된 회원 객체를 이용해 다시 Authentication 객체를 만든다.

여기서 Authenitcation을 다시 만드는 것이 조금 헷갈린다.

 

JWTAuthenticationToken은 사용자가 인증되기 전, 사용자가 인증된 후로 2가지 타입이 있다.

인증 전 생성자에는 보다시피 권한 정보가 없고, principal은 id인 email를 나타낸다.

반면, 인증 후 생성자에는 권한 정보가 있고, principal에는 JWT 페이로드에 담길 JwtAuthentication을 나타낸다.

 

그리고 생성된 토큰에 추가 정보를 입력하고 반환한다. 반환된 토큰은 AuthenticationRestController에서 SecurityContext에 인증객체로 담긴다. 여기에 담긴 인증객체는 @AuthenticationPrincipal 어노테이션으로 가져올 수 있다.

@PostMapping("signin")
public ApiResult<AuthenticationResultDto> signin(@RequestBody AuthenticationRequest authRequest) throws Exception {
	try {
    	JwtAuthenticationToken authToken = new JwtAuthenticationToken(authRequest.getPrincipal(), authRequest.getCredentials());
		Authentication authentication = authenticationManager.authenticate(authToken);
		SecurityContextHolder.getContext().setAuthentication(authentication);
		return ApiResult.OK(
        	new AuthenticationResultDto((AuthenticationResult) authentication.getDetails()));
		} catch (AuthenticationException e) {
        	e.printStackTrace();
			throw new Exception(e);
	}
}
    

@GetMapping("connections")
public ApiResult<List<ConnectedUserDto>> connections(@AuthenticationPrincipal JwtAuthentication principal) {
	return ApiResult.OK(
	userService.findAllConnectedUser(principal.id).stream()
		.map(ConnectedUserDto::new)
		.collect(Collectors.toList())
	);
}

 

로그인을 하면 SecurityContext에 인증토큰이 저장돼 필요할 때 사용할 수 있다는 것까진 알았는데, MSA와 같이 다른 어플리케이션에서 인증토큰을 공유해야되는 상황에선 어떻게 해야할까? 위에서 설명했듯이, HTTP Header에 JWT를 담아 전송하고 서버가 이를 처리할 수 있게 Filter 하나 만든다.

 

public class JwtAuthenticationTokenFilter extends GenericFilterBean {

	private static final Pattern BEARER = Pattern.compile("^Bearer$", Pattern.CASE_INSENSITIVE);
	
	private final Logger log = LoggerFactory.getLogger(getClass());
	
	private final String headerkey;
	
	private final Jwt jwt;
	
	public JwtAuthenticationTokenFilter(String headerkey, Jwt jwt) {
		this.headerkey = headerkey;
		this.jwt = jwt;
	}

	@Override
	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {
		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;
		
		if (SecurityContextHolder.getContext().getAuthentication() == null) {
			String authorizationToken = obtainAuthorizationToken(request);
			if (authorizationToken != null) {
				try {
					Jwt.Claims claims = jwt.verify(authorizationToken);
					log.debug("Jwt parse result: {}", claims);
					
					// 만료 10분 전
					if (canRefresh(claims, 6000 * 10)) {
						String refreshedToken = jwt.refreshToken(authorizationToken);
						response.setHeader(headerkey, refreshedToken);
					}
					
					Long userKey = claims.userKey;
					Email email = claims.email;
					List<GrantedAuthority> authorities = obtainAuthorities(claims);
					
					if (userKey != null && email != null && authorities.size() > 0) {
						JwtAuthenticationToken authentication = new JwtAuthenticationToken(new JwtAuthentication(userKey, email), null, authorities);
						authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
						SecurityContextHolder.getContext().setAuthentication(authentication);
					}
				} catch (Exception e) {
					log.warn("Jwt processing faild: {}", e.getMessage());
				}
			}
		} else {
			log.debug("SecurityContextHolder not populated with security token, as it already contained: '{}'",
					SecurityContextHolder.getContext().getAuthentication());
		}
		
		chain.doFilter(request, response);
	}

	private String obtainAuthorizationToken(HttpServletRequest request) throws UnsupportedEncodingException {
		String token = request.getHeader(headerkey);
		if (token != null) {
			log.debug("Jwt authorization api detected: {}", token);
			token = URLDecoder.decode(token, "UTF-8");
			String[] parts = token.split(" ");
			if (parts.length == 2) {
				String scheme = parts[0];
				String credentials = parts[1];
				return BEARER.matcher(scheme).matches() ? credentials : null;
			}
		}
		return null;
	}
	
	private boolean canRefresh(Claims claims, int refreshRangeMillis) {
		long exp = claims.exp();
		if (exp > 0) {
			long remain = exp - System.currentTimeMillis();
			return remain < refreshRangeMillis;
		}
		return false;
	}
	
	private List<GrantedAuthority> obtainAuthorities(Claims claims) {
		String[] roles = claims.roles; 
		return roles == null || roles.length == 0
			? Collections.emptyList()
			: Arrays.stream(roles).map(SimpleGrantedAuthority::new).collect(Collectors.toList());
		
	}

}

 

필터는 요청 헤더에 Bearer로 시작하는 JWT가 있는지 확인하고 이를 처리한다. 필터의 흐름을 그림으로 표현하면 아래와 같다.

 

 

  1. SecurityContext에 인증정보가 있는지 확인한다. 만약 있다면 다음 필터로 넘어간다.
  2. 인증정보가 없다면 HTTP 헤더에 JWT가 있는지 확인한다. 만약 만료 직전이면 새로 JWT 토큰을 만든다.
  3. 토큰을 SecurityContext에 저장하고 사용한다.

결론

로그인시 JWT토큰을 만들어서 클라이언트에게 응답으로 보낸다. 이후 요청은 HTTP HEADER에 담긴 JWT를 보고 위변조 유무를 확인해 사용자를 식별한다.

 

샘플코드를 보면서 따라 치기만 했는데도 이해하기 어려웠다. 그림으로 나름대로 정리하니 대충 흐름은 알 것 같은 느낌? 나만 알아볼 수 있게 생략한 게 많아서 다른 사람들에게 도움이 될 진 모르겠지만, 정리하고 나니 뿌듯하다.


 

Spring에서 HMAC-SHA256 인증해보기

이번에 외부 시스템과 연동을 진행하면서, 인증을 HMAC Signature 로 하게 되었는데요. HMAC이 가물가물해서 =) HMAC에 대한 간략한 소개와, Spring에서 어떻게 requestBody를 받아와서 HMAC Signature로 만드는

juneyr.dev

 

[ springboot ] JWT + 스프링부트

JSON Web Token 에 대해 알아보자

velog.io

프로그래머스 스쿨 강의 - 단순 CRUD는 그만! 웹 백엔드 시스템 구현(Spring Boot)