Spring Security, 직접 만들면서 이해해보자! - 2편: 인증, 인가, 예외 처리

2025. 5. 2. 12:47·서버
※ 시리즈 글
Spring Security, 직접 만들면서 이해해보자! - 1편: 아키텍처 이해 
Spring Security, 직접 만들면서 이해해보자! - 2편: 인증, 인가, 예외 처리 [현재 글]
Spring Security, 직접 만들면서 이해해보자! - 3편: FilterChainProxy와 Lambda DSL

들어가며

안녕하세요! 저번 글에서 Spring Security의 아키텍처에 대해서 가볍게 알아보았습니다. 이번 글에서는 본격적으로 Spring Security의 구현에 들어가겠습니다. 구현 과정은 목차를 확인하시고 순서대로 따라가시면 됩니다. 기본적으로 구현되어 있는 부분은 "app" 패키지에 있고, 다음과 같습니다. step-1-initial-setup을 참고하시면 됩니다.

  • 사용자 이름과 비밀번호로 회원가입과 로그인을 할 수 있다.
  • 로그인 시 JWT를 쿠키(이름은 "access_token")에 담아 전달한다.

즉, 로그인을 담당하는 AbstractAuthenticationProcessingFilter를 구현하지 않습니다. Spring Security는 기본적으로 인증된 정보를 세션에 저장하지만 요즘에는 인증 방식으로 JWT가 널리 사용되고 있습니다. 이에 맞게 조금 변경해서 구현해보겠습니다. 구현될 부분은 "security" 패키지에 담기게 됩니다.

인증(Authentication)

목표

이번 단계의 목표는 다음과 같습니다.

  • JWT Authentication Filter를 통해, 인증 처리를 할 수 있다.
  • 정상적으로 인증되면 인증 정보가 SecurityContext에 저장된다.
  • 쿠키가 없거나 인증 과정에서 문제가 발생할 경우 401 예외를 반환한다.

이번 단계에서 구현될 내용은 step-2-authentication을 확인하시면 됩니다.

SecurityContextHolder

Spring Secutiy는 인증 정보를 저장하기 위해서 Authentication, SecurityContext, 그리고 SecurityContextHolder를 사용합니다. SecurityContext는 ThreadLocal로 저장되며, 이는 요청동안(요청마다 하나의 스레드가 할당) 인증 정보가 유지될 수 있습니다.

https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html

// 경로 /security/context/Authentication

public interface Authentication {

    Object principal();
}

Principal에는 사용자 정보가 들어갑니다. Credentials는 로그인 과정에서 들어가는 부분이므로 제외합니다. Authorities는 사용자가 가진 권한을 나타내는 부분이고, 현재 단계에서는 필요하지 않으므로 제외합니다. Authentication은 인터페이스로 다양한 구현체들을 만들어 사용할 수 있습니다. 

// 경로 /security/context/SecurityContext

@Getter
@Setter
public class SecurityContext {

    private Authentication authentication;

    public static SecurityContext createEmptyContext() {
        return new SecurityContext(null);
    }

    public SecurityContext(Authentication authentication) {
        this.authentication = authentication;
    }
}

SecurityContext는 Authentication을 담을 수 있습니다.

// 경로 /security/context/SecurityContextHolder

public class SecurityContextHolder {

    private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<>();

    private SecurityContextHolder() {
    }

    public static SecurityContext getContext() {
        SecurityContext context = contextHolder.get();
        if (context == null) {
            context = SecurityContext.createEmptyContext();
            contextHolder.set(context);
        }
        return context;
    }

    public static void clearContext() {
        contextHolder.remove();
    }
}

SecurityContextHolder는 ThreadLocal을 통해 SecurityContext를 관리합니다. SecurityContextHolder는 싱글톤 패턴을 사용하였습니다. getContext를 통해 SecurityContext가 있을 경우, 반환하고, 없을 경우 만들어서 반환합니다.

ThreadLocal은 요청이 끝났다고 해서 자동으로 정리되지 않습니다. 그러므로 요청이 끝났을 경우, 명시적으로 정리해야 합니다. 이 내용은 뒷 부분에서 다시 설명하겠습니다.

인증 예외 처리

인증과 관련된 예외는 AuthenticationException을 사용합니다. 그리고 보통 인증과 관련된 예외가 발생하면 AuthenticationEntryPoint 인터페이스의 commence 메서드를 통해 처리하게 됩니다. 인터페이스의 이름이 굉장히 어색하게 느껴질 수 있는데 의미는 다음과 같습니다. Sprint Security는 기본적으로 인증이 필요한 요청이 왔을 때, 인증되지 않았을 경우 로그인 페이지로 이동하게 됩니다. 그래서 Authentication Entry Point라는 이름을 사용한 것이니다. 

 

현재 구현하는 Spring Security에서는 API 서버를 기준으로 합니다. 그래서 이름은 AuthenticationEntryPoint지만 HttpStatus 401에 Json으로 응답하는 방식으로 구현하겠습니다. 

// 경로 /security/exception/AuthenticationException

public class AuthenticationException extends RuntimeException {

    public AuthenticationException(String message) {
        super(message);
    }

    public AuthenticationException(String message, Throwable cause) {
        super(message, cause);
    }
}
// 경로 security/exception/AuthenticationEntryPoint

public interface AuthenticationEntryPoint {

    void commence(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException exception
    ) throws IOException, ServletException;
}
// 경로 /security/exception/AuthenticationEntryPointImpl

@Component
@RequiredArgsConstructor
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    @Override
    public void commence(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException exception
    ) throws IOException {
        String message = exception.getMessage() == null ? "Unauthorized" : exception.getMessage();
        String body = objectMapper.writeValueAsString(
                ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, message)
        );

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(body);
    }
}

AuthenticationEntryPoint는 인터페이스로 다양한 구현체를 만들 수 있습니다. 이 글에서는 기본 값으로 AuthenticationEntryPointImpl을 사용하여, HttpStatus 401에 JSON 응답을 하게 됩니다. 사용자는 AuthenticationEntryPoint를 구현한 다른 방식으로 커스텀할 수 있습니다.

 

현재 "/app/infra" 경로의 JwtTokenProvider가 던지는 예외가 IllegalArgumentException입니다. 이것을 인증 예외인 AuthenticationException으로 변경하게습니다.

