Spring Boot集成Spring Security

Scroll Down

在web应用中,安全无疑是非常重要的,相对于Apache Shiro 相比,Spring Security 拥有更加强大的功能。Spring Security是Spring项目中的一个安全模块,能够与spring boot项目无缝集成,它也可以轻易的自定义扩展以满足开发中的各种需求,对常见的Web安全攻击提供了防护支持。
这里使用是SpringBoot的版本是:2.2.5.RELEASE, Spring Security的版本是:5.2.2.RELEASE

开启Spring Security

创建一个Spring Boot项目,创建一个TestController,对外创建一个 /hello 服务,

@RestController
public class TestController {
    @GetMapping("/hello")
    private String hello(){
        return "hello spring security";
    }
}

启动项目,项目默认启动端口为8080,在浏览器访问http://localhost:8080/hello 时可以在浏览器看到 hello spring security
此时 /hello 是可以自由访问的,这样肯定是不安全的,加入Spring Security进行保护。在pom.xml 文件引入 spring-boot-starter-security:

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

再次访问/hello 时,可以得到表单验证的界面:
security_login.png
说明Spring Security已经起作用了,默认的用户名是user,密码有Spring Security自动生成,在IDE的控制台中,可以获取密码信息:

Using generated security password: f9405627-9ba6-49b9-b6fe-07e30f85c5dc

输入密码后,可访问到/hello 返回的信息。

注意:如果使用的Spring Boot版本为1.X版本,依赖的Security 4.X版本,那么就无需任何配置,启动项目访问则会弹出默认的httpbasic认证.现在使用的spring boot是2.2版本,默认的是表单模式。

基于HttpBasic 认证

虽然现在默认是表单方式的认证,但还是说一下HttpBasic的认证方式。可以通过配置,将表单的认证方式改为HttpBasic认证。
创建一个SecurityConfig 继承org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter 并重写 configure(HttpSecurity http) 方法。WebSecurityConfigurerAdapter是由Spring Security提供的Web应用安全配置的适配器:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.httpBasic()    //表单方式
                .and()
                .authorizeRequests()    //授权配置
                .anyRequest()   //所有请求
                .authenticated();//都要认证
    }
}

Spring Security提供了这种链式的方法调用,指定认证方式是HttpBasic认证并且所有请求都需要进行认证,重启项目访问 http://localhost:8080/hello,会出现以下界面:
security_login_httpbasic.png

如果需要换回表单认证,只需要修改configure方法中的配置:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
//        http.httpBasic()    //表单方式
        http.formLogin()
                .and()
                .authorizeRequests()    //授权配置
                .anyRequest()   //所有请求
                .authenticated();//都要认证
    }

基本原理

上面是开启了一个最简单的Spring Security的安全配置。通过配置,代码的执行过程可以简化为:
security_liucheng.png
Spring Security包含了众多的过滤器,形成了一条链,所有的请求必须通过这些过滤器后才能访问到资源。其中
UsernamePasswordAuthenticationFilter 过滤器用于处理表单方式的登录认证。
BasicAuthenticationFilter 用于处理基于HttpBasic方法的登录认证
FilterSecurityInterceptor 过滤器链中最后一个过滤器,主要用于判断请求是否通过,内部是AccessDecisionManager 进行投票判断。
ExceptionTranslateFilter捕获并处理,所以我们在ExceptionTranslateFilter过滤器用于处理了FilterSecurityInterceptor抛出的异常并进行处理,比如需要身份认证时将请求重定向到相应的认证页面,当认证失败或者权限不足时返回相应的提示信息。

在/hello 服务上打上断点,当我们访问 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(FilterInvocation fi)方法:

        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(fi.getRequest(), fi.getResponse());调用访问的接口,否则内部抛出异常,

InterceptorStatusToken token = super.beforeInvocation(fi);

异常由 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 请求。这时 /login 请求会被DefaultLoginPageGeneratingFilter 捕获并且渲染出表单界面:

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,因为没有配置,所以默认 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();
}