스터디/리얼 월드 스터디 회고

리얼 월드 스터디 회고 - 6 ) Spring Security + JWT

딱구킴 2023. 1. 5. 14:02

구현 주제 : Spring Security에 JWT 접목 해보기

JWT는 유명세 탓인지 구현되어 있는 예제들이 많기도 하고, 예전에 OAuth2.0에 JWT를 접목해서 구현해본 경험이 있어 구현 자체는 어렵지 않았다. (일단은 AccessToken만 구현하였다.)

 

대략적인 플로우는 아래와 같이 구성하였다.

로그인에 실패하는 경우

  • 로그인에 실패하는 경우에는 자동적으로 재 로그인을 하도록 되어있다. SpringSecurity가 제공하는 기본 양식을 사용했다.

로그인에 성공하는 경우

  • 로그인에 성공하게 되면 JWT를 발급하여 헤더에 넣어 결과를 내려준다.
    • 테스트할 때 보기 편하도록 ObjectMapper를 이용해서 토큰 결과를 JSON 형식으로 화면에 내려주도록 구성했다.
  • 토큰이 발급되면, 이후 인증은 JWT로 수행하게 된다.
    • 따라서, Session 전략은 Stateless로 구성하였다.

 

구현 내용

SecurityFilterChain 구성

@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
    private final CustomLoginSuccessHandler customLoginSuccessHandler;
    private final JwtProvider jwtProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.sessionManagement(  // (1)
                config -> config.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        http.formLogin(config -> config
                .successHandler(customLoginSuccessHandler));  //(2)

        http.authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/api/posts", "/api/members").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
                .anyRequest().authenticated());

        http.addFilterBefore(new JwtAuthenticationFilter(jwtProvider),
                UsernamePasswordAuthenticationFilter.class);  //(3)

				http.addFilterBefore(new JwtExceptionFilter(new ObjectMapper()),
                JwtAuthenticationFilter.class);  //(4)

        return http.build();
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring()
                .requestMatchers(PathRequest.toH2Console());
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
  • (1) : 로그인 성공 이후에는 JWT로 인증을 할 수 있도록 sessionCreationPolicy는 STATELESS로 설정
  • (2) : formLogin이 성공하면 JWT 토큰을 내려줄 수 있도록 CustomSuccessHandler를 구성
  • (3) : JwtAuthenticationFilter를 구현하여, UsernamePasswordAuthenticationFilter 앞에 위치하도록 설정. (토큰이 발급된 후에는 해당 필터로 인증 수행)
  • (4) : 스프링 시큐리티는 ExceptionHandling을 위한 설정과, AuthenticationEntryPoint를 제공하여 예외를 핸들링할 수 있는 기능을 제공함. 하지만, 메세지를 커스터마이징 하는 방법을 찾지 못해서, 콘솔에 스택 트레이스가 출력되는 것을 막는 방법을 알 수 없어서 위와 같이 Exception을 핸들링할 수 있는 Filter를 따로 구성하였음.
    • 필터는 Filter1 → Filter2 → Filter3 → Filter4 … 와 같이 순서대로 동작함
    • 만약에 Filter3에서 예외가 터졌을 경우, 반대로 예외가 던져진다. 즉, Filter3 → Filter2 → Filter1로 예외가 던져짐.
    • 따라서, 예외가 발생할 수 있는 필터의 앞쪽에 예외 핸들링을 위한 필터를 두고, 이 예외를 try-catch로 잡아서 objectMapper를 이용해서 JSON 형태로 예외 메세지를 뿌려주도록 구성해보았다.

 

JwtProvider 구현

  • JWT를 발급해주고, 클라이언트로 부터 전달 받은 JWT를 검증하는 로직을 갖고 있음
package com.realworld.study.auth.jwt;

import com.realworld.study.auth.Role;
import com.realworld.study.auth.util.UsernamePasswordAuthUtils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecurityException;
import jakarta.servlet.http.HttpServletRequest;
import java.security.Key;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

@Component
public class JwtProvider {
    private static final String AUTHORITIES_KEY = "authority";
    private static final String BEARER = "Bearer ";
    private static final String BLANK = "";

    private final long expireTime;
    private final Key key;

    public JwtProvider(
            @Value("${jwt.token.secret-key}") String secretKey,
            @Value("${jwt.token.expire-length}") long expireTime
    ) {
        this.expireTime = expireTime;

        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        key = Keys.hmacShaKeyFor(keyBytes);
    }

		// 토큰 발급 로직
    public String accessToken(Authentication authentication) {
        String email = UsernamePasswordAuthUtils.getEmail(authentication);
        Date expireTime = getExpireTime();

        return Jwts.builder()
                .setSubject(email)
                .claim(AUTHORITIES_KEY, Role.USER.key())
                .setExpiration(expireTime)
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();
    }

    private Date getExpireTime() {
        long now = new Date().getTime();
        return new Date(now + expireTime);
    }

		// 요청 헤더에서 토큰을 받아 토큰 값을 가져오는 로직 
    public String resolveTokenFrom(HttpServletRequest request) {
        String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER)) {
            return bearerToken.substring(BEARER.length());
        }

        return BLANK;
    }

    public boolean validateJwt(String jwt) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(jwt);
            return true;
        } catch (ExpiredJwtException e) {
            throw new JwtNotAvailable("토큰이 만료되었습니다. 다시 로그인 해주세요.");
        } catch (SecurityException | MalformedJwtException | UnsupportedJwtException e) {
            throw new JwtNotAvailable("올바르지 않은 토큰입니다.");
        }
    }

    public Authentication authentication(String jwt) {
        Claims claims = claims(jwt);
        String email = claims.getSubject();

        List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList(
                claims.get(AUTHORITIES_KEY).toString());

        User user = new User(email, BLANK, authorities);
        return new UsernamePasswordAuthenticationToken(user, BLANK, authorities);
    }

    private Claims claims(String jwt) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build()
                    .parseClaimsJws(jwt).getBody();
        } catch (ExpiredJwtException e) {
            throw new JwtNotAvailable("토큰이 만료되었습니다. 다시 로그인 해주세요.");
        }
    }
}
  • 체크 포인트
    • HS512를 이용하므로, secretKey 문자열은 64byte 이상이 되도록 구성해야 한다.
    • 해시← 라는 내용에 대한 공부가 필요하다. 스프린트 때 “해시”관련된 얘기를 많이 나눴는데, 공부해야 할 포인트들을 많이 얻었다.
      • 해시가 어떻게 계산되고, 어떤 용도로 사용되는가
      • 해시 맵에서 해시 충돌이 일어날 경우, 어떤 방식으로 대처하도록 구성하였는가(자바)
  • JwtNotAvailable 예외는 그냥 RuntimeException을 상속한 커스텀 예외다.

 

CustomLoginSuccessHandler

  • UsernamePassword 폼 로그인이 성공하면 JWT를 발급해주는 핸들러
package com.realworld.study.auth.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.realworld.study.auth.handler.dto.AccessTokenResponse;
import com.realworld.study.auth.jwt.JwtProvider;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

@RequiredArgsConstructor
@Component
public class CustomLoginSuccessHandler implements AuthenticationSuccessHandler {
    private final JwtProvider jwtProvider;
    private final ObjectMapper objectMapper;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws IOException, ServletException {
        String accessToken = jwtProvider.accessToken(authentication);
        response.setHeader(HttpHeaders.AUTHORIZATION, accessToken);
//        response.sendRedirect("/api/posts");  // Redirect 해버릴 경우 받는 측(프론트)에서 토큰을 못잡아낼 것 같음
        objectMapper.writeValue(response.getWriter(), new AccessTokenResponse(accessToken));
    }
}
  • 발급 해준 후에, 헤더에 심어주도록 구성했다. 또한, objectMapper를 이용해서 JSON형태로 화면에 뿌려줄 수 있도록 구성했다. (테스트를 편리하게 하기 위한 구성)

 

