Skip to main content

官方文档

https://shiro.apache.org/tutorial.html

超详细 Spring Boot 整合 Shiro 教程! - 腾讯云开发者社区-腾讯云 (tencent.com)

shiro

Shiro 的架构有 3 个主要概念SubjectSecurityManagerRealms. 下图是这些组件如何交互的高级概述

image-20220813222548584

Subject

既可以指用户,也可以指第 3 方进程、cron 作业、守护进程。或者说是与软件交互的任何东西

image-20220813222122163

SecurityManager

Realm

充当 Shiro 和应用程序安全数据之间的“桥梁”或“连接器”。

配置 Shiro 时,必须指定至少一个 Realm 用于身份验证或授权。可以配置多个 Realm ,但SecurityManager至少需要一个。

shiro 详细架构图

image-20220813222959085

shiro过滤器

过滤器简称对应的 java 类含义
anonorg.apache.shiro.web.filter.authc.AnonymousFilter认证(登录)才能使用
authcorg.apache.shiro.web.filter.authc.FormAuthenticationFilter指定 rul 需要 form 表单登录,默认会从请求中获取usernamepasswordrememberMe参数并尝试登录。登陆失败会跳转到 login 的 url。可以使用该过滤器做默认的登录逻辑。
authcBasicorg.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter指定 url 需要 basic 登录
userorg.apache.shiro.web.filter.authc.UserFilter已登录或记住我的用户访问
portorg.apache.shiro.web.filter.authz.PortFilter指定端口才可以访问
restorg.apache.shiro.web.filter.authz.HttpMethodPermissionFilter将请求方法转化成相应的动词来构造一个权限字符串
rolesorg.apache.shiro.web.filter.authz.RolesAuthorizationFilter指定角色访问
sslorg.apache.shiro.web.filter.authz.SslFilterhttps 访问
permsorg.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter指定权限可以访问
logoutorg.apache.shiro.web.filter.authc.LogoutFilter登出过滤器

anon:例子/admins/**=anon 没有参数,表示可以匿名使用。

authc:例如/admins/user/**=authc 表示需要认证(登录)才能使用,FormAuthenticationFilter 是表单认证,没有参数

roles:例子/admins/user/=roles[admin],参数可以写多个,多个时必须加上引号,并且参数之间用逗号分割,当有多个参数时,例如 admins/user/=roles["admin,guest"],每个参数通过才算通过,相当于 hasAllRoles()方法。

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

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 里的?后面的参数。

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

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

user:例如/admins/user/**=user 没有参数表示必须存在用户, 身份认证通过或通过记住我认证通过的可以访问,当登入操作时不做检查

注:

anon,authcBasic,auchc,user 是认证过滤器,

perms,roles,ssl,rest,port 是授权过滤器

认证&授权

登录

//获取用户,其会自动绑定到当前线程

Subject subject = SecurityUtils.getSubject();

//构建待认证 token

UsernamePasswordToken token = new UsernamePasswordToken("zhang", "123");

//登录,即身份验证

subject.login(token);

//判断是否已经认证

subject.isAuthenticated()

//登出 subject.logout(token);

shiro过滤器

过滤器简称对应的 java 类含义
anonorg.apache.shiro.web.filter.authc.AnonymousFilter认证(登录)才能使用
authcorg.apache.shiro.web.filter.authc.FormAuthenticationFilter指定 rul 需要 form 表单登录,默认会从请求中获取usernamepasswordrememberMe参数并尝试登录。登陆失败会跳转到 login 的 url。可以使用该过滤器做默认的登录逻辑。
authcBasicorg.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter指定 url 需要 basic 登录
userorg.apache.shiro.web.filter.authc.UserFilter已登录或记住我的用户访问
portorg.apache.shiro.web.filter.authz.PortFilter指定端口才可以访问
restorg.apache.shiro.web.filter.authz.HttpMethodPermissionFilter将请求方法转化成相应的动词来构造一个权限字符串
rolesorg.apache.shiro.web.filter.authz.RolesAuthorizationFilter指定角色访问
sslorg.apache.shiro.web.filter.authz.SslFilterhttps 访问
permsorg.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter指定权限可以访问
logoutorg.apache.shiro.web.filter.authc.LogoutFilter登出过滤器

anon:例子/admins/**=anon 没有参数,表示可以匿名使用。

authc:例如/admins/user/**=authc 表示需要认证(登录)才能使用,FormAuthenticationFilter 是表单认证,没有参数

roles:例子/admins/user/=roles[admin],参数可以写多个,多个时必须加上引号,并且参数之间用逗号分割,当有多个参数时,例如 admins/user/=roles["admin,guest"],每个参数通过才算通过,相当于 hasAllRoles()方法。

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

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 里的?后面的参数。

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

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

user:例如/admins/user/**=user 没有参数表示必须存在用户, 身份认证通过或通过记住我认证通过的可以访问,当登入操作时不做检查

注:

anon,authcBasic,auchc,user 是认证过滤器,

perms,roles,ssl,rest,port 是授权过滤器

DEMO

设计数据库(RBAC 模型)

分为 5 个表,分别是 mage_user,mage_role,mage_permission,mage_user_role,mage_role_permission 五张表 mage_user,mage_role 是多对多关系,关联表为 mage_user_role。mage_permission,mage_role 是多对多关系,关联表为 mage_role_permission

image-20220828170552950

数据库中共有两个用户张三、李四

张三是 admin 角色,拥有user:add user:delete user:update user:query 四个权限

李四是 consumer 角色 拥有 user:query权限

引入依赖

<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
</dependency>

自定义 Realm

package com.mqb.auth.infra.config;

import com.mqb.auth.infra.realm.MageRealm;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.HashMap;

/**
* @author qingbomy
* @date 2022/8/13 20:42
*/
@Configuration
public class ShiroConfiguration {
/**
* 自定义Realm
* @return
*/
@Bean
public MageRealm mageRealm(){
return new MageRealm();
}

/**
*
* @param mageRealm 自定义Realm
* @param sessionManager session管理器 可以去掉shiro登录时url里的JSESSIONID导致404的问题
* @return SecurityManager 安全管理器
*/
@Bean
@Autowired
public SecurityManager securityManager(MageRealm mageRealm, @Qualifier("sessionManager") DefaultWebSessionManager sessionManager){
DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
defaultWebSecurityManager.setSessionManager(sessionManager);
defaultWebSecurityManager.setRealm(mageRealm);
return defaultWebSecurityManager;
}

/**
*
* @param securityManager 安全管理器
* @return ShiroFilterFactoryBean
*/
@Bean
@Autowired
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager){
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
shiroFilterFactoryBean.setLoginUrl("/toLogin");
shiroFilterFactoryBean.setUnauthorizedUrl("/unauthor");
shiroFilterFactoryBean.setSuccessUrl("/home");
//为url添加权限
HashMap<String, String> filterChainDefinitionMap = new HashMap<>(16);
//后面可以从数据库中查询
//也可以在controller方法上添加
filterChainDefinitionMap.put("/toLogin","anon");
filterChainDefinitionMap.put("/user/add","perms[user:add]");
filterChainDefinitionMap.put("/user/update","perms[user:update]");
// filterChainDefinitionMap.put("/user/query","perms[user:query]");//测试使用注解完成权限设置
filterChainDefinitionMap.put("/user/delete","perms[user:delete]");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}


