Spring Security Jackson 직렬화 허용 목록 — OAuth2 토큰 교환이 500 으로 죽은 이유

일회용 토큰 로그인을 붙이고 나서, 로그인은 되는데 그다음이 안 되는 상태가 됐습니다. 로그인 성공 화면까지 잘 넘어가고 /oauth2/token 에서 500 이 떨어졌습니다.

패스워드 로그인은 멀쩡했습니다. 같은 사용자, 같은 클라이언트인데 로그인 수단만 바꾸면 죽었습니다.

원인은 Jackson 직렬화 허용 목록이었습니다. 인증에 성공한 객체가 저장은 되는데 복원이 안 되는 상태였습니다.

인가 정보에 실린 Authentication 이 저장과 복원을 거치며 Jackson 직렬화 허용 목록 검사에서 갈리는 상태 다이어그램

Jackson 직렬화 허용 목록이 인가 정보 복원을 막는다

JdbcOAuth2AuthorizationService 는 인가 정보를 DB 에 저장한다. 여기에 인증 주체(Principal)가 통째로 들어간다. 저장할 때 JSON 으로 만들고, /oauth2/token 으로 코드를 교환할 때 다시 객체로 복원한다.

이 왕복에 쓰는 ObjectMapper 는 아무 타입이나 복원하지 않는다. SecurityJackson2Modules 로 구성돼 있고, 여기에 Jackson 직렬화 허용 목록이 들어 있다.

이유는 역직렬화 자체가 공격 경로이기 때문이다. 임의의 클래스를 JSON 만 보고 만들 수 있으면 그걸로 원격 코드 실행까지 이어질 수 있다. 그래서 Spring Security 는 자기가 아는 타입만 복원한다.

문제는 이 목록에 OneTimeTokenAuthenticationWebAuthnAuthentication 이 없다는 것이다.

테스트 1 — 두 타입을 실제로 왕복시켜본다

인가 서버 전체를 띄우지 않고 왕복만 떼어냈다. SecurityJackson2Modules 를 등록한 ObjectMapper 로 직렬화했다가 다시 읽는다.

ObjectMapper mapper = new ObjectMapper();
mapper.registerModules(SecurityJackson2Modules.getModules(SerializationDemo.class.getClassLoader()));

roundTrip(mapper, UsernamePasswordAuthenticationToken.authenticated(user, null, authorities));
roundTrip(mapper, new OneTimeTokenAuthentication(user, authorities));

Spring Security 7.0.0 / Jackson 2.20.1 에서 돌린 결과다.

