背景
假设我们有很多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
代码下载地址