T_era

스프링 시큐리티 인증 및 인가 흐름 본문

Programing/Spring

스프링 시큐리티 인증 및 인가 흐름

블스뜸 2025. 5. 26. 12:30

스프링 시큐리티는 복잡한 인증(Authentication) 및 인가(Authorization) 과정을 여러 컴포넌트를 통해 체계적으로 처리한다.

  1. 클라이언트 요청: 사용자가 리소스 접근을 위해 요청을 보낸다.
  2. 인증 객체 생성: 요청에서 **사용자 이름(username)**과 **비밀번호(password)**를 추출하여 Authentication 객체를 생성한다.
  3. AuthenticationManager를 통한 인증 수행:
    • AuthenticationManager의 기본 구현체인 ProviderManager가 요청된 Authentication 객체를 처리할 적절한 AuthenticationProvider를 선택한다.
  4. AuthenticationProvider의 인증 로직 수행:
    • 선택된 AuthenticationProvider (예: DaoAuthenticationProvider for DB 인증, AnonymousAuthenticationProvider for 익명 사용자)가 실제 인증 로직을 수행한다.
  5. 사용자 조회 및 인증 객체 생성/반환: AuthenticationProvider는 데이터베이스 등에서 사용자 정보를 조회하고, 인증에 성공하면 사용자의 정보를 담은 인증된 Authentication 객체를 생성하여 반환한다.
  6. SecurityContextHolder에 인증 객체 저장: 인증이 완료된 Authentication 객체는 SecurityContextHolder에 저장된다. SecurityContextHolder는 애플리케이션 어디서든 현재 인증된 사용자 정보에 접근할 수 있도록 하는 저장소 역할을 한다.

  1. AuthorizationFilter의 인가 조회: 요청이 AuthorizationFilter에 도달하면, SecurityContextHolder에서 현재 인증된 Authentication 객체를 조회한다.
  2. AuthorizationManager의 인가 규칙 실행: AuthorizationManager는 authorizeHttpRequests에 정의된 URL 패턴 및 HTTP 메서드 규칙과 현재 요청을 매칭하고, 해당 인가 규칙을 실행한다.
  3. 인가 결과:
    • 인가 거부 시: AccessDeniedException이 발생하며, 설정된 AccessDeniedHandler에 의해 처리된다.
    • 인가 성공 시: 요청은 정상적으로 처리되어 다음 필터나 컨트롤러로 넘어간다.

스프링 시큐리티와 JWT 설정 예시

JWT 기반 인증을 위한 스프링 시큐리티 설정 예시를 통해 위에서 설명한 흐름이 어떻게 코드에 적용되는지 살펴보겠다.

// SecurityConfig.class
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtUtil jwtUtil;
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    private final CustomAccessDeniedHandler customAccessDeniedHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .cors(Customizer.withDefaults()) // CORS 설정 (프론트엔드와 협업 시 필요)
                .csrf(AbstractHttpConfigurer::disable) // CSRF 보호 비활성화 (Stateless REST API에선 불필요)
                .httpBasic(AbstractHttpConfigurer::disable) // HTTP Basic 인증 비활성화
                .formLogin(AbstractHttpConfigurer::disable) // HTML Form 로그인 비활성화

                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 사용 안 함 (JWT 기반이므로)
                )

                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/login").permitAll() // "/login" 경로는 인증 없이 허용
                        .requestMatchers("/logout").authenticated() // "/logout" 경로는 인증된 사용자만 허용
                        .requestMatchers(HttpMethod.POST, "/post/admin").hasRole(UserRole.ADMIN.name()) // POST "/post/admin"은 ADMIN 역할만 허용
                        .requestMatchers(HttpMethod.POST, "/post/user").hasRole(UserRole.USER.name())   // POST "/post/user"는 USER 역할만 허용
                        .anyRequest().denyAll() // 위 조건을 제외한 모든 요청은 거부
                )

                // 필터 등록: JwtFilter를 UsernamePasswordAuthenticationFilter 이전에 추가
                .addFilterBefore(new JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class)

                .exceptionHandling(configurer -> // 예외 처리 설정
                        configurer
                                .authenticationEntryPoint(customAuthenticationEntryPoint) // 인증 실패 시 처리할 핸들러
                                .accessDeniedHandler(customAccessDeniedHandler)     // 인가 실패 시 처리할 핸들러
                )
                .build();
    }
}

