SavedRequest 는 한 자리뿐이다 — 로그인 후 돌아갈 주소가 사라졌다

로그인 화면에서 비밀번호를 한 번 틀리고 다시 맞게 입력하면, 로그인은 되는데 원래 가려던 곳으로 안 갔습니다. 한 번에 성공하면 멀쩡했습니다.

인증 서버라 이게 그냥 불편함으로 끝나지 않았습니다. 원래 가려던 곳이 /oauth2/authorize 였기 때문에, 토큰 발급 흐름이 통째로 끊겼습니다.

SavedRequest 가 덮어써지면서 원래 주소가 사라지는 과정을 그린 상태 다이어그램

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

SavedRequest 는 누가 언제 저장하나

보호된 URL 에 인증 없이 접근하면 ExceptionTranslationFilter 가 그 요청을 붙잡는다. 그리고 두 가지를 한다.

① 지금 요청을 RequestCache 에 저장한다  (= SavedRequest)
② 로그인 화면으로 보낸다

로그인에 성공하면 SavedRequestAwareAuthenticationSuccessHandler 가 저장해둔 주소를 꺼내 그리로 돌려보낸다. 로그인 후 원래 자리로 돌아가는 게 이 구조 덕이다.

문제는 저장 자리가 하나라는 것이다. 쌓이지 않고 덮어쓴다.

테스트 1 — 한 번에 성공하면

최소 프로젝트로 재현했다. /secure/whoami 를 둘 다 보호해두고, 미인증으로 /secure 에 접근한 뒤 바로 로그인한다.

--- 1. 보호된 /secure 에 미인증 접근 ---
  HTTP 302
  → 여기서 SavedRequest = /secure

--- 2. 로그인하면 어디로 가는가 ---
Location: http://localhost:18082/secure?continue

원래 가려던 /secure 로 돌아간다. 정상이다.

테스트 2 — 중간에 다른 보호 URL 을 한 번 건드리면

같은 상황에서, 로그인하기 전에 /whoami 를 한 번 열어본다. 역시 미인증이라 막힌다.

--- 3. 같은 상황에서, 로그인 전에 다른 보호 URL 을 한 번 더 건드린다 ---
  SavedRequest = /secure
  /whoami 에 미인증 접근 → SavedRequest 가 덮어써짐

--- 4. 이제 로그인하면 어디로 가는가 ---
Location: http://localhost:18082/whoami?continue

/secure 가 사라졌다. 마지막에 막힌 주소가 이긴다.

이건 버그가 아니라 설계다. ExceptionTranslationFilter 는 미인증 접근을 볼 때마다 그 주소를 저장하고, 저장소는 덮어쓰기다. 사용자가 여러 페이지를 헤매다 로그인하면 마지막 것으로 가는 게 자연스럽기도 하다.

문제는 사용자가 아니라 스크립트가 그 URL 을 건드릴 때다.

실제로 무슨 일이 있었나

패스키 로그인 버튼에 Spring Security 가 제공하는 클라이언트 번들(/login/webauthn.js)을 썼다. 그 안에 이런 게 하드코딩돼 있다.

로그인 실패 → /login?error 로 이동
등록 성공   → /webauthn/register?success 로 이동

우리 로그인 화면은 SPA 라서 /login?error 가 따로 없었다. 그 주소로 이동하니 미인증 접근이 되고, ExceptionTranslationFilter 가 그걸 새 SavedRequest 로 저장했다.

그래서 흐름이 이렇게 됐다.

/oauth2/authorize 접근      → SavedRequest = /oauth2/authorize
패스키 실패 → JS 가 이동     → SavedRequest = /login?error   ← 덮어씀
다시 패스워드로 로그인 성공  → /login?error 로 복귀

인가 코드를 받으러 가야 하는데 로그인 화면으로 돌아오니, 사용자 눈에는 “로그인했는데 아무 일도 안 일어난다”로 보인다.

한 번에 성공한 사람은 아무 문제가 없어서, 재현 조건을 잡는 데 시간이 걸렸다.

어떻게 했나

번들을 안 쓰고 ceremony 를 직접 호출하도록 바꿨다. 브라우저 API 를 부르는 부분은 짧다.

const options = await fetch('/webauthn/authenticate/options', { method: 'POST' }).then(r => r.json());
const credential = await navigator.credentials.get({ publicKey: decode(options) });
const res = await fetch('/login/webauthn', { method: 'POST', body: JSON.stringify(encode(credential)) });
if (!res.ok) {
    showInlineError(await res.json());   // 페이지 이동 없이 그 자리에 표시
    return;
}

실패해도 페이지를 옮기지 않는다. 그러니 SavedRequest 가 건드려지지 않는다. 덤으로 에러 메시지를 화면 안에서 한국어로 보여줄 수 있게 됐다. 원래는 브라우저의 DOMException 영문 메시지가 그대로 노출됐다.

번들을 계속 쓰고 싶다면 /login?errorpermitAll 로 열어두는 방법도 있다. 그러면 미인증 접근이 아니게 되니 SavedRequest 도 안 건드린다. 다만 라이브러리가 어디로 이동시키는지 계속 따라다녀야 한다.

정리

로그인 후 복귀 주소는 전역 상태 하나다. 그 사이에 보호된 URL 을 건드리는 코드가 있으면 조용히 바뀐다.

특히 라이브러리가 제공하는 프론트 스크립트는 이동 경로가 하드코딩된 경우가 많다. 로그인 화면을 직접 만들었다면 그 이동 경로가 우리 흐름과 맞는지 봐야 한다. 안 맞으면 ceremony 를 직접 부르는 편이 낫다 — 코드가 몇 줄 늘지만 제어권이 넘어온다.

디버깅할 때는 로그인 성공 응답의 Location 헤더를 보면 바로 보인다. 저장된 주소가 그대로 나온다.

같은 패스키 작업에서 만난 다른 문제는 WebAuthn 등록 옵션은 세션에 못 담는다 에 적어뒀다.

참고

댓글 달기

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

위로 스크롤