SSO单点登录原理深度解剖(一)

一.未认证的请求是如何重定向到登录地址的?(SpringSecurity的认证流程)

1.前言 (参考了<https://blog.csdn.net/hou_ge/article/details/117846692> )

当第一次(未认证的情况下)访问client A(http://localhost:11980/cth/time)时,会重定向到client A的http://localhost:11980/cth/oauth2/authorization/messaging-client-oidc地址(Get请求),从浏览器这个视角我们看到的是这样的情况,那么在client A到底经历了什么呢?我们通过代码进行分析。

2.Spring security过滤器链

上面要分析的问题,其实就是SpringSecurity关于认证过程的逻辑。SpringSecurity实现认证逻辑,就是通过SpringSecurity 过滤器链实现的,我们先了解一下SpringSecurity过滤器链中的核心类FilterChainProxy。

2.1 FilterChainProxy

       在SpringSecurity中,SpringSecurity 的过滤器并不是直接内嵌到Servlet Filter中的,而是通过FilterChainProxy来统一管理的,即所有的Spring Security Filters的执行,都在FilterChainProxy中进行管理的,所以我们选择从FilterChainProxy类入手进行分析。

  为了实现上述描述的功能,SpringSecurity 过滤器由FilterChainProxy统一管理,然后在内部定义了一个VirtualFilterChain内部类,用于表示SpringScurity内部的过滤器链,其中doFilter()方法用于执行过滤器链中的过滤器。如下所示:

//FilterChainProxy#VirtualFilterChain(org.springframework.security.web)
@Override
public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
	if (this.currentPosition == this.size) {
		if (logger.isDebugEnabled()) {
			logger.debug(LogMessage.of(() -> "Secured " + requestLine(this.firewalledRequest)));
		}
		// Deactivate path stripping as we exit the security filter chain
		this.firewalledRequest.reset();
		this.originalChain.doFilter(request, response);
		return;
	}
	this.currentPosition++;
	Filter nextFilter = this.additionalFilters.get(this.currentPosition - 1);
	if (logger.isTraceEnabled()) {
		logger.trace(LogMessage.format("Invoking %s (%d/%d)", nextFilter.getClass().getSimpleName(),
				this.currentPosition, this.size));
	}
	nextFilter.doFilter(request, response, this);
}

我们通过断点,可以查看additionalFilters变量中的过滤器集合,即SpringSecurity过滤器链中所有过滤器,下面是应用A中的SpringSecurity 过滤器,如下所示:

(以下参考 SpringSecurity5-教程3-oauth2登录源码分析_authorization_request_not_found-CSDN博客

多了两个Filter:

OAuth2AuthorizationRequestRedirectFilter :重定向过滤器,即当未认证时,重定向到登录页

OAuth2LoginAuthenticationFilter:授权登录过滤器,处理指定的授权登录

debug放行直到进入OAuth2AuthorizationRequestRedirectFilter:

进入到resolve()方法里面,如下:

因为当前请求的是/time接口,并非授权登录请求,因此继续执行后续Filter;

debug放行进入AbstractAuthenticationProcessingFilter

首先执行方法requiresAuthentication判断当前请求是否需要当前AuthenticationFilter执行认证:

可知OAuth2AuthenticationFilter处理的请求是/login/oauth2/code/,因此返回false。因此不需要执行后面的OAuth2LoginAuthenticationFilter的attemptAuthentication方法。

直到现在,其实新加入的两个Filter都没有起作用,后续流程就是进入到AuthorizationFilter。被认证为匿名用户,授权校验抛出AccessDeniedExcepiton,然后ExceptionTranslateFilter处理重定向到登录页。

3.AuthorizationFilter

通过Debug执行代码,我们发现,在执行完AuthorizationFilter过滤器时,页面重定向到了http://localhost:11980/cth/oauth2/authorization/messaging-client-oidc地址(Get请求)。在执行过滤器AuthorizationFilter过滤器时,发生了什么呢?我们通过Debug方式,进行逐步的分析。

//AuthorizationFilter.java(org.springframework.security.web.access.intercept)
@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()) {
                              //enter this and throw exception
			throw new AccessDeniedException("Access Denied");
		}
		chain.doFilter(request, response);
	}
	finally {
		request.removeAttribute(alreadyFilteredAttributeName);
	}
}

首先我们debug进入AuthorizationFilter的 doFilter方法中,然后发现没有认证即granted =false,于是抛出了认证异常AccessDeniedException异常,如下图所示:

这个异常会被ExceptionTranslationFilter补获。如下代码:

//ExceptionTranslationFilter(org.springframework.security.web.access)
@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 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);
	}
}

当出现AccessDeniedException 异常时,会被ExceptionTranslationFilter过滤器的doFilter()方法中第二个catch 代码块进行拦截,然后交由handleSpringSecurityException()方法进行异常的处理,具体如下:

