【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。

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

整合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

    数据案例

    idrole
    1418430206598709249admin
    1418430206598709250user
  3. 设计权限表

    表结构

    20210929175819.png

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

    数据案例

    idpermissionurl
    1418430206598709251user:*:*
    1418430206598709252user: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());
    }
    复制代码
分类:
后端
标签: