微服务之间调用的安全认证

微服务之间的相互调用,需要一套认证机制来确认调用是安全的。这不同于在 API 网关的统一认证,主要是防止在微服务暴露在外网的情况下,内部接口被外部恶意调用。

如果微服务是在内网,对外暴露的只有 API 网关,则可以不用做认证。本篇以 JWT 技术来实现安全认证。更多关于 JWT ,可参考分布式应用系列(一):详细理解 JWT(Json Web Token)

微服务架构中,通常会将认证功能独立成一个微服务,即创建一个专门处理认证、授权、解析、核验的认证服务,可叫认证中心。

其实 API 调用安全认证与 OSS 单点登录认证,在总体流程是上是相似的的,消费者首先请求认证服务,认证服务创建签发令牌(Token)返回给客户端,消费者带着令牌发请求到生产者,接下来就是对令牌的验证,验证通过就返回到业务层。

令牌验证三种方案:

一、令牌是基于 JWT 创建的,此令牌支持自验证,可以直接在生产者端对 JWT 令牌进行解析验证。

二、认证服务在生成令牌时,存到缓存服务器,生产者从缓存取出消费者令牌,与消费者携带的令牌进行比较验证。

三、生产者拿到消费者的令牌,请求认证服务,由认证服务对签发的令牌进行验证,把验证结果返回生产者。

api-auth

认证服务

认证服务主要提供创建令牌、签发令牌、返回令牌给客户端、解析验证令牌。

创建认证服务

创建 Spring Boot Web 应用,添加 JWT 实现的 JAR 库(java-jwt 或 jjwt),这里以 java-jwt 库为例。

  • java-jwt:此库是 JWT 的标准实现;
  • jjwt:此库扩展了压缩功能,即生成的 token 是已压缩后的,非标准的,无法用标准的 JWT 实现来解析它,如果生成和解析都用此库则没有问题,若生成的 token 需要在不同开发语言的系统中解析,则不能使用,无法确保兼容。
  1. 微服务信息表

    数据库创建一张表,维护微服务信息表,表字段根据实际需要进行扩充。

    1
    2
    3
    4
    5
    6
    7
    8
    CREATE 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_idsecret_key,是查询条件。

  2. 添加依赖

    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 工具类 和 加密算法工具类,便于复用。

  1. 生成和验证 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
    **/
    @RestController
    @RequestMapping("/auth")
    public class AuthController {

    private Logger logger = LogManager.getLogger(AuthController.class);

    @Autowired
    private AppInfoService appInfoService;

    /**
    * 签发 Token
    *
    * @param authQuery 认证参数
    * @param response 响应
    * @return ResultBean
    */
    @RequestMapping("/token")
    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
    */
    @RequestMapping("/verify")
    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();
    }
    }
  2. 相关实体类

    • AuthQuery:实体类,查询数据库的条件,包含 appIdsecretKey 两个属性。
    • AppInfo:实体类,微服务应用信息,属性与数据库表 app_info 中的字段对应。
    • JwtToken:实体类,封装生成 JWT Token 的必要信息,示例中包含基本地 jwtIdtoken 两个属性。
    • ResultBean:实体类,封装响应结果,包含 code、state、msg、data 属性。

封装 JWT 工具类