如下是handleSpringSecurityException方法的代码:

//ExceptionTranslationFilter.java(org.springframework.security.web.access)

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

在handleSpringSecurityException()方法中,根据AuthenticationException或AccessDeniedException异常类型,进行下一步执行,因为我们上一步抛出的是AccessDeniedException异常,所以会执行其中handleAccessDeniedException()的方法(其实两类异常都是执行这个方法,只不过参数不一样而已)。handleAccessDeniedException()方法的实现如下:

//ExceptionTranslationFilter.java(org.springframework.security.web.access)

private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
		FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
	Authentication authentication = SecurityContextHolder.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);
	}
}

从debug到的情况看,isAnonymous 是true.

所以将执行sendStartAuthentication方法:

sendStartAuthentication的代码如下:

//ExceptionTranslationFilter.java(org.springframework.security.web.access)
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
		AuthenticationException reason) throws ServletException, IOException {
	// SEC-112: Clear the SecurityContextHolder's Authentication, as the
	// existing Authentication is no longer considered valid
	SecurityContext context = SecurityContextHolder.createEmptyContext();
	SecurityContextHolder.setContext(context);
	this.requestCache.saveRequest(request, response);
	this.authenticationEntryPoint.commence(request, response, reason);
}

在sendStartAuthentication()方法中, 又调用了authenticationEntryPoint的commence()方法,这里的authenticationEntryPoint是DelegatingAuthenticationEntryPoint实例,最终的页面跳转也是在commence()方法中。如下所示:

这就是DelegatingAuthenticationEntryPoint的commence方法:

//DelegatingAuthenticationEntryPoint.java(org.springframework.security.web.authentication)

@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
		AuthenticationException authException) throws IOException, ServletException {
	for (RequestMatcher requestMatcher : this.entryPoints.keySet()) {
		logger.debug(LogMessage.format("Trying to match using %s", requestMatcher));
		if (requestMatcher.matches(request)) {
			AuthenticationEntryPoint entryPoint = this.entryPoints.get(requestMatcher);
			logger.debug(LogMessage.format("Match found! Executing %s", entryPoint));
			entryPoint.commence(request, response, authException);
			return;
		}
	}
	logger.debug(LogMessage.format("No match found. Using default entry point %s", this.defaultEntryPoint));
	// No EntryPoint matched, use defaultEntryPoint
	this.defaultEntryPoint.commence(request, response, authException);
}

从debug中我们可以看到,entryPoint是LoginUrlAuthenticationEntryPoint,于是进入LoginUrlAuthenticationEntryPoint的commce方法。

LoginUrlAuthenticationEntryPoint的commence方法如下所示:

//LoginUrlAuthenticationEntryPoint.java(org.springframework.security.web.authentication)

@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
		AuthenticationException authException) throws IOException, ServletException {
	if (!this.useForward) {
		// redirect to login page. Use https if forceHttps true
		String redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
		this.redirectStrategy.sendRedirect(request, response, redirectUrl);
		return;
	}
	String redirectUrl = null;
	if (this.forceHttps && "http".equals(request.getScheme())) {
		// First redirect the current request to HTTPS. When that request is received,
		// the forward to the login page will be used.
		redirectUrl = buildHttpsRedirectUrlForRequest(request);
	}
	if (redirectUrl != null) {
		this.redirectStrategy.sendRedirect(request, response, redirectUrl);
		return;
	}
	String loginForm = determineUrlToUseForThisRequest(request, response, authException);
	logger.debug(LogMessage.format("Server side forward to: %s", loginForm));
	RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
	dispatcher.forward(request, response);
	return;
}

我们从debug的情况看,在这里拼出了要跳转的url。所以没经过认证的地址会跳到这个地址,最后通过调用redirectStrategy的sendRedirect()方法来完成最终的重定向。

这个是DefaultRedirectStrategy的sendRedirect方法。

//DefaultRedirectStrategy(org.springframework.security.web)

@Override
public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException {
	String redirectUrl = calculateRedirectUrl(request.getContextPath(), url);
	redirectUrl = response.encodeRedirectURL(redirectUrl);
	if (this.logger.isDebugEnabled()) {
		this.logger.debug(LogMessage.format("Redirecting to %s", redirectUrl));
	}
	response.sendRedirect(redirectUrl);
}

至此,通过执行redirectStrategy.sendRedirect()方法,就实现了重定向到应用A的登录地址

http://localhost:11980/cth/oauth2/authorization/messaging-client-oidc

了。

4、写在最后

这一节我们主要分析了未认证的请求是如何重定向到登录地址(当前应用)的,下一节我们开始分析授权服务器是如何进行授权的。

  • 22
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值