Server#BE#Spring Boot#로그인

여러 프론트엔드로 로그인 리다이렉트 시켜주기 :OAuth Origin 화이트리스트 설계

로컬 프론트와 배포된 프론트가 동시에 같은 dev 백엔드로 로그인해야 할 때, 고정된 리다이렉트 URI 하나로는 안 되는 이유와 origin 화이트리스트 + state 검증으로 풀어낸 구조

2026년 7월 24일2분 읽기조회 6
공유

들어가며

로컬에서 개발 중인 프론트(localhost:5173)로 dev 백엔드(https://dev.plimap.kr) 로그인 결과를 받고 싶었습니다. 그런데 배포 스크립트가 "리다이렉트 대상은 반드시 백엔드와 같은 오리진이어야 한다"고 검증하면서 계속 실패했습니다. 이 문제를 해결하고 구조를 분석하며 정리한 기록입니다.

문제 상황

기존 구조는 고정된 oauth.redirect-uri 값 하나만 썼습니다. 로그인이 끝나면 언제나 이 값 하나로만 리다이렉트됩니다. 배포된 프론트만 쓰는 동안은 문제없지만, 로컬 프론트로 dev 백엔드에 로그인해서 테스트하려는 순간 막힙니다. 배포 스크립트(deploy-dev.ps1)가 FrontendRedirectUri는 PublicBaseUrl과 동일 오리진이어야 한다고 강제하기 때문입니다.

핵심 아이디어

고정된 리다이렉트 URI 하나 대신, 로그인 시작 요청에 어느 프론트에서 왔는지를 함께 실어 보내고, 허용된 origin 목록에 있으면 그 프론트로 돌려보내는 구조로 바꿨습니다.

1. 허용 목록을 검증하는 설정 클래스

OAuthProperties가 redirect-uri(기본 리다이렉트 대상)와 함께 allowed-frontend-origins(허용된 프론트 origin 목록)를 갖습니다.

@ConfigurationProperties(prefix = "oauth")
public record OAuthProperties(
        String redirectUri,
        List<String> allowedFrontendOrigins
) {
    public OAuthProperties {
        redirectUri = normalizeRedirectUri(redirectUri);
        String defaultFrontendOrigin = extractOrigin(redirectUri);

        allowedFrontendOrigins = allowedFrontendOrigins == null
                ? List.of(defaultFrontendOrigin)
                : allowedFrontendOrigins.stream()
                        .map(OAuthProperties::normalizeOrigin)
                        .distinct()
                        .toList();

        if (!allowedFrontendOrigins.contains(defaultFrontendOrigin)) {
            throw new IllegalArgumentException(
                    "OAuth 기본 리다이렉트 Origin은 허용된 프론트 Origin에 포함되어야 합니다."
            );
        }
    }

    public String requireAllowedFrontendOrigin(String origin) {
        String normalizedOrigin = normalizeOrigin(origin);
        if (!allowedFrontendOrigins.contains(normalizedOrigin)) {
            throw new IllegalArgumentException("허용되지 않은 OAuth 프론트 Origin입니다.");
        }
        return normalizedOrigin;
    }
}

핵심은 normalizeOrigin()의 검증 규칙입니다. https는 어떤 도메인이든 허용하지만, http는 localhost / 127.0.0.1 / ::1 같은 loopback 호스트만 허용합니다. "로컬 개발 프론트는 되고, 임의의 외부 http 사이트는 안 된다"는 보안 규칙을 코드로 강제하는 부분입니다. 생성자에서 기본 redirect-uri의 origin이 허용 목록에 반드시 포함되도록 검증해서, 설정 실수로 기본값 자체가 막혀버리는 상황을 앱 기동 시점에 미리 차단합니다.

2. 로그인이 어디서 시작됐는지 기억하는 저장소

OAuthFrontendRedirectCookieRepository가 로그인 시작 시점의 origin을 OAuth 흐름 내내 들고 다닙니다.

public void saveRequestedOrigin(HttpServletRequest request, HttpServletResponse response, String state) {
    String allowedOrigin = validateRequestedOrigin(request); // ?frontendOrigin= 쿼리파라미터 검증
    String payload = state + "\n" + allowedOrigin; // state와 origin을 묶어서 저장
    String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes(UTF_8));
    authCookieUtil.setCookie(response, COOKIE_NAME, encodedPayload, COOKIE_MAX_AGE); // 3분
}