抽出生成和验证 JWT Token 功能到工具类,主要方法有:

  • 生成 Token:token不要有敏感信息,通常包含用户ID,jwtId等信息。
  • 验证 Token:检查是否合法,可以指定声明验证。
  • 刷新 RSA 公钥和私钥:刷新密钥对是为了防止泄漏、公钥和私钥通常是写死的,也可以做成配置的。集成配置管理中心后,可以对公钥和私钥进行动态修改,修改后重新初始化公钥、私钥对象。
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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
/**
* @name: JavaJwtUtil
* @desc: java_jwt 库工具类,创建签发验证token
**/
public class JavaJwtUtil {

private static RSAPrivateKey rsaPrivateKey = RSAUtil.getPrivateKey(RSAUtil.MODULUS, RSAUtil.PRIVATE_EXPONENT);
private static RSAPublicKey rsaPublicKey = RSAUtil.getPublicKey(RSAUtil.MODULUS, RSAUtil.PUBLIC_EXPONENT);


/**
* HMAC256 算法签发Token
*
* @param jwtId 用户id
* @param secret 密钥
* @return String
*/
public static String getTokenByHMAC256(String jwtId, String secret) {
/*默认一天有效期*/
Long endDateTime = System.currentTimeMillis() + 1000 * 60 * 1440;

String token = JWT.create()
.withClaim("jwtId", jwtId)
.withExpiresAt(new Date(endDateTime))
.sign(Algorithm.HMAC256(secret));
return token;
}

/**
* HMAC256 算法签发Token
*
* @param jwtId 用户id
* @param exp 过期时间,单位:分钟
* @param secret 密钥
* @return String
*/
public static String getTokenByHMAC256(String jwtId, int exp, String secret) {
Long endDateTime = System.currentTimeMillis() + 1000 * 60 * exp;

String token = JWT.create()
.withClaim("jwtId", jwtId)
.withExpiresAt(new Date(endDateTime))
.sign(Algorithm.HMAC256(secret));
return token;
}

/**
* RSA512 算法签发Token
*
* @param jwtId 用户ID
* @return String
*/
public static String getTokenByRSA512(String jwtId) {
Long endDateTime = System.currentTimeMillis() + 1000 * 60 * 1440;

Algorithm algorithm = Algorithm.RSA512(rsaPublicKey, rsaPrivateKey);
String token = JWT.create()
.withClaim("jwtId", jwtId)
.withExpiresAt(new Date(endDateTime))
.sign(algorithm);
return token;
}

/**
* RSA512 算法签发Token
*
* @param jwtId 用户id
* @param exp 有效期
* @return String
*/
public static String getTokenByRSA512(String jwtId, int exp) {

Long endDateTime = System.currentTimeMillis() + 1000 * 60 * exp;

Algorithm algorithm = Algorithm.RSA512(rsaPublicKey, rsaPrivateKey);
String token = JWT.create()
.withClaim("jwtId", jwtId)
.withExpiresAt(new Date(endDateTime))
.sign(algorithm);
return token;
}

/**
* 验证 HMAC256 Token
*
* @param token 令牌
* @param jwtId 用户id
* @param secret 密钥
* @return boolean
*/
public static boolean verifyTokenByHMAC256(String token, String jwtId, String secret) {

JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(secret))
.withClaim("jwtId", jwtId).build();
return verifyToken(token, jwtVerifier);
}

/**
* 验证 RSA512 Token
*
* @param token 令牌
* @param jwtId 用户ID
* @return boolean
*/
public static boolean verifyTokenByRSA512(String token, String jwtId) {

JWTVerifier jwtVerifier = JWT.require(Algorithm.RSA512(rsaPublicKey, rsaPrivateKey))
.withClaim("jwtId", jwtId).build();
return verifyToken(token, jwtVerifier);
}

/**
* 验证 Token
*
* @param token 令牌
* @param jwtVerifier JWTVerifier对象
* @return boolean
*/
private static boolean verifyToken(String token, JWTVerifier jwtVerifier) {
try {
jwtVerifier.verify(token);
return true;
} catch (JWTVerificationException e) {
e.printStackTrace();
return false;
}
}

/*
public static void main(String[] args) {

String token1 = JavaJwtUtil.getTokenByHMAC256("1001", 60, "AABBCCDD");
System.out.println(token1);

String token2 = JavaJwtUtil.getTokenByHMAC256("1001", "aabbccdd");
System.out.println(token2);

boolean check = JavaJwtUtil.verifyTokenByHMAC256(token2, "1001", "aabbccdd");
System.out.println(check);

String tokenByRSA = JavaJwtUtil.getTokenByRSA512("1001");
String tokenByRSA = JavaJwtUtil.getTokenByRSA512("1001", 1);

System.out.println(tokenByRSA);

boolean check = JavaJwtUtil.verifyTokenByRSA512(tokenByRSA, "1001");
System.out.println(check);

}
*/
}

封装 RSA 加密工具类

