最近在整理之前做过的项目时,对于后台管理项目用户权限这一块一直没有很详细的去总结过,用户权限管理一直是后台管理项目的核心,这里讲解的shiro,做了前后端分离处理。
项目环境
springboot 2.1.7
durid 1.1.10
mysql 5.7
shiro 1.4.0
shiro-redis:3.1.0
一 shiro介绍
1 基础介绍
Apache Shiro是Java的一个安全框架。目前,使用Apache Shiro的人越来越多,因为它相当简单,对比Spring Security,可能没有Spring
Security做的功能强大,但是在实际工作时可能并不需要那么复杂的东西,所以使用小而简单的Shiro就足够了。对于它俩到底哪个好,这个不必纠结,能更简单的解决项目问题就好了。
2 基本功能点
Authentication:身份认证/登录,验证用户是不是拥有相应的身份;
Authorization:授权,即权限验证,验证某个已认证的用户是否拥有某个权限;即判断用户是否能做事情,常见的如:验证某个用户是否拥有某个角色。或者细粒度的验证某个用户对某个资源是否具有某个权限;
Session Manager:会话管理,即用户登录后就是一次会话,在没有退出之前,它的所有信息都在会话中;会话可以是普通JavaSE环境的,也可以是如Web环境的;
Cryptography:加密,保护数据的安全性,如密码加密存储到数据库,而不是明文存储;
Web Support:Web支持,可以非常容易的集成到Web环境;
Caching:缓存,比如用户登录后,其用户信息、拥有的角色/权限不必每次去查,这样可以提高效率;
Concurrency:shiro支持多线程应用的并发验证,即如在一个线程中开启另一个线程,能把权限自动传播过去;
Testing:提供测试支持;
Run As:允许一个用户假装为另一个用户(如果他们允许)的身份进行访问;
Remember Me:记住我,这个是非常常见的功能,即一次登录后,下次再来的话不用登录了。
3 基本流程图
Subject:主体,代表了当前“用户”,这个用户不一定是一个具体的人,与当前应用交互的任何东西都是Subject,如网络爬虫,机器人等;即一个抽象概念;所有Subject都绑定到SecurityManager,与Subject的所有交互都会委托给SecurityManager;可以把Subject认为是一个门面;SecurityManager才是实际的执行者;
SecurityManager:安全管理器;即所有与安全有关的操作都会与SecurityManager交互;且它管理着所有Subject;可以看出它是Shiro的核心,它负责与后边介绍的其他组件进行交互,如果学习过SpringMVC,你可以把它看成DispatcherServlet前端控制器;
Realm:域,Shiro从从Realm获取安全数据(如用户、角色、权限),就是说SecurityManager要验证用户身份,那么它需要从Realm获取相应的用户进行比较以确定用户身份是否合法;也需要从Realm得到用户相应的角色/权限进行验证用户是否能进行操作;可以把Realm看成DataSource,即安全数据源。
流程如下:
步骤一:Shiro把用户的数据封装成标识token,token一般封装着用户名,密码等信息。
步骤二:使用Subject门面获取到封装着用户的数据的标识token
步骤三:Subject把标识token交给SecurityManager,在SecurityManager安全中心中,SecurityManager把标识token委托给认证器Authenticator进行身份验证。认证器的作用一般是用来指定如何验证,它规定本次认证用到哪些Realm。
步骤四:认证器Authenticator将传入的标识token,与数据源Realm对比,验证token是否合法。
二 常用的权限管理表关系
5张表,也就是现在流行的权限设计模型RBAC
分别是:用户表 ,角色表,菜单(权限)表 , 用户和角色关联表,角色和菜单关联表
建表语句在项目中
三 案例介绍
1 案例地址
2 搭建案例
pom
<!-- shiro核心包 -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>${shiro-spring.version}</version>
</dependency>
<!--大神的开源插件-->
<dependency>
<groupId>org.crazycake</groupId>
<artifactId>shiro-redis</artifactId>
<version>${shiro-redis.version}</version>
</dependency>
ShiroConfig
package com.qiuwei.shiro.config;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.session.mgt.eis.JavaUuidSessionIdGenerator;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.servlet.SimpleCookie;
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.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* @Author: qiuwei@19pay.com.cn
* @Version 1.0.0
*/
@Configuration
@Slf4j
@Data
@ConfigurationProperties(
prefix = "spring.redis"
)
public class ShiroConfig {
private String host = "localhost";
private int port = 6379;
private Duration timeout;
/**
* Filter工厂,设置对应的过滤条件和跳转条件
*
* @return ShiroFilterFactoryBean
*/
@Bean
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
// 过滤器链定义映射
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
/*
* anon:所有url都都可以匿名访问,authc:所有url都必须认证通过才可以访问;
* 过滤链定义,从上向下顺序执行,authc 应放在 anon 下面
* */
filterChainDefinitionMap.put("/login", "anon");
// 配置不会被拦截的链接 顺序判断,如果前端模板采用了thymeleaf,这里不能直接使用 ("/static/**", "anon")来配置匿名访问,必须配置到每个静态目录
// filterChainDefinitionMap.put("/css/**", "anon");
// filterChainDefinitionMap.put("/fonts/**", "anon");
// filterChainDefinitionMap.put("/img/**", "anon");
// filterChainDefinitionMap.put("/js/**", "anon");
// filterChainDefinitionMap.put("/html/**", "anon");
// 所有url都必须认证通过才可以访问
filterChainDefinitionMap.put("/**", "authc");
// 配置退出 过滤器,其中的具体的退出代码Shiro已经替我们实现了, 位置放在 anon、authc下面
filterChainDefinitionMap.put("/logout", "logout");
// 如果不设置默认会自动寻找Web工程根目录下的"/login.jsp"页面
// 配器shirot认登录累面地址,前后端分离中登录累面跳转应由前端路由控制,后台仅返回json数据, 对应LoginController中unauth请求
shiroFilterFactoryBean.setLoginUrl("/un_auth");
// 登录成功后要跳转的链接, 此项目是前后端分离,故此行注释掉,登录成功之后返回用户基本信息及token给前端
// shiroFilterFactoryBean.setSuccessUrl("/index");
// 未授权界面, 对应LoginController中 unauthorized 请求
shiroFilterFactoryBean.setUnauthorizedUrl("/unauthorized");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
/**
* RedisSessionDAO shiro sessionDao层的实现 通过redis, 使用的是shiro-redis开源插件
*
* @return RedisSessionDAO
*/
@Bean
public RedisSessionDAO redisSessionDAO() {
RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
redisSessionDAO.setRedisManager(redisManager());
redisSessionDAO.setSessionIdGenerator(sessionIdGenerator());
redisSessionDAO.setExpire(1800);
return redisSessionDAO;
}
/**
* Session ID 生成器
*
* @return JavaUuidSessionIdGenerator
*/
@Bean
public JavaUuidSessionIdGenerator sessionIdGenerator() {
return new JavaUuidSessionIdGenerator();
}
/**
* 自定义sessionManager
*
* @return SessionManager
*/
@Bean
public SessionManager sessionManager() {
MySessionManager mySessionManager = new MySessionManager();
mySessionManager.setSessionDAO(redisSessionDAO());
return mySessionManager;
}
/**
* 配置shiro redisManager, 使用的是shiro-redis开源插件
*
* @return RedisManager
*/
private RedisManager redisManager() {
RedisManager redisManager = new RedisManager();
redisManager.setHost(host);
redisManager.setPort(port);
redisManager.setTimeout((int) timeout.toMillis());
return redisManager;
}
/**
* cacheManager 缓存 redis实现, 使用的是shiro-redis开源插件
*
* @return RedisCacheManager
*/
@Bean
public RedisCacheManager cacheManager() {
RedisCacheManager redisCacheManager = new RedisCacheManager();
redisCacheManager.setRedisManager(redisManager());
// 必须要设置主键名称,shiro-redis 插件用过这个缓存用户信息
redisCacheManager.setPrincipalIdFieldName("userId");
return redisCacheManager;
}
/**
* 权限管理,配置主要是Realm的管理认证
*
* @return SecurityManager
*/
@Bean
public SecurityManager securityManager(ShiroRealm shiroRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(shiroRealm);
// 自定义session管理 使用redis
securityManager.setSessionManager(sessionManager());
// 自定义缓存实现 使用redis
securityManager.setCacheManager(cacheManager());
return securityManager;
}
/*
* 开启Shiro的注解(如@RequiresRoles,@RequiresPermissions),需借助SpringAOP扫描使用Shiro注解的类,并在必要时进行安全逻辑验证
* 配置以下两个bean(DefaultAdvisorAutoProxyCreator(可选)和AuthorizationAttributeSourceAdvisor)即可实现此功能
*/
@Bean
public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
advisorAutoProxyCreator.setProxyTargetClass(true);
return advisorAutoProxyCreator;
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
@Bean
public SimpleCookie cookie() {
// cookie的name,对应的默认是 JSESSIONID
SimpleCookie cookie = new SimpleCookie("SHARE_JSESSIONID");
cookie.setHttpOnly(true);
// path为 / 用于多个系统共享 JSESSIONID
cookie.setPath("/");
return cookie;
}
/* 此项目使用 shiro 场景为前后端分离项目,这里先注释掉,统一异常处理已在 GlobalExceptionHand.java 中实现 */
}
ShiroRealm
package com.qiuwei.shiro.config;
import com.qiuwei.shiro.entity.Menu;
import com.qiuwei.shiro.entity.Role;
import com.qiuwei.shiro.entity.User;
import com.qiuwei.shiro.mapper.MenuMapper;
import com.qiuwei.shiro.mapper.RoleMapper;
import com.qiuwei.shiro.mapper.UserMapper;
import com.qiuwei.shiro.util.ShiroUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.authc.credential.CredentialsMatcher;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.util.ByteSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Objects;
/**
* @Author: qiuwei@19pay.com.cn
* @Version 1.0.0
*/
@Slf4j
@Component
public class ShiroRealm extends AuthorizingRealm {
private UserMapper userMapper;
private RoleMapper roleMapper;
private MenuMapper menuMapper;
@Autowired
@SuppressWarnings("all")
public ShiroRealm(UserMapper userMapper, RoleMapper roleMapper, MenuMapper menuMapper) {
this.userMapper = userMapper;
this.roleMapper = roleMapper;
this.menuMapper = menuMapper;
}
/**
* 授权
*
* @param principalCollection
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
log.info("开始执行授权操作.......");
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
/**
* 查询用户角色
* 如果身份认证的时候没有传入User对象,这里只能取到userName
* 也就是SimpleAuthenticationInfo构造的时候第一个参数传递需要User对象
*/
User user = (User) principalCollection.getPrimaryPrincipal();
if (user == null) {
log.error("用户不存在");
throw new UnknownAccountException("用户不存在");
}
//TODO 是否为超级管理员 是 全部菜单权限
/**
* 查询用户角色
*/
List<Role> roles = this.roleMapper.listRoleByUserId(user.getUserId());
if(CollectionUtils.isNotEmpty(roles)){
for (Role role : roles) {
authorizationInfo.addRole(role.getRoleName());
// 根据角色查询权限
List<Menu> menus = this.menuMapper.listMenuByRoleId(role.getRoleId());
for (Menu m : menus) {
authorizationInfo.addStringPermission(m.getPerms());
}
}
}
return authorizationInfo;
}
/**
* 认证
*
* @param authenticationToken
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
log.info("开始进行身份认证......");
//获取用户的输入的账号.
String username = (String) authenticationToken.getPrincipal();
//通过username从数据库中查找 User对象.
//实际项目中,这里可以根据实际情况做缓存,如果不做,Shiro自己也是有时间间隔机制,2分钟内不会重复执行该方法
User user = userMapper.findByUsername(username);
if (Objects.isNull(user)) {
return null;
}
return new SimpleAuthenticationInfo(
// 这里传入的是user对象,比对的是用户名,直接传入用户名也没错,但是在授权部分就需要自己重新从数据库里取权限
user,
// 密码
user.getPassword(),
// salt = username + salt
ByteSource.Util.bytes(user.getSalt()),
// realm name
getName()
);
}
/**
* 将自己的验证方式加入容器
*
* 凭证匹配器(由于我们的密码校验交给Shiro的SimpleAuthenticationInfo进行处理了)
*
* @param credentialsMatcher
*/
@Override
public void setCredentialsMatcher(CredentialsMatcher credentialsMatcher) {
HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
/**
* 散列算法:这里可以使用MD5算法 也可以使用SHA-256
*/
hashedCredentialsMatcher.setHashAlgorithmName(ShiroUtils.hashAlgorithmName);
/**
* 散列的次数,比如散列2次,相当于 md5(md5(""));
*/
hashedCredentialsMatcher.setHashIterations(ShiroUtils.hashIterations);
super.setCredentialsMatcher(hashedCredentialsMatcher);
}
}
ShiroUtils
package com.qiuwei.shiro.util;
import com.qiuwei.shiro.entity.User;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.crypto.hash.SimpleHash;
import org.apache.shiro.session.Session;
import org.apache.shiro.subject.Subject;
/**
* Shiro工具类
*/
public class ShiroUtils {
/** 加密算法 */
public final static String hashAlgorithmName = "SHA-256";
/** 循环次数 */
public final static int hashIterations = 16;
public static String sha256(String password, String salt) {
return new SimpleHash(hashAlgorithmName, password, salt, hashIterations).toString();
}
// 获取一个测试账号 admin
public static void main(String[] args) {
// 3743a4c09a17e6f2829febd09ca54e627810001cf255ddcae9dabd288a949c4a
System.out.println(sha256("admin","123")) ;
}
/**
* 获取会话
*/
public static Session getSession() {
return SecurityUtils.getSubject().getSession();
}
/**
* Subject:主体,代表了当前“用户”
*/
public static Subject getSubject() {
return SecurityUtils.getSubject();
}
public static User getUserEntity() {
return (User)SecurityUtils.getSubject().getPrincipal();
}
public static Long getUserId() {
return getUserEntity().getUserId();
}
public static void setSessionAttribute(Object key, Object value) {
getSession().setAttribute(key, value);
}
public static Object getSessionAttribute(Object key) {
return getSession().getAttribute(key);
}
public static boolean isLogin() {
return SecurityUtils.getSubject().getPrincipal() != null;
}
public static void logout() {
SecurityUtils.getSubject().logout();
}
}
MySessionManager
package com.qiuwei.shiro.config;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.web.servlet.ShiroHttpServletRequest;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.apache.shiro.web.util.WebUtils;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.io.Serializable;
/**
*
* @Author: qiuwei@19pay.com.cn
* @Version 1.0.0
*
* 自定义session管理
* <br/>
* 传统结构项目中,shiro从cookie中读取sessionId以此来维持会话,在前后端分离的项目中(也可在移动APP项目使用),
* 我们选择在ajax的请求头中传递sessionId,因此需要重写shiro获取sessionId的方式。
* 自定义MySessionManager类继承DefaultWebSessionManager类,重写getSessionId方法
*/
public class MySessionManager extends DefaultWebSessionManager {
private static final String AUTHORIZATION = "Authorization";
private static final String REFERENCED_SESSION_ID_SOURCE = "Stateless request";
@Override
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
String id = WebUtils.toHttp(request).getHeader(AUTHORIZATION);
//如果请求头中有 Authorization 则其值为sessionId
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);
}
}
}
3 前后端分离需要注意的点
- 传统结构项目中,shiro从cookie中读取sessionId以此来维持会话,在前后端分离的项目中(也可在移动APP项目使用),我们选择在ajax的请求头中传递sessionId,因此需要重写shiro获取sessionId的方式。自定义MySessionManager类继承DefaultWebSessionManager类,重写getSessionId方法
- 登入失败,登入地址,前后端分离,不应该直接跳转页面,而是返回响应结果
4 测试
UserController
package com.qiuwei.shiro.controller;
import com.qiuwei.shiro.util.Response;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* @Author: qiuwei@19pay.com.cn
* @Version 1.0.0
*/
@RestController
@Slf4j
@RequestMapping("user")
public class UserController {
@Autowired
private Response response;
@GetMapping("list")
@RequiresPermissions("user:list")
public Response listUser() {
return response.success("用户列表");
}
@GetMapping("{userId}")
@RequiresPermissions("user:detail")
public Response detailUser(@PathVariable("userId") Long userId) {
return response.success("用户详情");
}
@PostMapping("add")
@RequiresRoles("admin")
@RequiresPermissions("user:add")
public Response addUser() {
return response.success("添加用户成功");
}
@DeleteMapping("del")
@RequiresRoles("role")
public Response delUser() {
return response.success("删除用户");
}
}
LoginController
package com.qiuwei.shiro.controller;
import com.qiuwei.shiro.entity.User;
import com.qiuwei.shiro.service.UserService;
import com.qiuwei.shiro.util.CacheUser;
import com.qiuwei.shiro.util.Response;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @Author: qiuwei@19pay.com.cn
* @Version 1.0.0
*/
@Slf4j
@RestController
public class LoginController {
private Response response;
private UserService userService;
@Autowired
@SuppressWarnings("all")
public LoginController(Response response, UserService userService) {
this.response = response;
this.userService = userService;
}
/**
* description: 登录
*
* @return 登录结果
*/
@PostMapping("/login")
public Response login(User user) {
log.warn("进入登录.....");
String username = user.getUsername();
String password = user.getPassword();
if (StringUtils.isBlank(username)) {
return response.failure("用户名为空!");
}
if (StringUtils.isBlank(password)) {
return response.failure("密码为空!");
}
CacheUser loginUser = userService.login(username, password);
// 登录成功返回用户信息
return response.success("登录成功!", loginUser);
}
/**
* description: 登出
*/
@RequestMapping("/logout")
public Response logOut() {
userService.logout();
return response.success("登出成功!");
}
/**
* 未登录,shiro应重定向到登录界面,此处返回未登录状态信息由前端控制跳转页面
* @return
*/
@RequestMapping("/un_auth")
public Response unAuth() {
return response.failure(HttpStatus.UNAUTHORIZED, "用户未登录!", null);
}
/**
* 未授权,无权限,此处返回未授权状态信息由前端控制跳转页面
* @return
*/
@RequestMapping("/unauthorized")
public Response unauthorized() {
return response.failure(HttpStatus.FORBIDDEN, "用户无权限!", null);
}
}
登入成功后 redis的数据
权限测试
admin 在配置角色权限的时候
配置了 1,3,4权限
所以 预期结果是 用户详情,用户添加有权限, 用户列表没有权限 ,删除用户没有权限
用户列表
用户详情
添加用户
删除用户
退出登入 查询redis中数据是否清楚
再次查询详情
到目前为止 都符合预期
参考文档:
跟我学shiro
SpringBoot2.0 整合 Shiro 框架,实现用户权限管理
SpringBoot2.x.x + Shiro + Redis 前后端分离实现