File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
package kr.itn.itnhub.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.function.Supplier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.http.HttpStatus;
@Configuration
public class SecurityConfig {
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 관리자 1계정. 값은 환경변수로만 들어온다.
* 운영에서는 APP_ADMIN_PASSWORD에 충분히 긴 무작위 문자열을 넣는다.
*/
@Bean
UserDetailsService userDetailsService(AdminProperties admin, PasswordEncoder encoder) {
return new InMemoryUserDetailsManager(
User.withUsername(admin.username())
.password(encoder.encode(admin.password()))
.roles("ADMIN")
.build());
}
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// SPA가 읽어서 X-XSRF-TOKEN 헤더로 되돌려 보낸다
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
// 기본 XorCsrfTokenRequestAttributeHandler는 BREACH 방지를 위해
// .getToken()이 돌려주는 값을 매 요청 XOR 마스킹한다. 그러면 쿠키에
// 저장된 원문 토큰과 브라우저가 그대로 되돌려 보내는 X-XSRF-TOKEN
// 헤더 값이 서로 달라 검증에 실패한다(SpaCsrfTokenRequestHandler로
// 실측: 마스킹 없이 헤더 값을 그대로 신뢰해야 쿠키 왕복이 성립함).
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
// XorCsrfTokenRequestAttributeHandler는 토큰을 지연 계산 Supplier로 _csrf
// 요청 속성에 넣기만 하고 실제로 resolve하지 않는다. 뷰 레이어가 없는 이
// 프로젝트에서는 아무도 .getToken()을 호출하지 않아 CookieCsrfTokenRepository의
// saveToken()이 끝내 실행되지 않고, 브라우저는 XSRF-TOKEN 쿠키를 영영 받지
// 못한다. Spring Security 레퍼런스가 SPA용으로 제시하는 방식대로,
// BasicAuthenticationFilter 뒤에 필터를 두어 토큰을 강제로 resolve시킨다.
.addFilterAfter(new OncePerRequestFilter() {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
if (csrfToken != null) {
csrfToken.getToken();
}
filterChain.doFilter(request, response);
}
}, BasicAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll())
.formLogin(form -> form
.loginProcessingUrl("/api/auth/login")
// 커스텀 로그인 페이지를 지정하면(실제 뷰가 없어도) 스프링 시큐리티가
// DefaultLoginPageGeneratingFilter를 아예 등록하지 않는다. Task 10의
// React SPA가 "/login"을 클라이언트 라우트로 쓸 수 있어야 하므로,
// 스프링이 만든 기본 로그인 폼이 그 경로를 가로채면 안 된다.
.loginPage("/login")
.successHandler((req, res, a) -> res.setStatus(HttpServletResponse.SC_OK))
.failureHandler((req, res, e) ->
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED)))
.logout(logout -> logout
.logoutUrl("/api/auth/logout")
.logoutSuccessHandler((req, res, a) ->
res.setStatus(HttpServletResponse.SC_NO_CONTENT)))
// API는 로그인 페이지로 리다이렉트하지 않고 401을 준다
.exceptionHandling(ex -> ex.authenticationEntryPoint(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)));
return http.build();
}
/**
* 렌더링(handle)은 기존 XorCsrfTokenRequestAttributeHandler 그대로 위임해 BREACH
* 방지 마스킹을 유지한다. 검증(resolveCsrfTokenValue)만 다르다: X-XSRF-TOKEN
* 헤더가 있으면(브라우저가 쿠키 원문을 그대로 담아 보내는 SPA 관례) 마스킹 해제
* 없이 그 값을 그대로 신뢰하고, 헤더가 없으면(예: 폼 파라미터 `_csrf`, 테스트의
* {@code with(csrf())} 등) 기존 Xor 위임자의 마스킹 해제 로직을 그대로 쓴다.
* 이렇게 두 경로를 다 살려야 쿠키→헤더 왕복과 기존 파라미터 기반 검증이 동시에
* 성립한다. Spring Security 레퍼런스가 SPA 연동용으로 제시하는 표준 패턴이다.
*/
private static final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
private final CsrfTokenRequestHandler delegate = new XorCsrfTokenRequestAttributeHandler();
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
this.delegate.handle(request, response, csrfToken);
}
@Override
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
String headerValue = request.getHeader(csrfToken.getHeaderName());
return StringUtils.hasText(headerValue) ? headerValue : this.delegate.resolveCsrfTokenValue(request, csrfToken);
}
}
}