Spring Security 기본 필터 [2 / 2]

junto·2024년 4월 6일
0

spring

목록 보기
10/30
post-thumbnail

Spring 기본 필터

7. LogoutFilter

public class LogoutFilter extends GenericFilterBean {
	public LogoutFilter(LogoutSuccessHandler logoutSuccessHandler, LogoutHandler... handlers) {
		this.handler = new CompositeLogoutHandler(handlers);
		Assert.notNull(logoutSuccessHandler, "logoutSuccessHandler cannot be null");
		this.logoutSuccessHandler = logoutSuccessHandler;
		setFilterProcessesUrl("/logout");
	}

	public LogoutFilter(String logoutSuccessUrl, LogoutHandler... handlers) {
		this.handler = new CompositeLogoutHandler(handlers);
		Assert.isTrue(!StringUtils.hasLength(logoutSuccessUrl) || UrlUtils.isValidRedirectUrl(logoutSuccessUrl),
				() -> logoutSuccessUrl + " isn't a valid redirect URL");
		SimpleUrlLogoutSuccessHandler urlLogoutSuccessHandler = new SimpleUrlLogoutSuccessHandler();
		if (StringUtils.hasText(logoutSuccessUrl)) {
			urlLogoutSuccessHandler.setDefaultTargetUrl(logoutSuccessUrl);
		}
		this.logoutSuccessHandler = urlLogoutSuccessHandler;
		setFilterProcessesUrl("/logout");
	}
	...
    
    	private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		if (requiresLogout(request, response)) {
			Authentication auth = this.securityContextHolderStrategy.getContext().getAuthentication();
			if (this.logger.isDebugEnabled()) {
				this.logger.debug(LogMessage.format("Logging out [%s]", auth));
			}
			this.handler.logout(request, response, auth);
			this.logoutSuccessHandler.onLogoutSuccess(request, response, auth);
			return;
		}
		chain.doFilter(request, response);
	}
}
  • 사용자가 만든 CustomLogoutSuccessHandler 여부에 따라 logoutFilter가 생성된다. 여기에서 기본 검증이 진행되는 걸 볼 수 있다.
  • 로그아웃이 성공하면, logoutSuccessHandler가 요청에 맞는 requestMather에 위임하여 특정 URL로 redirect시킨다.
  • 토큰 방식 로그인을 진행할 때, 기존 쿠키를 무효화 시키는 작업을 해당 로그아웃 필터를 구현해서 적용한다.

8. UsernamePasswordAuthenticationFilter

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
	...
	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException {
		if (this.postOnly && !request.getMethod().equals("POST")) {
			throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
		}
		String username = obtainUsername(request);
		username = (username != null) ? username.trim() : "";
		String password = obtainPassword(request);
		password = (password != null) ? password : "";
		UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
				password);
                
		setDetails(request, authRequest);
		return this.getAuthenticationManager().authenticate(authRequest);
	}
}
  • post 요청으로 login 요청을 받은 후에 username과 password를 통해 인증되지 않은 토큰을 만든다. 이를 AuthenticationManager에게 넘겨 토큰 검증을 시도한다. 이유는 사용자가 여러 인증 논리를 구현할 수 있으며(기본 로그인, 소셜 로그인) 인증 논리를 구현한 객체에 맞게 인증이 완료되면 인증된 토큰을 전달하기 위해서다.
  • 일반 로그인을 진행할 경우 위와 같이 DaoAuthenticationProvider에 의해 authenticate가 실행된다.
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
	...
    	@Override
	protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
			throws AuthenticationException {
		prepareTimingAttackProtection();
		try {
			UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
			if (loadedUser == null) {
				throw new InternalAuthenticationServiceException(
						"UserDetailsService returned null, which is an interface contract violation");
			}
			return loadedUser;
		}
		catch (UsernameNotFoundException ex) {
			mitigateAgainstTimingAttack(authentication);
			throw ex;
		}
		catch (InternalAuthenticationServiceException ex) {
			throw ex;
		}
		catch (Exception ex) {
			throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
		}
	}

	@Override
	protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
			UserDetails user) {
		boolean upgradeEncoding = this.userDetailsPasswordService != null
				&& this.passwordEncoder.upgradeEncoding(user.getPassword());
		if (upgradeEncoding) {
			String presentedPassword = authentication.getCredentials().toString();
			String newPassword = this.passwordEncoder.encode(presentedPassword);
			user = this.userDetailsPasswordService.updatePassword(user, newPassword);
		}
		return super.createSuccessAuthentication(principal, authentication, user);
	}
}
  • AbstractUserDetailsAuthenticationProvider는 여러 인증 논리 구현 객체를 제공할 수 있도록 공통된 틀을 제공한다.
  • 인증 공급자는 detailsService 인터페이스를 통해 유저 정보를 가져온다. 그 이유는 일반적으로 사용자 정보를 db, xml, 인메모리, ldap 등에서 관리하고 환경에 맞게 정보를 가져올 수 있기 때문이다.
  • 실제 과정은 더 복잡하지만 간단히 정리하면 detailsService를 이용해 사용자 정보를 가져오고, password를 검증하여 올바르다면 createSuccessAuthentication()을 실행한다.

