参考: Shiro Springboot 集群共享Session (Redis)+单用户登录
https://zhuanlan.zhihu.com/p/54176956
jdk8
maven
lombok
spring boot 2.5.7
<dependency>
<groupId>org.apache.shirogroupId>
<artifactId>shiro-spring-boot-web-starterartifactId>
<version>1.8.0version>
dependency>
创建UserInfo.java:
@Setter
@Getter
public class UserInfo implements Serializable {
private String username;
private String password;
private Set<String> roles;
private Set<String> perms;
}
创建CustomRealm .java:
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationException;
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 java.util.HashSet;
public class CustomRealm extends AuthorizingRealm {
/**
* 身份认证
* 主要作用是提供一个身份的鉴定功能,基本思路是,从数据库中查找用户身份信息,交给Shiro框架,shiro框架会自动与登录页传进来的账号信息进行对比是否匹配,如果匹配,则登录成功,否则登录失败
* @param authenticationToken
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
//登录TOKEN,包含了用户账号密码
UsernamePasswordToken upToken = (UsernamePasswordToken) authenticationToken;
String username = upToken.getUsername();
//下列多个判断可根据业务自行增删
// 判断用户名是否不存在,如果不存在抛出异常
if (username == null) {
throw new AccountException("Null usernames are not allowed by this realm.");
}
//模拟数据,可通自行通过查找数据库获取当前用户信息
UserInfo user = new UserInfo();
user.setUsername("aesop");
user.setPassword("123");
//查询用户的角色和权限存到SimpleAuthenticationInfo中,这样在其它地方
//SecurityUtils.getSubject().getPrincipal() 就能拿出用户的所有信息,包括角色和权限
/** 将用户权限和角色存入User对象*/
HashSet<String> roles = new HashSet<>();
roles.add("admin");
roles.add("teacher");
user.setRoles(roles);
HashSet<String> perms = new HashSet<>();
perms.add("blog:read");
perms.add("blog:search");
user.setPerms(perms);
//也可存入额外的信息到Session
//SecurityUtils.getSubject().getSession().setAttribute(Constants.SESSION_USER_INFO, userInfo);
//构造验证信息返回
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, user.getPassword(), getName());
return info;
}
/**
* 授权
* 身份鉴定完毕后,把权限赋予给当前用户,以便后续在需要的地方根据权限细致控制
* @param principals
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
//null usernames are invalid
if (principals == null) {
throw new AuthorizationException("PrincipalCollection method argument cannot be null.");
}
//获取当前用户对应的User对象
UserInfo user = (UserInfo) getAvailablePrincipal(principals);
//创建权限对象
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
//设置用户角色(user.getRoles()是一个Set,【admin,student。。。】)
info.setRoles(user.getRoles());
//设置用户许可(user.getPerms()是一个Set,【blog:read,blog:search。。。】)
info.setStringPermissions(user.getPerms());
return info;
}
}
创建ShiroConfig.java:
package com.example.springshirodemo.config.shiro;
import org.apache.shiro.session.mgt.eis.SessionDAO;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import javax.servlet.Filter;
import java.util.HashMap;
import java.util.Map;
/**
* Shiro核心配置
*/
@Configuration
public class ShiroConfig {
/**
* shiro的统一权限判定
* 根据业务需要对权限进行拦截或放行, anon:所有请求可访问, authc: 需要登录认证后才能访问
* @return
*/
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean() {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager(myRealm()));
Map<String, Filter> filters = new HashMap<>();
filters.put("authc", new LoginFormFilter());
shiroFilterFactoryBean.setFilters(filters);
Map<String, String> map = new HashMap<>();
// 登入登出
map.put("/doLogin", "anon");
map.put("/logout", "logout");
// swagger
map.put("/swagger**/**", "anon");
map.put("/webjars/**", "anon");
map.put("/v2/**", "anon");
// 对所有用户认证
map.put("/**", "authc");
// 未登录,重定向路径
// shiroFilterFactoryBean.setLoginUrl("/login");
// 首页
// shiroFilterFactoryBean.setSuccessUrl("/index");
// 错误页面,认证不通过跳转
// shiroFilterFactoryBean.setUnauthorizedUrl("/error");
shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
return shiroFilterFactoryBean;
}
/**
* 将自定义CustomRealm 注入进SecurityManager
* @return
*/
@Bean
public CustomRealm myRealm() {
return new CustomRealm();
}
/**
* 将自定义CustomRealm 注入进SecurityManager
* Shiro通过SecurityManager来管理内部组件实例,并通过它来提供安全管理的各种服务
* @return
*/
@Bean
public DefaultWebSecurityManager securityManager(CustomRealm customRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 自定义Realm
securityManager.setRealm(customRealm);
return securityManager;
}
}
自定义登录失败、或没有登陆时返回json格式,而不是重定向到login.jsp
页面。注意:配置了这个之后,重定向路径配置setLoginUrl
将失效
创建ShiroLoginFilter
类:
import cn.aesop.common.restful.ResultBean;
import cn.aesop.common.restful.ResultCode;
import com.alibaba.fastjson.JSON;
import org.apache.shiro.web.filter.authc.FormAuthenticationFilter;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
/**
* @author: hxy
* @description: 对没有登录的请求进行拦截, 全部返回json信息. 覆盖掉shiro原本的跳转login.jsp的拦截方式
* @date: 2017/10/24 10:11
*/
public class ShiroLoginFilter extends FormAuthenticationFilter {
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) {
PrintWriter out = null;
HttpServletResponse res = (HttpServletResponse) response;
try {
res.setCharacterEncoding("UTF-8");
res.setContentType("application/json");
out = response.getWriter();
out.println(JSON.toJSONString(ResultBean.FAIL(ResultCode.E_201)));
} catch (Exception e) {
} finally {
if (null != out) {
out.flush();
out.close();
}
}
return false;
}
}
在Controller接口上加上如下注解,即可拦截没有权限的请求
@RequiresRoles(value={"admin","user"},logical = Logical.OR)
@RequiresPermissions(value={"add","update"},logical = Logical.AND)
如果有多个权限/角色验证的时候中间用“,”隔开,默认是所有列出的权限/角色必须同时满足才生效。但是在注解中有logical = Logical.OR这块。这里可以让权限控制更灵活些。
如果将这里设置成OR,表示所列出的条件只要满足其中一个就可以,如果不写或者设置成logical = Logical.AND,表示所有列出的都必须满足才能进入方法。
用subject这种通过代码控制的方法我没有深入了解,所以没有找到这种权限的控制。再加上使用注解更加简洁明了,所以个人更倾向于使用注解方式来控制。
至此一个基本的shrio + spring boot的框架已经搭建完毕
登录成功后可以通过以下代码获取当前登录的用户信息
Subject currentUser = SecurityUtils.getSubject();
UserInfo principal = (UserInfo)currentUser.getPrincipal();
//或者从session中获取自定义的信息
//Session session = SecurityUtils.getSubject().getSession();
//UserInfo principal = (UserInfo) session.getAttribute(Constants.SESSION_USER_INFO);
上面的例子密码是直接明文保存在数据库的,不安全,需要进行加密后才能存储,并且要与身份认证形成一个体系,下面介绍基本修改步骤:
1) 创建凭证匹配器
/**
* 凭证匹配器
* (由于我们的密码校验交给Shiro的SimpleAuthenticationInfo进行处理了
* 所以我们需要修改下doGetAuthenticationInfo中的代码;
* )
* 可以扩展凭证匹配器,实现 输入密码错误次数后锁定等功能,下一次
*/
@Bean(name = "credentialsMatcher")
public HashedCredentialsMatcher hashedCredentialsMatcher() {
HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
//散列算法:这里使用MD5算法;
hashedCredentialsMatcher.setHashAlgorithmName("md5");
//散列的次数,比如散列两次,相当于 md5(md5(""));
hashedCredentialsMatcher.setHashIterations(2);
//storedCredentialsHexEncoded默认是true,此时用的是密码加密用的是Hex编码;false时用Base64编码
hashedCredentialsMatcher.setStoredCredentialsHexEncoded(true);
return hashedCredentialsMatcher;
}
2)在注入CustomRealm处设置凭证匹配器,修改代码如下
/**
* 注入自定义权限验证对象
* Shiro Realm 继承自AuthorizingRealm的自定义Realm,即指定Shiro验证用户登录的类为自定义的
*/
@Bean
public CustomerRealm userRealm() {
CustomerRealm realm = new CustomerRealm();
realm.setCredentialsMatcher(hashedCredentialsMatcher());
return realm;
}
3)修改CustomerRealm类的doGetAuthenticationInfo方法
...
//加入盐 salt=username+salt
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, user.getPassword(), ByteSource.Util.bytes(username+"salt"), getName());
...
4)在注册用户或创建密码时,使用以下规则创建加密密码,存入数据库
// md5 + salt + hash散列次数
Md5Hash md5Hash2 = new Md5Hash(password, username+"salt", 2);
return md5Hash2.toString();
参考:shiro使用Md5加密
将session保存到redis ,多机部署使用同一个redis,可以保证session互相共享; 系统重启,用户也无需重新登陆
1)maven pom加入redis
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
2)application.yml配置
spring:
redis:
host: localhost #redis服务PI
port: 6379 #服务端
Redis 的基本操作
@Autowired
private RedisTemplate<String, Object> redisTemplate;
//保存
redisTemplate.opsForValue().set("key-1", "value-1");
//带有效期的保存
redisTemplate.opsForValue().set("key-1", "value-1", 120, TimeUnit.SECONDS);
//删除
redisTemplate.delete("key-1");
3)创建类继承CachingSessionDAO,自定义session持久化实现
需要Override的4个方法是:
doCreate
: shiro创建session时,将session保存到redis
doUpdate
: 当用户维持会话时,刷新session的有效时间
doDelete
: 当用户注销或会话过期时,将session从redis中删除
doReadSession
: shiro通过sessionId获取Session对象,从redis中获取
创建 RedisSessionDAO.java
import org.apache.shiro.session.Session;
import org.apache.shiro.session.mgt.eis.CachingSessionDAO;
import org.springframework.data.redis.core.RedisTemplate;
import java.io.Serializable;
import java.util.concurrent.TimeUnit;
/**
* 自定义session持久化实现,针对集群共享进行的Shiro 扩展
*/
public class RedisSessionDAO extends CachingSessionDAO {
//存入Redis中的SessionID的前缀
private static final String PREFIX = "SENTGON_SHOP_SHIRO_SESSION_ID";
//有效期(后续使用时会增加时间单位,秒)
private static final int EXPRIE = 86400; //1天
//Redis 操作工具
private RedisTemplate<Serializable, Session> redisTemplate;
//构造函数
public RedisSessionDAO(RedisTemplate<Serializable, Session> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* shiro创建session时,将session保存到redis
* @param session
* @return
*/
@Override
protected Serializable doCreate(Session session) {
//生成SessionID
Serializable serializable = this.generateSessionId(session);
assignSessionId(session, serializable);
//将sessionid作为Key,session作为value存入redis
redisTemplate.opsForValue().set(PREFIX+serializable, session);
return serializable;
}
/**
* 当用户维持会话时,刷新session的有效时间
* @param session
*/
@Override
protected void doUpdate(Session session) {
//设置session有效期
session.setTimeout(EXPRIE * 1000);
//将sessionid作为Key,session作为value存入redis,并设置有效期
redisTemplate.opsForValue().set(PREFIX+session.getId(), session, EXPRIE, TimeUnit.SECONDS);
}
/**
* 当用户注销或会话过期时,将session从redis中删除
* @param session
*/
@Override
protected void doDelete(Session session) {
//null 验证
if (session == null) {
return;
}
//从Redis中删除指定SessionId的k-v
redisTemplate.delete(PREFIX+session.getId());
}
/**
* shiro通过sessionId获取Session对象,从redis中获取
* @param sessionId
* @return
*/
@Override
protected Session doReadSession(Serializable sessionId) {
if (sessionId == null) {
return null;
}
//从Redis中读取Session对象
Session session = redisTemplate.opsForValue().get(PREFIX+sessionId);
return session;
}
}
4)将RedisSessionManager注入 SecurityManager
@Autowired
private RedisTemplate redisTemplate;
/**
* 容器中注册RedisSessionDao
* @param redisTemplate
* @return
*/
@Bean
public SessionDAO redisSessionDAO(RedisTemplate redisTemplate) {
return new RedisSessionDAO(redisTemplate);
}
/**
* 将自定义CustomRealm 注入进SecurityManager
* Shiro通过SecurityManager来管理内部组件实例,并通过它来提供安全管理的各种服务
* @return
*/
@Bean
public DefaultWebSecurityManager securityManager(CustomerRealm customRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 自定义Realm
securityManager.setRealm(customRealm);
// 重写session管理器,注入自定义的SessionDao
DefaultWebSessionManager defaultWebSessionManager = new DefaultWebSessionManager();
defaultWebSessionManager.setSessionDAO(redisSessionDAO(redisTemplate));
securityManager.setSessionManager(defaultWebSessionManager);
return securityManager;
}
至此,已经完成Shiro的集群共享Session