Security 6.x 에서 SecurityContextHolder 인증 객체 저장을 위한 설정

Yoonhwan Kim·2024년 2월 9일
2

security

목록 보기
5/5
post-custom-banner

가장 먼저 이해해야할 부분

SecurityContextPersistenceFilter vs SecurityContextHolderFilter

Security 6.x 버전을 기점으로 SecurityContextHolder 를 사용했을 때 동작이 달라졌습니다.

특히, SecurityContextHolder.getContext().setAuthentication(..) 를 사용하고 그 인증 객체를 획득하려고 할 때 해당 인증 객체를 저장하는 시점 외에 다른 요청이 발생했을 경우 인증 객체가 유실되는 상황이 발생하는데요.

만약 JWT 를 사용하고 Presentation Layer 에서 로그인을 직접 구현한 방법으로 동작을 만든 상황이라면 더욱 난감할 것입니다.

해결 방법은 현재 DeprecatedSecurityContextPersistenceFilter 와 6.x 버전부터 사용중인 SecurityContextHolderFilter 클래스를 보면 이해가 빨리 됩니다.


SecurityContextPersistenceFilter

@Deprecated
public class SecurityContextPersistenceFilter extends GenericFilterBean {

	static final String FILTER_APPLIED = "__spring_security_scpf_applied";

	private SecurityContextRepository repo;

	private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
			.getContextHolderStrategy();

	private boolean forceEagerSessionCreation = false;

	public SecurityContextPersistenceFilter() {
		this(new HttpSessionSecurityContextRepository());
	}

	public SecurityContextPersistenceFilter(SecurityContextRepository repo) {
		this.repo = repo;
	}

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
	}
}

SecurityContextHolderFilter

public class SecurityContextHolderFilter extends GenericFilterBean {

	private static final String FILTER_APPLIED = SecurityContextHolderFilter.class.getName() + ".APPLIED";

	private final SecurityContextRepository securityContextRepository;

	private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
			.getContextHolderStrategy();

	/**
	 * Creates a new instance.
	 * @param securityContextRepository the repository to use. Cannot be null.
	 */
	public SecurityContextHolderFilter(SecurityContextRepository securityContextRepository) {
		Assert.notNull(securityContextRepository, "securityContextRepository cannot be null");
		this.securityContextRepository = securityContextRepository;
	}

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
	}
}

가장 중요한 포인트는 SecurityContextRepository 입니다.

SecurityContextRepository 인터페이스의 구현체를 SecurityContextPersistenceFilter 에서는 생성자를 통해서 HttpSessionSecurityContextRepository 클래스를 선언하여 쓰고 있지만,

SecurityContextHolderFilter는 사용하고 있지 않습니다.

HttpSessionSecurityContextRepository 는 클래스 이름에서 유추해볼 수 있듯 HttpSession 을 생성하는 로직을 담은 클래스입니다.

이 클래스를 Security 에 적용시켜서 기존 Security 가 동작했다고 볼 수 있습니다.


실제 동작 확인을 위한 코드

제가 REST API 형식으로 Security Filter 를 거쳐서 동작하도록 구현한 코드입니다.
JSON 형식으로 요청을 하면 로그인이 동작하게 됩니다.

여기서 로그인이 성공하게 되면 아래의 코드와 같이 SecurityContextHolder에 인증객체가 들어가는 것을 확인이 가능하게 됩니다.

로그인 처리부 일부

public class LoginAuthenticationFilterV2 extends AbstractAuthenticationProcessingFilter {

    public LoginAuthenticationFilterV2(
            final String defaultFilterProcessesUrl,
            final AuthenticationManager authenticationManager,
            final AuthenticationSuccessHandler authenticationSuccessHandler
    ) {
        super(defaultFilterProcessesUrl, authenticationManager);
        setAuthenticationSuccessHandler(authenticationSuccessHandler);
  
    }

    @Override
    public Authentication attemptAuthentication(
            HttpServletRequest request,
            HttpServletResponse response
    ) throws AuthenticationException, IOException {

        String method = request.getMethod();

        if (!method.equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }

        ServletInputStream inputStream = request.getInputStream();

        LoginRequestDtoV2 loginRequestDtoV2 = new ObjectMapper()
                .readValue(inputStream, LoginRequestDtoV2.class);

        return this.getAuthenticationManager().authenticate(
                new UsernamePasswordAuthenticationToken(
                        loginRequestDtoV2.username(),
                        loginRequestDtoV2.password()
                )
        );
    }

}

