Spring Security进阶学习

news2024/11/27 18:29:13

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 的关系。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sd3OFxId-1669625205160)(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 入门篇

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/88465.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

同城预约小程序上门服务上门理疗推拿按摩系统养生美容行业程序源码

在这个工作生活压力巨大的社会&#xff0c;大家恨不得一分钟掰成两半过&#xff0c;别提什么休闲娱乐了&#xff0c;能睡个饱觉就已经是奢侈了&#xff01;工作固然重要&#xff0c;身心的放松也需要重视&#xff0c;好在随着互联网&#xff0b;的发展&#xff0c;越来越多的行…

PMO在企业项目管理中的五个重要作用

PMO项目管理办公室是成功企业的关键管理工具。它对于推动项目的发展至关重要&#xff0c;以下是PMO的五个重要作用&#xff1a; 1、项目管理过程的标准化 PMO的主要目标在于方法、流程和工具的创建和标准化。 PMO 可能创建的模板包括&#xff1a; • 项目建议书模板。这有…

Java Optional 实用判空实用实战,优雅永不过时

平时我们很多实体类里面会嵌套实体类&#xff0c;实体里面还嵌套实体。 那么我们有时为了取出最里面的实体的某个值的时候&#xff0c;我们就不得不一层层剥开这个 让人流泪的洋葱&#xff0c; 一层层判断。 举例&#xff08;随便举的&#xff09;&#xff1a; 就像这么一个…

关于迭代器遍历及auto关键词

在使用vector容器或者字符串时&#xff0c;很经常会用到一些遍历操作&#xff0c;除了使用下标遍历之外&#xff0c;使用迭代器遍历也是超级方便&#xff0c;但是迭代器也有有一些小坑&#xff0c;一不注意就会编译出错&#xff0c;所以特意总结一下。 迭代器 迭代器很很多接口…

性能测试之nginx监控系统搭建

不同tomcat服务器的负载均衡 在Nginx服务器192.168.43.138上安装Nginx&#xff0c;&#xff08;安装教程在前几篇文章有详细描述 &#xff09;实现反向代理tomcat负载均衡 执行一下命令&#xff0c;关闭防火墙 systemctl disable firewalld.service systemctl stop firewall…

[附源码]Node.js计算机毕业设计防疫科普微课堂Express

项目运行 环境配置&#xff1a; Node.js最新版 Vscode Mysql5.7 HBuilderXNavicat11Vue。 项目技术&#xff1a; Express框架 Node.js Vue 等等组成&#xff0c;B/S模式 Vscode管理前后端分离等等。 环境需要 1.运行环境&#xff1a;最好是Nodejs最新版&#xff0c;我…

从外包被裁到拿到阿里Offer,多亏P8架构师的全套面试文档

引言 又是一年跳槽季&#xff0c;在疫情的影响下&#xff0c;今年的金三银四冷清不少。但无论如何&#xff0c;2020年招聘市场已经显示出了一个清晰的趋势&#xff0c;java开发岗面试越来越难&#xff0c;需求越来越少&#xff01;也更增加了游戏的“难度系数”。 跳槽时时刻刻…

99-数据结构与算法(上篇)

数据结构与算法数据结构和算法&#xff0c;一个非常古老的课题&#xff0c;工作的时候&#xff0c;一般只求程序能跑&#xff0c;并不太关注性能 一般情况下&#xff0c;我们尽量避坑&#xff0c;即避免这样&#xff1a;ArrayList Or LinkedList&#xff0c;哪个简单用哪个 实…

【Kubernetes】一主二从环境搭建,详细的图文描述

kubernetes&#xff0c;是一个全新的基于容器技术的分布式架构领先方案&#xff0c;是谷歌严格保密十几年的秘密武器----Borg系统的一个开源版本&#xff0c;于2014年9月发布第一个版本&#xff0c;2015年7月发布第一个正式版本。 kubernetes的本质是一组服务器集群&#xff0…

使用Java API操作HDFS

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 文章目录学习目标&#xff08;一&#xff09;了解HDFS Java API1、HDFS常见类与接口2、FileSystem的常用方法&#xff08;二&#xff09;编写Java程序访问HDFS1、创建Maven项…

Nacos 配置中心之长轮询--服务端

先回顾一下客户端和服务端交互的过程 服务端 入口 直接看长轮询的接口 ConfigController.listener PostMapping("/listener")Secured(action ActionTypes.READ, parser ConfigResourceParser.class)public void listener(HttpServletRequest request, HttpServ…

抓住三个关键因素,提高你的ASA广告效果!

​ 众所周知&#xff0c;App Store 作为 iOS 端的流量收口&#xff0c;旗下的 ASA 广告更是广告主在 iOS 生态投放广告的唯一渠道&#xff0c;所提供的四大广告位&#xff08;Today 标签、搜索标签、搜索结果和产品页面&#xff09;覆盖了用户访问的全路径&#xff0c;为广告主…

12月14日:跟着猫叔写代码api中的增删改查

首先在数据库中建立一个学生成绩信息表 DROP TABLE IF EXISTS bro_ceshiapi; CREATE TABLE bro_ceshiapi (id int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT id,name varchar(100) DEFAULT NULL COMMENT 姓名,class varchar(100) DEFAULT NULL COMMENT 班级,score decima…

[附源码]Python计算机毕业设计Django基于vuejs的文创产品销售平台app

项目运行 环境配置&#xff1a; Pychram社区版 python3.7.7 Mysql5.7 HBuilderXlist pipNavicat11Djangonodejs。 项目技术&#xff1a; django python Vue 等等组成&#xff0c;B/S模式 pychram管理等等。 环境需要 1.运行环境&#xff1a;最好是python3.7.7&#xff0c;…

学习Vue3 - 认识 Reactive 全家桶

reactive 用来绑定复杂的数据类型&#xff0c;例如&#xff1a;对象、数组 reactive 源码约束了我们的类型 他是不可以绑定普通的数据类型的&#xff0c;这样是不允许的&#xff0c;会报错 因此&#xff0c;如果绑定普通的数据类型&#xff0c;可以使用ref ref绑定对象或者…

计算机SCI论文,如何写吸引人的摘要? - 易智编译EaseEditing

摘要简明扼要的概括全文的主要内容&#xff0c;是整篇文章的精华&#xff0c;是编辑、审稿专家以及读者阅读文章的最先关注的部分。 一个好的摘要可以正确反映文章内容&#xff0c;引起编辑、审稿专家以及读者的关注。那如何写出一个好的论文摘要呢&#xff0c;今天小易为大家…

一种基于摩斯密码的页面加密方法(web和小程序)

1. 开始 web开发中&#xff0c;常有一些功能仅希望对开发、测试人员等一小部分人展示&#xff0c;比如测试一个小程序项目中&#xff0c;想让测试人员快速复制当前对应的h5页面&#xff0c;这时候如果页面是必须登录的&#xff0c;我们可以借助vconsole&#xff0c;然后维护一…

redis之主从切换可能有哪些问题

写在前面 本文一起看下Redis cluster 集群模式下&#xff0c;发生了主从切换时可能存在的问题以及应对方案。 1&#xff1a;主从数据不一致 主从数据不一致&#xff0c;是由于主从同步延迟造成的&#xff0c;可能的解决方案如下&#xff1a; 1&#xff1a;尽量将主从同机房…

React面试:谈谈虚拟DOM,Diff算法与Key机制

1.虚拟dom 原生的JS DOM操作非常消耗性能&#xff0c;而React把真实原生JS DOM转换成了JavaScript对象。这就是虚拟Dom&#xff08;Virtual Dom&#xff09; 每次数据更新后&#xff0c;重新计算虚拟Dom&#xff0c;并和上一次生成的虚拟dom进行对比&#xff0c;对发生变化的…

Ansys Zemax | 用于数字投影光学中均匀照明的蝇眼阵列

简介 在数字投影仪设计中&#xff0c;我们希望确保数字光源与投影图像在辐照度分布相匹配。因此&#xff0c;这一约束要求投影仪设计包含均匀照明的空间光调制器——通常以LCD面板的形式呈现。理论上听起来很容易&#xff0c;但实际上&#xff0c;此面板上的光源光束通常是高斯…