SpringSecurity 預設表單登入頁展示流程原始碼
SpringSecurity 預設表單登入頁展示流程原始碼
本篇主要講解 SpringSecurity提供的預設表單登入頁 它是如何展示的的流程,
涉及
1.FilterSecurityInterceptor,
2.ExceptionTranslationFilc,xmccmc,ter ,
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,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,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,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,(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,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,(AccessDeniedException) exception); } } }
先判斷獲取的異常是否是AccessDeniedException 再判斷是否是匿名使用者,如果是則呼叫 sendStartAuthentication 重定向到登入頁面
重定向登入頁面之前會儲存當前訪問的路徑,這就是為什麼我們訪問 /hello介面後 再登入成功後又會跳轉到 /hello介面,因為在重定向到/login介面前 這裡進行了儲存 requestCache.saveRequest(request,response);
protected void sendStartAuthentication(HttpServletRequest request,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,reason); }
authenticationEntryPoint.commence(request,reason);方法內部
呼叫LoginUrlAuthenticationEntryPoint 的 commence方法
LoginUrlAuthenticationEntryPoint 的commence方法內部有 構造重定向URL的方法
redirectUrl = buildRedirectUrlToLoginPage(request,authException); protected String buildRedirectUrlToLoginPage(HttpServletRequest request,AuthenticationException authException) { String loginForm = determineUrlToUseForThisRequest(request,authException); protected String determineUrlToUseForThisRequest(HttpServletRequest request,AuthenticationException exception) { return getLoginFormUrl(); }
最終會獲取到需要重定向的URL /login
然後sendRedirect 既會重定向到 /login 請求
5.DefaultLoginPageGeneratingFilter (會捕獲重定向的/login 請求)
DefaultLoginPageGeneratingFilter是過濾器鏈中的一個用於捕獲/login請求,並且渲染出一個預設表單頁面
public void doFilter(ServletRequest req,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,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,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 投票機制我也沒有深究 後續我會詳細深入一下再展開
以上所述是小編給大家介紹的SpringSecurity 預設表單登入頁展示流程原始碼,希望對大家有所幫助!