들어가며
로컬에서 개발 중인 프론트(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만 허용하는 규칙 하나로, "로컬 개발 편의"와 "임의 사이트로의 리다이렉트 방지"라는 상충하는 요구를 동시에 만족시켰습니다.- 백엔드 로직을 아무리 잘 만들어도, 프론트가 새 쿼리파라미터를 실어 보내지 않으면 동작하지 않습니다. 인프라/설정 변경은 항상 연동하는 다른 쪽의 변경까지 함께 봐야 완결됩니다.
