参考: Shiro Springboot 集群共享Session (Redis)+单用户登录
https://zhuanlan.zhihu.com/p/54176956
jdk8
maven
lombok
spring boot 2.5.7
<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring-boot-web-starter</artifactId> <version>1.8.0</version> </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<String>,【admin,student。。。】) info.setRoles(user.getRoles()); //设置用户许可(user.getPerms()是一个Set<String>,【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.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </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