錄
前言
一、權(quán)限底層表結(jié)構(gòu)設(shè)計(jì)
??1. RBAC模型簡(jiǎn)介
??2. 建表語句
二、用戶身份認(rèn)證和授權(quán)
??1. 初始化數(shù)據(jù)
??2、新增/user/login接口模擬登錄
??3. 調(diào)用登錄接口
三、用戶權(quán)限驗(yàn)證邏輯
??1. 定義接口權(quán)限注解
??2. 注解使用方式
??3. 接口驗(yàn)權(quán)的流程
四、用戶權(quán)限變動(dòng)后的狀態(tài)刷新
五、認(rèn)證失敗或無權(quán)限等異常情況處理
寫在最后
大家好!我是sum墨,一個(gè)一線的底層碼農(nóng),平時(shí)喜歡研究和思考一些技術(shù)相關(guān)的問題并整理成文,限于本人水平,如果文章和代碼有表述不當(dāng)之處,還請(qǐng)不吝賜教。
作為一名從業(yè)已達(dá)六年的老碼農(nóng),我的工作主要是開發(fā)后端Java業(yè)務(wù)系統(tǒng),包括各種管理后臺(tái)和小程序等。在這些項(xiàng)目中,我設(shè)計(jì)過單/多租戶體系系統(tǒng),對(duì)接過許多開放平臺(tái),也搞過消息中心這類較為復(fù)雜的應(yīng)用,但幸運(yùn)的是,我至今還沒有遇到過線上系統(tǒng)由于代碼崩潰導(dǎo)致資損的情況。這其中的原因有三點(diǎn):一是業(yè)務(wù)系統(tǒng)本身并不復(fù)雜;二是我一直遵循某大廠代碼規(guī)約,在開發(fā)過程中盡可能按規(guī)約編寫代碼;三是經(jīng)過多年的開發(fā)經(jīng)驗(yàn)積累,我成為了一名熟練工,掌握了一些實(shí)用的技巧。
我們?cè)谧鱿到y(tǒng)的時(shí)候,只要這個(gè)系統(tǒng)里面存在角色和權(quán)限相關(guān)的業(yè)務(wù)需求,那么接口的權(quán)限控制肯定必不可少。但是大家一搜接口權(quán)限相關(guān)的資料,出來的就是整合Shrio、Spring Security等各種框架,然后下面一頓貼配置和代碼,看得人云里霧里。實(shí)際上接口的權(quán)限控制是整個(gè)系統(tǒng)權(quán)限控制里面很小的一環(huán),沒有設(shè)計(jì)好底層數(shù)據(jù)結(jié)構(gòu),是無法做好接口的權(quán)限控制的。那么怎么做一個(gè)系統(tǒng)的權(quán)限控制呢?我認(rèn)為有以下幾步:
那么接下來我就按這個(gè)流程一一給大家說明權(quán)限是怎么做出來的。(注:只需要SpringBoot和Redis,不需要額外權(quán)限框架。)
本文參考項(xiàng)目源碼地址:summo-springboot-interface-demo
第一,只要一個(gè)系統(tǒng)是給人用的,那么這個(gè)系統(tǒng)就一定會(huì)有一張用戶表;第二,只要有人的地方,就一定會(huì)有角色權(quán)限的劃分,最簡(jiǎn)單的就是超級(jí)管理員、普通用戶;第三,如此常見的設(shè)計(jì),會(huì)有一套相對(duì)規(guī)范的設(shè)計(jì)標(biāo)準(zhǔn)。
而權(quán)限底層表結(jié)構(gòu)設(shè)計(jì)的標(biāo)準(zhǔn)就是:RBAC模型
RBAC(Role-Based Access Control)權(quán)限模型的概念,即:基于角色的權(quán)限控制。通過角色關(guān)聯(lián)用戶,角色關(guān)聯(lián)權(quán)限的方式間接賦予用戶權(quán)限。
回到業(yè)務(wù)需求上來,應(yīng)該是下面這樣的要求:
上圖可以看出,用戶 多對(duì)多 角色 多對(duì)多 權(quán)限
用表結(jié)構(gòu)展示的話就是這樣,一共5張表,3張實(shí)體表,2張關(guān)聯(lián)表
DROP TABLE IF EXISTS `t_user`;
CREATE TABLE `t_user` (
`user_id` bigint(20) unsigned zerofill NOT NULL AUTO_INCREMENT COMMENT '用戶ID',
`user_name` varchar(32) DEFAULT NULL COMMENT '用戶名稱',
`gmt_create` datetime DEFAULT NULL COMMENT '創(chuàng)建時(shí)間',
`gmt_modified` datetime DEFAULT NULL COMMENT '更新時(shí)間',
`creator_id` bigint DEFAULT NULL COMMENT '創(chuàng)建人ID',
`modifier_id` bigint DEFAULT NULL COMMENT '更新人ID',
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ;
DROP TABLE IF EXISTS `t_role`;
CREATE TABLE `t_role` (
`role_id` bigint(20) unsigned zerofill NOT NULL AUTO_INCREMENT COMMENT '角色I(xiàn)D',
`role_name` varchar(32) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT '角色名稱',
`role_code` varchar(32) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT '角色code',
`gmt_create` datetime DEFAULT NULL COMMENT '創(chuàng)建時(shí)間',
`gmt_modified` datetime DEFAULT NULL COMMENT '更新時(shí)間',
`creator_id` bigint DEFAULT NULL COMMENT '創(chuàng)建人ID',
`modifier_id` bigint DEFAULT NULL COMMENT '更新人ID',
PRIMARY KEY (`role_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;
DROP TABLE IF EXISTS `t_auth`;
CREATE TABLE `t_auth` (
`auth_id` bigint(20) unsigned zerofill NOT NULL AUTO_INCREMENT COMMENT '權(quán)限ID',
`auth_code` varchar(32) DEFAULT NULL COMMENT '權(quán)限code',
`auth_name` varchar(32) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT '權(quán)限名稱',
`gmt_create` datetime DEFAULT NULL COMMENT '創(chuàng)建時(shí)間',
`gmt_modified` datetime DEFAULT NULL COMMENT '更新時(shí)間',
`creator_id` bigint DEFAULT NULL COMMENT '創(chuàng)建人ID',
`modifier_id` bigint DEFAULT NULL COMMENT '更新人ID',
PRIMARY KEY (`auth_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;
DROP TABLE IF EXISTS `t_user_role`;
CREATE TABLE `t_user_role` (
`id` bigint(20) unsigned zerofill NOT NULL AUTO_INCREMENT COMMENT '物理ID',
`user_id` bigint NOT NULL COMMENT '用戶ID',
`role_id` bigint NOT NULL COMMENT '角色I(xiàn)D',
`gmt_create` datetime DEFAULT NULL COMMENT '創(chuàng)建時(shí)間',
`gmt_modified` datetime DEFAULT NULL COMMENT '更新時(shí)間',
`creator_id` bigint DEFAULT NULL COMMENT '創(chuàng)建人ID',
`modifier_id` bigint DEFAULT NULL COMMENT '更新人ID',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
DROP TABLE IF EXISTS `t_role_auth`;
CREATE TABLE `t_role_auth` (
`id` bigint(20) unsigned zerofill NOT NULL AUTO_INCREMENT COMMENT '物理ID',
`role_id` bigint DEFAULT NULL COMMENT '角色I(xiàn)D',
`auth_id` bigint DEFAULT NULL COMMENT '權(quán)限ID',
`gmt_create` datetime DEFAULT NULL COMMENT '創(chuàng)建時(shí)間',
`gmt_modified` datetime DEFAULT NULL COMMENT '更新時(shí)間',
`creator_id` bigint DEFAULT NULL COMMENT '創(chuàng)建人ID',
`modifier_id` bigint DEFAULT NULL COMMENT '更新人ID',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
上面已經(jīng)把表設(shè)計(jì)好了,接下來就是代碼開發(fā)了。不過,在開發(fā)之前我們要搞清楚認(rèn)證和授權(quán)這兩個(gè)詞是啥意思。
光看定義也很難懂,這里我舉個(gè)例子配合說明。
現(xiàn)有兩個(gè)用戶:小A和小B;兩個(gè)角色:管理員和普通用戶;4個(gè)操作:新增/刪除/修改/查詢。圖例如下:
那么,對(duì)于小A來說,認(rèn)證就是小A登錄系統(tǒng)后,會(huì)授予管理員的角色,授權(quán)就是授予小A新增/刪除/修改/查詢的權(quán)限;
同理,對(duì)于小B來說,認(rèn)證就是小B登錄系統(tǒng)后,會(huì)授予普通用戶的角色,授權(quán)就是授予小B查詢的權(quán)限。
接下來且看如何實(shí)現(xiàn)
INSERT INTO `t_user` (`user_id`, `user_name`, `gmt_create`, `gmt_modified`, `creator_id`, `modifier_id`) VALUES (1, '小A', '2023-09-21 09:48:14', '2023-09-21 09:48:19', -1, -1);
INSERT INTO `t_user` (`user_id`, `user_name`, `gmt_create`, `gmt_modified`, `creator_id`, `modifier_id`) VALUES (2, '小B', '2023-09-21 09:48:14', '2023-09-21 09:48:19', -1, -1);
INSERT INTO `t_role` (`role_id`, `role_name`, `role_code`, `gmt_create`, `gmt_modified`, `creator_id`, `modifier_id`) VALUES (1, '管理員', 'admin', '2023-09-21 09:52:45', '2023-09-21 09:52:47', -1, -1);
INSERT INTO `t_role` (`role_id`, `role_name`, `role_code`, `gmt_create`, `gmt_modified`, `creator_id`, `modifier_id`) VALUES (2, '普通用戶', 'normal', '2023-09-21 09:52:45', '2023-09-21 09:52:47', -1, -1);
INSERT INTO `t_auth` (`auth_id`, `auth_code`, `auth_name`, `gmt_create`, `gmt_modified`, `creator_id`, `modifier_id`) VALUES (1, 'add', '新增', '2023-09-21 09:52:45', '2023-09-21 09:52:47', -1, -1);
INSERT INTO `t_auth` (`auth_id`, `auth_code`, `auth_name`, `gmt_create`, `gmt_modified`, `creator_id`, `modifier_id`) VALUES (2, 'delete', '刪除', '2023-09-21 09:52:45', '2023-09-21 09:52:47', -1, -1);
INSERT INTO `t_auth` (`auth_id`, `auth_code`, `auth_name`, `gmt_create`, `gmt_modified`, `creator_id`, `modifier_id`) VALUES (3, 'query', '查詢', '2023-09-21 09:52:45', '2023-09-21 09:52:47', -1, -1);
INSERT INTO `t_auth` (`auth_id`, `auth_code`, `auth_name`, `gmt_create`, `gmt_modified`, `creator_id`, `modifier_id`) VALUES (4, 'update', '更新', '2023-09-21 09:52:45', '2023-09-21 09:52:47', -1, -1);
INSERT INTO `t_user_role` (`user_id`, `role_id`, `gmt_create`, `gmt_modified`, `creator_id`, `modifier_id`) VALUES (1, 1, '2023-09-21 09:52:45', '2023-09-21 09:52:47', -1, -1);
INSERT INTO `t_user_role` (`user_id`, `role_id`, `gmt_create`, `gmt_modified`, `creator_id`, `modifier_id`) VALUES (2, 2, '2023-09-21 09:52:45', '2023-09-21 09:52:47', -1, -1);
INSERT INTO `t_role_auth` (`role_id`, `auth_id`, `gmt_create`, `gmt_modified`, `creator_id`, `modifier_id`) VALUES (1, 2, '2023-09-21 09:52:45', '2023-09-21 09:52:47', -1, -1);
INSERT INTO `t_role_auth` (`role_id`, `auth_id`, `gmt_create`, `gmt_modified`, `creator_id`, `modifier_id`) VALUES (1, 1, '2023-09-21 09:52:45', '2023-09-21 09:52:47', -1, -1);
INSERT INTO `t_role_auth` (`role_id`, `auth_id`, `gmt_create`, `gmt_modified`, `creator_id`, `modifier_id`) VALUES (1, 3, '2023-09-21 09:52:45', '2023-09-21 09:52:47', -1, -1);
INSERT INTO `t_role_auth` (`role_id`, `auth_id`, `gmt_create`, `gmt_modified`, `creator_id`, `modifier_id`) VALUES (1, 4, '2023-09-21 09:52:45', '2023-09-21 09:52:47', -1, -1);
INSERT INTO `t_role_auth` (`role_id`, `auth_id`, `gmt_create`, `gmt_modified`, `creator_id`, `modifier_id`) VALUES (2, 3, '2023-09-21 09:52:45', '2023-09-21 09:52:47', -1, -1);
接口代碼如下
@GetMapping("/login")
public ResponseEntity<String> userLogin(@RequestParam(required = true) String userName,
HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse) {
return userService.login(userName, httpServletRequest, httpServletResponse);
}
業(yè)務(wù)代碼如下
@Override
public ResponseEntity<String> login(String userName, HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse) {
//根據(jù)名稱查詢用戶信息
UserDO userDO = userMapper.selectOne(new QueryWrapper<UserDO>().lambda().eq(UserDO::getUserName, userName));
if (Objects.isNull(userDO)) {
return ResponseEntity.ok("未查詢到用戶");
}
//查詢當(dāng)前用戶的角色信息
List<UserRoleDO> userRoleDOList = userRoleMapper.selectList(
new QueryWrapper<UserRoleDO>().lambda().eq(UserRoleDO::getUserId, userDO.getUserId()));
if (CollectionUtils.isEmpty(userRoleDOList)) {
return ResponseEntity.ok("當(dāng)前用戶沒有角色");
}
//查詢當(dāng)前用戶的權(quán)限
List<RoleAuthDO> roleAuthDOS = roleAuthMapper.selectList(new QueryWrapper<RoleAuthDO>().lambda()
.in(RoleAuthDO::getRoleId, userRoleDOList.stream().map(UserRoleDO::getRoleId).collect(
Collectors.toList())));
if (CollectionUtils.isEmpty(roleAuthDOS)) {
return ResponseEntity.ok("當(dāng)前角色沒有對(duì)應(yīng)權(quán)限");
}
//查詢權(quán)限code
List<AuthDO> authDOS = authMapper.selectList(new QueryWrapper<AuthDO>().lambda()
.in(AuthDO::getAuthId, roleAuthDOS.stream().map(RoleAuthDO::getAuthId).collect(
Collectors.toList())));
//生成唯一token
String token = UUID.randomUUID().toString();
//緩存用戶信息
redisUtil.set(token, JSONObject.toJSONString(userDO), tokenTimeout);
//緩存用戶權(quán)限信息
redisUtil.set("auth_" + userDO.getUserId(),
JSONObject.toJSONString(authDOS.stream().map(AuthDO::getAuthCode).collect(Collectors.toList())),
tokenTimeout);
//向localhost中添加Cookie
Cookie cookie = new Cookie("token", token);
cookie.setDomain("localhost");
cookie.setPath("/");
cookie.setMaxAge(tokenTimeout.intValue());
httpServletResponse.addCookie(cookie);
//返回登錄成功
return ResponseEntity.ok(JSONObject.toJSONString(userDO));
}
上面代碼用流程圖表示如下
小A登錄:http://localhost:8080/user/login?userName=小A
小B登錄:http://localhost:8080/user/login?userName=小B
(沒畫前端界面,大家將就看下哈)
通過第二步,用戶已經(jīng)進(jìn)行了認(rèn)證、授權(quán)的操作,那么接下來就是用戶驗(yàn)權(quán):即驗(yàn)證用戶是否有調(diào)用接口的權(quán)限。
前面定義了4個(gè)權(quán)限:新增/刪除/修改/查詢,分別對(duì)應(yīng)著4個(gè)接口。這里我們使用注解進(jìn)行一一對(duì)應(yīng)。
注解定義如下:
RequiresPermissions.java
package com.summo.demo.config.permissions;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresPermissions {
/**
* 權(quán)限列表
* @return
*/
String[] value();
/**
* 權(quán)限控制方式,且或者和
* @return
*/
Logical logical() default Logical.AND;
}
該注解有兩個(gè)屬性,value和logical。value是一個(gè)數(shù)組,代表當(dāng)前接口擁有哪些權(quán)限;logical有兩個(gè)值A(chǔ)ND和OR,AND的意思是當(dāng)前用戶必須要有value中所有的權(quán)限才可以調(diào)用該接口,OR的意思是當(dāng)前用戶只需要有value中任意一個(gè)權(quán)限就可以調(diào)用該接口。
注解處理代碼邏輯如下:
RequiresPermissionsHandler.java
package com.summo.demo.config.permissions;
import java.lang.reflect.Method;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import com.alibaba.fastjson.JSONObject;
import com.summo.demo.config.context.GlobalUserContext;
import com.summo.demo.config.context.UserContext;
import com.summo.demo.config.manager.UserManager;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class RequiresPermissionsHandler {
@Autowired
private UserManager userManager;
@Pointcut("@annotation(com.summo.demo.config.permissions.RequiresPermissions)")
public void pointcut() {
// do nothing
}
@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
//獲取用戶上下文
UserContext userContext = GlobalUserContext.getUserContext();
if (Objects.isNull(userContext)) {
throw new RuntimeException("用戶認(rèn)證失敗,請(qǐng)檢查是否登錄");
}
//獲取注解
MethodSignature signature = (MethodSignature)joinPoint.getSignature();
Method method = signature.getMethod();
RequiresPermissions requiresPermissions = method.getAnnotation(RequiresPermissions.class);
//獲取當(dāng)前接口上數(shù)據(jù)權(quán)限
String[] permissions = requiresPermissions.value();
if (Objects.isNull(permissions) && permissions.length == 0) {
throw new RuntimeException("用戶認(rèn)證失敗,請(qǐng)檢查該接口是否添加了數(shù)據(jù)權(quán)限");
}
//判斷當(dāng)前是and還是or
String[] notHasPermissions;
switch (requiresPermissions.logical()) {
case AND:
//當(dāng)邏輯為and時(shí),所有的數(shù)據(jù)權(quán)限必須存在
notHasPermissions = checkPermissionsByAnd(userContext.getUserId(), permissions);
if (Objects.nonNull(notHasPermissions) && notHasPermissions.length > 0) {
throw new RuntimeException(
MessageFormat.format("用戶權(quán)限不足,缺失以下權(quán)限:[{0}]", JSONObject.toJSONString(notHasPermissions)));
}
break;
case OR:
//當(dāng)邏輯為and時(shí),所有的數(shù)據(jù)權(quán)限必須存在
notHasPermissions = checkPermissionsByOr(userContext.getUserId(), permissions);
if (Objects.nonNull(notHasPermissions) && notHasPermissions.length > 0) {
throw new RuntimeException(
MessageFormat.format("用戶權(quán)限不足,缺失以下權(quán)限:[{0}]", JSONObject.toJSONString(notHasPermissions)));
}
break;
default:
//默認(rèn)為and
}
return joinPoint.proceed();
}
/**
* 當(dāng)數(shù)據(jù)權(quán)限為or時(shí),進(jìn)行判斷
*
* @param userId 用戶ID
* @param permissions 權(quán)限組
* @return 沒有授予的權(quán)限
*/
private String[] checkPermissionsByOr(Long userId, String[] permissions) {
// 獲取用戶權(quán)限集
Set<String> permissionSet = userManager.queryAuthByUserId(userId);
if (permissionSet.isEmpty()) {
return permissions;
}
//一一比對(duì)
List<String> tempPermissions = new ArrayList<>();
for (String permission1 : permissions) {
permissionSet.forEach(permission -> {
if (permission1.equals(permission)) {
tempPermissions.add(permission);
}
});
}
if (Objects.nonNull(tempPermissions) && tempPermissions.size() > 0) {
return null;
}
return permissions;
}
/**
* 當(dāng)數(shù)據(jù)權(quán)限為and時(shí),進(jìn)行判斷
*
* @param userId 用戶ID
* @param permissions 權(quán)限組
* @return 沒有授予的權(quán)限
*/
private String[] checkPermissionsByAnd(Long userId, String[] permissions) {
// 獲取用戶權(quán)限集
Set<String> permissionSet = userManager.queryAuthByUserId(userId);
if (permissionSet.isEmpty()) {
return permissions;
}
//如果permissions大小為1,可以單獨(dú)處理一下
if (permissionSet.size() == 1 && permissionSet.contains(permissions[0])) {
return null;
}
if (permissionSet.size() == 1 && !permissionSet.contains(permissions[0])) {
return permissions;
}
//一一比對(duì)
List<String> tempPermissions = new ArrayList<>();
for (String permission1 : permissions) {
permissionSet.forEach(permission -> {
if (permission1.equals(permission)) {
tempPermissions.add(permission);
}
});
}
//如果tempPermissions的長(zhǎng)度與permissions相同,那么說明權(quán)限吻合
if (permissions.length == tempPermissions.size()) {
return null;
}
//否則取出當(dāng)前用戶沒有的權(quán)限,并返回用作提示
List<String> notHasPermissions = Arrays.stream(permissions).filter(
permission -> !tempPermissions.contains(permission)).collect(Collectors.toList());
return notHasPermissions.toArray(new String[notHasPermissions.size()]);
}
}
使用比較簡(jiǎn)單,直接放到接口的方法上
@GetMapping("/add")
@RequiresPermissions(value = "add", logical = Logical.OR)
public ResponseEntity<String> add(@RequestBody AddReq addReq) {
return userService.add(addReq);
}
@GetMapping("/delete")
@RequiresPermissions(value = "delete", logical = Logical.OR)
public ResponseEntity<String> delete(@RequestParam Long userId) {
return userService.delete(userId);
}
@GetMapping("/query")
@RequiresPermissions(value = "query", logical = Logical.OR)
public ResponseEntity<String> query(@RequestParam String userName) {
return userService.query(userName);
}
@GetMapping("/update")
@RequiresPermissions(value = "update", logical = Logical.OR)
public ResponseEntity<String> update(@RequestBody UpdateReq updateReq) {
return userService.update(updateReq);
}
其實(shí)前面三步完成后,正向流已經(jīng)完成了,但用戶的權(quán)限是變化的,比如:
小B的權(quán)限從查詢變?yōu)榱?/span>查詢加更新
但小B的token還未過期,這時(shí)應(yīng)該怎么辦呢?
還記得登錄的時(shí)候,我有緩存兩個(gè)信息嗎
對(duì)應(yīng)代碼中的
//緩存用戶信息
redisUtil.set(token, JSONObject.toJSONString(userDO), tokenTimeout);
//緩存用戶權(quán)限信息
redisUtil.set("auth_" + userDO.getUserId(),JSONObject.toJSONString(authDOS.stream().map(AuthDO::getAuthCode).collect(Collectors.toList())),tokenTimeout);
在這里我其實(shí)將token和權(quán)限是分開存儲(chǔ)的,token只存用戶信息,而權(quán)限信息用auth_userId為key進(jìn)行存儲(chǔ)的,這樣就可以做到即使token還在,我也能動(dòng)態(tài)修改當(dāng)前用戶的權(quán)限信息了,且權(quán)限實(shí)時(shí)變更不會(huì)影響用戶體驗(yàn)。
不過,這個(gè)地方有一個(gè)爭(zhēng)議的點(diǎn)
用戶權(quán)限發(fā)生變更的時(shí)候,是更新權(quán)限緩存呢?還是直接刪除用戶的權(quán)限緩存呢?
我的建議是:刪除權(quán)限緩存。原因有三
tips:如何優(yōu)雅的實(shí)現(xiàn)“先查詢緩存再查詢數(shù)據(jù)庫?”請(qǐng)看我這篇文章:https://juejin.cn/post/7124885941117779998
出現(xiàn)由于權(quán)限不足或認(rèn)證失敗的問題,常見的做法有重定向到登錄頁、通知用戶刷新界面等,具體怎么處理還要看產(chǎn)品是怎么要求的。
關(guān)于網(wǎng)站的異常有很多,權(quán)限相關(guān)的狀態(tài)碼是401、服務(wù)器錯(cuò)誤的狀態(tài)碼是500,除此之外還會(huì)有自定義的錯(cuò)誤碼,我打算放在接口優(yōu)化系列的后面用專篇說明,敬請(qǐng)期待哦~
《優(yōu)化接口設(shè)計(jì)的思路》系列已結(jié)寫到第四篇了,前面幾篇都沒有總結(jié),在這篇總結(jié)一下吧。
從我開始寫博客到現(xiàn)在已經(jīng)6年了,差不多也寫了將近60篇左右的文章。剛開始的時(shí)候就是寫SpringBoot,寫SpringBoot如何整合Vue,那是2017年。
得益于老大的要求(或者是公司想省錢),剛工作的時(shí)候就是前后端代碼都寫,但是寫的一塌糊涂,甚至連最基礎(chǔ)的項(xiàng)目環(huán)境都搭不好。那時(shí)候在網(wǎng)上找個(gè)pom.xml配置,依賴死活下載不下來,后來才知道m(xù)aven倉庫默認(rèn)國外的源,要把它換成國內(nèi)的才能提高下載速度。那時(shí)候上班就是下午把項(xiàng)目跑起來了,第二天上午項(xiàng)目又啟動(dòng)不了了,如此循環(huán)往復(fù),我的筆記里面存了非常多的配置文件。再后來技術(shù)水平提高了點(diǎn),單項(xiàng)目終于會(huì)玩了,微服務(wù)又火起來了,了解過SpringCloud的小伙伴應(yīng)該知道SpringCloud的版本更復(fù)雜,搭建環(huán)境更難。在這可能有人會(huì)疑惑,你不會(huì)不能去問人嗎?我也很無奈,一則是社恐不敢問,二則是我們部門全是菜鳥,都等著我學(xué)會(huì)教他們呢...
后來我老大說,既然用不來人家的,那就自己寫一套,想起來那時(shí)真單純,我就真的自己開始寫微服務(wù)架構(gòu)。最開始我對(duì)微服務(wù)的唯一印象就是一個(gè)服務(wù)提供者、一個(gè)服務(wù)消費(fèi)者,肯定是兩個(gè)應(yīng)用,至于為啥是這樣,查的百度都是這樣寫的。然后我就建了兩個(gè)應(yīng)用,一個(gè)網(wǎng)關(guān)應(yīng)用、一個(gè)業(yè)務(wù)應(yīng)用,自己寫HttpUtil進(jìn)行服務(wù)間調(diào)用,也不知道啥是注冊(cè)中心,我只知道網(wǎng)關(guān)應(yīng)用那里要有業(yè)務(wù)應(yīng)用的IP地址,否則網(wǎng)關(guān)調(diào)不了業(yè)務(wù)代碼。當(dāng)時(shí)的調(diào)用代碼我已經(jīng)找不了,只記得當(dāng)時(shí)代碼的形狀很像一個(gè)“>”,用了太多的if...else...了!!!
那時(shí)候雖然代碼寫的很爛、bug一堆,但我們老大也沒罵我們,每周四還會(huì)給我們上夜校,跟我們講一些大廠的框架和技術(shù)棧。他跟我們說,現(xiàn)在多用用人家的技術(shù),到時(shí)候出去面試大廠也容易一些。寫博文也是老大讓我們做的,他說現(xiàn)在一點(diǎn)點(diǎn)的積累,等到過幾年就會(huì)變成文庫了。現(xiàn)在想來,真是一個(gè)不錯(cuò)的老大!
現(xiàn)在2023年了,我還在寫代碼,但也不僅僅只是寫代碼,還帶一些項(xiàng)目,獨(dú)立負(fù)責(zé)的也有。要說我現(xiàn)在的代碼水平嘛,屬于那種工廠熟練工水平,八股里面的什么JVM調(diào)優(yōu)啊、高并發(fā)系統(tǒng)架構(gòu)設(shè)計(jì)啊我一次都沒有接觸到過,遠(yuǎn)遠(yuǎn)稱不上大神。不過我還是想寫一些文章,不是為了炫技,只是想把我工作中遇到的問題變成后續(xù)解決問題的經(jīng)驗(yàn),說真的這些文章已經(jīng)開始幫到我了,如果它們也能幫助到你,榮幸之至!
原文鏈接:https://www.cnblogs.com/wlovet/p/17717905.html
由于項(xiàng)目需要,需要在基于 asp.net mvc 的 Web 項(xiàng)目框架中做權(quán)限的控制,于是才有了這個(gè)權(quán)限控制組件,最初只是支持 netframework,后來 dotnetcore 2.0 發(fā)布了之后添加了對(duì) asp.net core 的支持,在 dotnetcore 3.0 發(fā)布之后也增加了對(duì) asp.net core 3.0 的支持(1.9.0及之后版本),目前對(duì)于 asp.net core 支持的更多一些,asp.net core 可以使用 TagHelper 來控制頁面上元素的權(quán)限訪問,也可以通過 Policy 來控制權(quán)限訪問,同時(shí)支持通過中間件也可以實(shí)現(xiàn)對(duì)靜態(tài)資源的訪問。
安裝 nuget 包 WeihanLi.AspNetMvc.AccessControlHelper
Copydotnet add package WeihanLi.AspNetMvc.AccessControlHelper
以下代碼定義了一個(gè)簡(jiǎn)單的訪問策略,需要登錄且擁有 Admin 角色,可以根據(jù)自己需要調(diào)整優(yōu)化
Copypublic class AdminPermissionRequireStrategy : IResourceAccessStrategy
{
private readonly IHttpContextAccessor _accessor;
public AdminPermissionRequireStrategy(IHttpContextAccessor accessor)
{
_accessor = accessor;
}
public bool IsCanAccess(string accessKey)
{
var user = _accessor.HttpContext.User;
return user.Identity.IsAuthenticated && user.IsInRole("Admin");
}
public IActionResult DisallowedCommonResult => new ContentResult
{
Content = "No Permission",
ContentType = "text/plain",
StatusCode = 403
};
public IActionResult DisallowedAjaxResult => new JsonResult(new JsonResultModel
{
ErrorMsg = "No Permission",
Status = JsonResultStatus.NoPermission
});
}
定義頁面元素/控件訪問策略:
Copypublic class AdminOnlyControlAccessStrategy : IControlAccessStrategy
{
private readonly IHttpContextAccessor _accessor;
public AdminOnlyControlAccessStrategy(IHttpContextAccessor httpContextAccessor) => _accessor = httpContextAccessor;
public bool IsControlCanAccess(string accessKey)
{
if ("Never".Equals(accessKey, System.StringComparison.OrdinalIgnoreCase))
{
return false;
}
var user = _accessor.HttpContext.User;
return user.Identity.IsAuthenticated && user.IsInRole("Admin");
}
}
在 Startup 里注冊(cè)服務(wù):
Copyservices.AddAccessControlHelper()
.AddResourceAccessStrategy<AdminPermissionRequireStrategy>()
.AddControlAccessStrategy<AdminOnlyControlAccessStrategy>()
;
如果你只是 web api ,不涉及到頁面元素的權(quán)限控制可以只注冊(cè) ResourceAccessStrategy
Copyservices.AddAccessControlHelper()
.AddResourceAccessStrategy<AdminPermissionRequireStrategy>();
默認(rèn)訪問策略的生命周期是單例的,如果需要注冊(cè)為Scoped,可以指定默認(rèn)的生命周期
Copyservices.AddAccessControlHelper()
.AddResourceAccessStrategy<AdminPermissionRequireStrategy>(ServiceLifetime.Scoped);
對(duì)于 asp.net core 應(yīng)用推薦使用 Policy 來控制權(quán)限的訪問,可以在需要權(quán)限控制的 Action 或者 Controller 上設(shè)置 [Authorize("AccessControl")] 或者 [Authorize(AccessControlHelperConstants.PolicyName)]
Copy[Authorize(AccessControlHelperConstants.PolicyName)]
public class SystemSettingsController : AdminBaseController
{
// ...
}
Copy[Authorize(AccessControlHelperConstants.PolicyName)]
public ActionResult UserList()
{
return View();
}
在 Views 目錄下的 _ViewImports.cshtml 文件中導(dǎo)入 AccessControlHelper 的 TagHelper
Copy@using ActivityReservation
@using WeihanLi.AspNetMvc.AccessControlHelper
@using WeihanLi.AspNetMvc.MvcSimplePager
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, WeihanLi.AspNetMvc.AccessControlHelper
在需要權(quán)限控制的元素上增加 asp-access 的 attribute 就可以了,如果需要 access-key 通過 asp-access-key 來配置
Copy<ul class="list-group" asp-access asp-access-key="AdminOnly">
<li role="separator" class="list-unstyled">
<br />
</li>
<li class="list-group-item">@Html.ActionLink("用戶管理", "UserList", "Account")</li>
<li class="list-group-item">@Html.ActionLink("操作日志查看", "Index", "OperationLog")</li>
<li class="list-group-item">@Html.ActionLink("系統(tǒng)設(shè)置管理", "Index", "SystemSettings")</li>
<li class="list-group-item">
@Html.ActionLink("微信設(shè)置管理", "Index", new {
controller = "Config",
area = "Wechat"
})
</li>
</ul>
這樣就可以了,有權(quán)限訪問的時(shí)候才會(huì)正常渲染,沒有權(quán)限訪問的時(shí)候,這一段 ul 并不會(huì)渲染輸出,在客戶端瀏覽器查看源代碼也不會(huì)看到對(duì)應(yīng)的代碼
整項(xiàng)目地址:vue-element-admin
https://github.com/PanJiaChen/vue-element-admin
拖更有點(diǎn)嚴(yán)重,過了半個(gè)月才寫了第二篇教程。無奈自己是一個(gè)業(yè)務(wù)猿,每天被我司的產(chǎn)品虐的死去活來,之前又病了一下休息了幾天,大家見諒。
進(jìn)入正題,做后臺(tái)項(xiàng)目區(qū)別于做其它的項(xiàng)目,權(quán)限驗(yàn)證與安全性是非常重要的,可以說是一個(gè)后臺(tái)項(xiàng)目一開始就必須考慮和搭建的基礎(chǔ)核心功能。我們所要做到的是:不同的權(quán)限對(duì)應(yīng)著不同的路由,同時(shí)側(cè)邊欄也需根據(jù)不同的權(quán)限,異步生成。這里先簡(jiǎn)單說一下,我實(shí)現(xiàn)登錄和權(quán)限驗(yàn)證的思路。
上述所有的數(shù)據(jù)和操作都是通過vuex全局管理控制的。(補(bǔ)充說明:刷新頁面后 vuex的內(nèi)容也會(huì)丟失,所以需要重復(fù)上述的那些操作)接下來,我們一起手摸手一步一步實(shí)現(xiàn)這個(gè)系統(tǒng)。
首先我們不管什么權(quán)限,來實(shí)現(xiàn)最基礎(chǔ)的登錄功能。
隨便找一個(gè)空白頁面擼上兩個(gè)input的框,一個(gè)是登錄賬號(hào),一個(gè)是登錄密碼。再放置一個(gè)登錄按鈕。我們將登錄按鈕上綁上click事件,點(diǎn)擊登錄之后向服務(wù)端提交賬號(hào)和密碼進(jìn)行驗(yàn)證。 這就是一個(gè)最簡(jiǎn)單的登錄頁面。如果你覺得還要寫的更加完美點(diǎn),你可以在向服務(wù)端提交之前對(duì)賬號(hào)和密碼做一次簡(jiǎn)單的校驗(yàn)。詳細(xì)代碼
click事件觸發(fā)登錄操作:
this.$store.dispatch('LoginByUsername', this.loginForm).then(() => {
this.$router.push({ path: '/' }); //登錄成功之后重定向到首頁
}).catch(err => {
this.$message.error(err); //登錄失敗提示錯(cuò)誤
});
復(fù)制代碼
action:
LoginByUsername({ commit }, userInfo) {
const username = userInfo.username.trim()
return new Promise((resolve, reject) => {
loginByUsername(username, userInfo.password).then(response => {
const data = response.data
Cookies.set('Token', response.data.token) //登錄成功后將token存儲(chǔ)在cookie之中
commit('SET_TOKEN', data.token)
resolve()
}).catch(error => {
reject(error)
});
});
}
復(fù)制代碼
登錄成功后,服務(wù)端會(huì)返回一個(gè) token(該token的是一個(gè)能唯一標(biāo)示用戶身份的一個(gè)key),之后我們將token存儲(chǔ)在本地cookie之中,這樣下次打開頁面或者刷新頁面的時(shí)候能記住用戶的登錄狀態(tài),不用再去登錄頁面重新登錄了。
ps:為了保證安全性,我司現(xiàn)在后臺(tái)所有token有效期(Expires/Max-Age)都是Session,就是當(dāng)瀏覽器關(guān)閉了就丟失了。重新打開瀏覽器都需要重新登錄驗(yàn)證,后端也會(huì)在每周固定一個(gè)時(shí)間點(diǎn)重新刷新token,讓后臺(tái)用戶全部重新登錄一次,確保后臺(tái)用戶不會(huì)因?yàn)殡娔X遺失或者其它原因被人隨意使用賬號(hào)。
用戶登錄成功之后,我們會(huì)在全局鉤子router.beforeEach中攔截路由,判斷是否已獲得token,在獲得token之后我們就要去獲取用戶的基本信息了
//router.beforeEach
if (store.getters.roles.length === 0) { // 判斷當(dāng)前用戶是否已拉取完user_info信息
store.dispatch('GetInfo').then(res => { // 拉取user_info
const roles = res.data.role;
next();//resolve 鉤子
})
復(fù)制代碼
就如前面所說的,我只在本地存儲(chǔ)了一個(gè)用戶的token,并沒有存儲(chǔ)別的用戶信息(如用戶權(quán)限,用戶名,用戶頭像等)。有些人會(huì)問為什么不把一些其它的用戶信息也存一下?主要出于如下的考慮:
假設(shè)我把用戶權(quán)限和用戶名也存在了本地,但我這時(shí)候用另一臺(tái)電腦登錄修改了自己的用戶名,之后再用這臺(tái)存有之前用戶信息的電腦登錄,它默認(rèn)會(huì)去讀取本地 cookie 中的名字,并不會(huì)去拉去新的用戶信息。
所以現(xiàn)在的策略是:頁面會(huì)先從 cookie 中查看是否存有 token,沒有,就走一遍上一部分的流程重新登錄,如果有token,就會(huì)把這個(gè) token 返給后端去拉取user_info,保證用戶信息是最新的。 當(dāng)然如果是做了單點(diǎn)登錄得功能的話,用戶信息存儲(chǔ)在本地也是可以的。當(dāng)你一臺(tái)電腦登錄時(shí),另一臺(tái)會(huì)被提下線,所以總會(huì)重新登錄獲取最新的內(nèi)容。
而且從代碼層面我建議還是把 login和get_user_info兩件事分開比較好,在這個(gè)后端全面微服務(wù)的年代,后端同學(xué)也想寫優(yōu)雅的代碼~
先說一說我權(quán)限控制的主體思路,前端會(huì)有一份路由表,它表示了每一個(gè)路由可訪問的權(quán)限。當(dāng)用戶登錄之后,通過 token 獲取用戶的 role ,動(dòng)態(tài)根據(jù)用戶的 role 算出其對(duì)應(yīng)有權(quán)限的路由,再通過router.addRoutes動(dòng)態(tài)掛載路由。但這些控制都只是頁面級(jí)的,說白了前端再怎么做權(quán)限控制都不是絕對(duì)安全的,后端的權(quán)限驗(yàn)證是逃不掉的。
我司現(xiàn)在就是前端來控制頁面級(jí)的權(quán)限,不同權(quán)限的用戶顯示不同的側(cè)邊欄和限制其所能進(jìn)入的頁面(也做了少許按鈕級(jí)別的權(quán)限控制),后端則會(huì)驗(yàn)證每一個(gè)涉及請(qǐng)求的操作,驗(yàn)證其是否有該操作的權(quán)限,每一個(gè)后臺(tái)的請(qǐng)求不管是 get 還是 post 都會(huì)讓前端在請(qǐng)求 header里面攜帶用戶的 token,后端會(huì)根據(jù)該 token 來驗(yàn)證用戶是否有權(quán)限執(zhí)行該操作。若沒有權(quán)限則拋出一個(gè)對(duì)應(yīng)的狀態(tài)碼,前端檢測(cè)到該狀態(tài)碼,做出相對(duì)應(yīng)的操作。
有很多人表示他們公司的路由表是于后端根據(jù)用戶的權(quán)限動(dòng)態(tài)生成的,我司不采取這種方式的原因如下:
在之前通過后端動(dòng)態(tài)返回前端路由一直很難做的,因?yàn)関ue-router必須是要vue在實(shí)例化之前就掛載上去的,不太方便動(dòng)態(tài)改變。不過好在vue2.2.0以后新增了router.addRoutes
Dynamically add more routes to the router. The argument must be an Array using the same route config format with the routes constructor option.
有了這個(gè)我們就可相對(duì)方便的做權(quán)限控制了。(樓主之前在權(quán)限控制也走了不少歪路,可以在項(xiàng)目的commit記錄中看到,重構(gòu)了很多次,最早沒用addRoute整個(gè)權(quán)限控制代碼里都是各種if/else的邏輯判斷,代碼相當(dāng)?shù)鸟詈虾蛷?fù)雜)
首先我們實(shí)現(xiàn)router.js路由表,這里就拿前端控制路由來舉例(后端存儲(chǔ)的也差不多,稍微改造一下就好了)
// router.js
import Vue from 'vue';
import Router from 'vue-router';
import Login from '../views/login/';
const dashboard = resolve => require(['../views/dashboard/index'], resolve);
//使用了vue-routerd的[Lazy Loading Routes
](https://router.vuejs.org/en/advanced/lazy-loading.html)
//所有權(quán)限通用路由表
//如首頁和登錄頁和一些不用權(quán)限的公用頁面
export const constantRouterMap = [
{ path: '/login', component: Login },
{
path: '/',
component: Layout,
redirect: '/dashboard',
name: '首頁',
children: [{ path: 'dashboard', component: dashboard }]
},
]
//實(shí)例化vue的時(shí)候只掛載constantRouter
export default new Router({
routes: constantRouterMap
});
//異步掛載的路由
//動(dòng)態(tài)需要根據(jù)權(quán)限加載的路由表
export const asyncRouterMap = [
{
path: '/permission',
component: Layout,
name: '權(quán)限測(cè)試',
meta: { role: ['admin','super_editor'] }, //頁面需要的權(quán)限
children: [
{
path: 'index',
component: Permission,
name: '權(quán)限測(cè)試頁',
meta: { role: ['admin','super_editor'] } //頁面需要的權(quán)限
}]
},
{ path: '*', redirect: '/404', hidden: true }
];
復(fù)制代碼
這里我們根據(jù) vue-router官方推薦 的方法通過meta標(biāo)簽來標(biāo)示改頁面能訪問的權(quán)限有哪些。如meta: { role: ['admin','super_editor'] }表示該頁面只有admin和超級(jí)編輯才能有資格進(jìn)入。
注意事項(xiàng):這里有一個(gè)需要非常注意的地方就是 404 頁面一定要最后加載,如果放在constantRouterMap一同聲明了404,后面的所以頁面都會(huì)被攔截到404,詳細(xì)的問題見addRoutes when you've got a wildcard route for 404s does not work
關(guān)鍵的main.js
// main.js
router.beforeEach((to, from, next) => {
if (store.getters.token) { // 判斷是否有token
if (to.path === '/login') {
next({ path: '/' });
} else {
if (store.getters.roles.length === 0) { // 判斷當(dāng)前用戶是否已拉取完user_info信息
store.dispatch('GetInfo').then(res => { // 拉取info
const roles = res.data.role;
store.dispatch('GenerateRoutes', { roles }).then(() => { // 生成可訪問的路由表
router.addRoutes(store.getters.addRouters) // 動(dòng)態(tài)添加可訪問路由表
next({ ...to, replace: true }) // hack方法 確保addRoutes已完成 ,set the replace: true so the navigation will not leave a history record
})
}).catch(err => {
console.log(err);
});
} else {
next() //當(dāng)有用戶權(quán)限的時(shí)候,說明所有可訪問路由已生成 如訪問沒權(quán)限的全面會(huì)自動(dòng)進(jìn)入404頁面
}
}
} else {
if (whiteList.indexOf(to.path) !== -1) { // 在免登錄白名單,直接進(jìn)入
next();
} else {
next('/login'); // 否則全部重定向到登錄頁
}
}
});
復(fù)制代碼
這里的router.beforeEach也結(jié)合了上一章講的一些登錄邏輯代碼。
上面一張圖就是在使用addRoutes方法之前的權(quán)限判斷,非常的繁瑣,因?yàn)槲沂前阉械穆酚啥紥煸诹松先ィ形乙鞣N判斷當(dāng)前的用戶是否有權(quán)限進(jìn)入該頁面,各種if/else的嵌套,維護(hù)起來相當(dāng)?shù)睦щy。但現(xiàn)在有了addRoutes之后就非常的方便,我只掛載了用戶有權(quán)限進(jìn)入的頁面,沒權(quán)限,路由自動(dòng)幫我跳轉(zhuǎn)的404,省去了不少的判斷。
這里還有一個(gè)小hack的地方,就是router.addRoutes之后的next()可能會(huì)失效,因?yàn)榭赡躰ext()的時(shí)候路由并沒有完全add完成,好在查閱文檔發(fā)現(xiàn)
next('/') or next({ path: '/' }): redirect to a different location. The current navigation will be aborted and a new one will be started.
這樣我們就可以簡(jiǎn)單的通過next(to)巧妙的避開之前的那個(gè)問題了。這行代碼重新進(jìn)入router.beforeEach這個(gè)鉤子,這時(shí)候再通過next()來釋放鉤子,就能確保所有的路由都已經(jīng)掛在完成了。
就來就講一講 GenerateRoutes Action
// store/permission.js
import { asyncRouterMap, constantRouterMap } from 'src/router';
function hasPermission(roles, route) {
if (route.meta && route.meta.role) {
return roles.some(role => route.meta.role.indexOf(role) >= 0)
} else {
return true
}
}
const permission = {
state: {
routers: constantRouterMap,
addRouters: []
},
mutations: {
SET_ROUTERS: (state, routers) => {
state.addRouters = routers;
state.routers = constantRouterMap.concat(routers);
}
},
actions: {
GenerateRoutes({ commit }, data) {
return new Promise(resolve => {
const { roles } = data;
const accessedRouters = asyncRouterMap.filter(v => {
if (roles.indexOf('admin') >= 0) return true;
if (hasPermission(roles, v)) {
if (v.children && v.children.length > 0) {
v.children = v.children.filter(child => {
if (hasPermission(roles, child)) {
return child
}
return false;
});
return v
} else {
return v
}
}
return false;
});
commit('SET_ROUTERS', accessedRouters);
resolve();
})
}
}
};
export default permission;
復(fù)制代碼
這里的代碼說白了就是干了一件事,通過用戶的權(quán)限和之前在router.js里面asyncRouterMap的每一個(gè)頁面所需要的權(quán)限做匹配,最后返回一個(gè)該用戶能夠訪問路由有哪些。
最后一個(gè)涉及到權(quán)限的地方就是側(cè)邊欄,不過在前面的基礎(chǔ)上已經(jīng)很方便就能實(shí)現(xiàn)動(dòng)態(tài)顯示側(cè)邊欄了。這里側(cè)邊欄基于element-ui的NavMenu來實(shí)現(xiàn)的。 代碼有點(diǎn)多不貼詳細(xì)的代碼了,有興趣的可以直接去github上看地址,或者直接看關(guān)于側(cè)邊欄的文檔。
說白了就是遍歷之前算出來的permission_routers,通過vuex拿到之后動(dòng)態(tài)v-for渲染而已。不過這里因?yàn)橛幸恍I(yè)務(wù)需求所以加了很多判斷 比如我們?cè)诙x路由的時(shí)候會(huì)加很多參數(shù)
/**
* hidden: true if `hidden:true` will not show in the sidebar(default is false)
* redirect: noredirect if `redirect:noredirect` will no redirct in the breadcrumb
* name:'router-name' the name is used by <keep-alive> (must set!!!)
* meta : {
role: ['admin','editor'] will control the page role (you can set multiple roles)
title: 'title' the name show in submenu and breadcrumb (recommend set)
icon: 'svg-name' the icon show in the sidebar,
noCache: true if fasle ,the page will no be cached(default is false)
}
**/
復(fù)制代碼
這里僅供參考,而且本項(xiàng)目為了支持無限嵌套路由,所有側(cè)邊欄這塊使用了遞歸組件。如需要請(qǐng)大家自行改造,來打造滿足自己業(yè)務(wù)需求的側(cè)邊欄。
側(cè)邊欄高亮問題:很多人在群里問為什么自己的側(cè)邊欄不能跟著自己的路由高亮,其實(shí)很簡(jiǎn)單,element-ui官方已經(jīng)給了default-active所以我們只要
:default-active="$route.path" 將default-active一直指向當(dāng)前路由就可以了,就是這么簡(jiǎn)單
有很多人一直在問關(guān)于按鈕級(jí)別粒度的權(quán)限控制怎么做。我司現(xiàn)在是這樣的,真正需要按鈕級(jí)別控制的地方不是很多,現(xiàn)在是通過獲取到用戶的role之后,在前端用v-if手動(dòng)判斷來區(qū)分不同權(quán)限對(duì)應(yīng)的按鈕的。理由前面也說了,我司顆粒度的權(quán)限判斷是交給后端來做的,每個(gè)操作后端都會(huì)進(jìn)行權(quán)限判斷。而且我覺得其實(shí)前端真正需要按鈕級(jí)別判斷的地方不是很多,如果一個(gè)頁面有很多種不同權(quán)限的按鈕,我覺得更多的應(yīng)該是考慮產(chǎn)品層面是否設(shè)計(jì)合理。當(dāng)然你強(qiáng)行說我想做按鈕級(jí)別的權(quán)限控制,你也可以參照路由層面的做法,搞一個(gè)操作權(quán)限表。。。但個(gè)人覺得有點(diǎn)多此一舉。或者將它封裝成一個(gè)指令都是可以的。
這里再說一說 axios 吧。雖然在上一篇系列文章中簡(jiǎn)單介紹過,不過這里還是要在嘮叨一下。如上文所說,我司服務(wù)端對(duì)每一個(gè)請(qǐng)求都會(huì)驗(yàn)證權(quán)限,所以這里我們針對(duì)業(yè)務(wù)封裝了一下請(qǐng)求。首先我們通過request攔截器在每個(gè)請(qǐng)求頭里面塞入token,好讓后端對(duì)請(qǐng)求進(jìn)行權(quán)限驗(yàn)證。并創(chuàng)建一個(gè)respone攔截器,當(dāng)服務(wù)端返回特殊的狀態(tài)碼,我們統(tǒng)一做處理,如沒權(quán)限或者token失效等操作。
import axios from 'axios'
import { Message } from 'element-ui'
import store from '@/store'
import { getToken } from '@/utils/auth'
// 創(chuàng)建axios實(shí)例
const service = axios.create({
baseURL: process.env.BASE_API, // api的base_url
timeout: 5000 // 請(qǐng)求超時(shí)時(shí)間
})
// request攔截器
service.interceptors.request.use(config => {
// Do something before request is sent
if (store.getters.token) {
config.headers['X-Token'] = getToken() // 讓每個(gè)請(qǐng)求攜帶token--['X-Token']為自定義key 請(qǐng)根據(jù)實(shí)際情況自行修改
}
return config
}, error => {
// Do something with request error
console.log(error) // for debug
Promise.reject(error)
})
// respone攔截器
service.interceptors.response.use(
response => response,
/**
* 下面的注釋為通過response自定義code來標(biāo)示請(qǐng)求狀態(tài),當(dāng)code返回如下情況為權(quán)限有問題,登出并返回到登錄頁
* 如通過xmlhttprequest 狀態(tài)碼標(biāo)識(shí) 邏輯可寫在下面error中
*/
// const res = response.data;
// if (res.code !== 20000) {
// Message({
// message: res.message,
// type: 'error',
// duration: 5 * 1000
// });
// // 50008:非法的token; 50012:其他客戶端登錄了; 50014:Token 過期了;
// if (res.code === 50008 || res.code === 50012 || res.code === 50014) {
// MessageBox.confirm('你已被登出,可以取消繼續(xù)留在該頁面,或者重新登錄', '確定登出', {
// confirmButtonText: '重新登錄',
// cancelButtonText: '取消',
// type: 'warning'
// }).then(() => {
// store.dispatch('FedLogOut').then(() => {
// location.reload();// 為了重新實(shí)例化vue-router對(duì)象 避免bug
// });
// })
// }
// return Promise.reject('error');
// } else {
// return response.data;
// }
error => {
console.log('err' + error)// for debug
Message({
message: error.message,
type: 'error',
duration: 5 * 1000
})
return Promise.reject(error)
})
export default service
復(fù)制代碼
文章一開始也說了,后臺(tái)的安全性是很重要的,簡(jiǎn)簡(jiǎn)單單的一個(gè)賬號(hào)+密碼的方式是很難保證安全性的。所以我司的后臺(tái)項(xiàng)目都是用了兩步驗(yàn)證的方式,之前我們也嘗試過使用基于 google-authenticator 或者youbikey這樣的方式但難度和操作成本都比較大。后來還是準(zhǔn)備借助騰訊爸爸,這年代誰不用微信。。。安全性騰訊爸爸也幫我做好了保障。 樓主建議兩步驗(yàn)證要支持多個(gè)渠道不要只微信或者QQ,前段時(shí)間QQ第三方登錄就出了bug,官方兩三天才修好的,害我背了鍋/(ㄒoㄒ)/~~ 。
這里的兩部驗(yàn)證有點(diǎn)名不副實(shí),其實(shí)就是賬號(hào)密碼驗(yàn)證過之后還需要一個(gè)綁定的第三方平臺(tái)登錄驗(yàn)證而已。 寫起來也很簡(jiǎn)單,在原有登錄得邏輯上改造一下就好。
this.$store.dispatch('LoginByEmail', this.loginForm).then(() => {
//this.$router.push({ path: '/' });
//不重定向到首頁
this.showDialog = true //彈出選擇第三方平臺(tái)的dialog
}).catch(err => {
this.$message.error(err); //登錄失敗提示錯(cuò)誤
});
復(fù)制代碼
登錄成功之后不直接跳到首頁而是讓用戶兩步登錄,選擇登錄得平臺(tái)。 接下來就是所有第三方登錄一樣的地方通過 OAuth2.0 授權(quán)。這個(gè)各大平臺(tái)大同小異,大家自行查閱文檔,不展開了,就說一個(gè)微信授權(quán)比較坑的地方。注意你連參數(shù)的順序都不能換,不然會(huì)驗(yàn)證不通過。具體代碼,同時(shí)我也封裝了openWindow方法大家自行看吧。 當(dāng)?shù)谌绞跈?quán)成功之后都會(huì)跳到一個(gè)你之前有一個(gè)傳入redirect——uri的頁面
如微信還必須是你授權(quán)賬號(hào)的一級(jí)域名。所以你授權(quán)的域名是vue-element-admin.com,你就必須重定向到vue-element-admin.com/xxx/下面,所以你需要寫一個(gè)重定向的服務(wù),如vue-element-admin.com/auth/redirect?a.com 跳到該頁面時(shí)會(huì)再次重定向給a.com。
所以我們后臺(tái)也需要開一個(gè)authredirect頁面:代碼。他的作用是第三方登錄成功之后會(huì)默認(rèn)跳到授權(quán)的頁面,授權(quán)的頁面會(huì)再次重定向回我們的后臺(tái),由于是spa,改變路由的體驗(yàn)不好,我們通過window.opener.location.href的方式改變hash,在login.js里面再監(jiān)聽hash的變化。當(dāng)hash變化時(shí),獲取之前第三方登錄成功返回的code與第一步賬號(hào)密碼登錄之后返回的uid一同發(fā)送給服務(wù)端驗(yàn)證是否正確,如果正確,這時(shí)候就是真正的登錄成功。
created() {
window.addEventListener('hashchange', this.afterQRScan);
},
destroyed() {
window.removeEventListener('hashchange', this.afterQRScan);
},
afterQRScan() {
const hash = window.location.hash.slice(1);
const hashObj = getQueryObject(hash);
const originUrl = window.location.origin;
history.replaceState({}, '', originUrl);
const codeMap = {
wechat: 'code',
tencent: 'code'
};
const codeName = hashObj[codeMap[this.auth_type]];
this.$store.dispatch('LoginByThirdparty', codeName).then(() => {
this.$router.push({
path: '/'
});
});
}
復(fù)制代碼
到這里涉及登錄權(quán)限的東西也差不多講完了,這里樓主只是給了大家一個(gè)實(shí)現(xiàn)的思路(都是樓主不斷摸索的血淚史),每個(gè)公司實(shí)現(xiàn)的方案都有些出入,請(qǐng)謹(jǐn)慎選擇適合自己業(yè)務(wù)形態(tài)的解決方案。如果有什么想法或者建議歡迎去本項(xiàng)目下留言,一同討論。
*請(qǐng)認(rèn)真填寫需求信息,我們會(huì)在24小時(shí)內(nèi)與您取得聯(lián)系。