springboot3+springsecurity+redis 整合登录认证以及权限校验

发布于:2024-05-10 ⋅ 阅读:(24) ⋅ 点赞:(0)

1. 架构说明

整体架构如下(提供的对应的模块引入),围绕着springsecurity中的三大核心展开:

​ 1、Authentication:存储了认证信息,代表当前登录用户

​ 2、SeucirtyContext:上下文对象,用来获取Authentication

​ 3、SecurityContextHolder:上下文管理对象,用来在程序任何地方获取SecurityContext

重点放在FilterChain的编写上,引入认证的和授权的Filter,并通过调用AuthenticationManager.authenticate和AuthenticationProvider中 写入的加密器、userdetailservice做认证

在这里插入图片描述

2. 依赖引入

由于需要在登录时查询数据库所以需要引入对数据库操作的依赖

<dependencies>
      <!--mybatis和springboot整合-->
      <dependency>
          <groupId>org.mybatis.spring.boot</groupId>
          <artifactId>mybatis-spring-boot-starter</artifactId>
      </dependency>
      <!--Mysql数据库驱动8 -->
      <dependency>
          <groupId>mysql</groupId>
          <artifactId>mysql-connector-java</artifactId>
      </dependency>
      <!--persistence-->
      <dependency>
          <groupId>javax.persistence</groupId>
          <artifactId>persistence-api</artifactId>
      </dependency>
      <!--通用Mapper4-->
      <dependency>
          <groupId>tk.mybatis</groupId>
          <artifactId>mapper</artifactId>
      </dependency>
      <!--SpringBoot集成druid连接池-->
      <dependency>
          <groupId>com.alibaba</groupId>
          <artifactId>druid-spring-boot-starter</artifactId>
      </dependency>
      <!--lombok-->
      <dependency>
          <groupId>org.projectlombok</groupId>
          <artifactId>lombok</artifactId>
          <version>1.18.28</version>
          <scope>provided</scope>
      </dependency>
      <!--cloud_commons_utils-->
      <dependency>
          <groupId>com.simple.cloud</groupId>
          <artifactId>simpleCloud_api_commons</artifactId>
          <version>1.0-SNAPSHOT</version>
      </dependency>
      <!-- Spring Security依赖 -->
      <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-security</artifactId>
      </dependency>
      <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-web</artifactId>
          <scope>provided </scope>
      </dependency>

      <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-data-redis</artifactId>
      </dependency>
  </dependencies>

3. application.yml配置文件编写

spring:
  data:
    redis: # redis使用
      host: localhost
      port: 6379
      database: 0
      timeout: 1800000
      password:
      jedis:
        pool:
          max-active: 20 #最大连接数
          max-wait: -1    #最大阻塞等待时间(负数表示没限制)
          max-idle: 5    #最大空闲
          min-idle: 0     #最小空闲

  datasource:
    type: com.alibaba.druid.pool.DruidDataSource 
    driver-class-name: com.mysql.cj.jdbc.Driver 
    # 注意修改为用户存在的数据库
    url: jdbc:mysql://localhost:3306/对应库名称?characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true
    username: root
    password: abc123

# ========================mybatis===================
mybatis:
  mapper-locations: classpath:mapper/*.xml
  type-aliases-package: com.atguigu.cloud.entities
  configuration:
    map-underscore-to-camel-case: true

3. config核心配置文件

注意:security以及摒弃了之前继承 WebSecurityConfigurerAdapter 的方法,鼓励开发者自己写配置类将bean注入容器中,所以之前的springboot2的整合方法已经不好用啦,但是不用慌,大体上的实现还是一样的,毕竟只是换了一个壳子😁

@Configuration
@EnableWebSecurity //@EnableWebSecurity是开启SpringSecurity的默认行为
public class WebSecurityConfig{
}

3.1 加密器引入

自定义一个Md5 加密器

@Component
public class CustomMd5PasswordEncoder implements PasswordEncoder {
    public String encode(CharSequence rawPassword) {
        return MD5Helper.encrypt(rawPassword.toString());
    }

    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        return encodedPassword.equals(MD5Helper.encrypt(rawPassword.toString()));
    }
}

3.1.1 MD5Helper

public final class MD5Helper {

    public static String encrypt(String strSrc) {
        try {
            char hexChars[] = { '0', '1', '2', '3', '4', '5', '6', '7', '8',
                    '9', 'a', 'b', 'c', 'd', 'e', 'f' };
            byte[] bytes = strSrc.getBytes();
            MessageDigest md = MessageDigest.getInstance("MD5");
            md.update(bytes);
            bytes = md.digest();
            int j = bytes.length;
            char[] chars = new char[j * 2];
            int k = 0;
            for (int i = 0; i < bytes.length; i++) {
                byte b = bytes[i];
                chars[k++] = hexChars[b >>> 4 & 0xf];
                chars[k++] = hexChars[b & 0xf];
            }
            return new String(chars);
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
            throw new RuntimeException("MD5加密出错!!+" + e);
        }
    }
}

3.1.2 config中注入依赖

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

3.2 UserDetailServer 引入

直接使用Lambda表达式在config中注入

@Bean
public UserDetailsService userDetailsService(){
    // 调用 JwtUserDetailService实例执行实际校验
    return email -> {
        //实际上用email进行匹配 从数据库中获取
        SysUser authUser = sysUserMapper.getUserByEmail(email);

        if(null == authUser) {
            throw new UsernameNotFoundException("邮箱不存在!");
        }

        return new CustomUser(authUser, Collections.emptyList());
    };
}

基于mybatis实现对数据库的查找功能 —

3.2.1 sysUserMapper.java

public interface SysUserMapper extends Mapper<SysUser> {

    // 根据email获取用户
    SysUser getUserByEmail(String email);
}

3.2.2 sysUserMapper.xml 实现类

<?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.simple.cloud.mapper.SysUserMapper">
  <resultMap id="BaseResultMap" type="com.simple.cloud.entities.SysUser">
    <id column="id" jdbcType="BIGINT" property="id" />
    <result column="username" jdbcType="VARCHAR" property="username" />
    <result column="email" jdbcType="VARCHAR" property="email" />
    <result column="password" jdbcType="VARCHAR" property="password" />
    <result column="phone" jdbcType="VARCHAR" property="phone" />
    <result column="name" jdbcType="VARCHAR" property="name" />
    <result column="is_deleted" jdbcType="TINYINT" property="isDeleted" />
  </resultMap>

  <!-- // 根据email获取用户
    SysUser getUserByEmail(String email);-->
  <select id="getUserByEmail" resultMap="BaseResultMap">
    SELECT * FROM system_user WHERE email = #{email} AND is_deleted = 0
  </select>
</mapper>

3.3 config中引入AuthenticationProvider

有了我们的加密器和userdetail后就可以引入AuthenticationProvider

在安全框架中,AuthenticationProvider 是一个接口或类,用于处理身份验证逻辑。它通常用于验证用户的身份信息,例如用户名和密码、令牌或其他认证凭据。

当用户尝试进行身份验证时,系统会将用户的认证请求传递给相应的 AuthenticationProvider。该提供者负责根据配置的规则和算法来验证用户提供的凭据是否有效。如果验证成功,AuthenticationProvider 通常会创建一个表示经过身份验证的主体(如用户)的 Authentication 对象,并返回该对象以供进一步处理。这个 Authentication 对象包含了主体的相关信息,如用户名、角色、权限等。

/**
 * 调用loadUserByUsername获得UserDetail信息,在AbstractUserDetailsAuthenticationProvider里执行用户状态检查
 *
 * @return
 */
@Bean
public AuthenticationProvider authenticationProvider() {
    DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
    // DaoAuthenticationProvider 从自定义的 userDetailsService.loadUserByUsername 方法获取UserDetails
    authProvider.setUserDetailsService(userDetailsService());
    // 设置密码编辑器
    authProvider.setPasswordEncoder(passwordEncoder());
    return authProvider;
}

3.4 AuthenticationManager

上面一点提到了Authentication ,其中包含了

​ 1、Principal:用户信息,没有认证时一般是用户名,认证后一般是用户对象

​ 2、Credentials:用户凭证,一般是密码(在获取用户权限后会销毁,以保证安全,防止泄露)

​ 3、Authorities:用户权限

而在security中使用AuthenticationManager 来对其执行校验,所以需要引入相应的bean