9. DefaultLoginPageGeneratingFilter

public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
	...
    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		boolean loginError = isErrorPage(request);
		boolean logoutSuccess = isLogoutSuccess(request);
		if (isLoginUrlRequest(request) || loginError || logoutSuccess) {
			String loginPageHtml = generateLoginPageHtml(request, loginError, logoutSuccess);
			response.setContentType("text/html;charset=UTF-8");
			response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length);
			response.getWriter().write(loginPageHtml);
			return;
		}
		chain.doFilter(request, response);
	}
}
  • 로그인, 로그인 실패, 로그아웃 성공 경우마다 동적으로 페이지를 만들어 제공한다. 위 경우가 아니라면 다음 필터로 요청을 넘긴다.

10. DefaultLogoutPageGeneratingFilter

public class DefaultLogoutPageGeneratingFilter extends OncePerRequestFilter {

	private RequestMatcher matcher = new AntPathRequestMatcher("/logout", "GET");

	private Function<HttpServletRequest, Map<String, String>> resolveHiddenInputs = (request) -> Collections.emptyMap();

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
		if (this.matcher.matches(request)) {
			renderLogout(request, response);
		}
		else {
			if (logger.isTraceEnabled()) {
				logger.trace(LogMessage.format("Did not render default logout page since request did not match [%s]",
						this.matcher));
			}
			filterChain.doFilter(request, response);
		}
	}
  • url /logout 이고, get이면 로그아웃 페이지를 랜더링하는 로직을 가지고 있다. 로그아웃을 하시겠습니까? 라는 페이지가 나타난다.
  • logout 요청을 처리할 때 get과 post 중 무엇을 선택해야 하는가?https://stackoverflow.com/questions/3521290/logout-get-or-post 스택오버플로우 답변을 참고하자.
  • Spring Security 에서는 In your application it is not necessary to use GET /logout to perform a logout. So long as the needed CSRF token is present in the request, your application can simply POST /logout to induce a logout. 라고 하여 로그아웃을 확인하는 페이지를 반드시 구현할 필요 없다고 하며, POST 요청으로 http session, 발행한 토큰, 인증 정보를 무효화해야 한다고 나와있다.

11. BasicAuthenticationFilter

  • HTTP 요청을 보낼 때 헤더에 Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==같이 사용자 아이디=password를 Base64로 인코딩하여 전송하는 방법이다. 중간에 패킷의 내용을 들여다보는 packet sniffing에 취약하기에 보안을 위해 https 전송이 권장된다.
public class BasicAuthenticationFilter extends OncePerRequestFilter {
	...
    	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		try {
			Authentication authRequest = this.authenticationConverter.convert(request);
			if (authRequest == null) {
				this.logger.trace("Did not process authentication request since failed to find "
						+ "username and password in Basic Authorization header");
				chain.doFilter(request, response);
				return;
			}
			String username = authRequest.getName();
			this.logger.trace(LogMessage.format("Found username '%s' in Basic Authorization header", username));
			if (authenticationIsRequired(username)) {
				Authentication authResult = this.authenticationManager.authenticate(authRequest);
				SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
				context.setAuthentication(authResult);
				this.securityContextHolderStrategy.setContext(context);
				if (this.logger.isDebugEnabled()) {
					this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
				}
				this.rememberMeServices.loginSuccess(request, response, authResult);
				this.securityContextRepository.saveContext(context, request, response);
				onSuccessfulAuthentication(request, response, authResult);
			}
		}
		catch (AuthenticationException ex) {
			this.securityContextHolderStrategy.clearContext();
			this.logger.debug("Failed to process authentication request", ex);
			this.rememberMeServices.loginFail(request, response);
			onUnsuccessfulAuthentication(request, response, ex);
			if (this.ignoreFailure) {
				chain.doFilter(request, response);
			}
			else {
				this.authenticationEntryPoint.commence(request, response, ex);
			}
			return;
		}

