目录
1.RBAC权限控制模型
2.数据表设计
3.权限控制微服务
4.springcloud gateway filter
5.实现效果
1.RBAC权限控制模型
RBAC(Role Based Access Control,基于角色的访问控制),就是用户通过角色与权限进行关联,而不是直接将权限赋予用户。
一个用户拥有若干个角色,每个角色拥有若干个权限,这样就构成了“用户-角色-权限”的授权模型。这种授权模型的好处在于,不必每次创建用户时都进行权限分配的操作,只要分配用户相应的角色即可,而且角色的权限变更比用户的权限变更要少得多,减少频繁设置。
RBAC模型中,用户与角色之间、角色与权限之间,一般是多对多的关系。比如用户a可以同时拥有admin角色和user角色,user角色也可以同时被用户a及用户b拥有。
RBAC通过定义角色的权限,并对用户授予某个角色从而来控制用户的权限,实现了用户和权限的逻辑分离。简单来说就是,权限是属于角色的,想要获得某个权限,则用户必须先获得某个角色,通过角色操纵对应权限下的数据资源。
RBAC支持三个著名的安全原则:
- 最小权限原则:RBAC可以将角色配置成其完成任务所需的最小权限集合;
- 责任分离原则:可以通过调用相互独立互斥的角色来共同完成敏感的任务;
- 数据抽象原则:可以通过权限的抽象来体现。
RBAC模型的缺点是没有提供操作顺序的控制机制,这以缺陷使得RBAC模型很难 适应那些对操作顺序有严格要求的系统。、
下面对RBAC-0模型进行简单实现。
2.数据表设计
权限管理顾名思义,就是表与表之间的关系。所以必须要有三张表(用户表)(角色表)(数据资源表)
用户表:存储用户账号相关信息
角色表:存储角色相关信息
数据资源表:存储访问的api路由相关信息
其中用户表和角色表之间应建立映射关系:
表中存储用户账号和角色之间的关系,比如 :
此处只是简单应用,固一个账号只存放了一个角色,实践上一个账户应可存放多个角色,写成list形式以varchar格式存入数据库。
同时角色表和资源表之间也应建立映射关系:
至此,一个简单的RBAC权限模型下的数据库设计完毕。
3.权限控制微服务
然后,我们需要编写权限控制微服务,为角色分配权限和根据权限查询其拥有的权限:
@Service
@Slf4j
public class PermissionServiceImpl extends ServiceImpl<PermissionDao, RolesPermission> implements PermissionService {
@Resource
private PermissionDao permissionDao;
@Override
public boolean permissionAdd(String roles, List<String> codeList) {
RolesPermission rolesPermission = new RolesPermission();
rolesPermission.setRole(roles);
int result = 0;
RolesPermission role = permissionDao.selectOne(new QueryWrapper<RolesPermission>().eq("role",roles));
if(role!=null){
//去掉头尾括号,并转为列表
List<String> list = new java.util.ArrayList<>(Collections.singletonList(
StringUtils.strip(role.getPermissionCode(), "[]")));
//将新数据添加至列表
list.addAll(codeList);
rolesPermission.setPermissionCode(list.toString());
rolesPermission.setId(role.getId());
result = permissionDao.updateById(rolesPermission);
}else {
rolesPermission.setPermissionCode(codeList.toString());
result = permissionDao.insert(rolesPermission);
}
return result > 0;
}
}
上述代码以mybatis-plus框架实现,具体不进行展开。上述代码为为角色添加权限方法,方法中,会首先根据角色名称查询数据库是否有权限记录:有,则查出已拥有的权限列表,并将新添加的数据资源code列表存入,更新数据库;无,则添加一条新纪录。
同时,在该微服务控制类(controller)中编写获取路由api相关接口:
@GetMapping("/get")
public List<String> pathGet(@RequestParam("roles") String roles) {
RolesPermission permission = permissionService.getOne(new QueryWrapper<RolesPermission>().eq("role",roles));
String codes = StringUtils.strip(permission.getPermissionCode(), "[]");
List<String> list = Arrays.asList(codes.split(","));
List<String> pathList = new ArrayList<>();
for(String code:list){
String api = pathService.getOne(new QueryWrapper<PathPermission>().eq("permission_code",code.trim())).getPath();
pathList.add(api);
}
return pathList;
}
通过角色名称,即可返回该角色下所有的可访问api路由。
4.springcloud gateway filter
此处springcloud gateway需是集成了JWT token下的统一网关,可参考上一篇文章。
同时,token中需存储了用户角色相关信息,相关token生成工具类参考如下:
@Component
public class JwtTokenUtil {
private static final String JWT_CACHE_KEY = "jwt:userId:";
private static final String USER_ID = "userId";
private static final String USER_NAME = "username";
private static final String ACCESS_TOKEN = "access_token";
private static final String REFRESH_TOKEN = "refresh_token";
private static final String EXPIRE_IN = "expire_in";
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private AuthJwtProperties jwtProperties;
/**
* 生成 token 令牌主方法
* @param userId 用户Id或用户名
* @return 令token牌
*/
public Map<String, Object> generateTokenAndRefreshToken(String userId, String username, String roles) {
//生成令牌及刷新令牌
Map<String, Object> tokenMap = buildToken(userId, username, roles);
//redis缓存结果
cacheToken(userId, tokenMap);
return tokenMap;
}
//将token缓存进redis
private void cacheToken(String userId, Map<String, Object> tokenMap) {
stringRedisTemplate.opsForHash().put(JWT_CACHE_KEY + userId, ACCESS_TOKEN, tokenMap.get(ACCESS_TOKEN));
stringRedisTemplate.opsForHash().put(JWT_CACHE_KEY + userId, REFRESH_TOKEN, tokenMap.get(REFRESH_TOKEN));
stringRedisTemplate.expire(userId, jwtProperties.getExpiration() * 2, TimeUnit.MILLISECONDS);
}
//生成令牌
private Map<String, Object> buildToken(String userId, String username, String roles) {
Map<String, String> map = new HashMap<>();
map.put("roles",roles);
//生成token令牌
String accessToken = generateToken(userId, username, map);
//生成刷新令牌
String refreshToken = generateRefreshToken(userId, username, null);
//存储两个令牌及过期时间,返回结果
HashMap<String, Object> tokenMap = new HashMap<>(2);
tokenMap.put(ACCESS_TOKEN, accessToken);
tokenMap.put(REFRESH_TOKEN, refreshToken);
tokenMap.put(EXPIRE_IN, jwtProperties.getExpiration());
return tokenMap;
}
/**
* 生成 token 令牌 及 refresh token 令牌
* @param payloads 令牌中携带的附加信息
* @return 令牌
*/
public String generateToken(String userId, String username,
Map<String,String> payloads) {
Map<String, Object> claims = buildClaims(userId, username, payloads);;
return generateToken(claims);
}
public String generateRefreshToken(String userId, String username, Map<String,String> payloads) {
Map<String, Object> claims = buildClaims(userId, username, payloads);
return generateRefreshToken(claims);
}
//构建map存储令牌需携带的信息
private Map<String, Object> buildClaims(String userId, String username, Map<String, String> payloads) {
int payloadSizes = payloads == null? 0 : payloads.size();
Map<String, Object> claims = new HashMap<>(payloadSizes + 2);
if(payloadSizes!=0){
claims.put("roles", payloads.get("roles"));
}
claims.put("sub", userId);
claims.put("username", username);
claims.put("created", new Date());
if(payloadSizes > 0){
claims.putAll(payloads);
}
return claims;
}
/**
* 刷新令牌并生成新令牌
* 并将新结果缓存进redis
*/
public Map<String, Object> refreshTokenAndGenerateToken(String userId, String username,String roles) {
Map<String, Object> tokenMap = buildToken(userId, username,roles);
stringRedisTemplate.delete(JWT_CACHE_KEY + userId);
cacheToken(userId, tokenMap);
return tokenMap;
}
//缓存中删除token
public boolean removeToken(String userId) {
return Boolean.TRUE.equals(stringRedisTemplate.delete(JWT_CACHE_KEY + userId));
}
/**
* 判断令牌是否不存在 redis 中
*
* @param token 刷新令牌
* @return true=不存在,false=存在
*/
public Boolean isRefreshTokenNotExistCache(String token) {
String userId = getUserIdFromToken(token);
String refreshToken = (String)stringRedisTemplate.opsForHash().get(JWT_CACHE_KEY + userId, REFRESH_TOKEN);
return refreshToken == null || !refreshToken.equals(token);
}
/**
* 判断令牌是否过期
*
* @param token 令牌
* @return true=已过期,false=未过期
*/
public Boolean isTokenExpired(String token) {
try {
Claims claims = getClaimsFromToken(token);
Date expiration = claims.getExpiration();
return expiration.before(new Date());
} catch (Exception e) {
//验证 JWT 签名失败等同于令牌过期
return true;
}
}
/**
* 刷新令牌
*
* @param token 原令牌
* @return 新令牌
*/
public String refreshToken(String token) {
String refreshedToken;
try {
Claims claims = getClaimsFromToken(token);
claims.put("created", new Date());
refreshedToken = generateToken(claims);
} catch (Exception e) {
refreshedToken = null;
}
return refreshedToken;
}
/**
* 验证令牌
*
* @param token 令牌
* @param userId 用户Id用户名
* @return 是否有效
*/
public Boolean validateToken(String token, String userId) {
String username = getUserIdFromToken(token);
return (username.equals(userId) && !isTokenExpired(token));
}
/**
* 生成令牌
* @param claims 数据声明
* @return 令牌
*/
private String generateToken(Map<String, Object> claims) {
Date expirationDate = new Date(System.currentTimeMillis()
+ jwtProperties.getExpiration());
return Jwts.builder().setClaims(claims)
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS512,
jwtProperties.getSecret())
.compact();
}
/**
* 生成刷新令牌 refreshToken,有效期是令牌的 2 倍
* @param claims 数据声明
* @return 令牌
*/
private String generateRefreshToken(Map<String, Object> claims) {
Date expirationDate = new Date(System.currentTimeMillis() + jwtProperties.getExpiration() * 2);
return Jwts.builder().setClaims(claims)
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS512, jwtProperties.getSecret())
.compact();
}
/**
* 从令牌中获取数据声明,验证 JWT 签名
* @param token 令牌
* @return 数据声明
*/
private Claims getClaimsFromToken(String token) {
Claims claims;
try {
claims = Jwts.parser().setSigningKey(jwtProperties.getSecret()).parseClaimsJws(token).getBody();
} catch (Exception e) {
claims = null;
}
return claims;
}
}
登录验证通过后,使用用户名查询用户的角色,并将角色信息存入token中:
Map<String, Object> tokenMap = jwtTokenUtil
.generateTokenAndRefreshToken(String.valueOf(account.getId()), username,
//用户角色映射表中中查询用户角色
rolesService.getOne(new QueryWrapper<AccountRoles>().eq("username",username)).getRoles());
(如上图,token中携带roles信息)
然后,编写gateway过滤器:
import com.alibaba.fastjson.JSON;
import com.seven.springcloud.config.AuthJwtProperties;
import com.seven.springcloud.config.WhiteListProperties;
import com.seven.springcloud.constants.TokenConstants;
import com.seven.springcloud.entities.CommonResult;
import com.seven.springcloud.entities.enums.ResponseCodeEnum;
import com.seven.springcloud.service.PermissionService;
import com.seven.springcloud.util.JwtTokenUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import javax.annotation.Resource;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
@Slf4j
@Configuration
public class JwtAuthCheckFilter {
private static final String AUTH_TOKEN_URL = "/auth/login";
private static final String REFRESH_TOKEN_URL = "/auth/token/refresh";
public static final String USER_ID = "userId";
public static final String USER_NAME = "username";
public static final String FROM_SOURCE = "from-source";
@Resource
private WhiteListProperties whiteListProperties;
@Resource
private AuthJwtProperties authJwtProperties;
@Resource
private PermissionService permissionService;
@Resource
private JwtTokenUtil jwtTokenUtil;
@Bean
@Order(-101)
public GlobalFilter jwtAuthGlobalFilter() {
return (exchange, chain) -> {
log.info("登录判断");
ServerHttpRequest serverHttpRequest = exchange.getRequest();
ServerHttpResponse serverHttpResponse = exchange.getResponse();
ServerHttpRequest.Builder mutate = serverHttpRequest.mutate();
String requestUrl = serverHttpRequest.getURI().getPath();
// 跳过对登录请求的 token 检查。因为登录请求是没有 token 的,是来申请 token 的。
if(AUTH_TOKEN_URL.equals(requestUrl)) {
log.info("登录url,放行");
return chain.filter(exchange);
}
// 从 HTTP 请求头中获取 JWT 令牌
String token = getToken(serverHttpRequest);
if (StringUtils.isEmpty(token)) {
return unauthorizedResponse(exchange, serverHttpResponse, ResponseCodeEnum.TOKEN_MISSION);
}
// 对Token解签名,并验证Token是否过期
boolean isJwtNotValid = jwtTokenUtil.isTokenExpired(token);
if(isJwtNotValid){
return unauthorizedResponse(exchange, serverHttpResponse, ResponseCodeEnum.TOKEN_INVALID);
}
// 验证 token 里面的 userId 是否为空
String userId = jwtTokenUtil.getUserIdFromToken(token);
String username = jwtTokenUtil.getUserNameFromToken(token);
if (StringUtils.isEmpty(userId)) {
return unauthorizedResponse(exchange, serverHttpResponse, ResponseCodeEnum.TOKEN_INVALID);
}
// 设置用户信息到请求
addHeader(mutate, USER_ID, userId);
addHeader(mutate, USER_NAME, username);
// 内部请求来源参数清除
removeHeader(mutate, FROM_SOURCE);
return chain.filter(exchange.mutate().request(mutate.build()).build());
};
}
//添加头部信息
private void addHeader(ServerHttpRequest.Builder mutate, String name, Object value) {
if (value == null) {
return;
}
String valueStr = value.toString();
String valueEncode = urlEncode(valueStr);
mutate.header(name, valueEncode);
}
//移除头部信息
private void removeHeader(ServerHttpRequest.Builder mutate, String name) {
mutate.headers(httpHeaders -> httpHeaders.remove(name)).build();
}
//内容编码,配置为UTF-8
static String urlEncode(String str) {
try {
return URLEncoder.encode(str, "UTF-8");
}
catch (UnsupportedEncodingException e)
{
return StringUtils.EMPTY;
}
}
@Bean
@Order(-100)
public GlobalFilter permissionGlobalFilter() {
return (exchange, chain) -> {
log.info("权限判断");
// 从 HTTP 请求头中获取 JWT 令牌
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
ServerHttpRequest.Builder mutate = request.mutate();
String path = request.getURI().getPath();
//对白名单中的地址放行
List<String> whiteList = whiteListProperties.getWhites();
for(String str : whiteList){
if(path.contains(str)){
log.info("白名单,放行{}",request.getURI().getPath());
return chain.filter(exchange);
}
}
//String headerToken = request.getHeaders().getFirst(TokenConstants.AUTHENTICATION);
//判断用户权限
String token = getToken(request);
boolean permission = hasPermission(token,path);
if (!permission){
log.info("用户没有权限");
return unauthorizedResponse(exchange, response, ResponseCodeEnum.PERMISSION_DENIED);
}
return chain.filter(exchange.mutate().request(mutate.build()).build());
};
}
private boolean hasPermission(String token, String path){
//解码jwt token
Claims claims = Jwts.parser().setSigningKey(authJwtProperties.getSecret()).parseClaimsJws(token).getBody();
//获取token中的权限值
String roles = (String) claims.get("roles");
if(roles!=null){
List<String> pathList = permissionService.pathGet(roles);
path = path.replaceFirst("/api",StringUtils.EMPTY);
for (String api:pathList){
if(api.equals(path)){
return true;
}
}
}
return false;
}
//请求token
private String getToken(ServerHttpRequest request) {
String token = request.getHeaders().getFirst(authJwtProperties.getHeader());
// 如果前端设置了令牌前缀,则裁剪掉前缀
if (StringUtils.isNotEmpty(token) && token.startsWith(TokenConstants.PREFIX))
{
token = token.replaceFirst(TokenConstants.PREFIX, StringUtils.EMPTY);
}
return token;
}
//jwt鉴权失败处理类
private Mono<Void> unauthorizedResponse(ServerWebExchange exchange, ServerHttpResponse serverHttpResponse, ResponseCodeEnum responseCodeEnum) {
log.warn("token异常处理,请求路径:{}", exchange.getRequest().getPath());
serverHttpResponse.setStatusCode(HttpStatus.UNAUTHORIZED);
serverHttpResponse.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
CommonResult<Object> responseResult = new CommonResult<>(responseCodeEnum.getCode(),responseCodeEnum.getMessage());
DataBuffer dataBuffer = serverHttpResponse.bufferFactory()
.wrap(JSON.toJSONStringWithDateFormat(responseResult, JSON.DEFFAULT_DATE_FORMAT)
.getBytes(StandardCharsets.UTF_8));
return serverHttpResponse.writeWith(Flux.just(dataBuffer));
}
}
此处进行了两次拦截过滤:先进行登录拦截(查看是否携带token),再进行权限拦截(查看是否用于权限)。(登录拦截此处不进行展开,具体查看springcloud gateway集成jwt实现登录鉴权)
权限判断代码中,首先需要在配置文件进行配置,设置拦截白名单:
auth:
jwt:
enabled: true # 是否开启JWT登录认证功能
secret: passjava # JWT 私钥,用于校验JWT令牌的合法性
expiration: 1800000 # JWT 令牌的有效期,用于校验JWT令牌的合法性,一个小时
header: Authorization # HTTP 请求的 Header 名称,该 Header作为参数传递 JWT 令牌
userParamName: userId # 用户登录认证用户名参数名称
pwdParamName: password # 用户登录认证密码参数名称
useDefaultController: true # 是否使用默认的JwtAuthController
skipValidUrl: /auth/login
ignore:
whites: # 自定义白名单
- /auth/login
- /auth/token/refresh
@Data
@Component
@ConfigurationProperties(prefix = "auth.ignore")
public class WhiteListProperties {
private List<String> whites;
}
对登录登出等不需要权限的路由放行。
其次,需要在gateway微服务中导入feign依赖,编写feign service类调用权限管理的微服务:
@FeignClient(value = "cloud-roles-manage")
public interface PermissionService {
@GetMapping("/path/get")
List<String> pathGet(@RequestParam("roles") String roles);
}
通过导入该service,获得token中对应角色信息下的所有可访问路由。然后对当前路由进行配对,若配对成功,则拥有权限,否则则无权限。
若以后业务增长,可访问api较多,不适合一个个进行遍历,可直接进行在角色-数据映射表中存储api名称,然后对该字段进行模糊查询,具体应看具体业务需求进行设计。
上述代码中,jwt token中的一些相关属性请参考上一篇文章:springcloud gateway集成jwt实现登录鉴权
需注意的是:spring-boot2.2.x版本HttpMessageConvertersAutoConfiguration有所改动,加了个@Conditional(NotReactiveWebApplicationCondition.class) , 因为gateway是ReactiveWeb,所以针对HttpMessageConverters的自动配置就不生效了,故需要手动注入HttpMessageConverters,否则feign调用时会报错:
@Configuration
public class FeignConfig {
@Bean
@ConditionalOnMissingBean
public HttpMessageConverters messageConverters(ObjectProvider<HttpMessageConverter<?>> converters) {
return new HttpMessageConverters(converters.orderedStream().collect(Collectors.toList()));
}
}
5.实现效果
然后,我们对其进行实验,对应权限如下:
user角色拥有查询的权限,admin角色拥有查询和添加的权限。
首先登录拥有user角色的账号,获取对应的token。获取token后,携带token访问code:101下的api,进行添加地址:
判断用户没有权限。
访问查询接口:
拥有权限,查询成功。
切换拥有admin角色账号,获取token,访问添加接口:
访问成功,控制台打印如下;
先进行判断是否跳过鉴权;然后进行登录判断,是否携带token ,再进行权限判断,是否拥有权限。
以上即为用户权限判断全过程,RBAC模型的简单实现。