Shiro+Jwt+Redis

news2024/11/16 1:26:15

如何整合Shiro+Jwt+Redis,以及为什么要这么做

我个人认为


①为什么用shiro:“Shiro+Jwt+Redis”模式和“单纯的shiro”模式相比,主要用的是shiro里面的登录认证和权限控制功能

②为什么用jwt:“Shiro+Jwt”模式和“Shiro+Cookie”模式相比,后者的用户登录信息是存储在服务器的会话里面的,也就是后端服务器的缓存里面,这样的话就没办法分布式(多个后端),解决办法是把登录信息以及过期时间直接存储在一段字符串中,然后由前端保存,后端只需根据生成token时定义的秘钥去验证jwt是否正确即可,如果正确就允许接下来的操作。

③为什么用Redis:“Shiro+Jwt+Redis”模式和“Shiro+Jwt”模式相比,前者可以实现分布式环境下的会话共享,这么说有点抽象,通俗一点就是:在分布式系统中,用户的会话信息需要在多个服务器之间共享,而我可以把用户的一些前端经常请求的用户信息或者其他信息存储到redis里面,这样就不用去经常查询数据库信息了。


所以综上所述,我们使用Shiro+Jwt+Redis的模式。

Jwt

​ 需要了解一门技术,首先从为什么产生开始说起是最好的。JWT 主要用于用户登录鉴权,所以我们从最传统的 session 认证开始说起。

前置知识


**会话:**每个用户的一次登录到登出之间叫做一个会话。



登录状态:“无状态”和“有状态”是指对于服务器而言的两种不同的处理方式:

  1. 无状态(Stateless):在无状态的认证机制中,服务器不需要保存任何关于客户端的状态信息。每次客户端发送请求时,服务器只需要对请求进行处理,而无需考虑之前的请求状态。这意味着服务器可以更容易地进行水平扩展,因为不需要担心请求会被路由到特定的服务器上。
  2. 有状态(Stateful):相比之下,在有状态的认证机制中,服务器需要保存客户端的状态信息,通常通过会话对象或其他方式来记录客户端的状态。这意味着服务器需要在多个请求之间共享状态信息,可能需要使用特定的机制来保证状态的一致性和可靠性。

session认证

image-20240519190947325

​ 众所周知,http 协议本身是无状态的协议(http 是一种无状态协议,就是说每次用户进行用户名和密码认证之后,http 不会留下记录,下一次请求还需要进行认证。因为http 不知道每次请求是哪一个用户发出的)。

​ session认证就是说用户登录后把将此用户的登录状态存储到服务器的内存中。

​ session 认证的缺点其实很明显,由于 session 是保存在服务器里,所以如果分布式部署应用的话,会出现session不能共享的问题,很难扩展。

token认证

image-20240519191004738

token认证的过程就是在用户第一次登录的时候根据秘钥(一般秘钥中会包括此用户的唯一标志,比如账号)生成此次会话的token,然后之后前端每次访问后端都携带token,后端再根据秘钥解析,如果解析成功就说明token有效,进而可以信任此次请求进行接下来的操作。

​ 基于 token 的认证方式是一种服务端无状态的认证方式,服务端不存储 token 数据,适合分布式系统。

什么是JWT

​ 而JWT(全称:Json Web Token)是一种特殊的Token,它采用了JSON格式来对Token进行编码和解码,并携带了更多的信息,例如用户ID、角色、权限等。它包含了三部分:头部(Header)、数据(Payload)和签名(Signature)。其中,头部和数据都是经过Base64编码的JSON字符串,而签名是对头部和数据进行签名后得到的字符串。

Springboot使用JWT实现登录认证以及请求拦截

主要是两步:配置拦截器、配置要拦截哪些接口

<!--        jwt工具-->
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.10.3</version>
        </dependency>
package com.hebut.demo.common.utils;

import cn.hutool.core.codec.Base64;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

import java.util.HashMap;
import java.util.Map;

// JWT工具类
@Configuration
public class JwtUtil {

    @Value("${shiro.jwt.secret}")
    private static String secret;
    @Value("${shiro.jwt.expire}")
    private static Long expire;
    @Value("${shiro.jwt.header.alg}")
    private static String headerAlg;
    @Value("${shiro.jwt.header.typ}")
    private static String headerTyp;

    /**
     * 生成token
     */
    public static String getToken(String account) {
        // 设置秘钥
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append(account).append(secret);

        // 设置jwt头header
        Map<String, Object> headerClaims = new HashMap<>();
        headerClaims.put("alg", headerAlg); // 签名算法
        headerClaims.put("typ", headerTyp); // token 类型
        // 设置jwt的header,负载paload以及加密算法
        String token = JWT
                .create()
                .withHeader(headerClaims)
                .withClaim("account" ,account)
                .withClaim("expire", System.currentTimeMillis()+expire)
                .sign(Algorithm.HMAC256(stringBuilder.toString()));
        return token;
    }

