SpringSecurity 默認表單登錄頁展示流程源碼
本篇主要講解 SpringSecurity提供的默認表單登錄頁 它是如何展示的的流程,
涉及
1.FilterSecurityInterceptor,
2.ExceptionTranslationFilter ,
3.DefaultLoginPageGeneratingFilter 過濾器,
並且簡單介紹了 AccessDecisionManager 投票機制
1.準備工作(體驗SpringSecurity默認表單認證)
1.1 創建SpringSecurity項目
先通過IDEA 創建一個SpringBoot項目 並且依賴SpringSecurity,Web依賴
此時pom.xml會自動添加
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
1.2 提供一個接口
@RestController
public class HelloController {
@RequestMapping("/hello")
public String hello() {
return "Hello SpringSecurity";
}
}
1.3 啓動項目
直接訪問 提供的接口
http://localhost:8080/hello
會發現瀏覽器被直接重定向到了 /login 並且顯示如下默認的表單登錄頁
http://localhost:8080/login
1.4 登錄
在啓動項目的時候 控制檯會打印一個 seuciryt password : xxx
Using generated security password: f520875f-ea2b-4b5d-9b0c-f30c0c17b90b
直接登錄
用戶名:user 密碼 :f520875f-ea2b-4b5d-9b0c-f30c0c17b90b
登錄成功並且 瀏覽器又會重定向到 剛剛訪問的接口
2.springSecurityFilterchain 過濾器鏈
如果你看過我另一篇關於SpringSecurity初始化源碼的博客,那麼你一定知道當SpringSecurity項目啓動完成後會初始化一個 springSecurityFilterchain 它內部 additionalFilters屬性初始化了很多Filter 如下
所有的請求都會經過這一系列的過濾器 Spring Security就是通過這些過濾器 來進行認證授權等
3.FilterSecurityInterceptor (它會判斷這次請求能否通過)
FilterSecurityInterceptor是過濾器鏈中最後一個過濾器,主要用於判斷請求能否通過,內部通過AccessDecisionManager 進行投票判斷
當我們未登錄訪問
http://localhost:8080/hello
請求會被 FilterSecurityInterceptor 攔截
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
FilterInvocation fi = new FilterInvocation(request, response, chain);
invoke(fi);
}
重點看invoke方法
public void invoke(FilterInvocation fi) throws IOException, ServletException {
if ((fi.getRequest() != null)
&& (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
&& observeOncePerRequest) {
// filter already applied to this request and user wants us to observe
// once-per-request handling, so don't re-do security checking
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
else {
// first time this request being called, so perform security checking
if (fi.getRequest() != null && observeOncePerRequest) {
fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}
InterceptorStatusToken token = super.beforeInvocation(fi);
try {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
}
finally {
super.finallyInvocation(token);
}
super.afterInvocation(token, null);
}
}
源碼中有這樣一句,其實就是判斷當前用戶是否能夠訪問指定的接口,可以則執行 fi.getChain().doFilter 調用訪問的接口
否則 內部會拋出異常
InterceptorStatusToken token = super.beforeInvocation(fi);
beforeInvocation 方法內部是通過 accessDecisionManager 去做決定的
Spring Security已經內置了幾個基於投票的AccessDecisionManager包括(AffirmativeBased ,ConsensusBased ,UnanimousBased)當然如果需要你也可以實現自己的AccessDecisionManager
使用這種方式,一系列的AccessDecisionVoter將會被AccessDecisionManager用來對Authentication是否有權訪問受保護對象進行投票,然後再根據投票結果來決定是否要拋出AccessDeniedException
this.accessDecisionManager.decide(authenticated, object, attributes);
AffirmativeBased的 decide的實現如下
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
int deny = 0;
Iterator var5 = this.getDecisionVoters().iterator();
while(var5.hasNext()) {
AccessDecisionVoter voter = (AccessDecisionVoter)var5.next();
int result = voter.vote(authentication, object, configAttributes);
if (this.logger.isDebugEnabled()) {
this.logger.debug("Voter: " + voter + ", returned: " + result);
}
switch(result) {
case -1:
++deny;
break;
case 1:
return;
}
}
if (deny > 0) {
throw new AccessDeniedException(this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
} else {
this.checkAllowIfAllAbstainDecisions();
}
}
AffirmativeBased的邏輯是這樣的:
(1)只要有AccessDecisionVoter的投票爲ACCESS_GRANTED則同意用戶進行訪問;
(2)如果全部棄權也表示通過;
(3)如果沒有一個人投贊成票,但是有人投反對票,則將拋出AccessDeniedException。
當我們第一次訪問的時候
http://localhost:8080/hello的時候
返回 result = -1 會拋出 AccessDeniedException 拒絕訪問異常
4.ExceptionTranslationFilter (捕獲AccessDeniedException異常)
該過濾器它會接收到FilterSecurityInterceptor拋出的 AccessDeniedException異常)並且進行捕獲,然後發送重定向到/login請求
源碼如下:
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
try {
chain.doFilter(request, response);
logger.debug("Chain processed normally");
}
catch (IOException ex) {
throw ex;
}
catch (Exception ex) {
// Try to extract a SpringSecurityException from the stacktrace
Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex);
RuntimeException ase = (AuthenticationException) throwableAnalyzer
.getFirstThrowableOfType(AuthenticationException.class, causeChain);
if (ase == null) {
ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType(
AccessDeniedException.class, causeChain);
}
if (ase != null) {
if (response.isCommitted()) {
throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex);
}
handleSpringSecurityException(request, response, chain, ase);
}
else {
// Rethrow ServletExceptions and RuntimeExceptions as-is
if (ex instanceof ServletException) {
throw (ServletException) ex;
}
else if (ex instanceof RuntimeException) {
throw (RuntimeException) ex;
}
// Wrap other Exceptions. This shouldn't actually happen
// as we've already covered all the possibilities for doFilter
throw new RuntimeException(ex);
}
}
}
當獲取異常後 調用
handleSpringSecurityException(request, response, chain, ase);
handleSpringSecurityException 源碼如下:
private void handleSpringSecurityException(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, RuntimeException exception)
throws IOException, ServletException {
if (exception instanceof AuthenticationException) {
logger.debug(
"Authentication exception occurred; redirecting to authentication entry point",
exception);
sendStartAuthentication(request, response, chain,
(AuthenticationException) exception);
}
else if (exception instanceof AccessDeniedException) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authenticationTrustResolver.isAnonymous(authentication) || authenticationTrustResolver.isRememberMe(authentication)) {
logger.debug(
"Access is denied (user is " + (authenticationTrustResolver.isAnonymous(authentication) ? "anonymous" : "not fully authenticated") + "); redirecting to authentication entry point",
exception);
sendStartAuthentication(
request,
response,
chain,
new InsufficientAuthenticationException(
messages.getMessage(
"ExceptionTranslationFilter.insufficientAuthentication",
"Full authentication is required to access this resource")));
}
else {
logger.debug(
"Access is denied (user is not anonymous); delegating to AccessDeniedHandler",
exception);
accessDeniedHandler.handle(request, response,
(AccessDeniedException) exception);
}
}
}
先判斷獲取的異常是否是AccessDeniedException 再判斷是否是匿名用戶,如果是則調用 sendStartAuthentication 重定向到登錄頁面
重定向登錄頁面之前會保存當前訪問的路徑,這就是爲什麼我們訪問 /hello接口後 再登錄成功後又會跳轉到 /hello接口,因爲在重定向到/login接口前 這裏進行了保存 requestCache.saveRequest(request, response);
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
SecurityContextHolder.getContext().setAuthentication(null);
requestCache.saveRequest(request, response);
logger.debug("Calling Authentication entry point.");
authenticationEntryPoint.commence(request, response, reason);
}
authenticationEntryPoint.commence(request, response, reason);方法內部
調用LoginUrlAuthenticationEntryPoint 的 commence方法
LoginUrlAuthenticationEntryPoint 的commence方法內部有 構造重定向URL的方法
redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
protected String buildRedirectUrlToLoginPage(HttpServletRequest request,
HttpServletResponse response, AuthenticationException authException) {
String loginForm = determineUrlToUseForThisRequest(request, response,
authException);
protected String determineUrlToUseForThisRequest(HttpServletRequest request,
HttpServletResponse response, AuthenticationException exception) {
return getLoginFormUrl();
}
最終會獲取到需要重定向的URL /login
然後sendRedirect 既會重定向到 /login 請求
5.DefaultLoginPageGeneratingFilter (會捕獲重定向的/login 請求)
DefaultLoginPageGeneratingFilter是過濾器鏈中的一個用於捕獲/login請求,並且渲染出一個默認表單頁面
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
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);
}
isLoginUrlRequest 判斷請求是否是 loginPageUrl
private boolean isLoginUrlRequest(HttpServletRequest request) {
return matches(request, loginPageUrl);
}
因爲我們沒有配置所以 默認的 loginPageUrl = /login
驗證通過請求路徑 能匹配 loginPageUrl
String loginPageHtml = generateLoginPageHtml(request, loginError,
logoutSuccess);
generateLoginPageHtml 繪製默認的HTML 頁面,到此我們默認的登錄頁面怎麼來的就解釋清楚了
private String generateLoginPageHtml(HttpServletRequest request, boolean loginError,
boolean logoutSuccess) {
String errorMsg = "Invalid credentials";
if (loginError) {
HttpSession session = request.getSession(false);
if (session != null) {
AuthenticationException ex = (AuthenticationException) session
.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
errorMsg = ex != null ? ex.getMessage() : "Invalid credentials";
}
}
StringBuilder sb = new StringBuilder();
sb.append("<!DOCTYPE html>\n"
+ "<html lang=\"en\">\n"
+ " <head>\n"
+ " <meta charset=\"utf-8\">\n"
+ " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n"
+ " <meta name=\"description\" content=\"\">\n"
+ " <meta name=\"author\" content=\"\">\n"
+ " <title>Please sign in</title>\n"
+ " <link href=\"https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css\" rel=\"stylesheet\" integrity=\"sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M\" crossorigin=\"anonymous\">\n"
+ " <link href=\"https://getbootstrap.com/docs/4.0/examples/signin/signin.css\" rel=\"stylesheet\" crossorigin=\"anonymous\"/>\n"
+ " </head>\n"
+ " <body>\n"
+ " <div class=\"container\">\n");
String contextPath = request.getContextPath();
if (this.formLoginEnabled) {
sb.append(" <form class=\"form-signin\" method=\"post\" action=\"" + contextPath + this.authenticationUrl + "\">\n"
+ " <h2 class=\"form-signin-heading\">Please sign in</h2>\n"
+ createError(loginError, errorMsg)
+ createLogoutSuccess(logoutSuccess)
+ " <p>\n"
+ " <label for=\"username\" class=\"sr-only\">Username</label>\n"
+ " <input type=\"text\" id=\"username\" name=\"" + this.usernameParameter + "\" class=\"form-control\" placeholder=\"Username\" required autofocus>\n"
+ " </p>\n"
+ " <p>\n"
+ " <label for=\"password\" class=\"sr-only\">Password</label>\n"
+ " <input type=\"password\" id=\"password\" name=\"" + this.passwordParameter + "\" class=\"form-control\" placeholder=\"Password\" required>\n"
+ " </p>\n"
+ createRememberMe(this.rememberMeParameter)
+ renderHiddenInputs(request)
+ " <button class=\"btn btn-lg btn-primary btn-block\" type=\"submit\">Sign in</button>\n"
+ " </form>\n");
}
if (openIdEnabled) {
sb.append(" <form name=\"oidf\" class=\"form-signin\" method=\"post\" action=\"" + contextPath + this.openIDauthenticationUrl + "\">\n"
+ " <h2 class=\"form-signin-heading\">Login with OpenID Identity</h2>\n"
+ createError(loginError, errorMsg)
+ createLogoutSuccess(logoutSuccess)
+ " <p>\n"
+ " <label for=\"username\" class=\"sr-only\">Identity</label>\n"
+ " <input type=\"text\" id=\"username\" name=\"" + this.openIDusernameParameter + "\" class=\"form-control\" placeholder=\"Username\" required autofocus>\n"
+ " </p>\n"
+ createRememberMe(this.openIDrememberMeParameter)
+ renderHiddenInputs(request)
+ " <button class=\"btn btn-lg btn-primary btn-block\" type=\"submit\">Sign in</button>\n"
+ " </form>\n");
}
if (oauth2LoginEnabled) {
sb.append("<h2 class=\"form-signin-heading\">Login with OAuth 2.0</h2>");
sb.append(createError(loginError, errorMsg));
sb.append(createLogoutSuccess(logoutSuccess));
sb.append("<table class=\"table table-striped\">\n");
for (Map.Entry<String, String> clientAuthenticationUrlToClientName : oauth2AuthenticationUrlToClientName.entrySet()) {
sb.append(" <tr><td>");
String url = clientAuthenticationUrlToClientName.getKey();
sb.append("<a href=\"").append(contextPath).append(url).append("\">");
String clientName = HtmlUtils.htmlEscape(clientAuthenticationUrlToClientName.getValue());
sb.append(clientName);
sb.append("</a>");
sb.append("</td></tr>\n");
}
sb.append("</table>\n");
}
if (this.saml2LoginEnabled) {
sb.append("<h2 class=\"form-signin-heading\">Login with SAML 2.0</h2>");
sb.append(createError(loginError, errorMsg));
sb.append(createLogoutSuccess(logoutSuccess));
sb.append("<table class=\"table table-striped\">\n");
for (Map.Entry<String, String> relyingPartyUrlToName : saml2AuthenticationUrlToProviderName.entrySet()) {
sb.append(" <tr><td>");
String url = relyingPartyUrlToName.getKey();
sb.append("<a href=\"").append(contextPath).append(url).append("\">");
String partyName = HtmlUtils.htmlEscape(relyingPartyUrlToName.getValue());
sb.append(partyName);
sb.append("</a>");
sb.append("</td></tr>\n");
}
sb.append("</table>\n");
}
sb.append("</div>\n");
sb.append("</body></html>");
return sb.toString();
}
至此 SpringSecurity 默認表單登錄頁展示流程源碼部分已經全部講解完畢,會渲染出下面的頁面,但是一定要有網的情況,否則樣式可能會變化
6.總結
本篇主要講解 SpringSecurity提供的默認表單登錄頁 它是如何展示的的流程,包括涉及這一流程中相關的 3個過濾器
1.FilterSecurityInterceptor,
2.ExceptionTranslationFilter ,
3.DefaultLoginPageGeneratingFilter 過濾器,
並且簡單介紹了一下 AccessDecisionManager 它主要進行投票來判斷該用戶是否能夠訪問相應的 資源
AccessDecisionManager 投票機制我也沒有深究 後續我會詳細深入一下再展開
個人博客地址: https://www.askajohnny.com 歡迎訪問!
本文由博客一文多發平臺 OpenWrite 發佈!