在2017年年底发布的一篇博客中介绍了前后端分离的场景下,springboot与shiro整合,时隔一年多,springboot早已升级到2.X,前后端分离也慢慢回归一体化,于是有了这样一篇博客,本文主要介绍在前后端不分离的场景下,springboot2与shiro以及thymeleaf模板引擎的整合。另外,在之前的文章中很多评论提到的问题,在本文也会一并解答。
先介绍本项目中使用的主要依赖。该项目使用SpringBoot 2.1版本,权限控制使用Shiro 1.4版本,WEB层为springboot-web,持久层使用Mybatis-Plus 2.3.3版本,为了便于demo快速启动,数据库使用了H2,实际使用时可以切换到MySQL。为了便于session共享,Shiro的sessionId保存于redis中,使用了开源框架shiro-redis 3.1.0版本。
项目仍然是多模块项目,其中core为核心模块,包括公用数据模型,工具类,常量,通用操作类等等。
service模块是公共服务模块,其中包含实体类,相关服务接口与实现,Shiro基础配置等。
portal是网站前台模块,作为前台的启动模块可以打war包发布,因此包含各种配置文件,以及配置类,业务上包括用户登录相关的控制器,登录页面等,在实际项目中,前台网站相关的控制器以及页面都应该在此模块中。
admin是网站后台管理模块,也可单独打包发布,包括各种配置文件。在实际项目中,各种基础数据管理的控制器以及页面应该在此模块中。
generator模块用作Mybatis-Plus的代码生成。
下面着重介绍Shiro相关配置。
使用Shiro必须自定义Realm,先上代码
package com.sst.service.common.shiro;
import com.baomidou.mybatisplus.mapper.EntityWrapper;
import com.sst.core.util.PasswordUtil;
import com.sst.service.system.entity.SysUser;
import com.sst.service.system.service.ShiroService;
import com.sst.service.system.service.SysUserService;
import org.apache.shiro.SecurityUtils;
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.session.Session;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.SimplePrincipalCollection;
import org.apache.shiro.subject.support.DefaultSubjectContext;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.Collection;
/**
* @Author: Ian
*/
public class CustomShiroRealm extends AuthorizingRealm {
@Autowired
private SysUserService sysUserService;
@Autowired
private ShiroService shiroService;
/**
* 授权
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SysUser user = (SysUser) principals.getPrimaryPrincipal();
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
info.setStringPermissions(shiroService.getUserPerms(user));
info.setRoles(shiroService.getUserRoles(user));
return info;
}
/**
* 登录认证
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authcToken) throws AuthenticationException {
UsernamePasswordToken token = (UsernamePasswordToken) authcToken;
//获取用户账号
String username = token.getUsername();
String password = String.valueOf(token.getPassword());
//查询用户信息
SysUser sysUser = sysUserService.selectOne(new EntityWrapper().eq("username", username));
//账号不存在、密码错误
if (sysUser == null || !PasswordUtil.validatePassword(password, sysUser.getPassword(), sysUser.getSalt())) {
throw new IncorrectCredentialsException("用户名或密码不正确");
}
//账号锁定
if (sysUser.getStatus() == 0) {
throw new LockedAccountException("账号已被锁定,请联系管理员");
}
//清除该用户以前登录时保存的session,强制退出
// removeOldSession(username);
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(sysUser, password, getName());
return info;
}
private void removeOldSession(String username) {
DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager) SecurityUtils.getSecurityManager();
DefaultWebSessionManager sessionManager = (DefaultWebSessionManager) securityManager.getSessionManager();
// 获取当前已登录的用户session列表
Collection sessions = sessionManager.getSessionDAO().getActiveSessions();
SysUser temp;
for (Session session : sessions) {
Object attribute = session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);
if (attribute == null) {
continue;
}
temp = (SysUser) ((SimplePrincipalCollection) attribute).getPrimaryPrincipal();
if (username.equals(temp.getUsername())) {
sessionManager.getSessionDAO().delete(session);
}
}
}
}
在realm中我们重写doGetAuthorizationInfo方法来实现对当前登录用户授权的业务逻辑,重写doGetAuthenticationInfo来实现登录认证的业务逻辑。相信使用过Shiro框架的同学都非常熟悉以上代码,值得注意的是,我们保存在数据库中的密码是加密过的密码,同时还存储了盐值来加强密码的安全性,具体的加密方式可以在源码中查看PasswordUtil工具类。如果我们想实现当一个用户登录时,将其他用相同账号登录的用户踢下线,那么可以解除removeOldSession方法的注释,在用户认证成功后删除存储在redis中的其他同账号的session数据。
自定义SessionManager
import com.sst.core.constant.CommonConstants;
import org.apache.shiro.web.servlet.ShiroHttpServletRequest;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.util.StringUtils;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.io.Serializable;
/**
* @Author: Ian
* @Date: 2019/4/10
*/
public class CustomSessionManager extends DefaultWebSessionManager {
private static final String REFERENCED_SESSION_ID_SOURCE = "Stateless request";
public CustomSessionManager() {
super();
}
@Override
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
//改变默认id获取顺序 优先获取请求路径中的sessionId
String id = WebUtils.toHttp(request).getParameter(CommonConstants.URL_TOKEN);
if (!StringUtils.isEmpty(id)) {
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, id);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
return id;
} else {
//否则按默认规则从cookie取sessionId
return super.getSessionId(request, response);
}
}
}
我们重写了获取sessionId的逻辑。Shiro默认优先从cookie中获取sessionId,而我们在此改为优先从请求参数中获取sessionId,同时自定义了参数的名称。由于cookie是无法跨域的,我们这样改动使得用户只要有sessionId即使跨域也可认证成功,也就支持了前后端分离,同时也便于第三方调用。在上一篇文章中,我们是优先从请求头中获取sessionId,此处逻辑可以按需求处理。
在某些情况下(重定向等),Shiro会给页面的url加上;JSESSIONID=xxx的后缀,这个默认行为会导致前端页面url错误,比如使用thymeleaf的@{/}获取项目根路径时。因此我们自定义了ShiroHttpServletResponse和ShiroFilterFactoryBean类来避免Shiro重写url。
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.filter.mgt.FilterChainManager;
import org.apache.shiro.web.filter.mgt.FilterChainResolver;
import org.apache.shiro.web.filter.mgt.PathMatchingFilterChainResolver;
import org.apache.shiro.web.mgt.WebSecurityManager;
import org.apache.shiro.web.servlet.AbstractShiroFilter;
import org.apache.shiro.web.servlet.ShiroHttpServletRequest;
import org.springframework.beans.factory.BeanInitializationException;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletResponse;
public class CustomShiroFilterFactoryBean extends ShiroFilterFactoryBean {
@Override
public Class getObjectType() {
return CustomSpringShiroFilter.class;
}
@Override
protected AbstractShiroFilter createInstance() throws Exception {
org.apache.shiro.mgt.SecurityManager securityManager = getSecurityManager();
if (securityManager == null) {
String msg = "SecurityManager property must be set.";
throw new BeanInitializationException(msg);
}
if (!(securityManager instanceof WebSecurityManager)) {
String msg = "The security manager does not implement the WebSecurityManager interface.";
throw new BeanInitializationException(msg);
}
FilterChainManager manager = createFilterChainManager();
PathMatchingFilterChainResolver chainResolver = new PathMatchingFilterChainResolver();
chainResolver.setFilterChainManager(manager);
return new CustomSpringShiroFilter((WebSecurityManager) securityManager, chainResolver);
}
private static final class CustomSpringShiroFilter extends AbstractShiroFilter {
protected CustomSpringShiroFilter(WebSecurityManager webSecurityManager, FilterChainResolver resolver) {
super();
if (webSecurityManager == null) {
throw new IllegalArgumentException("WebSecurityManager property cannot be null.");
}
setSecurityManager(webSecurityManager);
if (resolver != null) {
setFilterChainResolver(resolver);
}
}
@Override
protected ServletResponse wrapServletResponse(HttpServletResponse orig, ShiroHttpServletRequest request) {
return new CustomShiroHttpServletResponse(orig, getServletContext(), request);
}
}
}
import org.apache.shiro.web.servlet.ShiroHttpServletRequest;
import org.apache.shiro.web.servlet.ShiroHttpServletResponse;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletResponse;
public class CustomShiroHttpServletResponse extends ShiroHttpServletResponse {
public CustomShiroHttpServletResponse(HttpServletResponse wrapped, ServletContext context, ShiroHttpServletRequest request) {
super(wrapped, context, request);
}
/**
* Return the specified URL with the specified session identifier suitably encoded.
*
* @param url URL to be encoded with the session id
* @param sessionId Session id to be included in the encoded URL
* @return the url with the session identifer properly encoded.
*/
@Override
public String toEncoded(String url, String sessionId) {
if ((url == null) || (sessionId == null))
return (url);
String path = url;
String query = "";
String anchor = "";
int question = url.indexOf('?');
if (question >= 0) {
path = url.substring(0, question);
query = url.substring(question);
}
int pound = path.indexOf('#');
if (pound >= 0) {
anchor = path.substring(pound);
path = path.substring(0, pound);
}
StringBuilder sb = new StringBuilder(path);
//重写toEncoded方法,注释掉这几行代码就不会再生成JESSIONID了。
// if (sb.length() > 0) { // session id param can't be first.
// sb.append(";");
// sb.append(DEFAULT_SESSION_ID_PARAMETER_NAME);
// sb.append("=");
// sb.append(sessionId);
// }
sb.append(anchor);
sb.append(query);
return (sb.toString());
}
}
以上两段代码取自网络,源头不可考,但亲测有效。
我们希望在html模板上从session方便的获取用数据,因此需要将用户信息保存在session中,无论是使用用户密码登录后,还是在使用Shiro的rememberMe功能时。因此有了一下代码。
import com.sst.core.constant.CommonConstants;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.AccessControlFilter;
import org.apache.shiro.web.util.WebUtils;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpSession;
/**
* @Author: Ian
* @Date: 2019/4/9
*/
public class UserSessionFilter extends AccessControlFilter {
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
Subject subject = getSubject(request, response);
if (subject == null) {
// 没有登录
return false;
}
HttpSession session = WebUtils.toHttp(request).getSession();
Object loginUser = session.getAttribute(CommonConstants.SESSION_USER_INFO);
if (loginUser == null && subject.getPrincipal() != null) {
session.setAttribute(CommonConstants.SESSION_USER_INFO, subject.getPrincipal());
}
return true;
}
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
return true;
}
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
return true;
}
}
在创建了以上自定义类后,我们需要通过配置让Shiro使用。由于portal和admin两个系统都需要Shiro配置,为了方便复用,创建了BaseShiroConfig类,而在portal和admin各自有ShiroConfig继承于BaseShiroConfig。
import at.pollux.thymeleaf.shiro.dialect.ShiroDialect;
import com.sst.core.constant.CommonConstants;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.web.mgt.CookieRememberMeManager;
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.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* @Author: Ian
* @Date: 2019/4/8
*/
@Slf4j
public class BaseShiroConfig {
private static final String SECRET_KEY = "sst1234";
private static final String COOKIE_PATH = "/";
@Autowired
private RedisProperties redisProperties;
/**
* 自定义的Realm
*/
@Bean(name = "customShiroRealm")
public CustomShiroRealm customShiroRealm() {
CustomShiroRealm customShiroRealm = new CustomShiroRealm();
return customShiroRealm;
}
@Bean
public DefaultWebSecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(customShiroRealm());
securityManager.setRememberMeManager(rememberMeManager());
// 自定义session管理 使用redis
securityManager.setSessionManager(sessionManager());
// 自定义缓存实现 使用redis
securityManager.setCacheManager(redisCacheManager());
return securityManager;
}
@Bean(name = "shiroDialect")
public ShiroDialect shiroDialect() {
return new ShiroDialect();
}
@Bean
public RedisManager redisManager() {
RedisManager redisManager = new RedisManager();
redisManager.setHost(redisProperties.getHost());
// redisManager.setPassword(redisProperties.getPassword());
redisManager.setDatabase(redisProperties.getDatabase());
redisManager.setPort(redisProperties.getPort());
return redisManager;
}
@Bean
public RedisCacheManager redisCacheManager() {
RedisCacheManager redisCacheManager = new RedisCacheManager();
redisCacheManager.setRedisManager(redisManager());
return redisCacheManager;
}
@Bean
public RedisSessionDAO redisSessionDAO() {
RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
redisSessionDAO.setRedisManager(redisManager());
return redisSessionDAO;
}
@Bean
public DefaultWebSessionManager sessionManager() {
CustomSessionManager sessionManager = new CustomSessionManager();
sessionManager.setSessionDAO(redisSessionDAO());
sessionManager.setSessionIdCookieEnabled(true);
sessionManager.setSessionIdCookie(simpleCookie());
return sessionManager;
}
/**
* 保存sessionId的cookie
*
* @return
*/
@Bean
public SimpleCookie simpleCookie() {
SimpleCookie simpleCookie = new SimpleCookie();
simpleCookie.setName(CommonConstants.SHIRO_COOKIE);
simpleCookie.setPath(COOKIE_PATH);
return simpleCookie;
}
/**
* 记住我的cookie对象;
*
* @return
*/
@Bean
public SimpleCookie rememberMeCookie() {
SimpleCookie simpleCookie = new SimpleCookie();
simpleCookie.setName(CommonConstants.SHIRO_REMEMBER_ME);
//
simpleCookie.setMaxAge(259200);
simpleCookie.setPath(COOKIE_PATH);
return simpleCookie;
}
/**
* cookie管理对象;
* rememberMeManager()方法是生成rememberMe管理器,而且要将这个rememberMe管理器设置到securityManager中
*
* @return
*/
@Bean
public CookieRememberMeManager rememberMeManager() {
CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
cookieRememberMeManager.setCookie(rememberMeCookie());
byte[] bytesOfMessage = null;
MessageDigest md = null;
try {
bytesOfMessage = SECRET_KEY.getBytes("UTF-8");
md = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException | UnsupportedEncodingException e) {
log.error(e.getMessage(), e);
}
byte[] b = md.digest(bytesOfMessage);
//rememberMe cookie加密的密钥 建议每个项目都不一样 默认AES算法 密钥长度(128 256 512 位)
cookieRememberMeManager.setCipherKey(b);
return cookieRememberMeManager;
}
/**
* 开启shiro注解
*
* @return
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor() {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor
= new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager());
return authorizationAttributeSourceAdvisor;
}
}
其中RedisSessionDao引自开源框架shiro-redis,其管理了sessionId在redis中的增删改查,sessionId在redis中默认过期时间为1800秒,每当登录用户访问系统时,过期时间会刷新,也就是说只有当用户在1800秒内没有任何访问时,session才会过期。
上面代码中simpleCookie配置了Shiro在cookie中存储sessionId使用的key,过期时间,以及PATH路径,由于我们session过期依赖于redis,因此cookie设定为永久有效,在重新登录后cookie会被新的值覆盖。为了使得同域名下的系统登录状态互通,我们给PATH配置为“/”即根路径,使得在portal登录后,进入admin系统无需再次登录。
再来看看portal模块下面的ShiroConfig类。
import com.sst.service.common.shiro.BaseShiroConfig;
import com.sst.service.common.shiro.CustomShiroFilterFactoryBean;
import com.sst.service.common.shiro.UserSessionFilter;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* @Author: Ian
* @Date: 2019/4/8
*/
@Configuration
public class ShiroConfig extends BaseShiroConfig {
@Bean(name = "shiroFilter")
public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new CustomShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
Map filters = new HashMap<>();
filters.put("userSession", new UserSessionFilter());
shiroFilterFactoryBean.setFilters(filters);
Map filterMap = new LinkedHashMap<>();
filterMap.put("/webjars/**", "anon");
filterMap.put("/login", "anon");
filterMap.put("/login.htm", "anon,userSession");
filterMap.put("/**/*.css", "anon");
filterMap.put("/**/*.js", "anon");
filterMap.put("/**/*.html", "anon");
filterMap.put("/img/**", "anon");
filterMap.put("/fonts/**", "anon");
filterMap.put("/**/favicon.ico", "anon");
filterMap.put("/captcha.jpg", "anon");
filterMap.put("/", "user,userSession");
filterMap.put("/sysWebsiteInfo/**", "anon");
//只有登录才能访问用authc,登录和记住我都能访问用user
filterMap.put("/**", "user,userSession");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterMap);
shiroFilterFactoryBean.setLoginUrl("/login.htm");
return shiroFilterFactoryBean;
}
}
shiroFilter这个Bean中我们使用了自定义的ShiroFilterFactoryBean,并在过滤器链中加入了自定义的UserSessionFilter过滤器。在需要从session取用户信息的路由中进行了配置。需要注意的是只有使用用户名密码登录才能访问的路径用authc,而登录和rememberMe都能访问用user。由于前后端不分离,登录页直接写上页面路由,鉴权未通过的请求将会直接重定向到登录页。
控制层的用户登录和登出接口代码如下。
/**
* 用户登录
*
* @param sysUser
* @return
*/
@RequestMapping(value = "/login", method = RequestMethod.POST,
consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8")
@ResponseBody
public ResponseDomain doLogin(@RequestBody SysUser sysUser) {
Subject subject = SecurityUtils.getSubject();
String username = sysUser.getUsername();
UsernamePasswordToken token = new UsernamePasswordToken(username, sysUser.getPassword(), sysUser.isRememberMe());
try {
subject.login(token);
} catch (AuthenticationException e) {
log.error(e.getMessage(), e);
token.clear();
return ResponseDomain.getFailedResponse().setResultDesc(e.getMessage());
}
return ResponseDomain.getSuccessResponse().setResultDesc("登录成功");
}
/**
* 退出登录
*
* @return
*/
@RequestMapping(value = "/logout", method = RequestMethod.POST)
@ResponseBody
public ResponseDomain logout() {
SecurityUtils.getSubject().logout();
return ResponseDomain.getSuccessResponse();
}
Shiro相关的核心代码已经全部贴出。
下面测试下我们的需求是否都已实现。
由于springboot-devtool会导致Bean转换失败的BUG(参考https://www.cnblogs.com/wobuchifanqie/p/9908243.html),因此调试时使用tomcat发布。
由于使用内存数据库,启动redis后项目就可以运行了。
访问portal模块登录页,地址http://localhost:9001/portal/login.htm。
点击登录后。
可以看到,“管理员”这个名称已经顺利从session中获取。
点击后台管理按钮,跳转到后台首页http://localhost:9002/admin/index.htm。
这说明登录状态以及session都已共享成功。
《在前后端分离的SpringBoot项目中集成Shiro权限框架》这篇博客发出后,很多同学产生了疑问并在文章后留言,作者由于时间有限并没有全部回复,在次做一个总结。
1.前后端分离跨域怎么解决?
后台需要支持跨域访问,最简单的解决方法就是加一个CORS过滤器,具体代码可以自行搜索,也可以直接引用com.thetransactioncompany的cors-filter,然后进行相应配置,这个过滤器一定要在Shiro的过滤器之前执行。
2.如何管理session过期?
上面文中已经有具体说明,简单来说就是让redis管理,redis中存储的session过期了,该用户的session就过期了。
3.如何保证安全性,token被盗用怎么办?
可能最安全的方法就是不要让token被截获,那么就得使用https协议访问后台。如果没有那么高的安全性需求,可以在前端对sessionId(token)进行加密保存和传递,加密可以动态加密,就是密钥从后台请求获取,密钥定时刷新。
4.为什么不用JWT?
JWT作为token由于有签名校验机制,安全性较高,但复杂度也随之升高,要更安全还是更快捷,还要根据项目需求来定。
https://github.com/rewindian/springboot2-shiro-thymeleaf