【Shiro】4. Spring Boot整合Shiro

整合思路

ShiroFilter会拦截所有请求,Shrio会判断哪些请求需要做认证和授权,哪些不需要做。

如果请求中访问的是系统的公共资源,则不需要进行认证和授权的操作,ShiroFilter直接放行即可。

如果请求中访问的是系统的受限资源,若第一次访问需要做认证,认证成功后,后续的访问进行授权。ShiroFilter依赖SecurityManager来完成认证和授权的具体操作,同时SecurityManager也依赖Realm来获取认证和授权的相应数据。

公共资源不需要认证和授权,任何用户都能访问。似于登录页面,注册页面。

受限资源是需要认证成功并赋予权限才能访问的资源。类似于系统主页,用户主页。

如果Shiro整合Spring Cloud,则将相应操作整合进Spring Cloud Gateway或者zuul即可。

20210928170338.png

整合Shiro实现认证

  1. pom.xml 中引入依赖

    <dependency>
        <groupId>org.apache.shiro</groupId>
        <artifactId>shiro-spring-boot-starter</artifactId>
        <version>1.5.3</version>
    </dependency>
    复制代码
  2. 创建工厂工具类

    @Component
    public class ApplicationContextUtils implements ApplicationContextAware {
    
        private static ApplicationContext context;
    
        // 工厂就是该方法的参数,当Spring Boot启动时,该参数就会接收到工厂
        @Override
        public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
            context = applicationContext;
        }
    
        // 根据Bean的名字获取工厂中指定对象
        public static Object getBean(String beanName) {
            return applicationContext.getBean(beanName);
        }
    
    }
    复制代码

    Realm不由Spring托管,所以无法自动注入Service对象,所以在创建Realm之前,需要创建一个获取工厂的工具类。

  3. 构建shiro包,在shiro包下构建realms包

  4. realms包中构建自定义Realm

    public class UserRealm extends AuthorizingRealm {
    
        @Override
        protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
            return null;
        }
    
        // 认证操作
        @Override
        protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
            // 获取前端传入身份信息
            String username = (String) token.getPrincipal();
    
            // 从工厂中获取userService
            UserSerivce userSerivce = (UserSerivce) ApplicationContextUtils.getBean("userService");
            // 根据身份信息从DB中获取User
            User user = userSerivce.getUserByUsername(username);
    
            // 获取加密后的密码和Salt,Shiro自动进行认证
            if (user != null) {
                return new SimpleAuthenticationInfo(username, user.getPassword(), ByteSource.Util.bytes(user.getSalt()), this.getName());
            }
    
            return null;
        }
    
    }
    复制代码

    默认被Spring工厂托管的Bean的名字都是其类名首字母小写,也可以指定,比方说@Service("userService")。

  5. 创建Shiro配置类

    @Configuration
    public class ShiroConfig {
    
        // 创建ShiroFilter
        @Bean
        public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager defaultWebSecurityManager) {
            ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
    
            // 给ShiroFilter注入SecurityManager
            shiroFilterFactoryBean.setSecurityManager(defaultWebSecurityManager);
    
            // 设置默认认证路径,认证失败后会调用该接口,也算是公共资源
            shiroFilterFactoryBean.setLoginUrl("/user/login");
    
            // 配置公共资源和受限资源
            Map<String, String> map = new HashMap<>();
            // anon是过滤器的一种,表示该资源是公共资源,需要设置在authc上面
            map.put("/user/register", "anon");
            map.put("/user/login", "anon");
            // authc是过滤器的一种,表示除了设置公共资源和默认认证路径之外所有资源是受限资源
            map.put("/**", "authc");
    
            shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
    
            return shiroFilterFactoryBean;
        }
    
        // 创建具有Web特性的SecurityManager
        @Bean
        public DefaultWebSecurityManager getDefaultWebSecurityManager(@Qualifier("getRealm") Realm realm) {
            DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
    
            // 给SecurityManager注入Realm
            defaultWebSecurityManager.setRealm(realm);
    
            return defaultWebSecurityManager;
        }
    
        // 创建自定义Realm
        @Bean
        public Realm getRealm() {
            UserRealm userRealm = new UserRealm();
    
            // 设置Hash凭证校验匹配器,用来完成密码加密校验
            HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
            // 设置加密算法MD5
            hashedCredentialsMatcher.setHashAlgorithmName("md5");
            // 设置散列次数1024
            hashedCredentialsMatcher.setHashIterations(1024);
    
            // 注入凭证校验匹配器
            userRealm.setCredentialsMatcher(hashedCredentialsMatcher);
    
            return userRealm;
        }
    
    }
    复制代码

    Spring在SecurityManager中注入自定义Realm时,因为工厂中已经有多个Realm,其中包括Shiro中的系统Realm和自定义Realm,所以不知道注入谁到SecurityManager中。我们需要指定下面getRealm方法创建的Realm,而getRealm方法创建的Bean的名字默认就是方法名getRealm,因此需要将getRealm放入@Qualifier中指定Bean的注入。

  6. 设计数据库

    user表中需要在基础上添加salt字段。

  7. 创建随机生成Salt的工具类

    public class SaltUtils {
    
        /**
         * 随机生成定长的Salt
         * @param n 长度
         * @return Salt
         */
        public static String getSalt(int n) {
            char[] chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789~!@#$%^&*()_+|{}:.,<>?/".toCharArray();
            
            StringBuilder stringBuilder = new StringBuilder();
    
            for (int i = 0; i < n; i++) {
                char c = chars[new Random().nextInt(chars.length)];
                stringBuilder.append(c);
            }
    
            return stringBuilder.toString();
        }
        
    }
    复制代码
  8. 创建Controller

    默认DB、MP和Service都已经配置并编写完毕。

    以下代码为了方便展示,将业务写在Controller中,实际开发时需要提取进Service。

    @RestController
    @RequestMapping("/user")
    public class UserContoller {
    
        @Autowired
        private UserService userService;
    
        @PostMapping("register")
        public Response register(@RequestBody UserRegisterDto userRegisterDto) {
            try {
                // 生成8位Salt
                String salt = SaltUtils.getSalt(8);
                // MD5 + Hash + Salt给密码加密
                Md5Hash md5Hash = new Md5Hash(userRegisterDto.getPassword(), salt, 1024);
                // 注册
                userService.register(userRegisterDto.getUsername(), md5Hash.toHex(), salt);
                // 注册成功
                return Response.ok().message("注册成功");
            } catch (Exception e) {
                return Response.error(ResponseEnum.UNIFIED_ERROR).message("注册失败");
            }
        }
    
        @PostMapping("login")
        public Response login(@RequestBody UserLoginDto userLoginDto) {
            Subject subject = SecurityUtils.getSubject();
    
            try {
                // 登录,Shiro自动认证
                subject.login(new UsernamePasswordToken(userLoginDto.getUsername(), userLoginDto.getPassword()));
                // 认证成功
                return Response.ok().message("登录成功");
            } catch (UnknownAccountException e) {
                return Response.error(ResponseEnum.UNKNOWN_ACCOUNT_ERROR);
            } catch (IncorrectCredentialsException e) {
                return Response.error(ResponseEnum.INCORRECT_CREDENTIALS_ERROR);
            }
        }
    
        @GetMapping("logout")
        public Response login() {
            Subject subject = SecurityUtils.getSubject();
    
            subject.logout();
    
            return Response.ok().message("退出成功");
        }
    
    }
    复制代码

    在Web环境中,只要Shiro配置类中配置了SecurityManager,那么Spring就会将其托管,无需在Controller中单独创建。

