SpringCloud實戰:Eureka+Gateway+Redis 自定義註解實現分佈式 細粒度權限管理
文章目錄
前言
無論是單體項目還是微服務項目,只要是涉及到用戶登錄的系統,大多都要有相應的權限控制機制來限制用戶的訪問、操作。而我們有時還需要去控制第三方系統接入的賬號權限,這就給我們的權限管理帶來了點麻煩。本文主要介紹如何設計一套基於SpringCloud 微服務框架下的 細粒度(菜單、接口、按鈕)權限管理機制。
鑑權流程設計
由於在微服務下,我們都是通過網關統一入口進行各個服務的接口訪問。所以鑑權這部分放在Gateway的過濾器內,各個服務需要把自己的接口及權限信息暴露給Gateway 鑑權流程如下
自定義Starter實現:服務自動推送接口及自定義註解權限信息到Redis的功能
自定義註解:VerifyPermission
package com.yxh.www.apiscan.annotation;
import java.lang.annotation.*;
/**
* 權限標識校驗註解
* </p>
*
* @author yangxiaohui
* @since 2020/5/9
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface VerifyPermission {
/**
* 接口名稱
*/
String name() default "";
/**
* 權限標識集合
*/
String[] keys() default {};
}
定義ServerApi
、ServerApiKey
存放接口信息
package com.yxh.www.apiscan.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
/**
* <p>
* 接口信息
* </p>
*
* @author yangxiaohui
* @since 2020/5/9
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class ServerApi implements Serializable {
private static final long serialVersionUID = 1L;
/**
* PK
*/
private String id;
/**
* 接口名稱
*/
private String apiName;
/**
* 接口地址
*/
private String apiPath;
/**
* 接口地址Hash
*/
private String apiHash;
/**
* 接口-服務ID
*/
private String serverId;
/**
* 接口-服務名
*/
private String serverName;
/**
* 權限標識識別
*/
private String permissionKeys;
/**
* 接口類型
*/
private String apiType;
}
package com.yxh.www.apiscan.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import sun.plugin2.message.Serializer;
import java.io.Serializable;
/**
* <p>
* 存放Api版本及Key
* </p>
*
* @author yangxiaohui
* @since 2020/5/12
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class ServerApiKey implements Serializable {
private static final long serialVersionUID = 1L;
private String apiHash;
private String apiKey;
public ServerApiKey(String apiHash, String apiKey) {
this.apiHash = apiHash;
this.apiKey = apiKey;
}
}
接口掃描配置類
ServerApiScanProperties
實現自定義Starter配置信息的獲取
ServerApiScanConfig
實現項目接口掃描,根據自定義掃描路徑進行接口、權限的推送保存
package com.yxh.www.apiscan.properties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* <p>
* 服務接口掃描 - 配置類
* </p>
*
* @author yangxiaohui
* @since 2020/5/14
*/
@SuppressWarnings("ConfigurationProperties")
@ConfigurationProperties(prefix = "server.api")
public class ServerApiScanProperties {
/**
* 掃描路徑
*/
private String scanPackage = "com.yxh.www.**.controller";
/**
* 服務名
*/
@Value("${spring.application.name}")
private String serverId;
/**
* 是否開啓接口掃描推送功能
*/
private Boolean enable=false;
public String getScanPackage() {
return scanPackage;
}
public void setScanPackage(String scanPackage) {
this.scanPackage = scanPackage;
}
public String getServerId() {
return serverId;
}
public void setServerId(String serverId) {
this.serverId = serverId;
}
}
package com.yxh.www.apiscan.conf;
import com.alibaba.fastjson.JSONObject;
import com.yxh.www.apiscan.annotation.VerifyPermission;
import com.yxh.www.apiscan.entity.ServerApi;
import com.yxh.www.apiscan.entity.ServerApiKey;
import com.yxh.www.apiscan.properties.ServerApiScanProperties;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import java.util.regex.Pattern;
/**
* <p>
*
* </p>
*
* @author yangxiaohui
* @since 2020/5/9
*/
@SuppressWarnings({"all"})
@Slf4j
@Configuration
@EnableConfigurationProperties(ServerApiScanProperties.class)
@ConditionalOnProperty(
prefix = "server.api",
name = "enable",
havingValue = "true"
)
public class ServerApiScanConfig {
private final ServerApiScanProperties serverApiScanProperties;
public ServerApiScanConfig(ServerApiScanProperties serverApiScanProperties) {
this.serverApiScanProperties = serverApiScanProperties;
}
@Bean
public List<ServerApi> getServerApis(WebApplicationContext applicationContext, RedisTemplate<String, Object> redisTemplate) {
List<ServerApi> serverApis = new ArrayList<>();
String apisKey="APIS_"+serverApiScanProperties.getServerId();
RequestMappingHandlerMapping mapping = applicationContext.getBean(RequestMappingHandlerMapping.class);
// 獲取url與類和方法的對應信息
Map<RequestMappingInfo, HandlerMethod> map = mapping.getHandlerMethods();
// API版本
TreeSet<String> apiVersionSet=new TreeSet<>();
for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : map.entrySet()) {
// 包路徑
String classPackage = entry.getValue().getBeanType().getName();
// 判斷路徑是否需要加載該接口
if (Pattern.matches(this.buildRegexPackage(), classPackage)) {
// 構建實體
ServerApi serverApi = new ServerApi();
// 方法唯一標識,用來生成Hash判斷是否被更新
StringBuffer methodKey=new StringBuffer();
// 方法
Method method = entry.getValue().getMethod();
// 獲取方法請求類型
Object[] methodTypes = entry.getKey().getMethodsCondition().getMethods().toArray();
// 獲取方法路徑
Object[] methodPaths = entry.getKey().getPatternsCondition().getPatterns().toArray();
methodKey.append(serverApiScanProperties.getServerId() + methodPaths[0].toString()+methodTypes[0].toString());
// 判斷是否包含權限標識註解
if (method.isAnnotationPresent(VerifyPermission.class)) {
VerifyPermission verifyPermission = method.getAnnotation(VerifyPermission.class);
if (null != verifyPermission && verifyPermission.keys().length > 0) {
serverApi.setPermissionKeys(StringUtils.join(verifyPermission.keys(), ","));
methodKey.append(StringUtils.join(verifyPermission.keys()));
}
if (StringUtils.isNotBlank(verifyPermission.name())){
methodKey.append(verifyPermission.name());
}
}
serverApi.setApiName(methodPaths[0].toString());
serverApi.setApiPath("/" + serverApiScanProperties.getServerId() + methodPaths[0].toString());
serverApi.setApiType(methodTypes[0].toString());
serverApi.setServerId(serverApiScanProperties.getServerId());
serverApi.setServerName(serverApiScanProperties.getServerId());
serverApis.add(serverApi);
apiVersionSet.add(methodKey.toString());
log.info("服務API:{} ", JSONObject.toJSONString(serverApi));
}
}
// 放入Redis數據
if (serverApis.size()>0) {
// 刪除舊數據
redisTemplate.delete(apisKey);
// 放入新數據
redisTemplate.opsForList().leftPushAll(apisKey, serverApis.toArray());
// 把Key放入集合,表示Api發生了更改
ServerApiKey serverApiKey=new ServerApiKey(DigestUtils.md5Hex(apiVersionSet.toString()),apisKey);
redisTemplate.opsForList().rightPush("ServerApiKeys",serverApiKey);
}
return serverApis;
}
private String buildRegexPackage() {
return serverApiScanProperties.getScanPackage().replace("**", "[\\w]*") + ".[\\w]*";
}
}
授權服務獲取所有服務接口並更新接口信息
package com.yxh.www.author.conf;
import com.yxh.www.apiscan.entity.ServerApi;
import com.yxh.www.apiscan.entity.ServerApiKey;
import com.yxh.www.author.domain.SmServerApi;
import com.yxh.www.author.service.SmServerApiService;
import com.yxh.www.redis.client.RedisListService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* <p>
* API 接口信息更新
* </p>
*
* @author yangxiaohui
* @since 2020/5/11
*/
@Slf4j
@Component
@EnableScheduling
@SuppressWarnings("all")
public class ServerUpdateConfig {
private final RedisListService redisListService;
private final RedisTemplate<String,Object> redisTemplate;
private final SmServerApiService smServerApiService;
private Set<String> apiHashSet=new HashSet<>();
public ServerUpdateConfig(List<ServerApi> serverApis,RedisListService redisListService, RedisTemplate<String, Object> redisTemplate, SmServerApiService smServerApiService) {
this.redisListService = redisListService;
this.redisTemplate = redisTemplate;
this.smServerApiService = smServerApiService;
this.initApiHashSet();
this.updateServerApis();
smServerApiService.updateServerApiToRedis(redisTemplate);
}
@Scheduled(cron = "0 */1 * * * ?")
public void updateServerApis(){
// 是否有待加載的API集合
long serverApiKeyCount=redisListService.count("ServerApiKeys");
if (serverApiKeyCount>0){
log.info("監聽到新的Api集合,開始加載...");
for (long i=0;i<serverApiKeyCount;i++){
ServerApiKey serverApiKey=redisListService.popListRight("ServerApiKeys");
log.info("加載服務API:{}",serverApiKey);
if (!apiHashSet.contains(serverApiKey.getApiHash())){
// 持久化接口數據
List<ServerApi> serverApiList =redisListService.list(serverApiKey.getApiKey());
if (serverApiList.size()>0){
// 獲取ServerID
String serverId=serverApiKey.getApiKey().split("_")[1];
smServerApiService.updateSmServerApiByServerId(serverId,this.buildSmServerApi(serverApiList,serverApiKey.getApiHash()));
// 更新Redis數據
smServerApiService.updateServerApiToRedis(redisTemplate);
}
this.apiHashSet.add(serverApiKey.getApiHash());
}else {
log.info("服務API無變更:{}",serverApiKey.getApiKey());
}
}
log.info("監聽到新的Api集合,加載完畢...");
}
}
/**
* 初始化 ApiHash集合
*/
private void initApiHashSet(){
Set<String> dbApiHashs=smServerApiService.listApiHash();
if (dbApiHashs!=null && dbApiHashs.size()>0){
apiHashSet.addAll(dbApiHashs);
}
}
private List<SmServerApi> buildSmServerApi(List<ServerApi> serverApiList,String apiHash){
LocalDateTime nowTime=LocalDateTime.now();
return new ArrayList<SmServerApi>(){{
for (ServerApi serverApi:serverApiList){
add(new SmServerApi(
DigestUtils.md5Hex(serverApi.getApiPath()),
serverApi.getApiName(),
serverApi.getApiPath(),
apiHash,
serverApi.getServerId(),
serverApi.getServerName(),
nowTime,
serverApi.getPermissionKeys(),
serverApi.getApiType()
));
}
}};
}
}
網關自定義過濾器攔截請求並校驗權限
package com.yxh.www.gateway.filter;
import com.alibaba.fastjson.JSONObject;
import com.yxh.www.author.dto.SmAccountToken;
import com.yxh.www.author.dto.SmUserToken;
import com.yxh.www.common.constant.Constant;
import com.yxh.www.common.result.ResultBuilder;
import com.yxh.www.common.result.ResultEnum;
import com.yxh.www.common.util.JwtTokenUtil;
import com.yxh.www.redis.util.RedisResObjectUtil;
import com.sun.org.apache.regexp.internal.RE;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.BoundHashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.regex.Pattern;
/**
* <p>
* 校驗請求Token過濾器
* </p>
*
* @author yangxiaohui
* @since 2020/5/8
*/
@Slf4j
@Component
@RefreshScope
public class VerifyRequestTokenFilter implements GlobalFilter, Ordered {
@Value("${system.request.ignore-token.urls}")
private List<String> ignoreTokenUrls;
private final RedisTemplate<String, Object> redisTemplate;
private Map<String, List<String>> pathValueServerApi = new ConcurrentHashMap<>();
private Map<String, List<String>> serverApi = new ConcurrentHashMap<>();
public VerifyRequestTokenFilter(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
this.reloadPathValueServerApis();
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String url = exchange.getRequest().getURI().getPath();
// 跳過swagger相關路徑
if (url.contains("api-docs") || url.contains("swagger") || url.contains("doc.html")) {
return chain.filter(exchange);
}
// 跳過不需要驗證的路徑
if (null != ignoreTokenUrls && ignoreTokenUrls.contains(url)) {
return chain.filter(exchange);
}
return this.verifyRequest(exchange, chain);
}
private Mono<Void> verifyRequest(ServerWebExchange exchange, GatewayFilterChain chain) {
// 響應體
ServerHttpResponse resp = exchange.getResponse();
// 獲取客戶端類型
String clientType = exchange.getRequest().getHeaders().getFirst(Constant.DEFAULT_REQUEST_CLIENT_TYPE_KEY);
// 獲取Token
String token = exchange.getRequest().getHeaders().getFirst(Constant.DEFAULT_REQUEST_AUTHORIZATION_KEY);
if (StringUtils.isBlank(token)) {
// Token爲空
return authErro(resp, ResultEnum.AUTHORIZATION_INVALID, "請求無效!");
}
// 解析Token
try {
// 根據Client解析Token
return this.parseTokenByClientType(token, clientType, exchange, chain);
} catch (Exception e) {
if (e instanceof MalformedJwtException) {
log.error("非法Token:", e);
return authErro(resp, ResultEnum.AUTHORIZATION_INVALID, "Token非法!");
} else if (e instanceof ExpiredJwtException) {
log.error("Token已過期:", e);
return authErro(resp, ResultEnum.AUTHORIZATION_INVALID, "Token已過期!");
}
log.error("JWT解析失敗:", e);
return authErro(resp, ResultEnum.AUTHORIZATION_INVALID, "Token解析失敗!");
}
}
private Mono<Void> parseTokenByClientType(String token, String clientType, ServerWebExchange exchange, GatewayFilterChain chain) throws Exception {
// 響應體
ServerHttpResponse resp = exchange.getResponse();
String url = exchange.getRequest().getURI().getPath();
// 解析Token
Claims claims = JwtTokenUtil.parseJWT(token);
log.info("Jwt解析成功:{}", claims);
Consumer<HttpHeaders> httpHeaders;
// Header放入必要參數
if (StringUtils.isBlank(clientType)) {
// 匹配內存內的 Url 權限標識
List<String> permissionKeys = this.getPermissionKeysByUrl(url);
// 如果內存內不存在,重新加載Redis匹配一次
if (permissionKeys == null || permissionKeys.isEmpty()) {
permissionKeys = this.reloadGetPermissionKeysByUrl(url);
}
// 判斷是否存在權限標識 如果該接口不存在權限標識就直接放行
if (permissionKeys != null && !permissionKeys.isEmpty()) {
// 獲取並 校驗用戶權限
Object userObject = redisTemplate.opsForValue().get(Constant.REDIS_USER_TOKEN_KEY_PREFIX + token);
if (userObject == null) {
return authErro(resp, ResultEnum.AUTHORIZATION_INVALID, "Token已過期!");
}
SmUserToken smUserToken = (SmUserToken) userObject;
// 校驗權限標識
if (smUserToken.getPermissions() != null && !smUserToken.getPermissions().isEmpty()) {
if (!smUserToken.getPermissions().containsAll(permissionKeys)) {
return authErro(resp, ResultEnum.PERMISSION_DENIED, "無權訪問!");
}
}
}
httpHeaders = this.buildSmUserHeader(claims);
} else if ("SDK".equals(clientType)) {
// 校驗請求域
String requestSourceHostIp=exchange.getRequest().getRemoteAddress().getHostString();
String requestSourceHostName=exchange.getRequest().getRemoteAddress().getHostName();
// 校驗請求接口權限
Object accountTokenObject = redisTemplate.opsForValue().get(Constant.REDIS_ACCOUNT_TOKEN_KEY + token);
if (accountTokenObject == null) {
return authErro(resp, ResultEnum.AUTHORIZATION_INVALID, "Token已過期!");
}
SmAccountToken smAccountToken = (SmAccountToken) accountTokenObject;
// 請求域
if(!smAccountToken.getAccountScopeIps().contains(requestSourceHostIp)&&!smAccountToken.getAccountScopeIps().contains(requestSourceHostName)){
return authErro(resp, ResultEnum.PERMISSION_DENIED, "CORS 請求被拒絕: 無效的域!");
}
if (!this.verifyAccountPermission(smAccountToken, url)) {
return authErro(resp, ResultEnum.PERMISSION_DENIED, "無權訪問!");
}
httpHeaders = this.buildSmAccountHeader(claims);
} else {
return authErro(resp, ResultEnum.AUTHORIZATION_INVALID, "無效的ClientType!");
}
// 修改RequestHeader 放入請求標識
ServerHttpRequest serverHttpRequest = exchange.getRequest().mutate().headers(httpHeaders).build();
exchange.mutate().request(serverHttpRequest).build();
return chain.filter(exchange);
}
/**
* 校驗第三方賬戶接口權限
*/
private boolean verifyAccountPermission(SmAccountToken smAccountToken, String requestUrl) {
// 校驗常規接口
if (smAccountToken.getApiPath().contains(requestUrl)) {
return true;
}
// 校驗PathValue接口
for (String pathValueUrl : smAccountToken.getPathValueApiPath()) {
if (Pattern.matches(this.buildPathValueApiRegx(pathValueUrl), requestUrl)) {
return true;
}
}
return false;
}
/**
* 匹配Url獲取該Url需要的權限標識
*/
private List<String> getPermissionKeysByUrl(String url) {
// 常規Url校驗
if (serverApi.containsKey(url)) {
return serverApi.get(url);
}
return this.getPermissionKeysForPathValueApiByUrl(url);
}
/**
* 重新加載Api資源,並匹配Url需要的權限標識
*/
private List<String> reloadGetPermissionKeysByUrl(String url) {
this.reloadPathValueServerApis();
return this.getPermissionKeysByUrl(url);
}
/**
* 從PathValueServerApi裏查找匹配Url
*/
private List<String> getPermissionKeysForPathValueApiByUrl(String url) {
if (this.pathValueServerApi != null && !this.pathValueServerApi.isEmpty()) {
for (Map.Entry<String, List<String>> entry : this.pathValueServerApi.entrySet()) {
if (Pattern.matches(this.buildPathValueApiRegx(entry.getKey()), url)) {
return entry.getValue();
}
}
}
return new ArrayList<>();
}
private String buildPathValueApiRegx(String pathValueApiPath) {
return Pattern.compile("\\{[\\w]*}").matcher(pathValueApiPath).replaceAll("[\\w]*");
}
/**
* 生成用戶的TokenHeader
*/
private Consumer<HttpHeaders> buildSmUserHeader(Claims claims) {
return httpHeader -> {
httpHeader.set(Constant.DEFAULT_REQUEST_TOKEN_USER_ID_KEY, claims.get("id").toString());
httpHeader.set(Constant.DEFAULT_REQUEST_TOKEN_USER_NAME_KEY, claims.get("userName").toString());
httpHeader.set(Constant.DEFAULT_REQUEST_TOKEN_USER_PHONE_KEY, claims.get("userPhone").toString());
};
}
/**
* 生成第三方賬戶的TokenHeader
*/
private Consumer<HttpHeaders> buildSmAccountHeader(Claims claims) {
return httpHeader -> {
httpHeader.set(Constant.DEFAULT_REQUEST_TOKEN_ACCOUNT_ID_KEY, claims.get("id").toString());
httpHeader.set(Constant.DEFAULT_REQUEST_TOKEN_ACCOUNT_NAME_KEY, claims.get("accountName").toString());
httpHeader.set(Constant.DEFAULT_REQUEST_TOKEN_ACCOUNT_SECRET_KEY_KEY, claims.get("secretKey").toString());
};
}
/**
* 構造授權失敗信息
*/
private Mono<Void> authErro(ServerHttpResponse resp, ResultEnum resultEnum, String mess) {
resp.setStatusCode(HttpStatus.OK);
resp.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
// 構造錯誤信息
String returnStr = JSONObject.toJSONString(ResultBuilder.error(resultEnum, mess));
DataBuffer buffer = resp.bufferFactory().wrap(returnStr.getBytes(StandardCharsets.UTF_8));
return resp.writeWith(Flux.just(buffer));
}
private void reloadPathValueServerApis() {
BoundHashOperations<String, String, List<String>> boundHashPathValueServerApiOperations = redisTemplate.boundHashOps(Constant.REDIS_SM_SERVER_PATH_VALUE_API_KEY);
BoundHashOperations<String, String, List<String>> boundHashServerApiOperations = redisTemplate.boundHashOps(Constant.REDIS_SM_SERVER_API_KEY);
Map<String, List<String>> pathValueApiMap = boundHashPathValueServerApiOperations.entries();
Map<String, List<String>> serverApiMap = boundHashServerApiOperations.entries();
if (pathValueApiMap != null && !pathValueApiMap.isEmpty()) {
this.pathValueServerApi.putAll(pathValueApiMap);
}
if (serverApiMap != null && !serverApiMap.isEmpty()) {
this.serverApi.putAll(serverApiMap);
}
}
@Override
public int getOrder() {
// 第二個執行這個過濾器
return -2147483647;
}
}
分析權限控制對象
權限控制模型,針對業務劃分成:用戶、第三方賬戶、角色、菜單(包括目錄、按鈕)、接口、組織幾個實體,相互關聯關係如下:
用戶和第三方賬戶、組織、角色關聯
此處第三方賬戶爲用戶的其他項目接入的賬號,類似SDK接入的概念、故用戶可能有多個第三方應用,所以用戶對應第三方賬號是一對多關係。
組織有且只有一個負責人,所以在組織表有一個負責人用戶ID進行部門主管(組織負責人)的綁定,組織成員和角色用戶關係在同一張關係表進行綁定,以objectType
字段進行區分。如圖
第三方賬號接口權限關聯
因爲第三方賬戶不會有菜單權限,也不會有角色信息。所以第三方賬戶和接口直接通過一個多對多關係表進行綁定管理 如圖:
角色與菜單、按鈕關聯
用戶通過角色可獲取自己擁有的菜單、按鈕、權限標識資源。而菜單、按鈕資源可以定義自己的權限標識,然後通過權限標識和接口進行關聯(在項目接口內進行自定義權限的註解,聲明可訪問的權限標,如果沒有自定義註解,默認不參與權限驗證)。所以也可以通過獲取用戶權限標識進而篩選控制用戶的接口權限 如圖: