jwt+shiro+redis实现token的自动刷新和token的可控性

文章目录

一、为何要使用jwt+shiro+redis

在微服务中我们一般采用的是无状态登录,而传统的session方式,在前后端分离的微服务架构下,如继续使用则必将要解决跨域sessionId问题、集群session共享问题等等。这显然是费力不讨好的,而整合shiro,却很不恰巧的与我们的期望有所违背:
  (1)shiro默认的拦截跳转都是跳转url页面,而前后端分离后,后端并无权干涉页面跳转。
  (2)shiro默认使用的登录拦截校验机制恰恰就是使用的session。
  这当然不是我们想要的,因此如需使用shiro,我们就需要对其进行改造,那么要如何改造呢?我们可以在整合shiro的基础上自定义登录校验,继续整合JWT,或者oauth2.0等,使其成为支持服务端无状态登录,即token登录。

但是仅仅使用jwt+shiro还不能实现token的可控性,和token的自动刷新。这样就会导致token如果在30min后过期,那么如果用户30min后仍然需要使用系统,那么可能就需要重新登录,这是非常不好的用户体验。其次在token的有效期内,即使用户退出了登录,token依然有效,依然可以使用,这是不安全的,所以需要使用redis来进行可控性操作。

二、AccessToken和RefreshToken

2-1. Shiro + JWT实现无状态鉴权机制

1. 首先post用户名与密码到login进行登入,如果成功在请求头Header返回一个加密的Authorization,失败的话直接返回10001未登录等状态码,以后访问都带上这个Authorization即可。

2. 鉴权流程主要是要重写shiro的入口过滤器BasicHttpAuthenticationFilter,在此基础上进行拦截、token验证授权等操作

2-2. 关于AccessToken及RefreshToken概念说明

1. AccessToken:用于接口传输过程中的用户授权标识,客户端每次请求都需携带,出于安全考虑通常有效时长较短。

2. RefreshToken:与AccessToken为共生关系,一般用于刷新AccessToken,保存于服务端,客户端不可见,有效时长较长。

2-3. 关于Redis中保存RefreshToken信息(做到JWT的可控性)

1. 登录认证通过后返回AccessToken信息(在AccessToken中保存当前的时间戳和帐号),同时在Redis中设置一条以帐号为Key,Value为当前时间戳(登录时间)的RefreshToken,现在认证时必须AccessToken没失效以及Redis存在所对应的RefreshToken,且RefreshToken时间戳和AccessToken信息中时间戳一致才算认证通过,这样可以做到JWT的可控性,如果重新登录获取了新的AccessToken,旧的AccessToken就认证不了,因为Redis中所存放的的RefreshToken时间戳信息只会和最新的AccessToken信息中携带的时间戳一致,这样每个用户就只能使用最新的AccessToken认证。

2. Redis的RefreshToken也可以用来判断用户是否在线,如果删除Redis的某个RefreshToken,那这个RefreshToken所对应的AccessToken之后也无法通过认证了,就相当于控制了用户的登录,可以剔除用户

2-4. 关于根据RefreshToken自动刷新AccessToken

1. 本身AccessToken的过期时间为5分钟(配置文件可配置),RefreshToken过期时间为30分钟(配置文件可配置),当登录后时间过了5分钟之后,当前AccessToken便会过期失效,再次带上AccessToken访问JWT会抛出TokenExpiredException异常说明Token过期,开始判断是否要进行AccessToken刷新,首先redis查询RefreshToken是否存在,以及时间戳和过期AccessToken所携带的时间戳是否一致,如果存在且一致就进行AccessToken刷新。

2. 刷新后新的AccessToken过期时间依旧为5分钟(配置文件可配置),时间戳为当前最新时间戳,同时也设置RefreshToken中的时间戳为当前最新时间戳,刷新过期时间重新为30分钟过期(配置文件可配置),最终将刷新的AccessToken存放在Response的Header中的Authorization字段返回。

3. 同时前端进行获取替换,下次用新的AccessToken进行访问即可。

三、导入依赖并配置

数据库文件

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`  (
  `username` varchar(11) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `name` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `password` varchar(20) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `roles` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `permission` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  PRIMARY KEY (`username`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES ('1', 'admin', '123', 'user', 'add');
INSERT INTO `user` VALUES ('2', '陈宝强', '456', 'admin', 'update');
INSERT INTO `user` VALUES ('3', '王小波', '123', 'vip', 'delete');
INSERT INTO `user` VALUES ('4', '李虎', '123', 'user', 'add');

SET FOREIGN_KEY_CHECKS = 1;
<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.1.3</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring</artifactId>
            <version>1.3.2</version>
        </dependency>
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.2.0</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>

数据源的配置

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/ssm?useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8
spring.datasource.username=root
spring.datasource.password=root

spring.redis.host=127.0.0.1
spring.redis.port=6379

mybatis.type-aliases-package=csu.org.jwtshiroredis.domain
mybatis.mapper-location=classpath:mappers/*.xml
mybatis.lazy-initialization=true

四、配置redis和实现redisUtil

1.配置redis

为什么需要配置redis,因为redis的默认序列化是jdk序列化,这样的序列化是以转义字符的形式存储的,不方便查看,我们需要对其进行修改,以方便查看。

package csu.org.redisspringboot.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.net.UnknownHostException;

@Configuration
public class RedisConfig { 
    //编写我们自己的redisTemplate
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) throws UnknownHostException { 
        //我们为了自己开发使用方便,一般使用<String, Object>类型
        RedisTemplate<String, Object> template = new RedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);

        //序列化配置
        //json序列化
        Jackson2JsonRedisSerializer 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);

        //String序列化
        StringRedisSerializer stringRedisSerializer=new StringRedisSerializer();

        //key使用String的序列化方式
        template.setKeySerializer(stringRedisSerializer);
        //hash的key也使用String序列化
        template.setHashKeySerializer(stringRedisSerializer);
        //value使用json序列化
        template.setValueSerializer(jackson2JsonRedisSerializer);
        //hash的value使用json序列化
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        template.afterPropertiesSet();

        return template;
    }
}

2、RedisUtil

编写redisUtil是为了方便我们使用redisTrmplate的api,简化我们的使用过程。

package csu.org.jwtshiroredis.utils;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;

@Component
public final class RedisUtil { 

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    // =============================common============================
    /** * 指定缓存失效时间 * @param key 键 * @param time 时间(秒) */
    public boolean expire(String key, long time) { 
        try { 
            if (time > 0) { 
                redisTemplate.expire(key, time, TimeUnit.SECONDS);
            }
            return true;
        } catch (Exception e) { 
            e.printStackTrace();
            return false;
        }
    }

    /** * 根据key 获取过期时间 * @param key 键 不能为null * @return 时间(秒) 返回0代表为永久有效 */
    public long getExpire(String key) { 
        return redisTemplate.getExpire(key, TimeUnit.SECONDS);
    }


    /** * 判断key是否存在 * @param key 键 * @return true 存在 false不存在 */
    public boolean hasKey(String key) { 
        try { 
            return redisTemplate.hasKey(key);
        } catch (Exception e) { 
            e.printStackTrace();
            return false;
        }
    }


    /** * 删除缓存 * @param key 可以传一个值 或多个 */
    @SuppressWarnings("unchecked")
    public void del(String... key) { 
        if (key != null && key.length > 0) { 
            if (key.length == 1) { 
                redisTemplate.delete(key[0]);
            } else { 
                redisTemplate.delete(CollectionUtils.arrayToList(key));
            }
        }
    }


    // ============================String=============================

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

    /** * 普通缓存放入 * @param key 键 * @param value 值 * @return true成功 false失败 */

    public boolean set(String key, Object value) { 
        try { 
            redisTemplate.opsForValue().set(key, value);
            return true;
        } catch (Exception e) { 
            e.printStackTrace();
            return false;
        }
    }


    /** * 普通缓存放入并设置时间 * @param key 键 * @param value 值 * @param time 时间(秒) time要大于0 如果time小于等于0 将设置无限期 * @return true成功 false 失败 */

    public boolean set(String key, Object value, long time) { 
        try { 
            if (time > 0) { 
                redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
            } else { 
                set(key, value);
            }
            return true;
        } catch (Exception e) { 
            e.printStackTrace();
            return false;
        }
    }


    /** * 递增 * @param key 键 * @param delta 要增加几(大于0) */
    public long incr(String key, long delta) { 
        if (delta < 0) { 
            throw new RuntimeException("递增因子必须大于0");
        }
        return redisTemplate.opsForValue().increment(key, delta);
    }


    /** * 递减 * @param key 键 * @param delta 要减少几(小于0) */
    public long decr(String key, long delta) { 
        if (delta < 0) { 
            throw new RuntimeException("递减因子必须大于0");
        }
        return redisTemplate.opsForValue().decrement(key,delta);
// return redisTemplate.opsForValue().increment(key, -delta);
    }

    public long strLen(String key){ 
        return redisTemplate.opsForValue().get(key).toString().length();
    }

    /* * 追加字符 * @param key 键 * @param str 要追加的字符 * */
    public boolean append(String key,String str){ 
        try { 
            redisTemplate.opsForValue().append(key,str);
            return true;
        }catch (Exception e){ 
            return false;
        }
    }


    // ================================Map=================================

    /** * HashGet * @param key 键 不能为null * @param item 项 不能为null */
    public Object hget(String key, String item) { 
        return redisTemplate.opsForHash().get(key, item);
    }

    /** * 获取hashKey对应的所有键值 * @param key 键 * @return 对应的多个键值 */
    public Map<Object, Object> hmget(String key) { 
        return redisTemplate.opsForHash().entries(key);
    }

    /** * HashSet * @param key 键 * @param map 对应多个键值 */
    public boolean hmset(String key, Map<String, Object> map) { 
        try { 
            redisTemplate.opsForHash().putAll(key, map);
            return true;
        } catch (Exception e) { 
            e.printStackTrace();
            return false;
        }
    }


    /** * HashSet 并设置时间 * @param key 键 * @param map 对应多个键值 * @param time 时间(秒) * @return true成功 false失败 */
    public boolean hmset(String key, Map<String, Object> map, long time) { 
        try { 
            redisTemplate.opsForHash().putAll(key, map);
            if (time > 0) { 
                expire(key, time);
            }
            return true;
        } catch (Exception e) { 
            e.printStackTrace();
            return false;
        }
    }


    /** * 向一张hash表中放入数据,如果不存在将创建 * * @param key 键 * @param item 项 * @param value 值 * @return true 成功 false失败 */
    public boolean hset(String key, String item, Object value) { 
        try { 
            redisTemplate.opsForHash().put(key, item, value);
            return true;
        } catch (Exception e) { 
            e.printStackTrace();
            return false;
        }
    }

    /** * 向一张hash表中放入数据,如果不存在将创建 * * @param key 键 * @param item 项 * @param value 值 * @param time 时间(秒) 注意:如果已存在的hash表有时间,这里将会替换原有的时间 * @return true 成功 false失败 */
    public boolean hset(String key, String item, Object value, long time) { 
        try { 
            redisTemplate.opsForHash().put(key, item, value);
            if (time > 0) { 
                expire(key, time);
            }
            return true;
        } catch (Exception e) { 
            e.printStackTrace();
            return false;
        }
    }


    /** * 删除hash表中的值 * * @param key 键 不能为null * @param item 项 可以使多个 不能为null */
    public void hdel(String key, Object... item) { 
        redisTemplate.opsForHash().delete(key, item);
    }


    /** * 判断hash表中是否有该项的值 * * @param key 键 不能为null * @param item 项 不能为null * @return true 存在 false不存在 */
    public boolean hHasKey(String key, String item) { 
        return redisTemplate.opsForHash().hasKey(key, item);
    }


    /** * hash递增 如果不存在,就会创建一个 并把新增后的值返回 * * @param key 键 * @param item 项 * @param by 要增加几(大于0) */
    public double hincr(String key, String item, double by) { 
        return redisTemplate.opsForHash().increment(key, item, by);
    }


    /** * hash递减 * * @param key 键 * @param item 项 * @param by 要减少记(小于0) */
    public double hdecr(String key, String item, double by) { 
        return redisTemplate.opsForHash().increment(key, item, -by);
    }


    // ============================set=============================

    /** * 根据key获取Set中的所有值 * @param key 键 */
    public Set<Object> sGet(String key) { 
        try { 
            return redisTemplate.opsForSet().members(key);
        } catch (Exception e) { 
            e.printStackTrace();
            return null;
        }
    }


    /** * 根据value从一个set中查询,是否存在 * * @param key 键 * @param value 值 * @return true 存在 false不存在 */
    public boolean sHasKey(String key, Object value) { 
        try { 
            return redisTemplate.opsForSet().isMember(key, value);
        } catch (Exception e) { 
            e.printStackTrace();
            return false;
        }
    }


    /** * 将数据放入set缓存 * * @param key 键 * @param values 值 可以是多个 * @return 成功个数 */
    public long sSet(String key, Object... values) { 
        try { 
            return redisTemplate.opsForSet().add(key, values);
        } catch (Exception e) { 
            e.printStackTrace();
            return 0;
        }
    }


    /** * 将set数据放入缓存 * * @param key 键 * @param time 时间(秒) * @param values 值 可以是多个 * @return 成功个数 */
    public long sSetAndTime(String key, long time, Object... values) { 
        try { 
            Long count = redisTemplate.opsForSet().add(key, values);
            if (time > 0)
                expire(key, time);
            return count;
        } catch (Exception e) { 
            e.printStackTrace();
            return 0;
        }
    }


    /** * 获取set缓存的长度 * * @param key 键 */
    public long sGetSetSize(String key) { 
        try { 
            return redisTemplate.opsForSet().size(key);
        } catch (Exception e) { 
            e.printStackTrace();
            return 0;
        }
    }


    /** * 移除值为value的 * * @param key 键 * @param values 值 可以是多个 * @return 移除的个数 */

    public long setRemove(String key, Object... values) { 
        try { 
            Long count = redisTemplate.opsForSet().remove(key, values);
            return count;
        } catch (Exception e) { 
            e.printStackTrace();
            return 0;
        }
    }

    // ===============================list=================================

    /** * 获取list缓存的内容 * * @param key 键 * @param start 开始 * @param end 结束 0 到 -1代表所有值 */
    public List<Object> lGet(String key, long start, long end) { 
        try { 
            return redisTemplate.opsForList().range(key, start, end);
        } catch (Exception e) { 
            e.printStackTrace();
            return null;
        }
    }


    /** * 获取list缓存的长度 * * @param key 键 */
    public long lGetListSize(String key) { 
        try { 
            return redisTemplate.opsForList().size(key);
        } catch (Exception e) { 
            e.printStackTrace();
            return 0;
        }
    }


    /** * 通过索引 获取list中的值 * * @param key 键 * @param index 索引 index>=0时, 0 表头,1 第二个元素,依次类推;index<0时,-1,表尾,-2倒数第二个元素,依次类推 */
    public Object lGetIndex(String key, long index) { 
        try { 
            return redisTemplate.opsForList().index(key, index);
        } catch (Exception e) { 
            e.printStackTrace();
            return null;
        }
    }


    /** * 将list放入缓存 * * @param key 键 * @param value 值 */
    public boolean lSet(String key, Object value) { 
        try { 
            redisTemplate.opsForList().rightPush(key, value);
            return true;
        } catch (Exception e) { 
            e.printStackTrace();
            return false;
        }
    }


    /** * 将list放入缓存 * @param key 键 * @param value 值 * @param time 时间(秒) */
    public boolean lSet(String key, Object value, long time) { 
        try { 
            redisTemplate.opsForList().rightPush(key, value);
            if (time > 0)
                expire(key, time);
            return true;
        } catch (Exception e) { 
            e.printStackTrace();
            return false;
        }

    }


    /** * 将list放入缓存 * * @param key 键 * @param value 值 * @return */
    public boolean lSet(String key, List<Object> value) { 
        try { 
            redisTemplate.opsForList().rightPushAll(key, value);
            return true;
        } catch (Exception e) { 
            e.printStackTrace();
            return false;
        }

    }


    /** * 将list放入缓存 * * @param key 键 * @param value 值 * @param time 时间(秒) * @return */
    public boolean lSet(String key, List<Object> value, long time) { 
        try { 
            redisTemplate.opsForList().rightPushAll(key, value);
            if (time > 0)
                expire(key, time);
            return true;
        } catch (Exception e) { 
            e.printStackTrace();
            return false;
        }
    }


    /** * 根据索引修改list中的某条数据 * * @param key 键 * @param index 索引 * @param value 值 * @return */

    public boolean lUpdateIndex(String key, long index, Object value) { 
        try { 
            redisTemplate.opsForList().set(key, index, value);
            return true;
        } catch (Exception e) { 
            e.printStackTrace();
            return false;
        }
    }


    /** * 移除N个值为value * * @param key 键 * @param count 移除多少个 * @param value 值 * @return 移除的个数 */

    public long lRemove(String key, long count, Object value) { 
        try { 
            Long remove = redisTemplate.opsForList().remove(key, count, value);
            return remove;
        } catch (Exception e) { 
            e.printStackTrace();
            return 0;
        }

    }

}

五、封装token

封装token来替换Shiro原生Token,要实现AuthenticationToken接口

shiro默认supports的是UsernamePasswordToken,而我们现在采用了jwt的方式,所以这里我们自定义一个JwtToken,来完成shiro的supports方法。

package csu.org.jwtshiroredis.shiro;


import org.apache.shiro.authc.AuthenticationToken;

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;
    }
}

六、编写JwtUtil

JwtUtil类是用来生成token和验校验解码token的。

步骤:

  1. 设置密钥和token的有效时间
  2. 生成token
  3. 校验token
  4. 获取token的信息
package csu.org.jwtshiroredis.utils;


import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.interfaces.DecodedJWT;
import csu.org.jwtshiroredis.domain.User;

import java.io.UnsupportedEncodingException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

public class JWTUtil { 
    //token有效时长
    private static final long EXPIRE=1*60*1000L;
    //token的密钥
    private static final String SECRET="jwt+shiro";


    public static String createToken(String username,Long current) { 
        //token过期时间
        Date date=new Date(current+EXPIRE);

        //jwt的header部分
        Map<String ,Object>map=new HashMap<>();
        map.put("alg","HS256");
        map.put("typ","JWT");

        //使用jwt的api生成token
        String token= null;//签名
        try { 
            token = JWT.create()
                    .withHeader(map)
                    .withClaim("username", username)//私有声明
                    .withClaim("current",current)//当前时间截点
                    .withExpiresAt(date)//过期时间
                    .withIssuedAt(new Date())//签发时间
                    .sign(Algorithm.HMAC256(SECRET));
        } catch (UnsupportedEncodingException e) { 
            e.printStackTrace();
        }
        return token;
    }

    //校验token的有效性,1、token的header和payload是否没改过;2、没有过期
    public static boolean verify(String token){ 
        try { 
            //解密
            JWTVerifier verifier=JWT.require(Algorithm.HMAC256(SECRET)).build();
            verifier.verify(token);
            return true;
        }catch (Exception e){ 
            return false;
        }
    }
    //无需解密也可以获取token的信息
    public static String getUsername(String token){ 
        try { 
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("username").asString();
        } catch (JWTDecodeException e) { 
            return null;
        }
    }

    //获取过期时间
    public static Long getExpire(String token){ 
        try { 
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("current").asLong();
        }catch (Exception e){ 
            return null;
        }
    }
}

七、编写jwtFilter

这个过滤器是我们的重点,这里我们继承的是Shiro内置的BasicHttpAuthenticationFilter,一个可以内置了可以自动登录方法的的过滤器。也可以继承AuthenticatingFilter

这个过滤器是要注册到shiro配置里面去的,用来辅助shiro进行过滤处理。所有的请求都会到过滤器来进行处理。

这个类的执行过程:

preHandle==>isAccessAllowed==>isLoginAttempt==>executeLogin

授权成功就会进入onLoginSuccess,否则进入onAccessDenied

我们需要重写几个方法:

  1. isAccessAllowed:是否允许访问。如果带有 token,则对 token 进行检查,否则直接通过。如果请求头不存在 Token,则可能是执行登陆操作或者是游客状态访问,无需检查 token,直接返回 true
  2. isLoginAttempt:判断用户是否想要登入。检测 header 里面是否包含 Token 字段。
  3. executeLoginexecuteLogin实际上就是先调用createToken来获取token,这里我们重写了这个方法,就不会自动去调用createToken来获取token,然后调用getSubject方法来获取当前用户再调用login方法来实现登录,这也解释了我们为什么要自定义jwtToken,因为我们不再使用Shiro默认的UsernamePasswordToken了。
  4. preHandle:拦截器的前置拦截,因为我们是前后端分析项目,项目中除了需要跨域全局配置之外,我们再拦截器中也需要提供跨域支持。这样,拦截器才不会在进入Controller之前就被限制了。

在这里我们需要对token的刷新进行校验,如果符合刷新的条件就会进行刷新。

  • 继承BasicHttpAuthenticationFilter
package csu.org.jwtshiroredis.filter;

import com.auth0.jwt.exceptions.TokenExpiredException;
import csu.org.jwtshiroredis.shiro.JWTToken;
import csu.org.jwtshiroredis.utils.JWTUtil;
import csu.org.jwtshiroredis.utils.RedisUtil;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.WebApplicationContextUtils;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;

public class JWTFilter extends BasicHttpAuthenticationFilter { 


    //是否允许访问,如果带有 token,则对 token 进行检查,否则直接通过
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { 
        //判断请求的请求头是否带上 "Token"
        System.out.println("isAccessAllowed");
        if (isLoginAttempt(request, response)){ 
            //如果存在,则进入 executeLogin 方法执行登入,检查 token 是否正确
            try { 
                executeLogin(request, response);
                return true;

            }catch (Exception e){ 
                /* *注意这里捕获的异常其实是在Realm抛出的,但是由于executeLogin()方法抛出的异常是从login()来的, * login抛出的异常类型是AuthenticationException,所以要去获取它的子类异常才能获取到我们在Realm抛出的异常类型。 * */
                System.out.println("刷新token");
                String msg=e.getMessage();
                Throwable cause = e.getCause();
                if (cause!=null&&cause instanceof TokenExpiredException){ 
                    //AccessToken过期,尝试去刷新token
                    String result=refreshToken(request, response);
                    if (result.equals("success")){ 
                        System.out.println("request.equals(\"success\")");
                        return true;
                    }
                    msg=result;
                }
                responseError(response,msg);
            }
        }
        //如果请求头不存在 Token,则可能是执行登陆操作或者是游客状态访问,无需检查 token,直接返回 true
        return true;
    }

    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) { 
        HttpServletRequest req= (HttpServletRequest) request;
        String token=req.getHeader("Authorization");
        return token !=null;
    }
    /* * executeLogin实际上就是先调用createToken来获取token,这里我们重写了这个方法,就不会自动去调用createToken来获取token * 然后调用getSubject方法来获取当前用户再调用login方法来实现登录 * 这也解释了我们为什么要自定义jwtToken,因为我们不再使用Shiro默认的UsernamePasswordToken了。 * */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception { 
        System.out.println("executeLogin");
        HttpServletRequest req= (HttpServletRequest) request;
        String token=req.getHeader("Authorization");
        JWTToken jwt=new JWTToken(token);
        //交给自定义的realm对象去登录,如果错误他会抛出异常并被捕获
        getSubject(request, response).login(jwt);
        return true;
    }

    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception { 
        System.out.println("preHandle");
        HttpServletRequest req= (HttpServletRequest) request;
        HttpServletResponse res= (HttpServletResponse) response;
        res.setHeader("Access-control-Allow-Origin",req.getHeader("Origin"));
        res.setHeader("Access-control-Allow-Methods","GET,POST,OPTIONS,PUT,DELETE");
        res.setHeader("Access-control-Allow-Headers",req.getHeader("Access-Control-Request-Headers"));
        // 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
        if (req.getMethod().equals(RequestMethod.OPTIONS.name())) { 
            res.setStatus(HttpStatus.OK.value());
            return false;
        }
        return super.preHandle(request, response);
    }

    /** * 将非法请求跳转到 /unauthorized/** */
    private void responseError(ServletResponse response, String message) { 
        System.out.println("responseError");

        try { 
            HttpServletResponse httpServletResponse = (HttpServletResponse) response;
            //设置编码,否则中文字符在重定向时会变为空字符串
            message = URLEncoder.encode(message, "UTF-8");
            httpServletResponse.sendRedirect("/unauthorized/" + message);
        } catch (IOException e) { 
            System.out.println(e.getMessage());
        }
    }


    /* * 这里的getBean是因为使用@Autowired无法把RedisUtil注入进来 * 这样自动去注入当使用的时候是未NULL,是注入不进去了。通俗的来讲是因为拦截器在spring扫描bean之前加载所以注入不进去。 * * 解决的方法: * 可以通过已经初始化之后applicationContext容器中去获取需要的bean. * */
    public <T> T getBean(Class<T> clazz,HttpServletRequest request){ 
        WebApplicationContext applicationContext = WebApplicationContextUtils.getRequiredWebApplicationContext(request.getServletContext());
        return applicationContext.getBean(clazz);
    }

    //刷新token
    private String refreshToken(ServletRequest request,ServletResponse response) { 
        System.out.println("refreshToken");

        HttpServletRequest req= (HttpServletRequest) request;
        RedisUtil redisUtil=getBean(RedisUtil.class,req);
        //获取传递过来的accessToken
        String accessToken=req.getHeader("Authorization");
        //获取token里面的用户名
        String username= JWTUtil.getUsername(accessToken);
        System.out.println("username"+username);
        //判断refreshToken是否过期了,过期了那么所含的username的键不存在
        System.out.println("redisUtil.hasKey(username)"+redisUtil.hasKey(username));
        if (redisUtil.hasKey(username)){ 
            //判断refresh的时间节点和传递过来的accessToken的时间节点是否一致,不一致校验失败
            long current= (long) redisUtil.get(username);
            if (current==JWTUtil.getExpire(accessToken)){ 
                //获取当前时间节点
                long currentTimeMillis = System.currentTimeMillis();
                //生成刷新的token
                String token=JWTUtil.createToken(username,currentTimeMillis);
                //刷新redis里面的refreshToken,过期时间是30min
                redisUtil.set(username,currentTimeMillis,30*60);
                //再次交给shiro进行认证
                JWTToken jwtToken=new JWTToken(token);
                try { 
                    getSubject(request, response).login(jwtToken);
                    // 最后将刷新的AccessToken存放在Response的Header中的Authorization字段返回
                    HttpServletResponse httpServletResponse = (HttpServletResponse) response;
                    httpServletResponse.setHeader("Authorization", token);
                    httpServletResponse.setHeader("Access-Control-Expose-Headers", "Authorization");
                    return "success";
                }catch (Exception e){ 
                    return e.getMessage();
                }
            }
        }
        return "token认证失效,token过期,重新登陆";
    }
}

八、自定义Myrealm对象

AccountRealm是shiro进行登录或者权限校验的逻辑所在,算是核心了,我们需要重写3个方法,分别是

  • supports:为了让realm支持jwt的凭证校验
  • doGetAuthorizationInfo:权限校验
  • doGetAuthenticationInfo:登录认证校验

需要注意的是,认证抛出的异常是会被jwtFilter的login()方法获取到的

认证的流程需要理清一下:

先获取token中信息包含的用户名,判断是否存在用户名,该用户名是否有对应的账号,再验证redis有没有该用户名对应的key,如果有,判断accessToken有没有过期,如果没有则获取对应的value,这就是refreshToken,接着判断该value和accessToken里面的时间戳是否一样,如果一样那么认证通过。

package csu.org.jwtshiroredis.shiro;
import com.auth0.jwt.exceptions.TokenExpiredException;
import csu.org.jwtshiroredis.domain.User;
import csu.org.jwtshiroredis.service.UserService;
import csu.org.jwtshiroredis.utils.JWTUtil;
import csu.org.jwtshiroredis.utils.RedisUtil;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
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 org.springframework.stereotype.Component;
@Component
public class MyRealm extends AuthorizingRealm { 
    @Autowired
    private UserService userService;
    @Autowired
    private RedisUtil redisUtil;

    //根据token判断此Authenticator是否使用该realm
    //必须重写不然shiro会报错
    @Override
    public boolean supports(AuthenticationToken token) { 
        return token instanceof JWTToken;
    }

    /** * 只有当需要检测用户权限的时候才会调用此方法,例如@RequiresRoles,@RequiresPermissions之类的 */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { 
        System.out.println("授权~~~~~");
        String token=principals.toString();
        String username= JWTUtil.getUsername(token);
        User user=userService.getUser(username);
        SimpleAuthorizationInfo info=new SimpleAuthorizationInfo();
        //查询数据库来获取用户的角色
        info.addRole(user.getRoles());
        //查询数据库来获取用户的权限
        info.addStringPermission(user.getPermission());
        return info;
    }


    /** * 默认使用此方法进行用户名正确与否验证,错误抛出异常即可,在需要用户认证和鉴权的时候才会调用 */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { 
        System.out.println("认证~~~~~~~");
        String jwt= (String) token.getCredentials();
        String username= null;
        try { 
            username= JWTUtil.getUsername(jwt);
        }catch (Exception e){ 
            throw new AuthenticationException("token非法,不是规范的token,可能被篡改了,或者过期了");
        }
        if (username==null){ 
            throw new AuthenticationException("token中无用户名");
        }
        User user=userService.getUser(username);
        if (user==null){ 
            throw new AuthenticationException("该用户不存在");
        }
        //开始认证,只要AccessToken没有过期,或者refreshToken的时间节点和AccessToken一致即可
        if (redisUtil.hasKey(username)){ 
            //判断AccessToken有无过期
            if (!JWTUtil.verify(jwt)){ 
                throw new TokenExpiredException("token认证失效,token过期,重新登陆");
            }else { 
                //判断AccessToken和refreshToken的时间节点是否一致
                long current= (long) redisUtil.get(username);
                if (current==JWTUtil.getExpire(jwt)){ 
                    return new SimpleAuthenticationInfo(jwt,jwt,"MyRealm");
                }else{ 
                    throw new AuthenticationException("token已经失效,请重新登录!");
                }
            }
        }else{ 
            throw new AuthenticationException("token过期或者Token错误!!");
        }
    }
}

九、编写shiroConfig

配置文件的任务主要有:

  1. 创建defaultWebSecurityManagerBean对象
  2. 创建ShiroFilterFactoryBean来进行 过滤拦截,权限和登录
  3. 关闭session
  4. 添加注解权限开发

springBoot整合jwt与单纯的shiro实现认证有三个不一样的地方,对应下面

  1. 因为不适用Session,所以为了防止会调用getSession()方法而产生错误,需要关闭session
  2. 一些修改,关闭SHiroDao等
  3. 注册JwtFilterShiroFilterFactoryBea
package csu.org.jwtshiroredis.config;

import csu.org.jwtshiroredis.filter.JWTFilter;
import csu.org.jwtshiroredis.shiro.MyRealm;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.servlet.Filter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;

@Configuration
public class ShiroConfig { 


    @Bean(name = "securityManager")
    public DefaultWebSecurityManager securityManager(MyRealm myRealm){ 
        DefaultWebSecurityManager securityManager=new DefaultWebSecurityManager();
        // 设置自定义 realm.
        securityManager.setRealm(myRealm);

        //关闭session
        DefaultSubjectDAO subjectDAO=new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator sessionStorageEvaluator=new DefaultSessionStorageEvaluator();
        sessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(sessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);
        return securityManager;
    }

    /** * 先走 filter ,然后 filter 如果检测到请求头存在 token,则用 token 去 login,走 Realm 去验证 */
    @Bean
    public ShiroFilterFactoryBean factory(@Qualifier("securityManager")DefaultWebSecurityManager securityManager){ 
        ShiroFilterFactoryBean factoryBean=new ShiroFilterFactoryBean();
        factoryBean.setSecurityManager(securityManager);
        // 添加自己的过滤器并且取名为jwt
        Map<String, Filter> filterMap=new LinkedHashMap<>();
        //设置我们自定义的JWT过滤器
        filterMap.put("jwt",new JWTFilter());
        factoryBean.setFilters(filterMap);

        // 设置无权限时跳转的 url;
        factoryBean.setUnauthorizedUrl("/unauthorized/无权限");
        Map<String,String>filterRuleMap=new HashMap<>();
        // 所有请求通过我们自己的JWT Filter
        filterRuleMap.put("/**","jwt");
        // 访问 /unauthorized/** 不通过JWTFilter
        filterRuleMap.put("/unauthorized/**","anon");
        factoryBean.setFilterChainDefinitionMap(filterRuleMap);
        return factoryBean;
    }

    /**
     * 添加注解支持,如果不加的话很有可能注解失效
     */
    @Bean
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator(){ 

        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator=new DefaultAdvisorAutoProxyCreator();
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        return defaultAdvisorAutoProxyCreator;
    }

    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(@Qualifier("securityManager") DefaultWebSecurityManager securityManager){ 

        AuthorizationAttributeSourceAdvisor advisor=new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }

    @Bean
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() { 
        return new LifecycleBeanPostProcessor();
    }
}

十、统一响应结果封装

由于是前后端分离项目,所以需要一个统一的规范的响应结果封装。

package csu.org.jwtshiroredis.utils;

import lombok.Data;

import java.io.Serializable;

@Data
public class Result implements Serializable { 

    private int code; // 200是正常,非200表示异常
    private String msg;
    private Object data;

    public static Result succ(Object data) { 
        return succ(200, "操作成功", data);
    }

    public static Result succ(int code, String msg, Object data) { 
        Result r = new Result();
        r.setCode(code);
        r.setMsg(msg);
        r.setData(data);
        return r;
    }

    public static Result fail(String msg) { 
        return fail(400, msg, null);
    }

    public static Result fail(String msg, Object data) { 
        return fail(400, msg, data);
    }

    public static Result fail(int code, String msg, Object data) { 
        Result r = new Result();
        r.setCode(code);
        r.setMsg(msg);
        r.setData(data);
        return r;
    }

}

十一、全局异常处理

前后端分离项目一定要注意异常的处理,不然你返回的一大堆乱七八糟的前端人员看不懂的错误提醒是没有用的,或者程序直接下机了更加不好,应该返回有关的异常信息,告诉前端人员那里出错了。

package csu.org.jwtshiroredis.config;

import com.auth0.jwt.exceptions.TokenExpiredException;
import csu.org.jwtshiroredis.utils.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.ShiroException;
import org.apache.shiro.authz.UnauthenticatedException;
import org.apache.shiro.authz.UnauthorizedException;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.io.IOException;

//捕获全局异常的处理
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandle { 

    // 捕捉shiro的异常
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    @ExceptionHandler(ShiroException.class)
    public Result handle401(ShiroException e) { 
        return Result.fail(401, e.getMessage(), null);
    }

    // 捕捉未登录的异常
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    @ExceptionHandler(UnauthenticatedException.class)
    public Result handle401(UnauthenticatedException e) { 
        System.out.println(e.getMessage());
        return Result.fail(401, "你还没有登录", null);
    }

    // 捕捉没有相应的权限或者角色的异常
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    @ExceptionHandler(UnauthorizedException.class)
    public Result handle401(UnauthorizedException e) { 
        System.out.println(e.getMessage());
        return Result.fail(401, "你没有权限访问"+e.getMessage(), null);
    }


    /** * @Validated 校验错误异常处理 */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    public Result handler(MethodArgumentNotValidException e) throws IOException { 
// log.error("运行时异常:-------------->",e);
        BindingResult bindingResult = e.getBindingResult();
        //这一步是把异常的信息最简化
        ObjectError objectError = bindingResult.getAllErrors().stream().findFirst().get();
        return Result.fail(HttpStatus.BAD_REQUEST.value(),objectError.getDefaultMessage(),null);
    }

    /** * 处理Assert的异常 */
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = IllegalArgumentException.class)
    public Result handler(IllegalArgumentException e) throws IOException { 
// log.error("Assert异常:-------------->{}",e.getMessage());
        return Result.fail(400,e.getMessage(),null);
    }


    //运行时错误处理
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = RuntimeException.class)
    public Result handle(RuntimeException e){ 
        return Result.fail(HttpStatus.BAD_REQUEST.value(),e.getMessage(),null);
    }


    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = TokenExpiredException.class)
    public Result handler(TokenExpiredException e) throws IOException { 
        return Result.fail(HttpStatus.BAD_REQUEST.value(),"token已经过期,请重新登录",null);
    }
}

十二、登录和登出Controller

对应的持久层和业务逻辑层代码自己去编写,比较简单。

登录

登录需要传递用户名密码过来,然后到数据库去查找对应的用户,如果找不到就提醒找不到用户,如果用户名密码正确就生成对应的AccessToken,同时把refreshToken保存到redis里面。

登出

登出只需要把redis里面的对应的用户名的refreshToken删除就可以了。

package csu.org.jwtshiroredis.controller;

import csu.org.jwtshiroredis.domain.User;
import csu.org.jwtshiroredis.service.UserService;
import csu.org.jwtshiroredis.utils.JWTUtil;
import csu.org.jwtshiroredis.utils.RedisUtil;
import csu.org.jwtshiroredis.utils.Result;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import java.io.UnsupportedEncodingException;

@RestController
public class LoginController { 

    @Autowired
    private UserService userService;
    @Autowired
    private RedisUtil redisUtil;

    @PostMapping("/login")
    public Result login(@RequestParam String username, @RequestParam String password){ 
        User user=userService.getUserByPass(username, password);
        Assert.notNull(user,"用户名或密码错误");
        long currentTimeMillis = System.currentTimeMillis();
        String token= JWTUtil.createToken(user.getUsername(),currentTimeMillis);
        redisUtil.set(username,currentTimeMillis,60*30);
        return Result.succ(200,"登陆成功",token);
    }

    @RequestMapping(path = "/unauthorized/{message}")
    public Result unauthorized(@PathVariable String message) throws UnsupportedEncodingException { 
        return Result.fail(message);
    }

    @DeleteMapping("/logout")
    @RequiresAuthentication
    public Result logout(HttpServletRequest request){ 
        String token=request.getHeader("Authorization");
        String username=JWTUtil.getUsername(token);
        redisUtil.del(username);
        return Result.succ(null);
    }
}

十三、相应的测试controller

package csu.org.jwtshiroredis.controller;

import csu.org.jwtshiroredis.service.UserService;
import csu.org.jwtshiroredis.utils.Result;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/user")
public class UserController { 

    @Autowired
    private UserService userService;

    @RequiresAuthentication
    @GetMapping("/test")
    public Result test(){ 
        return Result.succ("test");
    }

    @RequiresRoles("admin")
    @GetMapping("/admin")
    public Result admin(){ 
        return Result.succ("admin");
    }

    @RequiresRoles("vip")
    @PostMapping("/vip")
    public Result vip(){ 
        return Result.succ("vip");
    }

    @RequiresPermissions("update")
    @PutMapping("/update")
    public Result update(){ 
        return Result.succ("update");
    }

    @RequiresPermissions("delete")
    @DeleteMapping("/delete")
    public Result delete(){ 
        return Result.succ("delete");
    }

    @GetMapping("/guest")
    public Result guest(){ 
        return Result.succ("guest");
    }

}
    原文作者:csu_zhuzi
    原文地址: https://blog.csdn.net/weixin_44852935/article/details/108066849
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