		chain.doFilter(request, response);
	}
}
  • 요청에 담긴 내용이 인증 요청이라면 인증 객체(Authentication)로 변환한 후 username과 password를 검증한다. 유효하다면 다음 필터로 넘긴다.

12. RequestCacheAwareFilter

public class RequestCacheAwareFilter extends GenericFilterBean {
	public RequestCacheAwareFilter(RequestCache requestCache) {
		Assert.notNull(requestCache, "requestCache cannot be null");
		this.requestCache = requestCache;
	}

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		HttpServletRequest wrappedSavedRequest = this.requestCache.getMatchingRequest((HttpServletRequest) request,
				(HttpServletResponse) response);
		chain.doFilter((wrappedSavedRequest != null) ? wrappedSavedRequest : request, response);
	}
}
  • 사용자의 요청이 캐시되어 있으면 (RequestCache) 저장된 요청을 사용하여 다음 필터로 넘어간다. 캐시된 요청이 없다면 현재 요청으로 다음 필터를 진행한다.

13. SecurityContextHolderAwareRequestFilter

public class SecurityContextHolderFilter extends GenericFilterBean {

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

	private final SecurityContextRepository securityContextRepository;

	private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
		.getContextHolderStrategy();

	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);
	}

	private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws ServletException, IOException {
		if (request.getAttribute(FILTER_APPLIED) != null) {
			chain.doFilter(request, response);
			return;
		}
		request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
		Supplier<SecurityContext> deferredContext = this.securityContextRepository.loadDeferredContext(request);
		try {
			this.securityContextHolderStrategy.setDeferredContext(deferredContext);
			chain.doFilter(request, response);
		}
		finally {
			this.securityContextHolderStrategy.clearContext();
			request.removeAttribute(FILTER_APPLIED);
		}
	}
}
  • Security Context Holder에 Security Context를 저장하기 위해 사용하는 필터이다. 코드를 보면 FILTER_APPLIED를 이용해 이미 적용되었는지 판단하는데, 한 번만 실행하기 위해서 OncePerRequestFilter를 사용하면 되지 않았을까 하는 의문이 든다.
  • SecurityContext에 저장하고, 다음 필터를 실행하고 나서 완료가 되면 Security Context를 초기화하는 작업을 한다.

14. AnonymousAuthenticationFilter

  • Security Context Holder에 인증 객체가 없는지 감지하고 필요한 경우 인증 객체로 채우는 필터이다.
public class AnonymousAuthenticationFilter extends GenericFilterBean implements InitializingBean {
	
    ...
	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {
		Supplier<SecurityContext> deferredContext = this.securityContextHolderStrategy.getDeferredContext();
		this.securityContextHolderStrategy
			.setDeferredContext(defaultWithAnonymous((HttpServletRequest) req, deferredContext));
		chain.doFilter(req, res);
	}

	private Supplier<SecurityContext> defaultWithAnonymous(HttpServletRequest request,
			Supplier<SecurityContext> currentDeferredContext) {
		return SingletonSupplier.of(() -> {
			SecurityContext currentContext = currentDeferredContext.get();
			return defaultWithAnonymous(request, currentContext);
		});
	}

	private SecurityContext defaultWithAnonymous(HttpServletRequest request, SecurityContext currentContext) {
		Authentication currentAuthentication = currentContext.getAuthentication();
		if (currentAuthentication == null) {
			Authentication anonymous = createAuthentication(request);
			if (this.logger.isTraceEnabled()) {
				this.logger.trace(LogMessage.of(() -> "Set SecurityContextHolder to " + anonymous));
			}
			else {
				this.logger.debug("Set SecurityContextHolder to anonymous SecurityContext");
			}
			SecurityContext anonymousContext = this.securityContextHolderStrategy.createEmptyContext();
			anonymousContext.setAuthentication(anonymous);
			return anonymousContext;
		}
		else {
			if (this.logger.isTraceEnabled()) {
				this.logger.trace(LogMessage.of(() -> "Did not set SecurityContextHolder since already authenticated "
						+ currentAuthentication));
			}
		}
		return currentContext;
	}
}
  • 최초 접속으로 인증 정보가 없고, 인증을 하지 않았을 경우 defaultWithAnonymous() 호출하여 세션에 익명 사용자를 설정한다. 이는 Security Context가 없는 요청에 대해 익명 사용자로 인증을 설정하여, 인증되지 않은 사용자도 시스템의 일부 기능에 접근할 수 있도록 지원한다.

