背景

假设我们有很多java实现的项目,认证授权用的是shiro框架,可能还有一个sso单点登录平台

突然有一天,你的项目经理说要做微服务

然后,你就给了你领导很多建议,什么dubbo、什么spring cloud等等;涉及的内容可能方方面面

但是! 😅
该项目经理说:小明,你晚上加加班,花点时间来改造一下现有的项目就好了,我们现有的项目改造起来也不是很麻烦,另外,项目改造微服务不能影响原有的项目计划进度哦 😄
此时,你的心里万马奔腾

目标

总的来说一句话:用最少的工作量,改造基于shiro安全框架的微服务项目,实现spring cloud + shiro 框架集成
PS:当前博客描述的方案是小编根据公司实际情况设计实现、并且在生产环境正常运行的方案,可能一些设计不太合理,又或者不满足你的需求,但是,这个方案还是有借鉴意义的。觉得有用的,给个评论点个赞鼓励下。

方案设计

整体方案设计:

在这里插入图片描述

  • zuul网关服务,主要用于同一系统的访问出入口;
  • zuul实现一个filter,用于过滤所有的请求,校验登录状态及权限;认证:校验用户是否登录;授权:校验用户是否有权限
  • service-auth服务,主要实现认证、授权功能,因为所有的请求都需要经过该服务,所以集成的功能不能太多;必须要保证该模块高可用;该服务,使用feign的方式开发接口,提供给zuul调用
  • service-auth服务,集成shiro-redis安全框架,其他服务模块可以不用集成shiro安全框架,如果非要集成也是可以的,但是要解决shiro的会话共享问题;

认证授权流程

在这里插入图片描述

  • 在网关处配置支持https协议请求,则所有的服务均可以同时支持http、https协议请求
  • 优先从cookie获取会话ID,同时需要支持token参数方式校验,因为在做公众号登录的时候,要求在后端登录接口进行重定向,所以需要提供token参数确定客户端身份
  • 授权功能,通过url鉴权;服务名称、请求路径、请求方式三者确定唯一;当然也可以使用requirePermissions,根据自己的需要吧,我这里是根据公司项目实际需求根据url来识别权限的。

方案实现

版本:spring boot 2.1.5.RELEASE spring cloud Greenwich.SR2 jdk1.8以上 postgresql-10 redis-2.8.17

eureka注册中心

简简单单的一个注册中心,没有啥特殊的配置。
启动类 Application.java

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

@SpringBootApplication
@EnableEurekaServer
public class Application {
	public static void main(String[] args) {
		SpringApplication.run(Application.class, args);
	}
}

配置 application.yml

server:
  port: 7001
spring:
  application:
    name: eureka
  main:
    allow-bean-definition-overriding: true
eureka:
  instance:
    prefer-ip-address: true
    #hostname: svc-eureka #eureka服务端的实例名称
    instance-id: ${spring.cloud.client.ip-address}:${server.port}
  server:
    enable-self-preservation: false ## 中小规模下,自我保护模式坑比好处多,所以关闭它
    #renewal-threshold-update-interval-ms: 120000  ## 心跳阈值计算周期,如果开启自我保护模式,可以改一下这个配置
    eviction-interval-timer-in-ms: 5000 ## 主动失效检测间隔,配置成5秒
    use-read-only-response-cache: false ## 禁用readOnlyCacheMap
  client:
    register-with-eureka: false     #false表示不向注册中心注册自己。
    fetch-registry: false     #false表示自己端就是注册中心,我的职责就是维护服务实例,并不需要去检索服务
    service-url:
      defaultZone: http://${spring.cloud.client.ip-address}:${server.port}/eureka/
info:
  app.name: eureka
  company.name: test.com
  build.artifactId: "@project.artifactId@"
  build.version: "@project.version@"

详细代码,请下载附件查看

zuul网关

在网关实现了一个AuthFilter,用于过滤所有的请求,判断是否登录、是否有权限;
支持配置免登陆请求地址、免授权地址、兼容cookie跟token参数校验等。
兼容web端登陆、小程序、公众号登录等。
启动类 Application.java

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;
import org.springframework.cloud.openfeign.EnableFeignClients;

@SpringBootApplication
@EnableDiscoveryClient
@EnableZuulProxy
@EnableFeignClients
@EnableEurekaClient
public class Application {
	public static void main(String[] args) {
		SpringApplication.run(Application.class, args);
	}
}

