Spring Security基本认证

快速入门

在 Spring Boot 项目中使用 Spring Security 非常方便,创建一个新的 Spring Boot 项目,我们只需要引入 Web 和 Spring Security 依赖即可,具体代码如下:

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

然后我们在项目中提供一个用于测试的 /hello 接口,代码如下:

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

接下来启动项目,/hello 接口就已经被自动保护起来了。当用户访问 /hello 接口时,会自动跳转到登录页面,如图 2-1 所示,用户登录成功后,才能访问到 /hello 接口。

image 2024 04 10 17 23 34 612
Figure 1. 图 2-1 Spring Security 默认登录页面

默认的登录用户名是 user,登录密码则是一个随机生成的 UUID 字符串,在项目启动日志中可以看到登录密码(这也意味着项目每次启动时,密码都会发生变化):

Using generated security password: 8ef9c800-17cf-47a3-9984-8ff936db6dd8

输入默认的用户名和密码,就可以成功登录了。这就是 Spring Security 的强大之处,只需要引入一个依赖,所有的接口就会被自动保护起来。

流程分析

通过一个简单的流程图来看一下上面案例中的请求流程,如图 2-2 所示。

image 2024 04 10 17 26 14 911
Figure 2. 图 2-2 请求流程图

流程图比较清晰地说明了整个请求过程:

  1. 客户端(浏览器)发起请求去访问 /hello 接口,这个接口默认是需要认证之后才能访问的。

  2. 这个请求会走一遍 Spring Security 中的过滤器链,在最后的 FilterSecurityInterceptor 过滤器中被拦截下来,因为系统发现用户未认证。请求拦截下来之后,接下来会抛出 AccessDeniedException 异常。

  3. 抛出的 AccessDeniedException 异常在 ExceptionTranslationFilter 过滤器中被捕获,ExceptionTranslationFilter 过滤器通过调用 LoginUrlAuthenticationEntryPoint#commence 方法给客户端返回 302,要求客户端重定向到 /login 页面。

  4. 客户端发送 /login 请求。

  5. /login 请求被 DefaultLoginPageGeneratingFilter 过滤器拦截下来,并在该过滤器中返回登录页面。所以当用户访问 /hello 接口时会首先看到登录页面。

在整个过程中,相当于客户端一共发送了两个请求,第一个请求是 /hello,服务端收到之后,返回 302,要求客户端重定向到 /login,于是客户端又发送了 /login 请求。

读者现在去理解上面这一个流程图可能还有些困难,等阅读完本章后面的内容之后,再回过头来看这个流程图,应该就会比较清晰了。

原理分析

在 2.1.1 小节中,虽然开发者只是引入了一个依赖,代码不多,但是 Spring Boot 背后却默默做了很多事情:

  • 开启 Spring Security 自动化配置,开启后,会自动创建一个名为 springSecurityFilterChain 的过滤器,并注入到 Spring 容器中,这个过滤器将负责所有的安全管理,包括用户的认证、授权、重定向到登录页面等(springSecurityFilterChain 实际上代理了 Spring Security中的过滤器链)。

  • 创建一个 UserDetailsService 实例,UserDetailsService 负责提供用户数据,默认的用户数据是基于内存的用户,用户名为 user,密码则是随机生成的 UUID 字符串。

  • 给用户生成一个默认的登录页面。

  • 开启 CSRF 攻击防御。

  • 开启会话固定攻击防御。

  • 集成 X-XSS-Protection。

  • 集成 X-Frame-Options 以防止单击劫持。

这里涉及的细节还是非常多的,登录的细节我们会在后面的章节继续详细介绍,这里主要分析一下默认用户的生成以及默认登录页面的生成。

默认用户生成

Spring Security 中定义了 UserDetails 接口来规范开发者自定义的用户对象,这样方便一些旧系统、用户表已经固定的系统集成到 Spring Security 认证体系中。

