一. 概述
最近,我被公司安排去做一个子系统的登录权限控制,该子系统的需求是:
- 具备认证,授权模块(简单说就是能登录,然后有对应权限)
- 基于RBAC,能管理用户,角色,菜单(就是基于角色的访问控制)
- 从前端设计,到后端设计(相当于全栈。。。)
最终经过一番折腾,终于比较完整的做了出来。在这里先给大家看下效果:
最终实现的效果:
-
用户登录系统
-
登录后,根据拥有的角色,获取对应的菜单并展示
-
后端也做了权限控制,防止越权操作
-
系统管理员具有所有权限,支持添加用户,菜单,角色并授权
下面会详细介绍登录权限的设计方案,供大家学习和使用
二. 原理与设计
在正式介绍方案之前,先对用到的技术做下简单科普(想要对该技术做系统性学习的,可以先点个关注,后续会出对应专栏)
后端基于SpringBoot,Shiro,Jwt,前端基于Vue
1. 后端权限框架:Shiro
Apache Shiro是Java的一个安全框架。它可以非常容易的开发出足够好的应用,帮助我们完成认证、授权、会话管理等操作,同时支持与Spring集成。最后,它的API也非常简单,同时具有良好的扩展性,支持用户自定义实现方案,架构图如下所示:
可以看到,Shiro框架的核心组件主要有Subject,SecurityManager,Realm
-
Subject:当前主体,一般代表当前用户
-
SecurityManager:安全管理器,说白了就是管理Subject
-
Realm:域,安全数据源,一般从这里面拿数据
因此,Shiro简单讲,使用就是通过SecurityManager,做用户的登录登出操作,在使用过程中,需要从Realm中获取用户数据(这里一般查数据库),操作的对象就是Subject(当前用户)
2. 开放标准:Jwt
Json web token(JWT),是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准(RFC 7519)。它是客户端和服务端安全传递以及身份认证的一种解决方案,可以用在登录上。该token可以被加密,可以在上面添加一些业务信息供识别
它的组成包括三部分,头部,载荷和签证
-
头部:声明类型和加密算法
-
载荷:存放一些有效信息,比如一些业务相关的信息,例如用户信息
-
签证:签证信息,说白了就是拿头部和载荷然后做加密操作而构成
最终一个jwt字符串的样子大概如下所示:
4d7b21037d32fc7bbb26d0bda3270467311c00fff45ac.4dd993cafb6b04e5fbb2c387259f32b81d21011a0b6227b537da9e4ea404bd815dbd79ec9e304707d5e7b06185f8156e88aa225af96776a7c0393759525d3cea2bedd4cc370a256ec230eb4e1ee6e27d78df81b6e1c66bcd8e18e58c74500fd811f8d4b4147d369.c5bab625ff8bdec61426867b423da854e17a
下面说一下Jwt的一般流程:
-
浏览器通过http请求发送用户名和密码到服务器
-
服务器进行验证,验证通过后创建一个jwt token(携带用户信息)
-
将该token返回给浏览器,由浏览器保存
-
下次请求时,浏览器会带上当前token
-
服务器对该token进行验签,通过后从token中获取用户信息
-
根据当前获取的用户信息,做出响应,返回对应的数据
3. 访问控制方式:RBAC
RBAC,是基于角色的访问控制(Role-Based Access Control )。它是一种经典的访问控制方式,通过它极大地简化了权限管理
简单来说,一共有三种事务,用户,角色,权限。不同的用户有不同的角色,不同的角色有不同的权限,最终实现不同的用户有不同的权限
通过这样的权限设计,思路清晰,管理也方便
4. 前端框架:Vue
Vue是一套用于构建用户界面的渐进式框架。与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。Vue 的核心库只关注视图层,不仅易于上手,还便于与第三方库或既有项目整合。这里我们会用到Vue,本教程使用的模板基于vue-element-admin,它基于Vue和Element-ui实现
简单介绍完主要技术后,下面基于上述框架进行登录权限的设计
5. 后端登录权限——设计思路
下面介绍后端这一块的设计,主要包括两个点,一个是身份认证,一个是授权。这里采用的是shiro + jwt的方式来做,主要设计思路如下:
<1>. 身份认证
第一个步骤是身份认证,说白了就是用户在登录界面,输入用户名/密码进行登录的过程(登出操作也类似)
-
用户输入用户名+密码,点击登录按钮
-
服务器根据用户输入的信息进行身份校验,校验失败则返回失败结果
-
校验成功,则根据输入的用户信息,生成一个jwt token
-
将该token返回给用户浏览器
-
同时将该token存储到redis中
<2>. 获取用户信息
用户身份认证成功后,浏览器和服务器都会同时保存该token,浏览器保存在cookie中,服务器保存在redis中
后面每当用户需要请求业务数据时,每次都需要先获取用户信息,根据后端返回的用户信息,前端再做菜单的渲染
拆分身份认证与获取用户信息的步骤原因
将身份认证和获取用户信息分开,可以实现用户信息的动态调整。例如一个用户登录后,系统管理员将他的菜单做出调整,这时候用户再访问的时候就会马上生效了。反之,如果身份认证后就获取用户信息,那么这个时候调整菜单,也不会马上生效,而是需要等用户退出登录再进入时,变更才生效
这里获取用户信息的主要过程如下:
-
用户获取用户信息时,在请求头部中携带jwt token
-
服务器对jwt token验签,校验失败则直接返回
-
服务器对jwt token验签通过后,从redis中查询token信息,判断是否过期
-
如果过期直接返回,如果未过期,则根据jwt token,解密并查库得到用户信息
-
服务器将获取到的用户信息返回给用户
<3>. 授权
最后一个是授权。对于后端来说,不同的用户拥有不同的接口权限。有的接口允许访问,有的接口则不允许访问。对于越权操作,后端应该统一返回401,禁止访问
可以看出,这里与获取用户信息类似,唯一区别在于5和6
-
根据用户信息查询是否有权限,没有则返回401
-
若有该接口权限,则请求接口并响应结果
自此,基于后端的登录权限的设计就完成了,后面提供详细的实现方案(代码)
6. 前端登录权限——设计思路
这里由于我司的子系统为前后端分离的项目,就涉及到前端的登录和权限控制。我们的方案基于vue的脚手架项目,做了一些模仿和改进
下面介绍前端的登录权限设计方案
<1>. 身份认证
这里我们的思路是:
-
当用户输入账号和密码后,向服务器验证是否正确
-
正确后,服务端返回token,前端存储到cookie中
-
前端根据token再去拉取一个获取用户信息的接口,获取用户的详细信息
<2>. 粗粒度权限:菜单
对于菜单,这里有两种做法。一种是动态菜单,一种是静态菜单
-
动态菜单:菜单实时变化,数据保存在后端数据库
-
静态菜单:菜单不实时变化,数据保存在前端
这里两者的区别主要在于,动态菜单的菜单数据保存在后端,意思就是,每次前端多了一个菜单,都需要让后端去表里面加一条菜单数据,不然就不会显示。而静态菜单,则是前端直接写死在代码中,不依赖于后端
在当前这种前后端分离的大背景下,静态菜单更具有优势。前端的同学应该将菜单交给自己管理,而不是依赖于后端,这样子也有点耦合了
这里笔者也倾向于静态菜单。而且,静态菜单实现菜单权限,也是很容易的。具体思路为:
-
前端根据token拉取一个获取用户信息的接口,获取用户的详细信息
-
从用户的详细信息里面获取对应的菜单权限,然后进行菜单渲染
通过token获取用户对应的permission,动态根据用户的permission算出其对应有权限的路由,通过router.addRoutes动态挂载这些路由即可
<3>. 细粒度权限:按钮
上面讲完菜单权限,这里来讲下按钮权限。应该说,按钮权限相比菜单权限,粒度更小。它需要我们根据对应权限,对不同的组件进行不同的标记处理。这里笔者的做法是:
-
实现了权限控制的vue自定义指令,在需要控制权限的地方加上指令
-
自定义指令的判断逻辑
-
获取用户的详细信息后,得到对应的权限列表。然后判断该按钮的权限是否在对应的权限列表中,存在则展示该按钮,不存在则隐藏该按钮
-
因为真正需要按钮权限控制的地方不是很多,所以在这些地方手动加上指令,也是问题不大的
三. 实现方案
前面介绍了详细的设计思路,下面以代码的形式演示实现方案
1. 后端实现
<1>. 数据库设计
这里基于RBAC,一共五张表,用户表,角色表,菜单表,用户角色关联表,角色菜单关联表,表结构如下:
-- 系统用户表
CREATE TABLE `sys_user` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`username` varchar(255) COLLATE utf8_bin NOT NULL DEFAULT '' COMMENT '用户名',
`nick_name` varchar(255) COLLATE utf8_bin DEFAULT NULL COMMENT '昵称',
`phone` varchar(255) COLLATE utf8_bin DEFAULT NULL COMMENT '手机号码',
`email` varchar(255) COLLATE utf8_bin DEFAULT NULL COMMENT '邮箱',
`password` varchar(255) COLLATE utf8_bin DEFAULT NULL COMMENT '密码',
`status` tinyint(4) DEFAULT NULL COMMENT '状态:0-禁用 1-正常',
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=58 DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='系统用户表';
-- 系统角色表
CREATE TABLE `sys_role` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(255) COLLATE utf8_bin DEFAULT NULL COMMENT '角色名称',
`description` varchar(255) COLLATE utf8_bin DEFAULT NULL COMMENT '描述',
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='系统角色表';
-- 系统菜单表
CREATE TABLE `sys_menu` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`title` varchar(255) COLLATE utf8_bin DEFAULT NULL COMMENT '菜单标题',
`permission` varchar(255) COLLATE utf8_bin DEFAULT NULL COMMENT '前端权限标识',
`pid` int(255) DEFAULT NULL COMMENT '上级菜单ID',
`children` tinyint(4) DEFAULT '0' COMMENT '是否存在孩子节点:0-否 1-是',
`children_count` int(11) DEFAULT '0' COMMENT '子菜单数目',
`status` tinyint(4) DEFAULT '1' COMMENT '状态:0-禁用 1-正常',
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=22 DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='系统菜单表';
-- 系统用户角色关联表
CREATE TABLE `sys_user_role` (
`user_id` int(11) NOT NULL AUTO_INCREMENT COMMENT '用户主键',
`role_id` int(11) NOT NULL DEFAULT '0' COMMENT '角色id',
PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=InnoDB AUTO_INCREMENT=58 DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='系统用户角色关联表';
-- 系统角色菜单关联表
CREATE TABLE `sys_role_menu` (
`role_id` int(11) NOT NULL AUTO_INCREMENT COMMENT '角色主键',
`menu_id` int(11) NOT NULL DEFAULT '0' COMMENT '菜单主键',
PRIMARY KEY (`role_id`,`menu_id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='系统角色菜单关联表';
<2>. 依赖引入
引入shiro,jwt配置,如下所示:
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.0</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.1</version>
</dependency>
<3>. 核心实现
下面是shiro,jwt的核心实现。包括:
-
shiro的自定义realm,自定义filter
-
jwt的自定义filter,自定义工具类
-
将上述bean注入到spring ioc容器中
-
权限不足所抛出的异常,统一交由全局异常处理器来处理
AnthenticationConstants
/**
* 权限模块常量类
* @author zz
**/
public class AnthenticationConstants {
/**
* token缓存前缀
*/
public static final String TOKEN_CACHE_PREFIX = "ops-token-";
}
JWTToken
import lombok.Data;
import org.apache.shiro.authc.AuthenticationToken;
/**
* jwt token
* @author zz
**/
@Data
public class JWTToken implements AuthenticationToken {
private static final long serialVersionUID = 1282057025599826155L;
private String token;
public JWTToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
JWTFilter
import com.demo.ops.mgt.util.EncryptUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.UnauthorizedException;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.*;
/**
* jwt过滤器,核心实现类
* @author zz
**/
@Slf4j
public class JWTFilter extends BasicHttpAuthenticationFilter {
public static final String TOKEN = "X-Token";
private static String whiteList;
private static Set<String> whiteSet = new HashSet<>();
private static List<String> prefixSet = new ArrayList<>();
public synchronized void init() {
whiteList = "/sys/login,/sys/logout,/v2/*";
initWhiteSet(whiteList);
}
private static void initWhiteSet(String whiteList) {
if (whiteList != null) {
log.info("reset whiteList: {}", whiteList);
Set<String> set = new HashSet<>();
List<String> prefixs = new ArrayList<>();
Arrays.stream(whiteList.split("\\s*,\\s*"))
.forEach((s) -> {
if (s.endsWith("*")) {
prefixs.add(s.substring(0, s.length() - 1));
} else {
set.add(s);
}
});
prefixSet = prefixs;
whiteSet = set;
}
}
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws UnauthorizedException {
if (whiteList == null) {
init();
}
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String path = httpServletRequest.getServletPath();
if (whiteSet.contains(path)) {
return true;
}
for(String whitePrefix : prefixSet){
if(path.startsWith(whitePrefix)){
return true;
}
}
if (isLoginAttempt(request, response)) {
return executeLogin(request, response);
}
return false;
}
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
HttpServletRequest req = (HttpServletRequest) request;
String token = req.getHeader(TOKEN);
return token != null;
}
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String token = httpServletRequest.getHeader(TOKEN);
JWTToken jwtToken = new JWTToken(decryptToken(token));
try {
getSubject(request, response).login(jwtToken);
return true;
} catch (Exception e) {
log.debug("登录检查异常!异常信息:{}", e.getMessage(), e);
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);
}
@Override
protected boolean sendChallenge(ServletRequest request, ServletResponse response) {
log.debug("认证401!");
HttpServletResponse httpResponse = WebUtils.toHttp(response);
httpResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
httpResponse.setCharacterEncoding("utf-8");
httpResponse.setContentType("application/json; charset=utf-8");
final String message = "请先登录";
try (PrintWriter out = httpResponse.getWriter()) {
String responseJson = "{\"msg\":\"" + message + "\",\"symbol\":false}";
out.print(responseJson);
} catch (IOException e) {
log.error("登录检查输出信息异常!异常信息:", e);
}
return false;
}
/**
* token 加密
* @param token token
* @return 加密后的 token
*/
public static String encryptToken(String token) {
try {
EncryptUtil encryptUtil = new EncryptUtil(AnthenticationConstants.TOKEN_CACHE_PREFIX);
return encryptUtil.encrypt(token);
} catch (Exception e) {
log.error("token加密异常!异常信息:", e);
return null;
}
}
/**
* token 解密
* @param encryptToken 加密后的 token
* @return 解密后的 token
*/
public static String decryptToken(String encryptToken) {
try {
EncryptUtil encryptUtil = new EncryptUtil(AnthenticationConstants.TOKEN_CACHE_PREFIX);
return encryptUtil.decrypt(encryptToken);
} catch (Exception e) {
log.error("token解密异常!异常信息:", e);
return null;
}
}
}
JWTUtil
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.demo.boot.util.SpringContextUtil;
import com.demo.ops.mgt.entity.SysUser;
import com.demo.ops.mgt.service.ISysUserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import javax.servlet.http.HttpServletRequest;
import java.util.Date;
/**
* jwt工具类
* @author zz
**/
@Slf4j
public class JWTUtil {
private static final long EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;
/**
* 校验 token是否正确
* @param token 密钥
* @param secret 用户的密码
* @return 是否正确
*/
public static boolean verify(String token, String username, String secret) {
try {
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("username", username)
.build();
verifier.verify(token);
return true;
} catch (Exception e) {
log.debug("token过期!过期信息:{}", e.getMessage());
return false;
}
}
/**
* 从token中获取用户名
* @return token中包含的用户名
*/
public static String getUsername(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException e) {
log.debug("从token中获取用户名异常!异常信息:{}", e.getMessage());
return null;
}
}
/**
* 生成token
* @param username 用户名
* @param secret 用户的密码
* @return token
*/
public static String sign(String username, String secret) {
try {
username = StringUtils.lowerCase(username);
Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(secret);
return JWT.create()
.withClaim("username", username)
.withExpiresAt(date)
.sign(algorithm);
} catch (Exception e) {
log.error("生成token异常!异常信息:{}", e.getMessage());
return null;
}
}
/**
* 获取当前系统用户
* @param httpServletRequest
* @return
*/
public static SysUser getCurrentSysUser(HttpServletRequest httpServletRequest) {
String token = httpServletRequest.getHeader(JWTFilter.TOKEN);
if (StringUtils.isBlank(token)) {
return null;
}
String decryptToken = JWTFilter.decryptToken(token);
String userName = JWTUtil.getUsername(decryptToken);
ISysUserService sysUserService = (ISysUserService) SpringContextUtil.getBean("sysUserService");
return sysUserService.selectUserByUsername(userName);
}
}
ShiroRealm
import com.demo.boot.util.SpringContextUtil;
import com.demo.ops.mgt.entity.SysUser;
import com.demo.ops.mgt.service.ISysUserService;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
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.springframework.data.redis.core.StringRedisTemplate;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 自定义实现 ShiroRealm,包含认证和授权两大模块
* @author zz
**/
public class ShiroRealm extends AuthorizingRealm {
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JWTToken;
}
/**
* 授权模块,获取用户角色和权限
* @param token
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection token) {
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
SysUser sysUser = (SysUser) token.getPrimaryPrincipal();
ISysUserService sysUserService = (ISysUserService) SpringContextUtil.getBean("sysUserService");
Set<String> permissionSet = new HashSet<>();
if (sysUser != null) {
List<String> permissionsList = sysUserService.selectUserPermissions(sysUser.getId());
if (permissionsList != null && permissionsList.size() != 0) {
permissionSet = new HashSet<>(permissionsList);
}
}
simpleAuthorizationInfo.setStringPermissions(permissionSet);
return simpleAuthorizationInfo;
}
/**
* 用户认证
* @param authenticationToken
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
// 获取由JWTFilter#executeLogin传递过来的已解密token
String token = (String) authenticationToken.getCredentials();
String encryptToken = JWTFilter.encryptToken(token);
String username = JWTUtil.getUsername(token);
if (StringUtils.isBlank(username)) {
throw new AuthenticationException("token校验不通过");
}
ISysUserService sysUserService = (ISysUserService) SpringContextUtil.getBean("sysUserService");
SysUser sysUser = sysUserService.selectUserByUsername(username);
if (sysUser == null) {
throw new AuthenticationException("用户名或密码错误");
}
String encryptTokenInRedis = null;
try {
StringRedisTemplate stringRedisTemplate = (StringRedisTemplate) SpringContextUtil.getBean("stringRedisTemplate");
encryptTokenInRedis = stringRedisTemplate.opsForValue().get(AnthenticationConstants.TOKEN_CACHE_PREFIX + sysUser.getId());
} catch (Exception e) {
//ignore
}
// 如果找不到,说明已经失效
if (StringUtils.isBlank(encryptTokenInRedis) || !encryptToken.equals(encryptTokenInRedis)) {
throw new AuthenticationException("token已经过期");
}
if (!JWTUtil.verify(token, username, sysUser.getPassword())) {
throw new AuthenticationException("token校验不通过");
}
return new SimpleAuthenticationInfo(sysUser, token, "ops_realm");
}
}
JWTToken
import org.apache.shiro.mgt.SecurityManager;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.LinkedHashMap;
/**
* shiro配置类
* @author zz
**/
@Configuration
public class ShrioConfig {
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
//设置SecurityManager
shiroFilterFactoryBean.setSecurityManager(securityManager);
//在Shiro过滤器链上加入JWTFilter
LinkedHashMap<String, Filter> filters = new LinkedHashMap<>();
filters.put("jwt", new JWTFilter());
shiroFilterFactoryBean.setFilters(filters);
LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
//所有请求都要经过JWT过滤器
filterChainDefinitionMap.put("/**", "jwt");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
return shiroFilterFactoryBean;
}
@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(shiroRealm());
return securityManager;
}
@Bean
public ShiroRealm shiroRealm() {
return new ShiroRealm();
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
}
GlobalWebExceptionHandler
import com.demo.boot.vo.MessageBean;
import org.apache.shiro.authz.UnauthorizedException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
/**
* 全局异常处理器
* @author zz
**/
@ControllerAdvice
public class GlobalWebExceptionHandler {
@ResponseBody
@ExceptionHandler(UnauthorizedException.class)
public MessageBean handleUnauthorizedException(UnauthorizedException e) {
return MessageBean.error(9, "权限不足!");
}
}
<4>. 登录登出控制
下面是登录逻辑,主要包括:用户登录,用户登出,获取用户信息
SystemController
import com.demo.boot.vo.MessageBean;
import com.demo.ops.mgt.aop.LogAspect;
import com.demo.ops.mgt.authentication.*;
import com.demo.ops.mgt.entity.SysUser;
import com.demo.ops.mgt.service.ISysUserService;
import com.demo.ops.mgt.util.MD5Util;
import com.demo.ops.mgt.vo.LoginInfo;
import com.demo.ops.mgt.vo.LoginVo;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
/**
* 系统控制器
* @author zz
**/
@Api(tags = {"系统"}, produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@RestController
@RequestMapping("/sys")
@Slf4j
public class SystemController {
@ApiOperation(httpMethod = "POST", value = "登录")
@PostMapping("/login")
public MessageBean login(@RequestBody(required = false) LoginVo loginVo, HttpServletRequest request) throws Exception {
String username = StringUtils.lowerCase(loginVo.getUsername());
String password = MD5Util.encrypt(username, loginVo.getPassword());
if (username == null || password == null) {
return MessageBean.error(9, "请输入正确的用户名或密码");
}
SysUser sysUser = sysUserService.selectUserByUsername(username);
if (sysUser == null) {
return MessageBean.error(9, "用户名或密码错误");
}
if (!username.equals(sysUser.getUsername()) || !password.equals(sysUser.getPassword())) {
return MessageBean.error(9, "用户名或密码错误");
}
if (SysUser.STATUS_DISABLED.equals(sysUser.getStatus())) {
return MessageBean.error(9, "账号已被锁定");
}
String token = JWTFilter.encryptToken(JWTUtil.sign(username, password));
JWTToken jwtToken = new JWTToken(token);
this.saveTokenToRedis(sysUser, jwtToken, request);
return MessageBean.success(jwtToken);
}
@ApiOperation(httpMethod = "POST", value = "登出")
@PostMapping("/logout")
public MessageBean logout(@RequestHeader("X-Token") String token) throws Exception {
String decryptToken = JWTFilter.decryptToken(token);
String userName = JWTUtil.getUsername(decryptToken);
SysUser sysUser = sysUserService.selectUserByUsername(userName);
stringRedisTemplate.delete(AnthenticationConstants.TOKEN_CACHE_PREFIX + sysUser.getId());
return MessageBean.success();
}
@ApiOperation(httpMethod = "POST", value = "获取用户信息")
@PostMapping("info")
public MessageBean info() {
SysUser sysUser = UserContextUtil.getCurrentContext();
if (stringRedisTemplate.opsForValue().get(AnthenticationConstants.TOKEN_CACHE_PREFIX + sysUser.getId()) == null) {
return MessageBean.error(1, "未登录");
}
LoginInfo loginInfo = new LoginInfo();
loginInfo.setName(sysUser.getNickName());
loginInfo.setPermissions(sysUserService.selectUserPermissions(sysUser.getId()));
loginInfo.setEnvs(sysUserService.selectUserEnvs(sysUser.getId()));
loginInfo.setRoles(Arrays.asList("admin"));
loginInfo.setIntroduction("一般用户");
loginInfo.setAvatar("https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif");
return MessageBean.success(loginInfo);
}
private void saveTokenToRedis(SysUser sysUser, JWTToken token, HttpServletRequest request) throws Exception {
stringRedisTemplate.opsForValue().set(AnthenticationConstants.TOKEN_CACHE_PREFIX + sysUser.getId(), token.getToken(), 10080, TimeUnit.MINUTES);
}
@Autowired
private ISysUserService sysUserService;
@Autowired
private StringRedisTemplate stringRedisTemplate;
}
<5>. 接口权限控制
在核心实现中,我们已经将shiro的权限控制实现了,具体请参考 ShiroRealm.doGetAuthorizationInfo方法
这里,我们使用shiro的注解@RequiresPermissions来控制接口的权限。下面是demo示例:
@ApiOperation(httpMethod = "POST", value = "发布配置")
@PostMapping("deploy")
@RequiresPermissions(value = {"config:deploy"})
public MessageBean deploy(@RequestBody CiConfigBuildReq vo) throws Exception {
return configService.deploy(vo);
}
上面的代码含义为,deploy方法需要config:deploy的权限,如果没有权限,则会报权限不足
2. 前端实现
下面按照请求顺序,介绍下前端的具体实现方案
<1>. router.js
这里的思路为,搞两个路由表,一个是常量路由表,一个是动态需要根据权限加载的路由表,实例化的时候只挂载常量路由表,而动态需要根据权限加载的路由表通过meta标签来标识需要的权限
//所有权限通用路由表
//如首页和登录页和一些不用权限的公用页面
export const constantRoutes = [
{
path: '/redirect',
component: Layout,
hidden: true,
children: [
{
path: '/redirect/:path*',
component: () => import('@/views/redirect/index')
}
]
},
{
path: '/login',
component: () => import('@/views/login/index'),
hidden: true
},
{
path: '/auth-redirect',
component: () => import('@/views/login/auth-redirect'),
hidden: true
},
{
path: '/404',
component: () => import('@/views/error-page/404'),
hidden: true
},
{
path: '/401',
component: () => import('@/views/error-page/401'),
hidden: true
},
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
component: () => import('@/views/dashboard/index'),
name: 'Dashboard',
meta: { title: 'Dashboard', icon: 'dashboard', affix: true }
}
]
}
]
//动态需要根据权限加载的路由表
export const asyncRoutes = [
{
path: '/log',
component: Layout,
meta: { permission: ['log'] },
children: [
{
path: 'index',
component: () => import('@/views/log/index'),
name: '日志查询',
meta: { title: '日志查询', icon: 'search', noCache: false, permission: ['log'] }
}
]
},
{
name: '系统管理',
path: '/system',
redirect: 'noredirect',
component: Layout,
children: [
{
path: 'user',
component: () => import('@/views/system/user'),
name: '用户管理',
meta: { title: '用户管理', icon: 'peoples', noCache: false, permission: ['user'] }
},
{
path: 'role',
component: () => import('@/views/system/role'),
name: '角色管理',
meta: { title: '角色管理', icon: 'role', noCache: false, permission: ['role'] }
},
{
path: 'menu',
component: () => import('@/views/system/menu'),
name: '菜单管理',
meta: { title: '菜单管理', icon: 'menu', noCache: false, permission: ['menu'] }
}
],
meta: { title: '系统管理', icon: 'system', noCache: false, permission: ['system'] }
},
{
name: '系统日志',
path: '/syslog',
redirect: 'noredirect',
component: Layout,
children: [
{
path: 'syslog',
component: () => import('@/views/monitor/log'),
name: '系统日志',
meta: { title: '系统日志', icon: 'syslog', noCache: false, permission: ['syslog'] }
}
],
meta: { title: '系统日志', icon: 'syslog', noCache: false, permission: ['syslog'] }
},
// 404 page must be placed at the end !!!
{ path: '*', redirect: '/404', hidden: true }
]
const createRouter = () => new Router({
scrollBehavior: () => ({ y: 0 }),
routes: constantRoutes
})
const router = createRouter()
export function resetRouter() {
const newRouter = createRouter()
router.matcher = newRouter.matcher // reset router
}
//实例化vue的时候只挂载常量路由表
export default router
<2>. main.js
这里就是前面所讲的,根据用户信息获取到权限列表后,去动态加载可访问的路由
router.beforeEach(async(to, from, next) => {
document.title = getPageTitle(to.meta.title)
const hasToken = getToken()
if (hasToken) {
if (to.path === '/login') {
next({ path: '/' })
} else {
const hasRoles = store.getters.roles && store.getters.roles.length > 0
if (hasRoles) {
next()
} else {
try {
// 获取用户信息
const { permissions } = await store.dispatch('user/getInfo')
// 根据获取到的用户信息,提取权限列表,从而得到可用的路由
const accessRoutes = await store.dispatch('permission/generateRoutes', permissions)
// 动态加载路由
router.addRoutes(accessRoutes)
next({ ...to, replace: true })
} catch (error) {
await store.dispatch('user/resetToken')
Message.error(error || 'Has Error')
next(`/login`)
}
}
}
} else {
// 没有token
if (whiteList.indexOf(to.path) !== -1) {
next()
} else {
next(`/login`)
}
}
})
<3>. permission.js
最后是permission.js,它的主要作用就是根据传入的权限,得到一个动态的路由表
const actions = {
generateRoutes({ commit }, permissions) {
return new Promise(resolve => {
const accessedRoutes = filterAsyncRoutes(asyncRoutes, permissions)
commit('SET_ROUTES', accessedRoutes)
resolve(accessedRoutes)
})
}
}
export function filterAsyncRoutes(routes, permissions) {
const res = []
routes.forEach(route => {
const tmp = { ...route }
if (hasPermission(permissions, tmp)) {
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, permissions)
}
res.push(tmp)
}
})
return res
}
function hasPermission(permissions, route) {
if (route.meta && route.meta.permission) {
return permissions.some(permission => route.meta.permission.includes(permission))
} else {
return false
}
}
<4>. permissionDirect.js
自定义vue指令,用于检查按钮级别的权限
export const hasPermission = {
install(Vue) {
Vue.directive('hasPermission', {
bind(el, binding, vnode) {
const permissions = vnode.context.$store.state.user.permissions
const value = binding.value
let flag = true
for (const v of value) {
if (!permissions.includes(v)) {
flag = false
}
}
if (!flag) {
if (!el.parentNode) {
el.style.display = 'none'
} else {
el.parentNode.removeChild(el)
}
}
}
})
}
}
export const hasEnvPermission = {
install(Vue) {
Vue.directive('hasEnvPermission', {
bind(el, binding, vnode) {
const envs = vnode.context.$store.state.user.envs
var env = binding.value.env
if (!env) {
env = binding.value.ip
}
let flag = true
if (!envs.includes(env)) {
flag = false
}
if (!flag) {
if (!el.parentNode) {
el.style.display = 'none'
} else {
el.parentNode.removeChild(el)
}
}
}
})
}
}
<5>. install.js
安装vue自定义指令
import Vue from 'vue'
import { hasPermission, hasEnvPermission } from './permissionDirect'
const Plugins = [
hasPermission,
hasEnvPermission
]
Plugins.map((plugin) => {
Vue.use(plugin)
})
export default Vue
<6>. 按钮权限控制
前面定义好指令后,可以通过v-hasPermission来控制,下面举个例子:
<el-button v-if="buildButtonShow" v-hasPermission="['version:build']" style="float: right; padding: 3px 0; margin-left: 10px" type="primary" @click="showBuild">构建</el-button>
上面代码的含义为,该按钮需要具有version:build权限才显示,否则隐藏
四. 总结
自此,基于Shiro,Jwt,Vue的企业级登录权限设计与实现就大功告成了。最后的最后,让我们总结下已实现的所有功能:
-
用户登录:即身份认证,基于shiro的filter实现,并通过Jwt保存和传递用户信息,前端登录完成后,保存token至cookie
-
界面展示:登录后,通过token获取用户信息,并根据拥有的角色,获取对应的菜单,按钮并展示
-
后端权限控制:通过token,获取对应的用户权限列表,对越权操作统一返回401
-
前端权限控制:通过token,获取对应的用户权限列表,动态渲染菜单,并通过自定义指令隐藏无权限的按钮
-
权限控制管理:支持添加用户,菜单,角色并进行用户角色,角色菜单授权