// 去掉shiro登录时url里的JSESSIONID
@Bean
public DefaultWebSessionManager sessionManager() {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
sessionManager.setSessionIdUrlRewritingEnabled(false);
return sessionManager;
}


/**
* 开启Shiro的注解(如@RequiresRoles,@RequiresPermissions)
* @param SecurityManager 安全管理器
* @return
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(@Qualifier("securityManager") SecurityManager SecurityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(SecurityManager);
return authorizationAttributeSourceAdvisor;
}


@Bean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator();
creator.setProxyTargetClass(true);
return creator;
}

}

shiro 配置类

package com.mqb.auth.infra.config;

import com.mqb.auth.infra.realm.MageRealm;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.HashMap;

/**
* @author qingbomy
* @date 2022/8/13 20:42
*/
@Configuration
public class ShiroConfiguration {
/**
* 自定义Realm
* @return
*/
@Bean
public MageRealm mageRealm(){
return new MageRealm();
}

/**
*
* @param mageRealm 自定义Realm
* @param sessionManager session管理器 可以去掉shiro登录时url里的JSESSIONID导致404的问题
* @return SecurityManager 安全管理器
*/
@Bean
@Autowired
public SecurityManager securityManager(MageRealm mageRealm, @Qualifier("sessionManager") DefaultWebSessionManager sessionManager){
DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
defaultWebSecurityManager.setSessionManager(sessionManager);
defaultWebSecurityManager.setRealm(mageRealm);
return defaultWebSecurityManager;
}

/**
*
* @param securityManager 安全管理器
* @return ShiroFilterFactoryBean
*/
@Bean
@Autowired
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager){
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
shiroFilterFactoryBean.setLoginUrl("/toLogin");
shiroFilterFactoryBean.setUnauthorizedUrl("/unauthor");
shiroFilterFactoryBean.setSuccessUrl("/home");
//为url添加权限
HashMap<String, String> filterChainDefinitionMap = new HashMap<>(16);
//后面可以从数据库中查询
//也可以在controller方法上添加
filterChainDefinitionMap.put("/toLogin","anon");
// filterChainDefinitionMap.put("/user/add","perms[user:add]"); //测试使用注解完成权限设置
filterChainDefinitionMap.put("/user/update","perms[user:update]");
filterChainDefinitionMap.put("/user/query","perms[user:query]");
filterChainDefinitionMap.put("/user/delete","perms[user:delete]");


shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}


// 去掉shiro登录时url里的JSESSIONID
@Bean
public DefaultWebSessionManager sessionManager() {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
sessionManager.setSessionIdUrlRewritingEnabled(false);
return sessionManager;
}

/**
* 开启Shiro的注解(如@RequiresRoles,@RequiresPermissions)
* @param SecurityManager 安全管理器
* @return
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(@Qualifier("securityManager") SecurityManager SecurityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(SecurityManager);
return authorizationAttributeSourceAdvisor;
}


@Bean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator();
creator.setProxyTargetClass(true);
return creator;
}

}

Controller

    @PostMapping("/login")
public String login(UserVo userVo, Model model) {
UsernamePasswordToken token = new UsernamePasswordToken(userVo.getUsername(), userVo.getPassword());
//获取当前用户
Subject subject = SecurityUtils.getSubject();
try {
subject.login(token);
model.addAttribute("msg","登陆成功");
return "index";
}catch (UnknownAccountException e){
log.error(e.getMessage());
// model.addAttribute("msg","用户名不存在");
model.addAttribute("msg",e.getMessage());
return "login";
}catch (IncorrectCredentialsException e ){
log.error("密码错误");
model.addAttribute("msg","密码错误");
return "login";
}catch (LockedAccountException e){
log.error("账户被封禁");
model.addAttribute("msg","账户被封禁");
return "login";
}

几个页面的 controller

@GetMapping("/user/add")
public String add(){
return "user/add";
}
@GetMapping("/user/update")
public String update(){
return "user/update";
}
@GetMapping("/user/delete")
public String delete(){
return "/user/delete";
}
//要求当前用户拥有consumer角色身份才可以,并且该身份拥有user:query才可以访问query
@RequiresPermissions("user:query")
@RequiresRoles("consumer")
@GetMapping("/user/query")
public String query(){
return "/user/query";
}

启动项目使用张三登录

发现除了/user/query 其他的 url 都可以访问。原因是 张三虽然拥有user:query权限,但是他没有consumer角色的身份。

使用李四进行 登录

发现除了/user/query能访问之外其他的都无权访问。原因是 李四是consumer角色,仅仅拥有/user/query的权限

缓存管理

  1. 配置 Redis 序例化方式

  2. 实现 shiro 的 Cache 接口

  3. 实现 shiro 的 CacheManager 接口

  4. 自定义 Realm 中设置 CacheManager

配置序例化方式

    @Bean
public RedisConnectionFactory connectionFactory(){
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setPort(port);
redisStandaloneConfiguration.setHostName(host);
return new JedisConnectionFactory(redisStandaloneConfiguration);
}
@Bean
public RedisTemplate<String, Object> redisTemplate( RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);

//使用jackson序列化工具(default JDK serialization)序例化value
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<Object>(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
//设置任何属性可见
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
//序列化的时候将类名称序列化到json串中
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
//设置输入时忽略JSON字符串中存在而Java对象实际没有的属性
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

//使用redis自带的字符串序列化工具序列化key
RedisSerializer<String> redisSerializer = new StringRedisSerializer();
//key
redisTemplate.setKeySerializer(redisSerializer);
redisTemplate.setHashKeySerializer(redisSerializer);
//value
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
//初始化redisTemplate
redisTemplate.afterPropertiesSet();
return redisTemplate;
}

实现 cache 接口

package com.mqb.auth.infra.config.cache;

import lombok.Getter;
import lombok.Setter;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheException;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.Collection;
import java.util.Set;

/**
* @author qingbomy
* @date 2022/9/5 23:39
*/
@Setter
@Getter
@Component
public class RedisCache<K, V> implements Cache<K, V> {
@Resource(name = "redisTemplate")
private RedisTemplate<String, Object> redisTemplate;
private String cacheName;

@Override
public V get(K k) throws CacheException {

return (V) redisTemplate.opsForHash().get(cacheName, k.toString());
}

@Override
public V put(K k, V v) throws CacheException {
redisTemplate.opsForHash().put(cacheName, k.toString(), v);
return v;
}

@Override
public V remove(K k) throws CacheException {
V value = (V) redisTemplate.opsForHash().get(cacheName, k.toString());
redisTemplate.opsForHash().delete(cacheName, k.toString());
return value;
}

@Override
public void clear() throws CacheException {
redisTemplate.delete(cacheName);
}

@Override
public int size() {
return redisTemplate.opsForHash().size(cacheName).intValue();
}

@Override
public Set<K> keys() {
return (Set<K>) redisTemplate.opsForHash().keys(cacheName);

}

@Override
public Collection<V> values() {

return (Collection<V>) redisTemplate.opsForHash().values(cacheName);
}
}