// 경로 /app/infra/JwtTokenProvider

@Component
public class JwtTokenProvider implements TokenProvider {

    ...

    private Claims toClaims(String token) {
        if (token == null || token.isBlank()) {
            throw new AuthenticationException("토큰은 비어있을 수 없습니다."); // IllegalArgumentException을 변경
        }

        try {
            Jws<Claims> claimsJws = getClaimsJws(token);

            return claimsJws.getPayload();
        } catch (ExpiredJwtException e) {
            throw new AuthenticationException("만료된 토큰입니다.", e); // IllegalArgumentException을 변경
        } catch (JwtException e) {
            throw new AuthenticationException("유효하지 않은 토큰입니다.", e); // IllegalArgumentException을 변경
        }
    }
    
    ...
}

토큰이 비어 있거나, 만료되었거나 또는 유효하지 않을 경우 AuthenticationException을 던져서 다른 곳에서 처리하게 변경하였습니다.

인증 필터 구현

이제, 이 단계에서 제일 중요한 부분인 인증 필터를 구현하는 부분입니다. 필터를 작성하기 전에, 구성요소들을 먼저 구현해보겠습니다.

// 경로 /security/authentication/JwtAuthentication

public class JwtAuthentication implements Authentication {

    private final Long memberId;

    public JwtAuthentication(Long memberId) {
        this.memberId = memberId;
    }

    @Override
    public Long principal() {
        return memberId;
    }

    @Override
    public String toString() {
        return "%s: [ memberId= %s ]".formatted(getClass().getSimpleName(), memberId);
    }
}

Authentication을 구현한 JwtAuthentication입니다. 보통 Principal에는 사용자 정보가 담기게 됩니다. 현재 많은 정보가 필요하다고 생각되지는 않아서 memberId만 넣도록 하겠습니다. principal을 반환하면 memberId가 나오게 됩니다. 

참고로, Spring Security에서 Form Login 시에는 UsernamePasswordAuthenticationToken을 사용합니다.
// 경로 /security/authentication/TokenResolver

@Component
@RequiredArgsConstructor
public class TokenResolver {

    @Value("${security.cookie.token.key}")
    private String key;

    public Optional<String> extractAccessToken(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();

        if (cookies == null || cookies.length == 0) {
            return Optional.empty();
        }

        return Arrays.stream(cookies)
                .filter(cookie -> key.equals(cookie.getName()))
                .map(Cookie::getValue)
                .findFirst();
    }
}

TokenResolver는 이름이 "access_token"인 쿠키의 값을 Optional로 반환하는 클래스입니다. 크게 관련 있는 부분이 아니므로 빠르게 넘어가셔도 됩니다.

// 경로 /security/authentication/JwtAuthenticationFilter

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;
    private final TokenResolver tokenResolver;
    private final AuthService authService;
    private final AuthenticationEntryPoint authenticationEntryPoint;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        try {
            Authentication authentication = attemptAuthentication(request);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            filterChain.doFilter(request, response);
        } catch (AuthenticationException e) {
            authenticationEntryPoint.commence(request, response, e);
        } finally {
            SecurityContextHolder.clearContext();
        }
    }

    private Authentication attemptAuthentication(HttpServletRequest request) {
        Optional<String> tokenOpt = tokenResolver.extractAccessToken(request);
        if (tokenOpt.isEmpty()) {
            throw new AuthenticationException("액세스 토큰 쿠키가 비어있습니다.");
        }

        Long memberId = tokenProvider.getMemberId(tokenOpt.get());
        MemberInfoResponse memberInfo = authService.getMemberInfo(memberId);

        return new JwtAuthentication(memberInfo.memberId());
    }
}

JwtAuthenticationFilter는 OncePerRequestFilter를 상속받아서 만들어졌습니다. 이 부분은 아래에서 더 자세히 알아보겠습니다.

 

Filter는 filterChain.doFilter(request, response)을 기준으로 요청 전, 요청 후 처리를 합니다. 즉, attemptAuthentication을 해서 인증 정보를 얻고, SecurityContext에 그 정보를 저장합니다. doFilter를 통해, 다음 필터를 실행하거나 DispatcherServlet으로 이동합니다. 응답 단계애서 다시 finally의 SecurityContextHolder.clearContext가 실행됩니다. 다음의 그림으로 쉽게 이해하실 수 있습니다.

필터의 작동 과정

현재 단계에서는 finally에서 clearContext를 통해 ThreadLocal 값을 정리하게 됩니다. 이 부분은 step-6에서 SecurityContextHolderFilter를 통해서 대체됩니다.

attemptAuthentication은 AbstractAuthenticationProcessingFilter의 메서드에서 따온 이름입니다. 비슷한 인증 과정으로, 쿠키에서 JWT을 추출하고, JWT에서 memberId를 가져옵니다. 해당 사용자가 데이터베이스에 있는지 확인하기 위해서 authService.getMemberInfo를 사용합니다. 결과적으로 JwtAuthentication에 memberId를 담아서 반환합니다.

 

여기서 쿠키가 없거나, 토큰이 없거나, 토큰이 만료되거나, 토큰이 유효하지 않은 경우 AuthenticationException을 던지게 됩니다. AuthenticationException은 잡혀서 authenticationEntryPoint.commence를 호출하게 됩니다. 이 경우, 401 예외를 반환하게 됩니다.

OncePerRequestFilter

JwtAuthenticationFilter는 OncePerRequestFilter를 상속받습니다. OncePerRequestFilter는 GenericFilterBean을 상속받아서 만들어집니다. GenericFilterBean은 Filter 인터페이스를 구현한 추상 클래스입니다. 

 

Filter는 서블릿 표준이고, GenericFilterBean은 ApplicationContext, Bean 처리 등을 도와주는 Spring의 필터 편의 클래스입니다. 일반적으로 Spring에서 필터를 생성하기 위해서 GenericFilterBean을 사용합니다. 

 

OncePerRequestFilter는 요청에 대해서 필터를 한 번만 실행하는 필터입니다. "필터는 당연히 한 번만 실행되는게 아닌가?"라고 의아해하실 수 있습니다. forward가 이 경우에 해당됩니다. foward(Request Dispatcher)는 현재 요청을 가지고(request, response 그대로 유지) 클라이언트에 응답을 보내지 않고, 서버 내부에 다른 서블릿(또는 JSP)로 요청을 넘깁니다. 예를 들어, "/first"에서 "/second"로 forward를 한다면, 클라이언트가 "/first"를 요청했을 때, URL은 "/first"로 그대로지만 응답은 "/second"로 받습니다. forward 시 실행 흐름은 다음과 같습니다.

 

/first의 필터 요청 전 -> /first의 내부 실행 -> /first에서 /second로 forward -> /second의 필터 요청 전 -> /second 내부 실행 -> /second의 필터 요청 후 -> /first의 필터 요청 후

참고로, redirect는 클라이언트에게 응답 후, 재요청을 유도하는 것입니다.

OncePerRequestFilter는 Dispatcher Type이 ERROR, ASYNC일 경우 생략하고, FORWARD 타입일 경우, ".FILTERED" 속성을 통해, 한 번만 실행하도록 합니다. 

인증 필터 등록

위에서 구현한 인증 필터를 등록해보겠습니다. Spring에서는 @Component를 통해서 등록하거나 @Bean, FilterRegistrationBean을 사용해서 명시적으로 등록할 수 있습니다.

// 경로 /security/SecurityConfig

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final TokenProvider tokenProvider;
    private final TokenResolver tokenResolver;
    private final AuthService authService;
    private final AuthenticationEntryPoint authenticationEntryPoint;

    @Bean
    public FilterRegistrationBean<JwtAuthenticationFilter> jwtAuthenticationFilter() {
        JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(
                tokenProvider,
                tokenResolver,
                authService,
                authenticationEntryPoint
        );

        FilterRegistrationBean<JwtAuthenticationFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(jwtAuthenticationFilter);
        registrationBean.addUrlPatterns("/api/*");
        registrationBean.setOrder(1);

        return registrationBean;
    }
}

setFilter를 통해서 작성한 JwtAuthenticationFilter를 틍록할 수 있고, addUrlPatterns로 필터의 대상이 되는 경로를 지정할 수 있습니다. "/api/*"의 경우, "/api/a", "/api/b" 등 "/api"로 시작하는 경로들이 대상이 됩니다. 참고로, "/api"는 매칭되지 않습니다. setOrder로는 필터의 순서를 정할 수 있습니다. 작은 값이 먼저 실행됩니다.

 

필터를 등록할 때, setDispatcherTypes를 통해 허용할 DispatcherType을 선택할 수 있습니다. 기본 값으로 REQUEST만 허용합니다. 이 경우, FORWARD는 적용되지 않으므로, FORWARD 시 필터가 실행되지 않습니다. 이러면 OncePerRequestFilter가 의미없는 선택이 될 수 있습니다. 이해를 쉽게 하기위해서 생략했고, Step 7 FilterChainProxy에서 다시 알아보도록 하겠습니다.

결과

이번 Step에서는 인증 필터를 직접 등록하고, 쿠키에 JWT가 없거나, 유효하지 않을 경우 401 예외를 반환하도록 했습니다. 인증되었을 경우, SecurityContextHolder에서 인증 정보를 꺼낼 수 있도록 했습니다.

 

변경된 /app/ui/TestController와 테스트 코드를 통해서 목표가 달성되었는지 확인합니다.

인가(Authorization)

목표

이번 단계의 목표는 다음과 같습니다.

  • 인증되지 않았을 경우, 예외를 AuthorizationFilter에서 처리하도록 변경한다.
  • 사용자의 권한에 따른 접근 제어를 할 수 있다. 
  • 접근 권한이 없을 경우, 403 예외를 반환한다.

이번 단계에서 구현될 내용은 step-3-authorization을 확인하시면 됩니다.

인가 예외 처리

인증 부분의 AuthenticationException, AuthenticationEntryPoint와 동일하게 인가 예외에 대해서 구현하겠습니다.

// 경로 /security/exception/AuthorizationDeniedException

public class AuthorizationDeniedException extends RuntimeException {

    public AuthorizationDeniedException(String message) {
        super(message);
    }

    public AuthorizationDeniedException(String message, Throwable cause) {
        super(message, cause);
    }
}

Spring Security는 AccessDeniedException(java.nio.file 아님)과 그것을 상속한 AuthorizationDeniedException을 사용합니다. 여기서는 구현의 편의를 위해서 AuthorizationDeniedException만 사용하도록 하겠습니다.

// 경로 /security/exception/AccessDeniedHandler

public interface AccessDeniedHandler {

    void handle(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthorizationDeniedException exception
    ) throws IOException, ServletException;
}
// 경로 /security/exception/AccessDeniedHandlerImpl

@Component
@RequiredArgsConstructor
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    @Override
    public void handle(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthorizationDeniedException exception
    ) throws IOException {
        String message = exception.getMessage() == null ? "Access Denied" : exception.getMessage();
        String body = objectMapper.writeValueAsString(
                ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, message)
        );

        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(body);
    }
}

인증 예외 처리의 AuthenticationEntryPoint와 동일하게 AccessDeniedHandler는 인터페이스로 사용자가 다른 구현체를 커스텀해서 사용할 수 있습니다. 기본 값으로 HttpStatus 403과 JSON으로 응답하도록 구현했습니다.

권한(Authority) 적용

인가(Authorization)는 접근 권한이 있는지를 확인하는 것입니다. 접근 제어를 하기 위해서는 권한을 먼저 만들어야 합니다.

// 경로 /security/authorization/GrantedAuthority

public interface GrantedAuthority {

    String getAuthority();
}
// 경로 /security/authorization/SimpleGrantedAuthority

public class SimpleGrantedAuthority implements GrantedAuthority {

    private final String role;

    public SimpleGrantedAuthority(String role) {
        this.role = role;
    }

    @Override
    public String getAuthority() {
        return role;
    }
}

GrantedAuthority는 권한에 대한 인터페이스고, SimpleGrantedAuthority는 GrantedAuthority의 구현체입니다. 쉽게 권한을 담는 객체라고 이해하시면 될 것 같습니다.

// security/context/Authentication

public interface Authentication {

    Object principal();

    Collection<? extends GrantedAuthority> getAuthorities(); // 추가된 부분
}

권한이 생김에 따라 Authentication도 수정해주도록 합니다. 사용자는 여러 개의 권한을 가질 수 있으므로, Collection으로 반환하도록 합니다.