/**
 * 登录时需要调用AuthenticationManager.authenticate执行一次校验
 *
 * @param config
 * @return
 * @throws Exception
 */
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
    return config.getAuthenticationManager();
}

3.5 两个过滤器

在完成上面的配置之后就到了实际处理请求的filter了,我们知道springsecurity就是由一系列的filter构成,具体引入的如下两个

3.5.1 TokenLoginFilter 登录认证

本案例中用email , password做为登录的loginVo(email换成username也是可以的)

@Bean
public TokenLoginFilter authenticationTokenLoginFilter() {
		//这里通过上下文获取容器中的AuthenticationManager
     return new TokenLoginFilter(applicationContext.getBean(AuthenticationManager.class),redisTemplate);
 }
3.5.1.1 TokenLoginFilter 具体实现 继承UsernamePasswordAuthenticationFilter

UsernamePasswordAuthenticationFilter 是 Spring Security 中的一个过滤器,用于处理基于用户名和密码的身份验证请求。它通常与 AuthenticationProvider 一起使用(里面调用的就是刚刚定义的userdetailserivce),以验证用户提供的凭据是否有效。

当用户尝试通过用户名和密码进行身份验证时,系统会将认证请求传递给 UsernamePasswordAuthenticationFilter。该过滤器负责从请求中提取用户名和密码,并将它们封装成一个 Authentication 对象。然后,它会调用配置的 AuthenticationProvider 来验证这个 Authentication 对象。

如果 AuthenticationProvider 验证成功,即用户名和密码匹配,那么 UsernamePasswordAuthenticationFilter 会创建一个表示经过身份验证的主体(如用户)的 Authentication 对象,并将其存储在安全上下文中。这样,后续的请求就可以访问到已认证的用户信息。

public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {

    private RedisTemplate redisTemplate;

    public TokenLoginFilter(AuthenticationManager authenticationManager, RedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.setAuthenticationManager(authenticationManager);
        this.setPostOnly(false);
        //指定登录接口及提交方式,可以指定任意路径
        this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/system/loginCon/login","POST"));
    }

    /**
     * 登录认证
     * @param req
     * @param res
     * @return
     * @throws AuthenticationException
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res)
            throws AuthenticationException {
        try {
            LoginVo loginVo = new ObjectMapper().readValue(req.getInputStream(), LoginVo.class);

            Authentication authenticationToken = new UsernamePasswordAuthenticationToken(loginVo.getEmail(), loginVo.getPassword());
            return this.getAuthenticationManager().authenticate(authenticationToken);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

    }

    /**
     * 登录成功
     * @param request
     * @param response
     * @param chain
     * @param auth
     * @throws IOException
     * @throws ServletException
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
                                            Authentication auth) throws IOException, ServletException {
        //获取当前用户
        CustomUser customUser = (CustomUser)auth.getPrincipal();
        //生成token
        String token = JWTHelper.createToken(customUser.getSysUser().getId(),
                customUser.getSysUser().getEmail());

        //获取当前用户权限数据,放到Redis里面 key:id   value:当前用户
        //设置90s过期 即是登录之后会立即请求用户信息调用info() 这里存入的数据只是为了再info()处做双向认证
        //上面的info后面会讲到 , 在具体的业务中请求用户信息包括了权限信息
        redisTemplate.opsForValue().set(customUser.getSysUser().getId()+"——BUSINESS_KEY",
                JSON.toJSONString(customUser.getSysUser()) , 90 , TimeUnit.SECONDS);

        //返回token给前端(或者登录请求的调用者)
        Map<String,Object> map = new HashMap<>();
        map.put("token",token);

        ResponseUtil.out(response, ResultData.success(map));
    }

    /**
     * 登录失败
     * @param request
     * @param response
     * @param e
     * @throws IOException
     * @throws ServletException
     */
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                              AuthenticationException e) throws IOException, ServletException {

        ResponseUtil.out(response, ResultData.fail(ResultCodeEnum.RC401.getCode(), e.getMessage()));
    }
}

3.5.2 TokenAuthenticationFilter 权限认证

@Bean
public TokenAuthenticationFilter authenticationJwtTokenFilter() {
    return new TokenAuthenticationFilter(redisTemplate);
}
3.5.2.1 TokenAuthenticationFilter 具体实现 继承OncePerRequestFilter

OncePerRequestFilter是Spring框架中的一个抽象类,它用于确保在一次请求中只执行一次过滤操作。这个类主要用于实现自定义的过滤器,继承OncePerRequestFilter类并重写doFilterInternal方法来实现具体的过滤逻辑。

public class TokenAuthenticationFilter extends OncePerRequestFilter {

    private RedisTemplate redisTemplate;

    public TokenAuthenticationFilter(RedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        logger.info("uri:"+request.getRequestURI());
        //如果是登录接口,直接放行
        if("/system/loginCon/login".equals(request.getRequestURI())) {
            chain.doFilter(request, response);
            return;
        }

        UsernamePasswordAuthenticationToken authentication = getAuthentication(request);
        if(null != authentication) {
        		// 放到security上下文中,有需要的地方就可以获取到
            SecurityContextHolder.getContext().setAuthentication(authentication);
            chain.doFilter(request, response);
        } else {
            ResponseUtil.out(response, ResultData.fail(ResultCodeEnum.RC401.getCode(), ResultCodeEnum.RC401.getMessage()));
        }
    }

    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        //请求头是否有token
        String token = request.getHeader("token");
        if(!StringUtils.isEmpty(token)) {
            String email = JWTHelper.getUsername(token);
            Long userId = JWTHelper.getUserId(token);

            if(!StringUtils.isEmpty(email)) {
                //当前用户信息放到ThreadLocal里面
                LoginUserInfoHelper.setUserId(userId);
                LoginUserInfoHelper.setEmail(email);

                //通过 userId 从redis获取权限数据
                //后续在info中放入 一开始从redis拿到的数据都为null
                List<String> authStringList = (List<String>) redisTemplate.opsForValue().get(userId+"_LOGIN_BUSINESS_STORAGE_");

                //把redis获取字符串权限数据转换要求集合类型 List<SimpleGrantedAuthority>
                if(authStringList != null) {
                    List<SimpleGrantedAuthority> authList = new ArrayList<>();
                    for (String val : authStringList) {
                        authList.add(new SimpleGrantedAuthority(val));
                    }
                    //由于授权信息必须是GrantedAuthority对象所以得转换一下
                    return new UsernamePasswordAuthenticationToken(email,null, authList);
                } else {
                    return new UsernamePasswordAuthenticationToken(email,null, new ArrayList<>());
                }
            }
        }
        return null;
    }
}

3.6 security过滤规则

有了上面两个过滤器后,我们应该以什么样的规则来使用呢? 这就引出config中最重要的一环 SecurityFilterChain的bean注入

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // 禁用basic明文验证
        .httpBasic().disable()
        // 前后端分离架构不需要csrf保护
        .csrf().disable()
        // 禁用默认登录页
        .formLogin().disable()
        // 禁用默认登出页
        .logout().disable()
        // 前后端分离是无状态的,不需要session了,直接禁用。
        .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
                // 允许所有OPTIONS请求
                .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                // 允许直接访问授权登录接口
                .requestMatchers(HttpMethod.POST, "/system/loginCon/login").permitAll()
                // 允许 SpringMVC 的默认错误地址匿名访问
                .requestMatchers("/error").permitAll()
                // 允许任意请求被已登录用户访问,不检查Authority
                .anyRequest().authenticated())
        .authenticationProvider(authenticationProvider())
        // 加我们自定义的过滤器,替代UsernamePasswordAuthenticationFilter
        .addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class)
        // 登录过滤器
        .addFilter(authenticationTokenLoginFilter());

    return http.build();
}

这里就偷个懒些不写logout的实现😭

3.8 获取用户权限信息的方法info()

