持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第4天,点击查看活动详情
Spring Security 基本认证
在 Spring Security 的快速入门里,我们只需要 Spring Boot 项目中引入 Web 和 Spring Security 依赖,然后编写一个简单的/hello接口,启动项目,就可以体会到Spring Security对接口的保护。当我们访问/hello 接口时,会自动跳转到登录页面,只有输入用户名和密码才可以继续访问接口。虽然,我们只是简单的导入依赖编写接口,代码量不多,却可以初步使用Spring Security,那么其本身又做了哪些操作呢?我们会思考,用户名和密码从哪来,登录登出的页面又从哪来呢?
1. 流程分析
先来看看请求接口时大致的执行流程,如下图所示:
(1)客户端(浏览器)发起请求去访问/hello 接口,因为引入了 Spring Security 这个接口默认是需要认证之后才能访问的。
(2)这个请求会走一遍 Spring Security 中的过滤器链,在最后的 FilterSecurityInterceptor 过滤器中被拦截下来,因为系统发现用户未认证。请求拦截下来之后,接下来会抛出 AccessDeniedException 异常。
(3)抛出的 AccessDeniedException 异常在 ExceptionTranslationFilter 过滤器中被捕获, ExceptionTranslationFilter 过滤器通过调用 LoginUrlAuthenticationEntryPoint#commence 方法给 客户端返回 302,要求客户端重定向到/login 页面。
(4)客户端发送/login 请求。
(5)/login 请求被 DefaultLoginPageGeneratingFilter 过滤器拦截下来,并在该过滤器中返 回登录页面。所以当用户访问/hello 接口时会首先看到登录页面。 在整个过程中,相当于客户端一共发送了两个请求,第一个请求是/hello,服务端收到之 后,返回 302,要求客户端重定向到/login,于是客户端又发送了/login 请求。
2. 原理分析
虽然只是引入了一个依赖,代码不多,但是 Spring Boot 背后却进行了一系列操作:
- 开启 Spring Security 自动化配置。开启后,会自动创建一个名为 springSecurityFilterChain 的过滤器,并注入到 Spring 容器中,这个过滤器将负责所有的安全管理,包括用户的认证、授权、重定向到登录页面等(springSecurityFilterChain 实际上代理了 Spring Security中的过滤器链)。
- 创建一个 UserDetailsService 实例,UserDetailsService 负责提供用户数据,默认的用户数据是基于内存的用户,用户名为 user,密码则是随机生成的 UUID 字符串。
- 给用户生成一个默认的登录页面。
- 开启 CSRF 攻击防御。
- ...
2.1 默认用户生成
默认用户也就是用户名为user,密码在控制台获取的用户,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 ,当然框架中也提供了默认实现:
- 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 里的源码以及注释,这就可以找到默认用户的产生,再看看针对 UserDetailsService 的自动化配置类,也就是 UserDetailsServiceAutoConfiguration,以AutoConfiguration结尾,它的作用很明显,其源代码如下:
@AutoConfiguration
@ConditionalOnClass({AuthenticationManager.class})
@ConditionalOnBean({ObjectPostProcessor.class})
@ConditionalOnMissingBean(
value = {AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class, AuthenticationManagerResolver.class},
type = {"org.springframework.security.oauth2.jwt.JwtDecoder", "org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector", "org.springframework.security.oauth2.client.registration.ClientRegistrationRepository", "org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository"}
)
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);
public UserDetailsServiceAutoConfiguration() {
}
@Bean
@Lazy
public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties, ObjectProvider<PasswordEncoder> passwordEncoder) {
SecurityProperties.User user = properties.getUser();
List<String> roles = user.getRoles();
return new InMemoryUserDetailsManager(new UserDetails[]{User.withUsername(user.getName()).password(this.getOrDeducePassword(user, (PasswordEncoder)passwordEncoder.getIfAvailable())).roles(StringUtils.toStringArray(roles)).build()});
}
private String getOrDeducePassword(SecurityProperties.User user, PasswordEncoder encoder) {
String password = user.getPassword();
if (user.isPasswordGenerated()) {
logger.warn(String.format("%n%nUsing generated security password: %s%n%nThis generated password is for development use only. Your security configuration must be updated before running your application in production.%n", user.getPassword()));
}
return encoder == null && !PASSWORD_ALGORITHM_PATTERN.matcher(password).matches() ? "{noop}" + password : password;
}
}
从注解就可以看出这是一个配置类,其次,如果想要产生InMemoryUserDetailsManager 的实例就需要满足两个条件:
(1)当前 classpath 下存在 AuthenticationManager 类。
(2)当前项目中,系统没有提供 AuthenticationManager、AuthenticationProvider、 UserDetailsService 以及 ClientRegistrationRepository 实例。
默认情况下,上面的条件都会满足,此时 Spring Security 就会提供一个 InMemoryUserDetailsManager 实例。从 inMemoryUserDetailsManager 这个方法中可以看到,默认用户来自 SecurityProperties,核心代码如下:
@ConfigurationProperties(
prefix = "spring.security"
)
public class SecurityProperties {
public static final int BASIC_AUTH_ORDER = 2147483642;
public static final int IGNORED_ORDER = Integer.MIN_VALUE;
public static final int DEFAULT_FILTER_ORDER = -100;
private final Filter filter = new Filter();
private final User user = new User();
public SecurityProperties() {
}
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();
private boolean passwordGenerated = true;
public User() {
}
}
此处的User类是静态类,所以产生的默认用户的用户名固定,密码是随机的UUID,由类的注解也可以看出,默认用户的用户名,密码以及权限都是可以在Springboot的配置文件进行配置的。不妨试一下:
spring:
security:
user:
name: test
password: 1234
roles: admin,user
配置好后发现控制台就不会输出随机的UUID作为密码了,此时的用户名和密码就是配置文件里配置的。
2.2 默认页面生成
默认页面其实是两个,一个是之前展示的登录页面,另外一个则是注销登录页面。当用户登录成功之后,在浏览器中输入 http://localhost:8088/logout (端口号可以自定义)就可以看到注销登录页面:
那么这两个页面怎么来的呢?在Spring Security 的常见过滤器中包含 DefaultLoginPageGeneratingFilter 和 DefaultLogoutPageGeneratingFilter 两个和页面相关的过滤器。很明显,一个是构建登录页面,一个构建注销登录页面。\
2.2.1 登录页面生成
先来看 DefaultLoginPageGeneratingFilter,核心代码如下:
public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
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);
}
}
因为每个请求都会经过Spring Security的过滤器链,根据流程图展示,在第一次请求/hello 接口的时候,就会经过 DefaultLoginPageGeneratingFilter 过滤器,此时会判断出当前请求是否为登录出错请求、注销成功请求或者登录请求。如果是这三种请求中的任意一个,就会进行构建,但是由于/hello 接口和登录无关,因此 DefaultLoginPageGeneratingFilter 过滤器会直接放行/hello 接口。等到第二次重定向到 /login 页面的时候,isLoginUrlRequest(request)返回true,这个时候就会执行 generateLoginPageHtml(request, loginError, logoutSuccess) 进行页面的构建,此时请求就会在 DefaultLoginPageGeneratingFilter 中进行处理,生成登录页面并返回给客户端,然后跳出过滤器链。看了源码会知道,页面其实是由字符串根据不同的情况拼接出来的。
2.2.2 注销登录页面生成
同理,注销登录页面的产生大同小异, 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 {
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Did not render default logout page since request did not match [%s]",
this.matcher));
}
filterChain.doFilter(request, response);
}
}
从源码可以看出,请求到来之后,会先匹配是否是注销请求/logout,如果是/logout 请求,则返回一个注销请求的页面返回给客户端,构建过程和登录页面的渲染过程类似,也是字符串拼接,否则请求继续往下走,执行下一个过滤器。
所以,表面上只是加了一个依赖, 但实际上 Spring Security 和 Spring Boot 在背后做了很多配置,这也是Spring Boot的强大之处。