Backend/Spring

[예약 구매 프로젝트]Spring Security - JWT 적용

DooDuZ 2024. 6. 26. 14:01

Stateless

이번 프로젝트를 진행하면서 가장 많이 한 생각은 서버 리소스를 어떻게 하면 조금이라도 아낄 수 있을까 하는 것 이었습니다. 동 시간대 수만명의 동시 접속을 고려해야하는 상황에서 로직 하나, 스레드 하나의 비용을 최소화 시켜야 했습니다. 이번엔 서버 리소스를 아끼는 첫 번째 관문 Stateless 로그인에 대해 다룹니다. Spring Security와 JWT를 사용하여 구현했습니다.

 

 

 

[Spring Security] Session vs Token

Session - StatefulSpring Security는 기본적으로 formLogin을 통한 Session 방식 인증/인가를 지원합니다. 다른 처리 없이 UserDetails와 UserDetailsService를 구현하고 로그인하면 JSESSIONID라는 쿠키가 발급되는 걸 볼

dooduz.tistory.com

 

 

 

구현

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final LoginSuccessHandler loginSuccessHandler;
    private final AuthenticationConfiguration authenticationConfiguration;

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
        http.
                authorizeHttpRequests( authorizeRequests ->
                        authorizeRequests
                                .requestMatchers(
                                        // some urls...
                                ).permitAll()
                                .requestMatchers(HttpMethod.POST, "/member").permitAll()
                                .anyRequest().hasAnyRole("BASIC", "SELLER","ADMIN")
                )
                .sessionManagement( sessionManagement ->
                        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(loginAuthenticationFilter(authenticationConfiguration.getAuthenticationManager()), UsernamePasswordAuthenticationFilter.class)                
                .formLogin( formLogin -> formLogin.disable())
                .csrf(csrf -> csrf.disable());

        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public LoginAuthenticationFilter loginAuthenticationFilter(AuthenticationManager authenticationManager) {
        LoginAuthenticationFilter filter = new LoginAuthenticationFilter(authenticationManager, loginSuccessHandler);
        filter.setFilterProcessesUrl("/login");
        return filter;
    }
}

 

먼저 Security Config입니다. Security 기본 인가 필터인 UsernamePasswordAuthenticationFilter의 앞단에 토큰 인증을 위한 JwtAuthenticationFilter와 커스텀 LoginFilter를 배치합니다.

 

@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtTokenProvider jwtTokenProvider;

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

        String token = jwtTokenProvider.resolveToken(request);

        // 서명 확인
        Jws<Claims> claims = jwtTokenProvider.validateToken(token);

        if (token != null && claims != null) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }else if(token != null && claims == null){
            // 리프레시 토큰으로 재요청 하라고 전달
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json");
            response.setCharacterEncoding("UTF-8");

            ObjectMapper objectMapper = new ObjectMapper();
            String errorMessage = AuthMessage.INVALID_TOKEN.getMessage();

            response.getWriter().write(objectMapper.writeValueAsString(errorMessage));
            return;
        }

        filterChain.doFilter(request, response);
    }
}

 

다음은 Jwt 인증 필터입니다. 토큰이 인입되었을 때 서명이 유효하고 토큰이 존재한다면 SecurityContextHolder에 인증 정보를 저장해줍니다. 이 경우 SecurityContextHolder에 인증 정보가 저장되었기 때문에 doFilter로 뒤의 필터들이 실행되더라도 처리된 인증으로 간주하고 별다른 영향을 끼치지 않습니다.

 

액세스 토큰 인증 성공 시

 

그러나 토큰이 존재하는데 서명이 유효하지 않거나 만료되어 Claims가 null으로 반환된 경우 인증은 거절됩니다. 어떻게 전달하는 게 일반적 일지 고민을 많이 했는데 일단은 응답코드 401에 토큰 만료 메시지를 담아주었습니다. 이후 doFilter가 실행되더라도, response가 이미 빌드 되었으므로 뒤의 필터는 응답에 영향을 끼치지 못하고 설정한 메시지가 응답됩니다. 다만 지금은 굳이 뒤의 필터를 실행할 이유가 없기 때문에 return하여 불필요한 로직이 실행되지 않도록 처리했습니다.

 

