스프링 시큐리티는 스프링 기반의 어플리케이션의 보안(인증과 권한)을 담당하는 프레임워크이다.
spring security는 filter 기반으로 동작하기 때문에 spring MVC 와 분리되어 관리 및 동작한다.
다양한 로그인 방법(Form태그, OAuth2, JWT...)에 대해 구현이 되어있으니 수정(확장)해서 사용만 하면된다.
spring security 구조를 공부하다 보니 servlet, servlet container와 같은 용어가 계속 등장하는데, spring을 공부하면서 많이 듣게 되지만, 제대로 알고 있지는 않았기에 이번 기회에 정리 해 보았다.
servlet은 '동적 웹 페이지를 만들 때 사용되는 자바 기반의 웹 애플리케이션 프로그래밍 기술' 이라고 정의할 수 있다. 즉, 쉽게 말하자면 클라이언트로부터 오는 요청을 받아들이고, 요청에 대한 응답(동적 웹페이지 일 수 있음)을 다시 반환하는 서버 측의 작은 프로그램이라고 볼 수 있다.
srvlet conainer는 servlet을 관리하고 실행시키는 또다른 프로그램이며, servlet engine 또는 web container라고도 불린다. 클라이언트로부터 요청이 들어올 때마다 웹 서버에서 반복적으로 수행해야 하는 작업들을 대신 수행한다. 반복적인 작업의 예시로는 다음과 같다.
이러한 작업을 수행하는 servlet container의 대표적인 예시가 Tomcat이다. 즉, 서블릿 객체의 생성/실행/종료 등의 작업을 수행하고 서블릿과 웹의 통신을 관리하는 것이 Servlet container이다.
위 두가지 개념을 알고 있으면, spring security의 구조를 이해하는데 도움이 된다.
위 그림을 간단하게 살펴보면, client에서 서버로 http 요청을 보내면,해당 요청이 dispatcher servlet으로 넘어가기 전에 filter가 요청을 가로 챈다. 필터에서는 통과한 요청에 대해서만 Dispatcher Servlet에 해당 요청을 넘긴다. Dispatcher Servlet은 요청의 uri와 매칭되는 controller를 호출하여 요청을 전달한다. 대략적인 구조는 이러하다.
Dispatcher Servlet은 Servlet의 일종으로 하나의 instance라고 볼 수 있다.

filterChain은 서블릿 컨테이너가 관리하는 서블릿 필터들의 연결고리이다. http 요청에 대해 filter를 순차적으로 통과시키는데, 여기서 주목할 점은 DelegatingFilterProxy이다.
servlet container는 servlet container의 표준으로 작성된 servlet filter에 대해서는 instance를 생성할 수 있지만, spring context안에 있는, 즉 spring에 의해서 bean으로 등록되는 filter에 대해서는 servlet container가 filter로 인식하지 못한다. 이때 필요한 것이 delegatingFilterProxy이다. delegatingFilterProxy는 servlet container의 사이클과 Spring’s ApplicationContext 사이에서 연결고리가 되어준다. 표준 서블릿 컨테이너의 매커니즘을 통해 DelegatingFilterProxy를 등록하고, 내부 필터를 구현하고 있는 스프링 빈에게 모든 작업을 위임한다.
DelegatingFilterProxy는 ApplicationContext에서 Bean으로 등록된 Filter를 검색한 다음 호출할 수 있다.
즉, servlet container가 인식하지 못하는 spring bean으로 등록된 filter들을 delegatingFilterProxy를 통해서 인식하고 호출할 수 있게 된다.
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
Filter delegate = getFilterBean(someBeanName);
delegate.doFilter(request, response);
}
delegatingFilterProxy에서는 application context에서 bean filter를 찾고, 요청과 작업을 해당 bean filter에 넘긴다.
servlet 컨테이너가 시작되기 전에는 모든 bean들에 대해 등록이 되어 있어야 한다. 그러나 spring은 bean을 load하기 위해ContextLoaderListener를 사용하는데, 이 작업은 컨테이너가 bean에 대한 등록을 필요로 하는 시점이 되어도 완료되지 않는다. 수도 코드이기 때문에 정확한 로직을 알수는 없지만, getFilterBean과 같은 메소드를 통해 bean filter를 가져올 때 bean을 lazy하게 가져옴으로써 이 문제를 해결할 수 있다. delegatingFilterProxy에서 컨테이너 시작을 위해 초기화된 filter를 가져온 뒤, 해당 filter가 실제로 필요한 시점에, 즉 spring에서 모든 bean을 load하여 등록하였을 때, 실제 filter bean을 검색해서 불러올 수 있게 된다.
filterChainProxy는 SecurityFilterChain을 통해 많은 filter들에 권한을 위임하도록 허용된 bean이다. 요청이 들어왔을 때 어떤 securityFilterChain을 사용할지 결정한다.