实现 cacheManager 接口

package com.mqb.auth.infra.config.cache;

import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheException;
import org.apache.shiro.cache.CacheManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

/**
* @author qingbomy
* @date 2022/9/5 23:53
*/
@Component
public class RedisCacheManager<K,V> implements CacheManager{
// @Autowired
@Resource
private RedisCache<K, V> redisCache;
@Override
public <K, V> Cache<K, V> getCache(String s) throws CacheException {
redisCache.setCacheName(s);
return (Cache<K, V>) redisCache;
}

}

自定义 Realm 中设置 CacheManager

在 ShiroConfig 中配置 cacheManager

  @Autowired
CacheManager cacheManager;

@Bean
public CustomRealm customRealm(){
CustomRealm customRealm = new CustomRealm();
// 配置加密方式
HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher();
credentialsMatcher.setHashAlgorithmName(MessageDigestAlgorithms.MD5);
// 加密迭代次数,md5加密次数
credentialsMatcher.setHashIterations(Constant.Encypt.HASH_ITERATIONS);
customRealm.setCredentialsMatcher(credentialsMatcher);

//设置缓存管理器
customRealm.setCacheManager(cacheManager);
//开启授权缓存
customRealm.setAuthorizationCachingEnabled(Boolean.TRUE);
//开启认证缓存(不用开。不然会报错)
// customRealm.setAuthenticationCachingEnabled(Boolean.TRUE);
customRealm.setAuthorizationCacheName("授权");
customRealm.setAuthenticationCacheName("认证");
return customRealm;
}

会话管理

Shiro 提供了完整的企业级会话管理功能,不依赖于底层容器(如 web 容器 tomcat),不管 JavaSE 还是 JavaEE 环境都可以使用,提供了会话管理、会话事件监听、会话存储 / 持久化、容器无关的集群、失效 / 过期支持、对 Web 的透明支持、SSO 单点登录的支持等特性。即直接使用 Shiro 的会话管理可以直接替换如 Web 容器的会话管理。

客户端向服务器发送请求,shiro 检查是否携带 sessionId,如果携带 sessionId 那么与缓存中的 sessionId 比对,比对成功则通过,比对不成功或者没有携带 sessionId 那么 shiro 将生成 session,并将 sessionId 返回给浏览器,并存储到浏览器的 cookie 中,之后浏览器的每次请求都会携带 sessionId。

image-20220904122241001

SessionManager

会话管理器管理着应用中所有 Subject 的会话的创建、维护、删除、失效、验证等工作。是 Shiro 的核心组件,顶层组件 SecurityManager 直接继承了 SessionManager,且提供了 SessionsSecurityManager 实现直接把会话管理委托给相应的 SessionManager。

Shiro 提供了两个实现:DefaultSecurityManager 及 DefaultWebSecurityManager。

image-20220908152829881

SessionDao

session 有两个实现 MemorySessionDAO 和 EnterpriseCacheSessionDAO

  • MemorySessionDAO 直接在内存中进行会话维护
  • EnterpriseCacheSessionDAO:提供了缓存功能的会话维护,默认情况下使用 MapCache 实现,内部使用 ConcurrentHashMap 保存缓存的会话。

image-20220908153750477

DEMO

更换 shiro 的依赖

<dependency>
<groupId>org.crazycake</groupId>
<artifactId>shiro-redis</artifactId>
<version>3.0.0</version>
</dependency>
  1. 重写 DefaultWebSessionManager 的 getSessionId()方法
@Component
public class CustomWebSessionManager extends DefaultWebSessionManager {

private static final String HEADER = "Authorization";
@Override
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {

String token = request.getParameter(HEADER);
if (!ObjectUtils.isEmpty(token)) {
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, "url");
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, token);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
//禁止在url上拼接SessionId
request.setAttribute(ShiroHttpServletRequest.SESSION_ID_URL_REWRITING_ENABLED, this.isSessionIdUrlRewritingEnabled());

return token;
}else{
return super.getSessionId(request, response);
}
}
}
  1. 实现 SessionListener 接口用于监听
import com.mqb.auth.domain.vo.UserVo;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.SessionListener;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.support.DefaultSubjectContext;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

import java.util.Objects;
import java.util.concurrent.CopyOnWriteArrayList;

/**
* @Description:
* @Author: qingbomy
* @Email:
* @Date: 2022/9/10:14:27
*/
@Component
@Primary
@Slf4j
public class CustomSessionListener implements SessionListener {

private static CopyOnWriteArrayList<Session> sessions = new CopyOnWriteArrayList<>();

public static CopyOnWriteArrayList<Session> getSessions() {
return sessions;
}

// 拥有身份,创建session
@Override
public void onStart(Session session) {
log.debug("开始创建session");
sessions.add(session);
}


// 检测session valid
@Override
public void onStop(Session session) {
log.debug("检测session valid");
sessions.removeIf(s -> {
boolean flag = Objects.equals(session.getId(), s.getId());
Object principals = session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);
if (principals != null) {
Object primaryPrincipal = ((PrincipalCollection) principals).getPrimaryPrincipal();
UserVo user = (UserVo) primaryPrincipal;
System.out.println(user.getPhoneNumber() + " 账号已被停用");
}
return flag;
});
}

