인증 예외처리 리펙토링 과정

seokseungmin·2024년 11월 19일

Today I Learned

목록 보기
18/20

문제 발생

팀 프로젝트에서 로그인 인증이 필요한 API로그인이 필요 없는 API를 구분해야 하는 상황이 발생했습니다.

기존 코드 이슈

  1. SecurityConfig에서 모든 경로를 .permitAll()로 설정하여 인증 로직이 생략되고 Controller로 바로 넘어감.
  2. Controller 코드에서 직접 Authentication를 호출하여 인증 및 사용자 정보를 확인.
  3. 인증 실패를 Controller 내부에서 예외로 처리하며, 코드의 책임이 분산됨.

리팩토링 방향

  1. 인증 로직을 Controller 외부로 이동하여 단일 책임 원칙(SRP)을 지킴.
  2. 인증 실패를 Spring Security Filter에서 처리.
  3. 인증에 대한 검증 실패 응답을 통일성 있게 관리.

변경 과정

package com.zerobase.user.config;

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {

    // JWTUtil 주입
    private final JWTUtil jwtUtil;
    // AuthenticationManager가 인자로 받을 AuthenticationConfiguration 객체 생성자 주입
    private final AuthenticationConfiguration authenticationConfiguration;
    private final UserRepository userRepository;
    private final ProfileRepository profileRepository;
    private final RefreshRepository refreshRepository;
    private final CookieUtil cookieUtil;

    // AuthenticationManager Bean 등록
    @Bean
    public AuthenticationManager authenticationManager(
        AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        // CSRF 비활성화
        http.csrf().disable();

        // Form 로그인 방식 비활성화
        http.formLogin().disable();

        // HTTP Basic 인증 방식 비활성화
        http.httpBasic().disable();

        // 경로별 접근 권한 설정
        http.authorizeHttpRequests(auth -> auth
                .requestMatchers("api/**", "/login", "/logout", "/", "/join", "/reissue", "internal/**").permitAll()
                .requestMatchers("/admin").hasRole("ADMIN")
                .anyRequest().authenticated()
        );

        // 필터 추가
        http.addFilterBefore(new JWTFilter(userRepository, jwtUtil), LoginFilter.class);
        http.addFilterAt(
            new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil,
                refreshRepository, userRepository, profileRepository, cookieUtil),
            UsernamePasswordAuthenticationFilter.class);
        http.addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository),
            LogoutFilter.class);

        // 세션 설정
        http.sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        return http.build();
    }
}

기존 코드는 로그인 기능이든 비로그인 기능이든 ("api/**").permitAll()로 처리하여 .anyRequest().authenticated()에서 인증 정보를 검증하지 않고 컨트롤러로 넘어가는 구조였습니다.

리팩토링 전 컨트롤러 코드

package com.zerobase.user.controller;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserController {

    private final UserService userService;
    private final ProfileService profileService;
    private final UserRepository userRepository;
    private final ReissueService reissueService;

    private UserEntity getCurrentUser(Authentication authentication) {
        if (authentication == null || !authentication.isAuthenticated()) {
            throw new BizException(UNAUTHORIZED_ERROR);
        }

        CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
        return userRepository.findByEmail(customUserDetails.getUsername())
            .orElseThrow(() -> new BizException(USER_NOT_FOUND_ERROR));
    }

@PatchMapping("/Info")
public ResponseEntity<?> editUserInfo(@RequestBody @Valid EditUserInfoDTO editUserInfoDTO, Authentication authentication) {
    UserEntity currentUser = getCurrentUser(authentication);
    userService.editUserInfoProcess(editUserInfoDTO, currentUser);
    return ResponseEntity.status(OK).body(ResponseMessage.success());
}


    // ...이하 생략
}

컨트롤러 코드에 private final UserRepository userRepository; 리포지토리가 있는 것도 이상했고,

private UserEntity getCurrentUser(Authentication authentication) {
    if (authentication == null || !authentication.isAuthenticated()) {
        throw new BizException(UNAUTHORIZED_ERROR);
    }

    CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
    return userRepository.findByEmail(customUserDetails.getUsername())
        .orElseThrow(() -> new BizException(USER_NOT_FOUND_ERROR));
}

인증 부분도 컨트롤러에서 하면 좋지 않을 것 같다라는 팀원의 의견을 반영해 리팩토링을 시작했습니다!

먼저 getCurrentUser 메서드를 AuthenticationUtil 유틸 클래스로 따로 빼주어 컨트롤러에서 해당 코드를 정리했습니다.

package com.zerobase.user.util;

import com.zerobase.user.jwt.CustomUserDetails;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

@Component
public class AuthenticationUtil {

    private UserEntity getCurrentUser(Authentication authentication) {
        if (authentication == null || !authentication.isAuthenticated()) {
            throw new BizException(UNAUTHORIZED_ERROR);
        }

        CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
        return userRepository.findByEmail(customUserDetails.getUsername())
            .orElseThrow(() -> new BizException(USER_NOT_FOUND_ERROR));
    }
}

처음엔 이렇게 수정해주었지만, 이렇게 해도 인증에 대한 예외 처리를 필터에서 하지 않으면 컨트롤러까지 넘어와서 컨트롤러에서 예외 처리를 해야 하는 건 마찬가지였습니다.

컨트롤러에서 인증에 대한 예외 처리를 하지 않기 위해 로그인 기능을 처리하는 컨트롤러에서 Authentication authentication를 파라미터에서 제외해주었습니다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserController {

    private final UserService userService;
    private final ProfileService profileService;
    private final ReissueService reissueService;

    @PatchMapping("/Info")
public ResponseEntity<?> editUserInfo(@RequestBody @Valid EditUserInfoDTO editUserInfoDTO) {
    UserEntity currentUser = getCurrentUser(authentication);// 컴파일 에러
    userService.editUserInfoProcess(editUserInfoDTO, currentUser);
    return ResponseEntity.status(OK).body(ResponseMessage.success());
}

    // ...이하 생략
}

컨트롤러 파라미터 부분에서 Authentication authentication을 제거하니 로그인 요청 컨트롤러 모든 부분에서 UserEntity currentUser = getCurrentUser(authentication); 이 코드 부분에서 컴파일 오류가 발생했습니다.

그래서 컨트롤러에서 이 부분의 코드도 모두 삭제해주었습니다. 이제 컨트롤러에서 인증을 처리하는 코드 부분이 없으니, 인증 처리 오류를 필터에서 어떻게 처리할지 고민했습니다.

package com.zerobase.user.jwt;

@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {

    private final UserRepository userRepository;
    private final JWTUtil jwtUtil;

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

        // 헤더에서 access 키에 담긴 토큰을 꺼냄
        String accessToken = request.getHeader("access");

        // 토큰이 없다면 다음 필터로 넘김
        if (accessToken == null || accessToken.isEmpty()) {
            filterChain.doFilter(request, response);
            return;
        }

        // 토큰 만료 여부 확인, 만료 시 다음 필터로 넘기지 않음
        try {
            jwtUtil.isExpired(accessToken);
        } catch (MalformedJwtException e) {
            ResponseUtil.setJsonResponse(response, SC_UNAUTHORIZED, INVALID_TOKEN_FORMAT_ERROR);
            return;
        } catch (ExpiredJwtException e) {
            ResponseUtil.setJsonResponse(response, SC_UNAUTHORIZED, EXPIRED_TOKEN_ERROR);
            return;
        }

        // 토큰이 access인지 확인
        String category = jwtUtil.getCategory(accessToken);

        if (!category.equals("access")) {
            ResponseUtil.setJsonResponse(response, SC_UNAUTHORIZED, NOT_ACCESS_TOKEN_ERROR);
            return;
        }

        // 토큰에서 이메일과 역할 획득
        String email = jwtUtil.getEmail(accessToken);
        String roleString = jwtUtil.getRole(accessToken);

        Role role;
        try {
            // roleString을 Role enum으로 변환
            role = Role.fromString(roleString);
        } catch (IllegalArgumentException e) {
            System.out.println("Invalid role");
            filterChain.doFilter(request, response);
            return;
        }

        Optional<UserEntity> optionalUserEntity = userRepository.findByEmail(email);
        if (optionalUserEntity.isEmpty()) {
            filterChain.doFilter(request, response);
            return;
        }

        // UserEntity를 생성하여 값 설정
        UserEntity userEntity = UserEntity.builder()
            .username(optionalUserEntity.get().getUsername())
            .password(optionalUserEntity.get().getPassword())
            .email(email)
            .role(role)
            .build();

        // UserDetails에 회원 정보 객체 담기
        CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);

        // 스프링 시큐리티 인증 토큰 생성
        Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null,
            customUserDetails.getAuthorities());
        // 세션에 사용자 등록
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);
    }
}

회원가입 후 로그인하면 토큰을 주는데, 로그인 기능은 토큰이 필요하고 사용자가 API 요청 시 토큰을 보내줍니다. 즉, 토큰을 보내주면 사용자 인증 정보를 등록해둡니다.

