上一篇我们说了后端数据格式的问题,这一篇我们看一个细节点。
序言
相信各位的项目里总得有个登录功能,最常规的登录流程如下:
客户端:填写用户名 + 密码
把用户名 + 密码通过接口发送给后端
后端跟数据库中的存储信息比对,正确返回用户信息,错误提示“用户名或密码错误”
OK,流程肯定是这样没错,但是我们现在看看后端给的登录接口:
然后再看看目前中间层接口怎么传参的
从前端直接明文传输用户名和密码???这样肯定不行……
所以这一次,我们来用登录做一个示例,看看这种敏感信息在接口传输的时候,我们怎么保障数据安全。
数据加密原理
既然不能用明文,我们肯定很容易想到:给数据加密,数据传输的时候用密文,等传输成功了再解密。
而加密/解密的逻辑其实也不复杂。
整个加密过程中,我们可以看到发挥作用的,主要是密钥、加密/解密算法这两部分。
在这个流程中,我们留意到有两个密钥。实际上,这两个密钥可以相同,也可以不同,也因此区分为两大类:对称加密和非对称加密。
对称加密
当加密的密钥和解密的密钥相同的时候,即为对称加密,加密和解密的算法是互逆的。
由于两个密钥相同,所以对称加密的密钥一旦泄漏,则不再安全
常用的对称加密算法包括DES
、AES
、Base64
等等。
DES/AES加密算法
这两种算法都是对称加密算法中应用比较广泛的,其中DES甚至还曾经用在HTTPS协议
的SSL层
中,但因为其密钥长度过短,已经被认为漏洞太大,几乎已经淘汰。
AES相比于DES算法,密钥长度更长,被认为是高效安全的加密方法,目前使用在HTTPS协议
的SSL层
中。
这两种算法在使用上的直观感受是,即使密钥相同,每次生成的密文都不同。
Base64
其实严格来说这个并不是一种加密算法,而是编码方式,长度很短,任何人都可以轻易解码。
非对称加密
和对称加密不同,非对称加密中,加密时候用一个密钥,解密时候用另一个密钥,我们一般称之为密钥对。
其中一把公开的称为公钥,另一把私密的称为私钥,一般存储在服务端。
相比于对称加密,由于两把密钥不同,所以加密安全性的重点在私钥上。
这两把密钥根据不同的使用场景,诞生了一些新的名词:
公钥加密,私钥解密
这个是常规的做法,因为公钥是公开的,而私钥是私密的,所以整个加密和解密过程就比较安全。
私钥加密,公钥解密
也不是必须用公钥加密,私钥解密,当然可以反过来操作。
但是考虑到公钥不安全,所以这里需要引入一个第三方CA(数字证书颁发机构)来加强安全。
这个CA也有自己的公钥和私钥,同样也是公钥公开,私钥保密。
其中,公钥和CA的私钥生成的内容叫做数字证书,而对数字证书的解密则称为证书验证/签名验证。
RSA算法
RSA算法
是目前用的比较广泛的非对称加密算法,基本原理就是上面的常规做法,公钥加密,私钥解密。
散列算法(哈希算法)
不同于对称加密和非对称加密,散列算法是不可逆的,又称为哈希算法。
这种加密算法常见于密码的生成。
常见的散列算法例如MD5
、SHA-1
、SHA-256
、Argon2
。
数据加密实现
OK,明确了原理,接下来我们要做的就是代码实现了。
这里我们使用混合加密方式,结合对称加密AES
和非对称加密RSA
实现登录接口的加密和解密。
该混合加密方法参考了springboot+vue接口加密一文
中间层一共做了以下4件事情
- 生成RSA密钥对,存储
- 给客户端提供RSA的公钥
- 使用私钥解密客户端传递来的加密过的AES密钥
- 使用解密成功的AES密钥,解密登录信息,拿到真实的用户名和密码,传递给后端接口
客户端同样也是做了4件事情
- 生成随机的AES密钥
- 用AES密钥,对用户名和密码加密,生成密文
- 使用从客户端拿到的公钥,加密AES密钥
- 把密文和加密过的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密钥加密
这里选取了jsencrypt
和crypto-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);
};
服务端解密
服务端的解密分两步
- 使用RSA私钥,解密拿到AES密钥
- 用拿到的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;
}
总结
做完了上面这些事情,我们可以看到,用户登录时候的信息,不会在接口里明文传递了
OK,中间层又帮我们优化了一个隐患点!