網頁

2019/11/23

Spring Security 預設登入及登出頁面如何產生 how default login logout page generate

本篇介紹Spring Security預設的登入及登出頁面(default login logout page)是在哪裡被產生的。


簡單說Spring Security預設登入登出頁面其實是由後端產生,而非一個預先建立好的html頁面。

預設登入頁面由DefaultLoginPageGeneratingFilter產生。
預設登出頁面由DefaultLogoutPageGeneratingFilter產生。


Spring Security在初始時會呼叫WebSecurityConfigurerAdapter.init(),接著在裡面
呼叫WebSecurityConfigurerAdapter.getHttp()建立並執行HttpSecurity的預設配置。
在進行HttpSecurity的預設配置時套用了DefaultLoginPageConfigurer

節錄WebSecurityConfigurerAdapter原始碼如下,
執行順序為init() -> getHttp() -> configure()

WebSecurityConfigurerAdapter

/** 此即為自訂Spring Security配置類別會繼承的抽像類別 */
@Order(100)
public abstract class WebSecurityConfigurerAdapter implements
        WebSecurityConfigurer<WebSecurity> {
        ...

    /** 取得HttpSecurity物件及預設配置 */
    @SuppressWarnings({ "rawtypes", "unchecked" })
    protected final HttpSecurity getHttp() throws Exception {
        ...
        http = new HttpSecurity(objectPostProcessor, authenticationBuilder,
                sharedObjects); // 建立HttpSecurity物件
        if (!disableDefaults) { // disableDefaults預設是false,所以繼續進行下面的HttpSecurity配置
            // @formatter:off
            http
                .csrf().and()
                .addFilter(new WebAsyncManagerIntegrationFilter())
                .exceptionHandling().and()
                .headers().and()
                .sessionManagement().and()
                .securityContext().and()
                .requestCache().and()
                .anonymous().and()
                .servletApi().and()
                .apply(new DefaultLoginPageConfigurer<>()).and() // 建構並套用DefaultLoginPageConfigurer,在此生成預設的登入及登出頁面。
                .logout();
            ...
        }
        configure(http); // 其餘的HttpSecurity預設配置
        return http;
    }
    
    ... 

    /** WebSecurityConfigurerAdapter初始化 */
    public void init(final WebSecurity web) throws Exception {
        final HttpSecurity http = getHttp(); // 取得HttpSecurity物件及預設配置
        ...
    }

    /** 其餘的HttpSecurity預設配置 */
    protected void configure(HttpSecurity http) throws Exception {
        logger.debug("Using default configure(HttpSecurity). If subclassed this will potentially override subclass configure(HttpSecurity).");

        http
            .authorizeRequests()
                .anyRequest().authenticated()
                .and()
            .formLogin().and()
            .httpBasic();
    }

}

DefaultLoginPageConfigurer建構時即載入了兩個成員變數DefaultLoginPageGeneratingFilterDefaultLogoutPageGeneratingFilter的實例,也就是Spring Security預設的登出及登入頁面的來源。

節錄DefaultLoginPageConfigurer原始碼如下。

DefaultLoginPageConfigurer

public final class DefaultLoginPageConfigurer<H extends HttpSecurityBuilder<H>> extends
        AbstractHttpConfigurer<DefaultLoginPageConfigurer<H>, H> {

    private DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = new DefaultLoginPageGeneratingFilter(); // 在這產生預設登入頁面

    private DefaultLogoutPageGeneratingFilter logoutPageGeneratingFilter = new DefaultLogoutPageGeneratingFilter(); // 在這產生預設登出頁面

    @Override
    public void init(H http) throws Exception {
        Function<HttpServletRequest, Map<String, String>> hiddenInputs = request -> {
            CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
            if (token == null) {
                return Collections.emptyMap();
            }
            return Collections.singletonMap(token.getParameterName(), token.getToken());
        };
        this.loginPageGeneratingFilter.setResolveHiddenInputs(hiddenInputs); // 設定預設登入頁面隱藏的csrf input欄位<input name="_csrf" type="hidden" value="${_csrf.token}">
        this.logoutPageGeneratingFilter.setResolveHiddenInputs(hiddenInputs);  // 設定預設登出頁面隱藏的csrf input欄位<input name="_csrf" type="hidden" value="${_csrf.token}">
        http.setSharedObject(DefaultLoginPageGeneratingFilter.class,
                loginPageGeneratingFilter);
    }

    @Override
    @SuppressWarnings("unchecked")
    public void configure(H http) throws Exception {
        AuthenticationEntryPoint authenticationEntryPoint = null;
        ExceptionHandlingConfigurer<?> exceptionConf = http
                .getConfigurer(ExceptionHandlingConfigurer.class);
        if (exceptionConf != null) {
            authenticationEntryPoint = exceptionConf.getAuthenticationEntryPoint();
        }

        if (loginPageGeneratingFilter.isEnabled() && authenticationEntryPoint == null) {
            loginPageGeneratingFilter = postProcess(loginPageGeneratingFilter);
            http.addFilter(loginPageGeneratingFilter); // 把預設登入頁面加入HttpSecuirty的filters
            http.addFilter(this.logoutPageGeneratingFilter); // 把預設登出頁面加入HttpSecuirty的filters
        }
    }

}

每次客戶端發出請求時都須先通過Spring Security的Filter過濾,如果尚未驗證便會重新導向預設的登入頁面。
預設登入頁面的url為/login,並在通過Spring Security的FilterChainProxy時呼叫DefaultLoginPageGeneratingFilter.doFilter()並呼叫私有方法DefaultLoginPageGeneratingFilter.generateLoginPageHtml()產生登入頁面html字串最終透過HttpServletResponse寫出並渲染為html頁面。

節錄DefaultLoginPageGeneratingFilter原始碼如下。

DefaultLoginPageGeneratingFilter

public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {

    ...
    
    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); // 產生預設的登入頁面html字串
            response.setContentType("text/html;charset=UTF-8");
            response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length);
            response.getWriter().write(loginPageHtml); // 透過HttpServletResponse寫出登入頁面html字串

            return;
        }

        chain.doFilter(request, response);
    }
    ...
    /** 產生預設登入頁面html字串 */
    private String generateLoginPageHtml(HttpServletRequest request, boolean loginError,
            boolean logoutSuccess) {
        ...
        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");
        }
        ...
        sb.append("</div>\n");
        sb.append("</body></html>");

        return sb.toString();
    }
    ...
}

登出頁面由DefaultLogoutPageGeneratingFilter的產生過程相同,就不另外說明了。


參考:

沒有留言:

張貼留言