// 세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authToken);

비로그인 기능은 토큰이 필요 없으므로 사용자 인증 정보를 등록하는 부분을 생략합니다.

// 토큰이 없다면 다음 필터로 넘김
if (accessToken == null || accessToken.isEmpty()) {
    filterChain.doFilter(request, response);
    return;
}

사용자 인증 정보를 등록하는 부분이 없으므로 다음 필터로 요청을 넘깁니다.

package com.zerobase.user.config;

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {

    // JWTUtil 주입
    private final JWTUtil jwtUtil;
    // AuthenticationManager가 인자로 받을 AuthenticationConfiguration 객체 생성자 주입
    private final AuthenticationConfiguration authenticationConfiguration;
    private final UserRepository userRepository;
    private final ProfileRepository profileRepository;
    private final RefreshRepository refreshRepository;
    private final CookieUtil cookieUtil;
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; // 추가된 부분

    // AuthenticationManager Bean 등록
    @Bean
    public AuthenticationManager authenticationManager(
        AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        // CSRF 비활성화
        http.csrf().disable();

        // Form 로그인 방식 비활성화
        http.formLogin().disable();

        // HTTP Basic 인증 방식 비활성화
        http.httpBasic().disable();

        // 수정된 예외 처리 구성
        http.exceptionHandling()
            .authenticationEntryPoint(customAuthenticationEntryPoint);

        // 경로별 접근 권한 설정
        http.authorizeHttpRequests(auth -> auth
                 // 공통
                .requestMatchers("/", "/internal/**").permitAll()
                 // 로그인, 로그아웃, 회원가입, 재발급
                .requestMatchers("/login", "/logout", "/join", "/reissue").permitAll()
                 // 사용자 기능
                .requestMatchers(POST, "/api/v1/users/reset-password", "/api/v1/users").permitAll()
                .requestMatchers(GET, "/api/v1/users/*", "/api/v1/users/*/profile", "/api/v1/users/duplicate").permitAll()
                // 인증이 필요한 요청
                .anyRequest().authenticated()
        );

        http.addFilterBefore(new JWTFilter(userRepository, jwtUtil), LoginFilter.class);
        http.addFilterAt(
            new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil,
                refreshRepository, userRepository, profileRepository, cookieUtil),
            UsernamePasswordAuthenticationFilter.class);
        http.addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository),
            LogoutFilter.class);

        // 세션 설정
        http.sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        return http.build();
    }
}
http.addFilterBefore(new JWTFilter(userRepository, jwtUtil), LoginFilter.class);

필터처리 순서를 보면 로그인보다 JWTFilter를 먼저 검사합니다. 즉, 사용자가 API 요청을 하면 JWTFilter를 먼저 거칩니다. 여기서 비로그인 기능은 토큰이 필요 없어서 요청에 포함하지 않고 보내주기 때문에 SecurityContextHolder.getContext().setAuthentication(authToken); 인증 정보가 없어서

http.authorizeHttpRequests(auth -> auth
             // 공통
            .requestMatchers("/", "/internal/**").permitAll()
             // 로그인, 로그아웃, 회원가입, 재발급
            .requestMatchers("/login", "/logout", "/join", "/reissue").permitAll()
             // 사용자 기능
            .requestMatchers(POST, "/api/v1/users/reset-password", "/api/v1/users").permitAll()
            .requestMatchers(GET, "/api/v1/users/*", "/api/v1/users/*/profile", "/api/v1/users/duplicate").permitAll()
            // 인증이 필요한 요청
            .anyRequest().authenticated()
    );

비로그인 기능들은 .anyRequest().authenticated()의 사용자 인증 정보에 대한 검증 로직을 피하게 만들어야 합니다.
그렇지 않으면 인증 오류 발생!
비로그인 API들은 .permitAll()로 처리하고, 로그인 기능들은 .anyRequest().authenticated()를 거쳐서 사용자 인증에 대한 검증을 해주어야 합니다.

여기서 인증에 대한 오류가 발생하면, Spring Security가 내부적으로 인증 예외를 발생시킵니다. 이를 활용하여 CustomAuthenticationEntryPoint를 추가하였고, 필터에서 인증 실패 시 발생하는 예외를 한 곳에서 처리하도록 하였습니다.

package com.zerobase.user.exception;

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(
        HttpServletRequest request,
        HttpServletResponse response,
        AuthenticationException authException) {

        ResponseUtil.setJsonResponse(response, SC_UNAUTHORIZED, UNAUTHORIZED_ERROR);
    }
}