AuthenticationSuccessHandler 구현 일부

 @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication
    ) throws IOException, ServletException {

        // SampleController의 로그인 구현 로직과 비슷하게 들어간다.
        System.out.println("로그인 성공");
        AuthenticationDetail principal = (AuthenticationDetail) authentication.getPrincipal();

        Token jwtToken = jwtTokenProvider.createJwtToken(principal);

        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        response.getWriter().println(this.objectMapper.writeValueAsString(jwtToken));
        System.out.println("SecurityContextHolder.getContext().getAuthentication() = " + SecurityContextHolder.getContext().getAuthentication());
    }

Controller 일부

@GetMapping("/")
    public String index(HttpServletRequest request) {
        System.out.println("SecurityContextHolder.getContext().getAuthentication() = " + SecurityContextHolder.getContext().getAuthentication());
        System.out.println("ss : " +request.getSession().getAttribute("SPRING_SECURITY_CONTEXT"));
        return "index";
    }

동작 결과 확인

로그인 성공
Hibernate: 
    select
        u1_0.user_id,
        u1_0.password,
        u1_0.username 
    from
        user_t u1_0 
    where
        u1_0.username=?
SecurityContextHolder.getContext().getAuthentication() = 
UsernamePasswordAuthenticationToken [Principal=io.keede.bootlateststarter.domains.user.dto.AuthenticationDetail [Username=tester, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_MEMBER]], Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_MEMBER]]

로그인을 성공하면 위와 같이 성공 핸들러 로직이 동작하게 됩니다,

하지만 문제는 다른 요청을 호출하면 아래와 같이 확인이 됩니다.

SecurityContextHolder.getContext().getAuthentication() = 
AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]]
ss : null

SecurityContextHolder 의 인증 객체가 저장되지도 않았고, HttpSession 에도 저장되지 않은 결과가 나오게 됩니다.

공식문서에도 해결방법이 나와 있긴 하지만, 결국 저희가 신경써서 설정해야 하는 부분은 SecurityContextRepository 구현체를 등록해줘야 한다는 점이 됩니다.
그래야 인증객체도 정상적으로 유지가 되면서, HttpSession 을 이용할 수 있게 됩니다.


변경한 구현 코드

public LoginAuthenticationFilterV2(
            final String defaultFilterProcessesUrl,
            final AuthenticationManager authenticationManager,
            final AuthenticationSuccessHandler authenticationSuccessHandler
    ) {
        super(defaultFilterProcessesUrl, authenticationManager);
        setAuthenticationSuccessHandler(authenticationSuccessHandler);
		// 추가된 코드
		// 로그인 이후 Context 생성 전략 설정
        setSecurityContextRepository(
                new DelegatingSecurityContextRepository(
                        new HttpSessionSecurityContextRepository(),
                        new RequestAttributeSecurityContextRepository()
                )
        );
    }

이제 동작을 해보면,,?

로그인 성공시

로그인 성공
Hibernate: 
    select
        u1_0.user_id,
        u1_0.password,
        u1_0.username 
    from
        user_t u1_0 
    where
        u1_0.username=?
SecurityContextHolder.getContext().getAuthentication() = 
UsernamePasswordAuthenticationToken [Principal=io.keede.bootlateststarter.domains.user.dto.AuthenticationDetail [Username=tester, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_MEMBER]], Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_MEMBER]]

그 외 API 요청을 했을 시

SecurityContextHolder.getContext().getAuthentication() = UsernamePasswordAuthenticationToken [Principal=io.keede.bootlateststarter.domains.user.dto.AuthenticationDetail [Username=tester, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_MEMBER]], Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_MEMBER]]
ss : SecurityContextImpl [Authentication=UsernamePasswordAuthenticationToken [Principal=io.keede.bootlateststarter.domains.user.dto.AuthenticationDetail [Username=tester, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_MEMBER]], Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_MEMBER]]]

이처럼 Security 6.x 버전 이상부터는 Security 의 기본 로그인을 사용하지 않고 구현하여 사용할 경우나 커스텀한 로그인 로직에 SecurityContextHolder 에 인증 객체를 직접 넣어 쓸 경우에는 SecurityContextRepository 를 설정해줘야 하게 됐습니다.

추가 고려사항

만약 제 방식 처럼 Security 의 로그인 동작 필터를 구현하여 쓰지않고 Presentation Layer 에서 로그인 API를 사용하여 쓸 경우에는 SecurityContext , SecurityContextRepository , SecurityContextHolderFilter 를 직접 Bean 등록을 하고 Filter 등록을 해야 했습니다.

이후 아래와 같이 기존에 Security 에서 동작했던 Context를 저장하는 작업을 추가하여 사용했습니다.

        this.securityContext.setAuthentication(loginRequestDtoV2.toAuthenticationToken());
        SecurityContextHolder.setContext(this.securityContext);
        this.securityContextRepository.saveContext(this.securityContext, request, response);

위 코드는 Presentation Layer 에서 JWT 로그인으로 구현할 떄 쓰던 예시 코드입니다.


post-custom-banner

0개의 댓글