Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
329 changes: 150 additions & 179 deletions README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ protected void configure(HttpSecurity http) throws Exception {

.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
// 로그인 전/토큰 재발급 같은 엔드포인트는 "의도적으로" 예외 가능
.ignoringRequestMatchers(
new AntPathRequestMatcher("/api/auth/loginProc", "POST"),
new AntPathRequestMatcher("/api/auth/refresh", "POST"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ protected void configure(HttpSecurity http) throws Exception {
.logout()
.logoutUrl("/auth/logout")
.addLogoutHandler(jwtCookieLogoutHandler())
.invalidateHttpSession(true)
.clearAuthentication(true)
.deleteCookies("JSESSIONID")
.logoutSuccessUrl("/login")
.permitAll()
.and()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
Expand Down Expand Up @@ -122,10 +124,63 @@ private boolean isAllowedReferer(String referer) {
if (normalizedReferer.equals(allowedOrigin) || normalizedReferer.startsWith(allowedOrigin + "/")) {
return true;
}
if (isLocalhostSchemeVariant(normalizedReferer, allowedOrigin)) {
return true;
}
}
return false;
}

private boolean isLocalhostSchemeVariant(String refererUrl, String allowedOrigin) {
OriginParts refererOrigin = extractOrigin(refererUrl);
OriginParts allowed = extractOrigin(allowedOrigin);
if (refererOrigin == null || allowed == null) return false;

if (!isLocalhostHost(refererOrigin.host) || !isLocalhostHost(allowed.host)) return false;
if (refererOrigin.port != allowed.port) return false;

boolean refererHttp = "http".equals(refererOrigin.scheme);
boolean refererHttps = "https".equals(refererOrigin.scheme);
boolean allowedHttp = "http".equals(allowed.scheme);
boolean allowedHttps = "https".equals(allowed.scheme);
return (refererHttp && allowedHttps) || (refererHttps && allowedHttp);
}

private boolean isLocalhostHost(String host) {
if (!StringUtils.hasText(host)) return false;
String h = host.toLowerCase(Locale.ROOT);
return "localhost".equals(h) || "127.0.0.1".equals(h) || "::1".equals(h) || "[::1]".equals(h);
}

private OriginParts extractOrigin(String url) {
try {
URI uri = new URI(url);
String scheme = trimToNull(uri.getScheme());
String host = trimToNull(uri.getHost());
if (!StringUtils.hasText(scheme) || !StringUtils.hasText(host)) return null;

int port = uri.getPort();
if (port < 0) {
port = "https".equalsIgnoreCase(scheme) ? 443 : 80;
}
return new OriginParts(scheme.toLowerCase(Locale.ROOT), host.toLowerCase(Locale.ROOT), port);
} catch (URISyntaxException e) {
return null;
}
}

private static final class OriginParts {
private final String scheme;
private final String host;
private final int port;

private OriginParts(String scheme, String host, int port) {
this.scheme = scheme;
this.host = host;
this.port = port;
}
}

private boolean isIncludedPath(String path) {
for (String basePath : includeBasePaths) {
if (matchesBasePath(path, basePath)) return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping;

import javax.servlet.RequestDispatcher;
import javax.servlet.http.HttpServletRequest;
Expand All @@ -13,7 +13,7 @@
@RequestMapping("/error")
public class ErrorController {

@GetMapping
@RequestMapping
public String handleError(HttpServletRequest request) {
Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
String requestUri = (String) request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI);
Expand Down
33 changes: 33 additions & 0 deletions src/main/webapp/WEB-INF/views/common/header.jsp
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
<script>
(function() {
const header = document.getElementById('mainHeader');
const contextPath = '${pageContext.request.contextPath}';

function handleScroll() {
if (window.scrollY > 20) {
Expand All @@ -118,5 +119,37 @@
"max-glare": 0.3
});
}

const logoutForms = document.querySelectorAll('form.dropdown-form[action$="/auth/logout"]');
logoutForms.forEach((form) => {
form.addEventListener('submit', async (event) => {
if (typeof window.csrfFetch !== 'function') {
return;
}
event.preventDefault();

const submitButton = form.querySelector('button[type="submit"]');
if (submitButton) submitButton.disabled = true;

try {
const response = await window.csrfFetch(form.action, {
method: 'POST',
headers: { 'Accept': 'text/html' }
});

if (response.ok || response.redirected || response.status === 302) {
window.location.replace(contextPath + '/login');
return;
}

form.submit();
} catch (e) {
console.error('[logout] csrf logout failed, fallback to form submit', e);
form.submit();
} finally {
if (submitButton) submitButton.disabled = false;
}
});
});
})();
</script>
6 changes: 0 additions & 6 deletions src/main/webapp/resources/common/js/csrfFetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,6 @@ function withCsrfHeader(init = {}) {
return { ...init, headers };
}

/**
* csrfFetch
* - GET/HEAD/OPTIONS: 그냥 fetch
* - POST/PUT/PATCH/DELETE: CSRF 쿠키 보장 + 헤더 자동첨부
* - 401 처리/refresh/redirect 절대 안 함
*/
export async function csrfFetch(input, init = {}) {
const merged = { credentials: "same-origin", ...init };

Expand Down
103 changes: 103 additions & 0 deletions src/test/java/com/flyway/security/filter/LogoutFlowTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package com.flyway.security.filter;

import com.flyway.auth.service.AuthTokenService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.web.authentication.logout.CookieClearingLogoutHandler;
import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfFilter;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.stereotype.Controller;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;

import javax.servlet.http.Cookie;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

class LogoutFlowTest {

private MockMvc mockMvc;
private AuthTokenService authTokenService;
private CookieCsrfTokenRepository csrfTokenRepository;

@BeforeEach
void setUp() {
authTokenService = mock(AuthTokenService.class);
csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();

CsrfFilter csrfFilter = new CsrfFilter(csrfTokenRepository);

SecurityContextLogoutHandler contextLogoutHandler = new SecurityContextLogoutHandler();
contextLogoutHandler.setInvalidateHttpSession(true);
contextLogoutHandler.setClearAuthentication(true);

CookieClearingLogoutHandler cookieClearingLogoutHandler =
new CookieClearingLogoutHandler("JSESSIONID");

LogoutFilter logoutFilter = new LogoutFilter("/login",
(request, response, authentication) -> authTokenService.logout(request, response),
contextLogoutHandler,
cookieClearingLogoutHandler
);
logoutFilter.setLogoutRequestMatcher(new AntPathRequestMatcher("/auth/logout", "POST"));

mockMvc = MockMvcBuilders.standaloneSetup(new NoopController())
.addFilters(csrfFilter, logoutFilter)
.build();
}

@Test
@DisplayName("CSRF 토큰 없이 POST /auth/logout 요청하면 403을 반환한다")
void logout_withoutCsrfToken_returnsForbidden() throws Exception {
mockMvc.perform(post("/auth/logout"))
.andExpect(status().isForbidden());

verify(authTokenService, never()).logout(any(), any());
}

@Test
@DisplayName("유효한 CSRF 쿠키/헤더로 POST /auth/logout 요청하면 /login으로 리다이렉트되고 로그아웃 핸들러가 호출된다")
void logout_withValidCsrfToken_redirectsToLogin() throws Exception {
String token = newTokenValue();
MockHttpSession session = new MockHttpSession();

MvcResult result = mockMvc.perform(post("/auth/logout")
.session(session)
.cookie(new Cookie("XSRF-TOKEN", token))
.header("X-XSRF-TOKEN", token))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/login"))
.andReturn();

verify(authTokenService).logout(any(), any());
assertThat(session.isInvalid()).isTrue();

List<String> setCookies = result.getResponse().getHeaders(HttpHeaders.SET_COOKIE);
assertThat(setCookies).anyMatch(v -> v.startsWith("JSESSIONID=") && v.contains("Max-Age=0"));
}

private String newTokenValue() {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/auth/csrf");
CsrfToken token = csrfTokenRepository.generateToken(request);
return token.getToken();
}

/* 필터 체인 검증용 플레이스홀더(standaloneSetup은 최소 1개의 컨트롤러 필요) */
@Controller
static class NoopController {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.flyway.template.controller;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import javax.servlet.RequestDispatcher;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

class ErrorControllerTest {

private MockMvc mockMvc;

@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(new ErrorController()).build();
}

@Test
@DisplayName("GET /error 요청에 403 상태가 전달되면 403 에러 페이지를 렌더링한다")
void getError_with403_rendersForbiddenPage() throws Exception {
mockMvc.perform(get("/error")
.requestAttr(RequestDispatcher.ERROR_STATUS_CODE, 403)
.requestAttr(RequestDispatcher.ERROR_REQUEST_URI, "/auth/logout"))
.andExpect(status().isOk())
.andExpect(view().name("error/403"));
}

@Test
@DisplayName("POST /error 요청에 403 상태가 전달되어도 403 에러 페이지를 렌더링한다")
void postError_with403_rendersForbiddenPage() throws Exception {
mockMvc.perform(post("/error")
.requestAttr(RequestDispatcher.ERROR_STATUS_CODE, 403)
.requestAttr(RequestDispatcher.ERROR_REQUEST_URI, "/auth/logout"))
.andExpect(status().isOk())
.andExpect(view().name("error/403"));
}
}