Spring Security进阶学习

1,875

Spring Security系列文章

Spring Security整体架构

认证

认证核心组件的大体关系如下:

认证核心组件

Spring Security 中的认证工作主要由 AuthenticationManager 接口来负责,它处理来自框架其他部分的身份验证请求。其中还涉及到一些关键类,比如:AuthenticationProvider、Authentication 等等,后续等我们演示完项目实例后,会详细对这部分内容进行解读。

授权

当完成认证后,接下来就是授权了。在 Spring Security 的授权体系中,有两个关键接口:

  • AccessDecisionManager
  • AccessDecisionVoter

AccessDecisionVoter 是一个投票器,投票器会检查用户是否具备应有的角色,进而投出赞成、反对或者弃权票;AccessDecisionManager 则是一个决策器,来决定此次访问是否被允许。AccessDecision Voter 和 AccessDecisionManager 都有众多的实现类,在 AccessDecisionManager 中会挨个遍历 AccessDecisionVoter,进而决定是否允许用户访问,因而 AccessDecisionVoter 和 AccessDecisionManager 两者的关系类似于 AuthenticationProvider 和 ProviderManager 的关系。

过滤器

Spring Security 采用的是责任链的设计模式,它有一条很长的过滤器链。如下是常见的过滤器:

Spring Security 过滤器

Spring Security 的默认 Filter 链:

 SecurityContextPersistenceFilter
->HeaderWriterFilter
->LogoutFilter
->UsernamePasswordAuthenticationFilter
->RequestCacheAwareFilter
->SecurityContextHolderAwareRequestFilter
->SessionManagementFilter
->ExceptionTranslationFilter
->FilterSecurityInterceptor

这些过滤器按照既定的优先级排列,最终形成一个过滤器链,如下图所示。开发人员也可以自定义过滤器,并通过 @Order 注解来调整自定义过滤器在过滤器链中的位置。

Spring Security过滤器执行顺序图

下面介绍几个重要的过滤器:

  • SecurityContextPersistenceFilter 这个Filter是整个拦截过程的入口和出口(也就是第一个和最后一个拦截器),会在请求开始时从配置好的 SecurityContextRepository 中获取 SecurityContext,然后把它设置给 SecurityContextHolder。在请求完成后将 SecurityContextHolder 持有的 SecurityContext 再保存到配置好的 SecurityContextRepository,同时清除 securityContextHolder 所持有的 SecurityContext;

  • UsernamePasswordAuthenticationFilter过滤器用于处理基于表单方式的登录验证,该过滤器默认只有当请求方法为post、请求页面为/login时过滤器才生效,如果想修改默认拦截url,只需在刚才介绍的Spring Security配置类WebSecurityConfig中配置该过滤器的拦截url:.loginProcessingUrl("url")即可;

  • BasicAuthenticationFilter用于处理基于HTTP Basic方式的登录验证,当通过HTTP Basic方式登录时,默认会发送post请求/login,并且在请求头携带Authorization:Basic dXNlcjoxOWEyYWIzOC1kMjBiLTQ0MTQtOTNlOC03OThkNjc2ZTZlZDM=信息,该信息是登录用户名、密码加密后的信息,然后由BasicAuthenticationFilter过滤器解析后,构建UsernamePasswordAuthenticationFilter过滤器进行认证;如果请求头没有Authorization信息,BasicAuthenticationFilter过滤器则直接放行;

  • FilterSecurityInterceptor的拦截器,用于判断当前请求身份认证是否成功,是否有相应的权限,当身份认证失败或者权限不足的时候便会抛出相应的异常;

  • ExceptionTranslateFilter捕获并处理,所以我们在ExceptionTranslateFilter过滤器用于处理了FilterSecurityInterceptor抛出的异常并进行处理,比如需要身份认证时将请求重定向到相应的认证页面,当认证失败或者权限不足时返回相应的提示信息;

上图中的过滤器被 SecurityFilterChain 直接管理,再由 FilterChainProxy 统一管理,SecurityFilterChain 通过 FilterChainProxy 嵌入到 Web 项目的原生过滤器链中,如下图所示:

单个SecurityFilterChain

在 Spring Security 中,这样的过滤器链不止一个,可能会有多个,如下图所示。当存在多个过滤器链时,每个过滤器链之间要指定优先级,当请求到达后,会从 FilterChainProxy 进行分发,先和哪个过滤器链匹配上,就用哪个过滤器链进行处理。

