Spring Security MFA factor 누적 — 2차 인증이 1차 인증을 지워버렸다

인증 서버에 2단계 인증을 붙이고 나서, 패스워드로 로그인하고 일회용 토큰까지 넣었는데 보호된 자원이 계속 막혔습니다. 두 인증 다 성공 응답이 왔는데도 그랬습니다.

권한을 찍어보니 이유가 보였습니다. 2차 인증에 성공하는 순간 1차 인증의 표시가 사라져 있었습니다.

Spring Security MFA 는 인증 수단마다 표시를 붙이고 인가 시점에 조합을 확인하는 구조인데, 그 표시가 쌓이지 않고 있었습니다.

Spring Security MFA 의 factor 가 2차 인증에서 덮어써지는 경우와 병합되는 경우를 비교한 시퀀스 다이어그램

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

Spring Security MFA 는 권한으로 표현된다

Spring Security 7 의 다단계 인증은 별도 상태 기계가 아니다. “이 방식으로 인증했다”는 표시를 권한으로 붙이고, 인가 시점에 필요한 조합을 요구하는 구조다.

표시는 FactorGrantedAuthority 다. 상수가 이렇게 들어 있다.

public static final String PASSWORD_AUTHORITY = "FACTOR_PASSWORD";
public static final String OTT_AUTHORITY = "FACTOR_OTT";
public static final String WEBAUTHN_AUTHORITY = "FACTOR_WEBAUTHN";
public static final String X509_AUTHORITY = "FACTOR_X509";

요구하는 쪽은 AuthorizationManagerFactories 다.

AuthorizationManager<RequestAuthorizationContext> bothFactors =
    AuthorizationManagerFactories.<RequestAuthorizationContext>multiFactor()
        .requireFactors(FactorGrantedAuthority.PASSWORD_AUTHORITY,
            FactorGrantedAuthority.OTT_AUTHORITY)
        .build()
        .authenticated();

http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/secure").access(bothFactors)
    .anyRequest().authenticated());

여기까지는 문서대로다. 문제는 그 권한이 어떻게 쌓이느냐였다.

테스트 1 — 기본 상태로 두 번 인증한다

사용자 하나짜리 최소 프로젝트를 만들었다. 폼 로그인과 일회용 토큰 로그인을 둘 다 켜고, 매 단계마다 권한을 찍는다.

### demo.merge-factors=false

--- 1. 패스워드 로그인 ---
  로그인 HTTP 302
name=hoony via=UsernamePasswordAuthenticationToken authorities=[FACTOR_PASSWORD, ROLE_USER]

--- 2. 두 factor 가 필요한 자원 ---
 [HTTP 302]

--- 3. 일회용 토큰으로 2차 인증 ---
OTT OK hoony [FACTOR_OTT, ROLE_USER]
name=hoony via=OneTimeTokenAuthentication authorities=[FACTOR_OTT, ROLE_USER]

--- 4. 다시 그 자원 ---
 [HTTP 302]

3번을 보면 FACTOR_PASSWORD 가 없다. 2차 인증이 성공하면서 SecurityContext 의 Authentication 을 통째로 새 객체로 갈아치웠고, 새 객체에는 방금 붙인 FACTOR_OTT 만 들어 있다.

그래서 4번이 여전히 302다. 사용자 입장에서는 두 번 다 성공했는데 아무것도 안 열린다.

왜 덮어쓰는가

AbstractAuthenticationProcessingFilter 를 열어보면 병합이 조건부다.

Authentication current = this.securityContextHolderStrategy.getContext().getAuthentication();
if (shouldPerformMfa(current, authenticationResult)) {
    authenticationResult = authenticationResult.toBuilder()
        .authorities((a) -> {
            Set<String> newAuthorities = a.stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toUnmodifiableSet());
            for (GrantedAuthority currentAuthority : current.getAuthorities()) {
                if (!newAuthorities.contains(currentAuthority.getAuthority())) {
                    a.add(currentAuthority);
                }
            }
        })
        .build();
}

