对于安全管理框架来说,认证共嗯那个可以说是一切的起点,所以我们要从最基本的认证开始,在SpringSecurity中,对认证功能做了大量的封装,我们只需要稍微配置一下就能使用认证功能,也就是因为大量的封装,所以在我们去理解它的逻辑时就显得有些不易了,我们先从最基本的用法开始。
- SpringSecurity基本认证
- 登录表单配置
- 登录用户数据获取
- 用户的四种定义方式
在SpringBoot中使用SpringSecurity非常方便,创建一个新的SpringBoot项目,我们只需要引入web和SpringSecurity依赖即可。
org.springframework.boot
spring-boot-starter-web
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-security
我们在定义一个测试的接口
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello(){
return "hello springboot security";
}
}
接下来启动项目,/hello接口就被自动保护起来了,当用户访问/hello接口时,会自动跳转到登录页面,用户登录成功后,才能访问/hello接口
默认的登录用户名是user,登录密码则是一个随机生产的UUID 字符串,在项目启动日志中可以看到登录密码(这也意味着项目每次启动时,密码都会发生变化)
Using generated security password: 39fcc6b3-633b-443c-8bb4-68141bbe1506
输入用户名密码,就可以登录成功了。
流程分析我们通过一个简单的流程图来看一下上面请求流程
流程图比较清晰的说明了整个请求过程:
1、客户端(浏览器)发起请求去访问/hello接口,这个接口默认是需要认证之后才能访问到。
2、这个请求会走一遍SpringSecurity中过滤链,在最后的FilterSecurityInterceptor过滤器中被拦截下来,因为系统用户未认证。请求拦截下来之后,接下来会抛出AccessDeniedException异常。
3、抛出的AccessDeniedException异常在ExceptionTranslationFilter过滤器中被捕获,ExceptionTranslationFilter过滤器通过调用LoginUrlAuthenticationEntryPoint#commence方法给客户端返回302,要求客户端重定向到/login页面。
4、客户端发送/login请求。
5、/login请求被DefaultLoginPageGeneratingFilter过滤器拦截下来,并在该过滤器中返回登录页面。所以当用户访问/hello接口时会首先看到登录页面。
整个过程中,相当于客户端一共发送了两个请求,第一个请求是/hello,服务端收到之后,返回302,要求客户端重定向到/loogin,于是客户端又发送了/login请求。
原理分析在上面中,我们只是引入了依赖,但是SpringBoot确做了很多的事情:
- 开启Spring Security自动化配置,开启后,会自动创建一个名未SpringSecurityFilterChain的过滤器,并注入到Spring容器中,这个过滤器将负责所有的安全管理,包括用户的认证、授权、重定向到登录页面等(SpringSecurityFilterChain实际上代理了SpringSecurity中的过滤链)
- 创建一个UserDetailService实例,UserDetailService负责提供用户数据,默认的用户数据是基于内存的用户,用户名名为user,密码则是随机生成的UUID字符串。
- 给用户生成一个默认的登录页面。
- 开启CSRF攻击防御
- 开启会话固定攻击防御
- 集成X-XSS-Protection
- 集成X-Frame-Options以防止单击劫持
这里远远不止这些,在这里我们主要分析一下默认用户的生成以及默认页面的生成。
默认用户生成SpringSecurity中定义了UserDetail接口来规范我们自定义的用户对象,这样方便一些旧系统,用户表已经固定的系统集成到SpringSecurity认证体系中,UserDetail接口如下:
public interface UserDetails extends Serializable {
//返回当前用户所具备的权限
Collection getAuthorities();
//返回当前用户密码
String getPassword();
//返回但钱用户用户名
String getUsername();
//返回当前用户是否过期
boolean isAccountNonExpired();
//返回当前用户是否锁定
boolean isAccountNonLocked();
//返回当前用户账户凭证(如密码)是否过期
boolean isCredentialsNonExpired();
//返回当前用户是否可用
boolean isEnabled();
}
这里是用户对象的定义,而负责提供用户数据源的接口是UserDetailsService,该接口中只有一个查询用户的方法
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
loadUserByUsername有一个参数是username,这是用户在认证时传入的用户名,最常见的就是用户在登录表单中输入的用户名(实际中还可能存在其他情况,例如使用CAS单点登录,username并发表单输入的用户名, 而是CAS Server认证成功后回调的用户参数),我们在这里拿到用户名之后,再去数据库中查询用户,最终返回一个UserDetails实例。
在实例开发中,我们一般都需要自定义UserDetailsService的实现,如果我们不去定义,SpringSecurity也为UserDetailsService提供了默认是实现。
-
UserDetailsManager 在UserDetailsService的基础上,继续定义了添加用户、更新用户、删除用户、修改密码以及判用户是否存在共5种方法。
-
JdbcDaoImpl在UserDetailsService基础上,通过spring-jdbc实现了从数据库种查询用户的方法。
-
InMermoryUserDetailsManager实现了UserDetailsManager种关于用户的增删改查方法,不过都是基于内存操作的,数据并没有持久化。
-
JdbcUserDetailsManager继承自JdbcDaoImpl同时又实现了UserDetailsManager接口,因此可以通过JdbcUserDetailsManager实现对用户的增删改查操作,这些操作都会持久化到数据库种。不过JdbcUserDetailsManager有一个局限性,就是操作数据中用户的SQL都是提前写好的,不够灵活,因此在实际开发种JdbcUserDetailsManager使用并不多。
-
CachingUserDetailsService的特点是会将UserDetailsService缓存起来。
当我们使用SpringSecurity时,如果仅仅只是引入一个SpringSecurity依赖,则默认使用的用户就是由InMemoryUserDetailsManager提供的。
我们都知道SpringBoot之所以能够做到零配置的使用,就算因为它提供了众多的自动化配置类,其中,针对UserDetailsService的自动化配置类是UserDetailsServiceAutoConfiguration。我们来看一下
@Configuration(
proxyBeanMethods = false
)
@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"}
)
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) {
User user = properties.getUser();
List roles = user.getRoles();
return new InMemoryUserDetailsManager(new UserDetails[]{org.springframework.security.core.userdetails.User.withUsername(user.getName()).password(this.getOrDeducePassword(user, (PasswordEncoder)passwordEncoder.getIfAvailable())).roles(StringUtils.toStringArray(roles)).build()});
}
private String getOrDeducePassword(User user, PasswordEncoder encoder) {
String password = user.getPassword();
if (user.isPasswordGenerated()) {
logger.info(String.format("%n%nUsing generated security password: %s%n", user.getPassword()));
}
return encoder == null && !PASSWORD_ALGORITHM_PATTERN.matcher(password).matches() ? "{noop}" + password : password;
}
}
从上述代码中可以看到,有两个比较重要的条件促使系统自动提供一个InMemoryUserDetailsManager的实例:
- 当前classpath下存在AuthenticationManager类。
- 当前项目中,系统没有提供AuthenticationManager、AuthenticationProvider、UserDetailsService以及ClientRegistrationRepository实例。
默认情况下,上面的条件都会满足,此时SpringSecurity会提供一个InMemoryUserDetailsManager实例,从InMemoryUserDetailsManager方法种可以看到,用户数据源自SecurityProperties#getUser方法:
@ConfigurationProperties(prefix = "spring.security")
public class SecurityProperties {
public static final int BASIC_AUTH_ORDER = Ordered.LOWEST_PRECEDENCE - 5;
public static final int IGNORED_ORDER = Ordered.HIGHEST_PRECEDENCE;
public static final int DEFAULT_FILTER_ORDER = OrderedFilter.REQUEST_WRAPPER_FILTER_MAX_ORDER - 100;
private final Filter filter = new Filter();
private final User user = new User();
public User getUser() {
return this.user;
}
public Filter getFilter() {
return this.filter;
}
public static class Filter {
private int order = DEFAULT_FILTER_ORDER;
private Set dispatcherTypes = new HashSet(
Arrays.asList(DispatcherType.ASYNC, DispatcherType.ERROR, DispatcherType.REQUEST));
public int getOrder() {
return this.order;
}
public void setOrder(int order) {
this.order = order;
}
public Set getDispatcherTypes() {
return this.dispatcherTypes;
}
public void setDispatcherTypes(Set dispatcherTypes) {
this.dispatcherTypes = dispatcherTypes;
}
}
public static class User {
/**
* Default user name.
*/
private String name = "user";
/**
* Password for the default user name.
*/
private String password = UUID.randomUUID().toString();
/**
* Granted roles for the default user name.
*/
private List roles = new ArrayList();
private boolean passwordGenerated = true;
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
public String getPassword() {
return this.password;
}
public void setPassword(String password) {
if (!StringUtils.hasLength(password)) {
return;
}
this.passwordGenerated = false;
this.password = password;
}
public List getRoles() {
return this.roles;
}
public void setRoles(List roles) {
this.roles = new ArrayList(roles);
}
public boolean isPasswordGenerated() {
return this.passwordGenerated;
}
}
}
从SecurityProperties.User类中,我们就可以看到默认的用户名是user,默认的密码是一个UUID 字符串。
在回到InMemoryUserDetailsManager方法中,构造InMemoryUserDetailsManager实例时需要一个User对象
这里的User对象不是SecurityProperties.User,而是org.springframework.security.core.userdetails.User,这是SpringSecurity提供了一个实现了UserDetails接口的用户类,该类提供了相应的静态方法,用来构造一个默认的User实例。同时,默认的用户密码还在getOrDeducePassword方法中进行了二次处理,由于默认的encodeer为null,所以密码进行二次处理只是给密码加了一个前缀{noop},表示密码是明文存储的。
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;
}
经过上述梳理,我们已经明白SpringSecurity默认的用户名/密码是来自哪里!另外我们如果熟悉SpringBoot中properties属性的加载机制有一点了解,我们就会明白,只要我们在项目的application.yml/application.yml配置文件中添加如下配置,就能定制SecurityProperties.User类中各属性的值:
spring.security.user.name=test
spring.security.user.password=123456
spring.security.user.roles=admin,user
可以注意一点,当我们在定制了账户名和密码之后,在启动中,SpringSecurity就不会打印之前随机生成UUID字符串了。
默认页面生成在上面案例中,一共存在两个默认页面,一个是我们看到的登录页面,还有一个注销登录页面。当用户登录成功后,在浏览器输入 http://localhost:8080/logout 就可以看到注销登录页面
那么这两个页面是从哪里来的呢?
在上一篇文章中,我们就介绍了SpringSecurity常见的过滤器,在这些常见的过滤器中就包含两个和页面相关的过滤器:DefaultLoginPageGeneratingFilter和DefaultLogoutPageGeneratingFilter。
- DefaultLoginPageGeneratingFilter
DefaultLoginPageGeneratingFilter作为SpringSecurity过滤器链的一员,在第一次请求/hello接口的时候,就会经过DefaultLoginPageGeneratingFilter过滤器,但是由于/hello接口和登录无关,因此DefaultLoginPageGeneratingFilter过滤器并未干涉/hello接口。等到第二次重定向到/login页面的时候,这个时候和DefaultLoginPageGeneratingFilter就有关系了,此时请求就会在DefaultLoginPageGeneratingFilter中进行处理,生成登录页面返回给客户端。
我们来看一下DefaultLoginPageGeneratingFilter的源码,这里我列出核心部分:
public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
boolean loginError = this.isErrorPage(request);
boolean logoutSuccess = this.isLogoutSuccess(request);
if (!this.isLoginUrlRequest(request) && !loginError && !logoutSuccess) {
chain.doFilter(request, response);
} else {
String loginPageHtml = this.generateLoginPageHtml(request, loginError, logoutSuccess);
response.setContentType("text/html;charset=UTF-8");
response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length);
response.getWriter().write(loginPageHtml);
}
}
//--------------------------------------------------------------------------
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("SPRING_SECURITY_LAST_EXCEPTION");
errorMsg = ex != null ? ex.getMessage() : "Invalid credentials";
}
}
String contextPath = request.getContextPath();
StringBuilder sb = new StringBuilder();
sb.append("\n");
sb.append("\n");
sb.append(" \n");
sb.append(" \n");
sb.append(" \n");
sb.append(" \n");
sb.append(" \n");
sb.append(" Please sign in\n");
sb.append(" \n");
sb.append(" \n");
sb.append(" \n");
sb.append(" \n");
sb.append(" \n");
if (this.formLoginEnabled) {
sb.append(" \n");
sb.append(" Please sign in\n");
sb.append(createError(loginError, errorMsg) + createLogoutSuccess(logoutSuccess) + " \n");
sb.append(" Username\n");
sb.append(" \n");
sb.append("
\n");
sb.append(" \n");
sb.append(" Password\n");
sb.append(" \n");
sb.append("
\n");
sb.append(this.createRememberMe(this.rememberMeParameter) + this.renderHiddenInputs(request));
sb.append(" Sign in\n");
sb.append(" \n");
}
if (this.openIdEnabled) {
sb.append(" \n");
sb.append(" Login with OpenID Identity\n");
sb.append(createError(loginError, errorMsg) + createLogoutSuccess(logoutSuccess) + " \n");
sb.append(" Identity\n");
sb.append(" \n");
sb.append("
\n");
sb.append(this.createRememberMe(this.openIDrememberMeParameter) + this.renderHiddenInputs(request));
sb.append(" Sign in\n");
sb.append(" \n");
}
Iterator var7;
Entry relyingPartyUrlToName;
String url;
String partyName;
if (this.oauth2LoginEnabled) {
sb.append("Login with OAuth 2.0");
sb.append(createError(loginError, errorMsg));
sb.append(createLogoutSuccess(logoutSuccess));
sb.append("\n");
var7 = this.oauth2AuthenticationUrlToClientName.entrySet().iterator();
while(var7.hasNext()) {
relyingPartyUrlToName = (Entry)var7.next();
sb.append(" ");
url = (String)relyingPartyUrlToName.getKey();
sb.append("");
partyName = HtmlUtils.htmlEscape((String)relyingPartyUrlToName.getValue());
sb.append(partyName);
sb.append("");
sb.append("\n");
}
sb.append("\n");
}
if (this.saml2LoginEnabled) {
sb.append("Login with SAML 2.0");
sb.append(createError(loginError, errorMsg));
sb.append(createLogoutSuccess(logoutSuccess));
sb.append("\n");
var7 = this.saml2AuthenticationUrlToProviderName.entrySet().iterator();
while(var7.hasNext()) {
relyingPartyUrlToName = (Entry)var7.next();
sb.append(" ");
url = (String)relyingPartyUrlToName.getKey();
sb.append("");
partyName = HtmlUtils.htmlEscape((String)relyingPartyUrlToName.getValue());
sb.append(partyName);
sb.append("");
sb.append("\n");
}
sb.append("\n");
}
sb.append("\n");
sb.append("");
return sb.toString();
}
}
(1)、在doFilter中,首先会判断当前请求是否为登录出错请求、注销成功请求或者登录请求。如果是这三个请求中的任意一个,就会在DefaultLoginPageGeneratingFilter过滤器中生成登录页面并返回,否则继续往下走,执行下一个过滤器(这就是一开始的/hello请求没有被拦截下来的原因)
(2)、如果当前请求是登录出错请求、注销成功请求或者登录请求中的任意一个就会调用generateLoginPageHtml方法去生成登录页面,在该方法中,如果有异常信息就把异常信息取出来一同返回给前端,然后根据不同的登录常见,生成不同的页面。
(3)、登录页面生成后,接下来通过HttpServletResponse将登录页面写回到前端,然后调用return跳出过滤器链
- DefaultLogoutPageGeneratingFilter
public class DefaultLogoutPageGeneratingFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (this.matcher.matches(request)) {
this.renderLogout(request, response);
} else {
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Did not render default logout page since request did not match [%s]", this.matcher));
}
filterChain.doFilter(request, response);
}
}
}
从上述源码中可以看出,请求到来之后,会先判断是否注销请求/logou,如果是,则渲染一个注销请求的页面返回给客户端,渲染过程和前面页面(页面过程类似这里就不展示了),否则请求继续往下走,执行下一个过滤器