// 경로 /security/authentication/JwtAuthentication

public class JwtAuthentication implements Authentication {

    private final Long memberId;
    private final Collection<GrantedAuthority> authorities;

    public JwtAuthentication(Long memberId, Collection<? extends GrantedAuthority> authorities) {
        this.memberId = memberId;
        this.authorities = new ArrayList<>(authorities);
    }

    @Override
    public Long principal() {
        return memberId;
    }

    @Override
    public Collection<GrantedAuthority> getAuthorities() {
        return new ArrayList<>(authorities);
    }

    @Override
    public String toString() {
        return "%s: [ memberId= %s ]".formatted(getClass().getSimpleName(), memberId);
    }
}

Authentication의 구현체인 JwtAuthentication에도 권한 모음에 대한 필드를 추가하고, 권한들을 반환하도록 합니다.

// 경로 /security/authentication/JwtAuthenticationFilter

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;
    private final TokenResolver tokenResolver;
    private final AuthService authService;
    private final AuthenticationEntryPoint authenticationEntryPoint;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        try {
            Authentication authentication = attemptAuthentication(request);
            // 변견된 부분
            if (authentication == null) {
                filterChain.doFilter(request, response);
                return;
            }

            SecurityContextHolder.getContext().setAuthentication(authentication);
            filterChain.doFilter(request, response);
        } catch (AuthenticationException e) {
            authenticationEntryPoint.commence(request, response, e);
        } finally {
            SecurityContextHolder.clearContext();
        }
    }

    private Authentication attemptAuthentication(HttpServletRequest request) {
        Optional<String> tokenOpt = tokenResolver.extractAccessToken(request);
        if (tokenOpt.isEmpty()) {
            return null; // 변경된 부분
        }

        Long memberId = tokenProvider.getMemberId(tokenOpt.get());
        MemberInfoResponse memberInfo = authService.getMemberInfo(memberId);

        // 변경된 부분
        return new JwtAuthentication(memberInfo.memberId(), mapToAuthorities(memberInfo.role()));
    }

    // 추가된 부분
    private Collection<GrantedAuthority> mapToAuthorities(Role... roles) {
        List<GrantedAuthority> authorities = new ArrayList<>();
        for (Role role : roles) {
            authorities.add(new SimpleGrantedAuthority(role.toString()));
        }
        return authorities;
    }

이전 글에서, Spring Security에서 인증이 필요한데 인증되지 않을 경우 예외를 반환하는 곳은 인증 관련 필터가 아니라 AuthorizationFilter라고 언급했습니다. 그러므로, attemptAuthentication 메서드에서 Access Token 쿠키가 없을 경우, 예외를 반환하는게 아니라 null을 반환하는 것으로 변경합니다. 또한, doFilterInternal에서 attemptAuthentication의 반환 값이 null일 경우, 그냥 다음 필터로 넘어가도록 변경합니다.

 

attempAuthentication의 반환값인 JwtAuthentication을 생성하기 위해서는 사용자의 권한이 필요합니다. mapToAuthorities 메서드는 사용자의 권한들을 GrantedAuthority로 변경해줍니다.

이 예제에서는 구현의 편의를 위해 사용자가 권한을 하나만 들고 있게 했습니다. 즉, 사용자는 "MEMBER" 또는 "ADMIN" 권한을 가지게 됩니다.

AuthorizationFilter

AuthorizationFilter는 AuthorizationManager를 주입받아서 생성합니다. AuthorizationFilter는 필터 내에서 현재 인증 정보인 Authentication을 AuthorizationManager의 authorize 메서드에 전달하여 AuthorizationResult를 생성합니다. 이 결과에 따라서, 접예외를 반환하거나 다음 필터를 진행하게 됩니다.

// 경로 /security/AuthorizationManager

@FunctionalInterface
public interface AuthorizationManager {

    AuthorizationResult authorize(Supplier<Authentication> authentication, HttpServletRequest request);
}
// 경로 /security/AuthorizationResult

public interface AuthorizationResult {

    boolean isGranted();
}

AuthorizeManager는 AuthorizationResult를 반환하는 authorizate 메서드만 있는 함수형 인터페이스입니다.

// 경로 /security/authorization/AuthorizationDecision

public class AuthorizationDecision implements AuthorizationResult {

    private final boolean granted;

    public AuthorizationDecision(boolean granted) {
        this.granted = granted;
    }

    @Override
    public boolean isGranted() {
        return this.granted;
    }

    @Override
    public String toString() {
        return "%s: [ granted= %s ]".formatted(getClass().getSimpleName(), granted);
    }
}

AuthorizationDecision은 AuthorizationResult의 구현체입니다. 일반적으로 접근 권한이 있는지 여부를 담는 역할을 합니다.

// 경로 /security/authorization/AuthorityAuthorizationDecision

public class AuthorityAuthorizationDecision extends AuthorizationDecision {

    private final Collection<GrantedAuthority> authorities;

    public AuthorityAuthorizationDecision(boolean granted, Collection<GrantedAuthority> authorities) {
        super(granted);
        this.authorities = authorities;
    }

    @Override
    public String toString() {
        return "%s: [ granted= %s, authorities= %s ]".formatted(getClass().getSimpleName(), isGranted(), authorities);
    }
}

AuthorityAuthorizationDecision은 AuthorizationDecision을 상속한 클래스로 필요한 권한 목록을 필드로 가지게 됩니다.

AuthorizationManager에서 AuthorizationResult를 반환하지만 toString으로 해당 AuthorizationManager에서 필요한 권한 목록을 출력할 수 있습니다.
// 경로 /security/authorization/AuthorityAuthorizationManager

public class AuthorityAuthorizationManager implements AuthorizationManager {

    private final Set<String> authorities;

    public static AuthorityAuthorizationManager hasAuthority(String authority) {
        return new AuthorityAuthorizationManager(authority);
    }

    public AuthorityAuthorizationManager(String... authorities) {
        this.authorities = Set.of(authorities);
    }

    @Override
    public AuthorizationResult authorize(Supplier<Authentication> authentication, HttpServletRequest request) {
        boolean isGranted = isGranted(authentication.get(), authorities);

        return new AuthorityAuthorizationDecision(isGranted, createAuthorities(authorities));
    }

