Spring Security는 어떻게 인증된 사용자를 기억할까?

고라니·2023년 4월 23일
5
post-thumbnail
post-custom-banner

안녕하세요, 오늘은 Spring Security에 대해서 얘기해보려고 합니다.

Spring Security는 Spring Framework에서 인증 관련 모듈로, 인증 기능을 구현할 때 많은 편리함을 제공합니다.

웹 서비스는 보통 한번 인증을 거친 사용자에 대해서 다시 인증을 요구하지 않는데, Spring Security는 어떻게 함으로써 이게 가능한 지에 대해서 살펴보겠습니다.

들어가며

먼저 시작하기 전에 시나리오를 설정하겠습니다.

다음과 같이, 간단한 프로젝트가 있다고 생각해봅시다.

  1. 회원가입은 POST /signup API를 통해 가능하다.
  2. 로그인은 POST /login API를 통해 가능하다.
  3. 로그인한 회원만 접근할 수 있는 GET /hello API가 있다.

인증을 테스트 해볼 정도로 아주 간단한 프로젝트입니다.

이를 Spring Boot를 이용해서 간단하게 만들어보면...

  1. spring-boot-starter-web, spring-boot-starter-security 의존성 추가
  2. SecurityConfig.java 작성해서 Spring Security 설정
  3. 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 구조

서블릿 기반 어플리케이션에서 Spring Security는 Filter를 통해 동작하게 됩니다.

DelegatingFilterProxy라는 Filter 구현체를 통해 서블릿 컨테이너의 생명주기와 스프링 빈의 생명주기를 연결해주고, FilterChainProxy를 통해 보안관련 로직을 SecurityFilterChain에 위임합니다.

SecurityFilterChain에는 다양한 역할을 하는 필터를 구성할 수 있는데요, 여기에서 필터들의 종류와 Chain안에서의 순서를 확인할 수 있습니다.

SecurityContextPersistenceFilter

Filter의 동작 과정은 다음과 같습니다.

  1. 요청이 처리되기 전에 SecurityContextRepository에서 SecurityContext을 꺼내와서 SecurityContextHolder에 저장합니다.
  2. 요청이 처리됩니다.
  3. 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

SecurityContextHolderFilterSecurityContextPersistenceFilter와 비슷하지만, 3번의 과정이 빠져있습니다.

따라서, SecurityContext안에 Authentication 객체를 저장해주더라도 다음 HTTP 요청에서 사용할 수 없습니다.

SecurityContextRepository

앞서 살펴본 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로 대체되었을까요?

공식 문서에 따르면 다음과 같은 이유로 인해 변경되었다고 합니다.

  1. 응답이 완료되기도 전에 SecurityContext가 저장되어 사용자들을 놀라게 할 수 있다.
  2. SecurityContext 변경 여부 판단하는 로직이 복잡해서 모든 요청에 대해 레포지토리에 저장하게 되는데, 이로 인해 불필요한 쓰기 연산이 발생한다.

이렇게 대체 됨에 따라 불확실성을 제거할 수 있었고 성능 또한 올라갔다고 합니다.

눈으로 확인하기

실제로 바뀐 부분을 로그로 살펴봅시다.

Spring Security 5 Filter 기본 구성

Spring Security 5에서 SpringContextRepository으로 HttpSessionSecurityContextRepository가 기본 설정으로 선택됩니다.

SessionManagementFilter도 같이 등록된 것을 확인할 수 있는데,
사용자가 인증되었는 지 확인하는 역할과 함께
세션 관련 로직이 들어있는 SessionAuthenticationStrategy를 호출하는 역할을 담당합니다.

Spring Security 6 Filter 기본 구성

SecurityContextPersistenceFilter 대신 SecurityContextHolderFilter가 들어와 있는 것을 확인할 수 있습니다.

Spring Security 6부터 SessionManagementFilter도 기본 구성에서 제외되어, 세션 관련 로직이 필요한 곳에서 직접 SessionAuthenticationStrategy를 호출해야 합니다.

어떻게 명시적으로 저장할까?

Spring Security 6의 기본 설정을 사용한다고 가정해보겠습니다.

saveContext 호출

Spring Security 공식 문서에 따르면, SecurityContextRepositorysaveContext 메소드 호출을 통해 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);
    }
    
    ...
    
}

Session에 저장

기본 설정으로 추가되는 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가 저장되며, 이후 요청들에 대해 HttpSessionSecurityContextRepositorySecurityContext를 불러오게 됩니다.

해당 코드는 SecurityContextRepository 인터페이스를 의존하는 것이 아니라 실제 구현 클래스를 의존하게 되므로 좋은 객체지향적 설계라 보기 어렵습니다.

마무리

Spring Security의 많은 필터중에 SecurityContextPersistenceFilterSecurityContextHolderFilter에 대해서 알아보았습니다.

저는 프로젝트 진행 중에 Spring Boot 3 버전으로 올리면서 기존의 인증 절차가 제대로 이루어지지 않아서 이유를 살펴보다가 관련 내용을 접했습니다.

이번 글이 많은 도움이 되었으면 좋겠습니다.

post-custom-banner

4개의 댓글

comment-user-thumbnail
2023년 7월 4일

덕분에 해결했습니당 감사합니다~

1개의 답글
comment-user-thumbnail
2023년 11월 30일

저도 덕분에 해결했어요!
감사합니다~~ ㅜㅜ

1개의 답글