SecurityConfig에 해당 authenticationEntryPoint를 등록했습니다.

private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

   // 수정된 예외 처리 구성
   http.exceptionHandling((exceptionHandling) -> exceptionHandling
   .authenticationEntryPoint(customAuthenticationEntryPoint));

이렇게 설정하면, 인증 오류가 발생할 때 Spring Security가 AuthenticationException을 감지하고, 자동으로 CustomAuthenticationEntryPointcommence 메서드를 호출하여 인증 실패에 대한 응답을 처리하게 됩니다.


ResponseUtil 응답 처리 부분

인증 실패 시 클라이언트에게 일관된 응답을 제공하기 위해 ResponseUtil 클래스를 사용합니다.

package com.zerobase.user.util;

public class ResponseUtil {

    private static final ObjectMapper objectMapper = new ObjectMapper();

    /**
     * 공통 JSON 응답을 설정하는 메서드
     */
    public static void setJsonResponse(HttpServletResponse response, int statusCode,
        LoginSuccessDTO loginSuccessDTO) throws IOException {
        response.setStatus(statusCode);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        String jsonResponse = objectMapper.writeValueAsString(
            ResponseMessage.success(loginSuccessDTO));

        PrintWriter writer = response.getWriter();
        writer.write(jsonResponse);
        writer.flush();
    }

    public static void setJsonResponse(HttpServletResponse response, int statusCode,
        ErrorCode errorCode) {
        response.setStatus(statusCode);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        String jsonResponse;
        try {
            jsonResponse = objectMapper.writeValueAsString(ResponseMessage.fail(errorCode));
            PrintWriter writer = response.getWriter();
            writer.write(jsonResponse);
            writer.flush();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

이렇게 해서 기존 컨트롤러에서 처리하던 인증 오류에 대한 처리를 필터 단에서 통일된 방식으로 처리하도록 리팩토링하였습니다.


AuthenticationEntryPoint 상세 설명

AuthenticationEntryPoint는 Spring Security에서 인증 실패 시 호출되는 인터페이스로, 인증되지 않은 사용자에게 어떻게 응답할지를 결정합니다.

동작 방식

  • 사용자가 인증이 필요한 리소스에 접근했지만 인증되지 않은 경우, Spring Security는 자동으로 AuthenticationEntryPointcommence 메서드를 호출합니다.
  • 이때 AuthenticationException 객체와 함께 HttpServletRequest, HttpServletResponse 객체가 전달됩니다.
  • commence 메서드는 인증 실패에 대한 적절한 응답을 생성하여 클라이언트에게 반환합니다.

commence 메서드의 역할

  • 인증 실패 시 클라이언트에게 에러 응답을 반환합니다.
  • 일반적으로 HTTP 401 Unauthorized 상태 코드와 함께 에러 메시지 또는 에러 정보를 포함한 JSON 응답을 제공합니다.
  • commence 메서드를 구현하여 인증 실패 시의 응답을 커스터마이징할 수 있습니다.

처리 흐름

  1. 클라이언트가 보호된 API(인증이 필요한 기능)를 호출합니다.
  2. SecurityContextHolder에 인증 정보가 없는 상태에서 요청이 전달됩니다.
  3. 인증 로직 실행 중에 AuthenticationException이 발생합니다.
    • 이 예외는 주로 인증 필터나 인증 매니저에서 발생합니다.
  4. Spring Security의 ExceptionTranslationFilter가 이 예외를 감지하고, 설정된 AuthenticationEntryPoint로 예외 처리를 위임합니다.
  5. CustomAuthenticationEntryPointcommence 메서드가 호출되어 클라이언트에게 인증 실패에 대한 응답을 반환합니다.

결과

  • 비로그인 기능은 인증 없이 처리되고, 로그인 기능은 인증 실패 시 CustomAuthenticationEntryPoint를 통해 일관된 응답을 제공하게 되었습니다.
  • 컨트롤러에서는 비즈니스 로직에만 집중할 수 있도록 인증 로직을 제거했습니다.
  • 인증 실패 처리를 중앙화하여 코드의 유지보수성과 가독성을 향상시켰습니다.

이쪽 부분에 대한 추가 설명으로, AuthenticationEntryPoint를 활용함으로써 인증 실패 시의 응답 처리를 통합하고, 보안 관련 예외에 대한 관리가 용이해졌습니다.


profile

0개의 댓글