    private boolean isGranted(Authentication authentication, Collection<String> authorities) {
        return authentication != null && isAuthenticated(authentication, authorities);
    }

    private boolean isAuthenticated(Authentication authentication, Collection<String> authorities) {
        for (GrantedAuthority grantedAuthority : authentication.getAuthorities()) {
            if (authorities.contains(grantedAuthority.getAuthority())) {
                return true;
            }
        }
        return false;
    }

    private Collection<GrantedAuthority> createAuthorities(Collection<String> authorities) {
        List<GrantedAuthority> grantedAuthorities = new ArrayList<>(authorities.size());
        for (String authority : authorities) {
            grantedAuthorities.add(new SimpleGrantedAuthority(authority));
        }
        return grantedAuthorities;
    }
}

AuthorityAuthorizationManager는 AuthorizationManager의 구현체로 접근 권한을 제어하는 핵심적인 구체 클래스입니다. authorize 메서드 내에서 authentication이 null이 아니거나 사용자가 접근에 필요한 권한을 가진 경우 isGranted=true를 반환하게 됩니다.

// 경로 /security/authorization/AuthorizationFilter

@RequiredArgsConstructor
public class AuthorizationFilter extends GenericFilterBean {

    private final AuthorizationManager authorizationManager;
    private final AuthenticationEntryPoint authenticationEntryPoint;
    private final AccessDeniedHandler accessDeniedHandler;

    @Override
    public void doFilter(
            ServletRequest servletRequest,
            ServletResponse servletResponse,
            FilterChain filterChain
    ) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        AuthorizationResult result = authorizationManager.authorize(() -> authentication, request);

        if (result != null && !result.isGranted()) {
            handleAccessDeniedException(request, response, authentication);
            return;
        }

        filterChain.doFilter(request, response);
    }

    private void handleAccessDeniedException(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication
    ) throws IOException, ServletException {
        if (isAnonymous(authentication)) {
            authenticationEntryPoint.commence(request, response, new AuthenticationException("인증되지 않은 사용자입니다."));
            return;
        }
        accessDeniedHandler.handle(request, response, new AuthorizationDeniedException("접근을 위한 권한이 없습니다."));
    }

    private boolean isAnonymous(Authentication authentication) {
        return authentication == null;
    }
}

앞서 설명했던 것처럼 AuthorizationFilter는 AuthorizationManager를 주입받고, authorize 메서드를 통해 인가 결과를 받습니다. 인가 되지 않은 경우, unsucessfulAuthentication 메서드를 통해 예외 처리를 하게 됩니다. 여기서 authenticaion이 없는 즉, 인증되지 않았을 경우 AuthenticationEntryPoint를 통해 401 예외를 반환하게 됩니다. 나머지 경우에는 AccessDeniedHandler를 통해 403 예외를 반환하게 됩니다.

Spring Security에서는 인증되지 않은 사용자의 경우, AnonymousAuthenticationFilter를 거쳐, Authentication의 구현체인 AnonymousAuthenticationToken을 저장하게 됩니다. 이 예제에서는 구현의 복잡도를 줄이기 위해서 isAnonymous 메서드와 같이 authentication이 null인지 여부로 대체했습니다.

AuthorizationFilter 적용

// 경로 /security/SecurityConfig

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final TokenProvider tokenProvider;
    private final TokenResolver tokenResolver;
    private final AuthService authService;
    private final AuthenticationEntryPoint authenticationEntryPoint;
    private final AccessDeniedHandler accessDeniedHandler;

    @Bean
    public FilterRegistrationBean<JwtAuthenticationFilter> jwtAuthenticationFilter() {
        JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(
                tokenProvider,
                tokenResolver,
                authService,
                authenticationEntryPoint
        );

        FilterRegistrationBean<JwtAuthenticationFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(jwtAuthenticationFilter);
        registrationBean.addUrlPatterns("/api/*");
        registrationBean.setOrder(1);

        return registrationBean;
    }

    @Bean
    public FilterRegistrationBean<AuthorizationFilter> authorityAuthorizationFilter() {
        AuthorizationFilter authorizationFilter = new AuthorizationFilter(
                AuthorityAuthorizationManager.hasAuthority("ADMIN"),
                authenticationEntryPoint,
                accessDeniedHandler
        );

        FilterRegistrationBean<AuthorizationFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(authorizationFilter);
        registrationBean.addUrlPatterns("/api/private/admin/*");
        registrationBean.setOrder(2);

        return registrationBean;
    }

    @Bean
    public FilterRegistrationBean<AuthorizationFilter> authenticatedAuthorizationFilter() {
        AuthorizationManager authenticatedAuthorizationManager =
                (authentication, request) -> new AuthorizationDecision(authentication.get() != null);
        AuthorizationFilter authorizationFilter = new AuthorizationFilter(
                authenticatedAuthorizationManager,
                authenticationEntryPoint,
                accessDeniedHandler
        );

        FilterRegistrationBean<AuthorizationFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(authorizationFilter);
        registrationBean.addUrlPatterns("/api/*");
        registrationBean.setOrder(3);

        return registrationBean;
    }
}

authorityAuthorizationFilter와 authenticatedAuthorizationFilter를 순서대로 등록하겠습니다. authorityAuthorizationFilter는 AuthorityAuthorizationManager.hasAuthority("ADMIN")을 사용해서 접근 제어를 합니다. "/api/private/admin/*"에 접근하기 위해서는 "ADMIN" 권한이 필요함을 의미합니다. authenticatedAuthorizationFilter는 authenticatedAuthorizationManager를 사용해서 접근 제어를 합니다. authenticatedAuthorizationManager는 인증 정보가 있을 경우 AuthorizationDecision의 isGranted가 true가 됩니다. 즉, "/api/*"에 접근하기 위해서는 인증이 되어야 함을 의미합니다.

결과

이번 Step에서는 인가 필터를 직접 등록해봤습니다. 특정 권한이 필요한 경로에 대해서 접근 권한이 없을 경우 403 예외를 반환하게 했습니다. 그리고 인증이 필요한 경로에 대해서 인증되지 않았을 경우 401 예외를 반환하게 했습니다.

 

변경된 /app/ui/TestController와 테스트 코드를 통해서 목표가 달성되었는지 확인합니다.

인가(Authorization) - Advanced

목표

