• Springboot整合shiro、jwt、redis总结


    Springboot整合shiro、jwt、redis总结

    涉及技术:

    1. SpringBoot + Mybatis核心框架
    2. PageHelper插件 + 通用Mapper插件
    3. Shiro + Java-JWT无状态鉴权认证机制
    4. Redis(Jedis)缓存框架

    5. PostgreSql

    实现

    完全使用了 Shiro 的注解配置,保持高度的灵活性。

    放弃 Cookie ,Session ,使用JWT进行鉴权,完全实现无状态鉴权。

    JWT 密钥支持过期时间。

    对跨域提供支持。

    数据源

    由于开始是按照mysql方言写的所以创建表时遇到些坑,

    1.在postgre里user、password是关键字需要加冒号,

    2.Int自增应该写成serial类型: 

    先创建序列,然后设置字段的自增

    CREATE SEQUENCE users_id_seq 

    START WITH 1 

    INCREMENT BY 1 

    NO MINVALUE 

    NO MAXVALUE 

    CACHE 1;

    alter table users alter column id set default nextval('users_id_seq');  

    3.关于外键要直接写在外键后面  role_id int not NULL references role (id)

    4.配置文件如下(用的Druid连接池)

     

    5.在做小demo期间还学了Mybatis Generator逆向生成代码:

    很好用的偷懒神器,先配置srcmain esourcesgeneratorgeneratorConfig.xml文件,在项目根目录下(前提是配置了mvn)在IDEA的Maven窗口Plugins中双击执行),可自动生成Model、Mapper、MapperXML。

    Shiro + Java-JWT实现无状态鉴权机制(Token)

    首先Post用户名与密码到user/login进行登入,如果成功返回一个加密的AccessToken,失败的话直接返回401错误(帐号或密码不正确),以后访问都带上这个AccessToken即可,鉴权流程主要是重写了Shiro的入口过滤器JWTFilter(BasicHttpAuthenticationFilter),判断请求

    Header里面是否包含Authorization字段,有就进行Shiro的Token登录认证授权(用户访问每一个需要权限的请求必须在Header中添加Authorization字段存放AccessToken),没有就以游客直接访问(有权限管控的话,以游客访问就会被拦截)

    主要学习的几个概念:

    无状态

    微服务集群中的每个服务,对外提供的都使用RESTful风格的接口。而RESTful风格的一个最重要的规范就是:服务的无状态性,即

    服务端不保存任何客户端请求者信息

    客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份

    客户端请求不依赖服务端的信息,多次请求不需要必须访问到同一台服务器

    服务端的集群和状态对客户端透明

    服务端可以任意的迁移和伸缩(可以方便的进行集群化部署)

    减小服务端存储压力

    Jwt

    JSON Web Token(JWT)是目前最流行的跨域身份验证解决方案

     

    客户端接收服务器返回的JWT,将其存储在Cookie中。

    此后,客户端将在与服务器交互中都会带JWT。如果将它存储在Cookie中,就可以自动发送,但是不会跨域,因此一般是将它放入HTTP请求的Header Authorization字段中。

    Authorization: Bearer

    当跨域时,也可以将JWT被放置于POST请求的数据主体中。

    JWT头部分是一个描述JWT元数据的JSON对象    

    有效载荷部分,是JWT的主体内容部分,也是一个JSON对象,包含需要传递的数据。

    签名哈希部分是对上面两部分数据签名,通过指定的算法生成哈希,以确保数据不会被篡改。

    在网上搜了些关于加密的算法,一般采用MD5+盐的算法,但是当两个用户的明文密码相同时进行加密,会发现数据库中存在相同结构的暗文密码,所以采用AES-128 + Base64是以帐号+密码的形式进行加密密码,因为帐号具有唯一性,所以也不会出现相同结构的暗文密码这个问题

    Shiro

     

    配置

    写了获取当前登录用户工具类、Json和Object的互相转换的类、jwt工具类

    我把工具类粘贴到了博客园https://www.cnblogs.com/Treesir/p/11600245.html

    AES加密解密工具类、Base64工具是引用博客https://www.jianshu.com/p/f37f8c295057

    关于redis的配置是粘贴https://www.cnblogs.com/GodHeng/p/9301330.html的,引用了博主的JedisUtil类

    构建URL

    ResponseBean.java

    既然想要实现 restful,那我们要保证每次返回的格式都是相同的,因此建立了一个 ResponseBean 来统一返回的格式。

     

    仿造博客写了一个 CustomUnauthorizedException.java

     

    Controller

    主要实现了登陆、新增用户、通过制定id获取指定用户,其中用到了通用mapper进行查询,刚开始想用前几天看到jpa搜了下看到有类似的通用mapper。

    /**
     * JWT过滤
             * @return
            * @author guxiangdong
            * @creed: Talk is cheap,show me the code
            * @date 2019/9/25 13:59
            */
    @RestController
    @RequestMapping("/users")
    @PropertySource("classpath:config.properties")
    public class UserController {

        /**
         * RefreshToken过期时间
         */
        @Value("${refreshTokenExpireTime}")
        private String refreshTokenExpireTime;

        private final UserUtil userUtil;

        private final IUserService userService;

        @Autowired
        public UserController(UserUtil userUtil, IUserService userService) {
            this.userUtil = userUtil;
            this.userService = userService;
        }
        /**
         * 获取用户列表
         */
        @GetMapping
        @RequiresPermissions(logical = Logical.AND, value = {"user:view"})
        public ResponseBean user(@Validated BaseDto baseDto) {
            if (baseDto.getPage() == null || baseDto.getRows() == null) {
                baseDto.setPage(1);
                baseDto.setRows(10);
            }
            PageHelper.startPage(baseDto.getPage(), baseDto.getRows());
            List<UsersDto> usersDtos = userService.selectAll();
            PageInfo<UsersDto> selectPage = new PageInfo<UsersDto>(usersDtos);
            if (usersDtos == null || usersDtos.size() <= 0) {
                throw new CustomException("查询失败(Query Failure)");
            }
            Map<String, Object> result = new HashMap<String, Object>(16);
            result.put("count", selectPage.getTotal());
            result.put("data", selectPage.getList());
            return new ResponseBean(HttpStatus.OK.value(), "查询成功(Query was successful)", result);
        }

        /**
         * 登录授权
         */
        @PostMapping("/login")
        public ResponseBean login(@Validated(UserLoginValidGroup.class) @RequestBody UsersDto usersDto, HttpServletResponse httpServletResponse) {
            // 查询数据库中的帐号信息
            UsersDto usersDtoTemp = new UsersDto();
            usersDtoTemp.setAccount(usersDto.getAccount());
            usersDtoTemp = userService.selectOne(usersDtoTemp);
            if (usersDtoTemp == null) {
                throw new CustomUnauthorizedException("该帐号不存在(The account does not exist.)");
            }
            // 密码进行AES解密
            String key = AesCipherUtil.deCrypto(usersDtoTemp.getPsword());
            // 因为密码加密是以帐号+密码的形式进行加密的,所以解密后的对比是帐号+密码
            if (key.equals(usersDto.getAccount() + usersDto.getPsword())) {
                // 清除可能存在的Shiro权限信息缓存
                if (JedisUtil.exists(Constant.PREFIX_SHIRO_CACHE + usersDto.getAccount())) {
                    JedisUtil.delKey(Constant.PREFIX_SHIRO_CACHE + usersDto.getAccount());
                }
                // 设置RefreshToken,时间戳为当前时间戳,直接设置即可(不用先删后设,会覆盖已有的RefreshToken)
                String currentTimeMillis = String.valueOf(System.currentTimeMillis());
                JedisUtil.setObject(Constant.PREFIX_SHIRO_REFRESH_TOKEN + usersDto.getAccount(), currentTimeMillis, Integer.parseInt(refreshTokenExpireTime));
    //            // 从Header中Authorization返回AccessToken,时间戳为当前时间戳
                String token = JwtUtil.sign(usersDto.getAccount(), currentTimeMillis);
                httpServletResponse.setHeader("Authorization", token);
                httpServletResponse.setHeader("Access-Control-Expose-Headers", "Authorization");
                return new ResponseBean(HttpStatus.OK.value(), "登录成功(Login Success.)", null);
            } else {
                throw new CustomUnauthorizedException("帐号或密码错误(Account or Password Error.)");
            }
        }

        /**
         * 测试登录
         */
        @GetMapping("/article")
        public ResponseBean article() {
            Subject subject = SecurityUtils.getSubject();
            // 登录了返回true
            if (subject.isAuthenticated()) {
                return new ResponseBean(HttpStatus.OK.value(), "您已经登录了(You are already logged in)", null);
            } else {
                return new ResponseBean(HttpStatus.OK.value(), "你是游客(You are guest)", null);
            }
        }

        /**
         * 获取指定用户
         */
        @GetMapping("/{id}")
        @RequiresPermissions(logical = Logical.AND, value = {"user:view"})
        public ResponseBean findById(@PathVariable("id") Integer id) {
            UsersDto usersDto = userService.selectByPrimaryKey(id);
            if (usersDto == null) {
                throw new CustomException("查询失败(Query Failure)");
            }
            return new ResponseBean(HttpStatus.OK.value(), "查询成功(Query was successful)", usersDto);
        }

        /**
         * 新增用户
         */
        @PostMapping("/add")
        @RequiresPermissions(logical = Logical.AND, value = {"user:edit"})
        public ResponseBean add(@Validated(UserEditValidGroup.class) @RequestBody UsersDto UsersDto ,HttpServletResponse httpServletResponse) {
            // 判断当前帐号是否存在
            UsersDto userDtoTemp = new UsersDto();
            userDtoTemp.setAccount(UsersDto.getAccount());
            userDtoTemp = userService.selectOne(userDtoTemp);
            if (userDtoTemp != null && StringUtil.isNotBlank(userDtoTemp.getPsword())) {
                throw new CustomUnauthorizedException("该帐号已存在(Account exist.)");
            }
            UsersDto.setRegTime(new Date());
            // 密码以帐号+密码的形式进行AES加密
            if (UsersDto.getPsword().length() > Constant.PASSWORD_MAX_LEN) {
                throw new CustomException("密码最多8位(Psword up to 8 bits.)");
            }
            String key = AesCipherUtil.enCrypto(UsersDto.getAccount() + UsersDto.getPsword());
            UsersDto.setPsword(key);
            int count = userService.insert(UsersDto);
            if (count <= 0) {
                throw new CustomException("新增失败(Insert Failure)");
            }
            return new ResponseBean(HttpStatus.OK.value(), "新增成功(Insert Success)", UsersDto);
        }
    }

     

    配置 Shiro

    实现JWTToken

    JWTToken 差不多就是 Shiro 用户名密码的载体。因为前后端分离,服务器无需保存用户状态,所以不需要 RememberMe 这类功能,实现下 AuthenticationToken 接口即可

     

    实现Realm

    realm 的用于处理用户是否合法的这一块,需要我们自己实现。

    这里要重写supports方法不然会报错

    AuthenticationInfo代表了用户的角色信息集合,AuthorizationInfo代表了角色的权限信息集合,PrincipalCollection是一个身份集合,

     

     

     

     

    重写 Filter

    所有的请求都会先经过 Filter,所以我们继承官方的 BasicHttpAuthenticationFilter ,并且重写鉴权的方法代码的执行流程 preHandle(对跨域提供支持) -> isAccessAllowed(登入用户和游客看到的内容是不同的,如果在这里返回了false,请求会被直接拦截,用户看不到任何东西。所以在这里返回true,Controller中可以通过 subject.isAuthenticated() 来判断用户是否登入如果有些资源只有登入用户才能访问,只需要在方法上面加上 @RequiresAuthentication 注解即可但是这样做有一个缺点,就是不能够对GET,POST等请求进行分别过滤鉴权(因为我们重写了官方的方法),但实际上对应用影响不大) -> isLoginAttempt (检测Header里面是否包含Authorization字段,有就进行Token登录认证授权)-> executeLogin (进行登陆认证授权)。

    配置Shiro

    @Configuration

    public class ShiroConfig {

        /**

         * 配置使用自定义Realm,关闭Shiro自带的session

         * 详情见文档

    http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29

         */

        @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")

        @Bean("securityManager")

        public DefaultWebSecurityManager defaultWebSecurityManager(UsersRealm usersRealm) {

            DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();

            // 使用自定义Realm

            defaultWebSecurityManager.setRealm(usersRealm);

            // 关闭Shiro自带的session

            DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();

            DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();

            defaultSessionStorageEvaluator.setSessionStorageEnabled(false);

            subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);

            defaultWebSecurityManager.setSubjectDAO(subjectDAO);

            // 设置自定义Cache缓存

            defaultWebSecurityManager.setCacheManager(new CustomCacheManager());

            return defaultWebSecurityManager;

        }

    [顾祥东1] 

         * Shiro自带拦截器配置规则

         * rest:比如/admins/user/**=rest[user],根据请求的方法,相当于/admins/user/**=perms[user:method] ,其中method为post,get,delete等

         * port:比如/admins/user/**=port[8081],当请求的url的端口不是8081是跳转到schemal://serverName:8081?queryString,其中schmal是协议http或https等,serverName是你访问的host,8081是url配置里port的端口,queryString是你访问的url里的?后面的参数

         * perms:比如/admins/user/**=perms[user:add:*],perms参数可以写多个,多个时必须加上引号,并且参数之间用逗号分割,比如/admins/user/**=perms["user:add:*,user:modify:*"],当有多个参数时必须每个参数都通过才通过,想当于isPermitedAll()方法

         * roles:比如/admins/user/**=roles[admin],参数可以写多个,多个时必须加上引号,并且参数之间用逗号分割,当有多个参数时,比如/admins/user/**=roles["admin,guest"],每个参数通过才算通过,相当于hasAllRoles()方法。//要实现or的效果看http://zgzty.blog.163.com/blog/static/83831226201302983358670/

         * anon:比如/admins/**=anon 没有参数,表示可以匿名使用

         * authc:比如/admins/user/**=authc表示需要认证才能使用,没有参数

         * authcBasic:比如/admins/user/**=authcBasic没有参数表示httpBasic认证

         * ssl:比如/admins/user/**=ssl没有参数,表示安全的url请求,协议为https

         * user:比如/admins/user/**=user没有参数表示必须存在用户,当登入操作时不做检查

         * 详情见文档 http://shiro.apache.org/web.html#urls-

         */

        @Bean("shiroFilter")

        public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {

            ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();

            Map<String, Filter> filterMap = new HashMap<>(16);

            filterMap.put("jwt", new JwtFilter());

            factoryBean.setFilters(filterMap);

            factoryBean.setSecurityManager(securityManager);

            LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>(16);

            filterChainDefinitionMap.put("/**", "jwt");

            factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);

            return factoryBean;

        }

        /**

         * 添加注解支持

         */

        @Bean

        @DependsOn("lifecycleBeanPostProcessor")

        public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {

            DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();

            // 强制使用cglib,防止重复代理和可能引起代理出错的问题,https://zhuanlan.zhihu.com/p/29161098

            defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);

            return defaultAdvisorAutoProxyCreator;

        }

        @Bean

        public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {

            return new LifecycleBeanPostProcessor();

        }

        @Bean

        public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {

            AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();

            advisor.setSecurityManager(securityManager);

            return advisor;

        }

    }

     

    SpringBoot + Shiro + JWT集成Redis缓存(Jedis)

    Redis部分还没有太理解,大部分是仿照

    https://blog.csdn.net/qq_31897023/article/details/89082541

    大概过程

    写配置文件config.properties(Redis的配置属性)

    JedisConfig.java(JedisPool启动配置Bean,本来是直接将JedisUtil注入为Bean,每次使用直接@Autowired注入使用即可,但是在重写Shiro的CustomCache无法注入JedisUtil,所以就改成静态注入JedisPool连接池,JedisUtil工具类还是直接调用静态方法,无需@Autowired注入 取自https://blog.csdn.net/W_Z_W_888/article/details/79979103

    引用JedisUtil(Jedis工具类)、 StringUtil、 SerializableUtil

    重写Shiro的Cache保存读取和Shiro的Cache管理器

    重写Shiro的Cache保存读取和Shiro的Cache管理器

    CustomCache.java(Cache保存读取)

     
    /**
     * 重写Shiro的Cache保存读取
    */
    public class CustomCache<K,V> implements Cache<K,V> {
     
        /**
         * redis-key-前缀-shiro:cache:
         */
        public final static String PREFIX_SHIRO_CACHE = "shiro:cache:";
     
        /**
         * 过期时间-5分钟
         */
        private static final Integer EXPIRE_TIME = 5 * 60 * 1000;
     
        /**
         * 缓存的key名称获取为shiro:cache:account
         * @param key
         * @return java.lang.String
         * @author Wang926454
         * @date 2018/9/4 18:33
         */
        private String getKey(Object key){
            return PREFIX_SHIRO_CACHE + JWTUtil.getUsername(key.toString());
        }
     
        /**
         * 获取缓存
         */
        @Override
        public Object get(Object key) throws CacheException {
            if(!JedisUtil.exists(this.getKey(key))){
                return null;
            }
            return JedisUtil.getObject(this.getKey(key));
        }
     
        /**
         * 保存缓存
         */
        @Override
        public Object put(Object key, Object value) throws CacheException {
            // 设置Redis的Shiro缓存
            return JedisUtil.setObject(this.getKey(key), value, EXPIRE_TIME);
        }
     
        /**
         * 移除缓存
         */
        @Override
        public Object remove(Object key) throws CacheException {
            if(!JedisUtil.exists(this.getKey(key))){
                return null;
            }
            JedisUtil.delKey(this.getKey(key));
            return null;
        }
     
        /**
         * 清空所有缓存
         */
        @Override
        public void clear() throws CacheException {
            JedisUtil.getJedis().flushDB();
        }
     
        /**
         * 缓存的个数
         */
        @Override
        public int size() {
            Long size = JedisUtil.getJedis().dbSize();
            return size.intValue();
        }
     
        /**
         * 获取所有的key
         */
        @Override
        public Set keys() {
            Set<byte[]> keys = JedisUtil.getJedis().keys(new String("*").getBytes());
            Set<Object> set = new HashSet<Object>();
            for (byte[] bs : keys) {
                set.add(SerializableUtil.unserializable(bs));
            }
            return set;
        }
     
        /**
         * 获取所有的value
         */
        @Override
        public Collection values() {
            Set keys = this.keys();
            List<Object> values = new ArrayList<Object>();
            for (Object key : keys) {
                values.add(JedisUtil.getObject(this.getKey(key)));
            }
            return values;
        }
    }

    CustomCacheManager.java(缓存(Cache)管理器)

    /**
     * 重写Shiro缓存管理器
    */
    public class CustomCacheManager implements CacheManager {
        @Override
        public <K, V> Cache<K, V> getCache(String s) throws CacheException {
            return new CustomCache<K,V>();
        }
    }

    最后在Shiro的配置Bean里设置我们重写的缓存(Cache)管理器

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

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

    关于根据RefreshToken自动刷新AccessToken

    本身AccessToken的过期时间为5分钟(配置文件可配置),RefreshToken过期时间为30分钟(配置文件可配置),当登录后时间过了5分钟之后,当前AccessToken便会过期失效,再次带上AccessToken访问JWT会抛出TokenExpiredException异常说明Token过期,开始判断是否要进行AccessToken刷新,首先Redis查询RefreshToken是否存在,以及时间戳和过期AccessToken所携带的时间戳是否一致,如果存在且一致就进行AccessToken刷新,过期时间为5分钟(配置文件可配置),时间戳为当前最新时间戳,同时也设置RefreshToken中的时间戳为当前最新时间戳,刷新过期时间重新为30分钟过期(配置文件可配置),最终将刷新的AccessToken存放在Response的Header中的Authorization字段返

    回(前端进行获取替换,下次用新的AccessToken进行访问)

    测试

    先设置Content-Type为application/json

     

    然后填写请求参数帐号密码信息,进行请求访问,请求访问成功

     

    点击查看Header信息的Authorization属性即是Token字段

     

    访问需要权限的请求将Token字段放在Header信息的Authorization属性访问即可

     

    新增用户也需要权限的请求将Token字段放在Header信息的Authorization属性访问即可

     

     

    最后,目前自己最大问题就是可以理解代码,但是一旦自己动手做的时候就犯难,过于依赖网上搬砖,业余需要多加联系。


     [顾祥东1]配置Redis

  • 相关阅读:
    BZOJ 2300凸包+离线
    BZOJ 4140 凸包+二进制分组
    BZOJ 2178 Simpson积分
    BZOJ 4828 DP+BFS
    BZOJ 1845 Simpson积分
    BZOJ 1137 半平面交
    Codeforces 803G Periodic RMQ Problem ST表+动态开节点线段树
    Codeforces Round 411 Div.2 题解
    BZOJ 4530 LCT/线段树合并
    BZOJ 2946 SA/SAM
  • 原文地址:https://www.cnblogs.com/Treesir/p/11672422.html
Copyright © 2020-2023  润新知