securityFilterChain은 여러개의 security filter bean들로 이루어져 있으며, 이것 역시 또 하나의 bean이다. FilterChainProxy는 여러개의 SecurityFilterChain을 가질 수 있으며, SecurityFilterChain은 모두 unique하고 독립적으로 구성될 수 있다. 요청이 들어왔을 때 FilterC
hainProxy가 요청 uri에 맞는 SecurityFilterChain을 결정해서 권한을 위임한다.
각각의 security filter들은 서로 다른 목적에 의해서 사용된다. 예를들면, authentication, authorization 등등이 있다. 또한 각각의 filter들은 정해진 순서에 맞게 요청에 대해 수행되므로, 순서가 굉장히 중요하다.
또한 이미 정해져 있는 filter 뿐만 아니라, custom한 filter를 정의해서 사용할 수도 있다.
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String tenantId = request.getHeader("X-Tenant-Id");
boolean hasAccess = isUserAllowed(tenantId);
if (hasAccess) {
filterChain.doFilter(request, response);
return;
}
throw new AccessDeniedException("Access denied");
}
}
위 예시와 같이 doFilter에서 요청을 허용할 경우, 허용하지 않는 경우에 대해 응답값을 작성해주면된다. 작성된 customFilter는 securityFilterChain에 적용해주면 된다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
}
SecurityFilterChain을 작성할 class를 생성하고, 위와 같이 annotation을 달아준다. spring 6.0 버전 이전에는 Configuration이 EnabledWebSecurity에 포함되어 있었는데, spring 6.0 버전 이후부터는 제거되었기 때문에 같이 작성해줘야 한다.
WebSecurity는 httpFirewall() 메소드를 지원함으로써 spring application에 적용되는 방화벽에 대한 설정도 할 수 있도록 한다.
@Bean
WebSecurityCustomizer ignoringCustomizer() {
return (web) -> web.ignoring().requestMatchers("/resources/**", "/static/**");
}
위와 같이 WebSecurityCusomizer를 노출시킴으로써 webSecurity를 설정해줄 수 있다. ignoring() method를 적용할 경우 spring security filter를 거치지 않고 지나가기 때문에, 동적 요청에 대해서는 적용하지 않는 것을 권장한다. ignoring method의 경우 css,image와 같은 정적 요소에만 적용하도록 권장된다.
또한 WebSecurity는 httpFirewall() 메소드를 지원함으로써 다음과 같이 spring application에 적용되는 방화벽에 대한 설정도 할 수 있도록 한다.
@Bean
HttpFirewall allowHttpMethod() {
List<String> allowedMethods = new ArrayList<String>();
allowedMethods.add("GET");
allowedMethods.add("POST");
StrictHttpFirewall firewall = new StrictHttpFirewall();
firewall.setAllowedHttpMethods(allowedMethods);
return firewall;
}
@Bean
WebSecurityCustomizer fireWall() {
return (web) -> web.httpFirewall(allowHttpMethod());
}
HttpSecurity는 특정 http 요청에 대해서 security를 설정할 수 있도록 도와주는 class이다. 특정 endpoint를 가지는 요청에 대해서 authentication, authorization을 처리할 수 있을 뿐만 아니라, CORS, CSRF 보호에 대한 security 설정도 할 수 있도록 도와준다.
HttpSecurity를 이용한 security configuration에 대해 몇가지만 정리해 보았다.
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authz) -> authz
.antMatchers("/api/admin/**").hasRole("ADMIN")
.antMatchers("/api/user/**").hasRole("USER")
.anyRequest().authenticated()
);
return http.build();
}
}
spring 5.8과 spring 6.0 부터는 요청 uri에 대한 인가 설정 방법이 바뀌었다. 위에는 이전 버전의 방식이다.
spring 5.8 부터는 antMatchers, mvcMatchers, regexMatchers가 deprecated 되었으며, 6.0 이후 부터는 완전히 삭제 되었다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authz) -> authz
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasRole("USER")
.anyRequest().authenticated()
);
return http.build();
}
}
antMatchers 대신, 위와 같이 requestMatcher를 이용해 작성해줄 수 있다.
만약, spring mvc가 classpath에 있다면, spring security가 requestMatcher의 구현체로 MvcRequestMatcher를 선택할 것이고, 그렇지 않다면, AntPathRequestMatcher를 구현체로 선택할 것이다.

