微服务之间调用的安全认证
微服务之间的相互调用,需要一套认证机制来确认调用是安全的。这不同于在 API 网关的统一认证,主要是防止在微服务暴露在外网的情况下,内部接口被外部恶意调用。
如果微服务是在内网,对外暴露的只有 API 网关,则可以不用做认证。本篇以 JWT 技术来实现安全认证。更多关于 JWT ,可参考分布式应用系列(一):详细理解 JWT(Json Web Token)。
微服务架构中,通常会将认证功能独立成一个微服务,即创建一个专门处理认证、授权、解析、核验的认证服务,可叫认证中心。
其实 API 调用安全认证与 OSS 单点登录认证,在总体流程是上是相似的的,消费者首先请求认证服务,认证服务创建签发令牌(Token)返回给客户端,消费者带着令牌发请求到生产者,接下来就是对令牌的验证,验证通过就返回到业务层。
令牌验证三种方案:
一、令牌是基于 JWT 创建的,此令牌支持自验证,可以直接在生产者端对 JWT 令牌进行解析验证。
二、认证服务在生成令牌时,存到缓存服务器,生产者从缓存取出消费者令牌,与消费者携带的令牌进行比较验证。
三、生产者拿到消费者的令牌,请求认证服务,由认证服务对签发的令牌进行验证,把验证结果返回生产者。
认证服务
认证服务主要提供创建令牌、签发令牌、返回令牌给客户端、解析验证令牌。
创建认证服务
创建 Spring Boot Web 应用,添加 JWT 实现的 JAR 库(java-jwt 或 jjwt),这里以 java-jwt 库为例。
- java-jwt:此库是 JWT 的标准实现;
- jjwt:此库扩展了压缩功能,即生成的 token 是已压缩后的,非标准的,无法用标准的 JWT 实现来解析它,如果生成和解析都用此库则没有问题,若生成的 token 需要在不同开发语言的系统中解析,则不能使用,无法确保兼容。
微服务信息表
数据库创建一张表,维护微服务信息表,表字段根据实际需要进行扩充。
1
2
3
4
5
6
7
8CREATE TABLE `app_info` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`app_id` varchar(100) NOT NULL,
`secret_key` varchar(100) NOT NULL,
`app_name` varchar(50) DEFAULT NULL,
`app_desc` varchar(250) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微服务应用信息表'核心字段 app_id 和 secret_key,是查询条件。
添加依赖
java-jwt 依赖是必须添加的,如果微服务架构的注册中心是 Eureka,可以添加 eureka-client 依赖并配置注册到注册中心,其它依赖如 fastjson、commons-lang、hutool-all 按需添加。
1
2
3
4
5<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.8.1</version>
</dependency>
编写认证 API
主要两个接口,一个是生成 JWT Token 的 API,另一个是验证 Token 的 API。
生成和验证 API 都需要用到加密算法,建议抽出 JWT 工具类 和 加密算法工具类,便于复用。
生成和验证 JWT Token API
AuthController.class
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63/**
* @name: tokenAuth
* @desc: 认证API
* @author: gxing
* @date: 2019-05-27 14:02
**/
public class AuthController {
private Logger logger = LogManager.getLogger(AuthController.class);
private AppInfoService appInfoService;
/**
* 签发 Token
*
* @param authQuery 认证参数
* @param response 响应
* @return ResultBean
*/
public ResultBean getToken(AuthQuery authQuery, HttpServletRequest request, HttpServletResponse response) {
logger.info("authQuery:{}", JSON.toJSONString(authQuery));
if (StringUtils.isBlank(authQuery.getAppId()) || StringUtils.isBlank(authQuery.getSecretKey())) {
return new ResultBean().fialByNullParam().setMsg("appId and secretKey must not null");
}
//根据appId 和 secretKey 到数据库查询
AppInfo appInfo = appInfoService.queryAppInfo(authQuery);
if (appInfo == null) {
return new ResultBean().fialByNullParam().setMsg("auth fail");
}
String jwtId =Long.toString(System.currentTimeMillis());
//第二个参数是过期时间,单位:分钟,详见工具类,1440分钟=24小时
String token = JavaJwtUtil.getTokenByRSA512(jwtId, 1440);
JwtToken jwtToken = new JwtToken(jwtId, token);
return new ResultBean().success().setDate(jwtToken);
}
/**
* 验证 Token
*
* @param request
* @return ResultBean
*/
public ResultBean verifyToken(HttpServletRequest request) {
String token = request.getHeader("Authorization");
String jwtId = request.getHeader("jwtId");
boolean verify = JavaJwtUtil.verifyTokenByRSA512(token, jwtId);
if (!verify) {
return new ResultBean().fial().setMsg("Token 验证失败");
}
return new ResultBean().success();
}
}相关实体类
- AuthQuery:实体类,查询数据库的条件,包含 appId 和 secretKey 两个属性。
- AppInfo:实体类,微服务应用信息,属性与数据库表 app_info 中的字段对应。
- JwtToken:实体类,封装生成 JWT Token 的必要信息,示例中包含基本地 jwtId 和 token 两个属性。
- ResultBean:实体类,封装响应结果,包含 code、state、msg、data 属性。
封装 JWT 工具类
抽出生成和验证 JWT Token 功能到工具类,主要方法有:
- 生成 Token:token不要有敏感信息,通常包含用户ID,jwtId等信息。
- 验证 Token:检查是否合法,可以指定声明验证。
- 刷新 RSA 公钥和私钥:刷新密钥对是为了防止泄漏、公钥和私钥通常是写死的,也可以做成配置的。集成配置管理中心后,可以对公钥和私钥进行动态修改,修改后重新初始化公钥、私钥对象。
1 | /** |
封装 RSA 加密工具类
下面工具类使用模数的指数来生成 RSA 密钥时,必须重新设置 MODULUS 、PRIVATE_EXPONENT 和 PUBLIC_EXPONENT 属性的值,可取消 main 方法的注释并运行,将打印输出的值复制到这三个对应的属性。
1 | /** |
消费者服务
消费者服务在请求生产者服务前必须先请求 认证服务 拿到到用于认证的 Token,然后每向生产者服务发请求,必须在 请求头 中携带此 Token,通常设置该请求头名为:Authorization。
每次向生产者服务请求前都获取 Token 是不合适的,并且 Token 是有有效期的,第一次获取后,在有效期内可继续使用,所以在拿到 Token 后可以存起来,例如存到环境变量,或存到外部缓存系统 Redis 中,如果 Token 过期则重新获取。
获取 Token 两种方式,一种是在应用启动时就向认证服务请求获取 Token,但不支持动态更新;另一种是使用定时器,动态获取,定时器时间必须小于 Token 的过期时间,建议使用此方式。
应用访问认证服务必须提供 appId 和 secretKey 两种参数,用于从数据库查询该应用的合法性。可以定义实体类注入配置文件中的属性值,或从环境变量( Environment 或 System)中取出。
1 |
|
定时器获取 Token
如果 Token 是采用动态改变策略,可以使用定时任务的方式,定期请求认证服务获取 Token 并动态更新的环境变量,定时任务的间隔时间必须小于 Token 的有效时长。
使用定时任务,需要在 Spring Boot 启动类上添加 @EnableScheduling 注解开启定时任务,再编写定时任务的业务,示例如下。
1 | /** |
应用启动获取 Token
如果验证的 Token 不是动态改变的,可以在应用启动时就请求获取到 Token。
编写初始化 Token 配置类,实现 CommandLineRunner 接口,重写 run 方法。可以使用 RestTemplate 发送请求,如果认证服务、消费者服务都注册到了 Eureka Server(注册中心),也可以通过 Feign Client 来发送请求。启动初始化示例如下:
1 | /** |
缓存 Token
请求获取认证的 Token 也可以缓存到 Redis 中,这样虽然减少了请求认证的次数,但会产生网络延时,所以建议存到服务环境变量中。
请求拦截器设置请求头
HTTP 远程调用通常会用到 HttpClient 或 RestTemplate,Spring Cloud 还可以使用 Feign,在调用前每次手动设置请求头则非常麻烦,而这三种 HTTP 客户端都支持添加拦截器来统一处理请求。
Feign 拦截器设置请求头
在 Spring Cloud 中通常会用 Feign 来调用接口,Feign 提供了请求拦截器 feign.RequestInterceptor 来支持对请求进行统一处理。
Feign 请求拦截器实现 RequestInterceptor 接口
1
2
3
4
5
6
7
8
9
10
11/**
* @name: FeignBasicAuthRequestInterceptor
* @desc: Feign 请求拦截器
**/
public class FeignAuthRequestInterceptor implements RequestInterceptor {
public void apply(RequestTemplate requestTemplate) {
requestTemplate.header("JwtId", System.getProperty("jwtId"));
requestTemplate.header("Authorization", System.getProperty("token"));
}
}将 FeignAuthRequestInterceptor 注册为 Bean
1
2
3
4
5
6
7
8
9
10
11
12/**
* @name: FeignCustomConfig
* @desc: TODO
**/
public class FeignCustomConfig {
public FeignAuthRequestInterceptor feignBasicAuthRequestInterceptor(){
return new FeignAuthRequestInterceptor();
}
}如果有多个 Feign 配置类,可通过 @FeignClient 注解时的 configuration 属性指定该配置类。
RestTemplate 拦截器设置请求头
如果使用 RestTemplate 发送请求,可以给 RestTemplate 添加拦截器来统一处理请求,需要实现 ClientHttpRequestInterceptor 接口。示例如下:
RestTemplate 请求拦截器实现 ClientHttpRequestInterceptor 接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15/**
* @name: RestTemplateInterceptor
* @desc: RestTemplate 请求拦截器
**/
public class RestTemplateRequestInterceptor implements ClientHttpRequestInterceptor {
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
HttpHeaders headers = request.getHeaders();
headers.add("JwtId", System.getProperty("jwtId"));
headers.add("Authorization", System.getProperty("token"));
return execution.execute(request, body);
}
}创建 RestTemplate 实例时添加请求拦截器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16/**
* @name: RestTemplateConfig
* @desc: RestTemplate配置类
**/
public class RestTemplateConfig {
public RestTemplate restTemplate() {
//设置超时时间,毫秒
return new RestTemplateBuilder()
.setConnectTimeout(Duration.ofMillis(1000))
.setReadTimeout(Duration.ofMillis(1000))
.interceptors(new RestTemplateRequestInterceptor())
.build();
}
}
HttpClient 拦截器设置请求头
Apache Http Client 包(org.apache.http)下提供了 HttpRequestInterceptor 拦截器,可用于统一处理请求。
HttpClient 请求拦截器实现 HttpRequestInterceptor 接口
1
2
3
4
5
6
7
8
9
10
11/**
* @name: HttpClientRequestInterceptor
* @desc: HttpClient 请求拦截器
**/
public class HttpClientRequestInterceptor implements HttpRequestInterceptor {
public void process(HttpRequest request, HttpContext context) throws HttpException, IOException {
request.setHeader("JwtId", System.getProperty("JwtId"));
request.setHeader("Authorization", System.getProperty("token"));
}
}创建自定义的 httpClient 实例并添加请求拦截器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15/**
* @name: HttpClientConfig
* @desc: HttpClient 自定义配置
**/
public class HttpClientConfig {
public CloseableHttpClient closeableHttpClient() {
CloseableHttpClient httpclient = HttpClients.custom()
.addInterceptorLast(new HttpClientRequestInterceptor())
.build();
return httpclient;
}
}
生产者服务
生产者服务需要对消费接口请求进行身份认证,从请求头中取出 声明 和 Token,使用 JWT 进行验证。
可以使用 过滤器 或 拦截器 来对请求的身份进行认证,以下是过滤器实现示例:
创建过滤器实现身份认证
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33/**
* @name: HttpTokenAuthFilter
* @desc: 请求身份认证(Token)
**/
public class HttpTokenAuthFilter implements Filter {
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
response.setCharacterEncoding("UTF-8");
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
String token = request.getHeader("Authorization");
String jwtId = request.getHeader("jwtId");
if (StringUtils.isBlank(token) || StringUtils.isBlank(jwtId) || !JavaJwtUtil.verifyTokenByRSA512(token, jwtId)) {
PrintWriter printWriter = response.getWriter();
Map<String, String> resultMap = new HashMap<>();
resultMap.put("state", "fail");
resultMap.put("code", "400");
resultMap.put("msg", "认证失败");
String resultStr = JSON.toJSONString(resultMap);
printWriter.write(resultStr);
} else {
filterChain.doFilter(request, response);
}
}
}注册过滤器为 Bean 来启用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19/**
* @name: FilterConfig
* @desc: 过滤器配置
**/
public class FilterConfig {
public FilterRegistrationBean filterRegistrationBean(){
FilterRegistrationBean registrationBean = new FilterRegistrationBean();
registrationBean.setFilter(new HttpTokenAuthFilter());
List<String> urlPatterns = new ArrayList<>(1);
//针对所有请求
urlPatterns.add("/*");
registrationBean.setUrlPatterns(urlPatterns);
return registrationBean;
}
}
网关统一身份认证
如内部微服务必须经过网关才能访问,则可以在网关统一执行身份认证。例如,Zuul 网关,可创建一个前置过滤器(pre filter),在过滤器执行统一认证,捕抓并抛出异常,阻断路由到下游服务。关于 Zuul 过滤器,可参考 Spring Cloud系列(九):API网关 Zuul 其它详细设置。
创建 Token 认证前置过滤器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40/**
* @name: TokenAuthPreFilter
* @desc: 统一身份认证
**/
public class TokenAuthPreFilter extends ZuulFilter {
public String filterType() {
return FilterConstants.PRE_TYPE;
}
public int filterOrder() {
return 5;
}
public boolean shouldFilter() {
return true;
}
public Object run() throws ZuulException {
RequestContext context = RequestContext.getCurrentContext();
HttpServletRequest request = context.getRequest();
String jwtId = request.getHeader("JwtId");
String authorization = request.getHeader("Authorization");
System.out.println("JwtId : " + jwtId);
System.out.println("Authorization : " + authorization);
try {
JavaJwtUtil.verifyTokenByRSA512(authorization, jwtId);
} catch (Exception e) {
//必须抛出或打印出错误,才不会路由到下游服务
// throw e;
Throwable throwable = context.getThrowable();
throwable.printStackTrace();
}
return null;
}
}将认证过滤器注册为 Bean
1
2
3
4
5
6
7
8
9
10
11
12
13/**
* @name: ZuulConfig
* @desc: Zuul 网关配置
**/
public class ZuulConfig {
public TokenAuthPreFilter tokenAuthPreFilter(){
return new TokenAuthPreFilter();
}
}