더보기

Filter와 Bean

 

여기서 필터를 Bean으로 등록해서 써야할지 고민을 많이 했습니다. Bean은 싱글톤으로 관리되는데, 스레드는 요청마다 할당되어 병렬로 처리되기 때문에 싱글톤 사용 시 요청 병목이 생길 거라고 생각했습니다. 이번에 JWT 인증을 구현하면서 서버의 stateless함을 유지하는 방법과 함께 가장 많이 고민한 문제인 것 같습니다.

 

꽤 오래 고민하고 다른 곳에 자문을 구하고 내린 결론은, Bean이 싱글톤인 것과 여러 스레드에서 요청이 들어오는 건 '큰 상관이 없다'입니다. 되려 요청마다 new 연산으로 필터를 생성하면 그 또한 비용이기 때문에 성능상 유리하지 않고, 싱글톤의 동시성 이슈는 Bean을 사용할 때 되려 안전하게 컨트롤 할 수 있다는 생각을 하게 됐습니다.

 

결론은

필터에서 요청 병목이 생긴다면 싱글톤 패턴의 문제가 아니라 컴퓨팅 리소스 이슈일 가능성이 높다!

username & password Login

 

이번엔 토큰 없이 로그인하는 경우입니다. 위 시퀀스는 제가 구현한 내용을 이미지화한 것이기 때문에, 다른 분들의 구현 방식과 차이가 있을 수 있습니다.

 

token이 존재하지 않는 로그인 요청은 doFilter를 통해 LoginAuthenticationFilter로 넘어가게 됩니다. 

더보기

1. UsernamePasswordAuthenticationFilter를 상속한 LoginAuthenticationFilter에서 유저 검증을 시작합니다.
2. AuthenticationManager를 호출합니다. 일반적으로 사용되는 구현체는 ProviderManager입니다.
3. AuthenticationManager는 요청에 맞는 Provider를 찾아 작업을 위임합니다. 일반적으로 DaoAuthenticationProvider가 사용됩니다.

4. DaoAuthenticationProvider에서 UserDetailsService의 loaduserByUsername을 호출합니다.

5. UserDetailsService는 DB와 통신하여 사용자 데이터를 읽어오고 해당 사용자의 권한을 담은 UserDetails로 가공하여 결과를 반환합니다.

6. DaoAuthenticationManager에서 패스워드 검증이 진행됩니다.

7. 검증 성공 시 1의 LoginAuthenticationFilter로 돌아가서 LoginSuccessHandler가 호출됩니다.

     → 이때 반환 받은 UserDetails가 SecurityContextHolder에 저장됩니다.

8. LoginSucessHandler에서 JwtAuthService의 login메서드가 호출됩니다.

    → handler 내부에서 토큰 발급을 진행할 수도 있지만, 토큰 관련 기능이 여러개 있어 이를 묶어서 처리하는 service를 구현해 사용했습니다.

9. JwtAuthService에서 JwtProvider를 통해 토큰을 생성합니다.

10. 생성한 토큰을 사용자에게 반환합니다.

 

public class LoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final LoginSuccessHandler loginSuccessHandler;

    public LoginAuthenticationFilter(AuthenticationManager authenticationManager,
                                     LoginSuccessHandler loginSuccessHandler) {
        this.loginSuccessHandler = loginSuccessHandler;
        super.setAuthenticationManager(authenticationManager);
        setFilterProcessesUrl("/login");
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        return super.attemptAuthentication(request, response);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
                                            Authentication authResult) throws IOException, ServletException {
        loginSuccessHandler.onAuthenticationSuccess(request, response, authResult);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                              AuthenticationException failed) throws IOException, ServletException {
        super.unsuccessfulAuthentication(request, response, failed);
    }
}

 

먼저 UsernameAuthenticationFilter를 상속하여 만든 커스텀 필터 LoginAuthenticationFilter의 구현 코드입니다. setFilterProcessUrl에 지정한 URL로 오는 요청을 가로채서 처리합니다. 기존 부모 클래스의 attemptAuthentication를 호출하여 유저 정보 검증을 진행합니다. 검증 과정은 기존 필터와 완전히 동일하지만, 로그인 성공 시 Token발급 로직을 추가하기 위해 커스텀 successHandler가 사용됩니다.

@Component
@RequiredArgsConstructor
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final JwtAuthServiceImpl jwtAuthService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
        jwtAuthService.login(request, response, authentication);
        // super.onAuthenticationSuccess를 호출하면 "/"로 리다이렉트
        clearAuthenticationAttributes(request);
    }
}

 

 

사용자 인증에 성공하면 호출되는 successHandler입니다. 처음엔 여기서 JwtTokenProvider를 주입받아 토큰을 생성하고 헤더에 담아주는 작업을 처리했었는데, 리프레시 토큰을 통한 액세스 토큰 재발급 등을 처리하는 서비스가 필요해서 해당 기능을 service로 이관했습니다. 토큰을 http 헤더에 담을 때 리다이렉트가 테스트를 어렵게 만들어서, 현재는 막아 clearAuthenticationAttributes로 리다이렉트를 막아주고 있습니다.

 

@Service
@RequiredArgsConstructor
public class JwtAuthServiceImpl implements JwtAuthService {

    private final JwtRedisRepository jwtRedisRepository;
    private final JwtTokenProvider jwtTokenProvider;

    private final String accessTokenCookieName = "access_token";

    private final int accessTokenExpirySecond = 60 * 30;
    private final TokenUsernameEncoder tokenUsernameEncoder;
    private final String USER_AGENT_KEY = "User-Agent";
    //

    // controller가 아닌 usernamePasswordAuthenticationFilter -> loginSuccessHandler 에서 접근
    @Override
    public void login(HttpServletRequest request, HttpServletResponse response,
                      Authentication authentication) {
        String username = authentication.getName();
        String role = authentication.getAuthorities().iterator().next().getAuthority();

        MemberEntity memberEntity = (MemberEntity) authentication.getPrincipal();

        Long memberId = memberEntity.getMemberId();

        TokenWrapper tokenWrapper = generateTokens(username, role, String.valueOf(memberId), request, response);

        try {
            ObjectMapper objectMapper = new ObjectMapper();

            response.setStatus(HttpServletResponse.SC_OK);
            response.setContentType("application/json");
            response.getWriter().write(objectMapper.writeValueAsString(tokenWrapper));
        } catch (Exception e) {
            throw new MemberException(MemberResponseMessage.FAIL_CONVERT_TO_JSON, e);
        }
    }

    @Override
    public void logout(UserDetails userDetails, HttpServletRequest request, HttpServletResponse response) {
        String accessToken = jwtTokenProvider.resolveToken(request);

        try {
            // 엑세스 토큰에서 유저 이름 추출 -> redis key로 저장된 형식으로 인코딩
            String key = tokenUsernameEncoder.encrypt(jwtTokenProvider.getUsername(accessToken));
            String userAgent = request.getHeader(USER_AGENT_KEY);
            jwtRedisRepository.deleteUserAgent(key, userAgent);

            setCookie(response, accessTokenCookieName, null, 0);
        } catch (Exception e) {
            throw new AuthorizationException(AuthMessage.INVALID_TOKEN, e);
        }
    }


    @Override
    public void refreshAccessToken(HttpServletRequest request, HttpServletResponse response) {
        String refreshToken = jwtTokenProvider.resolveRefreshToken(request);

        // 리프레시 토큰이 유효하지 않거나, 레디스에 존재하지 않으면
        if (jwtTokenProvider.validateRefreshToken(refreshToken) == null || !isManaged(refreshToken)) {
            throw new AuthorizationException(AuthMessage.INVALID_TOKEN);
            //return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(MemberResponseMessage.INVALID_TOKEN);
        }

        String username = jwtTokenProvider.getUsernameByRefresh(refreshToken);
        String role = jwtTokenProvider.getRoleByRefresh(refreshToken);
        String memberId = jwtTokenProvider.getRefreshMemberId(refreshToken);

        // 액세스 토큰만 발급으로 변경
        try {
            String accessToken = jwtTokenProvider.createAccessToken(tokenUsernameEncoder.decrypt(username), role, memberId);
            setCookie(response, accessTokenCookieName, accessToken, accessTokenExpirySecond);
        } catch (Exception e) {
            throw new MemberException(MemberResponseMessage.FAIL_CONVERT_TO_JSON);
        }
    }

    private TokenWrapper generateTokens(String username, String role, String memberId, HttpServletRequest request,
                                        HttpServletResponse response) {
        String accessToken = jwtTokenProvider.createAccessToken(username, role, memberId);
        String userAgent = request.getHeader(USER_AGENT_KEY);

        String refreshToken = jwtTokenProvider.createRefreshToken(username, role, memberId, userAgent);

        // 발급한 refreshToken을 redis에 등록
        // 유저 이름 암호화 되어있으므로 provider에서 뽑아서 써야한다
        jwtRedisRepository.saveWithDuration(jwtTokenProvider.getUsernameByRefresh(refreshToken).toString(), userAgent,
                refreshToken);

        setCookie(response, accessTokenCookieName, accessToken, accessTokenExpirySecond);

        return new TokenWrapper(refreshToken);
    }

    public Boolean isManaged(String token) {
        String username = jwtTokenProvider.getUsernameByRefresh(token);
        String userAgent = jwtTokenProvider.getUserAgentByRefresh(token);

        // 레디스에 저장된 리프레시 토큰 가져오기
        return jwtRedisRepository.findUserAgent(username, userAgent).equals(token);
    }

    private void setCookie(HttpServletResponse response, String name, String token, int maxAge) {
        Cookie accessCookie = new Cookie(name, token);
        accessCookie.setHttpOnly(true);
        //cookie.setSecure(true);
        accessCookie.setPath("/");
        accessCookie.setMaxAge(maxAge);

        response.addCookie(accessCookie);
    }
}

 

JwtAuthService입니다. 로그인을 비롯해 토큰 재발급, 로그아웃 처리 등을 담당합니다. 로그인 성공 시 Access Token은 쿠키 형태로, Refresh Token은 응답 Body에 담아 전송합니다. Refresh Token은 Redis에 저장하여 User - Client 별로 관리하기 위해 Access Token발급에 필요한 정보 외에도 추가 정보를 포함하고 있습니다. 현재는 회원의 PK값을 사용하고 있는데, 이를 보안 향상을 위해 UUID로 바꿔주려는 고민을 하고있습니다.

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
    @Value("${JWT_ACCESS_SECRET}")
    private String accessSecretKey;

    @Value("${JWT_REFRESH_SECRET}")
    private String refreshSecretKey;
    // 유효기간 15분
    private final long accessTokenValidMillisecond = 1000L * 60 * 30;

    private final long refreshTokenValidMillisecond = 1000L * 60 * 60 * 24 * 7;
    private final String prefix = "Bearer ";
    private final String refreshHeaderKey = "Authorization";

    private final TokenUsernameEncoder tokenUsernameEncoder;

    @PostConstruct
    protected void init(){
        accessSecretKey = Base64.getEncoder().encodeToString(accessSecretKey.getBytes(StandardCharsets.UTF_8));
        refreshSecretKey = Base64.getEncoder().encodeToString(refreshSecretKey.getBytes(StandardCharsets.UTF_8));
    }

    public String createAccessToken(String username, String role, String memberId){
        Map<String, Object> claims = new HashMap<>();
        claims.put("sub", username);
        claims.put("role", role);
        claims.put("member-id", memberId);

        Date now = new Date();

        String token = Jwts.builder()
                .claims(claims)
                .issuedAt(now)
                .expiration(new Date(now.getTime() + accessTokenValidMillisecond))
                .signWith(SignatureAlgorithm.HS256, accessSecretKey)
                .compact();

        return token;
    }

    public String createRefreshToken(String username, String role, String memberId, String userAgent){
        Map<String, Object> claims = new HashMap<>();

        try {
            claims.put("sub", tokenUsernameEncoder.encrypt(username));
            claims.put("role", role);
            claims.put("member-id", memberId);
            claims.put("user-agent", userAgent);
        }catch (Exception e){
            throw new MemberException(MemberResponseMessage.FAIL_CONVERT_TO_JSON);
        }

        Date now = new Date();

        String token = Jwts.builder()
                .claims(claims)
                .issuedAt(now)
                .expiration(new Date(now.getTime() + refreshTokenValidMillisecond))
                .signWith(SignatureAlgorithm.HS256, refreshSecretKey)
                .compact();

        return token;
    }

    public Authentication getAuthentication(String token){
        Set<GrantedAuthority> authorities = new HashSet<>();
        authorities.add(new SimpleGrantedAuthority(getRole(token)));

        MemberEntity memberEntity = MemberEntity.builder().loginId(getUsername(token))
                .authorities(authorities).memberId(Long.parseLong(getMemberId(token))).build();

        UserDetails userDetails = memberEntity;

        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }
    // access 전용
    public String getUsername(String token){
        return Jwts.parser().setSigningKey(accessSecretKey).build().parseSignedClaims(token).getPayload().getSubject();
    }

    public String getRole(String token){
        return Jwts.parser().setSigningKey(accessSecretKey).build().parseSignedClaims(token).getPayload().get("role").toString();
    }

    public String getMemberId(String token){
        return Jwts.parser().setSigningKey(accessSecretKey).build().parseSignedClaims(token).getPayload().get("member-id").toString();
    }

    public String resolveToken(HttpServletRequest request){
        Cookie[] cookies = request.getCookies();
        String token = "";

        if(cookies != null){
            for(Cookie cookie : cookies){
                if(cookie.getName().equals("access_token")){
                    token = cookie.getValue();
                }
            }
        }
        //System.out.println(bearerToken);
        if (!token.equals("")) {
            //System.out.println(bearerToken.substring(7));
            return token;
        }
        return null;
    }

    public Date getExpireDate(String token){
        return Jwts.parser().setSigningKey(accessSecretKey).build().parseSignedClaims(token).getPayload().getExpiration();
    }

    public Jws<Claims> validateToken(String token) {
        try{
            return Jwts.parser().setSigningKey(accessSecretKey).build().parseSignedClaims(token);
        }catch (Exception e){
            return null;
        }
    }
    // access 전용 
}

 

다음으로 JwtTokenProvider입니다. Jwt가 Json Web Token의 약자인데 JwtToken이라니... 지금 보니 이상하군요. 여하튼 AccessToken과 RefreshToken을 발행하고 검증하는 역할을 수행하도록 구성했습니다. 현재는 Refresh Token을 다루는 메서드가 별도로 분리되어있는데, 코드가 너무 길어지기 때문에 Access Token 관련 부분만 첨부했습니다.

implementation 'io.jsonwebtoken:jjwt-api:0.12.6'

 

버전을 가장 최신 걸로 사용하면서 deprecated된 메서드가 많아 고생을 좀 했습니다. 결국 타협하고 줄 죽죽 그어지는 메서드를 쓰기도 했는데... 처음 하다 보니 각 메서드가 어떤 기능을 제공하는지 파악이 느려서 힘들었던 것 같습니다. API 공식 문서를 보는 연습을 좀 해야겠어요!

 

아마도 이번 프로젝트에서 가장 많은 고민을 한 부분인 것 같은데, 서버가 사용자 편의성을 내려놓지 않으면서 완전한 stateless 상태를 유지할 수 있을까에 대한 생각 꽤 오래 했습니다. 구현을 더 미룰 수 없어서 refresh 토큰은 redis를 통해 관리하는 것으로 타협했지만, 여전히 더 좋은 방식이 없을까 고민하고 있습니다.

 

로그아웃의 경우 만료된 쿠키를 같은 이름으로 덮어써서 access token을 제어하고 refresh토큰을 redis에서 삭제하는 것으로 처리했습니다.

 

처음 구현하는 JWT 방식 인증/인가여서 헤맨 시간이 정말 길었던 것 같습니다.

코드나 논리, 생각에 고쳐야할 점이 보인다면 말씀해 주셔요! 제게 큰 도움이 됩니다.