설정 항목 설명

  • cors(Customizer.withDefaults()): CORS(Cross-Origin Resource Sharing) 설정을 활성화한다. 백엔드 자체에서 발생하는 문제는 아니지만, 웹 브라우저 기반의 프론트엔드와 연동 시 교차 출처 요청을 허용하기 위해 필요하다.
  • csrf(AbstractHttpConfigurer::disable): CSRF(Cross-Site Request Forgery) 보호를 비활성화한다. JWT와 같은 Stateless REST API에서는 세션을 사용하지 않으므로 CSRF 공격으로부터 상대적으로 안전하여 비활성화하는 것이 일반적이다.
  • httpBasic(AbstractHttpConfigurer::disable): HTTP Basic 인증 방식을 비활성화한다.
  • formLogin(AbstractHttpConfigurer::disable): HTML 폼 기반 로그인을 비활성화한다.
  • sessionManagement(...):
    • sessionCreationPolicy(SessionCreationPolicy.STATELESS): 세션을 사용하지 않도록 설정한다. JWT는 클라이언트 측에서 토큰을 관리하므로 서버에 세션 상태를 유지할 필요가 없다.
  • authorizeHttpRequests(...): 요청에 대한 인가(Authorization) 규칙을 정의한다.
    • requestMatchers(URI).permitAll(): 해당 URI에 대한 인증 및 인가 검사를 생략하고 모든 접근을 허용한다.
    • requestMatchers(URI).authenticated(): 해당 URI에 대한 접근은 인증된 사용자만 허용하며, 특정 역할(Role)이나 권한(Authority)은 요구하지 않는다.
    • requestMatchers(HttpMethod.POST, URI).hasRole(UserRole.ADMIN.name()): 특정 HTTP 메서드(POST)와 URI에 대해 지정된 역할(ADMIN)을 가진 사용자만 접근을 허용한다.
    • anyRequest().denyAll(): 위에 정의된 규칙을 제외한 모든 요청을 거부한다.
  • addFilterBefore(new JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class):
    • **커스텀 필터(JwtFilter)**를 스프링 시큐리티 필터 체인에 추가한다.
    • UsernamePasswordAuthenticationFilter.class 이전에 JwtFilter를 실행하도록 지정하여, JWT 기반 인증 로직이 먼저 수행되도록 한다.
  • exceptionHandling(...): DispatcherServlet 이전 단계, 즉 스프링 시큐리티 필터 체인에서 발생하는 인증 및 인가 예외를 처리한다.
    • authenticationEntryPoint(customAuthenticationEntryPoint): 인증 과정에서 예외(AuthenticationException)가 발생했을 때 처리할 핸들러를 지정한다. (예: 로그인하지 않은 사용자가 보호된 리소스에 접근 시)
    • accessDeniedHandler(customAccessDeniedHandler): 인가 과정에서 예외(AccessDeniedException)가 발생했을 때 처리할 핸들러를 지정한다. (예: 인증은 되었지만 권한이 없는 사용자가 접근 시)

커스텀 예외 처리기 예시

스프링 시큐리티 필터 체인에서 발생하는 예외를 사용자에게 친화적인 JSON 형태로 응답하기 위한 커스텀 핸들러 예시이다.