UserDetails 接口定义如下:

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();
    String getPassword();
    String getUsername();
    boolean isAccountNonExpired();
    boolean isAccountNonLocked();
    boolean isCredentialsNonExpired();
    boolean isEnabled();
}

该接口中一共定义了 7 个方法:

  1. getAuthorities 方法:返回当前账户所具备的权限。

  2. getPassword 方法:返回当前账户的密码。

  3. getUsername 方法:返回当前账户的用户名。

  4. isAccountNonExpired 方法:返回当前账户是否未过期。

  5. isAccountNonLocked 方法:返回当前账户是否未锁定。

  6. isCredentialsNonExpired 方法:返回当前账户凭证(如密码)是否未过期。

  7. isEnabled 方法: 返回当前账户是否可用。

这是用户对象的定义,而负责提供用户数据源的接口是 UserDetailsService,UserDetailsService 中只有一个查询用户的方法,代码如下:

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException;
}

loadUserByUsername 有一个参数是 username,这是用户在认证时传入的用户名,最常见的就是用户在登录表单中输入的用户名(实际开发时还可能存在其他情况,例如使用 CAS 单点登录时, username 并非表单输入的用户名,而是 CAS Server 认证成功后回调的用户名参数),开发者在这里拿到用户名之后,再去数据库中查询用户,最终返回一个 UserDetails 实例。

在实际项目中,一般需要开发者自定义 UserDetailsService 的实现。如果开发者没有自定义 UserDetailsService 的实现,Spring Security 也为 UserDetailsService 提供了默认实现,如图 2-3 所示。

image 2024 04 10 17 44 40 010
Figure 3. 图 2-3 UserDetailsService 的默认实现类
  • UserDetailsManager 在 UserDetailsService 的基础上,继续定义了添加用户、更新用户、删除用户、修改密码以及判断用户是否存在共 5 种方法。

  • JdbcDaoImpl 在 UserDetailsService 的基础上,通过 spring-jdbc 实现了从数据库中查询用户的方法。

  • InMemoryUserDetailsManager 实现了 UserDetailsManager 中关于用户的增删改查方法,不过都是基于内存的操作,数据并没有持久化。

  • JdbcUserDetailsManager 继承自 JdbcDaoImpl 同时又实现了 UserDetailsManager 接口,因此可以通过 JdbcUserDetailsManager 实现对用户的增删改查操作,这些操作都会持久化到数据库中。不过 JdbcUserDetailsManager 有一个局限性,就是操作数据库中用户的 SQL 都是提前写好的,不够灵活,因此在实际开发中 JdbcUserDetailsManager 使用并不多。

  • CachingUserDetailsService 的特点是会将 UserDetailsService 缓存起来。

  • UserDetailsServiceDelegator 则是提供了 UserDetailsService 的懒加载功能。

  • ReactiveUserDetailsServiceAdapter 是 webflux-web-security 模块定义的 UserDetailsService 实现。

当我们使用 Spring Security 时,如果仅仅只是引入一个 Spring Security 依赖,则默认使用的用户就是由 InMemoryUserDetailsManager 提供的。

大家知道,Spring Boot 之所以能够做到零配置使用 Spring Security,就是因为它提供了众多的自动化配置类。其中,针对 UserDetailsService 的自动化配置类是 UserDetailsServiceAutoConfiguration,这个类的源码并不长,我们一起来看一下:

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(AuthenticationManager.class)
@ConditionalOnBean(ObjectPostProcessor.class)
@ConditionalOnMissingBean(
		value = { AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class },
		type = { "org.springframework.security.oauth2.jwt.JwtDecoder",
				"org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector" })
public class UserDetailsServiceAutoConfiguration {

	private static final String NOOP_PASSWORD_PREFIX = "{noop}";

	private static final Pattern PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\\{.+}.*$");

	private static final Log logger = LogFactory.getLog(UserDetailsServiceAutoConfiguration.class);

