若依微服务供第三方调用的接口鉴权问题

528 阅读4分钟

需求

现在有一个需求:

  1. 微服务本身要使用验证码,因为要暴露给公网;
  2. 有一个模块,比如名为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_tokensm4加密一下。

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==

如此,我们就实现了目标:

  1. 其他系统必须使用/auth/login_third_party接口生成的token,才可以访问hello模块的接口;
  2. /auth/login_third_party接口生成的token只能用于hello模块接口,其他模块都不可以;
  3. /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中,再发起接口的调用。为什么非要这么做呢,直接把用户名密码作为参数传递不行吗?

这是因为直接将用户名密码作为参数传递是不安全的,如果没有加解密机制,黑客可以直接破解,然后你的系统就完蛋了。当然,可以设计一套加解密机制来解决这个问题。

另一种方案就是让后端先调用登录再调用正常接口,这样就无需考虑安全性问题了。直接将密钥存到后端是不容易泄露的。