    /**
     * 无需秘钥就能获取其中的信息
     * 解析token.
     * {
     * "account": "account",
     * "timeStamp": "134143214"
     * }
     */
    public static Map<String, String> parseToken(String token) {
        HashMap<String, String> map = new HashMap<String, String>();
        // 解码 JWT
        DecodedJWT decodedJwt = JWT.decode(token);
        Claim account = decodedJwt.getClaim("account");
        Claim expire = decodedJwt.getClaim("expire");
        map.put("account", account.asString());
        map.put("expire", expire.asLong().toString());
        return map;
    }

    /**
     * 解析token获取账号.
     */
    public static String getAccount(String token) {
        HashMap<String, String> map = new HashMap<String, String>();
        DecodedJWT decodedJwt = JWT.decode(token);
        Claim account = decodedJwt.getClaim("account");
        return account.asString();
    }

    /**
     * 校验token是否正确
     * @param token Token
     * @return boolean 是否正确
     */
    public static boolean verify(String token) {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append(getAccount(token)).append(secret);
        // 帐号加JWT私钥解密
        Algorithm algorithm = Algorithm.HMAC256(stringBuilder.toString());
        JWTVerifier verifier = JWT.require(algorithm).build();
        try {
            verifier.verify(token);
            return true; // 验证成功
        } catch (JWTVerificationException e) {
            return false; // 验证失败
        }
    }

}
 
import com.demo.util.JWTUtils;
import com.auth0.jwt.exceptions.AlgorithmMismatchException;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
 
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

// 配置拦截器
@Slf4j
public class JWTInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = request.getHeader("token");
        if(StringUtils.isEmpty(token)){
            throw new Exception("token不能为空");
        }
        try {
            //在这里调用了 JWTUtils工具类的方法 验证传入token的合法性,你可以传token:111 试试
            JWTUtils.verify(token);
        } catch (SignatureVerificationException e) {
            log.error("无效签名! 错误 ->", e);
            return false;
        } catch (TokenExpiredException e) {
            log.error("token过期! 错误 ->", e);
            return false;
        } catch (AlgorithmMismatchException e) {
            log.error("token算法不一致! 错误 ->", e);
            return false;
        } catch (Exception e) {
            log.error("token无效! 错误 ->", e);
            return false;
        }
        return true;
    }
}
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
// 配置要拦截哪些接口
@Configuration
public class InterceptorConfig  implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new JWTInterceptor())
                //拦截的路径
                .addPathPatterns("/**")
                //排除登录接口 /test/login 表示你给控制器起的名称/控制器下的方法,如login
                .excludePathPatterns("/test/login");
    }
}

Shiro

​ Shiro提供了哪些功能呢?

  1. **登录认证(Authentication):**Shiro可以对用户进行身份验证,确保用户是合法的。它支持多种认证方式,包括用户名/密码、基于证书的认证、第三方登录等。
  2. **访问授权(Authorization):**Shiro可以对用户进行授权,确定用户是否有权限执行某个操作或访问某个资源。它支持基于角色的访问控制和基于权限的访问控制,可以定义细粒度的权限规则。
  3. **会话管理(Session Management):**Shiro可以管理用户的会话,包括跟踪用户的登录状态、管理会话的生命周期、实现单点登录等功能。
  4. **密码加密(Password Encryption):**Shiro可以帮助应用程序安全地存储和验证用户密码,它提供了多种加密算法和技术,如哈希算法、加盐、散列迭代等。
  5. **RememberMe功能:**Shiro提供了RememberMe功能,可以在用户登录后记住用户的身份,下次访问时自动登录。
  6. **Web支持:**Shiro提供了与Web应用程序集成的支持,可以轻松地保护Web资源、处理表单登录、实现注销等功能。
  7. **缓存支持:**Shiro支持将重要数据(如用户信息、权限信息)缓存在内存中,提高系统的性能和响应速度。

​ 不用害怕,因为我们就用到了**“登录认证”和"访问授权"**。本篇文章主要讲解“认证”、“授权”的功能。

主要模块讲解

image-20240521094154037

①Realm用于获取用户信息,在这里可以给登录认证以及访问授权这两个事务查询用户相关数据,查询完用户数据之后,返回一个SimpleAuthorizationInfo类型的对象,交给SecurityManager管理。

②SecurityManager将从Realm得到的信息赋值给对应的subject用于进行登录认证或者访问授权

③在一个用户登录到退出的整个过程,SecurityManager会一直为此用户保持一个会话session,会话信息存储在内存中,以便期间各种访问。

登录认证

​ shiro提供了方便的登录认证,可以通过subject.login(token)进行登录操作。

整体流程

①前端用户输入账号密码

②后端通过账号密码生成Shiro提供的UsernamePasswordToken类型的token

③调用shiro用户对象的登录方法subject.login(token)

④subject.login(token)会去调用多个方法,其中两个是public boolean supports(AuthenticationToken authenticationToken)protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken),前者判断所传入的token是不是shiro所支持的token也就是是不是UsernamePasswordToken类型的,后者用于获取用户信息。

protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken)方法讲解,在这个方法里面有两步,一步是根据token解析出来用户principal(也就是账号),然后使用principal去数据库或者其他数据源拿此用户对应的唯一凭证credentials(也就是密码),拿到之后创建SimpleAuthenticationInfo(Object principal, Object credentials, String realmName)对象,传入三个参数,前两个是账号密码,最后一个是你自定义的realm类的名字。