15. ExceptionTranslationFilter

  • 필터 체인 내에서 던져진 모든 AccessDeniedException 및 AuthenticationException을 처리하는 필터이다.
public class ExceptionTranslationFilter extends GenericFilterBean implements MessageSourceAware {
	...
    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		try {
			chain.doFilter(request, response);
		}
		catch (IOException ex) {
			throw ex;
		}
		catch (Exception ex) {
			// Try to extract a SpringSecurityException from the stacktrace
			Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
			RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer
				.getFirstThrowableOfType(AuthenticationException.class, causeChain);
			if (securityException == null) {
				securityException = (AccessDeniedException) this.throwableAnalyzer
					.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
			}
			if (securityException == null) {
				rethrow(ex);
			}
			if (response.isCommitted()) {
				throw new ServletException("Unable to handle the Spring Security Exception "
						+ "because the response is already committed.", ex);
			}
			handleSpringSecurityException(request, response, chain, securityException);
		}
	}
   
    private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response,
			FilterChain chain, RuntimeException exception) throws IOException, ServletException {
		if (exception instanceof AuthenticationException) {
			handleAuthenticationException(request, response, chain, (AuthenticationException) exception);
		}
		else if (exception instanceof AccessDeniedException) {
			handleAccessDeniedException(request, response, chain, (AccessDeniedException) exception);
		}
	}
  • AuthenticationException이 발생하면 필터는 handleAuthenticationException()을 실행하고, AccessDeniedException이 발생하면 필터는 handleAccessDeniedException()를 실행한다.
  • handleAuthenticationException()에서는 인증 실패 로그를 기록하고 사용자를 인증 절차를 시작할 수 있는 엔트리 포인트로 리디렉션한다.
  • handleAccessDeniedException()에서는 현재 사용자가 익명 사용자이거나 RememberMe 기능을 통해 인증된 경우, 추가적인 인증 절차를 요구한다.
    ...
    private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response,
			FilterChain chain, AuthenticationException exception) throws ServletException, IOException {
		this.logger.trace("Sending to authentication entry point since authentication failed", exception);
		sendStartAuthentication(request, response, chain, exception);
	}
   ...
   private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
			FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
		Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
		boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
		if (isAnonymous || this.authenticationTrustResolver.isRememberMe(authentication)) {
			if (logger.isTraceEnabled()) {
				logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied",
						authentication), exception);
			}
			sendStartAuthentication(request, response, chain,
					new InsufficientAuthenticationException(
							this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
									"Full authentication is required to access this resource")));
		}
		else {
			if (logger.isTraceEnabled()) {
				logger.trace(
						LogMessage.format("Sending %s to access denied handler since access is denied", authentication),
						exception);
			}
			this.accessDeniedHandler.handle(request, response, exception);
		}
	}
}
  • 이 필터는 에러 종류에 따라 리디렉션 하는 역할을 담당한다. 보안을 적용하는 필터가 아니다.

16. AuthorizationFilter

public class AuthorizationFilter extends GenericFilterBean {
	...
    	@Override
	public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
			throws ServletException, IOException {

		HttpServletRequest request = (HttpServletRequest) servletRequest;
		HttpServletResponse response = (HttpServletResponse) servletResponse;

		if (this.observeOncePerRequest && isApplied(request)) {
			chain.doFilter(request, response);
			return;
		}

		if (skipDispatch(request)) {
			chain.doFilter(request, response);
			return;
		}

		String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName();
		request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
		try {
			AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
			this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
			if (decision != null && !decision.isGranted()) {
				throw new AccessDeniedException("Access Denied");
			}
			chain.doFilter(request, response);
		}
		finally {
			request.removeAttribute(alreadyFilteredAttributeName);
		}
	}
}
  • 인증된 사용자가 요청한 작업을 수행할 권한이 있는지 확인하는 필터이다. 요청이 이미 처리되었거나 검증이 생략되어야 하는 요청인 경우 다음 필터로 넘어간다.
  • authorizationManager.check()를 통해 인증 정보와 요청에 대한 권한이 있는지 확인하여 권한 검증 결과에 대한 이벤트를 생성(publishAuthorizationEvent)한다.
  • 권한 부여가 제대로 되지 않았으면 에러를 발생시킨다. 문제가 없다면 다음 필터로 넘어간다.

참고 자료

profile
꾸준하게

0개의 댓글