Springboot + shiro + jwt + vue 实现基本的认证,鉴权

一,前言

最近在思考如何实现一个权限管理系统。
在查阅一些资料和开源项目后,自己模仿着写了个demo

二,概念

1,RBAC模型

RBAC(Role-Based Access Control)——基于角色的访问控制

RBAC是一套成熟的权限模型。在传统权限模型中,我们直接把权限赋予用户。而在RBAC中,增加了“角色”的概念,我们首先把权限赋予角色,再把角色赋予用户。这样,由于增加了角色,授权会更加灵活方便。在RBAC中,根据权限的复杂程度,又可分为RBAC0、RBAC1、RBAC2、RBAC3。其中,RBAC0是基础,RBAC1、RBAC2、RBAC3都是以RBAC0为基础的升级。我们可以根据自家产品权限的复杂程度,选取适合的权限模型。

(1) RBAC0

RBAC0是基础,很多产品只需基于RBAC0就可以搭建权限模型了。在这个模型中,我们把权限赋予角色,再把角色赋予用户。用户和角色,角色和权限都是多对多的关系。用户拥有的权限等于他所有的角色持有权限之和。
Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第1张图片

(2)角色分层模型RBAC1

RBAC1建立在RBAC0基础之上,在角色中引入了继承的概念。简单理解就是,给角色可以分成几个等级,每个等级权限不同,从而实现更细粒度的权限管理。
Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第2张图片

(3)角色限制模型RBAC2

RBAC2同样建立在RBAC0基础之上,仅是对用户、角色和权限三者之间增加了一些限制。这些限制可以分成两类,即静态职责分离SSD(Static Separation of Duty)和动态职责分离DSD(Dynamic Separation of Duty)。具体限制如下图:
Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第3张图片

(4)统一模型RBAC3

RBAC3是RBAC1和RBAC2的合集,所以RBAC3既有角色分层,也包括可以增加各种限制。

(5)基于RBAC的延展——用户组

基于RBAC模型,还可以适当延展,使其更适合我们的产品。譬如增加用户组概念,直接给用户组分配角色,再把用户加入用户组。这样用户除了拥有自身的权限外,还拥有了所属用户组的所有权限。
Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第4张图片

三,代码实践

该 demo 是模仿一个开源项目的,是在 RBAC0 基础上进行的一些扩展。该demo的重点是权限管理,所以一些不必要的就不贴出来了。文章末尾会提供源码。以下会分为前端和后端来说明。

这个demo不涉及到按钮的权限控制,按钮的权限控制并不难,这里提供一种思路:

定义一个全局方法,配合 v-if 实现,在用户登录成功后,获取用户的按钮权限(数组格式),存储到store中,定义公共函数hasPermission,在需要权限的按钮中使用即可。

1,数据库表设计

(1)admin

后台用户表,定义了后台用户的一些基本信息。

create table admin
(
   id                   bigint not null auto_increment,
   username             varchar(64) comment '用户名',
   password             varchar(64) comment '密码',
   icon                 varchar(500) comment '头像',
   email                varchar(100) comment '邮箱',
   nick_name            varchar(200) comment '昵称',
   note                 varchar(500) comment '备注信息',
   create_time          datetime comment '创建时间',
   login_time           datetime comment '最后登录时间',
   status               int(1) default 1 comment '帐号启用状态:0->禁用;1->启用',
   primary key (id)
);

在这里插入图片描述

(2)role

后台用户角色表,定义了后台用户角色的一些基本信息,通过给后台用户分配角色来实现菜单和资源的分配。

create table role
(
   id                   bigint not null auto_increment,
   name                 varchar(100) comment '名称',
   description          varchar(500) comment '描述',
   admin_count          int comment '后台用户数量',
   create_time          datetime comment '创建时间',
   status               int(1) default 1 comment '启用状态:0->禁用;1->启用',
   sort                 int default 0,
   primary key (id)
);

在这里插入图片描述

(3)admin_role_relation

后台用户和角色关系表,多对多关系表,一个角色可以分配给多个用户

create table admin_role_relation
(
   id                   bigint not null auto_increment,
   admin_id             bigint,
   role_id              bigint,
   primary key (id)
);

Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第5张图片

(4)menu

后台菜单表,用于控制后台用户可以访问的菜单,支持隐藏、排序和更改名称、图标。

create table menu
(
   id                   bigint not null auto_increment,
   parent_id            bigint comment '父级ID',
   create_time          datetime comment '创建时间',
   title                varchar(100) comment '菜单名称',
   level                int(4) comment '菜单级数',
   sort                 int(4) comment '菜单排序',
   name                 varchar(100) comment '前端名称',
   icon                 varchar(200) comment '前端图标',
   hidden               int(1) comment '前端隐藏',
   primary key (id)
);

Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第6张图片

(5)resource

后台资源表,用于控制后台用户可以访问的接口,使用了Ant路径的匹配规则,可以使用通配符定义一系列接口的权限。

create table resource
(
   id                   bigint not null auto_increment,
   category_id          bigint comment '资源分类ID',
   create_time          datetime comment '创建时间',
   name                 varchar(200) comment '资源名称',
   url                  varchar(200) comment '资源URL',
   description          varchar(500) comment '描述',
   primary key (id)
);

Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第7张图片

(6)resource_category

后台资源分类表,在细粒度进行权限控制时,可能资源会比较多,所以设计了个资源分类的概念,便于给角色分配资源。

create table resource_category
(
   id                   bigint not null auto_increment,
   create_time          datetime comment '创建时间',
   name                 varchar(200) comment '分类名称',
   sort                 int(4) comment '排序',
   primary key (id)
);

Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第8张图片

(7)role_menu_relation

后台角色菜单关系表,多对多关系,可以给一个角色分配多个菜单。

create table role_menu_relation
(
   id                   bigint not null auto_increment,
   role_id              bigint comment '角色ID',
   menu_id              bigint comment '菜单ID',
   primary key (id)
);

Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第9张图片

(8)role_resource_relation

后台角色资源关系表,多对多关系,可以给一个角色分配多个资源

create table role_resource_relation
(
   id                   bigint not null auto_increment,
   role_id              bigint comment '角色ID',
   resource_id          bigint comment '资源ID',
   primary key (id)
);

Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第10张图片

2,后端代码

后端是使用 shiro + jwt(java web token)实现的。如果不熟悉shiro建议先学习,同时也需要有jwt的一点知识,这里不作介绍。后端代码不会全部放出来在这,只放出关键的部分。

这里简单提一下登录功能:前端携带账号,密码请求登录接口,登录成功则返回 token(前端将token保存起来,并且每次发起请求都将token放在自定义请求头TOKEN中…)

    @PostMapping("/api/login")
    public RestResponse<String> login(@RequestBody Map<String, String> params,
                                      HttpServletResponse rsp) {
        //不做密码加密了
        String userName = params.get("userName");
        String password = params.get("password");
        Admin admin = adminService.login(userName, password);
        if (admin == null) {
            return RestResponse.fail(401, "用户名或密码错误!");
        } else {
            String token = JwtUtil.createToken(admin.getId().toString());
            return RestResponse.ok(token);
        }
    }
(1)JwtFilter

继承BasicHttpAuthenticationFilter,如果前端请求头中携带token信息,则进行认证鉴权操作,如果没有携带,则认为是游客

public class JwtFilter extends BasicHttpAuthenticationFilter {

    private static final String TOKEN_HEADER = "TOKEN";

    Logger log = LoggerFactory.getLogger(JwtFilter.class);

    /**
     * 如果带有 token,则对 token 进行检查,否则直接通过
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws UnauthorizedException {
        /*
         * 判断用户是否携带token
         * 然后进行登录
         * (检验token的合法性)
         * SignatureVerificationException -----> token 不合法
         * TokenExpiredException -----> token 过期
         */
        if (isLoginAttempt(request, response)) {
            try {
                this.executeLogin(request, response);
            } catch (Exception e) {
                // 认证出现异常,传递错误信息msg
                String msg = e.getMessage();
                // 获取应用异常(该Cause是导致抛出此throwable(异常)的throwable(异常))
                Throwable throwable = e.getCause();
                if (throwable != null && throwable instanceof SignatureVerificationException) {
                    // 该异常为JWT的AccessToken认证失败(Token或者密钥不正确)
                    msg = "登录异常,请重新登录!-- SignatureVerificationException";
                } else if (throwable != null && throwable instanceof TokenExpiredException) {
                    // 该异常为JWT的AccessToken已过期。让用户重新登录
                    msg = "请重新登录!-- TokenExpiredException";
                } else if (throwable != null && throwable instanceof AuthenticationException) {
                    msg = "token错误 -- AuthenticationException";
                } else {
                    // 应用异常不为空
                    if (throwable != null) {
                        // 获取应用异常msg
                        msg = throwable.getMessage();
                    }
                }
                log.info(msg);
                /**
                 * 错误两种处理方式
                 * 1. 将非法请求转发到/401的Controller处理,抛出自定义无权访问异常被全局捕捉再返回Response信息
                 * (或者直接抛出异常,springmvc中有一个默认的错误处理controller,会跳转到/error,然后被那个controller处理)
                 * 2.无需转发,直接返回Response信息
                 */
                 //this.response401(request, response, msg);
                 throw  e;
            }
        }
        //如果请求头不存在 Token,则可能是执行登陆操作或者是游客状态访问,无需检查 token,直接返回 true。
        return true;
    }

    /**
     * 判断用户是否想要登入。
     * 检测 header 里面是否包含 Token 字段
     */
    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
        HttpServletRequest req = (HttpServletRequest) request;
        String token = req.getHeader(TOKEN_HEADER); //从请求头中获取token
        return token != null;
    }

    /**
     * 执行登陆操作
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws AuthenticationException {
        HttpServletRequest req = (HttpServletRequest) request;
        String token = req.getHeader(TOKEN_HEADER);
        JwtToken jwtToken = new JwtToken(token);
        // 提交给realm进行登入,如果错误他会抛出异常并被捕获
        getSubject(request, response).login(jwtToken);
        // 如果没有抛出异常则代表登入成功,返回true
        return true;
    }

    /**
     * 对跨域提供支持。因为跨域请求会先发送一个 Option 请求
     */
    @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);
    }
    /**
     * 无需转发,直接返回Response信息
     */
    private void response401(ServletRequest req, ServletResponse resp, String msg) {
        HttpServletResponse httpServletResponse = (HttpServletResponse) resp;
        httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
        httpServletResponse.setCharacterEncoding("UTF-8");
        httpServletResponse.setContentType("application/json; charset=utf-8");
        PrintWriter out = null;
        try {
            out = httpServletResponse.getWriter();
            String data = new ObjectMapper().writeValueAsString(RestResponse.fail(401,msg));
            out.append(data);
        } catch (IOException e) {
//            throw new IOException();
        } finally {
            if (out != null) {
                out.close();
            }
        }
    }

}
(2)CustomRealm

自定义 Realm,shiro框架中认证鉴权的关键。(从数据库中查询用户的角色,权限信息)

@Component
public class CustomRealm extends AuthorizingRealm {

    Logger log = LoggerFactory.getLogger(Logger.class);

    @Override
    public void setName(String name) {
        super.setName("REALM");
    }

    /**
     * 必须重写此方法,不然会报错
     */
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }

    /**
     * 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        String token = (String) authenticationToken.getCredentials();
        String adminId = JwtUtil.getDate(token);
//        //token校验
        if (adminId == null || !JwtUtil.verify(token, adminId)) {
            throw new AuthenticationException("token认证失败!");
        }
        AdminService adminService = (AdminService) ApplicationContextUtils.getBean("adminServiceImpl");
        Admin admin = adminService.getAdminInfo(Long.parseLong(adminId));
        if (admin == null) {
            throw new AuthenticationException("该用户不存在!");
        } else {
            return new SimpleAuthenticationInfo(token, token, getName());
        }
    }


    /**
     * 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        //获取用户角色信息,权限信息
        String adminId = JwtUtil.getDate(principals.toString());
        RoleService roleService = (RoleService) ApplicationContextUtils.getBean("roleServiceImpl");
        ResourceService resourceService = (ResourceService) ApplicationContextUtils.getBean("resourceServiceImpl");
        //获取用户角色
        Set<String> roleSet = roleService.getRolesByAdminId(adminId);
//        //获取用户权限字符串
        Set<String> permissions = resourceService.getPermissionByUserId(adminId);
        info.setRoles(roleSet);
        info.setStringPermissions(permissions);
        return info;
    }
}
(3)ShiroConfig

shiro相关配置

@Configuration
public class ShiroConfig {

    /**
     * 先走 filter ,然后 filter 如果检测到请求头存在 token,则用 token 去 login,走 Realm 去验证
     */
    @Bean("shiroFilter")
    public ShiroFilterFactoryBean factory(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();

        // 添加自己的过滤器并且取名为jwt
        Map<String, Filter> filterMap = new LinkedHashMap<>();
        //设置我们自定义的JWT过滤器
        filterMap.put("jwt", new JwtFilter());
        factoryBean.setFilters(filterMap);
        factoryBean.setSecurityManager(securityManager);

        Map<String, String> filterRuleMap = new HashMap<>();
        filterRuleMap.put("/api/login", "anon");
        // 所有请求通过我们自己的JWT Filter
        filterRuleMap.put("/**", "jwt");
        //如果有静态资源,要开放静态资源
        factoryBean.setFilterChainDefinitionMap(filterRuleMap);
        return factoryBean;
    }

    /**
     * 注入 securityManager
     */
    @Bean("securityManager")
    public DefaultWebSecurityManager securityManager(CustomRealm customRealm) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // 设置自定义 realm.
        securityManager.setRealm(customRealm);
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);
        // 设置自定义Cache缓存
//        securityManager.setCacheManager(new RedisCacheManager());
        return securityManager;
    }

    /**
     * 添加注解支持
     */
    @Bean
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        // 强制使用cglib,防止重复代理和可能引起代理出错的问题
        // https://zhuanlan.zhihu.com/p/29161098
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        return defaultAdvisorAutoProxyCreator;
    }


    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }

    @Bean
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }
}
(4)在对应的接口上加上shiro的注解
@RestController
@RequiresRoles(value = {"商品管理员", "超级管理员"},logical=Logical.OR)
public class GoodsController {

    //品牌管理
    @RequiresPermissions(value = {"brand:*"})
    @GetMapping("/api/goods/brand")
    public RestResponse<String> brandManager() {
        return RestResponse.ok("品牌管理");
    }

    //商品管理
    @RequiresPermissions(value = {"product:*"})
    @GetMapping("/api/goods/product")
    public RestResponse<String> productManager() {
        return RestResponse.ok("商品管理");
    }

    //商品属性管理
    @RequiresPermissions(value = {"productAttribute:*"})
    @GetMapping("/api/goods/productAttribute")
    public RestResponse<String> productAttributeManager() {
        return RestResponse.ok("商品属性管理");
    }
}
(5)后端总结

可以改进的地方有很多,比如:
可以把查询到的用户角色,权限信息,放置到redis中,而不用每次都查询
接口注解上的角色和权限是写死的,感觉这样并不容易扩展,考虑下次再用 sping security实现一遍
还有对异常的全局处理等等…

3,前端代码

用到 vue 两个重要的知识,Vue-Router 和 Vuex ,还有Promise ,async await ,不熟练的建议了解后再看,不然头会晕妥妥的。

(1)登录功能

src/views/login/index.vue中,输入用户名,密码点击登录

		handleLogin() {
			this.$store
				.dispatch('Login', this.loginForm)
				.then(() => {
					console.log('登录成功')
					this.$router.push({ path: '/' })
				})
				.catch(() => {
					console.log('登录失败')
				})
		},

src/store/modules/user.js中,发起登录请求。登录成功后将token保存起来。

		// 登录
		Login({ commit }, userInfo) {
			const username = userInfo.userName.trim()
			return new Promise((resolve, reject) => {
				_LoginApi
					.login({ userName: username, password: userInfo.password })
					.then((response) => {
						const tokenStr = response.response
						setToken(tokenStr)
						commit('SET_TOKEN', tokenStr)
						resolve()
					})
					.catch((error) => {
						reject(error)
					})
			})
		},

设置axios请求拦截器,每次请求后端接口携带token

// 请求拦截器 带上token
axios.interceptors.request.use(
	function(config) {
		// 在发送请求之前做些什么,例如加入token
		let token = getToken()
		if (token != null && token != '') {
            config.headers.TOKEN = token
		}
		return config
	},
	function(error) {
		// 对请求错误做些什么
		return Promise.reject(error)
	}
)
(2)权限管理

首先我们需要修改src/router/index.js中的路由表,将路由表进行拆分,拆分成必须要显示的静态路由表和可以动态显示的动态路由表

Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第11张图片
然后我们需要添加src/store/modules/permission.js文件,在Vuex的Store中添加权限相关状态,比如和左侧菜单绑定的路由表。

Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第12张图片
这里有个比较核心的GenerateRoutes方法,用于生成当前用户可以访问的路由。我们的data参数中包含了用户可以访问的菜单信息。它的具体执行流程如下:从菜单信息中筛选出可以访问的动态路由,然后进行排序,最后提交状态改变到Vuex中去改变routers这个状态。

		GenerateRoutes({ commit }, data) {
			return new Promise((resolve) => {
				const { menus } = data
				const { username } = data
				const accessedRouters = asyncRouterMap.filter((v) => {
					//admin帐号直接返回所有菜单
					// if(username==='admin') return true;
					if (hasPermission(menus, v)) {
						if (v.children && v.children.length > 0) {
							v.children = v.children.filter((child) => {
								if (hasPermission(menus, child)) {
									return child
								}
								return false
							})
							return v
						} else {
							return v
						}
					}
					return false
				})
				//对菜单进行排序
				sortRouters(accessedRouters)
				commit('SET_ROUTERS', accessedRouters)
				resolve()
			})
		},

关于前端路由和后台菜单的匹配,其实是根据路由名称和菜单的前端名称来确定的,比如商品列表中的路由名称和menu表中存储的前端名称如下。
Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第13张图片
Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第14张图片
接下来我们需要修改src/store/index.js文件,在Vuex的Store中添加这个权限模块的状态。
Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第15张图片
我们还需要修改src/components/nav/index.vue文件,将左侧菜单组件和Vuex中存储的路由状态进行绑定,这样当我们修改了Vuex中的状态后,菜单就会改变了。mapGetters是个辅助函数,可以将Store中的Getter属性映射到局部计算属性。

Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第16张图片最后我们需要在用户登录成功后,通过store.dispatch('GenerateRoutes', { menus,username })来修改Vuex中存储的路由状态并传入用户可以访问的菜单信息。(在src/perission.js中)

router.beforeEach((to, from, next) => {
	//改变title
	if (to.meta.title) {
		document.title = to.meta.title
	}

	if (getToken()) {
		if (to.path === '/login') {
			next({ path: '/' })
		} else {
			if (store.getters.roles.length === 0) {
				store
					.dispatch('GetInfo')
					.then((res) => {
						// 拉取用户信息
						let menus = res.response.menus
						let username = res.response.username
						store
							.dispatch('GenerateRoutes', { menus, username })
							.then(() => {
								// 生成可访问的路由表
								router.addRoutes(store.getters.addRouters) // 动态添加可访问路由表
								next({ ...to, replace: true })
							})
					})
					.catch((err) => {
						store.dispatch('FedLogOut').then(() => {
							Message.error(
								err || 'Verification failed, please login again'
							)
							next({ path: '/' })
						})
					})
			} else {
				next()
			}
		}
	} else {
		if (whiteList.indexOf(to.path) !== -1) {
			next()
		} else {
			next('/login')
		}
	}
})

记得在 main.js中引入以下permission.js

import '@/permission' // permission control
(3)前端总结

对 Promise 语法还不是很熟悉,看了很久的项目源码才吸收前端的内容,然后东改西改才完成。对前端知识掌握的还不够

四,示范

不同角色登录的用户拥有的菜单和权限是不同的。

Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第17张图片
Springboot + shiro + jwt + vue 实现基本的认证,鉴权_第18张图片
如果没有相应的权限。访问对应的接口是会出错的。

五,总结

还有很多改善的地方。对于登录功能,还要考虑 token 的刷新,还有权限还不够系,还可以做到更细粒度的。
还有对异常的处理,demo并没有处理好。

六,demo 源码

码云

你可能感兴趣的:(spring,Vue,前端)