// 检测session过期
@Override
public void onExpiration(Session session) {
log.debug("检测session过期");
sessions.removeIf(s -> {
boolean flag = Objects.equals(session.getId(), s.getId());
Object principals = session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);
if (principals != null) {
Object primaryPrincipal = ((PrincipalCollection) principals).getPrimaryPrincipal();
UserVo user = (UserVo) primaryPrincipal;
System.out.println(user.getPhoneNumber()+ " 账号已过期");
}
return flag;
});
}
}
  1. ShiroConfig 中配置 SessionDAO 和 SessionManager

SessionDAO 中要用到 redis 存储 sessionId

@Bean
public IRedisManager redisManager() {
RedisManager redisManager = new RedisManager();
redisManager.setHost(host);
redisManager.setPort(port);
return redisManager;
}
@Bean
@Primary
public RedisSessionDAO redisSessionDAO(IRedisManager redisManager) {
RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
redisSessionDAO.setExpire(5*60);
redisSessionDAO.setRedisManager(redisManager);
// redisSessionDAO.setValueSerializer();
return redisSessionDAO;
}

@Bean("sessionManager")
public SessionManager sessionManager(@Autowired RedisSessionDAO redisSessionDAO,
@Autowired(required = false) SessionListener sessionListener) {
DefaultWebSessionManager sessionManager = new CustomWebSessionManager();
// 设置sessionDao
sessionManager.setSessionDAO(redisSessionDAO);
// 每隔半分钟检测一次session
sessionManager.setSessionValidationInterval(30000);
// 设置自定义的对应的会话监听器
sessionManager.setSessionListeners(Arrays.asList(sessionListener));
return sessionManager;
}

当用户登录后,每一个用户都会有一个 sessionId,sessionId 存在于 Cookie 中,每次请求都会携带 Cookie,shiro 拿到 sessionId 与 redis 中的对比,存在则正常访问,否则重新登陆。

image-20220910223049779

加密

MD5 加密

注册用户

    /**
* 用户注册
*/
@ApiOperation("用户注册")
@PostMapping("/register")
@ResponseBody
public String addUser(@RequestBody UserVo userVo) {
String password = userVo.getPassword();
String salt = new SecureRandomNumberGenerator().nextBytes().toHex();
userVo.setSalt(salt);
Md5Hash md5Hash = new Md5Hash(password, salt, Constant.Encypt.HASH_ITERATIONS);
userVo.setPassword(md5Hash.toString());
return mageFeign.addUser(userVo);
}

Constant.Encypt.HASH_ITERATIONS = 1024

  1. 使用 Md5Hash 类对用户输入的密码进行加密,使用下面的有参构造,第一个参数位用户输入密码,第二个参数为盐值,第三个参数为加密迭代次数。

image-20220904213239546

  1. 在自定义 Realm 中的认证返回下面的有参构造构造的对象。分别是用户对象(可以在授权方法取到)、数据库查询出的密码、ByteSource 类型的盐值、以及自定义的 realmName

    image-20220904213405578

@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken)
throws AuthenticationException {
UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
String phone = token.getUsername();
UserVo userVo = mageFeign.queryUserByPhone(phone);
if (userVo == null) {
//没找到帐号
throw new UnknownAccountException();
}
if (userVo.getLocked() == 1) {
//帐号锁定
throw new LockedAccountException();
}

String dbPassword = userVo.getPassword();
String salt = userVo.getSalt();
//交给AuthenticatingRealm使用CredentialsMatcher进行密码匹配,如果觉得人家的不好可以自定义实现
return new SimpleAuthenticationInfo(userVo, dbPassword, ByteSource.Util.bytes(salt), getName());

}
  1. 在 shiroConfig 中配置自定义的 Realm,设置密码匹配器是MD5,并指定迭代次数为 1024 次
@Bean
public CustomRealm customRealm(){
CustomRealm customRealm = new CustomRealm();
// 配置加密方式
HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher();
credentialsMatcher.setHashAlgorithmName(MessageDigestAlgorithms.MD5);
// 加密迭代次数,md5加密次数
credentialsMatcher.setHashIterations(Constant.Encypt.HASH_ITERATIONS);
customRealm.setCredentialsMatcher(credentialsMatcher);
return customRealm;

}

使用 Springboot+JWT+Shiro+redis 完成认证授权

自定义的缓存管理器和上面的一样 这里就不写了(RedisCache 和 RedisCacheManager)

1.定义 JwtToken 实现AuthenticationToken接口

package com.mqb.auth.infra.shiro;

import org.apache.shiro.authc.AuthenticationToken;

/**
* @Description:
* @Author: qingbomy
* @Email:
* @Date: 2022/9/16:21:52
*/
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;
}
}

2.配置 jedis

 @Value("${spring.redis.host}")
// @Value("127.0.0.1")
private String host;
@Value("${spring.redis.port}")
// @Value("6379")
private int port;


@Value("0")
private int database;
/**
* 连接超时时间
*/
@Value("10000")
private int timeout;

@Bean
public JedisPool redisPoolFactory() {
try {
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
JedisPool jedisPool = new JedisPool(jedisPoolConfig, host, port, timeout, null);
log.info("初始化Redis连接池JedisPool成功!地址: {}:{}", host, port);
return jedisPool;
} catch (Exception e) {
log.error("初始化Redis连接池JedisPool异常:{}", e.getMessage());
}
return null;
}

3.jedis 的工具类

package com.mqb.auth.infra.utils;

import com.mqb.auth.infra.exception.CustomException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

import java.util.Set;