	@Bean
	@ConditionalOnMissingBean(
			type = "org.springframework.security.oauth2.client.registration.ClientRegistrationRepository")
	@Lazy
	public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties,
			ObjectProvider<PasswordEncoder> passwordEncoder) {
		SecurityProperties.User user = properties.getUser();
		List<String> roles = user.getRoles();
		return new InMemoryUserDetailsManager(
				User.withUsername(user.getName()).password(getOrDeducePassword(user, passwordEncoder.getIfAvailable()))
						.roles(StringUtils.toStringArray(roles)).build());
	}

	private String getOrDeducePassword(SecurityProperties.User user, PasswordEncoder encoder) {
		String password = user.getPassword();
		if (user.isPasswordGenerated()) {
			logger.info(String.format("%n%nUsing generated security password: %s%n", user.getPassword()));
		}
		if (encoder != null || PASSWORD_ALGORITHM_PATTERN.matcher(password).matches()) {
			return password;
		}
		return NOOP_PASSWORD_PREFIX + password;
	}

}

从上述代码中可以看到,有两个比较重要的条件促使系统自动提供一个 InMemoryUserDetailsManager 的实例:

  1. 当前 classpath 下存在 AuthenticationManager 类。

  2. 当前项目中,系统没有提供 AuthenticationManager、AuthenticationProvider、UserDetailsService 以及 ClientRegistrationRepository 实例。

默认情况下,上面的条件都会满足,此时 Spring Security 会提供一个 InMemoryUserDetailsManager 实例。从 inMemoryUserDetailsManager 方法中可以看到,用户数据源自 SecurityProperties#getUser 方法:

@ConfigurationProperties(prefix = "spring.security")
public class SecurityProperties {
    private User user = new User();
    public User getUser() {
        return this.user;
    }
    public static class User {
        private String name = "user";
        private String password = UUID.randomUUID().toString();
        private List<String> roles = new ArrayList<>();
        //省略 getter/setter
    }
}

从 SecurityProperties.User 类中,我们就可以看到默认的用户名是 user,默认的密码是一个 UUID 字符串。

再回到 inMemoryUserDetailsManager 方法中,构造 InMemoryUserDetailsManager 实例时需要一个 User 对象。这里的 User 对象不是 SecurityProperties.User,而是 org.springframework.security.core.userdetails.User,这是 Spring Security 提供的一个实现了 UserDetails 接口的用户类,该类提供了相应的静态方法,用来构造一个默认的 User 实例。同时,默认的用户密码还在 getOrDeducePassword 方法中进行了二次处理,由于默认的 encoder 为 null,所以密码的二次处理只是给密码加了一个前缀{noop},表示密码是明文存储的(关于 {noop} 将在第 5 章密码加密中做详细介绍)。

经过以上的源码梳理,相信大家已经明白了 Spring Security 默认的用户名/密码是来自哪里了!

另外,当看了 SecurityProperties 的源码后,只要对 Spring Boot 中 properties 属性的加载机制有一点了解,就会明白,只要我们在项目的 application.properties 配置文件中添加如下配置,就能定制 SecurityProperties.User 类中各属性的值:

spring.security.user.name=javaboy
spring.security.user.password=123
spring.security.user.roles=admin,user

配置完成后,重启项目,此时登录的用户名就是 javaboy,登录密码就是 123 ,登录成功后用户具备 admin 和 user 两个角色。

默认页面生成

在 2.1.1 小节的案例中,一共存在两个默认页面,一个就是图 2-1 所示的登录页面,另外一个则是注销登录页面。当用户登录成功之后,在浏览器中输入 http://localhost:8080/logout 就可以看到注销登录页面,如图 2-4 所示。

image 2024 04 10 18 02 53 887
Figure 4. 图 2-4 注销登录页面

那么这两个页面是从哪里来的呢?这里剖析一下。

在 1.3.2 小节中,我们介绍了 Spring Security 中常见的过滤器,在这些常见的过滤器中就包含两个和页面相关的过滤器:DefaultLoginPageGeneratingFilter 和 DefaultLogoutPageGeneratingFilter。