多个SecurityFilterChain

关于 SecurityFilterChain 和 FilterChainProxy,以及还未提到的 DelegatingFilterProxy 是 Spring Security 过滤器链体系中非常重要的三个概念,深入学习时再结合源码分析,这里知道有这样一个概念即可。

项目实践

数据库

稍微复杂点的后台系统都会涉及到用户权限管理,既然我们选择使用 Spring Security 这一安全框架,那么就需要考虑如何来设计一套权限管理系统。首先需要知道的是,权限就是对数据(系统的实体类)和数据可进行的操作(增删查改)的集中管理。要构建一个可用的权限管理系统,涉及到三个核心类:一个是用户User,一个是角色Role,最后是权限Permission

用户角色,角色权限都是多对多关系,即一个用户拥有多个角色,一个角色属于多个用户;一个角色拥有多个权限,一个权限属于多个角色。这种方式需要指定用户有哪些角色,而角色又有哪些权限。

执行如下 SQL 语句,来构建数据表并初始化数据。

CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(50) DEFAULT NULL,
  `password` varchar(100) DEFAULT NULL,
  `phone` varchar(50) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户表';

insert into `user`(username,password,phone) values('zhangsan','123','123566534');
insert into `user`(username,password,phone) values('lisi','456','123566534');


CREATE TABLE `role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(50) DEFAULT NULL,
  `desc` varchar(50) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='角色表';

INSERT into `role`(name,`desc`) values('admin','管理员');
INSERT into `role`(name,`desc`) values('worker1','操作员1');
INSERT into `role`(name,`desc`) values('worker2','操作员2');

CREATE TABLE `permission` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(50) DEFAULT NULL,
  `url` varchar(50) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='权限表';

INSERT into permission(name,url) values('所有权限','');
INSERT into permission(name,url) values('p1','/r/r1');
INSERT into permission(name,url) values('p2','/r/r2');


CREATE TABLE `user_role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `uid` int(11) DEFAULT NULL,
  `rid` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `users_role_ibfk_1` (`uid`),
  KEY `users_role_ibfk_2` (`rid`),
  CONSTRAINT `users_role_ibfk_1` FOREIGN KEY (`uid`) REFERENCES `user` (`id`),
  CONSTRAINT `users_role_ibfk_2` FOREIGN KEY (`rid`) REFERENCES `role` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户角色对照表';

INSERT into user_role(uid,rid) values(1,2);
INSERT into user_role(uid,rid) values(2,3);

CREATE TABLE `role_permission` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `rid` int(11) DEFAULT NULL ,
  `pid` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `role_permission_ibfk_1` (`rid`),
  KEY `role_permission_ibfk_2` (`pid`),
  CONSTRAINT `role_permission_ibfk_1` FOREIGN KEY (`rid`) REFERENCES `role` (`id`),
  CONSTRAINT `role_permission_ibfk_2` FOREIGN KEY (`pid`) REFERENCES `permission` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='角色权限对照表';

INSERT into role_permission(rid,pid) values(1,1);
INSERT into role_permission(rid,pid) values(2,2);
INSERT into role_permission(rid,pid) values(3,3);

构建SpringBoot项目

1、引入依赖

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>2.7.0</version>
  <relativePath/>
</parent>

<dependencies>
  <!-- 以下是>spring boot依赖-->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>

  <!-- 以下是>spring security依赖-->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>
  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.20</version>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>

  <dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.4.2</version>
  </dependency>

  <dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.5.8</version>
  </dependency>

  <dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.23</version>
  </dependency>
</dependencies>

2、yaml 文件配置:

server:
  port: 8083
spring:
  application:
    name: springboot-security
  datasource:
    url: jdbc:mysql://localhost:3306/spring_security?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver

mybatis-plus:
  mapper-locations:
    - classpath:mapper/*.xml
    - classpath*:com/**/mapper/*.xml
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

3、创建数据库表对应的三个实体类:User、Role、Permission

@TableName("user")
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class User {

  private Long id;
  private String username;
  private String password;
  private String phone;
}

@TableName("role")
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Role {

  private Long id;

  private String name;

  private String desc;
}

@TableName("permission")
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Permission {

  private Long id;
  private String name;
  private String url;
}

4、数据库操作,包括 mapper 文件和对应的 xml 文件,这里仅展示 UserMapper.java 和UserMapper.xml

@Mapper
public interface UserMapper extends BaseMapper<User> {

  User selectByUserName(String username);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.msdn.security.springboot.mapper.UserMapper">

  <select id="selectByUserName" resultType="com.msdn.security.springboot.model.User">
    select * from user
    <where>
      <if test="username !=null and username !=''">
        username = #{username}
      </if>
    </where>
  </select>
</mapper>

5、自定义 UserDetailsService 实现类

@Component
@RequiredArgsConstructor
public class MyUserDetailsService implements UserDetailsService {

  private final UserMapper userMapper;
  private final PermissionMapper permissionMapper;

  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    //根据账号去数据库查询...
    User user = userMapper.selectByUserName(username);
    if (Objects.isNull(user)) {
      return null;
    }
    List<String> permissions = findPermissionsByUserId(user.getId().toString());
    String[] perArray = new String[permissions.size()];
    permissions.toArray(perArray);
    UserDetails userDetails =
        org.springframework.security.core.userdetails.User.withUsername(username)
            .password(user.getPassword())
            .authorities(perArray).build();
    return userDetails;
  }

  /**
   * 根据用户id查询用户权限
   *
   * @param userId
   * @return
   */
  public List<String> findPermissionsByUserId(String userId) {
    if (StrUtil.isEmpty(userId)) {
      return new ArrayList<>();
    }
    List<Permission> permissionList = permissionMapper.findPermissionsByUserId(userId);
    return permissionList.stream().map(Permission::getName).collect(Collectors.toList());
  }

}

本项目并没有自定义实体类来实现 UserDetails 接口,如果想要实现,可以这样做:

@Setter
@Builder
public class MyUserDetails implements UserDetails {

  private String username;
  private String password;
  private boolean enabled;
  private Collection<? extends GrantedAuthority> authorities;

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return authorities;
  }

  @Override
  public String getPassword() {
    return password;
  }

  @Override
  public String getUsername() {
    return username;
  }

  @Override
  public boolean isAccountNonExpired() {
    return true;
  }

  @Override
  public boolean isAccountNonLocked() {
    return true;
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return true;
  }

  @Override
  public boolean isEnabled() {
    return enabled;
  }
}

接着只需要修改 loadUserByUsername()方法即可。

  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    //根据账号去数据库查询...
    User user = userMapper.selectByUserName(username);
    if (Objects.isNull(user)) {
      return null;
    }
    List<String> permissions = findPermissionsByUserId(user.getId());

    List<GrantedAuthority> grantedAuthorities = new ArrayList<>(permissions.size());
    permissions.forEach(name -> grantedAuthorities.add(new SimpleGrantedAuthority(name)));

    return MyUserDetails.builder().username(username)
        .password(user.getPassword()).enabled(true).authorities(grantedAuthorities).build();
  }

认证与授权

6、自定义 web 安全配置

@Configuration
public class SecurityConfig {

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }

  //安全拦截机制(最重要)
  @Bean
  SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .csrf().disable()   //屏蔽CSRF控制,即spring security不再限制CSRF
        .authorizeRequests()
        .antMatchers("/login.html").permitAll()
        .antMatchers("/r/r1").hasAuthority("p1")
        .antMatchers("/r/r2").hasAuthority("p2")
        .antMatchers("/r/**").authenticated()//所有/r/**的请求必须认证通过
        .anyRequest().authenticated()
        .and()
        .formLogin()//允许表单登录
        .loginPage("/login.html")
        .loginProcessingUrl("/doLogin")
        .successForwardUrl("/login-success")//自定义登录成功的页面地址
        .and()
        .sessionManagement()
//                .invalidSessionUrl("/session/invalid")
        .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
        .and()
        .logout()
        .logoutUrl("/logout")
        .logoutSuccessUrl("/login-view?logout")
    ;
    return http.build();
  }
}

7、controller 层

@RestController
public class LoginController {

  @Autowired
  private MyUserDetailsService userDetailsService;

  @RequestMapping(value = "/login-success")
  public String loginSuccess() {
    return " 登录成功";
  }

  /**
   * 测试资源1
   *
   * @return
   */
  @GetMapping(value = "/r/r1")
  @PreAuthorize("hasAuthority('p1')") //拥有p1权限才可以访问
  public String r1() {
    return " 访问资源1";
  }