/**
* JedisUtil(推荐存Byte数组,存Json字符串效率更慢)
*/
@Component
public class JedisUtil {

/**
* 静态注入JedisPool连接池
* 本来是正常注入JedisUtil,可以在Controller和Service层使用,但是重写Shiro的CustomCache无法注入JedisUtil
* 现在改为静态注入JedisPool连接池,JedisUtil直接调用静态方法即可
*/
private static JedisPool jedisPool;

@Autowired
public void setJedisPool(JedisPool jedisPool) {
JedisUtil.jedisPool = jedisPool;
}

/**
* 获取Jedis实例
*/
public static synchronized Jedis getJedis() {
try {
if (jedisPool != null) {
return jedisPool.getResource();
} else {
return null;
}
} catch (Exception e) {
throw new CustomException("获取Jedis资源异常:" + e.getMessage());
}
}

/**
* 释放Jedis资源
*/
public static void closePool() {
try {
jedisPool.close();
} catch (Exception e) {
throw new CustomException("释放Jedis资源异常:" + e.getMessage());
}
}

/**
* 获取redis键值-object
*/
public static Object getObject(String key) {
try (Jedis jedis = jedisPool.getResource()) {
byte[] bytes = jedis.get(key.getBytes());
if (!(bytes == null || bytes.length == 0)) {
return SerializableUtil.unserializable(bytes);
}
} catch (Exception e) {
throw new CustomException("获取Redis键值getObject方法异常:key=" + key + " cause=" + e.getMessage());
}
return null;
}

/**
* 设置redis键值-object
*/
public static String setObject(String key, Object value) {
try (Jedis jedis = jedisPool.getResource()) {
return jedis.set(key.getBytes(), SerializableUtil.serializable(value));
} catch (Exception e) {
throw new CustomException("设置Redis键值setObject方法异常:key=" + key + " value=" + value + " cause=" + e.getMessage());
}
}

/**
* 设置redis键值-object-expiretime
*/
public static String setObject(String key, Object value, int expiretime) {
String result;
try (Jedis jedis = jedisPool.getResource()) {
result = jedis.set(key.getBytes(), SerializableUtil.serializable(value));
if ("OK".equals(result)) {
jedis.expire(key.getBytes(), expiretime);
}
return result;
} catch (Exception e) {
throw new CustomException("设置Redis键值setObject方法异常:key=" + key + " value=" + value + " cause=" + e.getMessage());
}
}

/**
* 获取redis键值-Json
*/
public static String getJson(String key) {
try (Jedis jedis = jedisPool.getResource()) {
return jedis.get(key);
} catch (Exception e) {
throw new CustomException("获取Redis键值getJson方法异常:key=" + key + " cause=" + e.getMessage());
}
}

/**
* 设置redis键值-Json
*/
public static String setJson(String key, String value) {
try (Jedis jedis = jedisPool.getResource()) {
return jedis.set(key, value);
} catch (Exception e) {
throw new CustomException("设置Redis键值setJson方法异常:key=" + key + " value=" + value + " cause=" + e.getMessage());
}
}

/**
* 设置redis键值-Json-expiretime
*/
public static String setJson(String key, String value, int expiretime) {
String result;
try (Jedis jedis = jedisPool.getResource()) {
result = jedis.set(key, value);
if ("OK".equals(result)) {
jedis.expire(key, expiretime);
}
return result;
} catch (Exception e) {
throw new CustomException("设置Redis键值setJson方法异常:key=" + key + " value=" + value + " cause=" + e.getMessage());
}
}

/**
* 删除key
*/
public static Long delKey(String key) {
try (Jedis jedis = jedisPool.getResource()) {
return jedis.del(key.getBytes());
} catch (Exception e) {
throw new CustomException("删除Redis的键delKey方法异常:key=" + key + " cause=" + e.getMessage());
}
}

/**
* key是否存在
*/
public static Boolean exists(String key) {
try (Jedis jedis = jedisPool.getResource()) {
return jedis.exists(key.getBytes());
} catch (Exception e) {
throw new CustomException("查询Redis的键是否存在exists方法异常:key=" + key + " cause=" + e.getMessage());
}
}

/**
* 模糊查询获取key集合(keys的速度非常快,但在一个大的数据库中使用它仍然可能造成性能问题,生产不推荐使用)
*/
public static Set<String> keysS(String key) {
try (Jedis jedis = jedisPool.getResource()) {
return jedis.keys(key);
} catch (Exception e) {
throw new CustomException("模糊查询Redis的键集合keysS方法异常:key=" + key + " cause=" + e.getMessage());
}
}

/**
* 模糊查询获取key集合(keys的速度非常快,但在一个大的数据库中使用它仍然可能造成性能问题,生产不推荐使用)
*/
public static Set<byte[]> keysB(String key) {
try (Jedis jedis = jedisPool.getResource()) {
return jedis.keys(key.getBytes());
} catch (Exception e) {
throw new CustomException("模糊查询Redis的键集合keysB方法异常:key=" + key + " cause=" + e.getMessage());
}
}

/**
* 获取过期剩余时间
*/
public static Long ttl(String key) {
Long result = -2L;
try (Jedis jedis = jedisPool.getResource()) {
result = jedis.ttl(key);
return result;
} catch (Exception e) {
throw new CustomException("获取Redis键过期剩余时间ttl方法异常:key=" + key + " cause=" + e.getMessage());
}
}
}

4.自定义序例化工具

package com.mqb.auth.infra.utils;

import com.mqb.auth.infra.exception.CustomException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.Assert;

import java.io.*;

/**
* @Description:
* @Author: qingbomy
* @Email:
* @Date: 2022/9/16:20:17
*/
@Slf4j
public class SerializableUtil {

/**
* 序例化
*/
public static byte[] serializable(Object o) {
//字节输出流
ByteArrayOutputStream byteArrayOutputStream = null;
// 对象输出流
ObjectOutputStream objectOutputStream = null;
try {
byteArrayOutputStream = new ByteArrayOutputStream();
objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
objectOutputStream.writeObject(o);
return byteArrayOutputStream.toByteArray();
} catch (IOException e) {
log.error("SerializableUtil工具类序列化出现IOException异常:{}", e.getMessage());
throw new CustomException("SerializableUtil工具类序列化出现IOException异常:" + e.getMessage());
} finally {
Assert.notNull(byteArrayOutputStream,"资源关闭异常");
Assert.notNull(objectOutputStream,"资源关闭异常");
try {
byteArrayOutputStream.close();
objectOutputStream.close();
} catch (IOException e) {
log.error("资源关闭异常");
throw new CustomException("SerializableUtil工具类反序列化,资源关闭异常:" + e.getMessage());

}
}

}

/**
* 反序例化
*/
public static Object unserializable(byte[] bytes) {
ByteArrayInputStream byteArrayInputStream = null;
ObjectInputStream objectInputStream = null;
try {
byteArrayInputStream = new ByteArrayInputStream(bytes);
objectInputStream = new ObjectInputStream(byteArrayInputStream);
return objectInputStream.readObject();

} catch (ClassNotFoundException e) {
log.error("SerializableUtil工具类反序列化出现ClassNotFoundException异常:{}", e.getMessage());
throw new CustomException("SerializableUtil工具类反序列化出现ClassNotFoundException异常:" + e.getMessage());
} catch (IOException e) {
log.error("SerializableUtil工具类反序列化出现IOException异常:{}", e.getMessage());
throw new CustomException("SerializableUtil工具类反序列化出现IOException异常:" + e.getMessage());
} finally {
Assert.notNull(objectInputStream,"资源关闭异常");
Assert.notNull(byteArrayInputStream,"资源关闭异常");
try {
objectInputStream.close();
byteArrayInputStream.close();
} catch (IOException e) {
log.error("SerializableUtil工具类反序列化出现资源关闭异常");
throw new CustomException("SerializableUtil工具类反序列化出现资源关闭异常");
}
}

}
}

5.定义 Jwt 工具类

package com.mqb.auth.infra.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 com.mqb.auth.infra.exception.CustomException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;

import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;

/**
* @Description:
* @Author: qingbomy
* @Email:
* @Date: 2022/9/16:19:52
*/
@Slf4j
public class JwtUtil {
/**
* JWT认证加密私钥(Base64加密)
*/
private static String secret = "mcdaps2=1@!";

/**
* 生成签名token
*
* @param phoneNumber 帐号
* @param currentTimeMillis 当前时间戳
* @return java.lang.String 返回加密的Token
*/
public static String generateToken(String phoneNumber, String currentTimeMillis) {
try {
Calendar instance = Calendar.getInstance();
//1天后过期
instance.add(Calendar.DATE,1);
Algorithm algorithm = Algorithm.HMAC256(secret);
return JWT.create()
.withClaim("phoneNumber", phoneNumber)
.withClaim("currentTimeMillis", currentTimeMillis)
.withExpiresAt(instance.getTime())
.sign(algorithm);
} catch (Exception e) {
log.error("JWTToken加密出现UnsupportedEncodingException异常:{}", e.getMessage());
throw new CustomException("JWTToken加密出现UnsupportedEncodingException异常:" + e.getMessage());
}
}

/**
* @param token 验证签名token
*/
public static boolean verify(String token) {
try {

Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm).build();
verifier.verify(token);
return true;
} catch (Exception e) {
log.error("JWTToken认证解密出现UnsupportedEncodingException异常:{}", e.getMessage());
throw new CustomException("JWTToken认证解密出现UnsupportedEncodingException异常:" + e.getMessage());
}

}

/**
* 获得Token中的信息无需secret解密也能获得
*/
public static String getClaim(String token, String claim) {
try {
DecodedJWT decode = JWT.decode(token);
return decode.getClaim(claim).asString();
} catch (JWTDecodeException e) {
log.error("解密Token中的公共信息出现JWTDecodeException异常:{}", e.getMessage());
throw new CustomException("解密Token中的公共信息出现JWTDecodeException异常:" + e.getMessage());
}
}


}

6.自定义过滤器 JwtFilter

isAccessAllowed 总是返回 true 的原因

例如我们提供一个地址 GET /article 登入用户和游客看到的内容是不同的 如果在这里返回了false,请求会被直接拦截,用户看不到任何东西 所以我们在这里返回true,Controller中可以通过 subject.isAuthenticated() 来判断用户是否登入 如果有些资源只有登入用户才能访问,我们只需要在方法上面加上 @RequiresAuthentication 注解即可 但是这样做有一个缺点,就是不能够对GET,POST等请求进行分别过滤鉴权(因为我们重写了官方的方法),但实际上对应用影响不大

