Spring Security + JWT - 3

chrkb1569·2022년 9월 6일
0

개인 프로젝트

목록 보기
8/28

지난 번에는 TokenProvider 파일을 추가하는 부분까지 했으므로, 오늘은 TokenProvider 이외에 파일들을 추가하여 Jwt 설정과 관련된 부분들을 모두 마치도록 하겠습니다.

먼저, JWT와 관련하여 발생하는 오류들을 처리하기 위한 파일들을 추가해줄텐데,

다음 2개의 파일을 생성해줍니다.

package chrkb1569.LoginAPI.jwt;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

// Authentication을 제시하지 못하거나, 정해진 인증 절차를 밟지 않는 경우 발생하는 오류들을 관리 401 오류 반환
public class JwtAuthenticationHandler implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}
package chrkb1569.LoginAPI.jwt;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

// Authentication 객체를 가지고 있더라도 필요한 권한을 가지고 있지 않은 경우 403 오류를 반환함
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}

우리는 이전까지 만들어보았던 URL의 경우에는 접근에 제한이 없었습니다.

하지만, Spring Security를 사용하게 될 경우에는 특정 URL에 접근하기 위해서 인증 절차를 제시할 수도 있으며, 특정 권한을 요구할 수도 있습니다.

이와 같은 상황에서 인증 절차를 따르지 못하거나, 특정 권한이 없는 경우에는 오류가 발생하는데, 이러한 오류를 처리하기 위한 파일이 바로 JwtAccessDeniedHandler, JwtAuthenticationHandler입니다.

AuthenticationEntryPoint

먼저, JwtAuthenticationHandler 파일이 구현하고 있는 인터페이스 AuthenticationEntryPoint 부터 설명해보겠습니다.

해당 오류는 특정 인증 절차나 Authentication 객체를 제시하지 못한 경우 발생하는 오류로, 401에러를 클라이언트에게 반환한다는 특징을 가지고 있습니다.

JwtAuthenticationHandler의 경우, 해당 오류가 발생한다면 request에 에러코드를 담아서 반환하도록 되어있습니다.

AccessDeniedHandler

그럼, JwtAccessDeniedHandler 파일이 구현하고 있는 AccessDeniedHandler 인터페이스의 경우, AuthenticationEntryPoint 인터페이스와 어떠한 차이점이 있을까요?

가장 큰 차이점은 바로 인증에 필요한 대상입니다.

AuthenticationEntryPoint 인터페이스의 경우, Authentication 객체를 가지고 있거나, 특정 인증 절차에 따른다면 오류는 발생하지 않습니다.

그러나, AccessDeniedHandler 인터페이스의 경우에는 Authentication이 가지고 있는 권한을 검사합니다.

즉, Authentication과 Authentication이 가지고 있는 권한을 확인하여 특정 권한을 가지고 있지 않은 경우에는 클라이언트에게 403 오류를 반환합니다.

그럼 일단 오류 처리를 위한 파일은 만들었으니, 본격적으로 JWT와 관련된 필터를 등록해야합니다.

우리가 로그인 기능을 구현하는데에 있어서 사용하는 기술이 바로 Spring Security인데, Spring Security의 경우 인가되지 않은 사용자의 접근을 막는 필터가 여러개 존재하며, 이 필터의 묶음을 FilterChain이라고합니다.

그러나, Spring Security에서는 JWT와 관련된 Filter가 존재하지 않기 때문에, 우리는 별도의 파일을 통하여 JWT와 관련된 Filter를 생성하여 FilterChain에 등록시켜줘야합니다.

package chrkb1569.LoginAPI.jwt;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;


@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;

    private String AUTHORIZATION_KEY = "Authorization";

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String jwt = resolveToken(request);

        if(StringUtils.hasText(jwt) && tokenProvider.isValidate(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    public String resolveToken(HttpServletRequest request) {
        String jwt = request.getHeader(AUTHORIZATION_KEY);

        if(StringUtils.hasText(jwt) && jwt.startsWith("BEARER ")) {
            return jwt.substring(7);
        }
        return null;
    }
}

다음과 같이 JWT와 관련된 Filter 파일을 생성하였습니다.

Filter의 기능은 주로 doFilterInternal() 메소드 내부에서 동작합니다.

먼저, 클라이언트의 request에서 토큰과 관련된 정보를 뽑아오는 resolveToken() 메소드부터 살펴보겠습니다.

 public String resolveToken(HttpServletRequest request) {
        String jwt = request.getHeader(AUTHORIZATION_KEY);

        if(StringUtils.hasText(jwt) && jwt.startsWith("BEARER ")) {
            return jwt.substring(7);
        }
        return null;
    }

해당 메소드는 클라이언트의 request로부터 토큰과 관련된 정보를 가져오는데, 권한과 관련된 정보는 헤더에서 Authorization이라는 키값으로 정보를 얻을 수 있습니다.

이때, 토큰의 형태는

"BEARER XXXXXXXX"의 형식으로 되어있습니다.

따라서, 정상적으로 토큰이 발급되어 있는 경우에는 Authorization이라는 키에 대한 값으로 "BEARER XXXXXX"의 형태로 토큰과 관련된 정보가 있다는 것입니다.

따라서, 이를 if()문을 통하여 확인한 뒤에, 정상적으로 토큰이 발급된 상태라면, 앞에 존재하는 "BEARER "를 제외한 나머지 토큰과 관련된 부분만을 반환하도록 해주는 메소드입니다.

그럼, 필터와 관련된 로직을 살펴본다면

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String jwt = resolveToken(request);

        if(StringUtils.hasText(jwt) && tokenProvider.isValidate(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

resolveToken() 메소드를 통하여 토큰에 대한 문자열을 받아온 뒤에, if()문과 tokenProvider의 메소드를 통하여 토큰의 유효성을 확인합니다.

만일, 토큰이 유효한 경우에는 해당 토큰을 통하여 Authentication 객체를 얻어, SecurityContextHolder.getContext().setAuthentication() 메소드를 통하여 해당 Authentication 객체를 저장합니다.

SecurityContextHolder 메소드의 경우에는 SecurityContextHolder 내부에 SecurityContext가 존재하고, SecurityContext 내부에 Authentication 객체가 존재하므로 Authentication 객체를 저장하기 위하여 다음과 같은 메소드를 사용하였습니다.

그 다음으로는 JWT filter를 FilterChain에 등록하기 위한 파일이 추가적으로 더 필요합니다.

package chrkb1569.LoginAPI.jwt;

import lombok.RequiredArgsConstructor;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    private final TokenProvider tokenProvider;

    @Override
    public void configure(HttpSecurity httpSecurity) {
        JwtFilter jwtFilter = new JwtFilter(tokenProvider);

        httpSecurity.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
    }

}

JWT를 사용하지 않는 Spring Security의 경우, 로그인 동작을 구현할 때, form 방식을 사용합니다.

form 방식을 통하여 로그인함과 동시에 ID와 password가 들어오는데, 이 정보들을 통하여 Authentication 객체를 반환하면서 인증의 권한을 넘기는 필터가 바로 UserNamePasswordAuthenticationFilter입니다.

그러나, 우리는 form 방식을 사용하지 않고, JWT를 사용하기 때문에, 로그인을 통하여 정보를 전달하는 대신, JWT를 통하여 정보를 전달합니다.

따라서, JwtFilter를 UserNamePasswordAuthenticationFilter 앞에 httpSecurity.addFilterBefore() 메소드를 통하여 등록해줍니다.

그럼 이제 마무리 단계로 앞에서 만들어주었던 Security 설정을 위한 파일들을 모두 만들어 주었기 때문에, SecurityConfig 파일을 완성해보면, 다음과 같습니다.

package chrkb1569.LoginAPI.config;

import chrkb1569.LoginAPI.jwt.JwtAccessDeniedHandler;
import chrkb1569.LoginAPI.jwt.JwtSecurityConfig;
import chrkb1569.LoginAPI.jwt.TokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;

@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final TokenProvider tokenProvider;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final AuthenticationEntryPoint authenticationEntryPoint;

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Bean
    public SecurityFilterChain configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .exceptionHandling()
                .accessDeniedHandler(jwtAccessDeniedHandler)
                .authenticationEntryPoint(authenticationEntryPoint)

                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .apply(new JwtSecurityConfig(tokenProvider));

        return http.build();
    }
}

필터 로직에서 가장 먼저 사용하는 코드는 csrf.disable()입니다.

csrf는 Cross site Request forgery라는 일종의 보안과 관련된 기능인데, Default 값으로 csrf 보호설정이 되어 있습니다.

csrf는 클라이언트로부터 오는 request 요청에 알 수 없는 공격 요소가 포함되어 있을 경우에 대비하여 GET 방식을 제외한 데이터에 변화를 주는 메소드(PUT, DELETE 등)로부터 보호합니다.

하지만, 이러한 경우는 서버에 인증 정보를 가지고 있을 경우에 필요한 보안 요소이고, 우리는 JWT를 활용하여 Stateless 상태를 유지하기 때문에 필요하지 않습니다.

따라서, csrf.disable()을 통하여 crsf 보안을 비활성화시킵니다.

그 다음으로는 exceptionHandling()을 통하여 인증 과정에서 발생하는 오류들을 처리할 파일들을 대입해주고, sessionManangeMent()를 통하여 세션을 처리하는 방식을 정해줍니다.

우리는 JWT를 사용하기 때문에 Stateless로 되게끔 설정해줍니다.

그리고 apply()를 통하여 우리가 만들었던 JwtFilter를 등록해줌으로써, 설정은 끝마쳤습니다.

사실 url별로 접근을 어떻게 할 것이고, 권한에 따라서 접근을 어떻게 달리할 것인지를 정해야하는데, Controller를 통하여 URL 처리를 어떻게 할지 아직 정하지 않았기 때문에 일단은 넘어가도록 하겠습니다.

일단 오늘까지 진행함으로써 JWT와 관련된 설정은 모두 끝마쳤고, Spring Security와 관련된 설정들을 진행하도록 해보겠습니다.

1개의 댓글

comment-user-thumbnail
2022년 9월 8일

최초 로그인 할 경우에는 사용자 인증 후 JWT 토큰을 발급해야 하는데 해당 부분은 어떻게 구현해야할까요? tokenProvider.createToken은 Authentication 객체를 파라미터로 받도록 되어있어서요. 그리고 JwtFilter 클래스에서 tokenProvider.isValidate 메서드가 이전 게시글에는 정의되어 있지 않던데 별도로 추가된 부분인건가요?

답글 달기