关键代码 AuthFilter.java


import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import com.alibaba.fastjson.JSONObject;
import com.fundway.auth.api.LoginCheckApi;
import com.google.common.collect.Maps;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;

/**
* 自定义过滤器,向下游服务请求加header认证信息.
* 与敏感头(设置向内部服务不传递哪些header正好相反),
* 这种方式好像不能传递名称为 Authorization,Cookie,Set-Cookie 的请求头,这三个传递不到下游服务,这三个由敏感头管理,只能传递token这种自定义的头
*/
@Component
public class AuthFilter extends ZuulFilter{

   @Autowired(required=true)
   private LoginCheckApi loginCheckApi;

   // 请求路径白名单,不校验登录,在application-url配置
   private static Set<String> urlSet;
   // 请求资源类型白名单,不校验登录,在application-url配置
   private static Set<String> fileSet;

   @Override
   public String filterType() {
   	//pre型过滤器,路由到下级服务前执行
   	return FilterConstants.PRE_TYPE;
   }

   @Override
   public int filterOrder() {
   	//优先级,数字越大,优先级越低  
   	return 0;
   }

   @Override
   public boolean shouldFilter() {
   	//是否执行该过滤器,true代表需要过滤 
   	return true;
   }

   /**
    * 过滤逻辑
    * pre过滤器在route过滤前执行,RequestContext负责通信包含了请求等信息,debug发现,context.addZuulRequestHeader,
    * 但在RibbonRoutingFilter 这个向下游服务发起请求的路由过滤器,自定义的header没有添加上。
    * RibbonRoutingFilter是默认的过滤器,run方法可以看到,逻辑是从原来的RequestContext生产新的RibbonCommandContext发起请求
    * @return
    * @throws ZuulException
    */
   @Override
   public Object run() {

   	//Zull的Filter链间通过RequestContext传递通信,内部采用ThreadLocal 保存每个请求的信息,
   	//包括请求路由、错误信息、HttpServletRequest、response等
   	RequestContext ctx = RequestContext.getCurrentContext();
   	HttpServletRequest request = this.getHttpServletRequest();

   	// option请求,直接放行
   	if (request.getMethod().equals(RequestMethod.OPTIONS.name())) {
   		return null;
   	}

   	// 判断需要放行的url或者静态资源文件
   	String url = request.getRequestURI();
   	String end  = "";
   	if(url.lastIndexOf("/") >= 0 ) {	// 判断需要放行的请求
   		end  = url.substring(url.lastIndexOf("/"));
   		if(urlSet.contains(end)) {
   			return null;
   		}
   	}
   	if(end.lastIndexOf(".") > 0) {	//判断需要放行的静态文件
   		end  = end.substring(end.lastIndexOf(".") + 1);
   		if(fileSet.contains(end)) {
   			return null;
   		}
   	}

   	// 获取到用户的Token
   	String cookie = request.getHeader("Cookie");	//获取到 JSESSIONID=值
   	if(StringUtils.isEmpty(cookie)) {
   		cookie = "";
   	}

   	String token = ctx.getRequest().getParameter("token");	//获取到 值

   	// 处理微信公众号登录业务,后端会重定向,生成的cookie是一个无效cookie,而后端重定向,又不能把有效cookie写到客户端
   	if(!StringUtils.isEmpty(token) && !"undefined".equals(token) && !cookie.contains(token)) {
   		cookie = "JSESSIONID=" + (ctx.getRequest().getParameter("token"));
   	}
   	if(StringUtils.isEmpty(token)) {	// 参数未空或者null的话,feign调用的接口会报错!!坑比
   		token = "";
   	}

   	//过滤该请求,不往下级服务去转发请求,到此结束  
   	if(StringUtils.isEmpty(cookie)) { // 会报跨域问题
   		this.setCORS(ctx);
   		ctx.setSendZuulResponse(false);  
   		ctx.setResponseStatusCode(200);  
   		Map<String, Object> result = Maps.newHashMap();
   		result.put("code", 401);
   		result.put("msg", "未登录");
   		result.put("obj", "来自网关的消息:未获取到有效的Token");
   		result.put("success", false);
   		ctx.setResponseBody(JSONObject.toJSONString(result));
   		ctx.getResponse().setContentType("text/html;charset=UTF-8");
   		return null;
   	}

   	// 增加请求头
   	ctx.addZuulRequestHeader("Cookie", cookie);

   	// 调用统一认证接口,判断是否登录 && 判断是否有功能权限 
   	// 优先校验cookie,,不通过则校验token //cookie从request里面拿
   	Object check = loginCheckApi.checkPermission(token, this.getUrl(request));

   	if(check instanceof HashMap) {
   		HashMap<String, Object> result = (HashMap) check;
   		if(Boolean.parseBoolean(result.get("success").toString())) {
   			// 添加序列化之后的用户信息
   			// 白名单url的请求,不能获取到该信息
   			setReqParams(ctx, request, "userEntity", result.get("obj").toString());
   			return null;
   		}
   		this.setCORS(ctx);
   		ctx.setSendZuulResponse(false);  
   		ctx.setResponseStatusCode(200);  
   		// 权限校验接口异常
   		ctx.setResponseBody(JSONObject.toJSONString(check));
   		ctx.getResponse().setContentType("text/html;charset=UTF-8");
   		return null;
   	} else {
   		this.setCORS(ctx);
   		ctx.setSendZuulResponse(false);  
   		ctx.setResponseStatusCode(200);  
   		Map<String, Object> result = Maps.newHashMap();
   		result.put("code", 401);
   		result.put("msg", "无权限");
   		result.put("obj", "来自网关的消息:该用户无当前请求权限");
   		result.put("success", false);
   		ctx.setResponseBody(JSONObject.toJSONString(result));
   		ctx.getResponse().setContentType("text/html;charset=UTF-8");
   		return null;
   	}
   }

   private String getUrl(HttpServletRequest request) {
   	// 获取到请求的相关数据  uri是斜杠开头
   	String uri = request.getRequestURI().toLowerCase().replaceAll("//", "/");
   	String method = request.getMethod().toLowerCase();
   	return method.concat(uri);
   }

   private HttpServletRequest getHttpServletRequest() {
   	try {
   		ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
   		HttpServletRequest request = attributes.getRequest();
   		return request;
   	} catch (Exception e) {
   		e.printStackTrace();
   		return null;
   	}
   }

   public static void  setReqParams(RequestContext ctx, HttpServletRequest request, String key, String value)  {
   	// 一定要get一下,下面这行代码才能取到值... [注1]
   	request.getParameterMap();
   	Map<String, List<String>> requestQueryParams = ctx.getRequestQueryParams();
   	if (requestQueryParams==null) {
   		requestQueryParams=new HashMap<>();
   	}

   	//将要新增的参数添加进去,被调用的微服务可以直接 去取,就想普通的一样,框架会直接注入进去
   	ArrayList<String> arrayList = new ArrayList<>();
   	arrayList.add(value);
   	requestQueryParams.put(key, arrayList);
   	ctx.setRequestQueryParams(requestQueryParams);
   }

   private void setCORS(RequestContext ctx) {
   	//处理跨域问题
   	HttpServletRequest request = ctx.getRequest();
   	HttpServletResponse response = ctx.getResponse();

   	// 这些是对请求头的匹配,网上有很多解释
   	response.setHeader("Access-Control-Allow-Origin",request.getHeader("Origin"));
   	response.setHeader("Access-Control-Allow-Credentials","true");
   	response.setHeader("Access-Control-Allow-Methods","GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH");
   	response.setHeader("Access-Control-Allow-Headers","authorization, content-type");
   	response.setHeader("Access-Control-Expose-Headers","X-forwared-port, X-forwarded-host");
   	response.setHeader("Vary","Origin,Access-Control-Request-Method,Access-Control-Request-Headers");
   }

   @Value("${whitelist.urlset}")
   public void setUtlSet(Set<String> urlSet) {
   	this.urlSet = urlSet;
   }

   @Value("${whitelist.fileset}")
   public void setFileSet(Set<String> fileSet) {
   	this.fileSet = fileSet;
   }
}

详细代码,请下载附件查看

auth认证授权