/**
* @Description:
* @Author: qingbomy
* @Email:
* @Date: 2022/9/16:22:25
*/
@Slf4j
public class JwtFilter extends BasicHttpAuthenticationFilter {
/**
* @Description:总是返回true,原因是让未登录的也能看到数据,但是和已经登陆的用户看到的不一样
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
boolean loginAttempt = this.isLoginAttempt(request, response);
// 如果是尝试登录
if (loginAttempt) {
try {
// 进行Shiro的登录UserRealm
this.executeLogin(request, response);
} catch (Exception e) {
String message = e.getMessage();
Throwable cause = e.getCause();
if (cause instanceof SignatureVerificationException) {
message = "Token或者密钥不正确(" + cause.getMessage() + ")";
} else if (cause instanceof TokenExpiredException) {
if (this.refreshToken(request, response)) {
return true;
} else {
message = "Token已过期(" + cause.getMessage() + ")";
}
} else {

// 应用异常不为空
if (cause != null) {
// 获取应用异常msg
message = cause.getMessage();
}
}
this.response401(response, message);
return false;
}
} else {
//不是尝试登录,没有携带token
HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
String method = httpServletRequest.getMethod();
String requestURI = httpServletRequest.getRequestURI();
log.info("当前请求 {} Authorization属性(Token)为空 请求类型 {}", requestURI, method);
}


return true;
}
/**
* 为什么重写
* 可以对比父类方法,只是将executeLogin方法调用去除了
* 如果没有去除将会循环调用doGetAuthenticationInfo方法
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
this.sendChallenge(request, response);
return false;
}
/**
* 检测Header里面是否包含Authorization字段,有就进行Token登录认证授权
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
// 拿到当前Header中Authorization的AccessToken(Shiro中getAuthzHeader方法已经实现)
String token = this.getAuthzHeader(request);
return token != null;
}

/**
* 进行AccessToken登录认证授权
*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
// 拿到当前Header中Authorization的AccessToken(Shiro中getAuthzHeader方法已经实现)
JwtToken token = new JwtToken(this.getAuthzHeader(request));
// 提交给UserRealm进行认证,如果错误他会抛出异常并被捕获
this.getSubject(request, response).login(token);
// 如果没有抛出异常则代表登入成功,返回true
return true;
}

/**
* 对跨域提供支持
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {

return super.preHandle(request, response);
}

/**
* 此处为AccessToken刷新,进行判断RefreshToken是否过期,未过期就返回新的AccessToken且继续正常访问
*/
private boolean refreshToken(ServletRequest request, ServletResponse response) {
// 拿到当前Header中Authorization的AccessToken(Shiro中getAuthzHeader方法已经实现)
String token = this.getAuthzHeader(request);
// 获取当前Token的帐号信息
String phoneNumber = JwtUtil.getClaim(token, "phoneNumber");
// 判断Redis中RefreshToken是否存在
if (JedisUtil.exists(Constant.Shiro.PREFIX_SHIRO_REFRESH_TOKEN + phoneNumber)) {
// Redis中RefreshToken还存在,获取RefreshToken的时间戳
String currentTimeMillisRedis = JedisUtil.getObject(Constant.Shiro.PREFIX_SHIRO_REFRESH_TOKEN + phoneNumber).toString();

// 获取当前AccessToken中的时间戳,与RefreshToken的时间戳对比,如果当前时间戳一致,进行AccessToken刷新
if (JwtUtil.getClaim(token, "currentTimeMillis").equals(currentTimeMillisRedis)) {
// 获取当前最新时间戳
String currentTimeMillis = String.valueOf(System.currentTimeMillis());

// 设置RefreshToken中的时间戳为当前最新时间戳,且刷新过期时间重新为30分钟过期(配置文件可配置refreshTokenExpireTime属性)
JedisUtil.setObject(Constant.Shiro.PREFIX_SHIRO_REFRESH_TOKEN + phoneNumber, currentTimeMillis, Constant.Shiro.REFRESH_TOKEN_EXPIRE_TIME);
// 刷新AccessToken,设置时间戳为当前最新时间戳
token = JwtUtil.generateToken(phoneNumber, currentTimeMillis);
// 将新刷新的AccessToken再次进行Shiro的登录
JwtToken jwtToken = new JwtToken(token);
// 提交给UserRealm进行认证,如果错误他会抛出异常并被捕获,如果没有抛出异常则代表登入成功,返回true
this.getSubject(request, response).login(jwtToken);
// 最后将刷新的AccessToken存放在Response的Header中的Authorization字段返回
HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
httpServletResponse.setHeader("Authorization", token);
httpServletResponse.setHeader("Access-Control-Expose-Headers", "Authorization");
return true;
}
}
return false;
}

/**
* 无需转发,直接返回Response信息
*/
private void response401(ServletResponse response, String msg) {
HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json; charset=utf-8");
try (PrintWriter out = httpServletResponse.getWriter()) {
String data = JSON.toJSONString(new ResponseBean(HttpStatus.UNAUTHORIZED.value(),
"无权访问(Unauthorized):" + msg, null));
out.append(data);
} catch (IOException e) {
log.error("直接返回Response信息出现IOException异常:{}", e.getMessage());
throw new CustomException("直接返回Response信息出现IOException异常:" + e.getMessage());
}
}

}

7.自定义 realm

package com.mqb.auth.infra.shiro;

import com.mqb.auth.app.feign.MageFeign;
import com.mqb.auth.domain.Permission;
import com.mqb.auth.domain.Role;
import com.mqb.auth.domain.vo.UserVo;
import com.mqb.auth.infra.exception.CustomException;
import com.mqb.auth.infra.utils.JedisUtil;
import com.mqb.auth.infra.utils.JwtUtil;
import com.mqb.infra.Constant;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authc.*;
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.util.Assert;

import javax.annotation.Resource;
import java.util.List;
import java.util.stream.Collectors;

/**
* @Description:
* @Author: qingbomy
* @Email:
* @Date: 2022/9/16:21:49
*/
public class UserRealm extends AuthorizingRealm {
@Resource
private MageFeign mageFeign;

/**
* 授权
*
* @param principalCollection
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
String phoneNumber = (String) principalCollection.getPrimaryPrincipal();

List<Permission> permissions = mageFeign.queryPermsByPhone(phoneNumber);
List<Role> roles = mageFeign.queryRolesByPhone(phoneNumber);
simpleAuthorizationInfo.setRoles(roles.stream().map(Role::getRoleName).collect(Collectors.toSet()));
simpleAuthorizationInfo.setStringPermissions(permissions.stream().map(Permission::getResourcePath).collect(Collectors.toSet()));
return simpleAuthorizationInfo;
}


/**
* 认证
*
* @param authenticationToken
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
JwtToken jwt = (JwtToken) authenticationToken;
String token = (String) jwt.getPrincipal();
String phoneNumber = JwtUtil.getClaim(token, "phoneNumber");
if (StringUtils.isEmpty(phoneNumber)) {
throw new UnknownAccountException();
}

UserVo user = mageFeign.queryUserByPhone(phoneNumber);
if (user == null) {
throw new UnknownAccountException("账号不存在");
}

boolean verify = JwtUtil.verify(token);
Boolean exists = JedisUtil.exists(Constant.Shiro.PREFIX_SHIRO_REFRESH_TOKEN + phoneNumber);

// 开始认证,Token认证通过,且Redis中存在RefreshToken,且两个Token时间戳一致
if (verify && exists) {
// 获取RefreshToken的时间戳
Object object = JedisUtil.getObject(Constant.Shiro.PREFIX_SHIRO_REFRESH_TOKEN + phoneNumber);
Assert.notNull(object, "");
String currentTimeMillisRedis = object.toString();
// 获取AccessToken时间戳,与RefreshToken的时间戳对比
if (JwtUtil.getClaim(token, "currentTimeMillis").equals(currentTimeMillisRedis)) {
//如果一直就正常返回
return new SimpleAuthenticationInfo(phoneNumber, token, "userRealm");
}
}
throw new AuthenticationException("Token已过期");
}
}

8.ShiroConfig

package com.mqb.auth.infra.shiro;

import com.mqb.auth.infra.shiro.cache.CustomCacheManager;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.mgt.SecurityManager;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;

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

/**
* @Description:
* @Author: qingbomy
* @Email:
* @Date: 2022/9/16:22:13
*/
@Configuration
public class ShiroConfig {
@Bean
public UserRealm userRealm() {
UserRealm userRealm = new UserRealm();
userRealm.setAuthenticationTokenClass(JwtToken.class);
return userRealm;
}

@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 关闭Shiro自带的session
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
securityManager.setSubjectDAO(subjectDAO);
// 设置自定义Cache缓存
securityManager.setCacheManager(new CustomCacheManager());
securityManager.setRealm(userRealm());
return securityManager;
}

/**
* 添加自己的过滤器,自定义url规则
*/
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean() {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager());
shiroFilterFactoryBean.setLoginUrl("/user/toLogin");
//设置jwt过滤器
Map<String, Filter> filterMap = new HashMap<>(16);
filterMap.put("jwt", new JwtFilter());
shiroFilterFactoryBean.setFilters(filterMap);

LinkedHashMap<String, String> map = new LinkedHashMap<>();
//用户登录不需要验证
map.put("/user/login", "anon");
map.put("/user/register", "anon");
//对于/shiro/**下的所有资源都需要jwt并验证对于权限才可访问
map.put("/shiro/**", "jwt");
shiroFilterFactoryBean.setFilterChainDefinitionMap(map);


return shiroFilterFactoryBean;
}