위 작성된 코드를 보면 !mvcPresent일 경우 AntPathRequestMatcher를, mvcPresent일 경우 createMvcMathcers method를 통해 MvcRequestMatcher를 구현체로 선택하는 것을 볼 수 있다.
@Configuration
@EnableWebSecurity
@EnableWebMvc
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception {
MvcRequestMatcher.Builder mvcMatcherBuilder = new MvcRequestMatcher.Builder(introspector).servletPath("/path");
http
.authorizeHttpRequests((authz) -> authz
.requestMatchers(mvcMatcherBuilder.pattern("/admin")).hasRole("ADMIN")
.requestMatchers(mvcMatcherBuilder.pattern("/user")).hasRole("USER")
.anyRequest().authenticated()
);
return http.build();
}
}
MvcRequestMatcher를 구현체로 선택하고, MvcRequestMatcher의 속성 중 하나인 ServletPath를 변경하고 싶다면, 위와 같이 작성해줄 수도 있다.
mvcMatcherBuilder.pattern("/admin")에서 .pattern()에 filttering이 필요한 uri를 넣어주면 된다.
private static final String[] EXCEPTION_URI= {"/", "/login/**", "/api/user/signup"};
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(Stream.of(EXCEPTION_URI)
.map(uri->new MvcRequestMatcher(introspector,uri))
.toArray(MvcRequestMatcher[]::new)).permitAll()
.anyRequest().authenticated()
);
return http.build();
}
MvcRequestMatcher.Builder를 통해 작성한 code는 위와 같이 작성할 수도 있다. 동일한 설정이 필요한 요청의 uri들을 array로 먼저 작성하고, 해당 uri에 대한 모든 MvxRequestMatcher를 생성하여 array 형태로 requestMatchers에 넣어줄 수 있다.

