做網頁系統開發一定會遇到帳號登入的問題。
大家都知道密碼要用[HttpPost]放在Request Body裡面來進行傳遞。這句話只對了一半,因為只要在瀏覽器按下F12進入開發者模式,密碼還是會被看光光的,好恐怖的阿。
所以對密碼進行加密是最基本的資安防護。
RSA加密
這次的測試我採用的是RSA加密。雖然其數學原理我不太懂。但簡單來說就是你要有一組公.私鑰。然後利用公Key加密,私Key解密。
流程大致會如下:
明碼(ex:Ab12345678) => 公Key加密 => 密文(一長串的亂碼) => (傳輸至Server) => 私Key解密 => 與(解密後的)資料庫儲存之密碼進行比對是否一致
上面的流程有幾點要注意的事項:
- 資料庫儲存的密碼也必須要存放RSA加密後的雜湊密碼
- 公Key每次加密後的雜湊值都會不同,所以不能直接用由前端回傳的雜湊值與資料庫存放的雜湊值比對密碼是否一致
RSA的更多詳細內容可以參考:[Day27] 非對稱金鑰加密系統(RSA)
產生公. 私Key
可以到這個網站(RSA Key Generator)產生公. 私Key。長度我會選擇2048 bits(越長越安全)
.Net WebAPI
把公. 私Key儲存成.pem檔,-----BEGIN-----跟-----END-----也原封不動的貼上。
安裝套件Portable.BouncyCastle
建立RsaService,用來處理加.解密。
RsaService.cs
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.OpenSsl;
using Org.BouncyCastle.Security;
using StockBuyingHelper.Service.Interfaces;
using System.Security.Cryptography;
using System.Text;
namespace StockBuyingHelper.Service.Implements
{
public class RsaService: IRsaService
{
public string Encrypt(string text)
{
var _publicKey = GetPublicKeyFromPemFile(@".\Keys\sbh.pub.pem");
var encryptedBytes = _publicKey.Encrypt(Encoding.UTF8.GetBytes(text), false);
return Convert.ToBase64String(encryptedBytes);
}
public string Decrypt(string encrypted)
{
var _privateKey = GetPrivateKeyFromPemFile(@".\Keys\sbh.key.pem");
var decryptedBytes = _privateKey.Decrypt(Convert.FromBase64String(encrypted), false);
return Encoding.UTF8.GetString(decryptedBytes, 0, decryptedBytes.Length);
}
private RSACryptoServiceProvider GetPrivateKeyFromPemFile(string filePath)
{
using (TextReader privateKeyTextReader = new StringReader(File.ReadAllText(filePath)))
{
AsymmetricCipherKeyPair readKeyPair = (AsymmetricCipherKeyPair)new PemReader(privateKeyTextReader).ReadObject();
RSAParameters rsaParams = DotNetUtilities.ToRSAParameters((RsaPrivateCrtKeyParameters)readKeyPair.Private);
RSACryptoServiceProvider csp = new RSACryptoServiceProvider();
csp.ImportParameters(rsaParams);
return csp;
}
}
private RSACryptoServiceProvider GetPublicKeyFromPemFile(string filePath)
{
using (TextReader publicKeyTextReader = new StringReader(File.ReadAllText(filePath)))
{
RsaKeyParameters publicKeyParam = (RsaKeyParameters)new PemReader(publicKeyTextReader).ReadObject();
RSAParameters rsaParams = DotNetUtilities.ToRSAParameters(publicKeyParam);
RSACryptoServiceProvider csp = new RSACryptoServiceProvider();
csp.ImportParameters(rsaParams);
return csp;
}
}
}
}
登入驗證的相關程式碼邏輯。這裡沒有把全部的程式碼都貼上來,只貼加.解密的部分,其他還有JWT的處理這邊先省略。
LoginService.cs
省略...
//模擬DB資料
var mockDbUser = new List<UserInfoModel>()
{
new UserInfoModel(){
Account = "Admin",
//Password為加上PasswordSalt後的完整密碼的雜湊值
Password = "a4cmsDIckfdXpRvqlrrjfL+qQi3huPGjXT40+ftLAJO685B42T45bN22EEKTdoKW17Hd6+edxpm3z3nno9QIZG0p4hDszQIfxYYpKYbrMgNABfDanymuqRFv12nZCCt0eRMF7qrWX5TejKaHc6RyE1J/bnyu5PQL/inAkMnw0UITgyQxPWadNszO304oHSP197oUTlNCJHSPnfzmQXvEF8Px/w9id/o5W1o7UzmguIlACCiZuryzNfeo7lpUjvcWjNVyUiyoFGXWuKxdfq4OBolfUYAmhnrTY+nA1S0w9H8UEaLv0vAtgrDZYivNXg7DH/2YQtRV4alXzsWyLygHwA==",
PasswordSalt = "onLrFc",
Name = "管理員",
Email = "Admin@test.com",
Role = "1"
},
new UserInfoModel(){
Account = "Test_Account",
//Password為加上PasswordSalt後的完整密碼的雜湊值
Password = "chBVb0lkT4SLeOLKjPdHU+kTCyut1HbWAk8NBqC/LXW9jm9EUfsByLbf5NdHtLa7/wTtZY4kJUvHRTY7BpDwmm2Vd1DyUNETCXPBPuLx54XBKRkV6J0shUzzVFF3haYE3x2OJL48t/hy7yGiGw8FBUvEiFILzjII0i55uggfWEQyXb71nBBMQLJbgUVsJUOCodD36nEu4QgYg4a9PRp3zAcTmg1NUD+GZdCk2fBMOGXLKFRAvSw96TY8QASnx8lOsTV5k8GlfJC1Zu4T7OJXi2rRJFbRJWJQp0B+2n7DwfH+IxP4wh1+/PvU/wweNCkhyUiNsV3yc9XytSo7p8WGRw==",
PasswordSalt = "eeoBIB",
Name = "測試帳號",
Email = "Test@test.com",
Role = "999"
},
};
if (string.IsNullOrEmpty(account))
{
errorMsg = "尚未登入.";
return (jwtToken: jwtToken, errorMsg: errorMsg);
}
user = mockDbUser.Where(c => c.Account.ToLower() == account.ToLower()).FirstOrDefault();
if (user != null)
{
//(私Key)解密
var decryptPsw = _rasService.Decrypt(password);
//頭尾補上英文salt字串
var pswWithSalt = $"{user.PasswordSalt.Substring(3, 3)}{decryptPsw}{user.PasswordSalt.Substring(0, 3)}";
//密碼比對(RSA加密後的結果每次都不會一樣,所以要解密後再進行比對。)
var pswCompare = _rasService.Decrypt(user.Password) == pswWithSalt ? true : false;
if (pswCompare == false)
{
return (jwtToken: jwtToken, errorMsg: "密碼錯誤.");
}
}
else
{
return (jwtToken: jwtToken, errorMsg: "帳號錯誤.");
}
省略...
上面程式碼比較特別的是,解密後的明文密碼頭尾還要再加上Salt隨機英文字串,才能組成完整的密碼。此做法可避免就算前端傳回的密碼如果不幸被反查破解,不知道後端的Salt字串,也還是無法得知完整的明文密碼,只能知道部分密碼。更多密碼加鹽可參考這篇文章:密碼要怎麼儲存才安全?該加多少鹽?-科普角度
流程:
前端傳入的密碼是經過RSA加密後的雜湊字串
解密過後的明文密碼
頭尾加上Salt字串組合成完整密碼
把我們MockDbData的User密碼進行解密,然後跟加上Salt字串的完整密碼進行比對。比對結果正確的話,就完成了我們的密碼驗證了。
Angular
前面說了這麼多,總歸一句話就是:[替密碼加密]。密碼既然是由前端送出,那當然的前端程式勢必要做些調整。
首先要先安裝前端的加.解密套件node-forge
npm i node-forge
接著修改登入元件,前端要修改的地方不多,要特別注意的地方如下:
- 公Key必須要跟後端的剛剛產出的公Key完全一致,不然到時候會解密失敗。
- API的Password參數,必須要替換成加密過後的encryptedPassword
login.component.ts
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { LoginDto } from 'src/app/core/dtos/request/login-dto';
import { LoginService } from 'src/app/core/http/login.service';
import { JwtInfoService } from 'src/app/core/services/jwt-info.service';
import * as forge from 'node-forge';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrl: './login.component.css'
})
export class LoginComponent implements OnInit
{
errorMessage: string = '';
account?: string = '';
password: string = '';
publicKey: string = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArc/vhkP0RV7wQE3LbJpS
m4ony6aE+CcFu6ky3r/IIplOh86yGkk+EUPrufQZ4K0naR6xgnL1Puv6WeiCzZj/
0JQeaeZUGweO2mx9TazXU4VqT95F+IwUJrhTVmJu/JwfNLjQ+cgo8WadZ2DB2jGs
SN1d3oGoRXiINZhsUsVJw6tkAuh3IIeAkVXeWaVJE0I0est+xX+g4sgz4UC23jxB
NZJJmXiBwOvAQ3Mg/DWBdBmuWweQCgr9Tc//KLCwE+xY1mZYu0DXR/JUecmbbrC4
BGZm0rogSSP8qd/5xVk7nVovbhT8iz/e4dylXuflA9dYrPrXkg7WEfya7/5If9kw
SwIDAQAB
-----END PUBLIC KEY-----`;
constructor(
private _loginService: LoginService,
private _router: Router,
private _jwtService: JwtInfoService
){
}
ngOnInit(): void
{
}
login()
{
let rsa = forge.pki.publicKeyFromPem(this.publicKey);
let encryptedPassword = window.btoa(rsa.encrypt(this.password));
let data:LoginDto = {Account: this.account, Password: encryptedPassword}
this._loginService.JwtLogin(data).subscribe({
next: res => {
if (res.message)
{
this.errorMessage = res.message;
return;
}
else
{//沒有錯誤訊息
this._jwtService.jwt = res.content;
this._jwtService.setJwtValid(true);
this._router.navigate(['/main']);
}
},
error: ex =>
{
alert(ex.message);
return;
}
})
}
}
要給後端的密碼已經被加密成湊值了
Ref: