基于Shiro,Jwt,Vue的企业级登录权限实现方案

一. 概述

最近,我被公司安排去做一个子系统的登录权限控制,该子系统的需求是:

  1. 具备认证,授权模块(简单说就是能登录,然后有对应权限)
  2. 基于RBAC,能管理用户,角色,菜单(就是基于角色的访问控制)
  3. 从前端设计,到后端设计(相当于全栈。。。)

最终经过一番折腾,终于比较完整的做了出来。在这里先给大家看下效果:

登录
用户管理
角色管理
菜单分配
菜单管理
最终实现的效果:

  • 用户登录系统

  • 登录后,根据拥有的角色,获取对应的菜单并展示

  • 后端也做了权限控制,防止越权操作

  • 系统管理员具有所有权限,支持添加用户,菜单,角色并授权

下面会详细介绍登录权限的设计方案,供大家学习和使用

二. 原理与设计

在正式介绍方案之前,先对用到的技术做下简单科普(想要对该技术做系统性学习的,可以先点个关注,后续会出对应专栏)

后端基于SpringBoot,Shiro,Jwt,前端基于Vue

1. 后端权限框架:Shiro

Apache Shiro是Java的一个安全框架。它可以非常容易的开发出足够好的应用,帮助我们完成认证、授权、会话管理等操作,同时支持与Spring集成。最后,它的API也非常简单,同时具有良好的扩展性,支持用户自定义实现方案,架构图如下所示:
shiro
可以看到,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的一般流程:

jwt一般流程

  1. 浏览器通过http请求发送用户名和密码到服务器

  2. 服务器进行验证,验证通过后创建一个jwt token(携带用户信息)

  3. 将该token返回给浏览器,由浏览器保存

  4. 下次请求时,浏览器会带上当前token

  5. 服务器对该token进行验签,通过后从token中获取用户信息

  6. 根据当前获取的用户信息,做出响应,返回对应的数据

3. 访问控制方式:RBAC

RBAC,是基于角色的访问控制(Role-Based Access Control )。它是一种经典的访问控制方式,通过它极大地简化了权限管理

简单来说,一共有三种事务,用户,角色,权限。不同的用户有不同的角色,不同的角色有不同的权限,最终实现不同的用户有不同的权限
rbac
通过这样的权限设计,思路清晰,管理也方便

4. 前端框架:Vue

Vue是一套用于构建用户界面的渐进式框架。与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。Vue 的核心库只关注视图层,不仅易于上手,还便于与第三方库或既有项目整合。这里我们会用到Vue,本教程使用的模板基于vue-element-admin,它基于Vue和Element-ui实现

简单介绍完主要技术后,下面基于上述框架进行登录权限的设计

5. 后端登录权限——设计思路

下面介绍后端这一块的设计,主要包括两个点,一个是身份认证,一个是授权。这里采用的是shiro + jwt的方式来做,主要设计思路如下:

<1>. 身份认证

第一个步骤是身份认证,说白了就是用户在登录界面,输入用户名/密码进行登录的过程(登出操作也类似)
身份认证

  1. 用户输入用户名+密码,点击登录按钮

  2. 服务器根据用户输入的信息进行身份校验,校验失败则返回失败结果

  3. 校验成功,则根据输入的用户信息,生成一个jwt token

  4. 将该token返回给用户浏览器

  5. 同时将该token存储到redis中

<2>. 获取用户信息

用户身份认证成功后,浏览器和服务器都会同时保存该token,浏览器保存在cookie中,服务器保存在redis中

后面每当用户需要请求业务数据时,每次都需要先获取用户信息,根据后端返回的用户信息,前端再做菜单的渲染

拆分身份认证与获取用户信息的步骤原因

将身份认证和获取用户信息分开,可以实现用户信息的动态调整。例如一个用户登录后,系统管理员将他的菜单做出调整,这时候用户再访问的时候就会马上生效了。反之,如果身份认证后就获取用户信息,那么这个时候调整菜单,也不会马上生效,而是需要等用户退出登录再进入时,变更才生效

这里获取用户信息的主要过程如下:
获取用户信息

  1. 用户获取用户信息时,在请求头部中携带jwt token

  2. 服务器对jwt token验签,校验失败则直接返回

  3. 服务器对jwt token验签通过后,从redis中查询token信息,判断是否过期

  4. 如果过期直接返回,如果未过期,则根据jwt token,解密并查库得到用户信息

  5. 服务器将获取到的用户信息返回给用户

<3>. 授权

最后一个是授权。对于后端来说,不同的用户拥有不同的接口权限。有的接口允许访问,有的接口则不允许访问。对于越权操作,后端应该统一返回401,禁止访问
授权
可以看出,这里与获取用户信息类似,唯一区别在于5和6

  1. 根据用户信息查询是否有权限,没有则返回401

  2. 若有该接口权限,则请求接口并响应结果

自此,基于后端的登录权限的设计就完成了,后面提供详细的实现方案(代码)

6. 前端登录权限——设计思路

这里由于我司的子系统为前后端分离的项目,就涉及到前端的登录和权限控制。我们的方案基于vue的脚手架项目,做了一些模仿和改进

下面介绍前端的登录权限设计方案

<1>. 身份认证

这里我们的思路是:

  1. 当用户输入账号和密码后,向服务器验证是否正确

  2. 正确后,服务端返回token,前端存储到cookie中

  3. 前端根据token再去拉取一个获取用户信息的接口,获取用户的详细信息

<2>. 粗粒度权限:菜单

对于菜单,这里有两种做法。一种是动态菜单,一种是静态菜单

  • 动态菜单:菜单实时变化,数据保存在后端数据库

  • 静态菜单:菜单不实时变化,数据保存在前端

这里两者的区别主要在于,动态菜单的菜单数据保存在后端,意思就是,每次前端多了一个菜单,都需要让后端去表里面加一条菜单数据,不然就不会显示。而静态菜单,则是前端直接写死在代码中,不依赖于后端

在当前这种前后端分离的大背景下,静态菜单更具有优势。前端的同学应该将菜单交给自己管理,而不是依赖于后端,这样子也有点耦合了

这里笔者也倾向于静态菜单。而且,静态菜单实现菜单权限,也是很容易的。具体思路为:

  1. 前端根据token拉取一个获取用户信息的接口,获取用户的详细信息

  2. 从用户的详细信息里面获取对应的菜单权限,然后进行菜单渲染

通过token获取用户对应的permission,动态根据用户的permission算出其对应有权限的路由,通过router.addRoutes动态挂载这些路由即可

<3>. 细粒度权限:按钮

上面讲完菜单权限,这里来讲下按钮权限。应该说,按钮权限相比菜单权限,粒度更小。它需要我们根据对应权限,对不同的组件进行不同的标记处理。这里笔者的做法是:

  1. 实现了权限控制的vue自定义指令,在需要控制权限的地方加上指令

  2. 自定义指令的判断逻辑

  • 获取用户的详细信息后,得到对应的权限列表。然后判断该按钮的权限是否在对应的权限列表中,存在则展示该按钮,不存在则隐藏该按钮

  • 因为真正需要按钮权限控制的地方不是很多,所以在这些地方手动加上指令,也是问题不大的

三. 实现方案

前面介绍了详细的设计思路,下面以代码的形式演示实现方案

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,获取对应的用户权限列表,动态渲染菜单,并通过自定义指令隐藏无权限的按钮

  • 权限控制管理:支持添加用户,菜单,角色并进行用户角色,角色菜单授权

五. 参考资料

shiro

shiro

jwt

vue

vue权限控制

  • 5
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值