整合思路
ShiroFilter会拦截所有请求,Shrio会判断哪些请求需要做认证和授权,哪些不需要做。
如果请求中访问的是系统的公共资源,则不需要进行认证和授权的操作,ShiroFilter直接放行即可。
如果请求中访问的是系统的受限资源,若第一次访问需要做认证,认证成功后,后续的访问进行授权。ShiroFilter依赖SecurityManager来完成认证和授权的具体操作,同时SecurityManager也依赖Realm来获取认证和授权的相应数据。
公共资源不需要认证和授权,任何用户都能访问。似于登录页面,注册页面。
受限资源是需要认证成功并赋予权限才能访问的资源。类似于系统主页,用户主页。
如果Shiro整合Spring Cloud,则将相应操作整合进Spring Cloud Gateway或者zuul即可。
整合Shiro实现认证
-
pom.xml
中引入依赖<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring-boot-starter</artifactId> <version>1.5.3</version> </dependency> 复制代码
-
创建工厂工具类
@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之前,需要创建一个获取工厂的工具类。
-
构建shiro包,在shiro包下构建realms包
-
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")。
-
创建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的注入。
-
设计数据库
user表中需要在基础上添加salt字段。
-
创建随机生成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(); } } 复制代码
-
创建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)—> 资源
-
设计用户表
-
设计角色表
表结构:
数据案例:
id role 1418430206598709249 admin 1418430206598709250 user -
设计权限表
表结构:
permission为权限标识符,url为权限标识符对应的URL。
数据案例:
id permission url 1418430206598709251 user:*:* 1418430206598709252 user:find:1418430206598709252 -
设计用户-角色表
表结构:
-
设计角色-权限表
表结构:
4. 授权流程
-
构建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。
-
在User类中添加角色集合
@Data public class User implements Serializable { private String id; private String username; private String password; private String salt; private List<String> roles; } 复制代码
-
在Role类中添加权限集合
@Data public class Role implements Serializable { private String id; private String role; List<Permission> permissions; } 复制代码
-
在UserMapper和UserService中提供通过username查询单个用户的角色集合的接口
具体使用逻辑实现使用MP或者Mybatis不同,自行选择即可。
List<Role> getRolesByUsername(String username); 复制代码
-
在UserMapper和UserService中提供通过role_id查询单个角色的权限集合的接口
List<Permission> getPermissionsByRoleId(String roleId); 复制代码
具体使用逻辑实现使用MP或者Mybatis不同,自行选择即可。
-
整合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; } } 复制代码
-
给Controller接口添加角色
@GetMapping("info") @RequiresRoles(value = {"admin", "user"}) public Response info() { ... } 复制代码
-
给Controller接口添加权限
@GetMapping("info") @RequiresPermissions(value = {"user:find:*", "admin:*:*"}) public Response info() { ... } 复制代码
整合Redis实现缓存
在前后端实际开发中,我们会大量使用注解来控制权限。在每一次执行认证或授权的操作时,Shiro都会去DB中查询身份或者权限信息。已知,身份信息和权限信息是不会经常变动的,且十分繁杂。如果同时有很多用户对系统做操作,每一次操作Shiro都需要去DB中查询身份或权限,无疑增加了数据库的压力,耗费了大量的计算资源。
为了避免上述问题,我们在设计身份和权限时,都会添加缓存。
所谓缓存,就是如果系统对该用户已经认证或授权过一次,就把该用户的身份信息或权限信息给缓存起来,当改用户再次做认证或者授权时,Shiro直接去缓存中获取给用户的身份信息和权限信息。
1. 实现流程
Shiro中提供了CacheManager作为缓存管理器,具体实现流程如下
2. 具体实现
Shiro默认的缓存为EhCache,只能实现本地缓存,如果应用服务器宕机,则缓存数据丢失。在实际生产实践中,一般都配合Redis实现分布式缓存,缓存数据独立于应用服务器之外,提高数据的安全性。
本文就不再阐述Shiro与EhCache的整合了,直接整合Redis。
-
pom.xml
中引入依赖<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> 复制代码
-
在
application.yml
中配置RedisSpring: ... # Redis配置 redis: port: 6379 host: localhost database: 0 复制代码
-
在shiro包中创建cache包
-
在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接口。
-
在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。
-
在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; } } 复制代码
-
序列化和反序列化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()); } 复制代码