  /**
   * 测试资源2
   *
   * @return
   */
  @GetMapping(value = "/r/r2")
  @PreAuthorize("hasAuthority('p2')") //拥有p2权限才可以访问
  public String r2() {
    return " 访问资源2";
  }

}

启动项目后,访问 http://localhost:8083/,重定向到 login.html 页面,输入 zhangsan 和 123 后,点击登录按钮,页面会显示“登录成功”,接着访问 r/r1 接口,页面显示“访问资源1”,但是 zhangsan 无权访问 r/r2。

同理,如果换做 lisi 账号来登录,只能访问 r/r2,无权访问 r/r1。

会话

用户认证通过后,为了避免用户的每次操作都进行认证可将用户的信息保存在会话中。spring security提供会话管理,认证通过后将身份信息放入SecurityContextHolder上下文,SecurityContext与当前线程进行绑定,方便获取用户身份。

获取用户身份

1、在 service 中增加代码

  /**
   * 从会话中获取当前登录用户名
   *
   * @return
   */
  public String getUserName() {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    if (!authentication.isAuthenticated()) {
      return "";
    }
    Object principal = authentication.getPrincipal();
    String username = "";
    if (principal instanceof UserDetails) {
      username = ((UserDetails) principal).getUsername();
    } else {
      username = principal.toString();
    }
    return username;
  }

2、修改 controller 层的方法

  @RequestMapping(value = "/login-success")
  public String loginSuccess() {
    return userDetailsService.getUserName() + " 登录成功";
  }

  /**
   * 测试资源1
   *
   * @return
   */
  @GetMapping(value = "/r/r1")
  @PreAuthorize("hasAuthority('p1')") //拥有p1权限才可以访问
  public String r1() {
    return userDetailsService.getUserName() + " 访问资源1";
  }

  /**
   * 测试资源2
   *
   * @return
   */
  @GetMapping(value = "/r/r2")
  @PreAuthorize("hasAuthority('p2')") //拥有p2权限才可以访问
  public String r2() {
    return userDetailsService.getUserName() + " 访问资源2";
  }

3、测试

登录成功后,可以打印出登录用户名称。

会话控制

Session 会话管理需要在configure(HttpSecurity http)方法中通过http.sessionManagement()开启配置。此处对http.sessionManagement()返回值的主要方法进行说明,这些方法涉及 Session 会话管理的配置,具体如下:

  • invalidSessionUrl(String invalidSessionUrl):指定会话失效时(请求携带无效的 JSESSIONID 访问系统)重定向的 URL,默认重定向到登录页面。
  • invalidSessionStrategy(InvalidSessionStrategy invalidSessionStrategy):指定会话失效时(请求携带无效的 JSESSIONID 访问系统)的处理策略。
  • maximumSessions(int maximumSessions):指定每个用户的最大并发会话数量,-1 表示不限数量。
  • maxSessionsPreventsLogin(boolean maxSessionsPreventsLogin):如果设置为 true,表示某用户达到最大会话并发数后,新会话请求会被拒绝登录;如果设置为 false,表示某用户达到最大会话并发数后,新会话请求访问时,其最老会话会在下一次请求时失效并根据 expiredUrl() 或者 expiredSessionStrategy() 方法配置的会话失效策略进行处理,默认值为 false。
  • expiredUrl(String expiredUrl):如果某用户达到最大会话并发数后,新会话请求访问时,其最老会话会在下一次请求时失效并重定向到 expiredUrl。
  • expiredSessionStrategy(SessionInformationExpiredStrategy expiredSessionStrategy):如果某用户达到最大会话并发数后,新会话请求访问时,其最老会话会在下一次请求中失效并按照该策略处理请求。注意如果本方法与 expiredUrl() 同时使用,优先使用 expiredUrl() 的配置。
  • sessionRegistry(SessionRegistry sessionRegistry):设置所要使用的 sessionRegistry,默认配置的是 SessionRegistryImpl 实现类。
  • sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED):创建 session 的时机,默认是 ifRequired,Spring Security在需要时才创建session。

通过修改 WebSecurityConfig 中的 configure 方法对该选项进行配置:

@Override
protected void configure(HttpSecurity http) throws Exception {
   http.sessionManagement()
        .maximumSessions(1)
        .maxSessionsPreventsLogin(false)
        .expiredSessionStrategy(new MyExpiredSessionStrategy())
}

这里需要我们新建一个 MyExpiredSessionStrategy 文件

