Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537 Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537
錄
前言
一、權限底層表結構設計
??1. RBAC模型簡介
??2. 建表語句
二、用戶身份認證和授權
??1. 初始化數據
??2、新增/user/login接口模擬登錄
??3. 調用登錄接口
三、用戶權限驗證邏輯
??1. 定義接口權限注解
??2. 注解使用方式
??3. 接口驗權的流程
四、用戶權限變動后的狀態刷新
五、認證失敗或無權限等異常情況處理
寫在最后
大家好!我是sum墨,一個一線的底層碼農,平時喜歡研究和思考一些技術相關的問題并整理成文,限于本人水平,如果文章和代碼有表述不當之處,還請不吝賜教。
作為一名從業已達六年的老碼農,我的工作主要是開發后端Java業務系統,包括各種管理后臺和小程序等。在這些項目中,我設計過單/多租戶體系系統,對接過許多開放平臺,也搞過消息中心這類較為復雜的應用,但幸運的是,我至今還沒有遇到過線上系統由于代碼崩潰導致資損的情況。這其中的原因有三點:一是業務系統本身并不復雜;二是我一直遵循某大廠代碼規約,在開發過程中盡可能按規約編寫代碼;三是經過多年的開發經驗積累,我成為了一名熟練工,掌握了一些實用的技巧。
我們在做系統的時候,只要這個系統里面存在角色和權限相關的業務需求,那么接口的權限控制肯定必不可少。但是大家一搜接口權限相關的資料,出來的就是整合Shrio、Spring Security等各種框架,然后下面一頓貼配置和代碼,看得人云里霧里。實際上接口的權限控制是整個系統權限控制里面很小的一環,沒有設計好底層數據結構,是無法做好接口的權限控制的。那么怎么做一個系統的權限控制呢?我認為有以下幾步:
那么接下來我就按這個流程一一給大家說明權限是怎么做出來的。(注:只需要SpringBoot和Redis,不需要額外權限框架。)
本文參考項目源碼地址:summo-springboot-interface-demo
第一,只要一個系統是給人用的,那么這個系統就一定會有一張用戶表;第二,只要有人的地方,就一定會有角色權限的劃分,最簡單的就是超級管理員、普通用戶;第三,如此常見的設計,會有一套相對規范的設計標準。
而權限底層表結構設計的標準就是:RBAC模型
RBAC(Role-Based Access Control)權限模型的概念,即:基于角色的權限控制。通過角色關聯用戶,角色關聯權限的方式間接賦予用戶權限。
回到業務需求上來,應該是下面這樣的要求:
上圖可以看出,用戶 多對多 角色 多對多 權限
用表結構展示的話就是這樣,一共5張表,3張實體表,2張關聯表
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 '創建時間',
`gmt_modified` datetime DEFAULT NULL COMMENT '更新時間',
`creator_id` bigint DEFAULT NULL COMMENT '創建人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 '角色ID',
`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 '創建時間',
`gmt_modified` datetime DEFAULT NULL COMMENT '更新時間',
`creator_id` bigint DEFAULT NULL COMMENT '創建人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 '權限ID',
`auth_code` varchar(32) DEFAULT NULL COMMENT '權限code',
`auth_name` varchar(32) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT '權限名稱',
`gmt_create` datetime DEFAULT NULL COMMENT '創建時間',
`gmt_modified` datetime DEFAULT NULL COMMENT '更新時間',
`creator_id` bigint DEFAULT NULL COMMENT '創建人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 '角色ID',
`gmt_create` datetime DEFAULT NULL COMMENT '創建時間',
`gmt_modified` datetime DEFAULT NULL COMMENT '更新時間',
`creator_id` bigint DEFAULT NULL COMMENT '創建人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 '角色ID',
`auth_id` bigint DEFAULT NULL COMMENT '權限ID',
`gmt_create` datetime DEFAULT NULL COMMENT '創建時間',
`gmt_modified` datetime DEFAULT NULL COMMENT '更新時間',
`creator_id` bigint DEFAULT NULL COMMENT '創建人ID',
`modifier_id` bigint DEFAULT NULL COMMENT '更新人ID',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
上面已經把表設計好了,接下來就是代碼開發了。不過,在開發之前我們要搞清楚認證和授權這兩個詞是啥意思。
光看定義也很難懂,這里我舉個例子配合說明。
現有兩個用戶:小A和小B;兩個角色:管理員和普通用戶;4個操作:新增/刪除/修改/查詢。圖例如下:
那么,對于小A來說,認證就是小A登錄系統后,會授予管理員的角色,授權就是授予小A新增/刪除/修改/查詢的權限;
同理,對于小B來說,認證就是小B登錄系統后,會授予普通用戶的角色,授權就是授予小B查詢的權限。
接下來且看如何實現
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);
}
業務代碼如下
@Override
public ResponseEntity<String> login(String userName, HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse) {
//根據名稱查詢用戶信息
UserDO userDO = userMapper.selectOne(new QueryWrapper<UserDO>().lambda().eq(UserDO::getUserName, userName));
if (Objects.isNull(userDO)) {
return ResponseEntity.ok("未查詢到用戶");
}
//查詢當前用戶的角色信息
List<UserRoleDO> userRoleDOList = userRoleMapper.selectList(
new QueryWrapper<UserRoleDO>().lambda().eq(UserRoleDO::getUserId, userDO.getUserId()));
if (CollectionUtils.isEmpty(userRoleDOList)) {
return ResponseEntity.ok("當前用戶沒有角色");
}
//查詢當前用戶的權限
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("當前角色沒有對應權限");
}
//查詢權限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);
//緩存用戶權限信息
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
(沒畫前端界面,大家將就看下哈)
通過第二步,用戶已經進行了認證、授權的操作,那么接下來就是用戶驗權:即驗證用戶是否有調用接口的權限。
前面定義了4個權限:新增/刪除/修改/查詢,分別對應著4個接口。這里我們使用注解進行一一對應。
注解定義如下:
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 {
/**
* 權限列表
* @return
*/
String[] value();
/**
* 權限控制方式,且或者和
* @return
*/
Logical logical() default Logical.AND;
}
該注解有兩個屬性,value和logical。value是一個數組,代表當前接口擁有哪些權限;logical有兩個值AND和OR,AND的意思是當前用戶必須要有value中所有的權限才可以調用該接口,OR的意思是當前用戶只需要有value中任意一個權限就可以調用該接口。
注解處理代碼邏輯如下:
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("用戶認證失敗,請檢查是否登錄");
}
//獲取注解
MethodSignature signature = (MethodSignature)joinPoint.getSignature();
Method method = signature.getMethod();
RequiresPermissions requiresPermissions = method.getAnnotation(RequiresPermissions.class);
//獲取當前接口上數據權限
String[] permissions = requiresPermissions.value();
if (Objects.isNull(permissions) && permissions.length == 0) {
throw new RuntimeException("用戶認證失敗,請檢查該接口是否添加了數據權限");
}
//判斷當前是and還是or
String[] notHasPermissions;
switch (requiresPermissions.logical()) {
case AND:
//當邏輯為and時,所有的數據權限必須存在
notHasPermissions = checkPermissionsByAnd(userContext.getUserId(), permissions);
if (Objects.nonNull(notHasPermissions) && notHasPermissions.length > 0) {
throw new RuntimeException(
MessageFormat.format("用戶權限不足,缺失以下權限:[{0}]", JSONObject.toJSONString(notHasPermissions)));
}
break;
case OR:
//當邏輯為and時,所有的數據權限必須存在
notHasPermissions = checkPermissionsByOr(userContext.getUserId(), permissions);
if (Objects.nonNull(notHasPermissions) && notHasPermissions.length > 0) {
throw new RuntimeException(
MessageFormat.format("用戶權限不足,缺失以下權限:[{0}]", JSONObject.toJSONString(notHasPermissions)));
}
break;
default:
//默認為and
}
return joinPoint.proceed();
}
/**
* 當數據權限為or時,進行判斷
*
* @param userId 用戶ID
* @param permissions 權限組
* @return 沒有授予的權限
*/
private String[] checkPermissionsByOr(Long userId, String[] permissions) {
// 獲取用戶權限集
Set<String> permissionSet = userManager.queryAuthByUserId(userId);
if (permissionSet.isEmpty()) {
return permissions;
}
//一一比對
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;
}
/**
* 當數據權限為and時,進行判斷
*
* @param userId 用戶ID
* @param permissions 權限組
* @return 沒有授予的權限
*/
private String[] checkPermissionsByAnd(Long userId, String[] permissions) {
// 獲取用戶權限集
Set<String> permissionSet = userManager.queryAuthByUserId(userId);
if (permissionSet.isEmpty()) {
return permissions;
}
//如果permissions大小為1,可以單獨處理一下
if (permissionSet.size() == 1 && permissionSet.contains(permissions[0])) {
return null;
}
if (permissionSet.size() == 1 && !permissionSet.contains(permissions[0])) {
return permissions;
}
//一一比對
List<String> tempPermissions = new ArrayList<>();
for (String permission1 : permissions) {
permissionSet.forEach(permission -> {
if (permission1.equals(permission)) {
tempPermissions.add(permission);
}
});
}
//如果tempPermissions的長度與permissions相同,那么說明權限吻合
if (permissions.length == tempPermissions.size()) {
return null;
}
//否則取出當前用戶沒有的權限,并返回用作提示
List<String> notHasPermissions = Arrays.stream(permissions).filter(
permission -> !tempPermissions.contains(permission)).collect(Collectors.toList());
return notHasPermissions.toArray(new String[notHasPermissions.size()]);
}
}
使用比較簡單,直接放到接口的方法上
@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);
}
其實前面三步完成后,正向流已經完成了,但用戶的權限是變化的,比如:
小B的權限從查詢變為了查詢加更新
但小B的token還未過期,這時應該怎么辦呢?
還記得登錄的時候,我有緩存兩個信息嗎
對應代碼中的
//緩存用戶信息
redisUtil.set(token, JSONObject.toJSONString(userDO), tokenTimeout);
//緩存用戶權限信息
redisUtil.set("auth_" + userDO.getUserId(),JSONObject.toJSONString(authDOS.stream().map(AuthDO::getAuthCode).collect(Collectors.toList())),tokenTimeout);
在這里我其實將token和權限是分開存儲的,token只存用戶信息,而權限信息用auth_userId為key進行存儲的,這樣就可以做到即使token還在,我也能動態修改當前用戶的權限信息了,且權限實時變更不會影響用戶體驗。
不過,這個地方有一個爭議的點
用戶權限發生變更的時候,是更新權限緩存呢?還是直接刪除用戶的權限緩存呢?
我的建議是:刪除權限緩存。原因有三
tips:如何優雅的實現“先查詢緩存再查詢數據庫?”請看我這篇文章:https://juejin.cn/post/7124885941117779998
出現由于權限不足或認證失敗的問題,常見的做法有重定向到登錄頁、通知用戶刷新界面等,具體怎么處理還要看產品是怎么要求的。
關于網站的異常有很多,權限相關的狀態碼是401、服務器錯誤的狀態碼是500,除此之外還會有自定義的錯誤碼,我打算放在接口優化系列的后面用專篇說明,敬請期待哦~
《優化接口設計的思路》系列已結寫到第四篇了,前面幾篇都沒有總結,在這篇總結一下吧。
從我開始寫博客到現在已經6年了,差不多也寫了將近60篇左右的文章。剛開始的時候就是寫SpringBoot,寫SpringBoot如何整合Vue,那是2017年。
得益于老大的要求(或者是公司想省錢),剛工作的時候就是前后端代碼都寫,但是寫的一塌糊涂,甚至連最基礎的項目環境都搭不好。那時候在網上找個pom.xml配置,依賴死活下載不下來,后來才知道maven倉庫默認國外的源,要把它換成國內的才能提高下載速度。那時候上班就是下午把項目跑起來了,第二天上午項目又啟動不了了,如此循環往復,我的筆記里面存了非常多的配置文件。再后來技術水平提高了點,單項目終于會玩了,微服務又火起來了,了解過SpringCloud的小伙伴應該知道SpringCloud的版本更復雜,搭建環境更難。在這可能有人會疑惑,你不會不能去問人嗎?我也很無奈,一則是社恐不敢問,二則是我們部門全是菜鳥,都等著我學會教他們呢...
后來我老大說,既然用不來人家的,那就自己寫一套,想起來那時真單純,我就真的自己開始寫微服務架構。最開始我對微服務的唯一印象就是一個服務提供者、一個服務消費者,肯定是兩個應用,至于為啥是這樣,查的百度都是這樣寫的。然后我就建了兩個應用,一個網關應用、一個業務應用,自己寫HttpUtil進行服務間調用,也不知道啥是注冊中心,我只知道網關應用那里要有業務應用的IP地址,否則網關調不了業務代碼。當時的調用代碼我已經找不了,只記得當時代碼的形狀很像一個“>”,用了太多的if...else...了!??!
那時候雖然代碼寫的很爛、bug一堆,但我們老大也沒罵我們,每周四還會給我們上夜校,跟我們講一些大廠的框架和技術棧。他跟我們說,現在多用用人家的技術,到時候出去面試大廠也容易一些。寫博文也是老大讓我們做的,他說現在一點點的積累,等到過幾年就會變成文庫了?,F在想來,真是一個不錯的老大!
現在2023年了,我還在寫代碼,但也不僅僅只是寫代碼,還帶一些項目,獨立負責的也有。要說我現在的代碼水平嘛,屬于那種工廠熟練工水平,八股里面的什么JVM調優啊、高并發系統架構設計啊我一次都沒有接觸到過,遠遠稱不上大神。不過我還是想寫一些文章,不是為了炫技,只是想把我工作中遇到的問題變成后續解決問題的經驗,說真的這些文章已經開始幫到我了,如果它們也能幫助到你,榮幸之至!
原文鏈接:https://www.cnblogs.com/wlovet/p/17717905.html
由于項目需要,需要在基于 asp.net mvc 的 Web 項目框架中做權限的控制,于是才有了這個權限控制組件,最初只是支持 netframework,后來 dotnetcore 2.0 發布了之后添加了對 asp.net core 的支持,在 dotnetcore 3.0 發布之后也增加了對 asp.net core 3.0 的支持(1.9.0及之后版本),目前對于 asp.net core 支持的更多一些,asp.net core 可以使用 TagHelper 來控制頁面上元素的權限訪問,也可以通過 Policy 來控制權限訪問,同時支持通過中間件也可以實現對靜態資源的訪問。
安裝 nuget 包 WeihanLi.AspNetMvc.AccessControlHelper
Copydotnet add package WeihanLi.AspNetMvc.AccessControlHelper
以下代碼定義了一個簡單的訪問策略,需要登錄且擁有 Admin 角色,可以根據自己需要調整優化
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 里注冊服務:
Copyservices.AddAccessControlHelper()
.AddResourceAccessStrategy<AdminPermissionRequireStrategy>()
.AddControlAccessStrategy<AdminOnlyControlAccessStrategy>()
;
如果你只是 web api ,不涉及到頁面元素的權限控制可以只注冊 ResourceAccessStrategy
Copyservices.AddAccessControlHelper()
.AddResourceAccessStrategy<AdminPermissionRequireStrategy>();
默認訪問策略的生命周期是單例的,如果需要注冊為Scoped,可以指定默認的生命周期
Copyservices.AddAccessControlHelper()
.AddResourceAccessStrategy<AdminPermissionRequireStrategy>(ServiceLifetime.Scoped);
對于 asp.net core 應用推薦使用 Policy 來控制權限的訪問,可以在需要權限控制的 Action 或者 Controller 上設置 [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 文件中導入 AccessControlHelper 的 TagHelper
Copy@using ActivityReservation
@using WeihanLi.AspNetMvc.AccessControlHelper
@using WeihanLi.AspNetMvc.MvcSimplePager
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, WeihanLi.AspNetMvc.AccessControlHelper
在需要權限控制的元素上增加 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("系統設置管理", "Index", "SystemSettings")</li>
<li class="list-group-item">
@Html.ActionLink("微信設置管理", "Index", new {
controller = "Config",
area = "Wechat"
})
</li>
</ul>
這樣就可以了,有權限訪問的時候才會正常渲染,沒有權限訪問的時候,這一段 ul 并不會渲染輸出,在客戶端瀏覽器查看源代碼也不會看到對應的代碼
整項目地址:vue-element-admin
https://github.com/PanJiaChen/vue-element-admin
拖更有點嚴重,過了半個月才寫了第二篇教程。無奈自己是一個業務猿,每天被我司的產品虐的死去活來,之前又病了一下休息了幾天,大家見諒。
進入正題,做后臺項目區別于做其它的項目,權限驗證與安全性是非常重要的,可以說是一個后臺項目一開始就必須考慮和搭建的基礎核心功能。我們所要做到的是:不同的權限對應著不同的路由,同時側邊欄也需根據不同的權限,異步生成。這里先簡單說一下,我實現登錄和權限驗證的思路。
上述所有的數據和操作都是通過vuex全局管理控制的。(補充說明:刷新頁面后 vuex的內容也會丟失,所以需要重復上述的那些操作)接下來,我們一起手摸手一步一步實現這個系統。
首先我們不管什么權限,來實現最基礎的登錄功能。
隨便找一個空白頁面擼上兩個input的框,一個是登錄賬號,一個是登錄密碼。再放置一個登錄按鈕。我們將登錄按鈕上綁上click事件,點擊登錄之后向服務端提交賬號和密碼進行驗證。 這就是一個最簡單的登錄頁面。如果你覺得還要寫的更加完美點,你可以在向服務端提交之前對賬號和密碼做一次簡單的校驗。詳細代碼
click事件觸發登錄操作:
this.$store.dispatch('LoginByUsername', this.loginForm).then(() => {
this.$router.push({ path: '/' }); //登錄成功之后重定向到首頁
}).catch(err => {
this.$message.error(err); //登錄失敗提示錯誤
});
復制代碼
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存儲在cookie之中
commit('SET_TOKEN', data.token)
resolve()
}).catch(error => {
reject(error)
});
});
}
復制代碼
登錄成功后,服務端會返回一個 token(該token的是一個能唯一標示用戶身份的一個key),之后我們將token存儲在本地cookie之中,這樣下次打開頁面或者刷新頁面的時候能記住用戶的登錄狀態,不用再去登錄頁面重新登錄了。
ps:為了保證安全性,我司現在后臺所有token有效期(Expires/Max-Age)都是Session,就是當瀏覽器關閉了就丟失了。重新打開瀏覽器都需要重新登錄驗證,后端也會在每周固定一個時間點重新刷新token,讓后臺用戶全部重新登錄一次,確保后臺用戶不會因為電腦遺失或者其它原因被人隨意使用賬號。
用戶登錄成功之后,我們會在全局鉤子router.beforeEach中攔截路由,判斷是否已獲得token,在獲得token之后我們就要去獲取用戶的基本信息了
//router.beforeEach
if (store.getters.roles.length === 0) { // 判斷當前用戶是否已拉取完user_info信息
store.dispatch('GetInfo').then(res => { // 拉取user_info
const roles = res.data.role;
next();//resolve 鉤子
})
復制代碼
就如前面所說的,我只在本地存儲了一個用戶的token,并沒有存儲別的用戶信息(如用戶權限,用戶名,用戶頭像等)。有些人會問為什么不把一些其它的用戶信息也存一下?主要出于如下的考慮:
假設我把用戶權限和用戶名也存在了本地,但我這時候用另一臺電腦登錄修改了自己的用戶名,之后再用這臺存有之前用戶信息的電腦登錄,它默認會去讀取本地 cookie 中的名字,并不會去拉去新的用戶信息。
所以現在的策略是:頁面會先從 cookie 中查看是否存有 token,沒有,就走一遍上一部分的流程重新登錄,如果有token,就會把這個 token 返給后端去拉取user_info,保證用戶信息是最新的。 當然如果是做了單點登錄得功能的話,用戶信息存儲在本地也是可以的。當你一臺電腦登錄時,另一臺會被提下線,所以總會重新登錄獲取最新的內容。
而且從代碼層面我建議還是把 login和get_user_info兩件事分開比較好,在這個后端全面微服務的年代,后端同學也想寫優雅的代碼~
先說一說我權限控制的主體思路,前端會有一份路由表,它表示了每一個路由可訪問的權限。當用戶登錄之后,通過 token 獲取用戶的 role ,動態根據用戶的 role 算出其對應有權限的路由,再通過router.addRoutes動態掛載路由。但這些控制都只是頁面級的,說白了前端再怎么做權限控制都不是絕對安全的,后端的權限驗證是逃不掉的。
我司現在就是前端來控制頁面級的權限,不同權限的用戶顯示不同的側邊欄和限制其所能進入的頁面(也做了少許按鈕級別的權限控制),后端則會驗證每一個涉及請求的操作,驗證其是否有該操作的權限,每一個后臺的請求不管是 get 還是 post 都會讓前端在請求 header里面攜帶用戶的 token,后端會根據該 token 來驗證用戶是否有權限執行該操作。若沒有權限則拋出一個對應的狀態碼,前端檢測到該狀態碼,做出相對應的操作。
有很多人表示他們公司的路由表是于后端根據用戶的權限動態生成的,我司不采取這種方式的原因如下:
在之前通過后端動態返回前端路由一直很難做的,因為vue-router必須是要vue在實例化之前就掛載上去的,不太方便動態改變。不過好在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.
有了這個我們就可相對方便的做權限控制了。(樓主之前在權限控制也走了不少歪路,可以在項目的commit記錄中看到,重構了很多次,最早沒用addRoute整個權限控制代碼里都是各種if/else的邏輯判斷,代碼相當的耦合和復雜)
首先我們實現router.js路由表,這里就拿前端控制路由來舉例(后端存儲的也差不多,稍微改造一下就好了)
// 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)
//所有權限通用路由表
//如首頁和登錄頁和一些不用權限的公用頁面
export const constantRouterMap = [
{ path: '/login', component: Login },
{
path: '/',
component: Layout,
redirect: '/dashboard',
name: '首頁',
children: [{ path: 'dashboard', component: dashboard }]
},
]
//實例化vue的時候只掛載constantRouter
export default new Router({
routes: constantRouterMap
});
//異步掛載的路由
//動態需要根據權限加載的路由表
export const asyncRouterMap = [
{
path: '/permission',
component: Layout,
name: '權限測試',
meta: { role: ['admin','super_editor'] }, //頁面需要的權限
children: [
{
path: 'index',
component: Permission,
name: '權限測試頁',
meta: { role: ['admin','super_editor'] } //頁面需要的權限
}]
},
{ path: '*', redirect: '/404', hidden: true }
];
復制代碼
這里我們根據 vue-router官方推薦 的方法通過meta標簽來標示改頁面能訪問的權限有哪些。如meta: { role: ['admin','super_editor'] }表示該頁面只有admin和超級編輯才能有資格進入。
注意事項:這里有一個需要非常注意的地方就是 404 頁面一定要最后加載,如果放在constantRouterMap一同聲明了404,后面的所以頁面都會被攔截到404,詳細的問題見addRoutes when you've got a wildcard route for 404s does not work
關鍵的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) { // 判斷當前用戶是否已拉取完user_info信息
store.dispatch('GetInfo').then(res => { // 拉取info
const roles = res.data.role;
store.dispatch('GenerateRoutes', { roles }).then(() => { // 生成可訪問的路由表
router.addRoutes(store.getters.addRouters) // 動態添加可訪問路由表
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() //當有用戶權限的時候,說明所有可訪問路由已生成 如訪問沒權限的全面會自動進入404頁面
}
}
} else {
if (whiteList.indexOf(to.path) !== -1) { // 在免登錄白名單,直接進入
next();
} else {
next('/login'); // 否則全部重定向到登錄頁
}
}
});
復制代碼
這里的router.beforeEach也結合了上一章講的一些登錄邏輯代碼。
上面一張圖就是在使用addRoutes方法之前的權限判斷,非常的繁瑣,因為我是把所有的路由都掛在了上去,所有我要各種判斷當前的用戶是否有權限進入該頁面,各種if/else的嵌套,維護起來相當的困難。但現在有了addRoutes之后就非常的方便,我只掛載了用戶有權限進入的頁面,沒權限,路由自動幫我跳轉的404,省去了不少的判斷。
這里還有一個小hack的地方,就是router.addRoutes之后的next()可能會失效,因為可能next()的時候路由并沒有完全add完成,好在查閱文檔發現
next('/') or next({ path: '/' }): redirect to a different location. The current navigation will be aborted and a new one will be started.
這樣我們就可以簡單的通過next(to)巧妙的避開之前的那個問題了。這行代碼重新進入router.beforeEach這個鉤子,這時候再通過next()來釋放鉤子,就能確保所有的路由都已經掛在完成了。
就來就講一講 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;
復制代碼
這里的代碼說白了就是干了一件事,通過用戶的權限和之前在router.js里面asyncRouterMap的每一個頁面所需要的權限做匹配,最后返回一個該用戶能夠訪問路由有哪些。
最后一個涉及到權限的地方就是側邊欄,不過在前面的基礎上已經很方便就能實現動態顯示側邊欄了。這里側邊欄基于element-ui的NavMenu來實現的。 代碼有點多不貼詳細的代碼了,有興趣的可以直接去github上看地址,或者直接看關于側邊欄的文檔。
說白了就是遍歷之前算出來的permission_routers,通過vuex拿到之后動態v-for渲染而已。不過這里因為有一些業務需求所以加了很多判斷 比如我們在定義路由的時候會加很多參數
/**
* 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)
}
**/
復制代碼
這里僅供參考,而且本項目為了支持無限嵌套路由,所有側邊欄這塊使用了遞歸組件。如需要請大家自行改造,來打造滿足自己業務需求的側邊欄。
側邊欄高亮問題:很多人在群里問為什么自己的側邊欄不能跟著自己的路由高亮,其實很簡單,element-ui官方已經給了default-active所以我們只要
:default-active="$route.path" 將default-active一直指向當前路由就可以了,就是這么簡單
有很多人一直在問關于按鈕級別粒度的權限控制怎么做。我司現在是這樣的,真正需要按鈕級別控制的地方不是很多,現在是通過獲取到用戶的role之后,在前端用v-if手動判斷來區分不同權限對應的按鈕的。理由前面也說了,我司顆粒度的權限判斷是交給后端來做的,每個操作后端都會進行權限判斷。而且我覺得其實前端真正需要按鈕級別判斷的地方不是很多,如果一個頁面有很多種不同權限的按鈕,我覺得更多的應該是考慮產品層面是否設計合理。當然你強行說我想做按鈕級別的權限控制,你也可以參照路由層面的做法,搞一個操作權限表。。。但個人覺得有點多此一舉?;蛘邔⑺庋b成一個指令都是可以的。
這里再說一說 axios 吧。雖然在上一篇系列文章中簡單介紹過,不過這里還是要在嘮叨一下。如上文所說,我司服務端對每一個請求都會驗證權限,所以這里我們針對業務封裝了一下請求。首先我們通過request攔截器在每個請求頭里面塞入token,好讓后端對請求進行權限驗證。并創建一個respone攔截器,當服務端返回特殊的狀態碼,我們統一做處理,如沒權限或者token失效等操作。
import axios from 'axios'
import { Message } from 'element-ui'
import store from '@/store'
import { getToken } from '@/utils/auth'
// 創建axios實例
const service = axios.create({
baseURL: process.env.BASE_API, // api的base_url
timeout: 5000 // 請求超時時間
})
// request攔截器
service.interceptors.request.use(config => {
// Do something before request is sent
if (store.getters.token) {
config.headers['X-Token'] = getToken() // 讓每個請求攜帶token--['X-Token']為自定義key 請根據實際情況自行修改
}
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來標示請求狀態,當code返回如下情況為權限有問題,登出并返回到登錄頁
* 如通過xmlhttprequest 狀態碼標識 邏輯可寫在下面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('你已被登出,可以取消繼續留在該頁面,或者重新登錄', '確定登出', {
// confirmButtonText: '重新登錄',
// cancelButtonText: '取消',
// type: 'warning'
// }).then(() => {
// store.dispatch('FedLogOut').then(() => {
// location.reload();// 為了重新實例化vue-router對象 避免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
復制代碼
文章一開始也說了,后臺的安全性是很重要的,簡簡單單的一個賬號+密碼的方式是很難保證安全性的。所以我司的后臺項目都是用了兩步驗證的方式,之前我們也嘗試過使用基于 google-authenticator 或者youbikey這樣的方式但難度和操作成本都比較大。后來還是準備借助騰訊爸爸,這年代誰不用微信。。。安全性騰訊爸爸也幫我做好了保障。 樓主建議兩步驗證要支持多個渠道不要只微信或者QQ,前段時間QQ第三方登錄就出了bug,官方兩三天才修好的,害我背了鍋/(ㄒoㄒ)/~~ 。
這里的兩部驗證有點名不副實,其實就是賬號密碼驗證過之后還需要一個綁定的第三方平臺登錄驗證而已。 寫起來也很簡單,在原有登錄得邏輯上改造一下就好。
this.$store.dispatch('LoginByEmail', this.loginForm).then(() => {
//this.$router.push({ path: '/' });
//不重定向到首頁
this.showDialog = true //彈出選擇第三方平臺的dialog
}).catch(err => {
this.$message.error(err); //登錄失敗提示錯誤
});
復制代碼
登錄成功之后不直接跳到首頁而是讓用戶兩步登錄,選擇登錄得平臺。 接下來就是所有第三方登錄一樣的地方通過 OAuth2.0 授權。這個各大平臺大同小異,大家自行查閱文檔,不展開了,就說一個微信授權比較坑的地方。注意你連參數的順序都不能換,不然會驗證不通過。具體代碼,同時我也封裝了openWindow方法大家自行看吧。 當第三方授權成功之后都會跳到一個你之前有一個傳入redirect——uri的頁面
如微信還必須是你授權賬號的一級域名。所以你授權的域名是vue-element-admin.com,你就必須重定向到vue-element-admin.com/xxx/下面,所以你需要寫一個重定向的服務,如vue-element-admin.com/auth/redirect?a.com 跳到該頁面時會再次重定向給a.com。
所以我們后臺也需要開一個authredirect頁面:代碼。他的作用是第三方登錄成功之后會默認跳到授權的頁面,授權的頁面會再次重定向回我們的后臺,由于是spa,改變路由的體驗不好,我們通過window.opener.location.href的方式改變hash,在login.js里面再監聽hash的變化。當hash變化時,獲取之前第三方登錄成功返回的code與第一步賬號密碼登錄之后返回的uid一同發送給服務端驗證是否正確,如果正確,這時候就是真正的登錄成功。
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: '/'
});
});
}
復制代碼
到這里涉及登錄權限的東西也差不多講完了,這里樓主只是給了大家一個實現的思路(都是樓主不斷摸索的血淚史),每個公司實現的方案都有些出入,請謹慎選擇適合自己業務形態的解決方案。如果有什么想法或者建議歡迎去本項目下留言,一同討論。
*請認真填寫需求信息,我們會在24小時內與您取得聯系。