안녕하세요, 오늘은 Spring Security에 대해서 얘기해보려고 합니다.
Spring Security는 Spring Framework에서 인증 관련 모듈로, 인증 기능을 구현할 때 많은 편리함을 제공합니다.
웹 서비스는 보통 한번 인증을 거친 사용자에 대해서 다시 인증을 요구하지 않는데, Spring Security는 어떻게 함으로써 이게 가능한 지에 대해서 살펴보겠습니다.
먼저 시작하기 전에 시나리오를 설정하겠습니다.
다음과 같이, 간단한 프로젝트가 있다고 생각해봅시다.
POST /signup
API를 통해 가능하다.POST /login
API를 통해 가능하다.GET /hello
API가 있다.인증을 테스트 해볼 정도로 아주 간단한 프로젝트입니다.
이를 Spring Boot를 이용해서 간단하게 만들어보면...
spring-boot-starter-web
, spring-boot-starter-security
의존성 추가SecurityConfig.java
작성해서 Spring Security 설정Controller.java
작성해서 API 엔드포인트 구현정도 일 것 같습니다.
/* SecurityConfig.java */
@Configuration
public class SecurityConfig {
private final static List<String> AUTH_WHITELIST = List.of(
"/login",
"/signup"
);
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.httpBasic().disable()
.csrf().disable()
.formLogin().disable()
.logout().disable()
.authorizeHttpRequests()
.requestMatchers(request -> AUTH_WHITELIST.contains(request.getRequestURI())).permitAll()
.anyRequest().authenticated();
return httpSecurity.build();
}
@Bean
public AuthenticationManager authenticationManager() {
return authentication -> {
throw new IllegalStateException("No authentication manager");
};
}
}
회원가입이나 로그인 API 구현을 Filter 방식을 이용하지 않고, Controller에 위임해서 구현해보겠습니다.
먼저, SecurityContext
에 저장될 Authentication
인터페이스를 구현하는 객체를 만들어봅시다.
/* Token.java: SecurityContext에 저장되는 Authentication 구현 객체 */
public class Token implements Authentication {
private final String name;
public Token(String name) { this.name = name; }
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(() -> "ROLE_USER");
}
@Override
public Object getCredentials() { return null; }
@Override
public Object getDetails() { return null; }
@Override
public Object getPrincipal() { return null; }
@Override
public boolean isAuthenticated() { return true; }
@Override
public void setAuthenticated(boolean isAuthenticated)
throws IllegalArgumentException {}
@Override
public String getName() { return name; }
}
유저의 정보(아이디나 비밀번호)를 저장해야 되는데, DB까지 연결하게 되면 너무 복잡해지므로 간단하게 HashMap을 이용했습니다.
/* Controller.java */
@RestController
public class Controller {
private final HashMap<String, String> users = new HashMap<>();
private void saveContext(String username) {
Token token = new Token(username);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(token);
SecurityContextHolder.setContext(context);
}
@PostMapping("/login")
public String login(@RequestParam String username, @RequestParam String password) {
if (users.containsKey(username) && users.get(username).equals(password)) {
saveContext(username);
return "login ok, hello " + username + "!";
} else return "error";
}
@PostMapping("/signup")
public String signup(@RequestParam String username, @RequestParam String password) {
if (users.containsKey(username)) {
return "duplicate username";
} else {
users.put(username, password);
saveContext(username);
return "signup ok, hello " + username + "!";
}
}
@GetMapping("/hello")
public String hello() {
Authentication token = SecurityContextHolder.getContext().getAuthentication();
String username = token.getName();
return "hello " + username;
}
}
이렇게 구현하면, 톰캣에서 생성해주는 JSESSIONID를 통해서 Authentication
객체를 연속된 HTTP 요청들에 걸쳐 사용할 수 있게 됩니다.
Postman으로 테스트를 해보면,
Spring Boot 2.7.10에서는 인증 절차가 잘 이루어지는 반면
Spring Boot 3.0.5에서는 인증 절차가 이루어지지 않습니다.
이유를 조금 살펴보면,
Spring Boot 2.7.10는 starter
의존성을 가져올 때
Spring Security 5를 가져오게 되지만,
Spring Boot 3.0.5에서는 Spring Security 6을 가져오게 됩니다.
그럼 Spring Boot 2.7.10에서는 어떻게 이게 가능할까요?
정답은 SecurityContextPersistenceFilter가 있기 때문에 가능합니다.
하지만 Spring Boot 3.0.5에서 해당 필터가 Deprecated가 되었기 때문에 이전의 코드만으로는 인증 절차가 제대로 이루어지지 않습니다.
SecurityContextPersistenceFilter를 조금 더 쉽게 이해하기 위해서 Spring Security의 구조를 알면 좋습니다.
서블릿 기반 어플리케이션에서 Spring Security는 Filter를 통해 동작하게 됩니다.
DelegatingFilterProxy
라는 Filter 구현체를 통해 서블릿 컨테이너의 생명주기와 스프링 빈의 생명주기를 연결해주고, FilterChainProxy
를 통해 보안관련 로직을 SecurityFilterChain
에 위임합니다.
SecurityFilterChain
에는 다양한 역할을 하는 필터를 구성할 수 있는데요, 여기에서 필터들의 종류와 Chain안에서의 순서를 확인할 수 있습니다.
Filter의 동작 과정은 다음과 같습니다.
SecurityContextRepository
에서 SecurityContext
을 꺼내와서 SecurityContextHolder
에 저장합니다.SecurityContextHolder
에 저장된 값이 바뀌었으면 SecurityContextRepository
에 저장합니다.하지만 실제 코드를 보면, SecurityContext
의 변경과 무관하게 SecurityContextRepository
에 무조건 저장하는 것을 볼 수 있습니다.
/* SecurityContextPersistenceFilter.java */
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
...
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
...
finally {
SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
// Crucial removal of SecurityContextHolder contents before anything else.
SecurityContextHolder.clearContext();
this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
request.removeAttribute(FILTER_APPLIED);
this.logger.debug("Cleared SecurityContextHolder to complete request");
}
}
SecurityContextRepository
에 저장하면서 SecurityContextHolder
를 비워주게 됩니다.
SecurityContextHolderFilter
는 SecurityContextPersistenceFilter
와 비슷하지만, 3번의 과정이 빠져있습니다.
따라서, SecurityContext
안에 Authentication
객체를 저장해주더라도 다음 HTTP 요청에서 사용할 수 없습니다.
앞서 살펴본 2개의 필터 모두 SecurityContextRepository
를 사용하게 되는데요, 이는 SecurityContext
를 저장하고 불러오는 역할을 담당합니다.
SecurityContextRepository
는 인터페이스이며, Spring Security의 기본 설정으로 DelegatingSecurityContextRepository
구현체가 설정됩니다.
DelegatingSecurityContextRepository
를 사용하면 여러개의 레포지토리를 구성할 수 있는 장점이 있습니다.
실제로, Spring Security 6에서 기본 설정은 다음과 같습니다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ...
.securityContext((securityContext) -> securityContext
.securityContextRepository(new DelegatingSecurityContextRepository(
new RequestAttributeSecurityContextRepository(),
new HttpSessionSecurityContextRepository()
))
);
return http.build();
}
RequestAttribute
를 사용하는 레포지토리와 HttpSession
을 사용하는 레포지토리 2개를 이용하는 것을 볼 수 있습니다.
Spring Security 6에서 왜 PersistenceFilter
가 deprecated가 되고 HolderFilter
로 대체되었을까요?
공식 문서에 따르면 다음과 같은 이유로 인해 변경되었다고 합니다.
SecurityContext
가 저장되어 사용자들을 놀라게 할 수 있다.SecurityContext
변경 여부 판단하는 로직이 복잡해서 모든 요청에 대해 레포지토리에 저장하게 되는데, 이로 인해 불필요한 쓰기 연산이 발생한다.이렇게 대체 됨에 따라 불확실성을 제거할 수 있었고 성능 또한 올라갔다고 합니다.
실제로 바뀐 부분을 로그로 살펴봅시다.
Spring Security 5에서 SpringContextRepository
으로 HttpSessionSecurityContextRepository
가 기본 설정으로 선택됩니다.
SessionManagementFilter
도 같이 등록된 것을 확인할 수 있는데,
사용자가 인증되었는 지 확인하는 역할과 함께
세션 관련 로직이 들어있는 SessionAuthenticationStrategy
를 호출하는 역할을 담당합니다.
SecurityContextPersistenceFilter
대신 SecurityContextHolderFilter
가 들어와 있는 것을 확인할 수 있습니다.
Spring Security 6부터 SessionManagementFilter
도 기본 구성에서 제외되어, 세션 관련 로직이 필요한 곳에서 직접 SessionAuthenticationStrategy
를 호출해야 합니다.
Spring Security 6의 기본 설정을 사용한다고 가정해보겠습니다.
Spring Security 공식 문서에 따르면, SecurityContextRepository
의 saveContext
메소드 호출을 통해 SecurityContext
를 저장할 수 있습니다.
SecurityContextHolder.setContext(securityContext);
securityContextRepository.saveContext(securityContext, httpServletRequest, httpServletResponse);
하지만 기본 설정으로 SecurityContextRepository
가 스프링 빈으로 등록되지 않기 때문에 의존성 주입을 받아야하는 경우, 별도의 등록과정이 필요합니다.
@Bean
public DelegatingSecurityContextRepository delegatingSecurityContextRepository() {
return new DelegatingSecurityContextRepository(
new RequestAttributeSecurityContextRepository(),
new HttpSessionSecurityContextRepository()
);
}
SecurityConfig도 수정해보겠습니다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.httpBasic().disable()
.csrf().disable()
.formLogin().disable()
.logout().disable()
.securityContext((securityContext) -> {
securityContext.securityContextRepository(delegatingSecurityContextRepository());
securityContext.requireExplicitSave(true);
})
.authorizeHttpRequests()
.requestMatchers(request -> AUTH_WHITELIST.contains(request.getRequestURI())).permitAll()
.anyRequest().authenticated();
return httpSecurity.build();
}
이제 HTTP 요청을 처리하는 Controller도 의존성을 주입을 받을 수 있도록 수정하면 다음과 같습니다.
@RestController
public class Controller {
...
private final SecurityContextRepository securityContextRepository;
public Controller(SecurityContextRepository securityContextRepository) {
this.securityContextRepository = securityContextRepository;
}
private void saveContext(String username, HttpServletRequest request, HttpServletResponse response) {
Token token = new Token(username);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(token);
SecurityContextHolder.setContext(context);
securityContextRepository.saveContext(context, request, response);
}
...
}
기본 설정으로 추가되는 HttpSessionSecurityContextRepository
를 이용할 수도 있습니다.
해당 클래스를 살펴보면 세션에 SPRING_SECURITY_CONTEXT
를 Key로 하여 SecurityContext
를 저장하고 있음을 알 수 있습니다.
Controller의 saveContext
메소드 코드를 다음과 같이 수정해보겠습니다.
private void saveContext(String username, HttpServletRequest request, HttpServletResponse response) {
Token token = new Token(username);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(token);
SecurityContextHolder.setContext(context);
RequestContextHolder.currentRequestAttributes().setAttribute("SPRING_SECURITY_CONTEXT", context, RequestAttributes.SCOPE_SESSION);
}
RequestContextHolder.currentRequestAttributes()
를 호출하게 되면, RequestAttributes
인터페이스를 구현한 객체가 반환됩니다.
현재 예제에서는 ServletRequestAttributes
클래스의 객체가 반환되는데, 해당 클래스의 setAttribute
메소드 코드를 살펴보면 다음과 같습니다.
@Override
public void setAttribute(String name, Object value, int scope) {
if (scope == SCOPE_REQUEST) {
if (!isRequestActive()) {
throw new IllegalStateException(
"Cannot set request attribute - request is not active anymore!");
}
this.request.setAttribute(name, value);
}
else {
HttpSession session = obtainSession();
this.sessionAttributesToUpdate.remove(name);
session.setAttribute(name, value);
}
}
따라서, Controller의 saveContext
메소드가 호출되면 세션에 SecurityContext
가 저장되며, 이후 요청들에 대해 HttpSessionSecurityContextRepository
가 SecurityContext
를 불러오게 됩니다.
해당 코드는 SecurityContextRepository
인터페이스를 의존하는 것이 아니라 실제 구현 클래스를 의존하게 되므로 좋은 객체지향적 설계라 보기 어렵습니다.
Spring Security의 많은 필터중에 SecurityContextPersistenceFilter
와 SecurityContextHolderFilter
에 대해서 알아보았습니다.
저는 프로젝트 진행 중에 Spring Boot 3 버전으로 올리면서 기존의 인증 절차가 제대로 이루어지지 않아서 이유를 살펴보다가 관련 내용을 접했습니다.
이번 글이 많은 도움이 되었으면 좋겠습니다.
덕분에 해결했습니당 감사합니다~