下面工具类使用模数的指数来生成 RSA 密钥时,必须重新设置 MODULUSPRIVATE_EXPONENTPUBLIC_EXPONENT 属性的值,可取消 main 方法的注释并运行,将打印输出的值复制到这三个对应的属性。

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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
/**
* @name: RSAUtil
* @desc: RSA 加解密工具类
* @author: gxing
* @date: 2019-05-27 13:57
**/
public class RSAUtil {

public static String RSA_ALGORITHM = "RSA";

/*模数*/
public static String MODULUS = "";
/*公钥指数*/
public static String PUBLIC_EXPONENT = "65537";
/*私钥指数*/
public static String PRIVATE_EXPONENT = "";

/**
* 公钥加密
* @param data
* @return
* @throws Exception
*/
public static String encryptByPublicKey(String data) throws Exception {
RSAPublicKey publicKey = RSAUtil.getPublicKey(MODULUS, PUBLIC_EXPONENT);
Cipher cipher = Cipher.getInstance(RSA_ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
// 模长
int key_len = publicKey.getModulus().bitLength() / 8;
// 加密数据长度 <= 模长-11
String[] datas = splitString(data, key_len - 11);
String mi = "";
// 如果明文长度大于模长-11则要分组加密
for (String s : datas) {
mi += bcd2Str(cipher.doFinal(s.getBytes()));
}
return mi;
}

/**
* 私钥解密
* @param data
* @return
* @throws Exception
*/
public static String decryptByPrivateKey(String data) throws Exception {
RSAPrivateKey privateKey = RSAUtil.getPrivateKey(MODULUS, PRIVATE_EXPONENT);
Cipher cipher = Cipher.getInstance(RSA_ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, privateKey);
// 模长
int key_len = privateKey.getModulus().bitLength() / 8;
byte[] bytes = data.getBytes();
byte[] bcd = ASCII_To_BCD(bytes, bytes.length);
// 如果密文长度大于模长则要分组解密
String ming = "";
byte[][] arrays = splitArray(bcd, key_len);
for (byte[] arr : arrays) {
ming += new String(cipher.doFinal(arr));
}
return ming;
}


/**
* 生成公钥和私钥
* @throws NoSuchAlgorithmException
*/
public static HashMap<String, Object> getKeys() throws NoSuchAlgorithmException {
HashMap<String, Object> map = new HashMap<String, Object>();
KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance(RSA_ALGORITHM);
keyPairGen.initialize(1024);
KeyPair keyPair = keyPairGen.generateKeyPair();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
map.put("public", publicKey);
map.put("private", privateKey);
return map;
}

/**
* 使用模和指数生成RSA公钥
* 注意:【此代码用了默认补位方式,为RSA/None/PKCS1Padding,不同JDK默认的补位方式可能不同,如Android默认是RSA
* /None/NoPadding】
* @param modulus 模
* @param exponent 指数
* @return
*/
public static RSAPublicKey getPublicKey(String modulus, String exponent) {
try {
BigInteger b1 = new BigInteger(modulus);
BigInteger b2 = new BigInteger(exponent);
KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGORITHM);
RSAPublicKeySpec keySpec = new RSAPublicKeySpec(b1, b2);
return (RSAPublicKey) keyFactory.generatePublic(keySpec);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}

/**
* 使用模和指数生成RSA私钥
* 注意:【此代码用了默认补位方式,为RSA/None/PKCS1Padding,不同JDK默认的补位方式可能不同,如Android默认是RSA
* /None/NoPadding】
*
* @param modulus 模
* @param exponent 指数
* @return
*/
public static RSAPrivateKey getPrivateKey(String modulus, String exponent) {
try {
BigInteger b1 = new BigInteger(modulus);
BigInteger b2 = new BigInteger(exponent);
KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGORITHM);
RSAPrivateKeySpec keySpec = new RSAPrivateKeySpec(b1, b2);
return (RSAPrivateKey) keyFactory.generatePrivate(keySpec);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}

/**
* 公钥加密
*
* @param data
* @param publicKey
* @return
* @throws Exception
*/
public static String encryptByPublicKey(String data, RSAPublicKey publicKey) throws Exception {
Cipher cipher = Cipher.getInstance(RSA_ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
// 模长
int key_len = publicKey.getModulus().bitLength() / 8;
// 加密数据长度 <= 模长-11
String[] datas = splitString(data, key_len - 11);
String mi = "";
// 如果明文长度大于模长-11则要分组加密
for (String s : datas) {
mi += bcd2Str(cipher.doFinal(s.getBytes()));
}
return mi;
}

/**
* 私钥解密
*
* @param data
* @param privateKey
* @return
* @throws Exception
*/
public static String decryptByPrivateKey(String data, RSAPrivateKey privateKey) throws Exception {
Cipher cipher = Cipher.getInstance(RSA_ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, privateKey);
// 模长
int key_len = privateKey.getModulus().bitLength() / 8;
byte[] bytes = data.getBytes();
byte[] bcd = ASCII_To_BCD(bytes, bytes.length);
// 如果密文长度大于模长则要分组解密
String ming = "";
byte[][] arrays = splitArray(bcd, key_len);
for (byte[] arr : arrays) {
ming += new String(cipher.doFinal(arr));
}
return ming;
}

/**
* ASCII码转BCD码
*/
public static byte[] ASCII_To_BCD(byte[] ascii, int asc_len) {
byte[] bcd = new byte[asc_len / 2];
int j = 0;
for (int i = 0; i < (asc_len + 1) / 2; i++) {
bcd[i] = asc_to_bcd(ascii[j++]);
bcd[i] = (byte) (((j >= asc_len) ? 0x00 : asc_to_bcd(ascii[j++])) + (bcd[i] << 4));
}
return bcd;
}

public static byte asc_to_bcd(byte asc) {
byte bcd;

if ((asc >= '0') && (asc <= '9'))
bcd = (byte) (asc - '0');
else if ((asc >= 'A') && (asc <= 'F'))
bcd = (byte) (asc - 'A' + 10);
else if ((asc >= 'a') && (asc <= 'f'))
bcd = (byte) (asc - 'a' + 10);
else
bcd = (byte) (asc - 48);
return bcd;
}

/**
* BCD转字符串
*/
public static String bcd2Str(byte[] bytes) {
char temp[] = new char[bytes.length * 2], val;

for (int i = 0; i < bytes.length; i++) {
val = (char) (((bytes[i] & 0xf0) >> 4) & 0x0f);
temp[i * 2] = (char) (val > 9 ? val + 'A' - 10 : val + '0');

val = (char) (bytes[i] & 0x0f);
temp[i * 2 + 1] = (char) (val > 9 ? val + 'A' - 10 : val + '0');
}
return new String(temp);
}

/**
* 拆分字符串
*/
public static String[] splitString(String string, int len) {
int x = string.length() / len;
int y = string.length() % len;
int z = 0;
if (y != 0) {
z = 1;
}
String[] strings = new String[x + z];
String str = "";
for (int i = 0; i < x + z; i++) {
if (i == x + z - 1 && y != 0) {
str = string.substring(i * len, i * len + y);
} else {
str = string.substring(i * len, i * len + len);
}
strings[i] = str;
}
return strings;
}

/**
* 拆分数组
*/
public static byte[][] splitArray(byte[] data, int len) {
int x = data.length / len;
int y = data.length % len;
int z = 0;
if (y != 0) {
z = 1;
}
byte[][] arrays = new byte[x + z][];
byte[] arr;
for (int i = 0; i < x + z; i++) {
arr = new byte[len];
if (i == x + z - 1 && y != 0) {
System.arraycopy(data, i * len, arr, 0, y);
} else {
System.arraycopy(data, i * len, arr, 0, len);
}
arrays[i] = arr;
}
return arrays;
}

public static void main(String[] args) throws Exception {
/*HashMap<String, Object> map = RSAUtil.getKeys();
//生成公钥和私钥
RSAPublicKey publicKey = (RSAPublicKey) map.get("public");
RSAPrivateKey privateKey = (RSAPrivateKey) map.get("private");

//模
String MODULUS = publicKey.getModulus().toString();
System.err.println("MODULUS:" + MODULUS);
//公钥指数
String PUBLIC_EXPONENT = publicKey.getPublicExponent().toString();
System.err.println("PUBLIC_EXPONENT:" + PUBLIC_EXPONENT);
//私钥指数
String PRIVATE_EXPONENT = privateKey.getPrivateExponent().toString();
System.err.println("PRIVATE_EXPONENT:" + PRIVATE_EXPONENT);


//明文
String ming = "Hello World";
//使用模和指数生成公钥和私钥
RSAPublicKey pubKey = RSAUtil.getPublicKey(MODULUS, PUBLIC_EXPONENT);
RSAPrivateKey priKey = RSAUtil.getPrivateKey(MODULUS, PRIVATE_EXPONENT);

//加密后的密文
String mi = RSAUtil.encryptByPublicKey(ming, pubKey);
System.err.println("加密后密文:"+mi);
//解密后的明文
ming = RSAUtil.decryptByPrivateKey(mi, priKey);
System.err.println("解密后明文" + ming);

String encStr = encryptByPublicKey("Hello World");
System.out.println(encStr);
System.out.println(decryptByPrivateKey(encStr));
*/
}
}

消费者服务

消费者服务在请求生产者服务前必须先请求 认证服务 拿到到用于认证的 Token,然后每向生产者服务发请求,必须在 请求头 中携带此 Token,通常设置该请求头名为:Authorization

每次向生产者服务请求前都获取 Token 是不合适的,并且 Token 是有有效期的,第一次获取后,在有效期内可继续使用,所以在拿到 Token 后可以存起来,例如存到环境变量,或存到外部缓存系统 Redis 中,如果 Token 过期则重新获取。

获取 Token 两种方式,一种是在应用启动时就向认证服务请求获取 Token,但不支持动态更新;另一种是使用定时器,动态获取,定时器时间必须小于 Token 的过期时间,建议使用此方式。

应用访问认证服务必须提供 appIdsecretKey 两种参数,用于从数据库查询该应用的合法性。可以定义实体类注入配置文件中的属性值,或从环境变量( Environment 或 System)中取出。

1
2
3
4
5
6
7
8
@Component
@ConfigurationProperties(prefix = "common.property.app")
public class AuthQuery {

private String appId;
private String secretKey;
//-----省略 set/get 方法------
}

定时器获取 Token

如果 Token 是采用动态改变策略,可以使用定时任务的方式,定期请求认证服务获取 Token 并动态更新的环境变量,定时任务的间隔时间必须小于 Token 的有效时长。

使用定时任务,需要在 Spring Boot 启动类上添加 @EnableScheduling 注解开启定时任务,再编写定时任务的业务,示例如下。

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
/**
* @name: TokenScheduledTask
* @desc: 定时任务动态更新 Token
**/
@Component
public class TokenScheduledTask {

@Autowired
private AuthQuery authQuery;
private static Logger logger = LogManager.getLogger(TokenScheduledTask.class);

//20小时,token默认有效期是24小时
private final static long DELAY = 1000 * 60 * 60 * 20;

@Autowired(required = false)
private AuthService authService;

@Scheduled(fixedDelay = DELAY)
public void reloadAuthToken() {
JwtToken jwtToken = this.getToken();
while (null == jwtToken) {
try {
Thread.sleep(1000);
jwtToken = getToken();
} catch (InterruptedException e) {
logger.info("thread sleep error", e);
e.printStackTrace();
}
}
System.setProperty("jwtId", jwtToken.getJwtId());
System.setProperty("token", jwtToken.getToken());

}

private JwtToken getToken() {
ResultBean result = authService.getToken(authQuery);
LinkedHashMap<String, String> resultDate = (LinkedHashMap<String, String>) result.getDate();
if (null == resultDate) {
return null;
}
return new JwtToken(resultDate.get("jwtId"), resultDate.get("token"));
}
}

应用启动获取 Token

如果验证的 Token 不是动态改变的,可以在应用启动时就请求获取到 Token。

编写初始化 Token 配置类,实现 CommandLineRunner 接口,重写 run 方法。可以使用 RestTemplate 发送请求,如果认证服务、消费者服务都注册到了 Eureka Server(注册中心),也可以通过 Feign Client 来发送请求。启动初始化示例如下:

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
/**
* @name: InitTokenConfig
* @desc: 应用启动时初始化 Token
**/
@Component
public class InitTokenConfig implements CommandLineRunner {

@Autowired
private RestTemplate restTemplateOne;
@Autowired
private AuthQuery authQuery;

@Override
public void run(String... args) throws Exception {

String url = "http://localhost:9060/auth/token";

MultiValueMap<String, Object> paramMap = new LinkedMultiValueMap<>();
paramMap.add("appId", this.authQuery.getAppId());
paramMap.add("secretKey", this.authQuery.getSecretKey());
ResultBean resultBean = restTemplateOne.postForObject(url, paramMap, ResultBean.class);
LinkedHashMap<String,String> linkedHashMap = (LinkedHashMap<String, String>) resultBean.getDate();

//设置到系统环境
System.setProperty("jwtId", linkedHashMap.get("jwtId"));
System.setProperty("token", linkedHashMap.get("token"));

}
}

缓存 Token

请求获取认证的 Token 也可以缓存到 Redis 中,这样虽然减少了请求认证的次数,但会产生网络延时,所以建议存到服务环境变量中。

请求拦截器设置请求头

HTTP 远程调用通常会用到 HttpClient 或 RestTemplate,Spring Cloud 还可以使用 Feign,在调用前每次手动设置请求头则非常麻烦,而这三种 HTTP 客户端都支持添加拦截器来统一处理请求。

Feign 拦截器设置请求头

在 Spring Cloud 中通常会用 Feign 来调用接口,Feign 提供了请求拦截器 feign.RequestInterceptor 来支持对请求进行统一处理。

  1. Feign 请求拦截器实现 RequestInterceptor 接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /**
    * @name: FeignBasicAuthRequestInterceptor
    * @desc: Feign 请求拦截器
    **/
    public class FeignAuthRequestInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate requestTemplate) {
    requestTemplate.header("JwtId", System.getProperty("jwtId"));
    requestTemplate.header("Authorization", System.getProperty("token"));
    }
    }
  2. 将 FeignAuthRequestInterceptor 注册为 Bean

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /**
    * @name: FeignCustomConfig
    * @desc: TODO
    **/
    @Component
    public class FeignCustomConfig {

    @Bean
    public FeignAuthRequestInterceptor feignBasicAuthRequestInterceptor(){
    return new FeignAuthRequestInterceptor();
    }
    }
  3. 如果有多个 Feign 配置类,可通过 @FeignClient 注解时的 configuration 属性指定该配置类。

RestTemplate 拦截器设置请求头

如果使用 RestTemplate 发送请求,可以给 RestTemplate 添加拦截器来统一处理请求,需要实现 ClientHttpRequestInterceptor 接口。示例如下:

  1. 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 {
    @Override
    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);
    }
    }
  2. 创建 RestTemplate 实例时添加请求拦截器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    /**
    * @name: RestTemplateConfig
    * @desc: RestTemplate配置类
    **/
    @Configuration
    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 拦截器,可用于统一处理请求。

  1. HttpClient 请求拦截器实现 HttpRequestInterceptor 接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /**
    * @name: HttpClientRequestInterceptor
    * @desc: HttpClient 请求拦截器
    **/
    public class HttpClientRequestInterceptor implements HttpRequestInterceptor {
    @Override
    public void process(HttpRequest request, HttpContext context) throws HttpException, IOException {
    request.setHeader("JwtId", System.getProperty("JwtId"));
    request.setHeader("Authorization", System.getProperty("token"));
    }
    }
  2. 创建自定义的 httpClient 实例并添加请求拦截器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    /**
    * @name: HttpClientConfig
    * @desc: HttpClient 自定义配置
    **/
    @Component
    public class HttpClientConfig {

    @Bean
    public CloseableHttpClient closeableHttpClient() {
    CloseableHttpClient httpclient = HttpClients.custom()
    .addInterceptorLast(new HttpClientRequestInterceptor())
    .build();
    return httpclient;
    }
    }

