spring security之 預設登入頁原始碼跟蹤

尋找的路上發表於2021-11-07

spring security之 預設登入頁原始碼跟蹤

​ 2021年的最後2個月,立個flag,要把Spring SecuritySpring Security OAuth2的應用及主流程原始碼研究透徹!

​ 專案中使用過Spring Security的童鞋都知道,當我們沒有單獨自定義登入頁時,Spring Security自己在初始化的時候會幫我們配置一個預設的登入頁,之前一直疑問預設登入頁是怎麼配置的,今晚特地找了原始碼跟一下。

springboot專案依賴

<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>

在專案中隨意編寫一個介面,然後進行訪問

@GetMapping("/")
public String hello() {
   return "hello, spring security";
}

在tomcat預設埠8080,localhost:8080 下訪問該介面,spring security會幫我們將路徑重定向到預設的登入頁

​ 那麼這個預設頁是怎麼來的呢?
原來Spring Security有一個預設的WebSecurityConfigurerAdapter,發現其中有一個init方法,於是在這個方法打了斷點,在應用啟動的時候進行跟蹤。

​ 跟蹤getHttp()方法,this.disableDefaults變數預設為false,意味著將會執行applyDefaultConfiguration(this.http);方法。檢視applyDefaultConfiguration方法

public void init(WebSecurity web) throws Exception {
    // 首先配置security要攔截的哪些http請求
   HttpSecurity http = getHttp();
   web.addSecurityFilterChainBuilder(http).postBuildAction(() -> {
      FilterSecurityInterceptor securityInterceptor = http.getSharedObject(FilterSecurityInterceptor.class);
      web.securityInterceptor(securityInterceptor);
   });
}

protected final HttpSecurity getHttp() throws Exception {
		if (this.http != null) {
			return this.http;
		}
		AuthenticationEventPublisher eventPublisher = getAuthenticationEventPublisher();
		this.localConfigureAuthenticationBldr.authenticationEventPublisher(eventPublisher);
		AuthenticationManager authenticationManager = authenticationManager();
		this.authenticationBuilder.parentAuthenticationManager(authenticationManager);
		Map<Class<?>, Object> sharedObjects = createSharedObjects();
		this.http = new HttpSecurity(this.objectPostProcessor, this.authenticationBuilder, sharedObjects);
		if (!this.disableDefaults) {
            // 預設的配置將會走這個分支
			applyDefaultConfiguration(this.http);
			ClassLoader classLoader = this.context.getClassLoader();
			List<AbstractHttpConfigurer> defaultHttpConfigurers = SpringFactoriesLoader
					.loadFactories(AbstractHttpConfigurer.class, classLoader);
			for (AbstractHttpConfigurer configurer : defaultHttpConfigurers) {
				this.http.apply(configurer);
			}
		}
		configure(this.http);
		return this.http;
	}

檢視applyDefaultConfiguration(this.http)方法,發現http物件new了一個DefaultLoginPageConfigurer物件屬性,

private void applyDefaultConfiguration(HttpSecurity http) throws Exception {
   http.csrf();
   http.addFilter(new WebAsyncManagerIntegrationFilter());
   http.exceptionHandling();
   http.headers();
   http.sessionManagement();
   http.securityContext();
   http.requestCache();
   http.anonymous();
   http.servletApi();
   http.apply(new DefaultLoginPageConfigurer<>());
   http.logout();
}

​ 檢視DefaultLoginPageConfigurer類定義,發現它在初始化的同時,它也初始化了自己的2個私有成員變數,分別是DefaultLoginPageGeneratingFilter預設登入頁面生成Filter,DefaultLogoutPageGeneratingFilter預設登入頁面Filter, 名字起得很好,見名知意,我們馬山知道這2個類的含義。

​ 檢視DefaultLoginPageGeneratingFilter的類成員變數,發現定義了一系列跟登入有關的成員變數,包括登入、登入等路徑,預設的登入頁面路徑是"/login"

public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {

   public static final String DEFAULT_LOGIN_PAGE_URL = "/login";

   public static final String ERROR_PARAMETER_NAME = "error";

   private String loginPageUrl;

   private String logoutSuccessUrl;

   private String failureUrl;

   private boolean formLoginEnabled;
    .....

​ 再結合類名思考,發現是個Filter類,那麼它們應該都會重新Filter的doFilter(ServletRequest request, ServletResponse response, FilterChain chain)方法,我們檢視一下DefaultLoginPageConfigurer類的``doFilter方法,果然,在doFilter`方法中發現了生成預設登入頁面的方法。

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
      throws IOException, ServletException {
    // 判斷當前的請求是否被認證通過
   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);
}

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";
      }
   }
   String contextPath = request.getContextPath();
   StringBuilder sb = new StringBuilder();
   sb.append("<!DOCTYPE html>\n");
   sb.append("<html lang=\"en\">\n");
   sb.append("  <head>\n");
   sb.append("    <meta charset=\"utf-8\">\n");
   sb.append("    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n");
   sb.append("    <meta name=\"description\" content=\"\">\n");
   sb.append("    <meta name=\"author\" content=\"\">\n");
   sb.append("    <title>Please sign in</title>\n");
   sb.append("    <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");
   sb.append("    <link href=\"https://getbootstrap.com/docs/4.0/examples/signin/signin.css\" "
         + "rel=\"stylesheet\" crossorigin=\"anonymous\"/>\n");
   sb.append("  </head>\n");
   sb.append("  <body>\n");
   sb.append("     <div class=\"container\">\n");
   if (this.formLoginEnabled) {
      sb.append("      <form class=\"form-signin\" method=\"post\" action=\"" + contextPath
            + this.authenticationUrl + "\">\n");
      sb.append("        <h2 class=\"form-signin-heading\">Please sign in</h2>\n");
      sb.append(createError(loginError, errorMsg) + createLogoutSuccess(logoutSuccess) + "        <p>\n");
      sb.append("          <label for=\"username\" class=\"sr-only\">Username</label>\n");
      sb.append("          <input type=\"text\" id=\"username\" name=\"" + this.usernameParameter
            + "\" class=\"form-control\" placeholder=\"Username\" required autofocus>\n");
      sb.append("        </p>\n");
      sb.append("        <p>\n");
      sb.append("          <label for=\"password\" class=\"sr-only\">Password</label>\n");
      sb.append("          <input type=\"password\" id=\"password\" name=\"" + this.passwordParameter
            + "\" class=\"form-control\" placeholder=\"Password\" required>\n");
      sb.append("        </p>\n");
      sb.append(createRememberMe(this.rememberMeParameter) + renderHiddenInputs(request));
      sb.append("        <button class=\"btn btn-lg btn-primary btn-block\" type=\"submit\">Sign in</button>\n");
      sb.append("      </form>\n");
   }
   if (this.openIdEnabled) {
      sb.append("      <form name=\"oidf\" class=\"form-signin\" method=\"post\" action=\"" + contextPath
            + this.openIDauthenticationUrl + "\">\n");
      sb.append("        <h2 class=\"form-signin-heading\">Login with OpenID Identity</h2>\n");
  ......
   return sb.toString();
}

​ 我們發現generateLoginPageHtml(HttpServletRequest request, boolean loginError, boolean logoutSuccess)這個方法中使用了最原始的Servlet寫html頁面的方法,將登入頁的html程式碼寫到字串中寫出到前端展示。到這裡,我們就大體知道預設登入頁面及登出頁面是怎麼生成的了。

​ 預設登入頁到這裡就結束了,有興趣的可以關注下,接下來會繼續寫springsecurity的自定義表單認證、授權、會話等內容剖析。距離2022年只剩54天!

相關文章