기존 방식대로라면, 특정 경로에 대한 접근 제어를 위해 매번 새로운 필터를 등록해야 합니다. 하지만 필터의 수가 많아질수록 가독성이 떨어지고, 실행 순서를 관리하기도 복잡해집니다. 이러한 문제를 해결하기 위해 RequestMatcherDelegatingManager를 사용합니다. 쉽게 말해, 하나의 AuthorizationManager에 여러 경로별 접근 제어 규칙을 등록하고, AuthorizationFilter는 이 AuthorizationManager 하나만 사용하면 됩니다. 이번 단계의 목표는 RequestMatcherDelegatingManager를 적용해 하나의 AuthorizationFilter에서 모든 접근 제어를 처리하는 것입니다.

 

이번 단계에서 구현될 내용은 step-4-authorization-advanced을 확인하시면 됩니다.

RequestMatcher

RequestMatcherDelegatingManager는 등록된 경로와 AuthorizationManager 목록 중, 요청 경로와 일치하는 항목을 찾아 해당 AuthorizationManager의 authorize메서드를 실행합니다. 그러므로, 경로, 경로와의 일치 여부, 경로와 AuthorizationManager에 해당하는 것들을 구현해야 합니다.

// 경로 /security/util/RequestMatcher

@RequiredArgsConstructor
public class RequestMatcher {

    public static final RequestMatcher ANY_REQUEST = new RequestMatcher(null, "/**");

    private final HttpMethod method;
    private final String pattern;

    public boolean matches(HttpServletRequest request) {
        boolean pathMatches = pathMatches(request.getRequestURI());

        if (method == null) {
            return pathMatches;
        }

        return pathMatches && method.matches(request.getMethod());
    }

    private boolean pathMatches(String path) {
        if (pattern.endsWith("/**")) {
            String base = pattern.substring(0, pattern.length() - 3);
            return path.startsWith(base);
        }
        return path.equals(pattern);
    }
}

RequestMatcher는 http method와 pattern을 필드로 가집니다. matches 메서드는 경로와의 일치 여부를 판단합니다. method가 null일 경우, 모든 메서드에 대해 매칭됩니다. pathMatches 메서드는 "/**" 모든 하위 경로를 처리하기 위해 만들어졌습니다. 예를 들어, "/api/**"일 경우 "/api", "/api/posts" 등이 매칭됩니다.

Spring Security에서 RequestMatcher는 interface로 다양한 구현체가 만들어져있습니다. matches 메서드의 파라미터 타입은 HttpServletRequest입니다. 그래서 예시처럼 http method, pattern 말고도 header의 값을 꺼내서 비교하는 등 다양한 방법을 적용할 수 있습니다. 이 예제에서는 구현의 복잡도를 줄이기 위해서 구체 클래스로 구현했습니다.
// 경로 /security/util/RequestMatcherEntry

public record RequestMatcherEntry<T>(RequestMatcher matcher, T entry) {
}

RequestMatcherEntry는 RequestMatcherEntry<AuthorizationManager>로 사용되며, 경로와 AuthorizationManager를 담기 위해서 만들어졌습니다. 제네릭으로 만든 이유는 util 패키지가 authorization 패키지의 AuthorizationManager를 의존하는 것을 막기 위해서 입니다.

RequestMatcherDelegatingAuthorizationManager

AuthorizationFilter에서는 RequestMatcherDelegatingAuthorizationManager를 통해 매칭되는 경로의 AuthorizationManager의 authorize 메서드를 사용하는 것을 위임합니다.

https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html

// 경로 /security/authorization/RequestMatcherDelegatingAuthorizationManager

public class RequestMatcherDelegatingAuthorizationManager implements AuthorizationManager {

    private final List<RequestMatcherEntry<AuthorizationManager>> mappings;

    public RequestMatcherDelegatingAuthorizationManager() {
        this.mappings = new ArrayList<>();
    }

    @Override
    public AuthorizationResult authorize(Supplier<Authentication> authentication, HttpServletRequest request) {
        for (RequestMatcherEntry<AuthorizationManager> mapping : mappings) {
            RequestMatcher matcher = mapping.matcher();
            AuthorizationManager manager = mapping.entry();

            if (matcher.matches(request)) {
                return manager.authorize(authentication, request);
            }
        }

        return new AuthorizationDecision(false);
    }

    public RequestMatcherDelegatingAuthorizationManager add(RequestMatcher matcher, AuthorizationManager manager) {
        this.mappings.add(new RequestMatcherEntry<>(matcher, manager));
        return this;
    }
}

RequestMatcherDelegatingAuthorizationManager는 List<RequestMatcherEntry<Authorization>>을 필트로 가지고 있고, add 메서드를 통해서 특정 경로와 그에 해당되는 AuthorizationManager를 등록할 수 있습니다. authorize 메서드는 모든 mapping들을 순회하면서 매칭되는 경로를 찾고, 매칭될 경우 해당되는 AuthorizationManager의 authorize 메서드를 실행합니다.

반복문을 통해 순회하므로, 먼저 등록된 RequestMatcherEntry가 우선순위가 높습니다.

RequestMatcherDelegatingAuthorizationManager 등록

// 경로 /security/SecurityConfig

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final TokenProvider tokenProvider;
    private final TokenResolver tokenResolver;
    private final AuthService authService;
    private final AuthenticationEntryPoint authenticationEntryPoint;
    private final AccessDeniedHandler accessDeniedHandler;

    @Bean
    public FilterRegistrationBean<JwtAuthenticationFilter> jwtAuthenticationFilter() {
        JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(
                tokenProvider,
                tokenResolver,
                authService,
                authenticationEntryPoint
        );

        FilterRegistrationBean<JwtAuthenticationFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(jwtAuthenticationFilter);
        registrationBean.addUrlPatterns("/api/*");
        registrationBean.setOrder(1);

        return registrationBean;
    }

    @Bean
    public FilterRegistrationBean<AuthorizationFilter> authorizationFilter() {
        AuthorizationManager authenticated =
                (authentication, request) -> new AuthorizationDecision(authentication.get() != null);

        RequestMatcherDelegatingAuthorizationManager authorizationManager = new RequestMatcherDelegatingAuthorizationManager()
                .add(new RequestMatcher(null, "/api/private/admin/**"), AuthorityAuthorizationManager.hasAuthority("ADMIN"))
                .add(new RequestMatcher(null, "/api/**"), authenticated);
        AuthorizationFilter authorizationFilter = new AuthorizationFilter(
                authorizationManager,
                authenticationEntryPoint,
                accessDeniedHandler
        );

        FilterRegistrationBean<AuthorizationFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(authorizationFilter);
        registrationBean.addUrlPatterns("/api/*");
        registrationBean.setOrder(2);
        return registrationBean;
    }
}

