Shiro+Jwt+Redis

如何整合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 "没有通过权限验证!";
    }
}

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

怎么搭建微信留言板功能

在信息爆炸的时代&#xff0c;微信已经成为了我们日常生活中不可或缺的一部分。它不仅仅是一个简单的聊天工具&#xff0c;更是一个充满无限可能的营销平台。今天&#xff0c;我要向大家介绍的是如何在你的微信平台上搭建一个独具特色的留言板功能&#xff0c;让用户能够自由发…

【Flutter】Dialog组件PageView组件

&#x1f525; 本文由 程序喵正在路上 原创&#xff0c;CSDN首发&#xff01; &#x1f496; 系列专栏&#xff1a;Flutter学习 &#x1f320; 首发时间&#xff1a;2024年5月27日 &#x1f98b; 欢迎关注&#x1f5b1;点赞&#x1f44d;收藏&#x1f31f;留言&#x1f43e; 目…

需求跟踪矩阵是什么?怎么创建?一文详解

一、什么是需求跟踪矩阵 对项目经理或产品经理来说&#xff0c;需求清单肯定不陌生&#xff0c;那什么是需求跟踪矩阵呢&#xff1f; 需求跟踪矩阵&#xff08;Requirement Track Matrix&#xff0c;简称RTM &#xff09;&#xff0c;是把产品需求从其来源连接到能满足需求的…

Spring中@Component注解

Component注解 在Spring框架中&#xff0c;Component是一个通用的注解&#xff0c;用于标识一个类作为Spring容器管理的组件。当Spring扫描到被Component注解的类时&#xff0c;会自动创建一个该类的实例并将其纳入Spring容器中管理。 使用方式 1、基本用法&#xff1a; Co…

[AI OpenAI] OpenAI 安全更新

AI 首尔峰会中分享我们的实践 我们自豪地构建并发布了在能力和安全性方面都处于行业领先地位的模型。 超过一亿用户和数百万开发者依赖于我们安全团队的工作。我们将安全视为我们必须在多个时间范围内投资并取得成功的事项&#xff0c;从使今天的模型与我们未来预期的更具能力…

【Spring Cloud】远程调用

目录 Spring Cloud Netflix Feign简介前言Feign是什么OpenFeign组件和Spring Cloud OpenFeignOpenFeign组件Spring Cloud OpenFeign OpenFeign-微服务接口调用需求说明1. 启动Eureka Server服务2.创建两个项目&#xff0c;将其注册到Eureka Server3.在服务提供者中添加业务处理…

如何处理逻辑设计中的时钟域

1.什么是时钟域 2.PLL对时钟域管理 不管是否需要变频变相&#xff0c;在FPGA内部将外部输入时钟从专用时钟引脚扇入后先做PLL处理。如何调用pll&#xff0c;见另一篇文章。 约束输入时钟 creat_clock -period 10 -waveform {0 5} [get_ports {sys_clk}] 3.单bit信号跨时钟…

【Linux进程篇】父子进程fork函数||进程生死轮回状态||僵尸进程与孤儿进程

W...Y的主页 &#x1f60a; 代码仓库分享&#x1f495; 前言&#xff1a;上篇文章中我们认识了进程&#xff0c;可执行程序在内存中加载运行被称作进程&#xff0c;而操作系统是通过给每一个可执行程序创建一个PCB来管理进程的。并且学习了一些查看进程的指令&#xff0c;认识…

Flask 蓝图路由的模块化开发

基于 Flask 蓝图路由的模块化开发 1. 编程目标 为了提高Flask应用的可维护性和可扩展性&#xff0c;我们通过使用Flask的蓝图(Blueprint)功能&#xff0c;可以将不同的功能模块拆分到独立的文件中&#xff0c;方便后续的开发和维护。 2. 项目结构 项目结构树如下&#xff1…

助力企业标准化搭建--图框模板的创建