=== UsernamePasswordAuthenticationToken (패스워드 로그인) ===
직렬화 OK  {"@class":"org.springframework.security.authentication.UsernamePasswordAuthenticationToken","authorities":["java.util.Collections$UnmodifiableRandomAccessList", …
역직렬화 OK  UsernamePasswordAuthenticationToken name=hoony

=== OneTimeTokenAuthentication (일회용 토큰 로그인) ===
직렬화 OK  {"@class":"org.springframework.security.authentication.ott.OneTimeTokenAuthentication","authorities":["java.util.Collections$UnmodifiableRandomAccessList",[{"@c …
역직렬화 실패  java.lang.IllegalArgumentException
  The class with org.springframework.security.authentication.ott.OneTimeTokenAuthentication and name of org.springframework.security.authentication.ott.OneTimeTokenAuthentication is not in the allowlist. If you believe this class is safe to deserialize, please provide an explicit mapping using Jackson annotations or by providing a Mixin. If the serialization is only done by a trusted source, you can also enable default typing. See https://github.com/spring-projects/spring-security/issues/4370 for details

여기서 중요한 건 직렬화는 성공한다는 점이다.

저장은 아무 문제 없이 끝난다. DB 에도 정상적으로 들어간다. 그래서 로그인 시점에는 에러가 안 난다. 한참 뒤 토큰을 교환하려고 그 JSON 을 다시 읽을 때 처음으로 터진다.

이것 때문에 원인을 찾는 데 시간이 걸렸다. 로그인 필터를 아무리 봐도 이상한 게 없었다. 문제는 로그인이 남긴 물건이었지 로그인 과정이 아니었다.

고치는 방법이 두 가지다

첫째, Mixin 을 등록해서 그 타입을 허용 목록에 넣는다. 에러 메시지가 안내하는 방법이다.

둘째, 저장되기 전에 타입을 바꾼다. 인증 자체는 그 타입으로 하고, 인가 정보에 실릴 때는 이미 검증된 타입으로 치환한다.

두 번째를 골랐는데, 첫 번째를 안 해보고 고른 건 아니다.

테스트 2 — Mixin 이 정말 통하는지

에러 메시지가 안내하는 방법을 그대로 해봤다. OneTimeTokenAuthentication 의 생성자는 (principal, authorities) 두 개뿐이라 이 정도면 될 것처럼 보인다.

@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY, property = "@class")
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY,
    getterVisibility = JsonAutoDetect.Visibility.NONE,
    isGetterVisibility = JsonAutoDetect.Visibility.NONE)
@JsonIgnoreProperties(ignoreUnknown = true)
abstract static class OneTimeTokenAuthenticationMixin {

    @JsonCreator
    OneTimeTokenAuthenticationMixin(
        @JsonProperty("principal") Object principal,
        @JsonProperty("authorities") Collection<? extends GrantedAuthority> authorities) {
    }
}

mapper.addMixIn(OneTimeTokenAuthentication.class, ...) 로 등록하고 같은 왕복을 돌렸다.

=== ③ OneTimeTokenAuthentication + Mixin 등록 ===
직렬화 OK  {"@class":"org.springframework.security.authentication.ott.OneTimeTokenAuthentication","principal":{"@class":"org.springframework.security.core.userde …
역직렬화 OK  OneTimeTokenAuthentication name=hoony authenticated=true authorities=[ROLE_USER, FactorGrantedAuthority [authority=FACTOR_OTT, issuedAt=2026-08-25T12:29:38.548681500Z]]

된다. 인증 여부도 2단계 인증 표시도 그대로 살아 돌아온다. 허용 목록 검사가 Mixin 이 등록된 타입을 통과시키기 때문이다.

다만 저 애노테이션 네 줄이 그냥 나온 게 아니다. @JsonAutoDetect필드 가시성을 열어줘야 했고, @JsonProperty 이름이 실제 필드명과 맞아야 하고, 생성자 인자 순서도 맞아야 한다. 대상 클래스 안을 알아야 쓸 수 있는 코드다.

그래서 인증 수단이 늘어날수록 부담이 는다. 일회용 토큰, 패스키, 그다음에 또 뭔가를 붙일 때마다 Mixin 을 하나씩 만들고, 라이브러리가 올라가서 그 클래스의 필드 구조가 바뀌면 같이 손봐야 한다.

인가 정보에 저장되는 타입을 하나로 고정하면 그 뒤로는 신경 쓸 게 없다. 그래서 두 번째로 갔다.

돌리면서 하나 더 걸렸다. FactorGrantedAuthorityissuedAtInstant 라서, JavaTimeModule 을 등록하지 않으면 직렬화 단계에서 먼저 막힌다.

직렬화 실패  com.fasterxml.jackson.databind.exc.InvalidDefinitionException
  Java 8 date/time type `java.time.Instant` not supported by default: add Module
  "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling

테스트 3 — 로그인 성공 직후에 치환한다

인증이 끝난 직후, 성공 핸들러에서 사용자 타입 토큰으로 바꾼다. 권한은 그대로 옮긴다.

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
        Authentication authentication) throws IOException, ServletException {
    Authentication normalized = authentication;

    if (authentication instanceof OneTimeTokenAuthentication) {
        AbstractAuthenticationToken token =
            tokenFactory.apply(authentication.getPrincipal(), authentication.getAuthorities());
        token.setDetails(authentication.getDetails());
        SecurityContexts.replaceAndSave(token, securityContextRepository, request, response);

        normalized = token;
        log.info("OTT 인증을 {} 으로 정규화: {}", token.getClass().getSimpleName(), authentication.getName());
    }

    successHandler.onAuthenticationSuccess(request, response, normalized);
}

tokenFactory 를 주입받게 한 건 사용자 종류마다 토큰 타입이 다르기 때문이다. 관리자면 관리자 토큰, 일반 사용자면 일반 사용자 토큰으로 만들어진다. 핸들러 자체는 어느 쪽인지 모른다.

권한을 그대로 넘기는 게 핵심이다. 2단계 인증을 통과했다는 표시(FACTOR_OTT)가 여기서 빠지면, 정규화는 됐는데 인가에서 다시 막히는 상태가 된다.

패스키도 같은 모양의 핸들러를 하나 더 만들었다. 다만 패스키는 Principal 이 PublicKeyCredentialUserEntity 라서 그대로 못 옮긴다. username 으로 UserDetails 를 다시 조회해서 넣어야 한다.

정리

Jackson 직렬화 허용 목록은 안전장치다. 끄거나 우회할 게 아니라, 목록에 있는 타입만 저장되게 만드는 쪽이 맞다.

증상만 보면 OAuth2 설정 문제처럼 보인다. /oauth2/token 이 500 이니까 토큰 엔드포인트를 먼저 뒤지게 되는데, 실제로 잘못된 건 그보다 한참 앞에서 저장된 값이었다.

저장과 복원 사이에 시간 간격이 있으면 에러가 나는 지점과 원인이 있는 지점은 다르다.

인증 수단을 붙일 때 로그인 성공까지만 확인하고 넘어가기 쉽다. 발급까지 한 번은 끝까지 통과시켜봐야 한다.

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

참고

댓글 달기

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

위로 스크롤