/**
* 添加注解支持
*/
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
// 强制使用cglib,防止重复代理和可能引起代理出错的问题
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}

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

@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}

测试

1、登录逻辑

image-20220917134542113

当用户登录成功,jwtToken 会通过 HttpServletResponse 进行返回

image-20220917134934854

之后用户的每次请求都会在请求头中携带 token。

2、测试登录

不携带 token 访问两个 controller

image-20220917140050095

访问 localhost:8080/user/article2,需要登录才可image-20220917140419537

访问 localhost:8080/user/article 显示游客访问的是数据

image-20220917140538413

带上 token 再次访问

访问 localhost:8080/user/article2 和 localhost:8080/user/articleimage-20220917140742940

遇到的问题

1、Shiro 跳转登录 url 后面会加上 JSESSIONID 导致报错

image-20220827202424628

想要去掉 JSESSIONID 就需要重写会话管理器DefaultWebSessionManager,然后注入到securityManager中。

    @Bean
public DefaultWebSessionManager sessionManager() {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
// 去掉shiro登录时url里的JSESSIONID
sessionManager.setSessionIdUrlRewritingEnabled(false);
return sessionManager;
}

之后在SecurityManager的 bean 中去将SessionManager设置为上面的sessionManager

@Bean
@Autowired
public DefaultWebSecurityManager defaultWebSecurityManager(MageRealm mageRealm,@Qualifier("sessionManager") DefaultWebSessionManager sessionManager){
DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
defaultWebSecurityManager.setSessionManager(sessionManager);
defaultWebSecurityManager.setRealm(mageRealm);
return defaultWebSecurityManager;
}

2、当使用注解配置权限或角色时,如果没有匹配,那么会报 500 错误

配置全局异常

@Slf4j
@RestControllerAdvice
public class GlobalException {

@ExceptionHandler(AuthorizationException.class)
public Object handler() {
// return ResponseData.error(ResponseCode.NO_AUTH);
return "无权访问";
}
}

3、当第一次登录时,mage 服务总是报错Connection reset

image-20220904120441609

解决方法:

[261]Connection reset by peer 的常见原因及解决办法_周小董的博客-CSDN 博客_connection peer

4、某些路径拦截不了

部分路径无法进行拦截,时有时无;因为使用的是 hashmap, 无序的,应该改为 LinkedHashMap

image-20220904203453937

5、缓存更新的问题

细说 shiro 之七:缓存 - nuccch - 博客园 (cnblogs.com)

6、第二次登录报错 SerializationException

问题描述:当第一次登录成功后,redis 中缓存了认证信息。退出,再重新登录就会出现序例化异常

Could not read JSON: Problem deserializing 'setterless' property ("realmNames"): no way to handle typed deser with setterless yet

image-20220906011430623

shiro 的 session 信息放 redis 反序列化异常解决 - 知乎 (zhihu.com)

image-20220907152116692

场景复原

第一次登录,能正常登陆成功,并且等根据权限对按钮授权

redis 中存的数据

点击退出之后重新登陆

分析

对比 redis 中存的数据查看 shiro 源码发现,shiro 会将 redis 中存储的数据反序例化为SimpleAuthenticationInfo类,该类实现了MutablePrincipalCollection接口,里面有个realmNames字段,但是只有getRealmNames()方法,并没有 setRealmNames()方法,导致反序例化时不能将realmNames正确的赋值,就产生了SerializationException.

并告诉我们Could not read JSON: Problem deserializing 'setterless' property ("realmNames"): no way to handle typed deser with setterless yet

就是 shiro 的 SimplePrincipalCollection 类中 realmNames 字段没有 setter 方法,没办法反序列化。

为一探究竟,查找源码发现SimpleAuthenticationInfo以及他的父类并没有 realmNames 字段。只有getRealmNames()方法,发现 realmNames 是由其他字段动态生成的

解决思路:

尝试设置objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false),但是跟踪了一圈源码之后,发现因为有 getter,这个字段已经不算未知字段了。。。

后来研究发现这个 realmNames 并没有实际的意义,那么就可以在序例化的时候不序例化该字段,只需要在realmNames属性上面加注解@JsonIgnore 就可以解决。但是这个类是框架 jar 包的类无法修改。

所以需要使用不一样的方式

  1. 更改 ObjectMapper 配置
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
//objectMapper.configure(MapperFeature.USE_ANNOTATIONS, false);
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);

//只序列化必要shiro字段
String [] needSerialize = {
"realmPrincipals"};
objectMapper.addMixIn(SimplePrincipalCollection.class, IncludShiroFields.class);
objectMapper.setFilters(new SimpleFilterProvider().addFilter("shiroFilter", SimpleBeanPropertyFilter.filterOutAllExcept(needSerialize)));
// 此项必须配置,否则会报java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to XXX
objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

核心就是 objectMapper.addMixIn()和 objectMapper.setFilters()两个方法 SimplePrincipalCollection 是需要处理的类,IncludShiroFields 就是一个简单的接口,如下:

@JsonFilter("shiroFilter")
public interface IncludShiroFields {

}

通过上面的配置间接控制 SimplePrincipalCollection 类中必要字段的序列化,从而解决了问题。 ps:因为使用了注解,一定要去掉 objectMapper.configure(MapperFeature.USE_ANNOTATIONS, false),不然配置不生效。

  1. 利用双亲委派机制重写源码

  2. 关闭认证缓存(最终解决)

  3. JWT 报不支持的异常

"Realm [" + realm + "] does not support authentication token [" + token + "]. Please ensure that the appropriate Realm implementation is configured correctly or that the realm accepts AuthenticationTokens of this type.";

解决方法

需要实现 AuthorizingRealm 的 supports()方法

参考文章

https://www.i4k.xyz/article/weixin_48352162/118251337