古有秦皇书同文、车同轨&#xff0c;今各行各业都有国际标准、国家标准&#xff0c;其目的就是为了标准化、统一化&#xff0c;由此可见标准化的重要性&#xff1b;一个企业若是想规范员工的操作&#xff0c;推行标准化也很重要&#xff1b;因此对于需要绘制电气图纸的行业来说…

从0开始学统计-秩和检验

1.什么是秩和检验&#xff1f; 秩和检验&#xff0c;也称为Wilcoxon 秩和检验&#xff0c;是一种非参数统计检验方法&#xff0c;用于比较两个独立样本的中位数是否有显著差异。它不要求数据满足正态分布假设&#xff0c;因此适用于小样本或者数据不满足正态分布假设的情况。 …

2024年怎么下载学浪app视频

想要在2024年紧跟潮流&#xff0c;成为一名优秀的学浪用户吗&#xff1f;今天就让我们一起探索如何下载学浪app视频吧&#xff01; 学浪视频下载工具打包 学浪下载工具打包链接&#xff1a;百度网盘 请输入提取码 提取码&#xff1a;1234 --来自百度网盘超级会员V10的分享…

性能怪兽!香橙派 Kunpeng Pro 开发板深度测评,带你解锁无限可能

性能怪兽&#xff01;香橙派 Kunpeng Pro 开发板深度测评&#xff0c;带你解锁无限可能 文章目录 性能怪兽&#xff01;香橙派 Kunpeng Pro 开发板深度测评&#xff0c;带你解锁无限可能一、背景二、香橙派 Kunpeng Pro 硬件规格概述三、使用准备与系统安装1️⃣、系统安装步骤…

字节面试:百亿级数据存储,怎么设计?只是分库分表吗?

尼恩&#xff1a;百亿级数据存储架构起源 在40岁老架构师 尼恩的读者交流群(50)中&#xff0c;经常性的指导小伙伴们改造简历。 经过尼恩的改造之后&#xff0c;很多小伙伴拿到了一线互联网企业如得物、阿里、滴滴、极兔、有赞、希音、百度、网易、美团的面试机会&#xff0c…

cuda 11.6 pytorch安装

在安装之前&#xff0c;需要先配置GPU环境&#xff08;安装CUDA和CudaNN) 命令行输入nvidia-smi&#xff0c;查看驱动信息 nvidia-smi 安装相应的CUDA 和CUDANN 验证&#xff1a;输入nvcc --version 或者nvcc -V 进行检查 nvcc --version nvcc -V 在anaconda里创建环境 con…

彭永东所交“答卷”道尽万般无奈,贝壳找房营收、利润双双锐减

就今年第一季度业绩披露后两日的股价变动来看&#xff0c;贝壳找房&#xff08;下称“贝壳”&#xff09;似乎并未在港股和美股市场取得预期的效果。 港股市场&#xff0c;截至5月24日收盘&#xff0c;贝壳-W&#xff08;HK:02423&#xff09;报收43.9港元/股&#xff0c;当日跌…

海外网红营销新趋势:“快闪式”营销如何迅速提升品牌曝光度

在当今数字化时代&#xff0c;海外网红营销已成为品牌迅速触达全球消费者、提升品牌曝光度和刺激销售的重要手段。其中&#xff0c;“快闪式”营销以其独特的时效性、创意性和互动性&#xff0c;成为品牌与海外网红合作的新趋势。本文Nox聚星将和大家探讨如何利用海外网红的影响…

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

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

5个好用的AI写论文网站推荐

目录 1.AIQuora论文写作 2.passyyds 答辩PPT 3.AIPassgo论文降AIGC 4.文状元 5.passyyds论文写作 毕业论文是每个毕业生的痛&#xff0c;不管你是本科还是硕士要想顺利毕业你就不得不面对论文。然而&#xff0c;面对论文写作时常常感到无从下手&#xff1a;有时缺乏灵感&a…

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

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