后端的“破接口”,我用NestJS中间层处理(3)——数据加密

349 阅读8分钟

上一篇我们说了后端数据格式的问题,这一篇我们看一个细节点。

序言

相信各位的项目里总得有个登录功能,最常规的登录流程如下:

客户端:填写用户名 + 密码

把用户名 + 密码通过接口发送给后端

后端跟数据库中的存储信息比对,正确返回用户信息,错误提示“用户名或密码错误

OK,流程肯定是这样没错,但是我们现在看看后端给的登录接口:

后端要求的接口传递逻辑.png

然后再看看目前中间层接口怎么传参的

当前中间层的传参方式.png

从前端直接明文传输用户名和密码???这样肯定不行……

所以这一次,我们来用登录做一个示例,看看这种敏感信息在接口传输的时候,我们怎么保障数据安全。

数据加密原理

既然不能用明文,我们肯定很容易想到:给数据加密,数据传输的时候用密文,等传输成功了再解密。

而加密/解密的逻辑其实也不复杂。

加密解密流程.png

整个加密过程中,我们可以看到发挥作用的,主要是密钥加密/解密算法这两部分。

在这个流程中,我们留意到有两个密钥。实际上,这两个密钥可以相同,也可以不同,也因此区分为两大类:对称加密非对称加密

对称加密

当加密的密钥和解密的密钥相同的时候,即为对称加密,加密和解密的算法是互逆的。

由于两个密钥相同,所以对称加密的密钥一旦泄漏,则不再安全

常用的对称加密算法包括DESAESBase64等等。

DES/AES加密算法

这两种算法都是对称加密算法中应用比较广泛的,其中DES甚至还曾经用在HTTPS协议SSL层中,但因为其密钥长度过短,已经被认为漏洞太大,几乎已经淘汰。

AES相比于DES算法,密钥长度更长,被认为是高效安全的加密方法,目前使用在HTTPS协议SSL层中。

这两种算法在使用上的直观感受是,即使密钥相同,每次生成的密文都不同

Base64

其实严格来说这个并不是一种加密算法,而是编码方式,长度很短,任何人都可以轻易解码。

非对称加密

和对称加密不同,非对称加密中,加密时候用一个密钥,解密时候用另一个密钥,我们一般称之为密钥对

其中一把公开的称为公钥,另一把私密的称为私钥,一般存储在服务端。

相比于对称加密,由于两把密钥不同,所以加密安全性的重点在私钥上。

这两把密钥根据不同的使用场景,诞生了一些新的名词:

公钥加密,私钥解密

这个是常规的做法,因为公钥是公开的,而私钥是私密的,所以整个加密和解密过程就比较安全。

公钥加密-私钥解密流程.png

私钥加密,公钥解密

也不是必须用公钥加密,私钥解密,当然可以反过来操作

但是考虑到公钥不安全,所以这里需要引入一个第三方CA(数字证书颁发机构)来加强安全。

这个CA也有自己的公钥私钥,同样也是公钥公开私钥保密

私钥加密-公钥解密流程.png

其中,公钥和CA的私钥生成的内容叫做数字证书,而对数字证书的解密则称为证书验证/签名验证

RSA算法

RSA算法是目前用的比较广泛的非对称加密算法,基本原理就是上面的常规做法,公钥加密,私钥解密。

散列算法(哈希算法)

不同于对称加密和非对称加密,散列算法是不可逆的,又称为哈希算法

这种加密算法常见于密码的生成

常见的散列算法例如MD5SHA-1SHA-256Argon2

数据加密实现

OK,明确了原理,接下来我们要做的就是代码实现了。

这里我们使用混合加密方式,结合对称加密AES非对称加密RSA实现登录接口的加密和解密。

混合加密逻辑.png

该混合加密方法参考了springboot+vue接口加密一文

中间层一共做了以下4件事情

  1. 生成RSA密钥对,存储
  2. 给客户端提供RSA的公钥
  3. 使用私钥解密客户端传递来的加密过的AES密钥
  4. 使用解密成功的AES密钥,解密登录信息,拿到真实的用户名和密码,传递给后端接口

客户端同样也是做了4件事情

  1. 生成随机AES密钥
  2. 用AES密钥,对用户名和密码加密,生成密文
  3. 使用从客户端拿到的公钥,加密AES密钥
  4. 把密文和加密过的AES密钥,通过接口传递给后端

RSA密钥对的生成和存储

密钥对的生成不复杂,只要用插件即可,这里选取了node-rsa

因为考虑到RSA密钥对每次生成时间比较长,所以要做一个存储方案,这里写入了服务端文件中,并配置了一个访问权限(主要针对私钥)

generateKeyPair() {
  const publicKeyPath = path.resolve(__dirname, PUBLIC_KEY_FILE_NAME);
  const privateKeyPath = path.resolve(__dirname, PRIVATE_KEY_FILE_NAME);

  // 如果密钥不存在,需要重新生成
  if (!fs.existsSync(publicKeyPath) || !fs.existsSync(privateKeyPath)) {
    const key = new NodeRSA({ b: 2048 });

    const publicKey = key.exportKey('public');
    const privateKey = key.exportKey('private');

    fs.writeFileSync(publicKeyPath, publicKey);
    // 设置访问权限,只给文件拥有者有读写权限,其他人无权访问
    fs.writeFileSync(privateKeyPath, privateKey, { mode: 0o600 });
  }
}

// 获取公钥
getPublicKey() {
  const publicKeyPath = path.resolve(__dirname, PUBLIC_KEY_FILE_NAME);

  let publicKey = '';

  if (!fs.existsSync(publicKeyPath)) {
    this.generateKeyPair();
  }

  publicKey = fs.readFileSync(publicKeyPath, 'utf8');

  return publicKey;
}