生产者服务

生产者服务需要对消费接口请求进行身份认证,从请求头中取出 声明 和 Token,使用 JWT 进行验证。

可以使用 过滤器拦截器 来对请求的身份进行认证,以下是过滤器实现示例:

  1. 创建过滤器实现身份认证

    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 {

    @Override
    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);
    }

    }
    }
  2. 注册过滤器为 Bean 来启用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    /**
    * @name: FilterConfig
    * @desc: 过滤器配置
    **/
    @Configuration
    public class FilterConfig {

    @Bean
    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 其它详细设置

  1. 创建 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 {
    @Override
    public String filterType() {
    return FilterConstants.PRE_TYPE;
    }

    @Override
    public int filterOrder() {
    return 5;
    }

    @Override
    public boolean shouldFilter() {

    return true;
    }

    @Override
    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;
    }
    }
  2. 将认证过滤器注册为 Bean

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    /**
    * @name: ZuulConfig
    * @desc: Zuul 网关配置
    **/

    @Configuration
    public class ZuulConfig {

    @Bean
    public TokenAuthPreFilter tokenAuthPreFilter(){
    return new TokenAuthPreFilter();
    }
    }

其它参考

  1. 微服务架构之访问安全
  2. 并发登录人数控制
  3. SpringBoot 并发登录人数控制
作者

光星

发布于

2019-05-26

更新于

2022-07-12

许可协议

评论