用户登录认证、访问授权、会话管理等;开放接口给gateway的AuthFilter使用;
关键代码

	/**
	 * 权限判断接口:先查询到资源对应的id,然后根据用户权限判断
	 */
	@Override
	public Result checkPermission(HttpServletRequest request, String cookie, String checkUrl) {
		UserEntity user = this.getUserInfo(request, cookie);
		if(null == user || user.getId() <=0) {
			return Result.error("未登录", 401);
		}

		// 获取用户功能权限ID集合
		Set<Integer> permissionSet = user.getPermissionId();
		// 减少放到请求中的属性
		user.setPermission(null);
		user.setPermissionId(null);

		// 获取微服务名称
		String[] str = checkUrl.split("/");
		String module = str[1];
		
		// 判断是否是免校验资源
		if(this.getIdByUrl(unCheckResMap.get(module), checkUrl) > 0) {
			return Result.ok(JSONObject.toJSONString(user));
		}

		// 用户完全没有权限, 且请求资源不是开放资源
		if(null == permissionSet || permissionSet.size() <= 0) {
			log.info("当前用户未分配权限:" + user.getLoginName());
			return Result.error("无权限", 401);
		}

		// 获取系统指定模块资源
		Integer resId = this.getIdByUrl(resMap.get(module), checkUrl);
		
		// 系统没有配置该权限,或者请求路径不存在
		if(resId <= 0 && isPass) {
			// log.info("系统没有配置该资源对应的权限, 但是配置放行:" + uri);
			return Result.ok(JSONObject.toJSONString(user));
		}
		
		// 系统配置了权限
		if(permissionSet.contains(resId)) {
			return Result.ok(JSONObject.toJSONString(user));
		}

		return Result.error("无权限", 401);
	}
	
	public Integer getIdByUrl(HashMap<String, Integer> value, String url) {
		Integer result = 0;
		if(null != value && value.size() > 0) {
			Set<Integer> resultSet = Sets.newHashSet();
			
			if(value.containsKey(url)) {
				result = value.get(url);
			} else {
				// 遍历,匹配,处理@PathVariable注解的请求
				value.entrySet().forEach(entry -> {
					String key1 = entry.getKey();
					if(key1.contains("{")) {
						AntPathMatcher matcher = new AntPathMatcher();
						if(matcher.match(key1, url)) {
							resultSet.add(entry.getValue());
						}
					}
				});
			}
			if(resultSet.size() > 0) {
				result = resultSet.stream().findFirst().get();
			}
		}
		return result;
	}
  • 认证服务,主要通过服务名称+请求方式+请求url来判定唯一的权限,比如post/service-demo1/user/getSystemUserInfo 其中post是请求方式,service-demo1是服务名称,/user/getSystemUserInfo是接口请求路径;兼容如 {id} 由@PathVariable注解标注的请求。
  • 当然也可以使用shiro的权限编码方式,如 user:getSystemUserInfo ,但是使用这种方式,需要处理好多个服务权限集中管理,权限编码不能冲突的问题。
    详细代码,请下载附件查看

相关截图

  • project结构:auth:认证服务;auth-api:使用feign开放的接口声明;demo:微服务项目demo,不集成shiro;demo1:微服务项目demo,集成shiro;shiro:独立出来的shiro模块,供其他模块使用。
    项目代码架构
  • 项目运行注册中心截图:
    在这里插入图片描述
  • 接口调用演示:
    在这里插入图片描述

在这里插入图片描述

  • auth服务接口文档,需要先登录才能打开:
    在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

其他说明

  • **子系统后端开发过程中,不需要将服务注册eureka;**提交前端对接、或者测试部署服务器的时候注册即可
    因为如果每位后端开发,都将服务组册到eureka,如果服务名称相同,在服务端会产生负载均衡,访问的接口,不一定是本地的接口,也可能是别人的接口
    开发过程不控制权限,发布测试环境后,统一管理权限;仅需关注如何获取登录用户信息即可
  • 集成shiro
    自行实现登录接口,产生本地会话,从而实现获取用户信息;有现成案例参考,复制粘贴即可,几乎不用考虑工作量问题;
    部署的时候,使用redis缓存共享会话即可;
    具体实现,请查看示例项目代码demo1
  • 不集成shiro
    网关校验登录成功之后,转发请求的过程,会把用户登录信息携带转发;具体的服务项目,直接通过参数名称获取即可;
    具体实现,请查看示例项目代码demo

代码下载地址

spring cloud + shiro集成方案.zip
码云gitee

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