前后端分离项目springBoot +shiro权限控制+redis
本次使用shiro主要实现以下几个功能
1.用户没有登录无法访问任何为权限控制的接口
2.用户登录后只能访问该用户所拥有的权限,实现了对后端接口颗粒级的权限控制
3.两个用户登录同一个账号时,后登录的用户会将先登录的用户挤掉
一.数据库设计
用户表 user 角色表role 用户角色中间表user_role
资源权限表resource 角色关联权限表role_resource
用户表,角色表,资源权限表都是多对多的关系,在中间表中均通过id关联
资源权限表中url字段配置对应的接口路径,用户拥有的角色有对应的资源路径便可以访问对应的接口权限.
二.shiro核心
1.Shiro四大核心功能:
**Authentication:**身份认证/登录,验证用户是不是拥有相应的身份;
**Authorization:**授权,即权限验证,验证某个已认证的用户是否拥有某个权限;即判断用户是否能做情, 常见的如:验证某个用户是否拥有某个角色。或者细粒度的验证某个用户对某个资源是否具有某个权限;
**Cryptography:**加密,保护数据的安全性,如密码加密存储到数据库,而不是明文存储;
**Session Manager:**会话管理,即用户登录后就是一次会话,在没有退出之前,它的所有信息都在会话中; 会话可以是普通JavaSE环境的,也可以是如Web环境的;
2.Shiro三个核心组件
**Subject:**主体,代表了当前“用户”,这个用户不一定是一个具体的人,与当前应用交互的任何东西都是Subject,如网络爬虫,机器人等;即一个抽象概念;所有Subject都绑定到SecurityManager,与Subject的所有交互都会委托给SecurityManager;可以把Subject认为是一个门面;SecurityManager才是实际的执行者;
**SecurityManager:**安全管理器;即所有与安全有关的操作都会与SecurityManager交互;且它管理着所有Subject;可以看出它是Shiro的核心,它负责与后边介绍的其他组件进行交互,如果学习过SpringMVC,你可以把它看成DispatcherServlet前端控制器;
**Realm:**域,Shiro从从Realm获取安全数据(如用户、角色、权限),就是说SecurityManager要验证用户身份,那么它需要从Realm获取相应的用户进行比较以确定用户身份是否合法;也需要从Realm得到用户相应的角色/权限进行验证用户是否能进行操作;可以把Realm看成DataSource,即安全数据源。
三.springboot整合使用shiro
首先要写一个shiro的配置类,初始化shiro中的bean
shiro的核心配置类:
/**
* Shiro 配置类
* shiro的核心配置类 shiro的所有初始化bean都在这个类中操作
*/
@Configuration
@SuppressWarnings("ALL")
public class ShiroConfig {
@Autowired(required = false)
private IResourcesService resourcesService;
/**
* 配置文件
*/
@Autowired
private ShiroProperties shiroProperties;
@Autowired
private RedisTemplate redisTemplate;
/**
* shiro的拦截器
*
* @param securityManager
* @return
**/
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
// 设置 securityManager
shiroFilterFactoryBean.setSecurityManager(securityManager);
// 登录的 url
shiroFilterFactoryBean.setLoginUrl(shiroProperties.getLoginUrl());
// 登录成功后跳转的 url
shiroFilterFactoryBean.setSuccessUrl(shiroProperties.getSuccessUrl());
// 未授权 url,如果配置了,则跳转该页面,没有配置则向前端响应401状态码,前后端分离则不配置
// shiroFilterFactoryBean.setUnauthorizedUrl(shiroProperties.getUnauthorizedUrl());
//自定义拦截器
Map<String, Filter> filtersMap = new LinkedHashMap<String, Filter>();
//限制同一帐号同时在线的个数。
filtersMap.put("authc", new MyAuthenticationFilter());
filtersMap.put("kickout", kickoutSessionControlFilter());
shiroFilterFactoryBean.setFilters(filtersMap);
// 这里配置授权链,跟mvc的xml配置一样
LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
// 设置免认证 url
String[] anonUrls = StringUtils.split(shiroProperties.getAnonUrl(), ',');
for (String url : anonUrls) {
filterChainDefinitionMap.put(url, "anon");
}
// 配置退出过滤器
// filterChainDefinitionMap.put(shiroProperties.getLogoutUrl(), "logout");
// 除上以外所有 url都必须认证通过才可以访问,未通过认证自动访问 LoginUrl
List<Resources> resourcesList = resourcesService.queryAll();
for (Resources resources : resourcesList) {
String url = resources.getUrl();
if (StringUtil.isNotEmpty(url)) {
String[] splitUrls = url.split(",");
for (String splitUrl : splitUrls) {
String permission = "perms[" + splitUrl + "]";
filterChainDefinitionMap.put(splitUrl, "kickout,authc," + permission);
}
}
}
filterChainDefinitionMap.put("/**", "kickout,authc");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
/**
* 配置各种manager,跟xml的配置很像,但是,这里有一个细节,就是各个set的次序不能乱
*
* @param realm
* @return
* @author
**/
@Bean
@DependsOn({"myShiroRealm"})
public SecurityManager securityManager(@Qualifier("sessionManager") DefaultWebSessionManager sessionManager) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 配置 缓存管理类 cacheManager,这个cacheManager必须要在前面执行,因为setRealm 和 setSessionManage都有方法初始化了cachemanager,看下源码就知道了
securityManager.setCacheManager(cacheManager());
// 配置 SecurityManager,并注入 shiroRealm 这个跟springmvc集成很像,不多说了
securityManager.setRealm(myShiroRealm());
// 配置 sessionManager
securityManager.setSessionManager(sessionManager);
return securityManager;
}
/**
* 生成一个ShiroRedisCacheManager 这没啥好说的
*
* @param template
* @return
* @author
**/
private ShiroRedisCacheManager cacheManager() {
return new ShiroRedisCacheManager(redisTemplate);
}
/**
* 这是我自己的realm 我自定义了一个密码解析器,这个比较简单,稍微跟一下源码就知道这玩意
*
* @param matcher
* @param userService
* @return
*/
@Bean("myShiroRealm")
@DependsOn({"hashedCredentialsMatcher"})
public MyShiroRealm myShiroRealm() {
MyShiroRealm myShiroRealm = new MyShiroRealm();
myShiroRealm.setCredentialsMatcher(hashedCredentialsMatcher());
return myShiroRealm;
}
/**
* 密码解析器 有好几种,这里是MD5 2次加密
*
* @return
*/
@Bean("hashedCredentialsMatcher")
public HashedCredentialsMatcher hashedCredentialsMatcher() {
HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
hashedCredentialsMatcher.setHashAlgorithmName("md5");//散列算法:这里使用MD5算法;
hashedCredentialsMatcher.setHashIterations(2);//散列的次数,比如散列两次,相当于 md5(md5(""));
return hashedCredentialsMatcher;
}
/**
* session 管理对象
*
* @return DefaultWebSessionManager
*/
@Bean("sessionManager")
public DefaultWebSessionManager sessionManager() {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
// 设置session超时时间,单位为毫秒
sessionManager.setGlobalSessionTimeout(shiroProperties.getSessionTimeout());
sessionManager.setSessionIdCookie(new SimpleCookie(shiroProperties.getSessionIdName()));
// 网上各种说要自定义sessionDAO 其实完全不必要,shiro自己就自定义了一个,可以直接使用,还有其他的DAO,自行查看源码即可
// sessionManager.setSessionDAO(new EnterpriseCacheSessionDAO());
sessionManager.setSessionDAO(new MemorySessionDAO());
return sessionManager;
}
/**
* 限制同一账号登录同时登录人数控制
*
* @return
*/
public KickoutSessionControlFilter kickoutSessionControlFilter() {
KickoutSessionControlFilter kickoutSessionControlFilter = new KickoutSessionControlFilter();
//使用cacheManager获取相应的cache来缓存用户登录的会话;用于保存用户—会话之间的关系的;
//这里我们还是用之前shiro使用的redisManager()实现的cacheManager()缓存管理
//也可以重新另写一个,重新配置缓存时间之类的自定义缓存属性
kickoutSessionControlFilter.setCacheManager(cacheManager());
//用于根据会话ID,获取会话进行踢出操作的;
kickoutSessionControlFilter.setSessionManager(sessionManager());
//是否踢出后来登录的,默认是false;即后者登录的用户踢出前者登录的用户;踢出顺序。
kickoutSessionControlFilter.setKickoutAfter(false);
//同一个用户最大的会话数,默认1;比如2的意思是同一个用户允许最多同时两个人登录;
kickoutSessionControlFilter.setMaxSession(1);
//被踢出后重定向到的地址;
kickoutSessionControlFilter.setKickoutUrl("/kickout");
return kickoutSessionControlFilter;
}
}
配置完后,要使用shiro的权限认证,需要自己写一个Realm继承AuthorizingRealm,实现shiro的省份认证,以及授权。如下:
public class MyShiroRealm extends AuthorizingRealm {
@Autowired
private IUserService userService;
@Autowired
private IUserRoleOrganizationService userRoleOrganizationService;
@Resource
private IResourcesService resourcesService;
//授权
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
Integer roleId = (Integer) SecurityUtils.getSubject().getPrincipal(); List<Resources> resourcesList = resourcesService.loadRoleResources(roleId); // 权限信息对象info,用来存放查出的用户的所有的角色(role)及权限(permission);
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
for (Resources resources : resourcesList) { info.addStringPermission(resources.getUrl());
}
return info;
}
//认证
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
//获取用户的输入的账号.
String username = (String) token.getPrincipal();
if (username == null) {
return null;
}
User user = userService.selectByUsername(username);
if (user == null) {
throw new UnknownAccountException();
}
if (DatabaseConstant.DATA_UNAVAILABLE == user.getStatus()) {
// 帐号锁定
throw new LockedAccountException();
}
// 查询当前用户的所有角色列表
List<UserRoleOrganization> userRoleOrganizationList = userRoleOrganizationService.selectUserRoleOrganizationList(user.getId()); if (userRoleOrganizationList == null || userRoleOrganizationList.size() == 0) { throw new DisabledAccountException();
}
UserRoleOrganization userRoleOrganization = userRoleOrganizationList.get(0); SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
// 此处我存储的是角色id,将每个角色对应的权限存起来,此处存的是什么,通过 subject.getPrincipal() 获取的就是什么
userRoleOrganization.getRoleId(), //用户
user.getPassword(), //密码
ByteSource.Util.bytes(username),
getName() //realm name
);
return authenticationInfo;
}
}
如上,有了shiro的核心配置,以及重写的Realm后,便能够使用shiro的权限控制了。但是在前后端分离的项目中,我们一般都需要自己控制登录和登出的逻辑。这时候就要用到shiro中的subject.login(token)和subject.logout()方法了,这样便可以在登录登出中写自己需要的逻辑控制了。
@PostMapping("/login")
public Result login(HttpServletRequest request, HttpServletResponse response, @RequestBody User user) {
if (StringUtils.isEmpty(user.getUsername()) || StringUtils.isEmpty(user.getPassword())) {
logger.info("登录失败,用户名或密码为空!");
return ResultUtil.failed(ErrorCode.THE_USERNAME_OR_PASSWORD_IS_NULL);
}
Subject subject = SecurityUtils.getSubject();
UsernamePasswordToken token = new UsernamePasswordToken(user.getUsername(), user.getPassword());
try {
subject.login(token);
logger.info("登录成功!");
Session session = SecurityUtils.getSubject().getSession();
session.setAttribute("kickout", false);
User cacheUser = userService.selectByUsername(user.getUsername());
cacheUser.setPassword("");
List<UserRoleOrganization> userRoleOrganizationList = userRoleOrganizationService.selectUserRoleOrganizationList(cacheUser.getId());
session.setAttribute("user", cacheUser);
session.setAttribute("userRoleOrganization", userRoleOrganizationList.get(0));
OperationLogUtil.addLog(OperationType.LOGIN, "login", "success");
return ResultUtil.success();
} catch (LockedAccountException e) {
token.clear();
//用户已经被锁定(被逻辑删除的用户)
logger.info("该账户已经被锁定");
return ResultUtil.failed(ErrorCode.THE_USER_IS_LOCKED);
} catch (DisabledAccountException e) {
token.clear();
//用户已经被锁定(被逻辑删除的用户)
logger.info("该账户还未被激活");
return ResultUtil.failed(ErrorCode.THE_USER_IS_NOT_ACTIVE);
} catch (AuthenticationException e) {
token.clear();
//用户名或密码不正确
logger.info("用户名或密码不正确");
return ResultUtil.failed(ErrorCode.THE_USER_INVALID);
}
}
@GetMapping("/logout")
public Result logout() {
Subject subject = SecurityUtils.getSubject();
User user = (User) subject.getSession().getAttribute("user");
UserRoleOrganization userRoleOrganization = (UserRoleOrganization) subject.getSession().getAttribute("userRoleOrganization");
Integer orgId;
if (userRoleOrganization == null) {
orgId = null;
} else {
orgId = userRoleOrganization.getOrganizationId();
}
subject.logout();
if (user != null) {
// 如果当前用户处于登录状态
logger.info("登出成功");
OperationLogUtil.logoutLog(OperationType.LOGOUT, "logout", "success", user, orgId);
}
return ResultUtil.success();
}
四.使用redis缓存权限信息
首先需要自己写一个shiro的缓存管理器继承自AbstractCacheManager,使用redis来缓存其中的权限信息。
public class ShiroRedisCacheManager extends AbstractCacheManager {
private RedisTemplate<byte[], byte[]> redisTemplate;
public ShiroRedisCacheManager(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
//为了个性化配置redis存储时的key,我们选择了加前缀的方式,所以写了一个带名字及redis操作的构造函数的Cache类
@Override
protected Cache createCache(String name) throws CacheException {
// 为了使使用cache缓存数据的前缀相同,直接没有传入name,在ShiroRedisCache中将缓存前缀写死
return new ShiroRedisCache(redisTemplate);
}
}
自己要实现shiroRediscache中操作redis的逻辑,具体如下:
public class ShiroRedisCache<K, V> implements Cache<K, V> {
private static Logger logger = LoggerFactory.getLogger(ShiroRedisCache.class);
private RedisTemplate redisTemplate;
private String prefix = "shiro_redis_cache:";
public String getPrefix() {
return prefix;
}
public void setPrefix(String prefix) {
this.prefix = prefix;
}
public ShiroRedisCache(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public ShiroRedisCache(RedisTemplate redisTemplate, String prefix) {
this(redisTemplate);
this.prefix = prefix;
}
@Override
public V get(K k) throws CacheException {
if (k == null) {
return null;
}
String jsonKey = getJsonString(k);
return (V) redisTemplate.opsForValue().get(jsonKey);
}
@Override
public V put(K k, V v) throws CacheException {
if (k == null || v == null) {
return null;
}
String jsonKey = getJsonString(k);
redisTemplate.opsForValue().set(jsonKey, v);
logger.info("put key : " + jsonKey);
return v;
}
@Override
public V remove(K k) throws CacheException {
if (k == null) {
return null;
}
String jsonKey = getJsonString(k);
V v = (V) redisTemplate.opsForValue().get(jsonKey);
logger.info("delete key : " + jsonKey);
redisTemplate.delete(jsonKey);
return v;
}
@Override
public void clear() throws CacheException {
redisTemplate.getConnectionFactory().getConnection().flushDb();
}
@Override
public int size() {
return redisTemplate.getConnectionFactory().getConnection().dbSize().intValue();
}
@Override
public Set<K> keys() {
// byte[] bytes = (getPrefix() + "*").getBytes();
String jsonString = this.prefix + "*";
Set<String> keys = redisTemplate.keys(jsonString);
Set<K> sets = new HashSet<>();
for (String key : keys) {
sets.add((K) key);
}
return sets;
}
@Override
public Collection<V> values() {
Set<K> keys = keys();
List<V> values = new ArrayList<>(keys.size());
for (K k : keys) {
values.add(get(k));
}
return values;
}
private String getJsonString(Object key) {
String s = JSON.toJSONString(key);
//这里纯粹是为了方便用可视化工具管理redis中的缓存数据
s = s.replace(":", "|");
return prefix + s;
}
如此便整合使用redis中缓存的shiro重点数据。
**RememberMe功能:**本代码并没有做shiro中RememberMe的功能,原因是代码中需要做同一账号只能单用户登录的功能(即同一账号同一时间只能允许一个用户登录),而这两个功能之间有冲突。在下一篇博客实现同一账号只能单用户登录的功能时会详细的解释。有兴趣的可以看下一篇博客。
小结:
**1.**shiro有自己的一套登录逻辑,如果访问登录接口时,前端传过来的是表单形式的username和password, shiro可以自己去去生成token进行登录校验,最终走的逻辑还是MyShiroRealm类中doGetAuthenticationInfo 方法中的代码,也能实现登录功能,但是当登录出差时就无法返回特定的错误信息给前端,所以一般都是自己实现登录逻辑,因此在前后端分离中无需配置shiro中的loginUrl,只需将后端的登录方法对应的url放开(即配置为anno)
**2.**由于是前后端分离,所以在用户没有认证或者没有对应的资源权限时,不应该直接跳转页面,而应该返回json信息给前端。
未认证时:shiro中只直接跳转到登录页面的(在shiro的认证过滤器FormAuthenticationFilter中的 onAccessDenied方法中可以看到),因此需要重写此过滤器,避免直接跳转页面
未授权时:shiro会在PermissionsAuthorizationFilter的isAccessAllowed中进行权限校验,如果没有权 限,会返回false,再执行AuthorizationFilter的onAccessDenied中的逻辑,onAccessDenied方法中会先校验用户是否登录,没有登录则会跳转到登录页面,登录了则会判断开发者是否配置了unauthorizedUrl,如果配置了,则跳转该页面,没有配置则向前端响应401状态码,因此为了避免直接跳转页面,不应配置unauthorizedUrl,这样shiro便可以直接响应401给前端(这样就不需要再次重写过滤器)
(一般情况用户没有登录是不会有被权限控制的接口访问的,不会跳转到shiro配置的登录页面,而当用户登录后如果长时间不操作,session会失效,这时可能会出现没有登录信息,访问到被权限控制的接口,直接跳转页面,所以需要被权限控制的接口,也需要配置authc,即认证过滤器)