현재 간단한 인스타그램 클론 프로젝트를 구현하고 있다. 인가에 대해서는 스프링 시큐리티 파트에서 정리한 적이 있으니 그를 참고하길 바란다. 현재 내가 사용하고 있는 인가는 JWT를 통한 인가 형태이다. 이에 대해 정리하고 왜 JWT를 썼는지 알아보자.
JWT에 대한 공식 문서에 따르면 이러한 형태를 띄고 있다. HEADER + PAYLOAD + VERIFY SIGNATURE 로 구성되어 있는 것이 JWT라고 한다.
HEADER에는 해쉬된 알고리즘과 그의 타입에 대해 정의하고 있다. PAYLOAD는 유저에 대한 정보를 담고 있는 것을 확인할 수 있다. 그리고 VERIFY SIGNATURE에는 시크릿 키가 담겨 있다.
JWT를 간략하게 이해하는 법은 이 토큰을 확인이 된 티켓 정도라고 생각하면 편하다. 이 토큰이 있으면 안에 유저 정보가 있기에 서로 건네주면서 api 요청에 대해 확인하는 것이라고 보면 된다.
JWT 방식이 아닌 세션 방식으로 로그인을 구현한다면 서버의 세션을 계속 확인해야 되는 상황이 온다. 요새는 MSA다 뭐다해서 많이 분리되어 있는 상황인데 서버에 지속해서 요청을 보내는 것은 곧 부하로 이어지기 때문에 간단한 토큰 방식으로 이를 처리한 셈이다.
당연히 있다. 로그인은 곧 보안과 이어지는 길이고 토큰의 보안에 대해 제대로 처리해주지 않는다면 이는 악용되어 사용되어질 확률이 높다. 예를 들면 사용자의 토큰이 탈취되는 경우가 있을 것이다. 인가가 성공적으로 허용된 토큰을 제 3자가 탈취해 이를 사용한다면 문제가 발생한 셈이기 때문이다.
위의 문제점을 생각하고 코드를 구현해보자.
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/api/account/**", "/api/account/**", "/swagger-ui.html", "/webjars/**", "/swagger-resources/**", "/v2/**", "/swagger-ui/**").permitAll()
.antMatchers("/api/user/**").hasRole("USER")
.antMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.apply(new JwtSecurityConfig(tokenProvider));
return httpSecurity.build();
}
이 부분은 인가에 따른 처리이다. 많이 생략되어서 그렇지만 현재 유저마다 권한이 존재한다. 나는 ROLE_USER와 ROLE_ADMIN으로 이를 구분해놨다. authenticationEntryPoint 같은 경우는 접근에 따른 인가를 의미한다. 즉, 권한이 없으면 401 Unauthorized를 리턴하게 된다. accessDeniedHandler는 접근은 했으나 권한이 다를 경우나 존재하지 않는다면 403 Forbidden를 리턴하게 된다.
이 부분은 도메인에 따른 인가를 어떻게 처리할 것인지 설정하는 부분이다. hasRole의 경우는 해당 도메인 의 권한이 존재해야 되는 부분이고 생략되서 안보이지만 permitAll의 경우에는 권한이 없어도 접근이 가능하게 설정한 부분이다.
@Component
@Log4j2
public class TokenProvider implements InitializingBean {
... 생략
@Override
public void afterPropertiesSet() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public String createToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = new Date().getTime();
Date validity = new Date(now + this.tokenValidityInMilliseconds);
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
public String createRefreshToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = new Date().getTime();
Date validity = new Date(now + this.refreshTokenValidityInMilliseconds);
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
public String createAdminToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = new Date().getTime();
Date validity = new Date(now + this.adminTokenValidityInSeconds);
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
public String createAdminRefreshToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = new Date().getTime();
Date validity = new Date(now + this.adminRefreshTokenValidityInMilliseconds);
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
public Authentication getAuthentication(String token) {
Claims claims = Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
public Claims parseJwtToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
}
액세스 토큰 생성에 대한 부분이다. 여기서 토큰이라는 단어에서 액세스 토큰이라는 말로 변경했는데 그 이유는 뒤에서 설명하도록 하겠다. 이렇게만 보면 사실 이해가 좀 어려울 수 있는데 로그인하는 과정에서 스프링 빈이 먼저 UsernamePasswordAuthenticationToken이라는 것을 발행해준다.
사용자가 로그인을 해서 DB에 존재하는지부터 시작해 존재한다면 해당 토큰을 생성해 저장하고 있는다. 즉, 이 부분은 로그인을 해서 권한을 얻은 인가라고 생각하면 된다. 그것을 인증해주는 토큰을 이제 SecurityContextHolder 안에 넣는다.
다시 돌아와서, 액세스 토큰을 생성할 때 인가인 Authentication에서 권한을 꺼내오고 지금 시간과 만료될 시간을 넣어 토큰을 생성하는 것이다.
이외에 나머지 코드는 관리자 전용 토큰, 토큰을 파싱해서 얻을 유저 정보, 토큰 검증에 대한 부분이다. 토큰 생성을 중심으로 설명할 예정이라 긴 설명은 제외하겠다.
@Log4j2
@RequiredArgsConstructor
public class JwtFilter extends GenericFilterBean {
public static final String AUTHORIZATION_HEADER = "Authorization";
private final TokenProvider tokenProvider;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String accessToken = resolveToken(httpServletRequest);
String requestURI = httpServletRequest.getRequestURI();
if (StringUtils.hasText(accessToken) && tokenProvider.validateToken(accessToken)) {
Authentication authentication = tokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
} else {
log.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
}
filterChain.doFilter(servletRequest, servletResponse);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.split(" ")[1].trim();
}
return null;
}
}
우리가 만들어준 토큰에 대해 스프링은 JwtFilter를 통해 유효한지 아닌지를 검증한다. JwtFilter를 오버라이딩 해줌으로써 여러 필터가 돌 때 우리가 설정한대로 제대로 동작할 수 있는 것이다.
@Override
public ResponseEntity<TokenResponse> login(SignInRequest request) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword());
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
String accessToken = tokenProvider.createToken(authentication);
String refreshToken = tokenProvider.createRefreshToken(authentication);
redisUtil.setDataExpire(request.getEmail(), refreshToken, refreshTokenValidityInMilliseconds);
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + accessToken);
return new ResponseEntity<>(new TokenResponse(accessToken), httpHeaders, HttpStatus.OK);
}
그래서 로그인 로직을 보면 아까 내가 위에서 설명한대로 DB에 유저가 존재하는지 확인하고 있으면 1차적인 인가 토큰을 주고 유저의 인가를 담는 SecurityContextHolder에 담는다. 그리고 액세스 토큰과 리프레시 토큰을 만들어서 리프레시 토큰의 경우에는 redis에 저장한다.
여기서 리프레시 토큰을 왜 만들까?
그 이유는 보안 때문이다. 사용자에게 준 토큰은 서버가 더 이상 직접적으로 관리할 수가 없다. 이미 브라우저 밖으로 나가버렸기 때문이다. 따라서 토큰이 탈취된 것에 대해 서버는 처리해줄 수가 없다. 그래서 액세스 토큰의 시간은 짧게 두어 금방 만료시킴으로써 탈취되어도 길게는 사용하지 못하게 하는 것이다.
리프레시 토큰은 반대로 만료 시간이 길다. 하지만 브라우저 밖으로 공개하진 않고 서버가 자체적으로 들고 있음으로써 탈취된 일도 없다. 따라서 액세스 토큰이 만료되더라도 리프레시 토큰은 만료되지 않기 때문에 이를 통해 액세스 토큰을 재발급해주고 기존에 탈취당한 액세스 토큰은 만료되면 더 이상 사용할 수 없게 된다.
JWT를 사용하는 이유는 세션에 저장하지 않고 PAYLOAD에 유저 정보를 담고 서명에 시크릿 키를 적어서 요청하지 않아도 인가에 따라 요청을 할 수 있도록 해주는 것이다.
다만, 보안에 이슈가 있기 때문에 단순히 서명에 대한 시크릿 키를 찍어서 맞추기 쉽게 하거나 액세스 토큰이 탈취되는 경우도 고려해서 구현해주어야 한다.