// CustomAuthenticationEntryPoint.class (인증 실패 시)
@Slf4j
@Component
@RequiredArgsConstructor
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper; // JSON 변환을 위한 ObjectMapper

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        log.error("Not Authenticated Request", authException);
        log.error("Request Uri : {}", request.getRequestURI());

        ErrorResponse errorResponse = new ErrorResponse(HttpStatus.UNAUTHORIZED.value(), authException.getMessage());
        String responseBody = objectMapper.writeValueAsString(errorResponse); // ErrorResponse 객체를 JSON 문자열로 변환

        response.setContentType(MediaType.APPLICATION_JSON_VALUE); // 응답 Content-Type을 JSON으로 설정
        response.setStatus(HttpStatus.UNAUTHORIZED.value()); // HTTP 상태 코드를 401 Unauthorized로 설정
        response.setCharacterEncoding("UTF-8"); // 문자 인코딩 설정
        response.getWriter().write(responseBody); // 응답 본문에 JSON 쓰기
    }
}
// CustomAccessDeniedHandler.class (인가 실패 시)
@Slf4j
@Component
@RequiredArgsConstructor
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        log.error("No Authorities", accessDeniedException);
        log.error("Request Uri : {}", request.getRequestURI());

        ErrorResponse errorResponse = new ErrorResponse(HttpStatus.FORBIDDEN.value(), accessDeniedException.getMessage());
        String responseBody = objectMapper.writeValueAsString(errorResponse);

        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpStatus.FORBIDDEN.value()); // HTTP 상태 코드를 403 Forbidden으로 설정
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(responseBody);
    }
}

JWT 필터 예시 코드

JWT 유효성 검증 및 인증 객체 생성을 담당하는 커스텀 필터 예시이다.

// JwtFilter.class
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter { // 요청당 한 번만 실행되는 필터

    private final JwtUtil jwtUtil; // JWT 관련 유틸리티

    @Override
    public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        String bearerJwt = request.getHeader("Authorization"); // Authorization 헤더에서 JWT 추출

        // JWT가 없으면 다음 필터로 넘김 (인증이 필요 없는 요청일 수 있음)
        if (bearerJwt == null) {
            chain.doFilter(request, response);
            return;
        }

        String jwt = jwtUtil.substringToken(bearerJwt); // "Bearer " 접두사 제거

        try {
            // JWT 유효성 검사 및 Claims 추출
            Claims claims = jwtUtil.extractClaims(jwt);
            if (claims == null) {
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
                return;
            }

            Long userId = jwtUtil.getUserId(jwt);
            UserRole userRole = jwtUtil.getUserRole(jwt);
            String email = jwtUtil.getEmail(jwt);

            AuthUser authUser = new AuthUser(userId, email, userRole); // 인증된 사용자 정보 객체 생성

            // UsernamePasswordAuthenticationToken 생성 및 SecurityContextHolder에 저장
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                    authUser, "", List.of(new SimpleGrantedAuthority("ROLE_" + userRole.name())) // 사용자 정보, 비밀번호(여기선 필요없음), 권한
            );
            SecurityContextHolder.getContext().setAuthentication(authenticationToken); // SecurityContextHolder에 인증 객체 설정

            chain.doFilter(request, response); // 다음 필터로 요청 전달

        } catch (SecurityException | MalformedJwtException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다."); // 401 응답
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다."); // 401 응답
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다."); // 400 응답
        } catch (Exception e) {
            log.error("Internal server error", e);
            response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); // 500 응답
        }
    }
}

JWT와 스프링 시큐리티 병용의 이유

JWT만 사용한다면 '인증'에 국한된 기능만을 직접 구현하는 것이 되지만, 스프링 시큐리티를 함께 사용하는 이유는 다음과 같다.

  • 포괄적인 보안 프레임워크: 스프링 시큐리티는 인증뿐만 아니라 인가(Authorization) 기능, 세션 관리(필요시), CSRF 보호, 다양한 공격 방어 등 웹 애플리케이션 보안 전반에 걸친 광범위한 기능을 제공한다.
  • 표준화된 구조: 보안 관련 로직을 직접 구현하는 대신, 스프링 시큐리티의 표준화된 필터 체인 및 컴포넌트 구조를 활용하여 안정적이고 유지보수하기 쉬운 보안 시스템을 구축할 수 있다.
  • 유연한 확장성: 필요에 따라 AuthenticationProvider, UserDetailsService, AuthenticationEntryPoint 등 다양한 인터페이스를 구현하여 커스텀 인증/인가 로직을 쉽게 통합하고 확장할 수 있다.

즉, JWT는 인증을 위한 토큰 방식을 제공하고, 스프링 시큐리티는 이 토큰을 활용하여 인증 및 인가와 같은 포괄적인 보안 기능을 구현하고 관리하는 강력한 프레임워크 역할을 수행한다.