• SpringSecurity详解,实现自定义登录接口


    1 SpringSecurity概述

    1.1 权限框架

    目前市面上比较流行的权限框架主要实ShiroSpring Security,这两个框架各自侧重点不同,各有各的优劣。

    1.1.1 Apache Shiro

    Apache Shiro是一个开源安全框架,提供身份验证、授权、密码学和会话管理。Shiro框架直观、易用,同时也能提供健壮的安全性。

    特点:

    Shiro的特点:

    1. 易于理解的Java Security APl
    2. 简单的身份认证(登录),支持多种数据源(LDAP,JDBC,Kerberos,ActiveDirectory等)
    3. 对角色的简单的签权(访问控制),支持细粒度的签权
    4. 支持一级缓存,以提升应用程序的性能
    5. 内置的基于POJO企业会话管理,适用于Web 以及非 Web的环境
    6. 异构客户端会话访问
    7. 非常简单的加密API
    8. 不跟任何的框架或者容器捆绑,可以独立运行

    1.1.2 SpringSecurity

    Spring Security是一个能够为基于Spring的企业应用系统提供描述性安全访问控制解决方案的安全框架。它提供了一组可以在Spring应用上下文中配置的Bean,充分利用了Spring IoC(依赖注入,也称控制反转)和AOP(面向切面编程)功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作。

    Spring Security是Spring家族中的一个安全管理框架。相比与另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比shiro丰富。一般Web应用的需要进行认证和授权。而认证和授权也是SpringSecurity作为安全框架的核心功能。

    SpringSecurity的特点:

    1. 与Spring Boot集成非常简单。
    2. 功能强大,高度可定制化。
    3. 支持OAuth2.0。
    4. 强大的加密ARI。
    5. 防止跨站请求伪造攻击(CSRF)。
    6. 提供Spring Cloud分布式组件。

    1.2 授权和认证

    一般来说,Web应用的安全性包括用户认证(Authentication)和用户授权(Authorization)两个部分,这两点也是SpringSecurity重要核心功能。

    1)用户认证:验证某个用户是否为系统中的合法主体,也就是说用户能否访问该系统。用户认证一般要求用户提供用户名和密码。系统通过校验用户名和密码来完成认证过程。

    (2)用户授权:是验证某个用户是否有权限执行某个操作。经过认证后判断当前用户是否有权限进行某个操作。

    RBAC (Role Based Access Control)基于角色的访问控制,通过抽象出“用户、角色、权限”"三个概念,实现用户分配角色,角色分配权限的权限管理方式,也是目前企业中权限管理主要实现方案。

    案例如下图所示:

    在这里插入图片描述

    1.3 SpringSecurity的功能

    Spring Security对Web安全性的支持大量地依赖于Servlet过滤器。这些过滤器拦截进入请求,并且在应用程序处理该请求之前进行某些安全处理。 Spring Security提供有若干个过滤器,它们能够拦截Servlet请求,并将这些请求转给认证和访问决策管理器处理,从而增强安全性。

    如今的Spring Security已经成为Spring Framework下最成熟的安全系统,它为我们提供了强大而灵活的企业级安全服务,如:

    • 认证授权机制

    • Web资源访问控制

    • 业务方法调用访问控制

    • 领域对象访问控制Access Control List(ACL)

    • 单点登录(Central Authentication Service)

    • 信道安全(Channel Security)管理等功能

    2 认证原理及流程

    2.1 项目引入SpringSecurity

    项目中单纯整合SpringSecurity很简单,先添加spring-boot-starter-security依赖

    <dependency>
       <groupId>org.springframework.bootgroupId>
       <artifactId>spring-boot-starter-securityartifactId>
    dependency>
    
    • 1
    • 2
    • 3
    • 4

    引入依赖后我们在尝试访问系统的任何接口就会自动跳转到一个SpringSecurity的默认登陆页面,默认用户名是user,密码会输出在控制台。必须登陆之后才能对接口进行访问。

    在这里插入图片描述

    那是添加了该依赖如何就能实现对所有接口的拦截呢?

    SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。我们看下一个简单的过滤器链:

    在这里插入图片描述

    UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要有它负责。

    ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException。

    FilterSecurityInterceptor:负责权限校验的过滤器。

    以上只是列举了主要的几个过滤器,详细流程如下。

    2.2 认证流程详解

    在这里插入图片描述

    Authentication接口:它的实现类,表示当前访问系统的用户,封装了用户相关信息。

    AuthenticationManager接口:定义了认证Authentication的方法。

    UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法loadUserByUsername(String username)

    UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。

    可以看到整个过程过滤器UsernamePasswordAuthenticationFilter先拦截用户的请求,调用AuthenticationManager接口的authenticate方法将用户信息传递;调用AuthenticationProvider接口(AbstractUserDetailsAuthenticationProvider是它的实现类,DaoAuthenticationProvider是实现类的实现类);在调用userDetailsServiceloadUserByUsername()方法根据用户名查询对应用户和权限信息,默认走的是InMemoryUserDetailsManager实现类,及在内存中查找;将信息封装成对象一层层返回。

    可以看到众多过滤器一层接一层。那我们如果要自己实现登录接口,从哪介入呢?

    能介入最主要的地方一个是开头一个是UserDetailsService层。

    3 自定义登录接口

    3.1 理论讲解

    要实现自定义登录,最主要是两个地方:

    • 要在自己写的页面手动调用登录接口同时还要进行附加操作。
    • UserDetailsService层不可能从内存中查询用户,需要跟实际的权限数据库关联。

    对应的解决方法如下:

    • 编写登录接口,在其中调用ProviderManager.authenticate()方法。该接口需要再未登录的情况下可调用,所以要放开权限。
    • 编写个UserDetailsService的实现类,重写loadUserByUsername()方法,在改方法中根据传来的用户名去自己的权限数据库查询用户信息。封装到用户实体类中。

    完整流程如下:

    在前后端分离中我们一般采用token验证,先调用我们要自定义登录接口,通过自定义接口调用调用ProviderManager的方法进行认证 ,如果认证通过生成jwt,然后将jwt返回给前端,同时将用户信息包括用户权限信息等存入到redis数据库中,redis数据库作为缓存读取速度远远大于从数据库中进行读取用户信息。然后通过我们创建的UserDetailsServiceImpl实现类中去查询数据库。除此之外我们还需要定义JWT认证过滤器,从前端请求头中获取token,解析token获取其中的userid,然后根据userid从redis中获取用户信息存入SecurityContextHolder。具体流程如下图:

    在这里插入图片描述

    3.2 代码实战

    1. 定义一个UserDetails的实体类用来存放用户信息。

      import lombok.AllArgsConstructor;
      import lombok.Data;
      import lombok.NoArgsConstructor;
      import org.apache.commons.logging.Log;
      import org.apache.commons.logging.LogFactory;
      import org.springframework.security.core.GrantedAuthority;
      import org.springframework.security.core.SpringSecurityCoreVersion;
      import org.springframework.security.core.userdetails.UserDetails;
      
      import java.io.Serializable;
      import java.util.Collection;
      
      @Data
      @AllArgsConstructor
      @NoArgsConstructor
      public class User implements UserDetails, Serializable {
          private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
      
          private static final Log logger = LogFactory.getLog(org.springframework.security.core.userdetails.User.class);
      
          private LoginUser loginUser;
      
      
          @Override
          public Collection<? extends GrantedAuthority> getAuthorities() {
              return null;
          }
      
          @Override
          public String getPassword() {
              return loginUser.getPassword();
          }
      
          @Override
          public String getUsername() {
              return loginUser.getUsername();
          }
      
          @Override
          public boolean isAccountNonExpired() {
              return true;
          }
      
          @Override
          public boolean isAccountNonLocked() {
              return true;
          }
      
          @Override
          public boolean isCredentialsNonExpired() {
              return true;
          }
      
          @Override
          public boolean isEnabled() {
              return true;
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
      • 39
      • 40
      • 41
      • 42
      • 43
      • 44
      • 45
      • 46
      • 47
      • 48
      • 49
      • 50
      • 51
      • 52
      • 53
      • 54
      • 55
      • 56
      • 57
      • 58
    2. 自己定义UserDetailsService的实现类,重写loadUserByUsername方法,在其中查询自己的数据库。

      import com.project.business.userDetailsService.entity.User;
      import com.project.business.userDetailsService.mapper.UserMapper;
      import org.springframework.security.core.userdetails.UserDetails;
      import org.springframework.security.core.userdetails.UserDetailsService;
      import org.springframework.security.core.userdetails.UsernameNotFoundException;
      
      import javax.annotation.Resource;
      
      public class UserDetailsServiceImpl implements UserDetailsService {
      
          @Resource
          private UserMapper userMapper;
      
          @Override
          public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
              LoginUser loginUser = userMapper.loadUserByUsername(username);
              //查询不到该用户信息抛异常
              if(loginUser == null) {
                  throw new RuntimeException("用户名或者密码错误");
              }
              User user = new User(loginUser);
              return user;
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
    3. 自定义登录接口

      我们需要自定义登陆接口,然后让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问。

      在接口中我们通过AuthenticationManagerauthenticate方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器。认证成功的话要生成一个jwt即token,放入响应中返回。并且为了让用户下回请求时能通过jwt识别出具体的是哪个用户,我们需要把用户信息存入redis有效时间为30分钟,可以把用户id作为key。登录后在访问时,后台会根据token解析出用户的userid去redis中查询,判断用户是否登陆过。

      登录接口代码如下:

      @RestController
      @RequestMapping("/user")
      public class LoginController {
      
          @Autowired
          private RedisTemplate redisTemplate;
      
          @Autowired
          private AuthenticationManager authenticationManager;
      
          private static final String USER_PREFIX = "login:";
      
      
          @PostMapping("/login")
          public Result login(@RequestBody LoginUser loginUser) {
              //通过AuthenticationManager的authenticate方法来进行用户认证
              UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(loginUser.getUsername(), loginUser.getPassword());
              Authentication authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
              if (authenticate == null) {
                  return Result.error(401, "登录校验失败");
              } else {
                  //获取用户信息
                  User user = (User) authenticate.getPrincipal();
                  //获取用户id
                  Long id = user.getLoginUser().getId();
                  //根据用户id生成token
                  String token = JwtUtil.createJWT(id.toString());
                  //将token放在redis中
                  redisTemplate.opsForValue().set(USER_PREFIX + String.valueOf(id),user,30, TimeUnit.MINUTES);
                  return Result.ok("登录成功,返回token").put("token", token);
              }
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33

      让Security放开user/login访问权限

      public class SecurityConfig extends WebSecurityConfigurerAdapter {
          @Bean
          @Override
          public AuthenticationManager authenticationManagerBean() throws Exception {
              return super.authenticationManagerBean();
      
          }
      
          /**
           * @param http
           * @throws Exception
           */
      
          @Override
          protected void configure(HttpSecurity http) throws Exception {
      
              http
                      //关闭csrf
                      .csrf().disable()
                      //不通过Session获取SecurityContext
                      .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                      .and()
                      .authorizeRequests()
                      // 对于登录接口 允许匿名访问
                      .antMatchers("/user/login").anonymous()
                      // 除上面外的所有请求全部需要鉴权认证
                      .anyRequest().authenticated();
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
    4. 后续访问接口token认证

      至此第一次登录就成功了,那之后调用接口怎么能判断是登陆过的不会再被springsecurity拦截了呢。

      思路:

      之后所有发送的请求,请求头中会带着token,需要自定义一个优先级靠前的过滤器,这个过滤器会去获取请求头中的token,对token进行解析取出其中的userid,去上一步存到的redis(或其他库)中查询,如果能查到代表之前登陆过了不需要再认证,封装Authentication对象存入SecurityContextHolder,之后springsecurity自己的过滤器会通过SecurityContextHolder获取信息判断是否登陆过;如果没查到说明没登录或登录过期,需要再次认证。

      过滤器

      @Component
      public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
      
          @Resource
          private RedisTemplate redisTemplate;
      
          private static final String USER_PREFIX = "login:";
      
          @Override
          protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
              //获取token
              String token = request.getHeader("token");
              if (!StringUtils.hasText(token)) {
                  //放行
                  filterChain.doFilter(request, response);
                  return;
              }
              //解析token
              String userid;
              try {
                  Claims claims = JwtUtil.parseJWT(token);
                  userid = claims.getSubject();
              } catch (Exception e) {
                  e.printStackTrace();
                  throw new RuntimeException("token非法");
              }
              //从redis中获取用户信息
              String redisKey = USER_PREFIX + userid;
              JSONObject jsonObject = (JSONObject) redisTemplate.opsForValue().get(redisKey);
              User user = JSONObject.parseObject(jsonObject.toJSONString(), User.class);
              if(Objects.isNull(user)){
                  throw new RuntimeException("用户未登录");
              }
              //存入SecurityContextHolder
              //获取权限信息封装到Authentication中
              UsernamePasswordAuthenticationToken authenticationToken =
                      new UsernamePasswordAuthenticationToken(user,null,null);
              SecurityContextHolder.getContext().setAuthentication(authenticationToken);
              //放行
              filterChain.doFilter(request, response);
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
      • 39
      • 40
      • 41
      • 42

      要把过滤器添加进配置,并且放到UsernamePasswordAuthenticationFilter前面,因为UsernamePasswordAuthenticationFilter过滤器是security过滤器链首位的,在他之前就要把用户状态存进SecurityContextHolder

      @Configuration
      public class SecurityConfig extends WebSecurityConfigurerAdapter {
          @Resource
          private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
          @Override
          protected void configure(HttpSecurity http) throws Exception {
              //此行要和上面配置文件中该方法中的内容写在一块,此处为了省略篇幅不都粘过来了
              http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10

    3.3 接口测试

    在调用user/login登录接口之前其他接口都访问不了,汇报403,Forbidden。此时调用登录接口。
    在这里插入图片描述
    在这里插入图片描述

    可以看到,登录接口调用成功,返回一个根据userId生成的token,且redis中存储了登录用户的信息,30分钟过期。在这30分钟内,携带请求头token就可以正常访问其他接口。
    在这里插入图片描述
    更换加密方式

    上述示例中采用的是明文方式比对。测试的时候在密码前加{noop}代表明文比对。

    1. 实际项目中一般不会在数据库中存储明文,会对其进行加密,就需要替换PasswordEncoder。

    2. 我们一般使用SpringSecurity为我们提供的BCryptPasswordEncoder

    3. 我们只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验。

    4. 我们可以在刚才创建的SecurityConfig配置类中注入BCryptPasswordEncoder对象。

      @Configuration
      public class SecurityConfig extends WebSecurityConfigurerAdapter {
      
          @Bean
          public PasswordEncoder passwordEncoder(){
              return new BCryptPasswordEncoder();
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8

    ​ 可以先用其加密入库,再次登录依旧对比成功。
    在这里插入图片描述

  • 相关阅读:
    mysql同步数据到es
    C#【疑难杂症篇】CS0012:必须添加对程序集“netstandard, Version=2.0.0.0, Culture=neutral...”的引用
    JavaScript 初学( 十七 ) - JS HTML DOM
    垃圾回收器-G1垃圾回收器详解
    C++保姆级入门教程(9)—— 一维数组基础
    Ubuntu挂载windows下的共享文件夹
    2020 ICPC银川 个人题解
    【Python从入门到进阶】35、selenium基本语法学习
    自定义类型:结构体----初学者笔记
    教育行业的网络安全:保护学生数据与防范网络欺凌
  • 原文地址:https://blog.csdn.net/qq_43331014/article/details/134085852