本篇文章主要用來闡述Spring Security渲染默認登錄表單頁面流程。在默認登錄表單頁面渲染過程中主要涉及到以下3個攔截器:
1)FilterSecurityInterceptor:該攔截器主要用來:獲取所配置資源訪問的授權信息,根據SecurityContextHolder中存儲的用戶信息來決定其是否有權限;
2)ExceptionTranslationFilter:該攔截器主要用來:異常轉換過濾器位於整個springSecurityFilterChain的后方,用來轉換整個鏈路中出現的異常;
3)DefaultLoginPageGeneratingFilter:如果沒有在配置文件中指定認證頁面,則由該過濾器生成一個默認認證頁面。
在FilterSecurityInterceptor主要用來判斷當前用戶(如果沒有登錄賬戶:系統會默認登錄賬戶為匿名用戶:
)是否有權限訪問特定資源,其內部是使用了AccessDecisionManager投票機制來實現。
新建Spring Security項目
新建一個spring boot項目
在maven中引入:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.0.2.RELEASE</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> 。。。 </dependencies>
設置Spring Security配置類
@EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private AuthenticationSuccessHandler authenticationSuccessHandler; @Autowired private AuthenticationFailureHandler authenticationFailureHandler; @Autowired private LogoutSuccessHandler logoutSuccessHandler; @Autowired private AuthenticationEntryPoint authenticationEntryPoint; @Autowired private UserDetailsService userDetailsService; @Autowired private TokenFilter tokenFilter; @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable(); // 基於token,所以不需要session http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); http.authorizeRequests() .antMatchers("/", "/*.html", "/favicon.ico", "/css/**", "/js/**", "/fonts/**", "/layui/**", "/img/**", "/v2/api-docs/**", "/swagger-resources/**", "/webjars/**", "/pages/**", "/druid/**", "/statics/**") .permitAll().anyRequest().authenticated(); http.formLogin()/*.loginPage("/login.html")*/.loginProcessingUrl("/login") .successHandler(authenticationSuccessHandler).failureHandler(authenticationFailureHandler); //.and().exceptionHandling().authenticationEntryPoint(authenticationEntryPoint); http.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler); // 解決不允許顯示在iframe的問題 http.headers().frameOptions().disable(); http.headers().cacheControl(); http.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class); } 。。。 }
需要注意:
1)在config(HttpSecurity http)中並未配置"http.formLogin().loginPage(String loginPage)",那么DefaultLoginPageGeneratingFilter就會渲染默認的登錄表單;否則,會根據自定義的登錄表單去渲染。
2)在config(HttpSecurity http)中注釋掉了http.formLogin()的".and().exceptionHandling().authenticationEntryPoint(authenticationEntryPoint);",這時候當filter鏈執行過程中拋出AccessDeniedException后,就會被ExceptionTranslationFilter攔截到異常,在異常處理時會執行ExceptionTranslationFilter#authenticationEntryPoint.commence(request, response, reason)中的authenticationEntryPoint就是LoginUrlAuthenticationEntryPoint的實例。
3)在config(HttpSecurity http)中如果打開http.formLogin()注釋的".and().exceptionHandling().authenticationEntryPoint(authenticationEntryPoint);",那么,Bean(authenticationEntryPoint)就會通過配置來覆蓋默認authenticationEntryPoint對象,其中Bean(authenticationEntryPoint)的注入實現:
/** * 未登錄,返回401 * * @return */ @Bean public AuthenticationEntryPoint authenticationEntryPoint() { return new AuthenticationEntryPoint() { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { ResponseInfo info = new ResponseInfo(HttpStatus.UNAUTHORIZED.value() + "", "請先登錄"); ResponseUtil.responseJson(response, HttpStatus.UNAUTHORIZED.value(), info); } }; }
此時,如果是訪問權限不足拋出AccessDeniedException異常時,在ExceptionTranslationFilter處理就會執行該bean,界面拋異常內容是一個json:
{"code":"401","message":"請先登錄"}
下邊的代碼流程分析都是以上看到代碼為准。
添加需要訪問權限的接口
@Api(tags = "用戶") @RestController @RequestMapping("/users") public class UserController { private static final Logger log = LoggerFactory.getLogger("adminLogger"); @Autowired private UserService userService; @Autowired private UserDao userDao; 。。。 @GetMapping @ApiOperation(value = "用戶列表") @PreAuthorize("hasAuthority('sys:user:query')") public PageTableResponse listUsers(PageTableRequest request) { return new PageTableHandler(new CountHandler() { @Override public int count(PageTableRequest request) { return userDao.count(request.getParams()); } }, new ListHandler() { @Override public List<SysUser> list(PageTableRequest request) { List<SysUser> list = userDao.list(request.getParams(), request.getOffset(), request.getLimit()); return list; } }).handle(request); } }
這里http://localhost:8080/users/listUsers,就會需要特定權限才能訪問。
如果項目啟動后直接訪問http://localhost:8080/users/listUsers,就會自動跳轉到http://localhost:8080/login頁面,到此測試項目已經搭建完成。接下來會結合代碼逐步進行分析認證:認證失敗后如何跳轉並渲染默認登錄表單頁面流程?
過濾鏈入口FilterChainProxy
在訪問http://localhost:8080/users/listUsers時,系統會進入到filter鏈執行入口類:FilterChainProxy#doFilter(HttpRequest request,HttpResponse response)
從filters中的filter就是Spring Security的核心過濾鏈。
FilterSecurityInterceptor是過濾器鏈中最后一個過濾器,主要用於判斷請求能否通過,內部通過AccessDecisionManager 進行投票判斷,那么接下來我們來分析下FilterSecurityInterceptor的執行邏輯。
FilterSecurityInterceptor分析
FilterSecurityInterceptor源碼:
public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter { private static final String FILTER_APPLIED = "__spring_security_filterSecurityInterceptor_filterApplied"; private FilterInvocationSecurityMetadataSource securityMetadataSource; private boolean observeOncePerRequest = true; public FilterSecurityInterceptor() { } public void init(FilterConfig arg0) throws ServletException { } public void destroy() { } public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { FilterInvocation fi = new FilterInvocation(request, response, chain); this.invoke(fi); } public FilterInvocationSecurityMetadataSource getSecurityMetadataSource() { return this.securityMetadataSource; } public SecurityMetadataSource obtainSecurityMetadataSource() { return this.securityMetadataSource; } public void setSecurityMetadataSource(FilterInvocationSecurityMetadataSource newSource) { this.securityMetadataSource = newSource; } public Class<?> getSecureObjectClass() { return FilterInvocation.class; } public void invoke(FilterInvocation fi) throws IOException, ServletException { if (fi.getRequest() != null && fi.getRequest().getAttribute("__spring_security_filterSecurityInterceptor_filterApplied") != null && this.observeOncePerRequest) { fi.getChain().doFilter(fi.getRequest(), fi.getResponse()); } else { if (fi.getRequest() != null && this.observeOncePerRequest) { fi.getRequest().setAttribute("__spring_security_filterSecurityInterceptor_filterApplied", Boolean.TRUE); } InterceptorStatusToken token = super.beforeInvocation(fi); try { fi.getChain().doFilter(fi.getRequest(), fi.getResponse()); } finally { super.finallyInvocation(token); } super.afterInvocation(token, (Object)null); } } public boolean isObserveOncePerRequest() { return this.observeOncePerRequest; } public void setObserveOncePerRequest(boolean observeOncePerRequest) { this.observeOncePerRequest = observeOncePerRequest; } }
當filter鏈執行到FilterSecurityInterceptor#doFilter(...)時,重要邏輯在FilterSecurityInterceptor#invoke(...):
1)InterceptorStatusToken token = super.beforeInvocation(fi);是調用了FilterSecurityInterceptor父類AbstractSecurityInterceptor#beforeInvocation(...);
2)這段代碼其實內部會驗證用戶是否有權限去訪問當前請求的資源的權限,如果有權限會繼續向下執行"fi.getChain().doFilter(fi.getRequest(), fi.getResponse());",否則將會在內部拋出異常,之后異常被ExceptionTranslationFilter攔截到。
這里的beforeInvocation(...)的實現是定義在FilterSecurityInterceptor的父類AbstractSecurityInterceptor中,beforeInvocation 方法內部是通過 accessDecisionManager 去做決定的:
protected InterceptorStatusToken beforeInvocation(Object object) { Assert.notNull(object, "Object was null"); boolean debug = this.logger.isDebugEnabled(); if (!this.getSecureObjectClass().isAssignableFrom(object.getClass())) { throw new IllegalArgumentException("Security invocation attempted for object " + object.getClass().getName() + " but AbstractSecurityInterceptor only configured to support secure objects of type: " + this.getSecureObjectClass()); } else { Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object); if (attributes != null && !attributes.isEmpty()) { if (debug) { this.logger.debug("Secure object: " + object + "; Attributes: " + attributes); } if (SecurityContextHolder.getContext().getAuthentication() == null) { this.credentialsNotFound(this.messages.getMessage("AbstractSecurityInterceptor.authenticationNotFound", "An Authentication object was not found in the SecurityContext"), object, attributes); } Authentication authenticated = this.authenticateIfRequired(); try { this.accessDecisionManager.decide(authenticated, object, attributes); } catch (AccessDeniedException var7) { this.publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated, var7)); throw var7; } 。。。 } } }
從上邊運行結果可以得知:
1)當用戶未登陸時,發送請求過程中Spring Security會自動裝配一個內置的用戶authenticated=AnonymousAuthenticationToken(這是一個匿名用戶);
2)accessDecisionManager其實就是選取類,Spring Security已經內置了幾個基於投票的AccessDecisionManager包括(AffirmativeBased ,ConsensusBased ,UnanimousBased)當然如果需要你也可以實現自己的AccessDecisionManager。這里它的實現類是:AffirmativeBase,其他AccessDecisionManager的實現結構如下:
- AffirmativeBased: 一票通過,只要有一個投票器通過就允許訪問,否則拋出AccessDeniedException;
- UnanimousBased: 所有投票器都通過才允許訪問,否則拋出AccessDeniedException。
使用這種方式,一系列的AccessDecisionVoter將會被AccessDecisionManager用來對Authentication是否有權訪問受保護對象進行投票,然后再根據投票結果來決定是否要拋出AccessDeniedException。具體可以以AffrmativeBased#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(); } }
AffrmativeBased#decide(...)內部邏輯:
(1)只要有AccessDecisionVoter的投票為ACCESS_GRANTED則同意用戶進行訪問;
(2)如果全部棄權也表示通過;
(3)如果沒有一個人投贊成票,但是有人投反對票,則將拋出AccessDeniedException。
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); this.logger.debug("Chain processed normally"); } catch (IOException var9) { throw var9; } catch (Exception var10) { Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(var10); RuntimeException ase = (AuthenticationException)this.throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain); if (ase == null) { ase = (AccessDeniedException)this.throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain); } if (ase == null) { if (var10 instanceof ServletException) { throw (ServletException)var10; } if (var10 instanceof RuntimeException) { throw (RuntimeException)var10; } throw new RuntimeException(var10); } if (response.isCommitted()) { throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", var10); } this.handleSpringSecurityException(request, response, chain, (RuntimeException)ase); } }
通過代碼分析和調試可以發現,一旦ExceptionTranslationFilter捕獲到AccessDeniedException異常之后會調用“this.handleSpringSecurityException(request, response, chain, (RuntimeException)ase);”,handleSpringSecurityException(...)源碼如下:
private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException { if (exception instanceof AuthenticationException) { this.logger.debug("Authentication exception occurred; redirecting to authentication entry point", exception); this.sendStartAuthentication(request, response, chain, (AuthenticationException)exception); } else if (exception instanceof AccessDeniedException) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (!this.authenticationTrustResolver.isAnonymous(authentication) && !this.authenticationTrustResolver.isRememberMe(authentication)) { this.logger.debug("Access is denied (user is not anonymous); delegating to AccessDeniedHandler", exception); this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception); } else { this.logger.debug("Access is denied (user is " + (this.authenticationTrustResolver.isAnonymous(authentication) ? "anonymous" : "not fully authenticated") + "); redirecting to authentication entry point", exception); this.sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException(this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication", "Full authentication is required to access this resource"))); } } }
1)如果異常類型是AuthenticationException,就執行: this.sendStartAuthentication(request, response, chain, (AuthenticationException)exception);
2)如果異常類型是AccessDeniedException,分支判斷:
2.1)如果當前登錄用戶非匿名用戶且非‘記住我’時,會執行: this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception);
2.2)否則,執行 this.sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException(this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication", "Full authentication is required to access this resource")));
從上邊斷點分析可以知道在我們未登錄時,請求一個需要認證后才能訪問的資源時,Spring Security會裝配一個內置的用戶authenticated=AnonymousAuthenticationToken(這是一個匿名用戶),因此這里代碼會執行到2.2)分支的方法:this.sendStartAuthentication(...)。sendStartAuthentication(...)源碼試下如下:
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException { SecurityContextHolder.getContext().setAuthentication((Authentication)null); this.requestCache.saveRequest(request, response); this.logger.debug("Calling Authentication entry point."); this.authenticationEntryPoint.commence(request, response, reason); }
代碼分析:
1)重定向登錄頁面之前會保存當前訪問的路徑,這就是為什么我們訪問 http://localhost:8080/roles/listRoles接口后,再登錄成功后又會跳轉到 http://localhost:8080/roles/listRoles接口,因為在重定向到/login接口前 這里進行了保存 requestCache.saveRequest(request, response);
2)斷點進入該方法,發現這的authenticationEntryPoint實現類就是:LoginUrlAuthenticationEntryPoint。
接來下到我們來分析LoginUrlAuthenticationEntryPoint 的 commence方法:
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { String redirectUrl = null; if (this.useForward) { if (this.forceHttps && "http".equals(request.getScheme())) { redirectUrl = this.buildHttpsRedirectUrlForRequest(request); } if (redirectUrl == null) { String loginForm = this.determineUrlToUseForThisRequest(request, response, authException); if (logger.isDebugEnabled()) { logger.debug("Server side forward to: " + loginForm); } RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm); dispatcher.forward(request, response); return; } } else { redirectUrl = this.buildRedirectUrlToLoginPage(request, response, authException); } this.redirectStrategy.sendRedirect(request, response, redirectUrl); }
1)在Spring Security項目中Spring Security配置類config(HttpSecurity http)內部配置了http.formLogin().loginPage("/login.html"),這里的redirectUrl為:http://localhost:18080/login.html,這就是實現自定義登錄頁面的其中攔截后跳轉登錄頁面實現中必不可少的一個配置。
2)當然你在自定
義了登錄頁面后,希望攔截到的未登錄時自動跳轉到登錄頁面,也可以不配置,從前端可以實現,每個頁面中都包含一個js驗證(如果返回401就跳轉到login.html):
$.ajaxSetup({ cache : false, headers : { "token" : localStorage.getItem("token") }, error : function(xhr, textStatus, errorThrown) { var msg = xhr.responseText; var response = JSON.parse(msg); var code = response.code; var message = response.message; if (code == 400) { layer.msg(message); } else if (code == 401) { localStorage.removeItem("token"); location.href = '/login.html'; } else if (code == 403) { console.log("未授權:" + message); layer.msg('未授權'); } else if (code == 500) { layer.msg('系統錯誤:' + message); } } });
同樣需要在Spring Security配置類config(HttpSecurity http)內部配置了http.formLogin()中配置.and().exceptionHandling().authenticationEntryPoint(authenticationEntryPoint);authenticationEntryPoint注入bean代碼實現:
/** * 未登錄,返回401 * * @return */ @Bean public AuthenticationEntryPoint authenticationEntryPoint() { return new AuthenticationEntryPoint() { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { ResponseInfo info = new ResponseInfo(HttpStatus.UNAUTHORIZED.value() + "", "請先登錄"); ResponseUtil.responseJson(response, HttpStatus.UNAUTHORIZED.value(), info); } }; }
這樣的話,ExceptionTranslationFilter捕獲AccessDeniedException異常執行this.authenticationEntryPoint.commence(request, response, reason);時,就會執行上邊代碼拋出json異常信息。前端拿到異常401后,會自動跳轉到登錄頁面。
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest)req; HttpServletResponse response = (HttpServletResponse)res; boolean loginError = this.isErrorPage(request); boolean logoutSuccess = this.isLogoutSuccess(request); if (!this.isLoginUrlRequest(request) && !loginError && !logoutSuccess) { chain.doFilter(request, response); } else { String loginPageHtml = this.generateLoginPageHtml(request, loginError, logoutSuccess); response.setContentType("text/html;charset=UTF-8"); response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length); response.getWriter().write(loginPageHtml); } }
其中isLoginUrlRequest(...)用來驗證是否請求為/login,
private boolean isLoginUrlRequest(HttpServletRequest request) { return this.matches(request, this.loginPageUrl); }
由於在Spring Security的配置類的config(HttpSecurity http)中並未配置http.formLogin().loginPage(...),因此這里loginPageUrl默認為:/login。
根據前邊分析,當驗證沒有權限訪問時,會拋出AccessDeniedException異常,之后被ExceptionTranslationFilter攔截到會發送一個/loign請求,被DefaultLoginPageGeneratingFilter攔截到。因此這里代碼會執行else代碼塊中的邏輯。
調用String loginPageHtml = generateLoginPageHtml(request, loginError, logoutSuccess);生成登錄表單頁面:
private String generateLoginPageHtml(HttpServletRequest request, boolean loginError, boolean logoutSuccess) { String errorMsg = "none"; if (loginError) { HttpSession session = request.getSession(false); if (session != null) { AuthenticationException ex = (AuthenticationException)session.getAttribute("SPRING_SECURITY_LAST_EXCEPTION"); errorMsg = ex != null ? ex.getMessage() : "none"; } } StringBuilder sb = new StringBuilder(); sb.append("<html><head><title>Login Page</title></head>"); if (this.formLoginEnabled) { sb.append("<body onload='document.f.").append(this.usernameParameter).append(".focus();'>\n"); } if (loginError) { sb.append("<p style='color:red;'>Your login attempt was not successful, try again.<br/><br/>Reason: "); sb.append(errorMsg); sb.append("</p>"); } if (logoutSuccess) { sb.append("<p style='color:green;'>You have been logged out</p>"); } if (this.formLoginEnabled) { sb.append("<h3>Login with Username and Password</h3>"); sb.append("<form name='f' action='").append(request.getContextPath()).append(this.authenticationUrl).append("' method='POST'>\n"); sb.append("<table>\n"); sb.append("\t<tr><td>User:</td><td><input type='text' name='"); sb.append(this.usernameParameter).append("' value='").append("'></td></tr>\n"); sb.append("\t<tr><td>Password:</td><td><input type='password' name='").append(this.passwordParameter).append("'/></td></tr>\n"); if (this.rememberMeParameter != null) { sb.append("\t<tr><td><input type='checkbox' name='").append(this.rememberMeParameter).append("'/></td><td>Remember me on this computer.</td></tr>\n"); } sb.append("\t<tr><td colspan='2'><input name=\"submit\" type=\"submit\" value=\"Login\"/></td></tr>\n"); this.renderHiddenInputs(sb, request); sb.append("</table>\n"); sb.append("</form>"); } if (this.openIdEnabled) { sb.append("<h3>Login with OpenID Identity</h3>"); sb.append("<form name='oidf' action='").append(request.getContextPath()).append(this.openIDauthenticationUrl).append("' method='POST'>\n"); sb.append("<table>\n"); sb.append("\t<tr><td>Identity:</td><td><input type='text' size='30' name='"); sb.append(this.openIDusernameParameter).append("'/></td></tr>\n"); if (this.openIDrememberMeParameter != null) { sb.append("\t<tr><td><input type='checkbox' name='").append(this.openIDrememberMeParameter).append("'></td><td>Remember me on this computer.</td></tr>\n"); } sb.append("\t<tr><td colspan='2'><input name=\"submit\" type=\"submit\" value=\"Login\"/></td></tr>\n"); sb.append("</table>\n"); this.renderHiddenInputs(sb, request); sb.append("</form>"); } if (this.oauth2LoginEnabled) { sb.append("<h3>Login with OAuth 2.0</h3>"); sb.append("<table>\n"); Iterator var9 = this.oauth2AuthenticationUrlToClientName.entrySet().iterator(); while(var9.hasNext()) { Entry<String, String> clientAuthenticationUrlToClientName = (Entry)var9.next(); sb.append(" <tr><td>"); sb.append("<a href=\"").append(request.getContextPath()).append((String)clientAuthenticationUrlToClientName.getKey()).append("\">"); sb.append((String)clientAuthenticationUrlToClientName.getValue()); sb.append("</a>"); sb.append("</td></tr>\n"); } sb.append("</table>\n"); } sb.append("</body></html>"); return sb.toString(); }
通過response.getWriter().write(loginPageHtml);渲染登錄頁面到瀏覽器。
至此 SpringSecurity 默認表單登錄頁展示流程源碼部分已經全部講解完畢。
參考:
《Spring Security(15)——權限鑒定結構(https://www.iteye.com/blog/elim-2247057)》
《SpringSecurity 默認表單登錄頁展示流程源碼( https://www.cnblogs.com/askajohnny/p/12227881.html)》