WebAuthn 등록 옵션은 세션에 못 담는다 — 직렬화가 두 번 막힌 이야기

패스키 등록을 붙이는데 등록 버튼을 누르면 500 이 났습니다. SerializationException 이었습니다.

WebAuthn 등록 옵션을 세션에 넣은 게 원인이었는데, 고치는 과정에서 한 번 더 막혔습니다. 세션이 안 돼서 JSON 으로 바꿨더니 이번엔 저장은 되는데 다시 읽을 수가 없었습니다.

WebAuthn 등록 옵션을 세션에 담을 때와 JSON 으로 저장할 때를 비교한 흐름도

Spring Security 7 / Jackson 2 / JDK 25 기준이다.

WebAuthn 등록 옵션은 요청 두 번 사이에 살아 있어야 한다

패스키 등록은 한 번에 끝나지 않는다.

① POST /webauthn/register/options
   서버가 난수 challenge 를 만들어 브라우저에 준다

② navigator.credentials.create(options)
   인증기(지문 등)가 키쌍을 만들고 challenge 에 서명한다

③ POST /webauthn/register
   서버가 ①에서 만든 challenge 와 대조해 검증한다

①과 ③ 사이에 challenge 를 서버 어딘가 들고 있어야 한다. 요청이 다르니 메모리 변수로는 안 되고, 세션이 제일 먼저 떠오른다.

PublicKeyCredentialCreationOptions 를 세션에 넣으면 될 것 같다. 안 된다.

테스트 1 — 세션에 담아본다

Spring Session 을 Redis 로 쓰면 기본 저장 방식이 JDK 직렬화다. 그래서 세션에 담긴 객체는 Serializable 이어야 한다.

WebAuthn 옵션을 하나 만들어 그대로 직렬화해봤다.

대상: org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions
  Serializable 구현 여부: false

=== 1. JDK 직렬화 (세션에 그냥 담을 때) ===
실패  java.io.NotSerializableException
  org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions

클래스 선언을 보면 그냥 이렇게 생겼다.

public final class PublicKeyCredentialCreationOptions {

Serializable 을 구현하지 않는다. 세션에 넣는 순간 저장 단계에서 터진다.

로컬에서 세션을 메모리로 쓰면 이 문제가 안 보인다는 게 고약하다. 톰캣 기본 세션은 객체를 그냥 들고 있어서 직렬화를 안 하기 때문이다. Redis 를 붙이는 순간에 처음 터진다.

테스트 2 — JSON 으로 바꾼다

그러면 JSON 으로 만들어 Redis 에 문자열로 넣으면 되겠다 싶었다. Spring Security 가 WebAuthn 용 Jackson 모듈을 제공한다.

ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new WebauthnJackson2Module());

직렬화는 잘 된다.

=== 2. Jackson 직렬화 ===
OK  {"rp":{"name":"demo","id":"localhost"},"user":{"name":"hoony","id":"sB0JzQJ0SbcO_XqqC1KW05JeQrlRgASteGXCXcmV0s4","displayName":"hoony"},"cha …

여기까지 보고 끝난 줄 알았다. 같은 모듈로 다시 읽으니 이렇게 됐다.

=== 3. Jackson 역직렬화 (같은 모듈로 다시 읽기) ===
실패  com.fasterxml.jackson.databind.exc.InvalidDefinitionException
  Cannot construct instance of `org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions`
  (no Creators, like default constructor, exist): cannot deserialize from Object value
  (no delegate- or property-based Creator)

WebauthnJackson2Module 안을 보면 이유가 나온다. 등록하는 게 전부 mixin 인데, 그 mixin 들이 직렬화 쪽만 정의한다. 기본 생성자도 @JsonCreator 도 없으니 Jackson 이 객체를 만들 방법이 없다.

즉 이 모듈은 브라우저로 내보내는 용도다. 옵션을 JSON 으로 응답에 실어 보내는 게 목적이지, 저장했다 다시 읽는 걸 상정하지 않았다.

테스트 3 — 그래서 빌더로 재구성한다

읽는 쪽을 직접 만들었다. JSON 을 트리로 읽고 빌더로 다시 조립한다.

PublicKeyCredentialCreationOptions.builder()
    .rp(PublicKeyCredentialRpEntity.builder().id(...).name(...).build())
    .user(ImmutablePublicKeyCredentialUserEntity.builder()
        .id(Bytes.fromBase64(...))
        .name(...)
        .displayName(...)
        .build())
    .challenge(Bytes.fromBase64(...))
    .pubKeyCredParams(...)
    .timeout(Duration.ofMillis(...))
    .build();

값 타입이 대부분 valueOf 로 복원되는데, UserVerificationRequirement 처럼 getValue() 로만 문자열을 뱉는 것도 있어서 그건 따로 매핑해야 했다.

저장은 Redis 에 직접 넣는다. 세션에 얹지 않고 별도 키로 둔다.

webauthn:creation:{sessionId}   TTL 5분
webauthn:request:{sessionId}    TTL 5분

TTL 을 짧게 두는 건 challenge 의 성질 때문이다. 재사용을 막으려고 만든 난수라 오래 살아 있을 이유가 없다. 등록을 하다 만 사용자의 값도 알아서 사라진다.

정리

WebAuthn 등록 옵션을 담을 자리를 고르면서 배운 건 이거다. 세션은 “아무거나 넣는 곳”이 아니다. 저장소를 Redis 로 바꾸는 순간 넣는 객체가 JDK 직렬화를 통과해야 한다는 조건이 붙는다. 프레임워크가 만들어주는 객체라고 다 통과하지 않는다.

그리고 Jackson 모듈이 있다고 왕복이 되는 것도 아니다. 직렬화 전용 모듈이 따로 있다. 이번 건은 저장은 성공하고 읽기에서 실패해서, 문제가 한 박자 늦게 나왔다.

라이브러리가 제공하는 직렬화 지원을 쓸 때는 왕복을 한 번 돌려보는 게 빠르다. 저장만 해보고 넘어가면 나중에 읽는 자리에서 만난다.

같은 인증 서버에서 겪은 다른 직렬화 문제도 적어뒀다.

인가 정보에 실린 Authentication 이 복원 단계에서 거부되던 이야기는 Jackson 직렬화 허용 목록 에 있다.

참고

댓글 달기

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

위로 스크롤