public class MyExpiredSessionStrategy implements SessionInformationExpiredStrategy {

  private static ObjectMapper objectMapper = new ObjectMapper();

  @Override
  public void onExpiredSessionDetected(SessionInformationExpiredEvent event)
      throws IOException, ServletException {
    String msg = "登录超时或已在另一台机器登录,您被迫下线!";
    HttpServletResponse response = event.getResponse();
    response.setContentType("application/json;charset=utf-8");
    response.getWriter().write(objectMapper.writeValueAsString(msg));
  }
}

会话超时

可以在 sevlet 容器中设置 Session的超时时间,如下设置 Session有效期为3600s;

yaml 配置文件:

server:
  servlet:
    session:
      timeout: 3600

session 超时之后,可以通过Spring Security 设置跳转的路径。

http.sessionManagement()
    .invalidSessionUrl("/session/invalid");

在 controller 中定义相关接口:

    @GetMapping(value = "/session/invalid")
    public String sessionInvalid(){
        return "session已失效,请重新认证";
    }

安全会话cookie

我们可以使用httpOnly和secure标签来保护我们的会话cookie:

  • httpOnly:如果为true,那么浏览器脚本将无法访问cookie
  • secure:如果为true,则cookie将仅通过HTTPS连接发

yml 配置文件:

server:
  servlet:
      cookie:
        http-only: true
        secure: true

退出

在 securityFilterChain(HttpSecurity http)中配置:

.and()
  .logout()
  .logoutUrl("/logout")
  .logoutSuccessUrl("/login-view?logout")

当退出操作出发时,将发生:

  • 使HTTP Session 无效
  • 清除 SecurityContextHolder
  • 跳转到 /login-view?logout

工作原理

认证流程

以表单方式登录验证为例,认证流程如下:

认证流程

  • 用户提交用户名、密码被 SecurityFilterChain 中的 UsernamePasswordAuthenticationFilter 过滤器获取到, 封装为请求Authentication,通常情况下是 UsernamePasswordAuthenticationToken 这个实现类。
  • 然后过滤器将 Authentication 提交至认证管理器(AuthenticationManager)进行认证 。
  • 认证成功后, AuthenticationManager 身份管理器返回一个被填充满了信息的(包括上面提到的权限信息, 身份信息,细节信息,但密码通常会被移除) Authentication 实例。
  • SecurityContextHolder 安全上下文容器将第3步填充了信息的 Authentication ,通过 SecurityContextHolder.getContext().setAuthentication()方法,设置到其中。 可以看出 AuthenticationManager 接口(认证管理器)是认证相关的核心接口,也是发起认证的出发点,它的实现类为 ProviderManager。而 Spring Security 支持多种认证方式,因此 ProviderManager 维护着一个 List 列表,存放多种认证方式,最终实际的认证工作是由 AuthenticationProvider完成的。其中web表单的对应的 AuthenticationProvider 实现类为 DaoAuthenticationProvider,它的内部又维护着一个UserDetailsService 负责UserDetails的获取。最终 AuthenticationProvider 将 UserDetails 填充至 Authentication。

下面我们就来详细讲解一下认证流程中的各个关键类。

AuthenticationManager

AuthenticationManager 认证管理器是用来处理认证请求的接口.

package org.springframework.security.authentication;
public interface AuthenticationManager {
  Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

AuthenticationManager 只有一个 authenticate 方法用来做认证,该方法有三个不同的返回值:

  • 返回 Authentication,表示认证成功;
  • 抛出 AuthenticationException 异常,表示用户输入了无效的凭证;
  • 返回 null,表示不能断定。

AuthenticationManager 是一个接口,它有很多实现类,开发人员可以自定义实现类。它默认的实现是 ProviderManager,但它不处理认证请求,而是将委托给 AuthenticationProvider 列表,然后依次使用 AuthenticationProvider 进行认证。

如果有一个 AuthenticationProvider 认证的结果不为null,则表示成功(否则失败,抛出 ProviderNotFoundException),之后不在进行其它 AuthenticationProvider 认证,并作为结果保存在 ProviderManager。

AuthenticationProvider

AuthenticationProvider 是一个身份认证接口,实现该接口来定制自己的认证方式。

AuthenticationProvider 的源码如下:

public interface AuthenticationProvider {
    Authentication authenticate(Authentication var1) throws AuthenticationException;

