Spring Security 필터 순서 — 인증 수단 등록을 URL 인가로 막을 수 없었다

2단계 인증을 붙이고 나서 생각해볼 게 하나 더 있었습니다. 인증 수단을 등록하고 해제하는 화면 자체를 어떻게 지킬 것인가입니다.

패스워드만 맞힌 세션에서 2단계 인증을 꺼버릴 수 있으면, 2단계 인증을 붙인 의미가 없어집니다. 비밀번호를 손에 넣은 사람이 제일 먼저 할 일이 그거니까요.

경로 인가 규칙으로 막으려다 안 돼서 Spring Security 필터 순서를 직접 찍어봤고, 거기서 답이 나왔습니다.

Spring Security 필터 순서에서 AuthorizationFilter 가 맨 마지막에 서는 것을 보여주는 구성도

Spring Boot 4.0.0 / Spring Security 7 / JDK 25 기준이다.

새 수단 등록은 기존 수준으로 인증된 세션에서만

이건 우리가 정한 규칙이 아니라 표준 쪽 권고다. NIST SP 800-63B 는 새 인증 수단을 계정에 묶는(바인딩) 작업을 기존 보증 수준으로 인증된 세션에서만 허용하라고 한다.

깃허브도 같은 모양이다. 2단계 인증을 켜둔 계정에서 설정 화면에 들어가려 하면 비밀번호를 다시 묻는다.

그래서 지켜야 할 경로가 이렇게 정리됐다.

/auth/mfa/**            2단계 인증 켜기·끄기, TOTP 등록
/webauthn/register/**   패스키 등록·삭제

강한 수단을 이미 가진 사용자가 1차 인증만으로 이 경로에 들어오면 막아야 한다. 아직 아무 수단도 없는 사용자는 최초 등록을 해야 하니 열어줘야 한다.

먼저 시도한 것 — 경로 인가 규칙

Spring Security 에서 경로를 막는 첫 번째 방법은 authorizeHttpRequests 다.

http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/webauthn/register/**").access(강한_factor_필요)
    .anyRequest().authenticated());

이게 안 통했다. 규칙을 걸어뒀는데 등록 요청이 그대로 처리됐다.

테스트 1 — Spring Security 필터 순서를 그대로 찍어본다

추측하지 말고 실제 체인을 뽑아봤다. FilterChainProxy 에서 필터 목록을 그대로 출력하는 엔드포인트를 하나 만들었다.

@GetMapping("/filters")
public String filters() {
    SecurityFilterChain chain = this.filterChainProxy.getFilterChains().get(0);
    StringBuilder sb = new StringBuilder();
    int i = 1;
    for (Filter f : chain.getFilters()) {
        sb.append(String.format("%2d. %s%n", i++, f.getClass().getSimpleName()));
    }
    return sb.toString();
}

폼 로그인과 일회용 토큰 로그인만 켠 최소 설정에서 나온 결과다.

 1. DisableEncodeUrlFilter
 2. WebAsyncManagerIntegrationFilter
 3. SecurityContextHolderFilter
 4. HeaderWriterFilter
 5. LogoutFilter
 6. GenerateOneTimeTokenFilter
 7. UsernamePasswordAuthenticationFilter
 8. OneTimeTokenAuthenticationFilter
 9. DefaultResourcesFilter
10. DefaultResourcesFilter
11. DefaultLoginPageGeneratingFilter
12. DefaultLogoutPageGeneratingFilter
13. DefaultOneTimeTokenSubmitPageGeneratingFilter
14. RequestCacheAwareFilter
15. SecurityContextHolderAwareRequestFilter
16. AnonymousAuthenticationFilter
17. ExceptionTranslationFilter
18. AuthorizationFilter

AuthorizationFilter 가 18번, 맨 마지막이다.

인증을 처리하는 필터들은 6~8번에 있다. 이 필터들은 자기가 맡은 경로의 요청을 받으면 거기서 처리하고 응답까지 끝낸다. 체인을 더 안 내려간다.

그러니까 URL 인가 규칙은 그 요청을 아예 못 본다. 규칙이 잘못된 게 아니라 순서상 도달하지 않는다.

패스키를 켜면 WebAuthnRegistrationFilter 도 같은 자리에 들어간다. 등록 POST 를 그 필터가 먼저 처리해버리니, 아래에 있는 인가 규칙으로는 막을 방법이 없다.

테스트 2 — 그래서 필터로 옮겼다

막으려면 그 필터보다 앞에 서야 한다. 가드를 필터로 만들어 인증 필터들 앞에 끼웠다.

public class CredentialManagementGuardFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {
        if (!isCredentialManagementPath(request)) {
            chain.doFilter(request, response);
            return;
        }
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (hasNoCredential(auth)) {
            chain.doFilter(request, response);   // 최초 등록(부트스트랩)은 허용
            return;
        }
        if (hasStrongFactor(auth)) {
            chain.doFilter(request, response);
            return;
        }
        if ("GET".equals(request.getMethod())) {
            stepUp(request, response);           // 2차 인증으로 보내고 끝나면 돌려보낸다
            return;
        }
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}

자격증명 관리 경로에서 가드 필터가 최초 등록·강한 factor·GET step-up·변경 403 으로 갈라지는 흐름도

동선을 나눈 이유가 있다.

GET 은 step-up 으로 보낸다. 관리 화면을 열려는 것뿐이니 2차 인증을 받고 다시 오게 하면 된다. 여기서 SavedRequest 가 유지돼야 원래 화면으로 돌아온다.

변경 요청은 403 으로 끊는다. 등록·해제 POST 나 DELETE 는 리다이렉트로 돌려보낼 게 아니라 그냥 거절한다.

아무 수단도 없는 사용자는 통과시킨다. 이게 없으면 신규 사용자가 최초 등록을 못 한다. 잠긴 문에 열쇠를 넣어두는 셈이라 예외가 필요하다.

화면을 숨기는 걸로는 부족하다

처음에는 등록 화면 링크를 조건부로 노출하는 것도 생각했다. 그것만으로는 안 된다.

WebAuthn 등록 엔드포인트는 URL 이 고정이다. Spring Security 코드에 박혀 있어서 바꿀 수 없다.

POST /webauthn/register/options
POST /webauthn/register
DELETE /webauthn/register/{id}

주소를 아는 사람은 화면을 거치지 않고 바로 요청을 보낼 수 있다. 링크를 숨기는 건 눈에서 감추는 것이지 막는 게 아니다. 엔드포인트 자체를 막아야 한다.

정리

Spring Security 에서 “이 경로를 막고 싶다”는 요구는 대개 authorizeHttpRequests 로 해결된다. 그런데 인증 필터가 직접 처리하는 경로는 예외다. 인가 규칙보다 앞에 있어서 규칙이 도달하지 않는다.

막으려는 경로를 누가 처리하는지, 즉 Spring Security 필터 순서에서 어디에 서는지 먼저 확인하는 게 순서다. 필터 목록을 찍어보면 5분이면 안다. 저 엔드포인트 목록을 안 보고 인가 규칙만 고치다가 시간을 썼다.

같은 인증 서버에서 factor 를 어떻게 쌓는지는 Spring Security 7 MFA factor 누적 에 적어뒀다.

참고

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