public String consumeRedirectUri(HttpServletRequest request, HttpServletResponse response) {
    try {
        String encodedPayload = authCookieUtil.getCookieValue(request, COOKIE_NAME);
        String callbackState = request.getParameter("state");
        String payload = new String(Base64.getUrlDecoder().decode(encodedPayload), UTF_8);
        String savedState = payload.substring(0, payload.indexOf("\n"));

        // 타이밍 공격 방지를 위해 MessageDigest.isEqual로 비교
        if (!MessageDigest.isEqual(savedState.getBytes(UTF_8), callbackState.getBytes(UTF_8))) {
            return defaultRedirectUri();
        }
        String frontendOrigin = payload.substring(payload.indexOf("\n") + 1);
        return oAuthProperties.redirectUriFor(frontendOrigin);
    } catch (IllegalArgumentException exception) {
        return defaultRedirectUri(); // 파싱 실패 시 안전하게 기본값으로 폴백
    } finally {
        clearCookie(response);
    }
}

state와 origin을 함께 묶어서 저장하고 검증하는 이유가 중요합니다. 쿠키가 위조되거나 다른 로그인 시도의 것과 섞여도, 엉뚱한 곳으로 리다이렉트되지 않도록 막기 위해서입니다.

3. 콜백 이전에 미리 막는 필터

OAuthFrontendOriginFilter는 GET /oauth/authorization/{provider} 요청만 가로챕니다. Spring Security의 OAuth2 리다이렉트 필터보다 먼저 실행되어, 허용되지 않은 origin이면 카카오/구글로 넘어가기도 전에 400을 반환합니다.

protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
    try {
        redirectCookieRepository.validateRequestedOrigin(request);
    } catch (IllegalArgumentException exception) {
        redirectCookieRepository.clearCookie(response);
        response.sendError(HttpServletResponse.SC_BAD_REQUEST, "허용되지 않은 OAuth 프론트 Origin입니다.");
        return;
    }
    filterChain.doFilter(request, response);
}

4. 성공/실패 핸들러도 저장해둔 origin을 사용하도록 변경

// Before
@Value("${oauth.redirect-uri}")
private String redirectUri;

// After
String redirectUri = redirectCookieRepository.consumeRedirectUri(request, response);
String redirectUrl = UriComponentsBuilder.fromUriString(redirectUri)
        .queryParam("isNewUser", isNewUser)
        .build()
        .toUriString();
response.sendRedirect(redirectUrl);

5. 배포 스크립트도 새 파라미터를 검증하도록 변경

deploy-dev.ps1에 -OAuthAllowedFrontendOrigins 파라미터를 추가해, GitHub Variables(DEV_OAUTH_ALLOWED_FRONTEND_ORIGINS)로 허용 목록을 받고, https 또는 localhost http만 허용하는 검증을 거치도록 했습니다. 값이 비어 있으면 배포 대상 도메인과 http://localhost:5173을 기본으로 함께 허용하도록 만들어서, 별도 설정 없이도 로컬 개발 프론트가 기본으로 동작하게 했습니다.

중요한 점은, "기본" 리다이렉트 대상(OAUTH_REDIRECT_URI) 자체는 여전히 백엔드와 동일 오리진이어야 한다는 제약이 그대로 남아있다는 것입니다. localhost:5173으로 보내려면 로그인 시작 시점에 ?frontendOrigin=http://localhost:5173을 명시적으로 실어 요청해야 합니다.

// 프론트 로그인 링크도 함께 수정해야 동작한다
// Before
href={`${API_BASE_URL}/oauth/authorization/kakao`}

// After
href={`${API_BASE_URL}/oauth/authorization/kakao?frontendOrigin=${encodeURIComponent(APP_ORIGIN)}`}

정리

같은 오리진 검증에 localhost 예외 하나만 추가하는 단순한 방법도 있었지만, 실제 구조는 로그인 시작 시점에 명시적으로 지정한 origin을 state와 함께 쿠키에 묶어 검증하는 방식으로 훨씬 견고하게 설계되어 있었습니다.

  • 고정값 하나 대신 화이트리스트 + 요청 시점 검증으로 여러 프론트를 안전하게 지원할 수 있습니다.
  • http는 loopback만 허용하는 규칙 하나로, "로컬 개발 편의"와 "임의 사이트로의 리다이렉트 방지"라는 상충하는 요구를 동시에 만족시켰습니다.
  • 백엔드 로직을 아무리 잘 만들어도, 프론트가 새 쿼리파라미터를 실어 보내지 않으면 동작하지 않습니다. 인프라/설정 변경은 항상 연동하는 다른 쪽의 변경까지 함께 봐야 완결됩니다.

관련 포스트

댓글

방명록을 남겨주세요