Shiro过滤器

Shiro提供多个默认的过滤器,我们可以用这些过滤器来配置控制指定URL的权限。

常用的有两种:anon和authc。

过滤器缩写 过滤器 功能
anon AnonymousFilter 指定URL可以匿名访问,无需认证和授权
authc FormAuthenticationFilter 指定URL需要form表单登录,默认会从请求中获取username,password , rememberMe等参数并尝试登录,如果登录不了就会跳转到setLoginUrl配置的认证路径。我们也可以用这个过滤器做默认的登录逻辑,但是一般都是我们自己在控制器写登录逻辑的,因为可以定制出错返回的信息。
authcBasic BasicHttpAuthenticationFilter 指定URL需要basic登录
logout LogoutFilter 登出过滤器,配置指定URL就可以实现退出功能,非常方便
noSessionCreation NoSessionCreationFilter 禁止创建会话
perms PermissionsAuthorizationFilter 需要指定权限才能访问
port PortFilter 需要指定端口才能访问
rest HttpMethodPermissionFilter 将http请求方法转化成相应的动词来构造一个权限字符串,这个感觉意义不大,有兴趣自己看源码的注释
roles RolesAuthorizationFilter 需要指定角色才能访问
ssl SslFilter 需要https请求才能访问
user UserFilter 需要已登录或 "记住我" 的用户才能访问

整合Shiro实现授权

前文说了,Shiro提供了三种授权方式,在前后端分离的系统中,我们主要使用注解式实现授权。后端只负责写接口传递用户的权限信息,具体前台如何显示由前端负责。

1. @RequiresRoles注解

该注解标注在接口方法上,表示是指定的角色才可以访问该接口。

@GetMapping
@RequiresRoles("admin")
public Response findAll() {
    ...
}
复制代码

也可以设置多个角色,表示同时具有指定的所有角色才能访问该接口。

@GetMapping
@RequiresRoles("admin")
public Response findAll() {
    ...
}
复制代码

2. @RequiresPermissions注解

该注解标注在接口方法上,表示有指定访问权限才可以访问该接口。

@GetMapping
@RequiresPermissions("user:*:*")
public Response findAll() {
    ...
}
复制代码

也可以设置多个访问权限,表示同时具有指定的所有访问权限才能访问该接口。

@GetMapping
@RequiresPermissions(value = {"user:*:*", "product:*:*"})
public Response findAll() {
    ...
}
复制代码

3. 授权数据持久化

在实际项目中,权限数据需要在DB中获,因此我们要设计角色表权限表

通常情况下,一般是这样设计的:用户 <—(* *)—> 角色,角色 <—(* *)—> 权限,权限 <—(1 1)—> 资源

20210929162150.png

  1. 设计用户表

    20210929182304.png

  2. 设计角色表

    表结构

    20210929175606.png

    数据案例

    id role
    1418430206598709249 admin
    1418430206598709250 user
  3. 设计权限表

    表结构

    20210929175819.png

    permission为权限标识符,url为权限标识符对应的URL。

    数据案例

    id permission url
    1418430206598709251 user:*:*
    1418430206598709252 user:find:1418430206598709252
  4. 设计用户-角色表

    表结构

    20210929180038.png

  5. 设计角色-权限表

    表结构

    20210929180350.png

4. 授权流程

  1. 构建Role和Permission的Bean

    @Data
    public class Role implements Serializable {
        
        private String id;
        
        private String role;
        
    }
    复制代码
    @Data
    public class Permission implements Serializable {
    
        private String id;
    
        private String permission;
    
        private String url;
    
    }
    复制代码

    所有的Bean必须序列化,因为后文要将该Bean存入Redis。

  2. 在User类中添加角色集合

    @Data
    public class User implements Serializable {
    
        private String id;
    
        private String username;
    
        private String password;
    
        private String salt;
    
        private List<String> roles;
    
    }
    复制代码
  3. 在Role类中添加权限集合

    @Data
    public class Role implements Serializable {
        
        private String id;
        
        private String role;
        
        List<Permission> permissions;
        
    }
    复制代码
  4. 在UserMapper和UserService中提供通过username查询单个用户的角色集合的接口

    具体使用逻辑实现使用MP或者Mybatis不同,自行选择即可。

    List<Role> getRolesByUsername(String username);
    复制代码
  5. 在UserMapper和UserService中提供通过role_id查询单个角色的权限集合的接口

    List<Permission> getPermissionsByRoleId(String roleId);
    复制代码

    具体使用逻辑实现使用MP或者Mybatis不同,自行选择即可。

  6. 整合Realm中授权的方法

    public class UserRealm extends AuthorizingRealm {
    
        // 授权操作
        @Override
        protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
            // 获取身份信息
            String username = (String) principals.getPrimaryPrincipal();
    
            // 从工厂中取出UserService
            UserService userService = (UserService) ApplicationContextUtils.getBean("userService");
    
            // 注入该角色的角色和权限
            List<Role> roles = userService.getRolesByUsername(username);
            if (roles != null) {
                SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
                roles.forEach(role -> {
                    // 注入角色
                    simpleAuthorizationInfo.addRole(role.getRole());
    
                    // 获取权限集合
                    List<Permission> permissions = userService.getPermissionsByRoleId(role.getId());
                    // 也可以使用该方法判断集合是否不为空
                    if (!CollectionUtils.isEmpty(permissions)) {
                        permissions.forEach(permission -> {
                            // 注入权限
                            simpleAuthorizationInfo.addStringPermission(permission.getPermission());
                        });
                    }
                });
                return simpleAuthorizationInfo;
            }
            
            return null;
        }
    
        @Override
        protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
            // 获取前端传入身份信息
            String username = (String) token.getPrincipal();
    
            // 获取userService
            UserSerivce userSerivce = (UserSerivce) ApplicationContextUtils.getBean("userService");
            // 根据身份信息从DB中获取User
            User user = userSerivce.getUserByUsername(username);
    
            // 获取加密的密码和Salt,Shiro自动进行认证
            if (user != null) {
                return new SimpleAuthenticationInfo(username, user.getPassword(), ByteSource.Util.bytes(user.getSalt()), this.getName());
            }
    
            return null;
        }
    
    }
    复制代码
  7. 给Controller接口添加角色

    @GetMapping("info")
    @RequiresRoles(value = {"admin", "user"})
    public Response info() {
        ...
    }
    复制代码
  8. 给Controller接口添加权限

    @GetMapping("info")
    @RequiresPermissions(value = {"user:find:*", "admin:*:*"})
    public Response info() {
        ...
    }
    复制代码

整合Redis实现缓存

在前后端实际开发中,我们会大量使用注解来控制权限。在每一次执行认证或授权的操作时,Shiro都会去DB中查询身份或者权限信息。已知,身份信息和权限信息是不会经常变动的,且十分繁杂。如果同时有很多用户对系统做操作,每一次操作Shiro都需要去DB中查询身份或权限,无疑增加了数据库的压力,耗费了大量的计算资源。

为了避免上述问题,我们在设计身份和权限时,都会添加缓存

所谓缓存,就是如果系统对该用户已经认证或授权过一次,就把该用户的身份信息或权限信息给缓存起来,当改用户再次做认证或者授权时,Shiro直接去缓存中获取给用户的身份信息和权限信息。

1. 实现流程

Shiro中提供了CacheManager作为缓存管理器,具体实现流程如下

20210930101858.png

2. 具体实现

Shiro默认的缓存为EhCache,只能实现本地缓存,如果应用服务器宕机,则缓存数据丢失。在实际生产实践中,一般都配合Redis实现分布式缓存,缓存数据独立于应用服务器之外,提高数据的安全性。

本文就不再阐述Shiro与EhCache的整合了,直接整合Redis。

  1. pom.xml 中引入依赖

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    复制代码
  2. application.yml 中配置Redis

    Spring:
      ...
      # Redis配置
      redis:
        port: 6379
        host: localhost
        database: 0
    复制代码
  3. 在shiro包中创建cache包

  4. 在cache包中创建Redis缓存管理器

    public class RedisCacheManager implements CacheManager {
    
        // 每次执行缓存时,都会调用该方法,自动注入s 
        // 参数s为在ShiroConfig中设置的认证缓存或授权缓存的名字
        @Override
        public <K, V> Cache<K, V> getCache(String s) throws CacheException {
            // 自动去RedisCahce中找具体实现
            return new RedisCache<K, V>(s);
        }
        
    }
    复制代码

    Shiro中提供了一个全局缓存管理器接口CacheManager,如果要实现自定义缓存管理器,必须要让自定义缓存管理器实现CacheManager接口。

  5. 在cache包中创建Reids缓存

    public class RedisCache<K, V> implements Cache<K, V> {
    
        // 认证缓存或者授权缓存名名字
        private String cacheName;
    
        public RedisCache() {
    
        }
    
        public RedisCache(String cacheName) {
            this.cacheName = cacheName;
        }
    
        // 获取RedisTemplate实例
        private RedisTemplate getRedisTemplate() {
            // 从工厂中取出RedisTemplate实例
            RedisTemplate redisTemplate = (RedisTemplate) ApplicationContextUtils.getBean("redisTemplate");
            // 将Key的序列化规则设置为字符串
            redisTemplate.setKeySerializer(new StringRedisSerializer());
            // 将Hash中field的序列化规则设置为字符串
            redisTemplate.setHashKeySerializer(new StringRedisSerializer());
    
            return redisTemplate;
        }
    
        // 获取缓存
        @Override
        public V get(K k) throws CacheException {
            return (V) getRedisTemplate().opsForHash().get(this.cacheName, k.toString())
        }
    
        // 存入缓存
        @Override
        public V put(K k, V v) throws CacheException {
            getRedisTemplate().opsForHash().put(this.cacheName, k.toString(), v);
    
            return null;
        }
    
        // 删除缓存
        @Override
        public V remove(K k) throws CacheException {
            return (V) getRedisTemplate().opsForHash().delete(this.cacheName, k.toString());;
        }
    
        // 清空所有缓存
        @Override
        public void clear() throws CacheException {
            getRedisTemplate().delete(this.cacheName);
        }
    
        // 缓存数量
        @Override
        public int size() {
            return getRedisTemplate().opsForHash().size(this.cacheName).intValue();
        }
    
        // 获取所有Key
        @Override
        public Set<K> keys() {
            return getRedisTemplate().opsForHash().keys(this.cacheName);
        }
    
        // 获取所有Value
        @Override
        public Collection<V> values() {
            return getRedisTemplate().opsForHash().values(this.cacheName);
        }
    
    }
    复制代码

    CacheManager底层真正实现缓存的是Cache<K,V>,因此还需要创建一个RedisCache才能真正实现自定义缓存,RedisCache同样要实现Cache接口。

    RedisCache中所有接口全部使用Redis来实现,从而实现Shiro与Redis的整合,至于什么时候调用RedisCache中的什么接口,由Shiro来决定,我们只需定义即可。

    Redis对于Shiro身份和权限的管理使用的数据结构是Hash,Key对应cacheName,field对应k,value对应v。

  6. 在ShiroConfig中配置缓存管理器

    @Configuration
    public class ShiroConfig {
    
        // 创建ShiroFilter
        @Bean
        public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager defaultWebSecurityManager) {
            ...
        }
    
        // 创建具有Web特性的SecurityManager
        @Bean
        public DefaultWebSecurityManager getDefaultWebSecurityManager(@Qualifier("getRealm") Realm realm) {
            ...
        }
    
        // 创建自定义Realm
        @Bean
        public Realm getRealm() {
           	...
    
            // 注入缓存管理器
            userRealm.setCacheManager(new RedisCacheManager());
            // 开启全局缓存
            userRealm.setCachingEnabled(true);
            // 开启认证缓存,并命名(真实的认证缓存名为cacheName)
            userRealm.setAuthenticationCachingEnabled(true);
            userRealm.setAuthenticationCacheName("authenticationCache");
            // 开启授权缓存,并命名(真实的授权缓存名为完整包名+cacheName)
            userRealm.setAuthorizationCachingEnabled(true);
            userRealm.setAuthorizationCacheName("authorizationCache");
    
            return userRealm;
        }
    
    }
    复制代码
  7. 序列化和反序列化Salt

    按照上文的配置方式,Salt是直接被ByteSource存储,没有被序列化的。

    // 获取加密的密码和Salt,Shiro自动进行认证
    if (user != null) {
        return new SimpleAuthenticationInfo(username, user.getPassword(), ByteSource.Util.bytes(user.getSalt()), this.getName());
    }
    复制代码

    在Shiro认证过程中,Salt也要随着Username和Password一起被存入缓存。Username和Password被String序列化和反序列化,而Salt(ByteSource)也需要进行序列化和反序列化。

    在shiro包中创建salt包,在salt包中创建能够被Redis序列化和反序列化ByteSource

    public class MyByteSource implements ByteSource, Serializable {
    
        private byte[] bytes;
        private String cachedHex;
        private String cachedBase64;
    
        public MyByteSource() {
    
     }
    
     public MyByteSource(byte[] bytes) {
            this.bytes = bytes;
        }
    
        public MyByteSource(char[] chars) {
            this.bytes = CodecSupport.toBytes(chars);
        }
    
        public MyByteSource(String string) {
            this.bytes = CodecSupport.toBytes(string);
        }
    
        public MyByteSource(ByteSource source) {
            this.bytes = source.getBytes();
        }
    
        public MyByteSource(File file) {
            this.bytes = (new MyByteSource.BytesHelper()).getBytes(file);
        }
    
        public MyByteSource(InputStream stream) {
            this.bytes = (new MyByteSource.BytesHelper()).getBytes(stream);
        }
    
        public static boolean isCompatible(Object o) {
            return o instanceof byte[] || o instanceof char[] || o instanceof String || o instanceof ByteSource || o instanceof File || o instanceof InputStream;
        }
    
        @Override
        public byte[] getBytes() {
            return this.bytes;
        }
    
        @Override
        public boolean isEmpty() {
            return this.bytes == null || this.bytes.length == 0;
        }
    
        @Override
        public String toHex() {
            if (this.cachedHex == null) {
                this.cachedHex = Hex.encodeToString(this.getBytes());
            }
    
            return this.cachedHex;
        }
    
        @Override
        public String toBase64() {
            if (this.cachedBase64 == null) {
                this.cachedBase64 = Base64.encodeToString(this.getBytes());
            }
    
            return this.cachedBase64;
        }
    
        @Override
        public String toString() {
            return this.toBase64();
        }
    
        @Override
        public int hashCode() {
            return this.bytes != null && this.bytes.length != 0 ? Arrays.hashCode(this.bytes) : 0;
        }
    
        @Override
        public boolean equals(Object o) {
            if (o == this) {
                return true;
            } else if (o instanceof ByteSource) {
                ByteSource bs = (ByteSource)o;
                return Arrays.equals(this.getBytes(), bs.getBytes());
            } else {
                return false;
            }
        }
    
        private static final class BytesHelper extends CodecSupport {
            private BytesHelper() {
            }
    
            public byte[] getBytes(File file) {
                return this.toBytes(file);
            }
    
            public byte[] getBytes(InputStream stream) {
                return this.toBytes(stream);
            }
        }
    
    }
    复制代码

    注意,不能将MyByteSource继承SimpleByteSource,因为SimpleByteSource没有无参构造,因此只能实现序列化而不能实现反序列化,因为Salt被Redis反序列化时,需要调用MyByteSource的无参构造,因此MyByteSource只能实现ByteSource。

    修改认证时使用的ByteSource

    // 获取加密的密码和Salt,Shiro自动进行认证
    if (user != null) {
        return new SimpleAuthenticationInfo(username, user.getPassword(), new MyByteSource(user.getSalt()), this.getName());
    }
    复制代码

猜你喜欢

转载自juejin.im/post/7013690467401334797