requestMacher method는 다음과 같이 여러개의 RequestMatcher를 받을 수 있도록 작성되어 있다.
Configuration Migrations에 관련해서는 다음글을 참고했다.
https://docs.spring.io/springsecurity/reference/5.8/migration/servlet/config.html
.exceptionHandling((exceptionHandling) ->
exceptionHandling
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
)
AuthenticationEntryPoint는 인증되지 않은 사용자가 인증이 필요한 요청을 할 때(401), 예외를 핸들링 하도록 도와준다.
아래와 같이 AuthenticationEntryPoint를 implememts하여 override 해주면 된다.
@Component
public class JWTAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
AccessDeniedHandler는 인증이 완료되었으나 해당 엔드포인트에 접근할 권한이 없는 경우(403) 발생하는 예외를 핸들링 하도록 도와준다. 아래와 같이 AccessDeniedHandler를 implement하여 override 해주면 된다.
@Component
public class JWTAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
CSRF란?
Cross Site Request Forgery의 약자로, 사이트간의 위조된 요청을 뜻한다. CSRF는 웹 보안 취약점의 일종이며, 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위(데이터 수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 한다.
<form action="http://bank.com/transfer" method="POST">
<input type="hidden" name="accountNo" value="5678"/>
<input type="hidden" name="amount" value="1000"/>
<input type="submit" value="Show Kittens Pictures"/>
</form>
💡 <img> 태그의 경우 src에 GET 형식의 요청 url을 작성할 경우, 클릭하지 않고 이미지를 보는 것만으로도 요청을 보낼 수 있다고 한다. 만약 api를 개발하면서, 어떠한 resource를 수정하는 api를 GET 형식으로 작성해 두었을 경우, GET 요청 만으로도 resoruce를 수정할 수 있게 된다. 물론 이유야 많겠지만, 특정 resource를 수정하거나 삭제하는 method가 아닌, 획득하는 경우에만 GET 요청을 사용해야 하는 또 다른 이유이다.
csrf 공격이 가능한 이유는 사용자가 특정 사이트에 로그인 되어있다면 해당 사이트로 요청을 전송할 때마다 해당 사이트의 쿠키가 웹 브라우저에 의해서 함께 자동으로 전달된다는 것이다.
http 프로토콜의 경우 stateless를 지향하고 있지만, 특정 목적에 의해서(예를 들면 브라우저를 새로고침하거나, 다른 화면으로 넘어갈 때마다 로그인이 해제 되는 불편함을 개선 등) 사용자가 특정 사이트에 로그인 했을 때 세션을 지속적으로 유지하도록 하는 기술을 사용하곤 한다. 따라서 특정 사이트에 사용자가 로그인 했다면, 서버와 클라이언트 사이에 세션이 생성되고, 클라이언트는 해당 세션에 대한 session id를 브라우저 내의 쿠키에 담아 저장하고, 서버 또한 해당 session id를 서버의 저장 공간에 저장하고 있다가, 클라이언트로부터 요청이 왔을 때 함께 보낸 쿠키 속 session id를 서버 측 session id와 비교해 사용자를 식별한다. 그렇기 때문에 사용자가 특정 사이트로 공격자의 의한 악의적인 요청을 보냈을 때, 서버는 자동적으로 함께 전달된 쿠키 속 session id만을 검사하고, 일반적인 사용자라고 믿고 요청을 처리하게 된다.
사용자가 특정 웹사이트에 로그인할 경우, 서버 측에서는 csrf 토큰을 발행하여 특정 html 안에 넣어서 전달하거나, 혹은 response의 cookie 속에 담아서 전달해줄 수 있다. 이 경우 클라이언트 측에서 javascript를 통해서 csrf 토큰을 획득할 수 있다. 클라이언트는 모든 요청(GET 요청을 제외하고)을 전송할 때마다 요청 헤더에 csrf 토큰을 담아 함께 전달한다. 서버는 csrf 토큰을 검증하여 일치하는 경우에만 요청을 처리한다. 악성 사이트를 통해서 요청을 보내더라도, 악성 사이트는 원래 사이트의 클라이언트 측에서 보내는 요청 header 속의 csrf 토큰을 알아낼 수 없기 때문에 공격이 방지 된다.

CsrfFilter class의 doFilterInternal 함수를 살펴보자.
requireCsrfProtectionMatcher.matches(request) :
Csrf 검증 메서드를 체크한다. 기본적으로 GET, HEAD, TRACE, OPTIONS를 제외한 모든 메서드에 대해서는 CsrfToken을 검증한다. 만약 GET으로 요청이 들어왔다면 검증 없이 다음 Filter로 넘어간다.
csrf 검증이 필요한 경우 :
1.csrfTokenRepository로부터 서버에 저장된 사용자 세션의 csrf 토큰을 가져온다.
DeferredCsrfToken deferredCsrfToken = this.tokenRepository.loadDeferredToken(request, response);
default DeferredCsrfToken loadDeferredToken(HttpServletRequest request, HttpServletResponse response) {
return new RepositoryDeferredCsrfToken(this, request, response);
}
RepositoryDeferredCsrfToken(CsrfTokenRepository csrfTokenRepository, HttpServletRequest request, HttpServletResponse response) {
this.csrfTokenRepository = csrfTokenRepository;
this.request = request;
this.response = response;
}
CsrfToken csrfToken = deferredCsrfToken.get();
private void init() {
if (this.csrfToken == null) {
this.csrfToken = this.csrfTokenRepository.loadToken(this.request);
this.missingToken = this.csrfToken == null;
if (this.missingToken) {
this.csrfToken = this.csrfTokenRepository.generateToken(this.request);
this.csrfTokenRepository.saveToken(this.csrfToken, this.request, this.response);
}
}
}
2.요청 header 속 csrf 토큰을 가져온다.
요청 header에 먼저 csrf 토큰이 있는지 살피고, 없으면 요청 body에 csrf 토큰이 있는지 살펴본다.
String actualToken = this.requestHandler.resolveCsrfTokenValue(request, csrfToken);
default String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
return actualToken;
}
3.서버에 저장된 csrf 토큰과 요청에 담긴 csrf 토큰을 비교하여 일치하는 경우 다음 filter를 실행시키고, 그렇지 않은 경우 accessDeniedHandler의 handler 메소드를 통해서 예외를 발생시킨다.
if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
boolean missingToken = deferredCsrfToken.isGenerated();
this.logger.debug(LogMessage.of(() -> {
return "Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request);}));
AccessDeniedException exception = !missingToken ? new InvalidCsrfTokenException(csrfToken, actualToken) : new MissingCsrfTokenException(actualToken);
this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception);
}
else {
filterChain.doFilter(request, response);
}
spring security에서는 httpSecurity class를 통해서 csrf 관련 설정을 해줄 수 있다.
만약 jwt와 같은 토큰 기반 인증의 rest api를 사용하는 경우, csrf 관련 설정을 굳이 해줄 필요 없다. 왜냐하면 세션-쿠키 인증 방식과 달리 토큰 기반 인증은 session id를 사용자 인증에 활용하지 않으며, 클라이언트만 알 수 있는(갈취당하지 않았다면,,) 토큰을 통해서 사용자를 인증할 수 있기 때문에 csrf filtering을 해줄 필요가 없다.
.csrf(AbstractHttpConfigurer::disable)
위와 같이 csrf 설정을 disable 해주면 된다.
HttpSecurity를 통해서 사용자의 session을 관리해줄 수 있다.
💡Session?
클라이언트로부터 오는 일련의 요청을 하나의 상태로 보고 그 상태를 일정하게 유지하는 기술. 즉, 웹 사이트의 여러 페이지에 걸쳐 사용되는 사용자 정보를 저장하는 방법이다.

위와 같이 세션 생성과 관리를 다룰 수 있는 설정들이 매우 많다. 그중 몇가지만 정리해보았다.
사용자가 여러번 로그인하여 세션을 생성하는 것을 방지한다. 두번째로 로그인을 시도할 경우, 첫번째로 로그인한 세션이 invalidated된다.
http.sessionManagement(session -> session
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
);
최대 세션을 하나로 유지하되, 두번째 로그인을 성공했을 때, 첫번째 세션을 무너뜨리는 것이 아닌, 두번째 로그인 자체를 방지하도록 한다.
만료된 세션 , invalid한 세션 내에서 사용자가 특정 요청을 보낼 경우 특정 url로 redirection 시키기 위해서 사용할 수 있다.
sessionCreationPolicy를 통해 spring security의 세션 생성 정책을 설정할 수 있다.
enum type의 paramter를 통해 설정 가능하다.
ALWAYS
spring security가 항상 세션을 생성. 즉, 필요하던 안하던 항상 세션을 생성하여 유지하고 있음.
IF_REQUIRED
spring security가 세션이 필요한 경우에만 생성함.(기본값)
즉, token과 같은 사용자 인증 정보를 저장해야 하는 경우에 세션을 생성하여 사용.
NEVER
spring security가 session을 생성하지 않지만, 기존에 생성된 세션이 존재하면 사용. session은 spring security를 통해서만 생성되는 것이 아니다. 톰캣이나 기타 다른 모듈로부터 만들어진 session이 존재한다면 이를 사용한다.
STATLESS
spring security가 생성하지도 않고, 기존에 생성되어 있는 session을 사용하지도 않음. stateless를 원칙으로 하는 Rest Api, jwt 기반 인증을 활용하는 경우 사용됨. (jwt 기반 인증은 세션을 사용하지 않음. 즉, 서버가 사용자 인증 정보를 일일히 기록하고 있지 않음)
.sessionManagement((sessionManagement)->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) )
addFilterBefore은 filter의 위치를 조정, 삽입하는 경우에 사용할 수 있다.
.addFilterBefore(new JWTFilter(tokenProvider),UsernamePasswordAuthenticationFilter.class)
위와 같이 custom filter를 spring security 기본 필터 앞에 삽입하여 순서를 조정해줄 수 있다.
지금까지는 spring security의 대략적인 구조와 filter 사용 방법에 대해 정리해 보았다. 자세한 인증 로직과 custom하여 작성하는 방법에 대해서는 글이 너무 길어져서 다음 글에 이어서 작성했다.
이번 포스팅 참고 자료
https://sjh836.tistory.com/165
https://catsbi.oopy.io/c0a4f395-24b2-44e5-8eeb-275d19e2a536
https://lotuus.tistory.com/79
https://velog.io/@tmdgh0221/Spring-Security-%EC%99%80-OAuth-2.0-%EC%99%80-JWT-%EC%9D%98-%EC%BD%9C%EB%9D%BC%EB%B3%B4