需求
现在有一个需求:
- 微服务本身要使用验证码,因为要暴露给公网;
- 有一个模块,比如名为
hello,它的所有接口都是要暴露给第三方调用的,它也需要鉴权。
如果我们直接使用原来的登录接口:/auth/login,会报错,必须得有验证码。
但是验证码都是图片,接口层面不可能获取验证码内容,怎么办呢?
不成熟想法:SM4加密
大体思路:hello模块的所有接口走和主程序不同的鉴权机制。
首先,在auth模块创建一个login_third_party接口,它的作用是没有验证码,只输入账号密码就可以登陆,它的token使用sm4加密:
@Value("${yharim.sm4-key}")
private String sm4Key;
@PostMapping("login_third_party")
public R<?> loginThirdParty(@RequestBody LoginBody form) throws Exception {
LoginUser userInfo = sysLoginService.login(form.getUsername(), form.getPassword());
Map<String, Object> token = tokenService.createToken(userInfo);
token.put("access_token", Sm4Utils.encrypt(token.get("access_token").toString(), sm4Key));
return R.ok(token);
}
如代码所示,还是使用原来的jwt去生成token,只是把access_token用sm4加密一下。
sm4密钥需要在ruoyi_auth_dev.yml配置:
yharim:
# sm4密钥
sm4-key: ibwXwIf2ulraSmwpG4kG8Q==:eRhijBXRF0emMuS/Zj7ssQ==
当然,这个接口我们也要加入ruoyi_gateway_dev.yml的接口白名单:
# 安全配置
security:
# 不校验白名单
ignore:
whites:
- /auth/login_no_captcha
此时就可以使用账号密码调用这个登录接口,返回的accessToken是经过sm4加密的token:
接下来,我们去网关模块,给hello模块单独配置一套token校验规则。
找到filter/AuthFilter,如果接口属于hello模块,那么尝试进行sm4解密,如果解密失败,说明token有问题,直接返回错误信息:
@Resource
private ThirdPartyProperties thirdPartyProperties;
@Value("${keso.sm4-key}")
private String sm4Key;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain)
{
...
// erp接口模块的token进行sm4解密
if (StringUtils.matches(url, thirdPartyProperties.getList())) {
try {
token = Sm4Utils.decrypt(token, sm4Key);
} catch (Exception e) {
return unauthorizedResponse(exchange, "令牌来源错误");
}
}
...
此时,hello模块和原系统其他接口的token就不再共享。如果黑客想破解,必须知道sm4密钥是多少。
这里的ThirdPartyProperties配置了放行接口路径,我们可以看一下网关配置文件:
# 安全配置
security:
# 供第三方使用的接口名单
third-party:
list:
- /erpinterface/**
keso:
# sm4密钥
sm4-key: ibwXwIf2ulraSmwpG4kG8Q==:eRhijBXRF0emMuS/Zj7ssQ==
如此,我们就实现了目标:
- 其他系统必须使用
/auth/login_third_party接口生成的token,才可以访问hello模块的接口; /auth/login_third_party接口生成的token只能用于hello模块接口,其他模块都不可以;/auth/login接口生成的token不可以用于hello模块接口,其他模块都可以。
最后,sm4工具类如下:
public class Sm4Utils {
private static final String DELIMITER = ":";
static {
Security.addProvider(new BouncyCastleProvider());
}
public static byte[] generateKey() throws Exception {
KeyGenerator kg = KeyGenerator.getInstance("SM4", "BC");
kg.init(128); // 128位密钥
return kg.generateKey().getEncoded();
}
public static String encrypt(String data, byte[] key, byte[] iv) throws Exception {
Cipher cipher = Cipher.getInstance("SM4/CBC/PKCS5Padding", "BC");
SecretKeySpec keySpec = new SecretKeySpec(key, "SM4");
IvParameterSpec ivSpec = new IvParameterSpec(iv);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
byte[] encrypted = cipher.doFinal(data.getBytes());
return Base64.getEncoder().encodeToString(encrypted);
}
public static String encrypt(String data, String keyAndIv) throws Exception {
byte[][] bytes = decodeKeyAndIv(keyAndIv);
return Sm4Utils.encrypt(data, bytes[0], bytes[1]);
}
public static String decrypt(String encryptedData, byte[] key, byte[] iv) throws Exception {
Cipher cipher = Cipher.getInstance("SM4/CBC/PKCS5Padding", "BC");
SecretKeySpec keySpec = new SecretKeySpec(key, "SM4");
IvParameterSpec ivSpec = new IvParameterSpec(iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encryptedData));
return new String(decrypted);
}
public static String decrypt(String encryptedData, String keyAndIv) throws Exception {
byte[][] bytes = decodeKeyAndIv(keyAndIv);
return Sm4Utils.decrypt(encryptedData, bytes[0], bytes[1]);
}
public static byte[] generateIV() {
SecureRandom secureRandom = new SecureRandom();
byte[] iv = new byte[16];
secureRandom.nextBytes(iv);
return iv;
}
public static String encodeKeyAndIv(byte[] key, byte[] iv) {
String encodedKey = Base64.getEncoder().encodeToString(key);
String encodedIv = Base64.getEncoder().encodeToString(iv);
return encodedKey + DELIMITER + encodedIv;
}
public static byte[][] decodeKeyAndIv(String encoded) {
String[] parts = encoded.split(DELIMITER, 2);
if (parts.length != 2) {
throw new IllegalArgumentException("编码的字符串格式不正确");
}
byte[] key = Base64.getDecoder().decode(parts[0]);
byte[] iv = Base64.getDecoder().decode(parts[1]);
return new byte[][]{key, iv};
}
public static void main(String[] args) throws Exception {
// 生成密钥和IV
byte[] key = Sm4Utils.generateKey();
byte[] iv = generateIV(); // 实际项目应使用SecureRandom生成
String base64 = encodeKeyAndIv(key, iv);
System.out.println(base64);
// 加密
String encrypted = Sm4Utils.encrypt("Hello SM4", base64);
System.out.println("加密结果: " + encrypted);
// 解密
String decrypted = Sm4Utils.decrypt(encrypted, base64);
System.out.println("解密结果: " + decrypted);
}
}
问题
这个想法的问题在于,若依在common模块的common-security中有一个类叫做HeaderInterceptor,下面是其中的preHandle方法:
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
{
if (!(handler instanceof HandlerMethod))
{
return true;
}
SecurityContextHolder.setUserId(ServletUtils.getHeader(request, SecurityConstants.DETAILS_USER_ID));
SecurityContextHolder.setUserName(ServletUtils.getHeader(request, SecurityConstants.DETAILS_USERNAME));
SecurityContextHolder.setUserKey(ServletUtils.getHeader(request, SecurityConstants.USER_KEY));
String token = SecurityUtils.getToken();
if (StringUtils.isNotEmpty(token))
{
LoginUser loginUser = AuthUtil.getLoginUser(token);
if (StringUtils.isNotNull(loginUser))
{
AuthUtil.verifyLoginUserExpire(loginUser);
SecurityContextHolder.set(SecurityConstants.LOGIN_USER, loginUser);
}
}
return true;
}
当我们成功调用一次接口后(需要提供token的),它会取出token,校验有效期,如果有效,则会刷新并延长有效期;如果超出有效期,则过期。
我们通过sm4加密的token,就会导致token在这里无法成功解密,进一步导致token永远无法过期。
最终方案
其实我们的核心诉求不过是必要输入验证码而已。在网关模块有一个ValidateCodeFilter,用于过滤需要进行验证码过滤的请求。
我们只需要扩展一下,让hello模块的接口都放行:
// 非登录/注册请求、验证码关闭、hello接口模块的接口,不处理
if (!StringUtils.equalsAnyIgnoreCase(request.getURI().getPath(), VALIDATE_URL)
|| !captchaProperties.getEnabled()
|| StringUtils.matches(request.getURI().getPath(), thirdPartyProperties.getList()))
{
return chain.filter(exchange);
}
配置中心如下形式:
# 供第三方使用的接口名单
third-party:
list:
- /hello/**
因为登录请求也会有验证码校验,所以我们在auth模块添加一个新的接口来进行登录,在网关配置文件里放行这个接口:
@PostMapping("login_third_party")
public R<?> loginThirdParty(@RequestBody LoginBody form) {
LoginUser userInfo = sysLoginService.login(form.getUsername(), form.getPassword());
Map<String, Object> token = tokenService.createToken(userInfo);
return R.ok(token);
}
其实代码和登录接口完全一样。
为什么要这么调
通过上文可知,所有供第三方调用的接口都需要先调用登录接口获得token,然后将其放入headers中,再发起接口的调用。为什么非要这么做呢,直接把用户名密码作为参数传递不行吗?
这是因为直接将用户名密码作为参数传递是不安全的,如果没有加解密机制,黑客可以直接破解,然后你的系统就完蛋了。当然,可以设计一套加解密机制来解决这个问题。
另一种方案就是让后端先调用登录再调用正常接口,这样就无需考虑安全性问题了。直接将密钥存到后端是不容易泄露的。