JWT、自定义注解结合前端页面实现接口访问权限控制及SpringCloud Feign分布式服务调用信息传递
一、简述
通过JWT、自定义注解实现后台接口访问权限的控制:
1.通过PC端页面将用户(员工)、角色、菜单页面、按钮与权限标识绑定;
2.登录认证过程将登录人信息、权限标识与token绑定并存入redis中;
3.对接口进行自定义注解标识;
4.每次请求通过拦截器中进行权限校验(访问用户自身绑定的权限标识与后台接口的注解标识比对)
二、流程图
三、用户访问接口具体逻辑时序图
四、简要代码
1.gateway-service网关服务(AuthFilter过滤器)
/**
* 请求认证过滤
*/
@Component
@Slf4j
public class AuthFilter implements GlobalFilter, Ordered {
/**
* 基于LRU暂存token对应的用户信息和权限
*/
public static final ConcurrentLinkedHashMap<String, TokenValueInMap> cacheMap =
new ConcurrentLinkedHashMap.Builder<String, TokenValueInMap>()
.maximumWeightedCapacity(20000)
.weigher(Weighers.singleton())
.build();
/**
* 基于记录无效token,防止无效token多次,以提升系统性能
*/
private static final ConcurrentLinkedHashMap<String, Long> cacheInValidToken =
new ConcurrentLinkedHashMap.Builder<String, Long>()
.maximumWeightedCapacity(2000)
.weigher(Weighers.singleton())
.build();
@Autowired
RedisService<HashMap, String> redisService;
@Autowired
RedisProperties redisProperties;
private static ThreadPoolExecutor threadPoolExecutorForSendToRedis;
static {
threadPoolExecutorForSendToRedis = new ThreadPoolExecutor(
//线程池维护线程的最少数量
100,
//线程池维护线程的最大数量
10000,
//线程池维护线程所允许的空闲时间
120, TimeUnit.SECONDS,
//线程池所使用的缓冲队列
new ArrayBlockingQueue<Runnable>(500),
//加入失败,则在调用的主线程上执行
new ThreadPoolExecutor.CallerRunsPolicy());
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
HttpResult httpResult = HttpStatus.Unauthorized;
//备注:针对白名单请求,如果具有有效的token,则将有效的token(即:自身能够正常解析 + redis中未失效)也一并解析
try {
//从请求头部获取token
String token = getTokenFromRequest(request);
//是否是匿名请求,即:请求路径中是否含有unAuth
boolean isHasUnAuth = false;
//对于未携带token的请求,需要判断是否能够直接放行,通过PassConfig中的白名单进行控制
AntPathMatcher antPathMatcher = new AntPathMatcher();
long count = PassConfig.WITELIST.stream().filter(pattern -> antPathMatcher.match(pattern, request.getPath().value())).count();
if (count > 0) {
isHasUnAuth = true;
if (StringUtils.isBlank(token)) {
//放行前校验请求中的签名
this.preChainFilterProcess(request, "");
//放行 未携带token的unAuth请求
return chain.filter(exchange);
}
}
//访问需要授权的接口时未携带token
if (!isHasUnAuth && StringUtils.isBlank(token)) {
throw new Exception("请先登录系统");
}
String authorities = null;
Map<String, Object> userMap = null;
//获取token对应的用户信息
// tokenValueInMap为null,表示token无效,需要重新登录
TokenValueInMap tokenValueInMap = getTokenValue(token);
if (null != tokenValueInMap) {
authorities = tokenValueInMap.tokenValueInRedis.getAuthority();
userMap = tokenValueInMap.tokenValueInRedis.getClaims();
}
//非白名单内的请求,且携带自身解析有效的token,但是token在redis中不存在,直接抛出异常,需要用户重新登录
if (!isHasUnAuth && null == tokenValueInMap) {
throw new Exception("登录已超时, 请重新登录");
}
//白名单内的请求,且携带自身解析有效的token,但是token在redis中不存在,可以放行
if (isHasUnAuth && null == tokenValueInMap) {
this.preChainFilterProcess(request, "");
return chain.filter(exchange);
}
//token自动续期
final Map<String, Object> userMapTmp = userMap;
threadPoolExecutorForSendToRedis.execute(() -> {
this.resetTokenExpireTime(token, userMapTmp);
});
ServerHttpRequest httpRequest = request.mutate()
.header(User.CONTEXT_USER_ID, userMap.get(JwtTokenUtils.USERID).toString())
.header(User.CONTEXT_USER_NAME, userMap.get(JwtTokenUtils.USERNAME).toString())
.header(User.CONTEXT_USER_LOGIN_TYPE, userMap.get(JwtTokenUtils.USERLOGINTYPE).toString())
.header(User.CONTEXT_CLIENT_ID, userMap.get(JwtTokenUtils.CLIENTID).toString())
.header(Company.CONTEXT_COMPANY_ID, null == userMap.get(JwtTokenUtils.COMPANYID) ? ""
: userMap.get(JwtTokenUtils.COMPANYID).toString())
.header(Company.CONTEXT_COMPANY_NAME, null == userMap.get(JwtTokenUtils.COMPANYNAME) ? ""
: URLEncoder.encode(userMap.get(JwtTokenUtils.COMPANYNAME).toString(), "UTF-8"))
.header(Dept.CONTEXT_DEPT_ID, null == userMap.get(JwtTokenUtils.DEPTID) ? ""
: userMap.get(JwtTokenUtils.DEPTID).toString())
.header(Dept.CONTEXT_DEPT_NAME, null == userMap.get(JwtTokenUtils.DEPTNAME) ? ""
: URLEncoder.encode(userMap.get(JwtTokenUtils.DEPTNAME).toString(), "UTF-8"))
.header(User.CONTEXT_USER_AUTHORITIES, authorities)
.header(User.CONTEXT_INVITATION_CODE, null == userMap.get(JwtTokenUtils.INVITATIONCODE) ? ""
: userMap.get(JwtTokenUtils.INVITATIONCODE).toString()) //邀请码
.header(User.CONTEXT_USER_TOKEN, token).build();
this.preChainFilterProcess(request, userMap.get(JwtTokenUtils.USERLOGINTYPE).toString());
return chain.filter(exchange.mutate().request(httpRequest).build());
} catch (BaseException baseException) {
if (!BaseException.DEFAULT_CODE.equals(baseException.getCode())) {
httpResult.setStatus(baseException.getCode());
}
return processException(httpResult, baseException, exchange);
} catch (Exception ex) {
return processException(httpResult, ex, exchange);
}
}
/**
* 认证时的异常处理
*
* @param httpResult
* @param ex
* @param response
* @return
*/
private Mono<Void> processException(HttpResult httpResult, Exception ex, ServerWebExchange exchange) {
ServerHttpResponse response = exchange.getResponse();
httpResult.setMessage(ex.getMessage());
String result = JSONObject.toJSON(httpResult).toString();
response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
response.setStatusCode(org.springframework.http.HttpStatus.OK);
return exchange.getResponse()
.writeWith(Flux.just(exchange.getResponse().bufferFactory().wrap(result.getBytes())));
}
/**
* 过滤器执行的优先级,值越小优先级越高
*
* @return
*/
@Override
public int getOrder() {
return -5;
}
/**
* 从请求总获取token
*
* @param request
* @return
*/
private String getTokenFromRequest(ServerHttpRequest request) {
String token = request.getHeaders().getFirst(JwtTokenUtils.HEADER_AUTH);
//由于websocket比较特殊,涉及http upgrade的过程
//无法在header中携带token,只能在请求路径中携带token
//如果请求以"/websocket-server/chat/"开头,就认为是websocket类型的请求,token放在请求的最后,通过"/"截取,以获取token
if (request.getURI().getPath().startsWith("/websocket-server/chat/")) {
token = request.getURI().getPath().substring(request.getURI().getPath().lastIndexOf("/") + 1);
}
return token;
}
/**
* 自动续期
* 重新设置token的过期时间
*
* @param token
* @param userMap
* @return
*/
private void resetTokenExpireTime(String token, Map<String, Object> userMap) {
String userId = userMap.get(JwtTokenUtils.USERID).toString();
String userLoginType = userMap.get(JwtTokenUtils.USERLOGINTYPE).toString();
//为了系统安全,通过员工超级密码生成的token不会自动续期,默认12小时后过期
if (UserLoginTypeConstants.EmployeeLoginPCBySuperPassword.equals(userLoginType)) {
return;
}
if (LoginDeviceTypeConstants.APP.equals(LoginRelatedHelper.getLoginDeviceType(userLoginType))) {
//登录方式为app,自动续期12天
boolean isOK = redisService.expire(token, redisProperties.getAppTokenExpireMilliSeconds());
if (isOK) { //从性能上考虑,只有上一步执行成功,才会执行下一步
redisService.expire(userLoginType + userId, redisProperties.getAppTokenExpireMilliSeconds());
}
} else if (LoginDeviceTypeConstants.PC.equals(LoginRelatedHelper.getLoginDeviceType(userLoginType))) {
//登录方式为pc或第三方模拟登录,自动续期8小时ø
boolean isOK = redisService.expire(token, redisProperties.getPcTokenExpireMilliSeconds());
if (isOK) { //从性能上考虑,只有上一步执行成功,才会执行下一步
redisService.expire(userLoginType + userId, redisProperties.getPcTokenExpireMilliSeconds());
}
} else {
//针对虚拟用户的调用,由于token是不失效的,所以不用续期
}
}
/**
* 执行chain.filter之前进行的预处理
*
* @param request
* @param userLoginType
*/
private void preChainFilterProcess(ServerHttpRequest request, String userLoginType) {
this.veritySign(request, userLoginType);
}
/**
* 校验请求中的签名
*
* @param request
* @param userLoginType
*/
private void veritySign(ServerHttpRequest request, String userLoginType) {
userLoginType = null == userLoginType ? "" : userLoginType;
//暂时认定通过第三方系统 虚拟用户登录 的请求中必须要携带sign和signTimestamp
//【待开发】等前端改造完后再开放对所有用户请求的签名校验
if (userLoginType.startsWith(UserTypeConstants.VirtualUser)) {
String sign = request.getQueryParams().getFirst("sign");
String signTimestamp = request.getQueryParams().getFirst("signTimestamp");
if (StringUtils.isBlank(sign)) {
throw new BaseException(HttpStatus.SignError.getStatus(), "请求中的签名 sign 不能为空");
}
if (StringUtils.isBlank(signTimestamp)) {
throw new BaseException(HttpStatus.SignError.getStatus(), "请求中签名的时间戳 signTimestamp 不能为空");
}
}
}
/**
* 先从缓存中获取token对应的用户和权限,如果存在 且 没有过期,则直接返回,否则需要到redis中去获取,再写入map
* 由于这里对token进行了缓存,所以在分布式场景中,客户退出登录后,token还在缓存中,这样就无法做到立即实现单点控制,但是考虑到服务器的tps,目前暂时采取这种折中的方式
*
* @param token
* @return
*/
private TokenValueInMap getTokenValue(String token) {
// 先从缓存中获取token对应的权限,如果存在 且 没有过期,则直接返回,否则需要到redis中去获取,再写入map
TokenValueInMap authorityInMapValue = cacheMap.get(token);
// 60*60*8 * 1000 = 28800 * 1000 = 28800000 (map中的值8小时后过期)
if (null == authorityInMapValue || (authorityInMapValue.timeMillis + 28800000) < System.currentTimeMillis()) {
//如果判定是无效token,则直接返回
if (cacheInValidToken.containsKey(token)) {
Long timeMillis = cacheInValidToken.get(token);
// 60*60*24 * 1000 = 86400 * 1000 = 86400000 (24小时)
if (timeMillis + 86400000 > System.currentTimeMillis()) {
return null;
}
}
String tokenValueInRedis = redisService.getValue(token);
if (StringUtils.isNotBlank(tokenValueInRedis)) {
TokenValueInRedis tokenValue = JSON.parseObject(tokenValueInRedis, new TypeReference<TokenValueInRedis>() {
});
authorityInMapValue = new TokenValueInMap(tokenValue, System.currentTimeMillis());
cacheMap.put(token, authorityInMapValue);
} else {
cacheInValidToken.put(token, System.currentTimeMillis());
}
}
return authorityInMapValue;
}
}
2.auth-client服务(VerifySignatureInterceptor 拦截器)
/**
* 校验签名拦截器
* 放在所有拦截器的最前面
*/
@Slf4j
@Component
@Setter
public class VerifySignatureInterceptor extends HandlerInterceptorAdapter {
/**
* 由外部实例化VerifySignatureInterceptor对象后传入
*/
private RedisService redisService;
public static final String Authorization = "Authorization";
public static final String AuthKey = "authKey";
/**
* 签名校验
*
* @param request
* @param response
* @param handler
* @return
* @throws IOException
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
try {
//如果是访问内部资源文件,则直接放行
if (!(handler instanceof HandlerMethod)) {
return true;
}
if (!(request instanceof RequestWrapper)) {
return true;
}
RequestWrapper requestWrapper = (RequestWrapper) request;
//请求中包含签名参数 sign 和 signTimestamp 时,才会校验签名的正确性
SortedMap<String, String> urlParams = HttpUtils.getUrlParams(requestWrapper);
String sign = SignUtils.getSign(urlParams);
String signTimestamp = SignUtils.getSignTimestamp(urlParams);
if (!StringUtils.isEmpty(sign) && !StringUtils.isEmpty(signTimestamp)) {
//从请求中获取所有参数,并组装成验证签名所需要的map
SortedMap<String, String> allParams = HttpUtils.getAllParams(requestWrapper);
this.verifyRequestBySign(requestWrapper, allParams);
}
return true;
} catch (Exception ex) {
ex.printStackTrace();
// this.returnJson(response, ex);
CommonExceptionHandler.sendErrorByResponse(response, ex);
return false;
}
}
/**
* 根据签名校验请求的有效性,根据所有入参进行签名计算
*
* @param allParams
*/
private void verifyRequestBySign(HttpServletRequest request, SortedMap<String, String> allParams) {
String sign = SignUtils.getSign(allParams);
String signTimestamp = SignUtils.getSignTimestamp(allParams);
if (!StringUtils.isEmpty(sign) && !StringUtils.isEmpty(signTimestamp)) {
// 写入token
String token = request.getHeader(Authorization);
allParams.put(Authorization.toLowerCase(), null == token ? "" : token);
//从redis中获取签名用的key,并放入SortedMap中
boolean isHasClientID = false;
String clientId = request.getHeader(User.CONTEXT_CLIENT_ID);
if (StringUtils.isEmpty(clientId)) {
clientId = allParams.get("clientId");
}
if (!StringUtils.isEmpty(clientId)) {
Object authClientObj = this.redisService.hashGet(AuthConstants.PREFIX_AUTH_CLIENT, clientId);
if (!StringUtils.isEmpty(authClientObj)) {
JSONObject jsonObject = JSON.parseObject(authClientObj.toString());
allParams.put(AuthKey, jsonObject.getString(AuthKey));
isHasClientID = true;
}
}
if (!isHasClientID) {
throw new BaseException(HttpStatus.SignError.getStatus(), "请求参数中或token中缺少ClientID的值");
}
log.info("==allParams====allParams={}"+JSON.toJSONString(allParams));
// 对参数进行签名验证
boolean isSigned = SignUtils.verifySign(allParams, 30);
if (!isSigned) {
throw new BaseException(HttpStatus.SignError.getStatus(), "请求的签名有误,可能是由于请求过期或参数被篡改");
}
}
}
3.auth-client服务(RequestContextInterceptor 拦截器)
/**
* 用户上下文拦截器
* 从请求头header中获取用户权限,判断用户是否具有访问权限
* 未授权:抛出异常 HttpStatus.AuthorizationError
* 有权限:将用户信息放入用户上下文UserContextHolder,以方便业务系统获取用户信息
*/
@Slf4j
public class RequestContextInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
try {
//如果是访问内部资源文件,则直接放行
if (!(handler instanceof HandlerMethod)) {
return true;
}
//非正常http请求,并且 非文件上传类请求,可以直接放行
if (!(request instanceof HttpServletRequestWrapper)) {
return true;
}
//如果在之前的拦截中设置了当前用户,则跳过如下的认证逻辑
if (null != UserContextHolder.currentUser()) {
return true;
}
User user = getUser(request);
Dept dept = getDept(request);
Company company = getCompany(request);
if (null == user || null == user.getUserId() || null == user.getUserName()) {
throwException(request, HttpStatus.Unauthorized.getStatus(), "消息头中缺少userid或username");
}
if (null != user && null == user.getUserLoginType()) {
throwException(request, HttpStatus.Unauthorized.getStatus(), "消息头中缺少userlogintype");
}
//员工登录必须有机构和科室
if (null != user && user.isEmployee()) {
if (null == dept || null == dept.getDeptId() || null == dept.getDeptName()) {
throwException(request, HttpStatus.Unauthorized.getStatus(), "消息头中缺少deptid或deptname");
}
if (null == company || null == company.getCompanyId() || null == company.getCompanyName()) {
throwException(request, HttpStatus.Unauthorized.getStatus(), "消息头中缺少companyid或companyname");
}
}
if (null != user && !UserPermissionUtil.verify(user, (HandlerMethod) handler)) {
throwException(request, HttpStatus.Forbidden.getStatus(), "请求的URL没有权限 -> " + request.getRequestURI());
}
UserContextHolder.set(user);
DeptContextHolder.set(dept);
CompanyContextHolder.set(company);
return true;
} catch (Exception ex) {
ex.printStackTrace();
CommonExceptionHandler.sendErrorByResponse(response, ex);
return false;
}
}
/**
* 拦截请求成功执行后的操作
*
* @throws Exception
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse respone, Object arg2, ModelAndView arg3)
throws Exception {
// DOING NOTHING
}
/**
* 拦截请求成功执行后的最终操作
*
* @throws Exception
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse respone, Object arg2, Exception exception)
throws Exception {
UserContextHolder.shutdown();
DeptContextHolder.shutdown();
CompanyContextHolder.shutdown();
}
/**
* 从请求头header中获取用户信息
*
* @param request
* @return
*/
private User getUser(HttpServletRequest request) {
String userId = request.getHeader(User.CONTEXT_USER_ID);
String userName = request.getHeader(User.CONTEXT_USER_NAME);
String authoritiesStr = request.getHeader(User.CONTEXT_USER_AUTHORITIES);
String userLoginType = request.getHeader(User.CONTEXT_USER_LOGIN_TYPE);
String token = request.getHeader(User.CONTEXT_USER_TOKEN);
String clientId = request.getHeader(User.CONTEXT_CLIENT_ID);
String invitationCode = request.getHeader(User.CONTEXT_INVITATION_CODE); //邀请码
if (null == userId || null == userName) {
return null;
}
List<String> authorities = CollectionConvertUtil.stringToList(null == authoritiesStr ? "" : authoritiesStr, ";");
return User.builder()
.token(token)
.userId(userId)
.userName(userName)
.userLoginType(userLoginType)
.clientId(clientId)
.invitationCode(invitationCode)
.authorities(authorities).build();
}
/**
* 从请求头header中获取科室信息
*
* @param request
* @return
*/
private Dept getDept(HttpServletRequest request) {
String deptid = request.getHeader(Dept.CONTEXT_DEPT_ID);
String deptname;
if (null == deptid) {
return null;
}
try {
deptname = URLDecoder.decode(request.getHeader(Dept.CONTEXT_DEPT_NAME), "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
deptname = "解析错误";
}
return Dept.builder().deptId(deptid).deptName(deptname).build();
}
/**
* 从请求头header中获取机构信息
*
* @param request
* @return
*/
private Company getCompany(HttpServletRequest request) {
String companyid = request.getHeader(Company.CONTEXT_COMPANY_ID);
if (null == companyid) {
return null;
}
String companyname;
try {
companyname = URLDecoder.decode(request.getHeader(Company.CONTEXT_COMPANY_NAME), "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
companyname = "解析错误";
}
Company company = new Company();
company.setCompanyId(companyid);
company.setCompanyName(companyname);
return company;
}
/**
* 抛出异常
* 如果当前请求的url是白名单内的,可以不用抛出异常
*
* @param request
*/
private void throwException(HttpServletRequest request, String code, String message) {
if (!AuthPassList.isSkipAuthCheck(request)) {
throw new BaseException(code, message);
}
}
}
五、注意点 敲黑板
在此示例代码中:
1、在gateway网关token校验中,校验通过后会将Redis中该用户信息、权限标识等信息放入request中带入下一环节;
2、前提由于auth-client是其他服务的公共依赖包,略过验签。。。在权限校验拦截器中,会从request中取出用户相关信息、权限标识进行访问权限校验,并在结束后将这些信息放入Threadlocal中,与线程绑定,因为下面业务代码中需要从该线程中取出该用户信息进行使用;
3、此实例代码是基于分布式服务,当调用其他服务时,由于用户信息是存入Threadlocal中只与某一JVM中线程绑定,其他服务是无法获取该用户信息的,所以为了使其他服务能够使用该用户信息,加了一个拦截器FeignRequestContextInterceptor,基于SpringCloud的Feign服务调用,在此拦截器中将用户相关信息放入RequestTemplate对象中并带入其他服务;
代码如下:
/**
* @Description 在通过feign调用服务时,传递当前请求中所有header信息
*/
public class FeignRequestContextInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate requestTemplate) {
User user = UserContextHolder.currentUser();
Dept dept = DeptContextHolder.currentDept();
Company company = CompanyContextHolder.currentCompany();
if (null != user) {
requestTemplate.header(User.CONTEXT_USER_TOKEN, user.getToken());
requestTemplate.header(User.CONTEXT_USER_ID, user.getUserId());
requestTemplate.header(User.CONTEXT_USER_NAME, user.getUserName());
requestTemplate.header(User.CONTEXT_USER_LOGIN_TYPE, user.getUserLoginType());
requestTemplate.header(User.CONTEXT_USER_AUTHORITIES, CollectionConvertUtil.listToString(user.getAuthorities(), ";"));
requestTemplate.header(User.CONTEXT_CLIENT_ID, user.getClientId());
requestTemplate.header(User.CONTEXT_INVITATION_CODE, user.getInvitationCode()); //邀请码
}
if (null != dept) {
requestTemplate.header(Dept.CONTEXT_DEPT_ID, dept.getDeptId());
try {
requestTemplate.header(Dept.CONTEXT_DEPT_NAME, URLEncoder.encode(dept.getDeptName(), "UTF-8"));
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
if (null != company) {
requestTemplate.header(Company.CONTEXT_COMPANY_ID, company.getCompanyId());
try {
requestTemplate.header(Company.CONTEXT_COMPANY_NAME, URLEncoder.encode(company.getCompanyName(), "UTF-8"));
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
}
}