shiro自己也有session,但是我希望将这个session交给redis托管,因为如果系统采用分布式,登录信息只保存在一个节点是肯定不行的。仔细弄了几天,遇到几个坑,查了很多资料,终于走通了。现在做个笔记。有关shiro的认证与校验见https://blog.csdn.net/ws6afa88/article/details/109061610,shiro的session交给redis管理流程主要参考https://www.jianshu.com/p/5aa03c2d118e,但里面的坑很多。
0. ShiroDemo目录结构
1. 主要依赖
<!--整合shiro-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.5.3</version>
</dependency>
<!--整合redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2. shiro-config
@Configuration
public class ShiroConfig {
@Bean(name = "shiroFilter")
public ShiroFilterFactoryBean shiroFilter() {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(getSecurityManager());
//设置登录页面
shiroFilterFactoryBean.setLoginUrl("/login");
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
filterChainDefinitionMap.put("/toLogin", "anon");
filterChainDefinitionMap.put("/login", "anon");
filterChainDefinitionMap.put("/", "anon");
filterChainDefinitionMap.put("/index", "anon");
//主要这行代码必须放在所有权限设置的最后,不然会导致所有 url 都被拦截 剩余的都需要认证
filterChainDefinitionMap.put("/**", "authc");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
//开启shiro的注解,主要在thymeleaf中使用
@Bean(name = "shiroDialect")
public ShiroDialect shiroDialect() {
return new ShiroDialect();
}
@Bean
public SecurityManager getSecurityManager() {
DefaultSecurityManager defaultSecurityManager = new DefaultWebSecurityManager();
defaultSecurityManager.setRealm(customRealm());
//自定义SessionManager
defaultSecurityManager.setSessionManager(getDefaultWebSessionManager());
return defaultSecurityManager;
}
//相关定义不再赘述,见https://blog.csdn.net/ws6afa88/article/details/109061610
@Bean
public CustomRealm customRealm() {
CustomRealm customRealm = new CustomRealm();
customRealm.setCredentialsMatcher(new MyCredentialsMatcher());
return customRealm;
}
/**
* *
* 开启Shiro的注解(如@RequiresRoles,@RequiresPermissions),需借助SpringAOP扫描使用Shiro注解的类,并在必要时进行安全逻辑验证
* *
* 配置以下两个bean(DefaultAdvisorAutoProxyCreator(可选)和AuthorizationAttributeSourceAdvisor)即可实现此功能
* * @return
*/
@Bean
public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
advisorAutoProxyCreator.setProxyTargetClass(true);
return advisorAutoProxyCreator;
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor() {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(getSecurityManager());
return authorizationAttributeSourceAdvisor;
}
/**
* Session Manager
* 使用的是shiro-redis开源插件
*/
@Bean
public DefaultWebSessionManager getDefaultWebSessionManager(){
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
// sessionManager.setGlobalSessionTimeout(10);//设置session过期时间 10ms
// sessionManager.setSessionValidationInterval(1000);//定时查询所有session是否过期的时间
// Collection<SessionListener> listeners
ArrayList<SessionListener> list = new ArrayList<SessionListener>();
list.add(new MySessionListener());
list.add(new MySessionListener2());
//定义了自定义了session的增删改查操作
sessionManager.setSessionDAO(redisSessionDao());
//自定义了cookie的存管理操作
sessionManager.setSessionIdCookie(sessionIdCookie());
//定义了两个session监听器
sessionManager.setSessionListeners(list);
return sessionManager;
}
/**
* SessionDAO的作用是为Session提供CRUD并进行持久化的一个shiro组件 MemorySessionDAO 直接在内存中进行会话维护
* EnterpriseCacheSessionDAO
* 提供了缓存功能的会话维护,默认情况下使用MapCache实现,内部使用ConcurrentHashMap保存缓存的会话。
*
* @return
*/
@Bean
public RedisSessionDao redisSessionDao() {
RedisSessionDao redisSessionDao = new RedisSessionDao();
return redisSessionDao;
}
/**
* 配置保存sessionId的cookie 注意:这里的cookie 不是记住我 cookie 记住我需要一个cookie session管理
* 也需要自己的cookie 默认为: JSESSIONID 问题: 与SERVLET容器名冲突,重新定义为
*
* @return
*/
@Bean("sessionIdCookie")
public SimpleCookie sessionIdCookie() {
// 这个参数是cookie的名称
SimpleCookie simpleCookie = new SimpleCookie("REDIS-SESSION");
// setcookie的httponly属性如果设为true的话,会增加对xss防护的安全系数。它有以下特点:
// setcookie()的第七个参数
// 设为true后,只能通过http访问,javascript无法访问
// 防止xss读取cookie
simpleCookie.setHttpOnly(true);
simpleCookie.setPath("/");
// maxAge=-1表示浏览器关闭时失效此Cookie
simpleCookie.setMaxAge(-1);
return simpleCookie;
}
}
3. 自定义RedisSessionDao
public class RedisSessionDao extends AbstractSessionDAO {
@Autowired
RedisUtil redisUtil;
private final String SHIRO_SESSIOM_PREFIX = "shiro-session";
private String getKey(String key){
return (SHIRO_SESSIOM_PREFIX+key);
}
//创建sessionID
@Override
protected Serializable doCreate(Session session) {
Serializable sessionId = generateSessionId(session);
assignSessionId(session,sessionId);
saveSession(session);
System.out.println("创建了sessionID: "+sessionId);
return sessionId;
}
//将字节ID反序列化为对象
@Override
protected Session doReadSession(Serializable sessionId) {
if (sessionId == null) {
return null;
}
String key = getKey(sessionId.toString());
String value = (String)redisUtil.get(key);
System.out.println("反序列化了sessionID: "+key);
if(value == null){
return null;
}
return (Session) SerializationUtils.deserialize(ByteArrayUtils.toByteArray(value));
}
//更新session
@Override
public void update(Session session) throws UnknownSessionException {
System.out.println("更新了sessionID: "+session.getId());
saveSession(session);
}
private void saveSession(Session session){
if(session !=null&& session.getId()!=null) {
String key = getKey(session.getId().toString());
byte[] value = SerializationUtils.serialize(session);
redisUtil.set(key.toString(), ByteArrayUtils.toHexString(value));
redisUtil.expire(key.toString(), 600);
}
}
//删除了session
@Override
public void delete(Session session) {
if(session == null || session.getId() ==null){
return;
}
String key = getKey(session.getId().toString());
redisUtil.del(key.toString());
System.out.println("删除了sessionID: "+session.getId());
}
@Override
public Collection<Session> getActiveSessions() {
System.out.println("getActiveSessions()=====");
Set<String> keys = redisUtil.getKeys(SHIRO_SESSIOM_PREFIX);
Set<Session> sessions = new HashSet<>();
if(CollectionUtils.isEmpty(keys)){
return sessions;
}
for(String key:keys){
Session session = (Session)SerializationUtils.deserialize(((String)redisUtil.get(key)).getBytes());
sessions.add(session);
}
return sessions;
}
}
这里 SerializationUtils.deserialize(@Nullable byte[] bytes)进行反序列时,我遇到了一个大坑。由于 byte[]类型value被redis保存时,自动保存为Object,实际上还是保存为String,这时再取出value,将value转换成byte[]传给SerializationUtils.deserialize(@Nullable byte[] bytes),就会报反序列化错误:java.io.StreamCorruptedException: invalid stream header: EFBFBDEF,真是太坑爹了。后来发现是 byte[]类型value强转为Object,取出再转换回byte[]类型时会有问题。于是网上找到了这样的方法,先将byte[]类型value转换为16进制表示格式的字符串保存,取出时再将其转换回byte[]类型即可,这里主要参考https://blog.csdn.net/qq_34446485/article/details/81542691,用到的ByteArrayUtils定义如下:
import org.springframework.util.StringUtils;
public class ByteArrayUtils {
/**
* 字节数组转成16进制表示格式的字符串
*
* @param byteArray 需要转换的字节数组
* @return 16进制表示格式的字符串
**/
public static String toHexString(byte[] byteArray) {
if (byteArray == null || byteArray.length < 1)
throw new IllegalArgumentException("this byteArray must not be null or empty");
final StringBuilder hexString = new StringBuilder();
for (int i = 0; i < byteArray.length; i++) {
if ((byteArray[i] & 0xff) < 0x10)//0~F前面不零
hexString.append("0");
hexString.append(Integer.toHexString(0xFF & byteArray[i]));
}
return hexString.toString().toLowerCase();
}
public static byte[] toByteArray(String hexString) {
if (StringUtils.isEmpty(hexString))
throw new IllegalArgumentException("this hexString must not be empty");
hexString = hexString.toLowerCase();
final byte[] byteArray = new byte[hexString.length() / 2];
int k = 0;
for (int i = 0; i < byteArray.length; i++) {//因为是16进制,最多只会占用4位,转换成字节需要两个16进制的字符,高位在先
byte high = (byte) (Character.digit(hexString.charAt(k), 16) & 0xff);
byte low = (byte) (Character.digit(hexString.charAt(k + 1), 16) & 0xff);
byteArray[i] = (byte) (high << 4 | low);
k += 2;
}
return byteArray;
}
}
我的RedisConfig定义如下:
@Configuration
public class RedisConfig {
//编写我们自己的template
@Bean
@SuppressWarnings("all")
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory)
throws UnknownHostException {
//我们为了自己开发方便,一般使用<String,Object>
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
//Json序列化配置
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<Object>(Object.class);
ObjectMapper om=new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
//String的序列化
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
//key采用String的序列化方式
template.setKeySerializer(stringRedisSerializer);
//hash的key也采用String的序列化方式
template.setHashKeySerializer(stringRedisSerializer);
//value的序列化采用jackson
template.setValueSerializer(jackson2JsonRedisSerializer);
//hash的value也采用jackson
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
}
redisUtil中主要方法定义如下:
public Object get(String key) {
return key == null ? null : redisTemplate.opsForValue().get(key);
}
public boolean set(String key, Object value) {
try {
redisTemplate.opsForValue().set(key, value);
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
public boolean expire(String key, long time) {
try {
if (time > 0) {
redisTemplate.expire(key, time, TimeUnit.SECONDS);
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
//通过前缀模糊查询keys
public Set<String> getKeys(String shiro_sessiom_prefix) {
StringRedisTemplate sr=new StringRedisTemplate();
Set<String> keys = sr.keys(shiro_sessiom_prefix + "*");
return keys;
}
4. 两个监听类
//监听类MySessionListener
public class MySessionListener implements SessionListener {
private final AtomicInteger sessionCount = new AtomicInteger(0);
MySessionListener(){
System.out.println("MySessionListener注册进来了============");
}
@Override
public void onStart(Session session) {
sessionCount.incrementAndGet();
System.out.println("登陆+1=="+sessionCount.get());
}
@Override
public void onStop(Session session) {
sessionCount.decrementAndGet();
System.out.println("登陆-1=="+sessionCount.get());
}
@Override
public void onExpiration(Session session) {
sessionCount.decrementAndGet();
System.out.println("登陆过期-1=="+sessionCount.get());
}
}
//监听类MySessionListener2
public class MySessionListener2 extends SessionListenerAdapter {
@Override
public void onStart(Session session) {
System.out.println("会话创建:" + session.getId());
}
}
5.测试
登录后,后台显示:
再看redis中:
再看cookie:
6. 总结
搞了两天,反序列化过程因为一直报“java.io.StreamCorruptedException: invalid stream header: EFBFBDEF”,挺烦,还好最后解决了问题,也发现对序列化理解不够,这个坑得找个时间好好填一下。同时,我还发现登录后经常有两个sessionID,不是特别清楚,是不是因为页面跳转?还需要自己下去仔细研究。