需要实现的功能如题,本文将分块记录每一个关键环节。
闲言少叙,直接上干货↓↓
核心依赖
这个shiro-all看上去有点非主流,但是在maven库中确实是存在的,不想麻烦的直接依赖这个就行了
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-all</artifactId>
<version>1.3.2</version>
</dependency>
<dependency>
<groupId>org.crazycake</groupId>
<artifactId>shiro-redis</artifactId>
<version>3.2.3</version>
</dependency>
返回结果实体类
没啥说的
package com.example.demo.shiro;
public class RestResult<T> {
int code;
String msg;
T data;
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}
用户信息实体类
这个虽然看上去也没啥说的,但是我经历过一个异常堆栈,原因是在realm实现类里实现认证方法new SimpleAuthenticationInfo的时候,principal参数传的是UserInfo类的对象,而最开始的UserInfo类没有id字段,所以在执行subject.logout()方法的时候抛出异常,提示principal的对象没有id字段。然后我加上了id字段,但是登陆的时候没有向其中传值,所以又抛出id字段值为null的异常。
所以解决这个问题的方法是要么在实体类中加id字段并有值,要么principal参数不要传对象,直接传个username或者其他什么的标识的字符串
2021-07-20补充说明
如果实体类存在父类,则登录后,父类字段的信息不会被保存,现象就是在非登录线程中,使用subject.getPrincipal()方法获取出实体对象,属于父类的字段内容为null
package com.example.demo.shiro;
import java.io.Serializable;
public class UserInfo implements Serializable {
private String id;
private String userName;
private String password;
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
}
实现realm
realm是shiro的核心组件,实现的这两个方法名字看上去很像。
doGetAuthenticationInfo方法作用是认证,根据传入的账户和密码验证用户是否存在,整合了redis实现单点登录情况下,在执行过此方法后,会以当前session的ID加shiro固定的前缀为key在redis中保存此次登录的session,以此实现共享session,避免重复认证;
doGetAuthorizationInfo方法作用是赋予角色和资源(也可以理解为权限标识)。
package com.example.demo.shiro;
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;
public class SupRealm extends AuthorizingRealm {
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
Set<String> roles = new HashSet<>();
roles.add("role1");
roles.add("role2");
simpleAuthorizationInfo.addRoles(roles);
Set<String> perms = new HashSet<>();
perms.add("perms1");
perms.add("perms2");
simpleAuthorizationInfo.addStringPermissions(perms);
return simpleAuthorizationInfo;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
UsernamePasswordToken token = (UsernamePasswordToken)authenticationToken;
String userName = token.getUsername();
String pwd = String.valueOf(token.getPassword());
UserInfo userInfo = new UserInfo();
userInfo.setPassword(pwd);
userInfo.setUserName(userName);
return new SimpleAuthenticationInfo(userInfo.getUserName(), userInfo.getPassword(), "SupRealm");
}
}
未认证的请求返回JSON数据
对于未登录的请求,shiro默认的实现是重定向至一个url,如果在配置ShiroFilterFactoryBean没有指定url则默认重定向至/login.jsp,但是对于前后端分离的情况下,最好还是在判断为未登录的情况返回一个状态码或是一个JSON数据。
对于未登录的请求发生后,shiro也还是会在redis中缓存一个session,对于这种无效的session显然是我们不希望存在的,所以在返回状态码或JSON数据前我们将这个无效session删除掉。
ShiroAuthcFilter 的配置会在最后看到
package com.example.demo.shiro;
import com.alibaba.fastjson.JSONObject;
import org.apache.shiro.web.filter.authc.FormAuthenticationFilter;
import org.springframework.util.StringUtils;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.PrintWriter;
public class ShiroAuthcFilter extends FormAuthenticationFilter {
@Autowired
@Qualifier("sessionDAO")
private RedisSessionDAO redisSessionDAO;
@Override
protected void redirectToLogin(ServletRequest request, ServletResponse response) throws IOException {
// 删除无效session
Subject subject = SecurityUtils.getSubject();
Session session = subject.getSession();
redisSessionDAO.delete(session);
RestResult restResult = new RestResult();
response.setContentType("text/html");
response.setCharacterEncoding("UTF-8");
HttpServletRequest httpRequest = (HttpServletRequest)request;
String device = httpRequest.getHeader("device");
PrintWriter writer = response.getWriter();
try {
restResult.setCode(-1);
restResult.setMsg("请登录");
writer.print(JSONObject.toJSON(restResult).toString());
}catch (Exception e){
e.printStackTrace();
throw e;
}finally {
writer.flush();
writer.close();
}
}
}
实现登录和踢人
sessionManager和redisCacheManager之所以能被注入是因为在配置类中配置了这两个Bean(在后面能看到)。
登录方法前三行代码是shiro的登录操作,基本上是固定搭配。主要说一下踢人的逻辑,创建一个Deque集合来保存登录时shiro生成的sessionid,然后将这个集合以username为key保存到redis中,如果集合中保存的sessionid多于一个,则删除集合中其他的sessionid,并根据被删除的sessionid获取对应的session(即被踢掉的session),设置kickout属性值为true。
这样,被踢掉的session再发请求的时候,校验属性kickout的属性值为true,则不允许继续操作,并返回状态
package com.example.demo.shiro;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.mgt.DefaultSessionKey;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.crazycake.shiro.RedisCacheManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.Serializable;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/shiroController")
public class ShiroController {
@Autowired
@Qualifier("sessionManager")
private DefaultWebSessionManager sessionManager;
@Autowired
@Qualifier("redisCacheManager")
private RedisCacheManager redisCacheManager;
@GetMapping("/sys/login")
public RestResult login(String username, String pwd){
Subject subject = SecurityUtils.getSubject();
UsernamePasswordToken token = new UsernamePasswordToken(username, pwd);
subject.login(token);
Cache<String, Deque<Serializable>> cache = this.redisCacheManager.getCache("kickout-session");
Deque<Serializable> deque = cache.get(username);
if(deque == null){
deque = new ArrayDeque<>();
}
Session session = subject.getSession();
if(!deque.contains(session.getId())){
deque.add(session.getId());
cache.put(username, deque);
}
while(1 < deque.size()){
Serializable serializable = deque.removeFirst();
if(!serializable.equals(session.getId())){
Session kickoutSession = this.sessionManager.getSession(new DefaultSessionKey(serializable));
kickoutSession.setAttribute("kickout", true);
kickoutSession.setAttribute("kickoutMsg", "让人踢了");
cache.put(username, deque);
}
}
RestResult restResult = new RestResult();
restResult.setMsg("登录成功");
restResult.setCode(0);
return restResult;
}
@GetMapping("/static/testM1")
public Map<String, Object> testM1(){
Map<String,Object> map = new HashMap<>();
map.put("field1","value1");
return map;
}
@GetMapping("/bus/testM2")
public String testM2(){
return "testM2";
}
@GetMapping("/bus/testM3")
public String testM3(){
return "testM3";
}
@GetMapping("/bus/testM4")
@RequiresPermissions("test41")
public String testM4(){
return "testM4";
}
}
实现被踢
实现一个拦截器,需继承AccessControlFilter 类,并实现isAccessAllowed和onAccessDenied方法。
这里isAccessAllowed方法返回值不能设置为true,如果设置为true则onAccessDenied方法不能被执行。
我们在onAccessDenied方法中获取当前请求的session,并校验kickout属性是否为true,如果是true则登出允许继续请求,并返回状态。
在网上查阅资料的时候,发现有不少人喜欢将已登录的有效session保存起来并标记被踢session的方法和识别session被踢并且登出的方法写在一起,如果这样写的话用户在登录的时候就不能第一时间保存有效sessionid,而是在登录后的第一个请求中保存,感觉这样怪怪的,而且每次请求都要走一遍是否需要保存sessionid的逻辑,更奇怪。
KickoutSessionFilter 的配置会在最后看到
package com.example.demo.shiro;
import com.alibaba.fastjson.JSONObject;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.mgt.DefaultSessionKey;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.AccessControlFilter;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.crazycake.shiro.RedisCacheManager;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.io.PrintWriter;
import java.io.Serializable;
import java.util.ArrayDeque;
import java.util.Deque;
public class KickoutSessionFilter extends AccessControlFilter {
private SessionManager sessionManager;
private Cache<String, Deque<Serializable>> cache;
private RedisCacheManager redisCacheManager;
public void setSessionManager(SessionManager sessionManager){
this.sessionManager = sessionManager;
}
public void setCache(RedisCacheManager redisCacheManager){
this.cache = redisCacheManager.getCache("kickout-session");
this.redisCacheManager = redisCacheManager;
}
@Override
protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) throws Exception {
return false;
}
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
Subject subject = getSubject(request,response);
if (subject == null || !subject.isAuthenticated()) {
return true;
}
Session session = subject.getSession();
if(session.getAttribute("kickout") != null && (boolean)session.getAttribute("kickout")){
subject.logout();
saveRequest(request);
RestResult restResult = new RestResult();
restResult.setCode(1);
restResult.setMsg("被踢了");
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html");
((DefaultWebSessionManager)this.sessionManager).getSessionDAO().delete(session);
PrintWriter writer = response.getWriter();
try{
writer.print(JSONObject.toJSON(restResult).toString());
return false;
}catch (Exception e){
e.printStackTrace();
}finally {
writer.close();
}
}
return true;
}
}
shiro的配置类
配置是重中之重,没有这部分,上边的活全都白干。
挨个说一下吧。
getRealm()、securityManager()和shiroFilterFactoryBean()的配置是shiro配置的核心,realm负责认证和授权、securityManager是shiro的安全管理器,创建subject就是由它完成的、shiroFilterFactoryBean用来定义拦截规则。
lifecycleBeanPostProcessor()和defaultAdvisorAutoProxyCreator()配置用来开启shiro的注解功能。
redisManager()、sessionDAO()、cookie()、sessionManager()、redisCacheManager()和sessionIdGenerator()配置是实现单点登录的关键配置。
redisManager()用来配置redis的配置信息。
redisCacheManager()用来操作redis缓存。
sessionDAO()用来执行session的增删改查。
sessionManager()用来管理session的生命周期。
cookie()用来配置sessionid以何key保存在前端。
sessionIdGenerator()sessionid生成器。
kickoutSessionFilter()和shiroAuthcFilter()都是自定义的过滤器。
kickoutSessionFilter()配置的功能是session被踢时的逻辑。
shiroAuthcFilter()配置的是对未登录请求的处理逻辑。
有一个容易被忽视的点需要注意一下,就是自定义的过滤器的配置要在shiroFilterFactoryBean的配置后面,否则会报如下的异常。
org.apache.shiro.UnavailableSecurityManagerException: No SecurityManager accessible to the calling code, either bound to the org.apache.shiro.util.ThreadContext or as a vm static singleton. This is an invalid application configuration.
2021-07-21更新
关于defaultAdvisorAutoProxyCreator配置,会将包含数据库事务的springBean的aop代理方式改为jdk动态代理,若仍想用cglib的方式代理,则需要设置defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
关于kickoutSessionFilter和shiroAuthcFilter以及一系列的自定义filter配置,如果将这些过滤器声明为springbean,则这些过滤器将过滤所有请求,所以在这里要将@Bean去掉
package com.example.demo.shiro;
import org.apache.shiro.session.mgt.eis.JavaUuidSessionIdGenerator;
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.servlet.SimpleCookie;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.crazycake.shiro.RedisCacheManager;
import org.crazycake.shiro.RedisManager;
import org.crazycake.shiro.RedisSessionDAO;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.apache.shiro.mgt.SecurityManager;
import org.springframework.context.annotation.DependsOn;
import javax.servlet.Filter;
import java.util.LinkedHashMap;
import java.util.Map;
@Configuration
public class ShiroConfigure {
@Bean
public SupRealm getRealm(){
SupRealm realm = new SupRealm();
return realm;
}
@Bean
public DefaultWebSecurityManager securityManager(){
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(getRealm());
securityManager.setSessionManager(sessionManager());
securityManager.setCacheManager(redisCacheManager());
return securityManager;
}
@Bean
public RedisManager redisManager(){
RedisManager manager = new RedisManager();
manager.setHost("10.32.144.118:6379");
manager.setTimeout(360000);
manager.setDatabase(2);
return manager;
}
@Bean
public JavaUuidSessionIdGenerator sessionIdGenerator(){
return new JavaUuidSessionIdGenerator();
}
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager){
LinkedHashMap<String, String> map = new LinkedHashMap<>();
map.put("/shiroController/static/**","anon");
map.put("/shiroController/sys/login","anon");
map.put("/shiroController/bus/**","authc,kickout");
ShiroFilterFactoryBean filter = new ShiroFilterFactoryBean();
filter.setSecurityManager(securityManager);
filter.setFilterChainDefinitionMap(map);
Map<String, Filter> filterMap = new LinkedHashMap<>();
filterMap.put("kickout", kickoutSessionFilter());
filterMap.put("authc",shiroAuthcFilter());
filter.setFilters(filterMap);
return filter;
}
@Bean
public RedisSessionDAO sessionDAO(){
RedisSessionDAO sessionDAO = new RedisSessionDAO();
sessionDAO.setRedisManager(redisManager());
sessionDAO.setSessionIdGenerator(sessionIdGenerator());
return sessionDAO;
}
@Bean
public SimpleCookie cookie(){
SimpleCookie cookie = new SimpleCookie("sid");
cookie.setHttpOnly(true);
cookie.setPath("/");
return cookie;
}
@Bean
public DefaultWebSessionManager sessionManager(){
DefaultWebSessionManager manager = new DefaultWebSessionManager();
//manager.setGlobalSessionTimeout(-1000L);
manager.setDeleteInvalidSessions(true);
manager.setSessionIdCookie(cookie());
manager.setSessionDAO(sessionDAO());
return manager;
}
@Bean
public RedisCacheManager redisCacheManager(){
RedisCacheManager manager = new RedisCacheManager();
manager.setRedisManager(redisManager());
return manager;
}
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor(){
return new LifecycleBeanPostProcessor();
}
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator(){
return new DefaultAdvisorAutoProxyCreator();
}
/*@Bean*/
public KickoutSessionFilter kickoutSessionFilter(){
KickoutSessionFilter kickoutSessionFilter = new KickoutSessionFilter();
kickoutSessionFilter.setCache(redisCacheManager());
kickoutSessionFilter.setSessionManager(sessionManager());
return kickoutSessionFilter;
}
/*@Bean*/
public ShiroAuthcFilter shiroAuthcFilter(){
ShiroAuthcFilter shiroAuthcFilter = new ShiroAuthcFilter();
return shiroAuthcFilter;
}
}
结尾
学而时习之不亦说乎。
之前几乎没有接触过shiro框架,之所以会研究它基本上是出于兴趣。从着手编写第一行代码到完成本文所展示的功能,共耗时三天,因为之前对这个框架了解不是很多,所以在学习的过程中遇到不少问题,通过翻阅各种资料(一顿百度),看了少部分源码,最终一一攻克。
很享受这种专注于研究某一件事的感觉,尤其是在期间虽然遇到各种问题,但最后修成正果的成就感。
社会竞争激烈,唯有不断的给自己积累筹码才能持续生存,保持学习,加油!