⑥subject.login(token)就会比对realm返回的用户账号密码是否一致。

⑦除此之外,shiro还提供加密功能,比如用户的密码使用了md5加密,那么在配置类里面就可以声明加密的算法,之后用户调用subject.login(token)方法的时候就会自动给前端传给后端的密码加密,进而直接和realm中获取的数据进行比对。

代码实现

需要写两个类,一个shiroconfig配置类,一个realm的方法重写。

UserRealm

// 需要重写两个方法
public class UserRealm extends AuthorizingRealm
{
    /**
     * 授权:这里先不实现
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection arg0)
    {
        return null;
    }

    /**
     * 登录认证,自定义登录认证方式:账号是否存在,token是否过期
     * 重写之后,如果需要登录认证的接口就会自动调用此接口进行登录认证
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException
    {
        UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken)authenticationToken;
        String account = usernamePasswordToken.getUsername();
        // 然后根据账号从数据库或者其他数据源查新密码
        String password = select(account)// 这一行是伪代码,select换成自己的查询逻辑就行
        return new SimpleAuthenticationInfo(account, password, getName());// getName是AuthorizingRealm实现的方法,将会返回此类的名字,比如在这里就是“UserRealm”
    }

}

ShiroConfig

@Configuration
public class ShiroConfig {

    // 初始化SecurityManager,把自定义的Realm交给SecurityManager管理
    @Bean(value = "defaultWebSecurityManager")
    public DefaultWebSecurityManager getDefaultWebSecurityManager(){
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        UserRealm userRealm = new UserRealm();
        defaultWebSecurityManager.setRealm(userRealm);
        return defaultWebSecurityManager;
    }
}

访问授权

​ 访问授权就是通过配置shiro的拦截器拦截前端访问请求,然后再通过Realm获取用户的权限信息,如果访问用户有此权限就通过拦截器。

整体流程

①设置要拦截哪些路径或者接口,大概有两个方法:注解形式拦截器中配置

②用户携带token访问后端接口

③shiro拦截器拦截,然后调用Realm的protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection arg0)方法通过查询数据库或者其他数据源获得此用户的权限信息,将权限信息添加到SimpleAuthorizationInfo info = new SimpleAuthorizationInfo()实例中并返回。

代码实现

UserRealm

// 需要重写两个方法
public class UserRealm extends AuthorizingRealm
{
    /**
     * 授权:这里先不实现
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection arg0)
    {
        return null;
    }

    /**
     * 登录认证,自定义登录认证方式:账号是否存在,token是否过期
     * 重写之后,如果需要登录认证的接口就会自动调用此接口进行登录认证
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException
    {
        UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken)authenticationToken;
        String account = usernamePasswordToken.getUsername();
        // 然后根据账号从数据库或者其他数据源查新密码
        String password = select(account)// 这一行是伪代码,select换成自己的查询逻辑就行
        return new SimpleAuthenticationInfo(account, password, getName());// getName是AuthorizingRealm实现的方法,将会返回此类的名字,比如在这里就是“UserRealm”
    }

}

ShiroConfig

@Configuration
public class ShiroConfig {

    // 初始化SecurityManager,把自定义的Realm交给SecurityManager管理
    @Bean(value = "defaultWebSecurityManager")
    public DefaultWebSecurityManager getDefaultWebSecurityManager(){
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        UserRealm userRealm = new UserRealm();
        defaultWebSecurityManager.setRealm(userRealm);
        return defaultWebSecurityManager;
    }
    
     /**
     * 添加自己的过滤器,自定义url规则,
     * Filter工厂,设置对应的过滤条件和跳转条件
     * Shiro自带拦截器配置规则
     * 详情见文档 http://shiro.apache.org/web.html#urls-
     *
     * @date 2018/8/31 10:57
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(@Qualifier("defaultWebSecurityManager") DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // 添加自己的过滤器并且取名为jwt
        Map<String, Filter> filterMap = new HashMap<>();
        filterMap.put("jwt", new JwtFilter());
        shiroFilterFactoryBean.setFilters(filterMap);

        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
//        // 登出
//        filterChainDefinitionMap.put("/logout", "logout");
        // 登录页面可以匿名访问
        filterChainDefinitionMap.put("/sys/login", "anon");
//        // 首页需要身份验证后才能访问
//        filterChainDefinitionMap.put("/index", "authc");
//        // 错误页面,认证不通过跳转
//        filterChainDefinitionMap.put("/error", "authc");
//        // 其他页面需要具有 admin 角色才能访问
//        filterChainDefinitionMap.put("/admin/**", "roles[admin]");
//        // 其他页面需要具有 user:create 权限才能访问
//        filterChainDefinitionMap.put("/sys/login", "perms[sys:login]");
//        // 其他页面需要具有 user:update 和 user:delete 权限才能访问
//        filterChainDefinitionMap.put("/user/manage", "perms[\"user:update,user:delete\"]");
        filterChainDefinitionMap.put("/**", "jwt");  // /**,一般放在最下,表示对所有资源起作用,使用JwtFilter
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);

        return shiroFilterFactoryBean;
    }
    
    
    /*
    开启注解的权限控制
    @RequiresAuthentication(标识用户必须在当前会话中进行了身份验证(登录)才能访问被注解的方法或类)
    @RequiresUser(标识用户必须在应用程序中进行了身份验证(不一定是当前会话)才能访问被注解的方法或类)
    @RequiresGuest(标识用户必须是一个“guest”(未经身份验证)才能访问被注解的方法或类)
    @RequiresRoles(标识用户必须具有指定的角色才能访问被注解的方法或类。可以指定一个或多个角色,如 @RequiresRoles("admin") 或 @RequiresRoles({"admin", "user"}))
    @RequiresPermissions(标识用户必须具有指定的权限才能访问被注解的方法或类。可以指定一个或多个权限,如 @RequiresPermissions("user:create") 或 @RequiresPermissions({"user:create", "user:update"}))
    @RequiresGuest(标识用户必须是一个“guest”(未经身份验证)才能访问被注解的方法或类)
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(@Qualifier("securityManager") DefaultWebSecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }
}

Redis

​ 那为什么还要用Redis呢,从Shiro章节可以得知,如果要实现每次访问后端接口进行登录认证拦截的话,都要调用Realm中的登录认证方法,这样的话每次都要查询数据库,数据库压力太大,所以我们使用Redis来存储用户登录信息来解决这个问题,除此之外Redis里面还能存储更多前端经常要访问到的用户信息,省的经常去数据库里面查询了。

Springboot整合Shiro+Jwt+Redis

数据流向和项目结构

数据流向

image-20240521214547303

项目结构

image-20240521145229144

Redis配置

配置文件,配置redis地址

spring:
  redis:
    host: 10.1.40.83
    port: 6379
    password:
    database: 0
    timeout: 5000
    lettuce:
      pool:
        max-idle: 16
        max-active: 32
        min-idle: 8

配置Redis的常量

/**
 * 常量
 * @author dolyw.com
 * @date 2018/9/3 16:03
 */
public class RedisConstant {

    private RedisConstant() {}

    public static final String PREFIX_REFRESH_TOKEN = "refresh_token";

    public static final String PREFIX_ACCESS_TOKEN = "access_token";

    public static final String PREFIX_SHIRO_EXPIRE = "access_token";

    public static final String PREFIX_SHIRO_JWT = "shiro:jwt:";

}

config自动注入

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {
    @Bean(name = "redisTemplate")
    public RedisTemplate<String, Object> getRedisTemplate(LettuceConnectionFactory lettuceConnectionFactory) {
        // 设置序列化
        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        // 配置redisTemplate
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(lettuceConnectionFactory);
        RedisSerializer<?> stringSerializer = new StringRedisSerializer();
        // key序列化
        redisTemplate.setKeySerializer(stringSerializer);
        // value序列化
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        // Hash key序列化
        redisTemplate.setHashKeySerializer(stringSerializer);
        // Hash value序列化
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}
import com.alibaba.excel.util.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

/**
 * @className: RedisUtil
 * @description:
 * @author: sh.Liu
 * @date: 2022-03-09 14:07
 */
// TODO: 2023/7/25 此工具类可以进一步优化
@Component
public class RedisUtil {


    // 使用jwt的过期时间毫秒
    private final long defaultTimeout = 1*24*60*60*1000;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;



    /**
     * 是否存在指定的key
     *
     * @param key
     * @return
     */
    public boolean hasKey(String key) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }

    /**
     * 删除指定的key
     *
     * @param key
     * @return
     */
    public boolean delete(String key) {
        return Boolean.TRUE.equals(redisTemplate.delete(key));
    }


    //- - - - - - - - - - - - - - - - - - - - -  String类型 - - - - - - - - - - - - - - - - - - - -

    /**
     * 根据key获取值
     *
     * @param key 键
     * @return 值
     */
    public Object get(String key) {
        return key == null ? null : redisTemplate.opsForValue().get(key);
    }

    /**
     * 将值放入缓存
     *
     * @param key   键
     * @param value 值
     * @return true成功 false 失败
     */
    public void set(String key, String value) {
        set(key, value, defaultTimeout);
    }

    /**
     * 将值放入缓存并设置时间
     *
     * @param key   键
     * @param value 值
     * @param time  时间(秒) -1为无期限
     * @return true成功 false 失败
     */
    public void set(String key, String value, long time) {
        if (time > 0) {
            redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
        } else {
            redisTemplate.opsForValue().set(key, value, defaultTimeout, TimeUnit.SECONDS);
        }
    }

    //- - - - - - - - - - - - - - - - - - - - -  object类型 - - - - - - - - - - - - - - - - - - - -
    /**
     * 根据key读取数据
     */
    public Object getObject(final String key) {
        if (StringUtils.isBlank(key)) {
            return null;
        }
        try {
            return redisTemplate.opsForValue().get(key);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    /**
     * 写入数据
     */
    public boolean setObject(final String key, Object value) {
        if (StringUtils.isBlank(key)) {
            return false;
        }
        try {
            setObject(key, value , defaultTimeout);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return false;
    }


    public boolean setObject(final String key, Object value, long time) {
        if (StringUtils.isBlank(key)) {
            return false;
        }
        if (time > 0) {
            redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
        } else {
            redisTemplate.opsForValue().set(key, value, defaultTimeout, TimeUnit.SECONDS);
        }
        return true;
    }
}

JWT配置

依赖

        <!--jwt-->
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.19.0</version>
        </dependency>

配置类

shiro:
  jwt:
    # 加密秘钥
    secret: f4e2e52034348f86b67cde581c0f9eb5
    # token有效时长,7天,单位毫秒
    expire: 604800000
    header:
      # 加密算法
      alg: HS256
      # token类型
      typ: JWT

JWT工具类

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;

@Component
public class JwtUtil {

    @Value("${shiro.jwt.secret}")
    private String secret;
    @Value("${shiro.jwt.expire}")
    private Long expire;
    @Value("${shiro.jwt.header.alg}")
    private String headerAlg;
    @Value("${shiro.jwt.header.typ}")
    private String headerTyp;

    /**
     * 生成token
     */
    public String getToken(String account, long currentTimeMillis) {
        // 设置秘钥
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append(account).append(secret);

        // 设置jwt头header
        Map<String, Object> headerClaims = new HashMap<>();
        headerClaims.put("alg", headerAlg); // 签名算法
        headerClaims.put("typ", headerTyp); // token 类型
        // 设置jwt的header,负载paload以及加密算法
        String token = JWT
                .create()
                .withHeader(headerClaims)
                .withClaim("account" ,account)
                .withClaim("expire", currentTimeMillis + expire)
                .sign(Algorithm.HMAC256(stringBuilder.toString()));
        return token;
    }

    /**
     * 无需秘钥就能获取其中的信息
     * 解析token.
     * {
     * "account": "account",
     * "timeStamp": "134143214"
     * }
     */
    public Map<String, String> parseToken(String token) {
        HashMap<String, String> map = new HashMap<String, String>();
        // 解码 JWT
        DecodedJWT decodedJwt = JWT.decode(token);
        Claim account = decodedJwt.getClaim("account");
        Claim expire = decodedJwt.getClaim("expire");
        map.put("account", account.asString());
        map.put("expire", expire.asLong().toString());
        return map;
    }

    /**
     * 解析token获取账号.
     */
    public String getAccount(String token) {
        HashMap<String, String> map = new HashMap<String, String>();
        DecodedJWT decodedJwt = JWT.decode(token);
        Claim account = decodedJwt.getClaim("account");
        return account.asString();
    }

    /**
     * 校验token是否正确
     * @param token Token
     * @return boolean 是否正确
     */
    public boolean verify(String token) {
        DecodedJWT decodedJwt = JWT.decode(token);
        Claim account = decodedJwt.getClaim("account");
        Claim expire = decodedJwt.getClaim("expire");
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append(account.asString()).append(secret);
        // 验证JWT的签名和有效性
        Algorithm algorithm = Algorithm.HMAC256(stringBuilder.toString());
        JWTVerifier verifier = JWT.require(algorithm).build();
        try {
            verifier.verify(token);
            return true; // 验证通过
        } catch (JWTVerificationException e) {
            return false; // 验证失败
        }
    }


    /**
     * 校验token是否过期
     * @param token Token
     * @return boolean 是否正确
     */
    public boolean isExpired(String token) {
        DecodedJWT decodedJwt = JWT.decode(token);
        Claim expire = decodedJwt.getClaim("expire");
        // 验证过期时间
        Long expireTime = expire.asLong();
        if (System.currentTimeMillis() > expireTime) {
            return true;
        }
        return false;
    }


    /**
     * 获取token过期时间
     * @param token Token
     * @return boolean 是否正确
     */
    public long getExpiredTime(String token) {
        DecodedJWT decodedJwt = JWT.decode(token);
        Claim expire = decodedJwt.getClaim("expire");
        return expire.asLong();
    }


}

Shiro配置

ShiroConfig

@Configuration
public class ShiroConfig {


    /**
     * 添加自己的过滤器,自定义url规则,
     * Filter工厂,设置对应的过滤条件和跳转条件
     * Shiro自带拦截器配置规则
     * 详情见文档 http://shiro.apache.org/web.html#urls-
     *
     * @date 2018/8/31 10:57
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(@Qualifier("defaultWebSecurityManager") DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // 添加自己的过滤器并且取名为jwt
        Map<String, Filter> filterMap = new HashMap<>();
        filterMap.put("jwt", new JwtFilter());
        shiroFilterFactoryBean.setFilters(filterMap);

        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
//        // 登出
//        filterChainDefinitionMap.put("/logout", "logout");
        // 登录页面可以匿名访问
        filterChainDefinitionMap.put("/sys/login", "anon");
//        // 首页需要身份验证后才能访问
//        filterChainDefinitionMap.put("/index", "authc");
//        // 错误页面,认证不通过跳转
//        filterChainDefinitionMap.put("/error", "authc");
//        // 其他页面需要具有 admin 角色才能访问
//        filterChainDefinitionMap.put("/admin/**", "roles[admin]");
//        // 其他页面需要具有 user:create 权限才能访问
//        filterChainDefinitionMap.put("/sys/login", "perms[sys:login]");
//        // 其他页面需要具有 user:update 和 user:delete 权限才能访问
//        filterChainDefinitionMap.put("/user/manage", "perms[\"user:update,user:delete\"]");
        filterChainDefinitionMap.put("/**", "jwt");  // /**,一般放在最下,表示对所有资源起作用,使用JwtFilter
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);

        return shiroFilterFactoryBean;
    }

    @Bean(value = "defaultWebSecurityManager")
    public DefaultWebSecurityManager getDefaultWebSecurityManager(@Qualifier("userRealm") UserRealm userRealm){
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        defaultWebSecurityManager.setRealm(userRealm);
        //关闭shiro的session(无状态的方式使用shiro)
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        defaultWebSecurityManager.setSubjectDAO(subjectDAO);
        return defaultWebSecurityManager;
    }

    // 将自己的验证方式加入容器
    @Bean
    public UserRealm userRealm() {
        return new UserRealm();
    }


    /*
    开启注解的权限控制
    @RequiresAuthentication(标识用户必须在当前会话中进行了身份验证(登录)才能访问被注解的方法或类)
    @RequiresUser(标识用户必须在应用程序中进行了身份验证(不一定是当前会话)才能访问被注解的方法或类)
    @RequiresGuest(标识用户必须是一个“guest”(未经身份验证)才能访问被注解的方法或类)
    @RequiresRoles(标识用户必须具有指定的角色才能访问被注解的方法或类。可以指定一个或多个角色,如 @RequiresRoles("admin") 或 @RequiresRoles({"admin", "user"}))
    @RequiresPermissions(标识用户必须具有指定的权限才能访问被注解的方法或类。可以指定一个或多个权限,如 @RequiresPermissions("user:create") 或 @RequiresPermissions({"user:create", "user:update"}))
    @RequiresGuest(标识用户必须是一个“guest”(未经身份验证)才能访问被注解的方法或类)
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(@Qualifier("securityManager") DefaultWebSecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }

    /**
     * 由Spring管理 Shiro的生命周期
     */
    @Bean
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }

    @Bean
    @DependsOn("lifecycleBeanPostProcessor")
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        // 强制使用cglib,防止重复代理和可能引起代理出错的问题
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        return defaultAdvisorAutoProxyCreator;
    }
}

Realm配置

import com.hebut.demo.common.constant.RedisConstant;
import com.hebut.demo.common.utils.JwtUtil;
import com.hebut.demo.common.utils.RedisUtil;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.HashSet;
import java.util.Set;

/**
 * 自定义Realm 处理登录 权限
 * 
 * @author ruoyi
 */
public class UserRealm extends AuthorizingRealm
{
    @Autowired
    private RedisUtil redisUtil;
    @Autowired
    private JwtUtil jwtUtil;


    // 这个方法要重写,debug源码得知shiro会判断token的类型是不是自己支持的类型,不重写的话会报错
    @Override
    public boolean supports(AuthenticationToken authenticationToken) {
        return authenticationToken instanceof JwtToken;
    }



    /**
     * 授权
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection arg0)
    {
        // 获取第一个身份(用户信息),由于在doGetAuthenticationInfo方法中返回的对象中principal参数传入的是token,所以这里获得的也是token
        String token = (String) arg0.getPrimaryPrincipal();
        String account = jwtUtil.getAccount(token);
        // 角色列表
        Set<String> roles = new HashSet<String>();
        // 根据账号从数据库或者其他数据源获取角色信息(这个操作省略不写了)

        // 比如查询到用户有一个admin的角色,在这里添加
        roles.add("admin");

        // 功能权限
        Set<String> menus = new HashSet<String>();
        // 根据账号从数据库或者其他数据源获取权限信息(这个操作省略不写了)

        // 比如查询到用户有一个sys:select的权限,在这里添加
        menus.add("sys:select");
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        // 管理员拥有所有权限
        info.setRoles(roles);
        info.setStringPermissions(menus);

        return info;
    }

    /**
     * 登录认证,自定义登录认证方式:账号是否存在,token是否过期
     * 重写之后,如果需要登录认证的接口就会自动调用此接口进行登录认证
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException
    {

        System.out.println("登录认证");
        // JwtToken中重写了这个方法了
        String token = (String) authenticationToken.getCredentials();
        // 判断token是否有效(这里只是验证了签名是否有效)
        if (!jwtUtil.verify(token)){
            return null;
        }
        String account = jwtUtil.getAccount(token);
        if (jwtUtil.isExpired(token)){
            // 如果过期了,去redis里面查看refreshtime(这里没有实现这个步骤,直接过期了就退出)

            return null;
        }else {
            // 没有过期则判断token是否和redis里面存储的相等
            // 获取accessToken
            String accessToken = redisUtil.getObject(RedisConstant.PREFIX_SHIRO_JWT + account + RedisConstant.PREFIX_ACCESS_TOKEN).toString();
            if(token.equals(accessToken)){
                // 认证通过则返回认证信息
                return new SimpleAuthenticationInfo(token, token, getName());
            }
            return null;
        }
    }

}

重写AuthenticationToken

import org.apache.shiro.authc.AuthenticationToken;

/**
 * @author: lhy
 * 自定义的shiro接口token,可以通过这个类将string的token转型成AuthenticationToken,可供shiro使用
 * 注意:需要重写getPrincipal和getCredentials方法,因为是进行三件套处理的,没有特殊配置shiro无法通过这两个         方法获取到用户名和密码,需要直接返回token,之后交给JwtUtil去解析获取。(当然了,可以对realm进行配   
        置HashedCredentialsMatcher,这里就不这么处理了)
 */
public class JwtToken implements AuthenticationToken {
    private String token;

    public JwtToken(String token) {
        this.token = token;
    }

    @Override
    public Object getPrincipal() {
        return token;
    }
    @Override
    public Object getCredentials() {
        return token;
    }
}

继承BasicHttpAuthenticationFilter并重写,用于每次访问后端的时候做登录认证

import com.hebut.demo.common.shiro.JwtToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.springframework.stereotype.Component;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;

/**
 * @author: lhy
 *  jwt过滤器,作为shiro的过滤器,对请求进行拦截并处理
    跨域配置不在这里配了,我在另外的配置类进行配置了,这里把重心放在验证上
 */
@Slf4j
@Component
public class JwtFilter extends BasicHttpAuthenticationFilter{

    /**
     * 过滤器拦截请求的入口方法
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        try {
            return executeLogin(request, response);  //token验证
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 进行token的验证
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        //在请求头中获取token
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader("Authorization"); //前端命名Authorization
        //token不存在
        if(token == null || "".equals(token)){

            return false;
        }

        //token存在,进行验证
        JwtToken jwtToken = new JwtToken(token);
        getSubject(request, response).login(jwtToken);  //通过subject,提交给myRealm进行登录验证
        return true;
    }

    /**
     * isAccessAllowed()方法返回false,即认证不通过时进入onAccessDenied方法
     */
//    @Override
//    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
//        return super.onAccessDenied(request, response);
//    }

    /**
     * token认证executeLogin成功后,进入此方法,可以进行token更新过期时间
     */
//    @Override
//    protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception {
			
//    }
}

controller层异常拦截

import org.apache.shiro.authz.AuthorizationException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;


// 用于拦截controller中抛出的异常
// 使用样例:@ControllerAdvice(basePackages="org.my.pkg")扫描此包下面所有的controller
@ControllerAdvice
public class GlobalExceptionHandler {

    // 拦截AuthorizationException异常
    @ExceptionHandler(AuthorizationException.class)
    @ResponseBody
    public String handleAuthorizationException(AuthorizationException e) {
        return "没有通过权限验证!";
    }
}

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

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

相关文章

Kibana(一张图片胜过千万行日志)

Kibana&#xff08;一张图片胜过千万行日志&#xff09; Kibana是一个开源的分析和可视化平台&#xff0c;设计用于和Elasticsearch一起工作。 你用Kibana来搜索&#xff0c;查看&#xff0c;并和存储在Elasticsearch索引中的数据进行交互。 你可以轻松地执行高级数据分析&a…

斐讯N1刷OpenWRT并安装内网穿透服务实现远程管理旁路由

文章目录 前言1. 制作刷机固件U盘1.1 制作刷机U盘需要准备以下软件&#xff1a;1.2 制作步骤 2. N1盒子降级与U盘启动2.1 N1盒子降级2.2 N1盒子U盘启动设置2.3 使用U盘刷入OpenWRT2.4 OpenWRT后台IP地址修改2.5 设置旁路由&无线上网 3. 安装cpolar内网穿透3.1 下载公钥3.2 …

etcd 和 MongoDB 的混沌(故障注入)测试方法

最近在对一些自建的数据库 driver/client 基础库的健壮性做混沌&#xff08;故障&#xff09;测试, 去验证了解业务的故障处理机制和恢复时长. 主要涉及到了 MongoDB 和 etcd 这两个基础组件. 本文会介绍下相关的测试方法. MongoDB 中的故障测试 MongoDB 是比较世界上热门的文…

【Linux】 虚拟机可以ping通主机 主机却ping不通虚拟机 解决方法

我当时初学linux&#xff0c;需要虚拟机联网&#xff0c;且虚拟机和windows需要能相互ping通&#xff0c;我当时就是虚拟机一切正常&#xff0c;虚拟机显示有网可以ping通百度&#xff0c;也可以ping通windows&#xff0c;但是windows就是ping不通虚拟机&#xff0c;这个问题困…

容器组件:页面和自定义组件生命周期(HarmonyOS学习第五课)

页面和自定义组件生命周期 先明确自定义组件和页面的关系&#xff1a; 自定义组件:Component装饰的UI单元&#xff0c;可以组合多个系统组件实现U的复用。 页面:即应用的UI页面。可以由一个或者多个自定义组件组成&#xff0c;Entry装饰的自定义组件为贞面的入口组件&#xf…

海信电视刷机以及简化操作经验介绍

刷机 强制U盘刷机 准备一个U盘&#xff0c;U盘格式化成 FAT32 格式。下载对应的刷机包&#xff0c;将 TargetHis 拷贝到u盘根目录关闭电视电源启动电视&#xff0c;并且不断的按下音量‘-’按键等待刷机就可以了 能开机 能开机的话就1.2一样&#xff0c;进入系统后&#xf…

03. Spring 事务管理

文章目录 1. Spring 事务管理简介2. Transactional 注解属性信息3. Transactional 简单使用4. Transactional 使用注意事项4.1 正确指定事务回滚的异常类型4.1.1 Java 异常继承体系 4.2 Transactional 注解应用在 public 方法或类上才有效4.3 正确设置 Transactional 的 propag…

【Spring】深入理解 Spring 状态机:简化复杂业务逻辑的利器

前言 在软件开发中&#xff0c;有许多场景需要处理状态转换和状态驱动的逻辑&#xff0c;比如订单处理、工作流程管理、游戏引擎等。Spring 状态机&#xff08;Spring State Machine&#xff09;是 Spring Framework 提供的一个强大的模块&#xff0c;用于帮助开发人员轻松构建…

【VTKExamples::Utilities】第二期 AnimationScene

很高兴在雪易的CSDN遇见你 VTK技术爱好者 QQ:870202403 公众号:VTK忠粉 前言 本文分享VTK样例AnimationScene,并解析VTK中动画的基本框架,希望对各位小伙伴有所帮助! 感谢各位小伙伴的点赞+关注,小易会继续努力分享,一起进步! 你的点赞就是我的动力(^U^)ノ…

OSCP学习,布置你的Kali Linux

为什么要写这篇文章&#xff1f; 我是一个OSCP学习者&#xff0c;以教促学。同时也能让各位入门的师傅们更好的了解OSCP这门课程。本人文笔不太好&#xff0c;如果有什么写的不对的地方&#xff0c;师傅们多多指正。 参考资料&#xff1a; OSCP 考试电子书 Linux Basics for…

微信小程序毕业设计-跑腿系统项目开发实战(附源码+演示视频+LW)

大家好&#xff01;我是程序猿老A&#xff0c;感谢您阅读本文&#xff0c;欢迎一键三连哦。 &#x1f49e;当前专栏&#xff1a;微信小程序毕业设计 精彩专栏推荐&#x1f447;&#x1f3fb;&#x1f447;&#x1f3fb;&#x1f447;&#x1f3fb; &#x1f380; Python毕业设计…

深度学习之加宽全连接

1.Functional API 搭建神经网络模型 1.1.利用Functional API编写宽深神经网络模型进行手写数字识别 import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.datasets import load_iris from sklearn.model_selection import train_test_spli…

工程技术SCI期刊,中科院4区,收稿范围非常广泛,审稿快易录用!

一、期刊名称 CMES-Computer Modeling in Engineering & Sciences 二、期刊简介概况 期刊类型&#xff1a;SCI 学科领域&#xff1a;工程技术 影响因子&#xff1a;2.4 中科院分区&#xff1a;4区 三、期刊征稿范围 本期刊在工程与科学的计算机建模领域发表具有合理永…

python中的空语句以及对于条件语句的总结

if条件&#xff1a; 代码块 if条件&#xff1a; 代码块1 else&#xff1a; 代码块2 if条件1&#xff1a; 代码块1 elif条件2&#xff1a; 代码块2 else&#xff1a; 代码块3

2016届蓝桥杯大赛软件类国赛Java大学B组 愤怒小鸟 数学模拟

注意开浮点数 ​​​​ import java.util.Scanner;public class Main {static Scanner scnew Scanner(System.in);public static void main(String[] args) {double t0;int cnt0;double distance1000;while(distance>1){//相撞时间tdistance/60.0;distance-t*20;cnt;}Syste…

梭住绿色,植梦WILL来,容声冰箱“节能森林计划”再启航

近日&#xff0c;容声冰箱再度开启了“节能森林计划”绿色公益之旅。 据「TMT星球」了解&#xff0c;此次活动深入到阿拉善荒漠化地带&#xff0c;通过实地考察和亲身体验&#xff0c;见证容声了“节能森林计划”项目的持续落地和实施效果。 2022年&#xff0c;容声冰箱启动了…

IP地址在广告行业中的重要地位

新时代&#xff0c;广告已经成为了企业推广产品的必要手段&#xff0c;而企业想要广告效果好&#xff0c;就要做到精准投放营销广告&#xff0c;将“花钱”的广告精准送到产品的受众用户面前&#xff0c;让收益大于花销&#xff0c;而归根究底就是广告转化率与回报率是否达到预…

Kivy 项目51斩百词 6 播放读音

为了给小喇叭图像绑定点击事件&#xff0c;实现当用户点击按钮时&#xff0c;触发该事件对应的回调方法。 在方法内对于不同的系统Kivy使用不同的播放语音方法&#xff0c; 对于Windows系统 使用SoundLoader播放语音&#xff0c; 对于其他的Unix系统 使用Pyjnjus播放…

PawSQL: 企业级SQL审核工具的新玩家

随着数据库应用在企业中的广泛使用&#xff0c;确保SQL代码质量的重要性日益凸显。现有的SQL审核工具很多&#xff0c;包括Yearning、goInception、Bytebase、爱可生的SQLE、云和恩墨的SQM等等&#xff0c;但是它们或者规则覆盖度、或者是在正确率等方面存在明显不足&#xff1…

Halo Theme AirCloud 主题文档

发现一款简洁的halo主题 Halo Theme AirCloud 主题文档 | LogDicthttps://www.logdict.com/archives/AirCloud