一.未认证的请求是如何重定向到登录地址的?(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、写在最后
这一节我们主要分析了未认证的请求是如何重定向到登录地址(当前应用)的,下一节我们开始分析授权服务器是如何进行授权的。