기존의 authorityAuthorizationFilter와 authenticatedAuthorizationFilter를 RequestMatcherDelegatingAuthorizationManager를 통해 합쳤습니다. 이제 AuthorizationFilter에서 RequestMatcherDelegatingAuthorizationManager에 등록된 경로와 매칭되는 것을 찾고, 그에 해당되는 AuthorizationManager의 authorize 메서드를 실행합니다.

결과

이번 Step에서 TestController와 테스트 코드는 변경되지 않았습니다. RequestMatcherDelegatingAuthorizationManager로 변경하고도 기존 테스트가 잘 통과하는지 확인합니다.

예외 처리 - ExceptionTranslationFilter

목표

현재 AuthorizationFilter에서 인가 처리와 예외 처리를 모두 담당하고 있습니다. ExceptionTranslationFilter를 통해 책임을 분리해보겠습니다.

 

이번 단계에서 구현될 내용은 step-5-exception-translation-filter를 확인하시면 됩니다.

AuthorizationFilter 수정

// 경로 /security/authorization/AuthorizationFilter

@RequiredArgsConstructor
public class AuthorizationFilter extends GenericFilterBean {

    private final AuthorizationManager authorizationManager;
    // 사용하지 않는 필드 제거

    @Override
    public void doFilter(
            ServletRequest servletRequest,
            ServletResponse servletResponse,
            FilterChain filterChain
    ) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        AuthorizationResult result = authorizationManager.authorize(() -> authentication, request);

        if (result != null && !result.isGranted()) {
            throw new AuthorizationDeniedException("접근을 위한 권한이 없습니다."); // 수정된 부분
        }

        filterChain.doFilter(request, response);
    }
}

handleAccessDeniedException 메서드를 통해서 조건에 따라 AuthenticationEntryPoint 또는 AccessDeniedHandler로 처리하는 코드는 ExceptionTranslationFilter에서 처리하게 됩니다. 단순하게 AuthorizationDeniedException 예외를 던지는 것으로 수정합니다. 또한 사용하지 않는 AuthenticationEntryPoint, AccessDeniedHandler 필드를 제거합니다.

ExceptionTranslationFilter

// 경로 /security/exception/ExceptionTranslationFilter

@RequiredArgsConstructor
public class ExceptionTranslationFilter extends GenericFilterBean {

    private final AuthenticationEntryPoint authenticationEntryPoint;
    private final AccessDeniedHandler accessDeniedHandler;

    @Override
    public void doFilter(
            ServletRequest servletRequest,
            ServletResponse servletResponse,
            FilterChain filterChain
    ) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        try {
            filterChain.doFilter(request, response);
        } catch (AuthenticationException e) {
            authenticationEntryPoint.commence(request, response, e);
        } catch (AuthorizationDeniedException e) {
            if (isAnonymous()) {
                authenticationEntryPoint.commence(request, response, new AuthenticationException("인증되지 않은 사용자입니다."));
            } else {
                accessDeniedHandler.handle(request, response, e);
            }
        }
    }

    private boolean isAnonymous() {
        SecurityContext context = SecurityContextHolder.getContext();
        return context.getAuthentication() == null;
    }
}

ExceptionTranslationFilter는 AuthorizationFilter보다 앞에 등록되어 Authorization에서 발생한 예외를 잡을 수 있습니다. 구현 내용은 AuthorizationFilter와 동일합니다.

ExceptionTranslationFilter 등록

// 경로 /security/SecurityConfig

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final TokenProvider tokenProvider;
    private final TokenResolver tokenResolver;
    private final AuthService authService;
    private final AuthenticationEntryPoint authenticationEntryPoint;
    private final AccessDeniedHandler accessDeniedHandler;

    @Bean
    public FilterRegistrationBean<JwtAuthenticationFilter> jwtAuthenticationFilter() {
        JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(
                tokenProvider,
                tokenResolver,
                authService,
                authenticationEntryPoint
        );

        FilterRegistrationBean<JwtAuthenticationFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(jwtAuthenticationFilter);
        registrationBean.addUrlPatterns("/api/*");
        registrationBean.setOrder(1);

        return registrationBean;
    }

    @Bean
    public FilterRegistrationBean<ExceptionTranslationFilter> exceptionTranslationFilter() {
        ExceptionTranslationFilter exceptionTranslationFilter = new ExceptionTranslationFilter(
                authenticationEntryPoint,
                accessDeniedHandler
        );

        FilterRegistrationBean<ExceptionTranslationFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(exceptionTranslationFilter);
        registrationBean.addUrlPatterns("/api/*");
        registrationBean.setOrder(2);

        return registrationBean;
    }

    @Bean
    public FilterRegistrationBean<AuthorizationFilter> authorizationFilter() {
        AuthorizationManager authenticated =
                (authentication, request) -> new AuthorizationDecision(authentication.get() != null);

        RequestMatcherDelegatingAuthorizationManager authorizationManager = new RequestMatcherDelegatingAuthorizationManager()
                .add(new RequestMatcher(null, "/api/private/admin/**"), AuthorityAuthorizationManager.hasAuthority("ADMIN"))
                .add(new RequestMatcher(null, "/api/**"), authenticated);
        AuthorizationFilter authorizationFilter = new AuthorizationFilter(authorizationManager);

        FilterRegistrationBean<AuthorizationFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(authorizationFilter);
        registrationBean.addUrlPatterns("/api/*");
        registrationBean.setOrder(3);
        return registrationBean;
    }
}

위에서 설명했듯이, ExceptionTranslationFilter는 AuthorizationFilter보다 앞에 등록됩니다. 즉, Authentication Filter -> ExceptionTranslationFilter -> AuthorizationFilter 순으로 필터가 등록됩니다.

결과

이번 Step에서 TestController와 테스트 코드는 변경되지 않았습니다. 예외 처리의 책임을 ExceptionTranslationFilter로 분리하고도 기존의 테스트가 정상적으로 동작하는지 확인합니다.