    boolean supports(Class<?> var1);
}

Spring Security 支持多种不同的认证方式,不同的认证方式对应不同的身份类型,每个 AuthenticationProvider 需要实现supports()方法来表明自己支持的认证方式,如我们使用表单方式认证,在提交请求时 Spring Security 会生成 UsernamePasswordAuthenticationToken,它是一个 Authentication,里面封装着用户提交的用户名、密码信息。而对应的,哪个 AuthenticationProvider 来处理它?

我们在 DaoAuthenticationProvider 的基类 AbstractUserDetailsAuthenticationProvider 发现以下代码:

    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }

也就是说当web表单提交用户名密码时,Spring Security 由 DaoAuthenticationProvider 处理。

如果有一个 AuthenticationProvider 认证的结果不为null,则表示成功(否则失败,抛出 ProviderNotFoundException),之后不在进行其它 AuthenticationProvider 认证,并作为结果保存在 ProviderManager。

ProviderManager 具有一个可选的 parent,如果所有的 AuthenticationProvider 都认证失败,那么就会调用 parent 进行认证。parent 相当于一个备用认证方式,即各个 AuthenticationProvider 都无法处理认证问题的时候,就由 parent 来负责。

Authentication

最后,我们来看一下Authentication(认证信息)的结构,它是一个接口,我们之前提到的 UsernamePasswordAuthenticationToken就是它的实现之一:

package org.springframework.security.core;
public interface Authentication extends Principal, Serializable {
  // 获取用户的权限
  Collection<? extends GrantedAuthority> getAuthorities();

  //获取用户凭证,一般是密码,认证之后会移出,来保证安全性
  Object getCredentials();
	//获取用户携带的详细信息,Web应用中一般是访问者的ip地址和sessionId
  Object getDetails();
	// 获取当前用户
  Object getPrincipal();
	//判断当前用户是否认证成功
  boolean isAuthenticated();

  void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

从这个接口中,我们可以得到用户身份信息,密码,细节信息,认证信息,以及权限列表。

官方文档里说过,当用户提交登录信息时,会将用户名和密码进行组合成一个实例 UsernamePasswordAuthenticationToken,而这个类是 Authentication 的一个常用的实现类,用来进行用户名和密码的认证,类似的还有 RememberMeAuthenticationToken,它用于记住我功能。

UserDetailsService

现在咱们现在知道 DaoAuthenticationProvider 处理了web表单的认证逻辑,认证成功后既得到一个Authentication(UsernamePasswordAuthenticationToken),里面包含了身份信息(Principal)。这个身份信息就是一个 Object ,大多数情况下它可以被强转为UserDetails对象。

DaoAuthenticationProvider 中包含了一个 UserDetailsService 实例,它负责根据用户名提取用户信息 UserDetails(包含密码),而后 DaoAuthenticationProvider 会去对比 UserDetailsService 提取的用户密码与用户提交的密码是否匹配作为认证成功的关键依据,因此可以通过将自定义的 UserDetailsService 公开为spring bean来定义自定义身份验证。

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

很多人把 DaoAuthenticationProvider 和 UserDetailsService 的职责搞混淆,其实 UserDetailsService 只负责从特定的地方(通常是数据库)加载用户信息,仅此而已。而 DaoAuthenticationProvider 的职责更大,它完成完整的认证流程,同时会把 UserDetails 填充至 Authentication

UserDatails 是用户信息,源码如下:

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
}

它和 Authentication 接口很类似,比如它们都拥有 username,authorities。Authentication 的 getCredentials()与 UserDetails 中的getPassword()需要被区分对待,前者是用户提交的密码凭证,后者是用户实际存储的密码,认证其实就是对这两者的比对。Authentication 中的 getAuthorities()实际是由 UserDetails 的 getAuthorities()传递而形成的。还记得 Authentication 接口中的getDetails()方法吗?其中的 UserDetails 用户详细信息便是经过了 AuthenticationProvider 认证之后被填充的。

通过实现 UserDetailsService 和 UserDetails,我们可以完成对用户信息获取方式以及用户信息字段的扩展。

Spring Security 提供的 InMemoryUserDetailsManager(内存认证),JdbcUserDetailsManager(jdbc认证)就是 UserDetailsService 的实现类,主要区别无非就是从内存还是从数据库加载用户。

自定义 UserDetailsService