shouldPerformMfa 가 false 면 병합 없이 그냥 교체된다. 그 판정이 이렇게 생겼다.

private boolean shouldPerformMfa(@Nullable Authentication current, Authentication authenticationResult) {
    if (!this.mfaEnabled) {
        return false;
    }
    if (current == null || !current.isAuthenticated()) {
        return false;
    }
    if (!declaresToBuilder(authenticationResult)) {
        return false;
    }
    return current.getName().equals(authenticationResult.getName());
}

조건이 네 개다. mfaEnabled 의 기본값이 false 라서 아무 설정도 안 하면 항상 교체다.

나머지 셋도 그냥 넘길 게 아니다. 세 번째 조건은 특히 조용하다.

private static boolean declaresToBuilder(Authentication authentication) {
    for (Method method : authentication.getClass().getDeclaredMethods()) {
        if (method.getName().equals("toBuilder") && method.getParameterTypes().length == 0) {
            return true;
        }
    }
    return false;
}

getDeclaredMethods() 라서 상속받은 toBuilder() 는 쳐주지 않는다. 커스텀 Authentication 을 만들면서 부모 것을 그대로 쓰면, 설정을 다 켜놨는데도 병합이 안 된다. 예외도 로그도 없다.

테스트 2 — 병합을 켠다

@EnableMultiFactorAuthentication 이 이걸 전역으로 켜준다. 다만 필터 체인이 여럿이면 전역 적용이 곤란해서, 어노테이션이 하는 일을 필요한 체인에만 손으로 배선했다.

.oneTimeTokenLogin(ott -> ott
    .withObjectPostProcessor(new ObjectPostProcessor<OneTimeTokenAuthenticationFilter>() {
        @Override
        public <O extends OneTimeTokenAuthenticationFilter> O postProcess(O filter) {
            filter.setMfaEnabled(true);
            return filter;
        }
    })
    ...

같은 시나리오를 다시 돌린 결과다.

### demo.merge-factors=true

--- 1. 패스워드 로그인 ---
name=hoony via=UsernamePasswordAuthenticationToken authorities=[FACTOR_PASSWORD, ROLE_USER]

--- 3. 일회용 토큰으로 2차 인증 ---
OTT OK hoony [FACTOR_OTT, FACTOR_PASSWORD, ROLE_USER]
name=hoony via=OneTimeTokenAuthentication authorities=[FACTOR_OTT, FACTOR_PASSWORD, ROLE_USER]

--- 4. 다시 그 자원 ---
SECURE OK [FACTOR_OTT, FACTOR_PASSWORD, ROLE_USER] [HTTP 200]

이제 쌓인다. 두 factor 를 다 갖췄으니 자원도 열린다.

바뀐 건 설정 한 줄이고, 나머지 코드는 두 번 다 같다.

이 구조가 주는 것

권한으로 표현하니까 인증 수단을 더 붙여도 인가 규칙을 안 건드려도 된다. 패스키를 추가하면 FACTOR_WEBAUTHN 이 붙고, 그걸 단독 강인증으로 인정할지는 요구 조합만 바꾸면 된다.

게이트가 인정하는 조합
  WEBAUTHN 단독
  PASSWORD + OTT
  PASSWORD + TOTP

반대로 말하면, 표시가 안 쌓이면 이 설계 전체가 무너진다. 2차 인증까지 통과한 사용자가 계속 문 앞에서 막히는데 에러는 안 나는 상태가 된다.

인증 수단을 붙일 때 “로그인이 되나”만 보고 넘어가기 쉽다. 그다음 화면까지 한 번은 통과시켜봐야 한다.

같은 인증 서버에서 인증 경로마다 계정 잠금이 다르게 걸리던 이야기는 Spring Security 계정 상태 검사 단일화 에 적어뒀다.

참고

댓글 달기

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

위로 스크롤