通过过滤器的名字就可以分辨出 DefaultLoginPageGeneratingFilter 过滤器用来生成默认的登录页面,DefaultLogoutPageGeneratingFilter 过滤器则用来生成默认的注销页面。

先来看 DefaultLoginPageGeneratingFilter。作为 Spring Security 过滤器链中的一员,在第一次请求 /hello 接口的时候,就会经过 DefaultLoginPageGeneratingFilter 过滤器,但是由于 /hello 接口和登录无关,因此 DefaultLoginPageGeneratingFilter 过滤器并未干涉 /hello 接口。等到第二次重定向到 /login 页面的时候,这个时候就和 DefaultLoginPageGeneratingFilter 有关系了,此时请求就会在 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);
            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";
            }
        }

        StringBuilder sb = new StringBuilder();
        String contextPath = request.getContextPath();
        if (this.formLoginEnabled) {
            sb.append("");
        }
        if (openIdEnabled) {
            sb.append("");
        }
        if (oauth2LoginEnabled) {
            sb.append("");
        }
        if (this.saml2LoginEnabled) {
            sb.append("");
        }
        return sb.toString();
    }
}

DefaultLoginPageGeneratingFilter 的源码执行流程还是非常清晰的, 我们梳理一下:

  1. 在 doFilter 方法中,首先判断出当前请求是否为登录出错请求、注销成功请求或者登录请求。如果是这三种请求中的任意一个,就会在 DefaultLoginPageGeneratingFilter 过滤器中生成登录页面并返回,否则请求继续往下走, 执行下一个过滤器(这就是一开始的 /hello 请求为什么没有被 DefaultLoginPageGeneratingFilter 拦截下来的原因)。

  2. 如果当前请求是登录出错请求、注销成功请求或者登录请求中的任意一个,就会调用 generateLoginPageHtml 方法去生成登录页面。在该方法中,如果有异常信息就把异常信息取出来一同返回给前端,然后根据不同的登录场景,生成不同的登录页面。生成过程其实就是字符串拼接,拼接出不同的登录表单(由于源码太长,上面没有贴出来具体的字符串拼接源码,读者可以自行查看 DefaultLoginPageGeneratingFilter 类的源码)。

  3. 登录页面生成后,接下来通过 HttpServletResponse 将登录页面写回到前端,然后调用 return 方法跳出过滤器链。

这就是 DefaultLoginPageGeneratingFilter 的工作过程。这里重点搞明白为什么 /hello 请求没有被拦截,而 /login 请求却被拦截了, 其他都很好懂。

理解了 DefaultLoginPageGeneratingFilter,再来看 DefaultLogoutPageGeneratingFilter 就更容易了,DefaultLogoutPageGeneratingFilter 部分核心源码如下:

public class DefaultLogoutPageGeneratingFilter extends OncePerRequestFilter {
    private RequestMatcher matcher = new AntPathRequestMatcher("/logout", "GET");

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        if (this.matcher.matches(request)) {
            renderLogout(request, response);
        } else {
            filterChain.doFilter(request, response);
        }
    }

    private void renderLogout(HttpServletRequest request,
                              HttpServletResponse response)
            throws IOException {
        String page = "";
        response.setContentType("text/html;charset=UTF-8");
        response.getWriter().write(page);
    }
}

从上述源码中可以看出,请求到来之后,会先判断是否是注销请求 /logout,如果是 /logout 请求,则渲染一个注销请求的页面返回给客户端,渲染过程和前面登录页面的渲染过程类似,也是字符串拼接(这里省略了字符串拼接,读者可以参考 DefaultLogoutPageGeneratingFilter 的源码);否则请求继续往下走,执行下一个过滤器。

通过前面的分析,相信大家对这个简单的案例已经有所了解,看似只是加了一个依赖,但实际上 Spring Security 和 Spring Boot 在背后都默默做了很多事情,当然还有很多没有介绍到的,我们将在后面的章节中和大家一起继续深究。