发送公钥的接口

生成RSA密钥对后,创建一个单独的接口,用来给客户端发送RSA公钥

公钥本身有公开的属性,所以这个接口暂时不用考虑权限校验

@Get('public-key')
async getPublicKey() {
  const publicKey = this.authService.getPublicKey();

  return { code: 200, data: publicKey, message: 'success' };
}

生成AES密钥和加密用户信息

客户端需要随机生成AES密钥,并对用户信息加密,此外,还要用从服务器获取到的公钥对AES密钥加密

这里选取了jsencryptcrypto-js两个库

首先是随机密钥的生成

const generateAESKey = () => {
  return CryptoJS.lib.WordArray.random(16).toString(CryptoJS.enc.Hex);
};

接着是对用户登录信息的加密

const encryptWithAES = (data: any, aesKey: string) => {
  const encrypted = CryptoJS.AES.encrypt(data, aesKey);
  return encrypted.toString();
};

最后是用RSA公钥对AES密钥加密

const encryptAESKeyWithRSA = (aesKey: string, rsaPublicKey: string) => {
  const encrypt = new JSEncrypt();
  encrypt.setPublicKey(rsaPublicKey); // 设置 RSA 公钥

  const encryptedAESKey = encrypt.encrypt(aesKey);
  if (!encryptedAESKey) {
    throw new Error("RSA加密失败,请检查公钥是否正确");
  }

  return encryptedAESKey;
};

登录信息加密发送

OK,方法都封装完了,最后就是把这些功能集成到登录接口中去

// 从服务器获取公钥
const getKey = async () => {
  const res = await getPublicKey().catch((err) => console.error(err));

  if (res && res.code === 200) {
    const rsaPublicKey = res.data;

    return rsaPublicKey;
  }
};

// 登录表单的按钮触发(即登录行为)
const onFinish = async (values: LoginInfo) => {
  setLoading(true);

  const { username, password = "" } = values;

  // 加密登录,结合使用AES和RSA
  // 1.从服务器获取RSA公钥
  const rsaPublicKey = await getKey();

  // 2.生成随机AES密钥
  const aesKey = generateAESKey();

  // 3.用AES密钥对用户名和密码加密,生成密文
  const encryptedUser = encryptWithAES(
    JSON.stringify({ username, password }),
    aesKey
  );

  // 4.对AES密钥,使用RSA公钥加密
  const encryptedAESKey = encryptAESKeyWithRSA(aesKey, rsaPublicKey);

  // 5.加密报文传输
  const res = await loginCrypto(encryptedUser, encryptedAESKey).catch((err) =>
    console.error(err)
  );

  if (res && res.code === 200) {
    const { token, ...userInfo } = res.data;
    // 设置用户信息
    LocalStorage.set(HR_USER_INFO_KEY, userInfo);
    // 设置token
    Cookie.set(HR_TOKEN_KEY, token);
    handlePermission(userInfo.staffNumber);

    const { redirect } = query;

    navigate((redirect || "/home") as string, { replace: true });
  }
  setLoading(false);
};

服务端解密

服务端的解密分两步

  1. 使用RSA私钥,解密拿到AES密钥
  2. 用拿到的AES密钥,再去解密登录信息,拿到明文的登录信息

首先完善一系列方法,包括获取私钥、使用RSA私钥解密获取AES密钥、用AES密钥解密密文

这里除了早先使用的node-rsa库,为了和客户端统一,对AES的解密使用crypto-js

// 获取密钥
getPrivateKey() {
  const privateKeyPath = path.resolve(__dirname, PRIVATE_KEY_FILE_NAME);

  let privateKey = '';

  if (!fs.existsSync(privateKeyPath)) {
    return privateKey;
  }

  privateKey = fs.readFileSync(privateKeyPath, 'utf8');

  return privateKey;
}

// 用RSA密钥解密获取AES密钥
decryptAESKeyWithRSA(encryptedAESKey: string) {
  const privateKey = this.getPrivateKey();

  const rsa = new NodeRSA(privateKey);
  rsa.setOptions({ encryptionScheme: 'pkcs1' }); // 设置为 PKCS1 填充

  try {
    const aesKey = rsa.decrypt(encryptedAESKey, 'utf8'); // 解密为原始字符串
    return aesKey;
  } catch (err) {
    console.log(err);
  }
}

// 使用AES密钥解密获取明文
decryptDataWithAES(data: string, aesKey: string) {
  const decrypted = CryptoJS.AES.decrypt(data, aesKey);

  const originalData = CryptoJS.enc.Utf8.stringify(decrypted);

  if (!originalData) {
    throw new Error('解密失败,可能是密钥错误或数据损坏');
  }

  return originalData;
}

解密数据发送后端

最后一步就是集成刚才的这些流程,把解密后的数据发送给后端

@Post('login/crypto')
async loginCrypto(@Body() cryptoUserInfo: UserCrypto) {
  const { cryptoUser, cryptoAES } = cryptoUserInfo;

  // 用RSA密钥解密获取AES密钥
  const aesKey = this.authService.decryptAESKeyWithRSA(cryptoAES);

  // 用AES密钥获取登录的用户信息明文
  const userInfo = this.authService.decryptDataWithAES(cryptoUser, aesKey);

  // 把登录的用户信息明文发送给后端
  const result = await this.userService.login(JSON.parse(userInfo));

  return result;
}

总结

做完了上面这些事情,我们可以看到,用户登录时候的信息,不会在接口里明文传递了

加密的登录信息传递.png

OK,中间层又帮我们优化了一个隐患点!

很棒表情包.gif