이전 글에서 Spring Security 개념에 대해서 정리했다면 이번 글은 Spring Security + JWT 를 적용해보고자 합니다. 앞서 말했다시피 학습과 병행하며 진행된 만큼 아직 개선한 여지가 있는 부분이 있습니다. 글을 읽으시다가 잘못 작성되거나 개선할 부분이 있다면 댓글로 남겨주시면 감사하겠습니다.🙌🏻

 

 
 

목차

1. 구현해보기

  1.1 의존성 추가

  1.2 Spring Security Config

  1.3 JwtAuthorizationFilter

  1.4 CustomAuthenticationProvider

  1.5 CustomUserDetailsService

  1.6 CustomAuthSuccessHandler

  1.7 CustomAuthFailureHandler

 

2. 테스트

 

 구현해보기

 

  • Spring Boot 3.4.0
  • JDK 17
  • Build : Gradle

 

의존성 추가

dependencies {

	implementation 'org.springframework.boot:spring-boot-starter-security'
	testImplementation 'org.springframework.security:spring-security-test'

	//jwt
	implementation 'io.jsonwebtoken:jjwt:0.9.1'
	implementation 'com.sun.xml.bind:jaxb-impl:4.0.1'
	implementation 'com.sun.xml.bind:jaxb-core:4.0.1'
	implementation 'javax.xml.bind:jaxb-api:2.4.0-b180830.0359'

}

 

 

Spring Security  Config

 

 1. SecurityFilterChain

Spring Security에서 제공하는 인증, 인가를 위한 필터들의 모임으로 사용하는 목적에 맞게 커스텀하여 필터 체인으로 포함시켜 사용할 수 있다.

 

1.1 SecurityContextPersistenceFilter

   @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
         http
                .csrf(csrf -> csrf.disable()) // JWT 토큰을 사용으로 비활성화
                .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
                .addFilterBefore(jwtAuthorizationFilter(), BasicAuthenticationFilter.class) //JWT Filter

                //JWT 이용하여 인증
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

                // form 기반의 로그인을 비활성화
                .formLogin(login -> login.disable())

                // cumstom으로 구성한 필터 사용
                .addFilterBefore(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class )

                .httpBasic(withDefaults());

        return http.build();
    }

 

 

 

JwtAuthorizationFilter

 

API 요청이 들어왔을 때, JWT토큰 검증을 위해 Spring Security 필터 체인에 Custom Filter를 추가했습니다.

JwtAuthorizationFilter 는 토큰이 필요하지 않는 API를 제외하고는 모두 수행되며, Authorization 헤더를 통해 JWT의 유효성 검사를 진행합니다. 

@Component
@Slf4j
public class JwtAuthorizationFilter extends OncePerRequestFilter {


    private String Bearer = "Bearer ";
    @Autowired
    private JwtTokenUtil jwtTokenUtil;

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

        // 토큰이 필요하지 않는 API의 경우 다음 필터로 이동
        if(request.getRequestURI().startsWith("/api/auth")) {
            response.addHeader("X-Custom-Filter", "Public Access");
            filterChain.doFilter(request, response);

            return;
        }

        try {

            // Client에서 API를 요청할 때 Header 확인
            String token = request.getHeader(HttpHeaders.AUTHORIZATION);

            if(token == null || "".equals(token)) {
                log.error("Token is null");
                setErrorResponse(response, ErrorCode.NOT_EXISTS_TOKEN,null);
            }

            token = token.substring(Bearer.length());

            if(!jwtTokenUtil.isTokenValid(token)) {
                setErrorResponse(response, ErrorCode.INVALID_TOKEN, null);
            }

            filterChain.doFilter(request,response);

        } catch (Exception e) {
            setErrorResponse(response,null, e);
        }

    }

    public static void setErrorResponse(HttpServletResponse response, ErrorCode errorCode, Exception e) throws IOException {

        response.setContentType("application/json;charset=UTF-8");

        if(!"null".equals(e)) {

            if(e instanceof ExpiredJwtException) {
                errorCode = ErrorCode.EXPIRED_TOKEN;
            } else if (e instanceof SignatureException | e instanceof JwtException) {
                errorCode = ErrorCode.INVALID_TOKEN;
            } else if (e instanceof NestedServletException || e instanceof Exception) {
                errorCode = ErrorCode.INTERNAL_SERVER_ERROR;
            }
        }

        PrintWriter writer = response.getWriter();

        response.setStatus(errorCode.getStatus().value());

        HashMap<String, String> jsonMap = new HashMap<>();
        jsonMap.put("status", String.valueOf(errorCode.getStatus().value()));
        jsonMap.put("message", errorCode.getMessage());

        writer.print(JSONObject.toJSONString(jsonMap));
        writer.flush();
        writer.close();

    }
}

 

 

CustomAuthenticationFilter

 

로그인 API인 /api/auth/login 으로 요청이 들어오면 해당 필터에 들어오게 되는데, 인증 요청을 처리하는 핵심 인터페이스로 실제 인증 로직을 실행하는 부분입니다.

 

@Slf4j
@Component
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    public CustomAuthenticationFilter(AuthenticationManager authenticationManager) {
        super.setAuthenticationManager(authenticationManager);
    }

    // login 요청을 하면 실행되는 함수
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {

        log.info("1. CustomAuthenticationFilter");
        UsernamePasswordAuthenticationToken authenticationToken = null;
        try {

            // request 파라미터로 임시 토큰 발급
            ObjectMapper objectMapper = new ObjectMapper();
            User user = objectMapper.readValue(request.getInputStream(), User.class);

            authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserId(), user.getPassword());

            log.info("Principal : {}", authenticationToken.getPrincipal());
            log.info("Credentials : {}", authenticationToken.getCredentials());

            setDetails(request,authenticationToken);


        } catch (Exception e) {
            log.error(e.getMessage());
        }

        return this.getAuthenticationManager().authenticate(authenticationToken);
    }

}

 

 

 

CustomAuthenticationProvider

 

전달받은 사용자의 아이디와 비밀번호를 기반으로 비즈니스 로직을 처리하여 사용자의 '인증'에 대해서 검증을 수행하는 클래스 입니다.

CustomAuthenticationFilter로부터 생성한 토큰을 통해 'CustomUserDetailsService'를 통해 데이터베이스 내에서 사용자 정보를 조회합니다.

 

@Slf4j
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {

    @Resource
    private CustomUserDetailsService customUserDetailsService;
    @NonNull
    private PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        log.info("2. CustomAuthenticationProvider");

        String userId = (String) authentication.getPrincipal();
        String password = (String) authentication.getCredentials();
        //사용자 인증에 대해서 검증 수행

        UserDetails userDetails = customUserDetailsService.loadUserByUsername(userId);

        // DB정보랑 일치한 지 확인
        if(!passwordEncoder.matches(password, userDetails.getPassword())){
            throw new BadCredentialsException("User Info Mismatch");
        }

        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities());

        return authenticationToken;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}

 

 

CustomUserDetailsService

 

사용자 정보를 데이터베이스에서 가져오는 부분입니다.

@Service
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {

        log.info("UserDetailsService {}" , userId);

        User user = userRepository.findByUserId(userId)
                .orElseThrow(() -> new UsernameNotFoundException("User not found: " + userId));
        
        return new PrincipalDetails(user);
    }
}

 

CustomAuthSuccessHandler

 

사용자의 '인증'에 대해 성공했을 경우 수행되는 Handler로 성공에 대한 사용자에게 반환값을 구성하여 전달하는 클래스입니다.

@Slf4j
@Configuration
public class CustomAuthSuccessHandler implements AuthenticationSuccessHandler {

    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    
    @Autowired
    private JwtProperties jwtProperties;

    @Autowired
    private UserService userService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        log.info("3.1 CustomAuthSuccessHandler");

        String userId = (String) authentication.getPrincipal();

        // JWT토큰 발급
        String accessToken = jwtTokenUtil.generateAccessToken(userId);
        String refreshToken = jwtTokenUtil.generateRefreshToken(userId);

        // 사용자 refreshToken DB에 저장
        userService.updateUserRefreshToken(userId, refreshToken);

        // 응답규격 생성
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_OK);
        response.addHeader(jwtProperties.getHeaderString(), jwtProperties.getTokenPrefix()+accessToken);

        HashMap<String, String> jsonMap = new HashMap<>();
        jsonMap.put("status", String.valueOf(HttpServletResponse.SC_OK));
        jsonMap.put("message", "정상 처리되었습니다.");

        PrintWriter writer = response.getWriter();
        writer.print(JSONObject.toJSONString(jsonMap));
        writer.flush();
        writer.close();

    }
}

 

CustomAuthFailureHandler

 

사용자의 '인증'에 대해 실패했을 경우 수행되는 Handler로 실패에 대한 사용자에게 반환값을 구성하여 전달하는 클래스입니다.

@Slf4j
@Configuration
public class CustomAuthFailureHandler implements AuthenticationFailureHandler {
    
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        log.info("3.2 CustomAuthFailureHandler");

        response.setContentType("application/json;charset=UTF-8");

        ErrorCode errorCode = null;

        if(exception instanceof UsernameNotFoundException || exception instanceof BadCredentialsException){
            errorCode = ErrorCode.RESOURCE_NOT_FOUND;
        }  else if (exception instanceof InternalAuthenticationServiceException || exception instanceof AuthenticationCredentialsNotFoundException) {
            errorCode = ErrorCode.UNAUTHORIZED;
        } else {
            errorCode = ErrorCode.INTERNAL_SERVER_ERROR;
        }

        PrintWriter writer = response.getWriter();

        response.setStatus(errorCode.getStatus().value());

        HashMap<String, String> jsonMap = new HashMap<>();
        jsonMap.put("status", String.valueOf(errorCode.getStatus().value()));
        jsonMap.put("message", errorCode.getMessage());

        writer.print(JSONObject.toJSONString(jsonMap));
        writer.flush();
        writer.close();

    }
}

 

 

 테스트

 

1. 회원가입

회원가입 API
DB에 해당 사용자정보 저장

 

2. 로그인 

 

case1) 일치한 사용자 정보 없음

 

 

case2) 정상로그인 - Headers에 AccessToken 값 확인가능

'정리 > Spring' 카테고리의 다른 글

Spring Security 란?  (0) 2024.12.10
[Spring] DI & AOP  (0) 2021.09.24
[Spring] REST API  (0) 2021.09.09
[Spring] Spring MVC  (0) 2021.09.09
[Spring] Spring이란?  (0) 2021.09.09

+ Recent posts