shiro+JWT+EhCache,实现认证、鉴权缓存的移除与更新。
目录
前言
基本上一个正经的管理系统,都会带有有较细粒度的权限过滤,通常都是使用RBAC模式进行权限的设计,也就是:权限-角色-用户。
一个用户可以拥有多个角色,一个角色拥有多个权限。
用户不直接与权限关联,而是与角色关联
同时,实际开发中,为了提高鉴权的效率,一般也会对用户的认证信息与权限信息进行缓存操作,为了满足权限共享,多使用Redis,不过这里讲的是使用EhCache做缓存服务,换成redis实际上也就是更换实现。
本文基于springboot编写,所涉及的entity、service,以及权限表需自行定义
流程解释
用户登录时,需要自定义登录逻辑,如是否需要验证码等,登录成功后,使用JWT工具生成token并返回,,前端需要保存返回的token,后续的请求需要携带此token。
除了登录操作和被放行的可直接访问的资源以外,一个请求在执行真正业务之前,一般都会经过一层过滤器的过滤,过滤器中会对当前的请求进行一个登录认证操作。
首先是判断此次请求有无携带token,如未携带,表明该请求未登录,直接做出相应返回,如跳转登录页,或是返回指定错误信息。
以上只是本次请求是否可行的判断,而真正的权限鉴定,一般都在真正访问接口前进行
如权限鉴定和角色鉴定
流程图:
代码
组件介绍
BasicHttpAuthenticationFilter-过滤器
先要明确一点,当shiro执行了此Filter时,就已经证明了所请求的资源是需要验证的
方法解释
通常都是自定义一个过滤器继承该类,并重写几个特定方法,如
preHandle 预处理方法,可用于解决跨域问题
isAccessAllowed是否允许本次请求访问资源,允许则返回true
isLoginAttempt判断是否需要执行登录操作,一般可在isAccessAllowed中使用,判断本次是否执行登录操作,而判断的依据可以是检查请求是否携带token
executeLogin执行登录验证,若本次请求携带token,则在isAccessAllowed中执行登录操作
onAccessDenied拒绝请求,若本次请求未带token,或者登录验证失败,则执行此方法,可在此方法中返回指定数据给本次请求,如web端跳转登录页,或前后分离时返回登录code
preHandle->isAccessAllowed->isLoginAttempt->executeLogin->onAccessDenied
访问流程图
图中执行链以调用开头的,都是自己执行调用的,其余的为shiro执行
代码
/**
* 判断用户是否需要登入。
* 检测header或cookie里是否包含token字段即可
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String authorization = httpServletRequest.getHeader(JWT_TOKEN_HEADER_NAME);//从header获取token
Cookie[] cookies = null;//准备从cookie获取token
if (StringUtils.isBlank(authorization) && ArrayUtils.isNotEmpty((cookies=httpServletRequest.getCookies()))) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(JWT_TOKEN_COOKIE_NAME)) {
authorization = StringUtils.isNotBlank(cookie.getValue()) ? cookie.getValue() : null;
break;
}
}
}
request.setAttribute("token",authorization);//若token位置唯一,则不用再存一次,直接返回ture,在executeLogin再获取即可
return !StringUtils.isBlank(authorization);
}
//执行登录操作
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
Object tokenAttr = request.getAttribute("token");//isLoginAttempt中已对token位置做处理
JWTToken token = new JWTToken(tokenAttr.toString());//新建一个token对象
if (token.getPrincipal()==null) return false;//若token创建失败,直接退出
// 提交给realm进行登入,如果错误他会抛出异常并被捕获
getSubject(request,response).login(token);//让shiro执行登录操作,最终会走Realm的验证方法,失败则抛异常
return true;
}
//shiro调用此方法判断本次请求是否被允许
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
if (isLoginAttempt(request, response)) {//判断是否需要执行登录,不执行,直接返回false
try {
return executeLogin(request, response);//执行登录操作
} catch (Exception e) {
request.setAttribute("msg", e.getMessage());
}
}
return false;
}
//当本次请求不被允许时,shiro会调用此方法
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest)request;
String uri = httpServletRequest.getRequestURI();
if (uri.startsWith(WEB_PAGE_PATH_PREFIX)) {//判断是否是web网页请求,是则跳转login页面
//可对本次请求的uri进行处理,保证登录完成后再次回到本页面
RequestDispatcher requestDispatcher = ((HttpServletRequest) request).getRequestDispatcher(login_html);
requestDispatcher.forward(request, response);
}else {//前后分离,则可回写状态码
response.setCharacterEncoding("utf-8");
OutputStream os = response.getOutputStream();
os.write(JSONObject.toJSONBytes(error_json)));
os.flush();
os.close();
}
return false;
}
//预处理,对跨域提供支持
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
一般实体
AuthenticationToken
shiro验证过程中,用于携带token和用户唯一凭证的实例
public class JWTToken implements AuthenticationToken {
// 密钥
private String token;
private String userId;
public JWTToken(String token) {
this.token = token;
this.userId =JWTUtil.getUserId(this.token);//对token进行解析,获取用户唯一标识
}
//获取标识
public Object getPrincipal() {
return userId;//后续可用于判断token是否解析正常
}
//获取密钥,即token
public Object getCredentials() {
return token;
}
}
PrincipalCollection
凭证集合,shiro已提供子类,可满足正常需求:SimplePrincipalCollection
同时,这里的Principal与token中的Principal意义不同,token中的Principal仅是为了方便后续判断的标识,而真正的PrincipalCollection中存储的Principal,是后续业务逻辑中,可能会用到的必要用户信息,如用户实体,其余用户信息等
AuthorizationInfo
用户验证信息,shiro已提供子类,可满足正常需求:SimpleAuthenticationInfo
其中存储了用户的凭证集合
AuthorizationInfo
用于存储用户权限集合信息,包含角色,权限等信息,已有可用子类:SimpleAuthorizationInfo
AuthorizingRealm真正登录及鉴权的工具
需重写方法
supports:使自定义的token被shiro支持
doGetAuthorizationInfo:获取权限
doGetAuthenticationInfo:验证信息
getAuthorizationCacheKey获取权限存于缓存中的key值,自定义规则,简化后续操作
getAuthenticationCacheKey:获取认真信息存于缓存中的key值,自定义规则,简化后续操作
@Component
public class MyRealm extends AuthorizingRealm {
@Autowired
@Lazy
private ManagerService managerService;
@Autowired
@Lazy
private RoleService roleService;
@Autowired
@Lazy
private CacheUtil cacheUtil;
@Autowired
@Lazy
private MenuService menuService;
/**
* 大坑!,必须重写此方法,不然Shiro会报错
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JWTToken;
}
/**
* 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authToken) throws AuthenticationException, CommonBaseException {
// 解密获得username,用于和数据库进行对比
String userId = authToken.getPrincipal().toString();
if (userId == null)//判断token数据是否有效
throw new AuthenticationException(Constant.ADMIN_LOGIN_TIMEOUT);
Manager manager = managerService.getById(userId);//从数据库中检出用户
if (manager == null)//判断用户是否存在
throw new AuthenticationException(Constant.ADMIN_MANAGER_NOT_EXIST);
if (manager.getStatus() == 0)//判断用户状态
throw new AuthenticationException(CommonBaseErrorCode.ADMIN_MANAGER_IS_LOCK.getErrMsg());
String token = authToken.getCredentials().toString();//验证token的真实有效性
if (!JWTUtil.verify(token, userId, manager.getPassword()))
throw new AuthenticationException(Constant.ADMIN_USERNAME_PASSWORD_ERROR);
//返回用户验证信息,其中,库中数据作为主要principal存入,并且存入token,并标记本次验证的realmName,因为shiro支持多realm验证。
return new SimpleAuthenticationInfo(manager, token, Constant.MY_REALM);
}
/**
* 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
//解密用户名
Object primaryPrincipal = principals.getPrimaryPrincipal();//获取验证用户时存入的主凭证
if (primaryPrincipal == null)//判空
throw new AuthenticationException(Constant.ADMIN_LOGIN_TIMEOUT);
Manager manager = (Manager) primaryPrincipal;//强转
//查询用户所拥有角色
List<Role> roles = roleService.getUserRole(manager.getId().toString());//角色service获取用户Roles
manager.setRoles(roles);//存入manager实体中(看需求存入)
//设置用户角色
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
simpleAuthorizationInfo.addRoles(
new ArrayList<String>() {{
roles.forEach(r -> add(r.getRoleName()));
}});//遍历获取到的角色,将角色的唯一标识建立集合
//查询用户角色拥有的菜单(权限)
List<Menu> menus = menuService.findMenuByRIds(
new ArrayList<Integer>() {{
roles.forEach(role -> add(role.getId()));
}}, 1);
//设置用户菜单权限
ArrayList<String> permissions = new ArrayList<String>() {{
menus.forEach(menu -> add(menu.getPermission()));
}};//同Role操作
simpleAuthorizationInfo.addStringPermissions(permissions);
return simpleAuthorizationInfo;
}
//获取用户授权缓存key
protected Object getAuthorizationCacheKey(PrincipalCollection principals) {
// 已知主principal为用户实体,这里直接强转,并用ID作为缓存key
return ((Manager) principals.getPrimaryPrincipal()).getId();
}
//获取用户缓存key
protected Object getAuthenticationCacheKey(AuthenticationToken token) {
//已知token中的Principal就是userId,唯一标识,做key,后续方便操作缓存
return token != null ? token.getPrincipal() : null;
}
}
配置信息
maven引入
<!--便捷entity-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.6</version>
<scope>provided</scope>
</dependency>
<!-- apache组件包commons -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!--JWT验证-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
<!--shiro依赖-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.0</version>
<exclusions>
<exclusion>
<artifactId>slf4j-api</artifactId>
<groupId>org.slf4j</groupId>
</exclusion>
</exclusions>
</dependency>
<!-- ehchache -->
<dependency>
<groupId>net.sf.ehcache</groupId>
<artifactId>ehcache</artifactId>
</dependency>
<!-- springBoot整合ehchache -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- shiro整合ehchache -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-ehcache</artifactId>
<version>1.4.0</version>
</dependency>
Config配置
EhCacheConfig
import net.sf.ehcache.Cache;
import net.sf.ehcache.CacheManager;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.shiro.cache.ehcache.EhCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CacheConfig {
@Bean
public EhCacheManager ehCacheManager(CacheManager cacheManager) {
EhCacheManager ehCacheManager = new EhCacheManager();
ehCacheManager.setCacheManager(cacheManager);
return ehCacheManager;
}
}
XML文件
<?xml version="1.0" encoding="UTF-8"?>
<ehcache name="es">
<!--缓存对象存放路径-->
<diskStore path="java.io.tmpdir"/>
<!--默认缓存配置-->
<defaultCache maxElementsInMemory="10000" eternal="false"
timeToIdleSeconds="0" timeToLiveSeconds="0"
overflowToDisk="false" diskPersistent="false"
diskExpiryThreadIntervalSeconds="120" />
<!-- 授权缓存 -->
<cache name="authorizationCache" maxEntriesLocalHeap="2000"
eternal="false" timeToIdleSeconds="0"
timeToLiveSeconds="0" overflowToDisk="false"
statistics="true"> </cache>
<!-- 认证缓存 -->
<cache name="authenticationCache" maxEntriesLocalHeap="2000"
eternal="false" timeToIdleSeconds="0"
timeToLiveSeconds="0" overflowToDisk="false"
statistics="true"> </cache>
</ehcache>
ShiroConfig
配置了缓存后,shiro会自动以realm中重写的缓存key,从所配置的缓存中获取数据,如果为空,则调用realm的鉴权或认证方法,完成操作后,会自动存入缓存中
import at.pollux.thymeleaf.shiro.dialect.ShiroDialect;
import org.apache.shiro.cache.ehcache.EhCacheManager;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
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;
@Configuration
public class ShiroConfig {
@Bean
public ShiroDialect shiroDialect() {
return new ShiroDialect();
}
@Bean("securityManager")
public DefaultWebSecurityManager securityManager(MyRealm myRealm,EhCacheManager ehCacheManager) {
myRealm.setCachingEnabled(true);
//启用身份验证缓存,即缓存AuthenticationInfo信息,默认false
myRealm.setAuthenticationCachingEnabled(true);
// 缓存AuthenticationInfo信息的缓存名称 在ehcache.xml中有对应缓存的配置
myRealm.setAuthenticationCacheName("authenticationCache");
//启用授权缓存,即缓存AuthorizationInfo信息,默认false
myRealm.setAuthorizationCachingEnabled(true);
//缓存AuthorizationInfo信息的缓存名称 在ehcache.xml中有对应缓存的配置
myRealm.setAuthorizationCacheName("authorizationCache");
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
// 使用自己的realm
manager.setRealm(myRealm);
manager.setCacheManager(ehCacheManager);
/*
* 关闭shiro自带的session,详情见文档
* http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29
*/
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
manager.setSubjectDAO(subjectDAO);
return manager;
}
@Bean("shiroFilter")
public ShiroFilterFactoryBean factory(DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
//设置登录页面
factoryBean.setLoginUrl("/login.html");
//设置登录成功后跳转的页面
factoryBean.setSuccessUrl("/");
// 添加自己的过滤器并且取名为jwt
Map<String, Filter> filterMap = new HashMap<>();
filterMap.put("jwt", new JWTFilter());
factoryBean.setFilters(filterMap);
//拦截器
Map<String,String> filterChainDefinitionMap = new LinkedHashMap<String,String>();
//配置映射关系 anon即不验证,jwt即为刚刚添加的过滤器,
// /**为除了被放行的资源,其余都要经过jwt过滤器
filterChainDefinitionMap.put("/static/**","anon");//放行静态资源
filterChainDefinitionMap.put("/login", "anon");//放行登录操作
// 所有请求通过JWT Filter做验证
filterChainDefinitionMap.put("/**", "jwt");
factoryBean.setSecurityManager(securityManager);
factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return factoryBean;
}
/**
* 下面的代码是添加注解支持
*/
@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(DefaultWebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}
工具类Util
JWTUtil
public class JWTUtil {
/**
* 校验token是否正确
*/
public static boolean verify(String token, String userId, String secret) {
try {
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("userId", userId).build();
verifier.verify(token);
return true;
} catch (Exception exception) {
return false;
}
}
/**
* 获得token中的信息,无需secret解密也能获得
*/
public static String getUserId(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("userId").asString();
} catch (JWTDecodeException e) {
return null;
}
}
/**
* 生成签名,指定天数后过期
*/
public static String sign(String username, String secret) {
Date date = new Date(System.currentTimeMillis()+ EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(secret);
// 附带username信息
return JWT.create().withClaim("userId", username)
.withExpiresAt(date).sign(algorithm);
}
/**
* 加密用户密码
*/
public static String encryptPassword(String password ){
return new SimpleHash("MD5",password,password.substring(password.length()-2),password.length()).toString();
}
}
其他
shiro的login简述
当在filter中调用getSubject时,会从当前线程中获取一个ThreadContext对象,这个对象将绑定一个ThreadLocal,在执行login操作时,shiro会调用realm中的认证方法,并将成功认证后的结果绑定到ThreadLocal中。
在后续的代码中,若有地方需要从shiro中获取当前认证信息,也就是用户信息时,只需要从SecuretyUtil中get当前的Subject,便可以获取到,在filter的login操作时,已绑定到线程中的认证信息。
缓存的过程
认证缓存
当用户登录时,shiro的login会从缓存中,尝试利用token,利用realm中的认证缓存key方法,获取缓存key,再去已在config中配置的cache中获取认证缓存,若缓存为空,再真正调用realm中的认证方法,并将成功登录的结果存入缓存中,以供下次获取。
权限缓存
当一个用户发起请求,并且该请求已被放行,访问到了真正的接口,同时该接口有权限要求时,shiro会执行realm中鉴权方法,并将结果缓存,以待下次使用。
所以当一个接口有权限限制时,必须配置权限声明,不然任意用户成功通过filter登录后,都能成功访问该接口。
注意事项
若shiro使用到了缓存,而一个用户的登录凭证(token)又由用户存储时,当我们变更用户权限或甚至是锁定用户,我们就必须要及时的对用户缓存数据进行清除,若不然,当用户再次发起请求时,只会从缓存中获取失效的数据。
当对用户角色做变更时,需要删除该用户的缓存的缓存。
当对角色的权限变更候,需要删除所有拥有该角色的用户的缓存。
当对用户进行锁定操作时,需要删除用户认证及权限缓存。
当对权限进行变更时,需要找到所有拥有该权限的角色,删除所有拥有这些角色的用户的权限缓存
权限鉴定
shiro提供了几种注解用于鉴权,注解于控制单元上
用户必须登录:@RequiresAuthentication
用户必须有以下权限: @RequiresPermissions(“权限名,realm鉴权时加入的集合中的菜单”)
用户必须为以下角色:@RequiresRoles(“角色名”)