SpringBoot+Shiro+Ehcache
公司项目重构, 用到了 SpringBoot+SpringMVC+Mybatis+Shiro+Redis等技术, 这篇博客用于记录 SpringBoot整合 Shiro的一些问题. 这篇博客很多地方引用了 https://blog.csdn.net/weixin_42236404/article/details/89319359#comments_12032156
本项目前后端分离, 使用了 Redis作为登录 Token缓存, 并设置过期时间, 下面上代码:
1: pom文件相关依赖
<properties>
<java.version>1.8</java.version>
<shiro.version>1.3.2</shiro.version>
</properties>
<!-- SpringBoot整合Spring Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- redis的驱动包:jedis -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<!--shiro核心-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<version>${shiro.version}</version>
</dependency>
<!--shiro的Web模块-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-web</artifactId>
<version>${shiro.version}</version>
</dependency>
<!--shiro和Spring集成-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>${shiro.version}</version>
</dependency>
<!--shiro底层使用的ehcache缓存-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-ehcache</artifactId>
<version>${shiro.version}</version>
</dependency>
<dependency>
<groupId>net.sf.ehcache</groupId>
<artifactId>ehcache-core</artifactId>
<version>2.6.8</version>
</dependency>
2: 登录入口
@ApiOperation(value = "用户登录", httpMethod = "POST")
@PostMapping(value = "/login")
@ResponseBody
@ApiImplicitParams({
@ApiImplicitParam(name = "username", value = "账号", required = true),
@ApiImplicitParam(name = "password", value = "密码", required = true)
})
public JsonResult login(@RequestParam("username") String username, @RequestParam("password") String password, HttpServletResponse response) throws Exception {
// 根据用户名查询用户
Qxczry qxczry = qxczryService.getCzryById(username);
if (qxczry == null || !qxczry.getQxczryzhxx().getPassword().equals(WebEncrypt.encode(password))) {
return new JsonResult("400", "账号或密码错误");
} else {
String token = qxczryService.createToken(qxczry.getCzrydm());
CookieUtil.addCookie(response, "USER_LOGIN_TOKEN", token, 30 * 60);
return new JsonResult("200", "登陆成功");
}
}
@ApiOperation(value = "用户登出", httpMethod = "POST", notes = "Parameter或 Header中必须携带 token")
@ResponseBody
@ApiImplicitParams({
@ApiImplicitParam(name = "token", value = "登录令牌", required = true)
})
@PostMapping("/logout")
public JsonResult logout(HttpServletRequest httpServletRequest) {
String token = TokenUtil.getRequestToken(httpServletRequest);
qxczryService.logout(token);
return new JsonResult("200", "您已安全退出系统");
}
WebEncrypt类作用是加密, 此处不贴出
3: 编写 Service方法, 生成一个 Token, 并保存到 Redis中
@Override
public Qxczry getCzryById(String czrydm) {
return qxczryMapper.getCzryById(czrydm);
}
@Override
public String createToken(String czrydm) {
// 生成一个token
String token = TokenGenerator.generateValue();
// 将 token保存到 Redis中, 并设置存活时间
redisTemplate.opsForValue().set(token, czrydm, 30L, TimeUnit.MINUTES);
// 并将 token返回
return token;
}
@Override
public void logout(String token) {
String userUUID = redisTemplate.opsForValue().get(token);
redisTemplate.opsForValue().set(token, userUUID, 1L, TimeUnit.MILLISECONDS);
}
ps: 登出方法写的很 LOW, 别介意, 这是初版 - -
4: 编写 ShiroConfig类
@Configuration
public class ShiroConfig {
/**
* 缓存管理器
* @return
*/
@Bean
public EhCacheManager ehCacheManager(){
EhCacheManager cacheManager = new EhCacheManager();
cacheManager.setCacheManagerConfigFile("classpath:ehcache.xml");
return cacheManager;
}
/**
* aop代理
* @return
*/
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}
/**
* 自定义realm
* @return
*/
@Bean
public AuthRealm authRealm() {
AuthRealm authRealm = new AuthRealm();
authRealm.setCacheManager(ehCacheManager());
return authRealm;
}
@Bean("securityManager")
public SecurityManager securityManager(AuthRealm authRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(authRealm);
securityManager.setRememberMeManager(null);
securityManager.setCacheManager(ehCacheManager());
return securityManager;
}
@Bean("shiroFilter")
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);
//auth过滤
Map<String, Filter> filters = new HashMap<>();
filters.put("auth", new AuthFilter());
shiroFilter.setFilters(filters);
Map<String, String> filterMap = new LinkedHashMap<>();
// anno匿名访问 auth验证
filterMap.put("/webjars/**", "anon");
filterMap.put("/druid/**", "anon");
filterMap.put("/sys/login", "anon");
filterMap.put("/swagger/**", "anon");
filterMap.put("/v2/api-docs", "anon");
filterMap.put("/swagger-ui.html", "anon");
filterMap.put("/swagger-resources/**", "anon");
filterMap.put("/**", "auth");
shiroFilter.setFilterChainDefinitionMap(filterMap);
return shiroFilter;
}
@Bean("lifecycleBeanPostProcessor")
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}
5: 实现自定义的 AuthenticationToken
public class AuthToken extends UsernamePasswordToken {
private String token;
public AuthToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
6: 编写自己的 Realm
@Component("AuthRealm")
public class AuthRealm extends AuthorizingRealm {
@Resource
@Lazy
private RoleMapper roleMapper;
@Resource
@Lazy
private PermissionMapper permissionMapper;
@Resource
@Lazy
private QxczryMapper qxczryMapper;
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 权限认证,即登录过后,每个身份不一定,对应的所能看的页面也不一样
* @param principalCollection
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
// 获得当前登录的用户对象
Qxczry qxCzry = (Qxczry) principalCollection.getPrimaryPrincipal();
// 查询该用户的所有角色
List<String> roleSns = roleMapper.selectRoleByCzryDm(qxCzry.getCzrydm());
// 查询该用户的所有权限
List<String> expressions = permissionMapper.selectGnmkzyByCzryDm(qxCzry.getCzrydm());
// 将角色和权限添加到授权信息对象
info.addRoles(roleSns);
info.addStringPermissions(expressions);
return info;
}
/**
* 身份认证,即登录通过账号和密码验证登陆人的身份信息
* @param token
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {String accessToken = (String) token.getPrincipal();
//1. 根据accessToken,查询用户信息
String tokenEntity = redisTemplate.opsForValue().get(accessToken);
//2. token失效
if (tokenEntity == null) {
throw new IncorrectCredentialsException("token失效,请重新登录");
}
//3. 调用数据库的方法, 从数据库中查询 username 对应的用户记录
Qxczry qxczry = qxczryMapper.getCzryById(tokenEntity);
//4. 若用户不存在, 则可以抛出 UnknownAccountException 异常
if (qxczry == null) {
throw new UnknownAccountException("用户不存在!");
}
//5. 根据用户的情况, 来构建 AuthenticationInfo 对象并返回. 通常使用的实现类为: SimpleAuthenticationInfo
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(qxczry, accessToken, this.getName());
return info;
}
}
需添加 @Lazy注解, 否则 Service缓存注解、事务注解不生效
7: 实现自定义 AuthenticatingFilter
public class AuthFilter extends AuthenticatingFilter {
/**
* 生成自定义token
*
* @param request
* @param response
* @return
* @throws Exception
*/
@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
//获取请求token
String token = TokenUtil.getRequestToken((HttpServletRequest) request);
return new AuthToken(token);
}
/**
* 步骤1.所有请求全部拒绝访问
*
* @param request
* @param response
* @param mappedValue
* @return
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
if (((HttpServletRequest) request).getMethod().equals(RequestMethod.OPTIONS.name())) {
return true;
}
return false;
}
/**
* 步骤2,拒绝访问的请求,会调用onAccessDenied方法,onAccessDenied方法先获取 token,再调用executeLogin方法
*
* @param request
* @param response
* @return
* @throws Exception
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
//获取请求token,如果token不存在,直接返回
String token = TokenUtil.getRequestToken((HttpServletRequest) request);
if (StringUtils.isBlank(token)) {
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtil.getOrigin());
httpResponse.setCharacterEncoding("UTF-8");
Map<String, Object> result = new HashMap<>();
result.put("status", 400);
result.put("msg", "请先登录");
String json = JSON.toJSONString(result);
httpResponse.getWriter().print(json);
return false;
}
return executeLogin(request, response);
}
/**
* token失效时候调用
*/
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setContentType("application/json;charset=utf-8");
httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtil.getOrigin());
httpResponse.setCharacterEncoding("UTF-8");
try {
//处理登录失败的异常
Throwable throwable = e.getCause() == null ? e : e.getCause();
System.out.println(e);
Map<String, Object> result = new HashMap<>();
result.put("status", 400);
result.put("msg", "登录凭证已失效,请重新登录");
String json = JSON.toJSONString(result);
httpResponse.getWriter().print(json);
} catch (IOException e1) {
}
return false;
}
}
补充:
ecachche.xml
<?xml version="1.0" encoding="UTF-8"?>
<ehcache updateCheck="false" dynamicConfig="false">
<diskStore path="java.io.tmpdir"/>
<cache name="users"
timeToLiveSeconds="300"
maxEntriesLocalHeap="1000"/>
<!--
name:缓存名称。
maxElementsInMemory:缓存最大个数。
eternal:对象是否永久有效,一但设置了,timeout将不起作用。
timeToIdleSeconds:设置对象在失效前的允许闲置时间(单位:秒)。仅当eternal=false对象不是永久有效时使用,可选属性,默认值是0,也就是可闲置时间无穷大。
timeToLiveSeconds:设置对象在失效前允许存活时间(单位:秒)。最大时间介于创建时间和失效时间之间。仅当eternal=false对象不是永久有效时使用,默认是0.,也就是对象存活时间无穷大。
overflowToDisk:当内存中对象数量达到maxElementsInMemory时,Ehcache将会对象写到磁盘中。
diskSpoolBufferSizeMB:这个参数设置DiskStore(磁盘缓存)的缓存区大小。默认是30MB。每个Cache都应该有自己的一个缓冲区。
maxElementsOnDisk:硬盘最大缓存个数。
diskPersistent:是否缓存虚拟机重启期数据 Whether the disk store persists between restarts of the Virtual Machine. The default value is false.
diskExpiryThreadIntervalSeconds:磁盘失效线程运行时间间隔,默认是120秒。
memoryStoreEvictionPolicy:当达到maxElementsInMemory限制时,Ehcache将会根据指定的策略去清理内存。默认策略是LRU(最近最少使用)。你可以设置为FIFO(先进先出)或是LFU(较少使用)。
clearOnFlush:内存数量最大时是否清除。
-->
<defaultCache name="defaultCache"
maxElementsInMemory="10000"
eternal="false"
timeToIdleSeconds="120"
timeToLiveSeconds="120"
overflowToDisk="false"
maxElementsOnDisk="100000"
diskPersistent="false"
diskExpiryThreadIntervalSeconds="120"
memoryStoreEvictionPolicy="LRU"/>
</ehcache>
public class HttpContextUtil {
public static HttpServletRequest getHttpServletRequest() {
return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
}
public static String getDomain() {
HttpServletRequest request = getHttpServletRequest();
StringBuffer url = request.getRequestURL();
return url.delete(url.length() - request.getRequestURI().length(), url.length()).toString();
}
public static String getOrigin() {
HttpServletRequest request = getHttpServletRequest();
return request.getHeader("Origin");
}
}
public class CookieUtil {
/**
* 添加 Cookie
* @param response 响应对象
* @param name
* @param value
* @param timeout 生命存活周期
*/
public static void addCookie(HttpServletResponse response, String name, String value, int timeout) {
// 将token封装cookie中, 通过response对象返回到浏览器中
Cookie cookie = new Cookie(name, value);
// 设置作用域
cookie.setPath("/");
// 设置生命周期
cookie.setMaxAge(timeout);
response.addCookie(cookie);
}
/**
* 获取token
* @param request token
*/
public static String getToken(HttpServletRequest request, HttpServletResponse response) {
Cookie[] cookies = request.getCookies();
if(cookies != null){
for (Cookie cookie : cookies) {
if(cookie.getName().equals("USER_LOGIN_TOKEN")){
cookie.setMaxAge(30 * 60);
// response.addCookie(cookie);
return cookie.getValue();
}
}
}
return null;
}
}
AuthRealm类权限认证会加载该角色锁拥有的权限和贴有 @RequiresPermissions注解的类做比对, 也就是说只有贴了此注解才被 Shiro所管理
@ApiOperation(value = "重新加载所有权限", httpMethod = "POST")
@PostMapping("/reloadPermission")
@RequiresPermissions(value = {"权限管理:重新加载所有权限", "permission:reloadPermission"}, logical = Logical.OR)
public JsonResult<Boolean> reloadPermission(){
JsonResult<Boolean> result = new JsonResult<>();
try {
permissionService.reload();
result.setCode("200");
result.setData(true);
} catch (Exception e) {
e.printStackTrace();
result.setCode("500");
result.setMsg("加载失败");
result.setData(false);
}
return result;
}
以下为流程图:
项目尚未完成, 不过 Shiro基本功能已实现, 以上代码有以下细节未处理:
1. 登录后用户操作不能刷新客户端 Cookie的存活时间
2. 无权限异常提示未处理