@Service 
public class MyUserDetailsService implements UserDetailsService { 
  @Override 
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { 
    //登录账号 
    System.out.println("username="+username); 
    //根据账号去数据库查询... 
    //这里暂时使用静态数据 
    UserDetails userDetails = 
      User.withUsername(username).password("123").authorities("p1").build(); 
    return userDetails; 
  } 
} 

PasswordEncoder

DaoAuthenticationProvider 认证处理器通过 UserDetailsService 获取到 UserDetails 后,它是如何与请求 Authentication 中的密码做对比呢?

在这里 Spring Security 为了适应多种多样的加密类型,又做了抽象,DaoAuthenticationProvider 通过 PasswordEncoder 接口的matches 方法进行密码的对比,而具体的密码对比细节取决于实现:

public interface PasswordEncoder {
    String encode(CharSequence var1);

    boolean matches(CharSequence var1, String var2);

    default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }
}

而 Spring Security 提供很多内置的 PasswordEncoder,能够开箱即用,使用某种 PasswordEncoder 只需要进行如下声明即可,如下:

@Bean 
public PasswordEncoder passwordEncoder() { 
    return  NoOpPasswordEncoder.getInstance(); 
} 

NoOpPasswordEncoder 采用字符串匹配方法,不对密码进行加密比较处理,密码比较流程如下:

1、用户输入密码(明文 )

2、DaoAuthenticationProvider 获取 UserDetails(其中存储了用户的正确密码)

3、DaoAuthenticationProvider 使用 PasswordEncoder 对输入的密码和正确的密码进行校验,密码一致则校验通过,否则校验失败。

NoOpPasswordEncoder 的校验规则拿输入的密码和 UserDetails 中的正确密码进行字符串比较,字符串内容一致则校验通过,否则 校验失败。

实际项目中首选 BCryptPasswordEncoder,在安全配置中定义:

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

测试发现认证失败,提示:Encoded password does not look like BCrypt。

原因:

由于UserDetails 中存储的是原始密码(比如:123),它不是BCrypt格式。

测试BCrypt

@SpringBootTest
public class BCryptTest {

    @Test
    public void getBCryptCode() {
        String hashpw = BCrypt.hashpw("123", BCrypt.gensalt());
        System.out.println(hashpw);
        boolean checkpw = BCrypt.checkpw("123", hashpw);
        System.out.println(checkpw);
    }
}

输出结果为:

$2a$10$tkLR.8WiDh5dsRd6Hlkw/OrN4SWJ54pPGLWlfn/TJvXsxbDPLsHgS
true

实际项目中存储在数据库中的密码并不是原始密码,都是经过加密处理的密码。

经过一系列的认证流程后,假设认证成功后,加载 UserDetails 来封装要返回的 Authentication 对象,加载的 UserDetails 对象是包含用户权限等信息的。认证成功返回的 Authentication 对象将会保存在当前的 SecurityContext 中,供我们后续使用。

授权流程

授权流程

Spring Security 可以通过 http.authorizeRequests() 对web请求进行授权保护。Spring Security 使用标准Filter建立了对web请求的拦截,最终实现对资源的授权访问。授权流程如下:

授权流程图

分析授权流程:

  1. 拦截请求,已认证用户访问受保护的web资源将被 SecurityFilterChain 中的 FilterSecurityInterceptor 的子类拦截。

  2. 获取资源访问策略,FilterSecurityInterceptor 会从 SecurityMetadataSource 的子类 DefaultFilterInvocationSecurityMetadataSource 获取要访问当前资源所需要的权限 Collection 。

SecurityMetadataSource 其实就是读取访问策略的抽象,而读取的内容,其实就是我们配置的访问规则, 读取访问策略如:

http.csrf().disable()   //屏蔽CSRF控制,即spring security不再限制CSRF
.authorizeRequests()
.antMatchers(	"/r/r1").hasAuthority("p1")
.antMatchers("/r/r2").hasAuthority("p2")
  1. 最后,FilterSecurityInterceptor 会调用 AccessDecisionManager 进行授权决策,若决策通过,则允许访问资 源,否则将禁止访问。

AccessDecisionManager(访问决策管理器)的核心接口如下:

public interface AccessDecisionManager {
   /**   
   * 通过传递的参数来决定用户是否有访问对应受保护资源的权限   
   */   
    void decide(Authentication authentication , Object object, Collection<ConfigAttribute> 
configAttributes ) throws AccessDeniedException, InsufficientAuthenticationException;
 //略..     
}

这里着重说明一下decide的参数:

  • authentication:要访问资源的访问者的身份
  • object:要访问的受保护资源,web请求对应FilterInvocation
  • configAttributes:是受保护资源的访问策略,通过SecurityMetadataSource获取。

decide接口就是用来鉴定当前用户是否有访问对应受保护资源的权限。

授权决策

1、AccessDecisionManager,为web和方法安全性提供访问决。AccessDecisionManagerAbstractSecurityInterceptor调用,负责做出最终的访问控制决策。AccessDecisionManager 接口包含三种方法:

package org.springframework.security.access;
public interface AccessDecisionManager {
  void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException;

  boolean supports(ConfigAttribute attribute);

  boolean supports(Class<?> clazz);
}

AccessDecisionManagerdecide 方法传递了它所需的所有相关信息,以便做出授权决定。特别是,传递 secure Object可以检查实际安全对象调用中包含的那些参数。例如,假设安全对象是 MethodInvocation。查询 MethodInvocation 任何Customer参数很容易,然后在AccessDecisionManager 中实现某种安全逻辑,以确保允许委托人对该客户进行操作。如果访问被拒绝,预计实现将抛出AccessDeniedException

AbstractSecurityInterceptor在启动时调用supports(ConfigAttribute)方法来确定AccessDecisionManager是否可以处理传递的ConfigAttribute。安全拦截器实现调用supports(Class)方法以确保配置的AccessDecisionManager支持安全拦截器将呈现的安全对象的类型。

2、AccessDecisionVoter

package org.springframework.security.access;
public interface AccessDecisionVoter<S> {
  int ACCESS_GRANTED = 1;
  int ACCESS_ABSTAIN = 0;
  int ACCESS_DENIED = -1;

  boolean supports(ConfigAttribute attribute);

  boolean supports(Class<?> clazz);

  int vote(Authentication authentication, S object, Collection<ConfigAttribute> attributes);
}

具体实现返回int,可能的值反映在AccessDecisionVoter静态字段ACCESS_ABSTAINACCESS_DENIEDACCESS_GRANTED中。如果投票实施对授权决定没有意见,则返回ACCESS_ABSTAIN。如果确实有意见,则必须返回ACCESS_DENIEDACCESS_GRANTED

AccessDecisionVoter 是一个投票器,投票器会检查用户身份具备应有的角色,进而投出赞成、反对或者弃权票;AccessDecisionManager 则是一个决策器,来决定此次访问是否被允许。AccessDecisionVoter 和 AccessDecisionManager 都有众多的实现类,在 AccessDecisionManager 中会挨个遍历 AccessDecisionVoter,进而决定是否允许用户访问,因而 AccessDecisionVoter 和 AccessDecisionManager 两者的关系类似于 AuthenticationProvider 和 ProviderManager 的关系。

![进入决策投票](Spring Security进阶使用.assets/access-decision-voting.png)

Spring Security 提供的最常用的 AccessDecisionVoter 是简单的 RoleVoter,它将配置属性视为简单的角色名称,并在用户被分配了该角色时授予访问权限。

在 Spring Security 中,用户请求一个资源(通常是一个网络接口或者一个 Java 方法)所需要的角色会被封装成一个 ConfigAttribute 对象,在 ConfigAttribute 对象中只有一个 getAttribute 方法,该方法返回一个 String 字符串,就是角色的名称。如果任何 ConfigAttribute 以前缀 ROLE_开头,它将投票。如果有 GrantedAuthority 返回String表示(通过getAuthority()方法)完全等于从前缀ROLE_开始的一个或多个ConfigAttributes,它将投票授予访问权限。如果与ROLE_开头的任何ConfigAttribute没有完全匹配,则RoleVoter将投票拒绝访问。如果没有ConfigAttributeROLE_开头,选民将弃权。

总结

关于 Spring Security 的理论学习暂时先到这一步,后续还有几篇文章就是实际应用相关的。关于 Spring Security 的认证与授权工作原理分析会有些枯燥,后续会结合项目进行讲解。最后还是推荐大家读一下《深入浅出Spring Security》这本书。

参考文献

Spring Security -- Spring Boot中开启Spring Security

Spring Security详解(一)认证之核心组件和服务

Spring Security 详解

《深入浅出Spring Security》

Spring Security 入门篇