前面的filter中多次提到这个info方法,那具体实现就放在这里啦,主要的目的是获取到权限信息,而前端调用info接口也可以获取到相应的登录用户信息,前端做他的权限控制,后端也做权限控制双重保障
由于前面在登录成功后TokenLoginFiltersuccessfulAuthentication方法向redis中放入了数据(90s过期时间)理想情况下,前端登录请求一成功,返回状态码200,此时会直接调用info方法再次发送请求,请求相应的用户具体信息(菜单按钮权限等用于渲染页面)而后端会把相应的按钮权限放入redis中,后续有请求匹配就会查找是否有相应的权限来决定是否执行

 /**
  * 获取用户信息
  * @return
  */
 @PostMapping("/info")
 public ResultData info(HttpServletRequest request){
     String token = request.getHeader("token");

     //获取用户id
     Long userId = JWTHelper.getUserId(token);
     //根据id从redis中获取当前用户
     SysUser sysUser = JSON.parseObject((String) redisTemplate.opsForValue().get(userId + "——BUSINESS_KEY"), SysUser.class);

     if(sysUser == null)
         return ResultData.fail(ResultCodeEnum.RC401.getCode(), "当前用户未登录,请登录后再试");

     //获取用户的角色
     List<SysRole> sysRoles = sysUserService.selectAllByUserId(userId);
     sysUser.setRoleList(sysRoles);

     //根据id获取所有菜单列表
     List<RouterVo> routerList = sysMenuService.getAllRouterListByUserId(userId);

     //根据id获取所有按钮列表
     List<String> permsList = sysMenuService.getAllMenuListByUserId(userId);

     //map中插入相应的值
     Map<String, Object> map = new HashMap<>();
     map.put("routers",routerList);
     map.put("buttons",permsList);
     map.put("roles",sysUser.getRoleList());
     map.put("name",sysUser.getName());

     // 存放权限信息到redis中 , springsecurity通过 userId 做为key获取权限列表
     redisTemplate.opsForValue().set(sysUser.getId()+"_LOGIN_BUSINESS_STORAGE_", permsList);

     return ResultData.success(map);
 }

4. 最终版config配置类

@Configuration
@EnableWebSecurity //@EnableWebSecurity是开启SpringSecurity的默认行为
public class WebSecurityConfig{
    @Resource
    private RedisTemplate redisTemplate;
    @Resource
    private ApplicationContext applicationContext;
    @Resource
    private SysUserMapper sysUserMapper;


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

    @Bean
    public TokenAuthenticationFilter authenticationJwtTokenFilter() {
        return new TokenAuthenticationFilter(redisTemplate);
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // 禁用basic明文验证
            .httpBasic().disable()
            // 前后端分离架构不需要csrf保护
            .csrf().disable()
            // 禁用默认登录页
            .formLogin().disable()
            // 禁用默认登出页
            .logout().disable()
            // 前后端分离是无状态的,不需要session了,直接禁用。
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
                    // 允许所有OPTIONS请求
                    .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                    // 允许直接访问授权登录接口
                    .requestMatchers(HttpMethod.POST, "/system/loginCon/login").permitAll()
                    // 允许 SpringMVC 的默认错误地址匿名访问
                    .requestMatchers("/error").permitAll()
                    // 允许任意请求被已登录用户访问,不检查Authority
                    .anyRequest().authenticated())
            .authenticationProvider(authenticationProvider())
            // 加我们自定义的过滤器,替代UsernamePasswordAuthenticationFilter
            .addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class)
            .addFilter(authenticationTokenLoginFilter());

        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService(){
        // 调用 JwtUserDetailService实例执行实际校验
        return email -> {
            //实际上用email进行匹配 从数据库中获取
            SysUser authUser = sysUserMapper.getUserByEmail(email);

            if(null == authUser) {
                throw new UsernameNotFoundException("邮箱不存在!");
            }

            return new CustomUser(authUser, Collections.emptyList());
        };
    }

    /**
     * 调用loadUserByUsername获得UserDetail信息,在AbstractUserDetailsAuthenticationProvider里执行用户状态检查
     *
     * @return
     */
    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        // DaoAuthenticationProvider 从自定义的 userDetailsService.loadUserByUsername 方法获取UserDetails
        authProvider.setUserDetailsService(userDetailsService());
        // 设置密码编辑器
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    /**
     * 登录时需要调用AuthenticationManager.authenticate执行一次校验
     *
     * @param config
     * @return
     * @throws Exception
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public TokenLoginFilter authenticationTokenLoginFilter() {
        return new TokenLoginFilter(applicationContext.getBean(AuthenticationManager.class),redisTemplate);
    }
}

到此结束👍希望对各位能有帮助


网站公告

今日签到

点亮在社区的每一天
去签到