JwtAuthenticationFilter

  • 토큰이 발급된 이후에는 토큰으로 인증할 수 있도록 필터를 구성했다.
package com.realworld.study.auth.jwt;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        String jwt = jwtProvider.resolveTokenFrom(request);
        if (StringUtils.hasText(jwt)) {
            setAuthenticationOrNot(jwt);
        }

        filterChain.doFilter(request, response);
    }

    private void setAuthenticationOrNot(String accessToken) {
        if (jwtProvider.validateJwt(accessToken)) {
            Authentication authentication = jwtProvider.authentication(accessToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            return;
        }

        logger.info("JWT 미인증 사용자 입니다.");
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        return request.getRequestURI().equals("/login");
    }
}
  • OncePerRequestFilter를 사용했는데, shouldNotFilter를 사용하기 위함이다. “/login” 엔드포인트는 JWT 필터가 적용 될 필요가 없기 때문이다.

 

JwtExceptionFilter

  • 예외 처리를 하기 위한 필터다.
  • JwtAuthenticationFilter에서 예외가 터지면 해당 필터가 처리할 수 있도록 JwtAuthenticationFilter 앞에 오게 구성했다.
package com.realworld.study.auth.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.realworld.study.exception.presentation.dto.ExceptionResponse;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;

@RequiredArgsConstructor
public class JwtExceptionFilter extends OncePerRequestFilter {
    private final ObjectMapper objectMapper;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        try {
            filterChain.doFilter(request, response);
        } catch (JwtNotAvailable e) {
            ExceptionResponse exceptionResponse = new ExceptionResponse(
                    HttpStatus.UNAUTHORIZED.name(), e.getMessage());

            responseSetup(response);
            objectMapper.writeValue(response.getWriter(), exceptionResponse);
        }
    }

    private void responseSetup(HttpServletResponse response) {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
    }
}
  • 예외를 잡아 JSON 형태로 결과를 아래와 같이 뿌려준다.
{
    "code": "UNAUTHORIZED",
    "message": "토큰이 만료되었습니다. 다시 로그인 해주세요."
}

마무리

이번 스프린트에서는 큰 소득이 있었다. 현준님의 캐리로 해시라는 것에 대해 알 수 있었고, “자기가 사용하는 언어의 자료구조 정도는 알고 써라”라는 명언도 득템했다. 특히, ArrayList가 왜 사이즈가 1.5배로 증가하는가? 에 대해, “바이너리 서치를 사용하기 쉽게 하기 위함이 아닐까??”라는 의외의 인사이트를 얻게 되었다.

 

게다가, 종립님 팬이 한분 계셔서 홀린 듯이 종립님의 블로그까지 찾아보게 되었는데, 그 중에서도 아래의 글이 너무 끌렸다. 시간내서 봐야하는 좋은 글 같고, 공부를 어떻게 해야하는지도 대략적으로 알 수 있게 잘 설명해주신 듯 하다. 본받고싶다!

JVM 메모리 구조와 GC

 

게다가, Armeria + Netty 라는 시스템이 어떤식으로 동작하는지도 알게 될 수 있었는데(대화하다 보니 어느새 여기까지..) 너무 흥미로운 주제였다. 언젠가는 꼭 써볼 기회가 있었으면 좋겠다. 또한, 이희승님은 너무 대단하다는 것도 다시한번 깨닫는 기회가 되었다.(아무래도 팬이 되어버린듯)

 

마지막으로, OncePerRequestFilter에 대한 토론도 나누게 되었는데, 세상에는 하나의 문제를 바라보는 다양한 시각이 있음을 다시 한번 깨달았다. 그리고 서로 질문도 하고 응답도 하게 되면서, 건설적인 토론을 할 수 있다는 것이 정말 좋았던 것 같다.