SecurityContext 정리 - SecurityContextHolderFilter

목표

서블릿 기반 웹 서버에서는 요청 1건마다 하나의 스레드가 할당되어 처리됩니다. ThreadLocal은 요청동안 유지되며, 요청이 끝난 뒤 자동으로 값을 정리하지 않습니다. WAS는 성능을 위해 스레드를 재사용하는 스레드 풀을 운영합니다. 즉, 요청 A에서 ThreadLocal에 값을 저장하고 정리하지 않으면, 다음 요청 B에서 같은 스레드를 쓸 경우, 그 값을 그대로 사용할 수 있는 문제가 있습니다. 이 문제를 SecurityContextHolderFilter로 해결해보겠습니다.

 

이번 단계에서 구현될 내용은 step-6-security-context-holder-filter를 확인하시면 됩니다.

SecurityContextHolderFilter

SecurityContextHolderFilter는 Filter 중 거의 제일 앞에서 실행되며 요청이 끝난 뒤 finally를 통해서 ThreadLocal의 값을 정리합니다.

// 경로 /security/context/SecurityContextHolderFilter

public class SecurityContextHolderFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } finally {
            SecurityContextHolder.clearContext();
        }
    }
}

Spring Security SecurityContextHolderFilter의 역할은 이것보다 많습니다. Spring Security는 기본적으로 세션을 통해 로그인을 유지합니다. 로그인 이후 요청이 들어올 경우, 세션을 통해 SecurityContext을 얻어 SecurityContextHolder에 저장해줍니다.

// 경로 /security/authentication/JwtAuthenticationFilter

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    ... 생략

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        try {
           ... 생략
        } catch (AuthenticationException e) {
            authenticationEntryPoint.commence(request, response, e);
        } 
        // finally로 처리되던 부분을 제거한다.
    }
    
   ... 생략
}

JwtAuthenticationFilter에서는 SecurityContextHolderFilter를 구현하기 전까지 finally를 통해서 SecurityContext를 정리해줬습니다. 이 부분은 이제 필요없으니 제거해줍니다.

SecurityContextHolderFilter 등록

// 경로 /security/SecurityConfig

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    ... 생략

    @Bean
    public FilterRegistrationBean<SecurityContextHolderFilter> securityContextHolderFilter() {
        SecurityContextHolderFilter securityContextHolderFilter = new SecurityContextHolderFilter();
        FilterRegistrationBean<SecurityContextHolderFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(securityContextHolderFilter);
        registrationBean.addUrlPatterns("/api/*");
        registrationBean.setOrder(0);
        return registrationBean;
    }

    @Bean
    public FilterRegistrationBean<JwtAuthenticationFilter> jwtAuthenticationFilter() {
        ... 생략
    }

    @Bean
    public FilterRegistrationBean<ExceptionTranslationFilter> exceptionTranslationFilter() {
        ... 생략
    }

    @Bean
    public FilterRegistrationBean<AuthorizationFilter> authorizationFilter() {
        ... 생략
    }
}

요청이 끝난 후 SecurityContext를 정리하는 부분은 제일 마지막에 필요한 부분입니다. 그러므로 SecurityContextHolderFilter 필터를 제일 앞에 위치시킵니다.

결과

이번 Step에서 TestController와 테스트 코드는 변경되지 않았습니다. SecurityContextHolderFilter를 통해 요청이 끝난 후 SecurityContext를 정리시켜, 보안적인 문제가 발생하지 않도록 했습니다. 기존의 테스트가 정상작으로 동작하는지 확인합니다.

마치며

이번 글에서는 Spring Security의 인증, 인가, 예외 처리 등을 직접 구현해보았습니다. 하지만 아직 부족한 점이 많습니다. 특정 경로에 대해서 인증 필터, 인가 필터를 계속 추가하게 된다면 필터는 늘어나고 순서를 관리하기 힘들어지게 됩니다. 또한, 인증과 인가를 설정하는 코드가 복잡하여 쉽게 구성할 수 없는 문제가 있습니다.

 

이런한 문제를 해결법을 다음 글인 3편: FilterChainProxy와 Lambda DSL에서 알아보겠습니다.

 

읽어주셔서 감사합니다!

참고

  • Spring Security's High-Level Architecture
  • Spring Security's Authentication Architecture
  • Spring Security's Authorization Architecture

'서버' 카테고리의 다른 글

Spring Security, 직접 만들면서 이해해보자! - 3편: FilterChainProxy와 Lambda DSL  (0) 2025.05.02
Spring Security, 직접 만들면서 이해해보자! - 1편: 아키텍처 이해  (1) 2025.04.30
로컬 환경과 CI 환경의 시간 정밀도 차이로 인한 테스트 실패 해결  (0) 2025.04.01
Presigned URL과 CDN으로 이미지 업로드 & 조회 개선  (0) 2025.03.07
테스트 후 데이터 정리를 통해 테스트 간 데이터 독립성 보장  (0) 2025.03.03
'서버' 카테고리의 다른 글
  • Spring Security, 직접 만들면서 이해해보자! - 3편: FilterChainProxy와 Lambda DSL
  • Spring Security, 직접 만들면서 이해해보자! - 1편: 아키텍처 이해
  • 로컬 환경과 CI 환경의 시간 정밀도 차이로 인한 테스트 실패 해결
  • Presigned URL과 CDN으로 이미지 업로드 & 조회 개선
alstn113
alstn113
웹 프론트엔드, 서버 개발에 관한 이야기를 다룹니다 :D
  • alstn113
    alstn113's devlog
    alstn113
  • 전체
    오늘
    어제
    • 분류 전체보기 (50)
      • 서버 (21)
      • 웹 프론트엔드 (5)
      • 협업 (2)
      • 우아한테크코스 6기 백엔드 (12)
      • 책, 영상, 블로그 정리 (8)
      • 회고 (1)
  • 블로그 메뉴

    • 홈
  • 링크

    • Github
  • 공지사항

  • 인기 글

  • 태그

    회고
    플러피
    우아한테크코스
    글쓰기
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
alstn113
Spring Security, 직접 만들면서 이해해보자! - 2편: 인증, 인가, 예외 처리
상단으로

티스토리툴바