前面講完了環境建立的部分,接下來的就往功能面去寫,後續幾天都會圍繞在註冊登錄的部分,今天就先整理密碼加密。
bcrypt 是一種專為密碼儲存設計的自適應雜湊函數,基於 Blowfish 加密演算法。
// bcrypt 不是簡單的雜湊函數
// ❌ 簡單 MD5/SHA 雜湊(不安全)
const simpleHash = crypto.createHash('md5').update('password123').digest('hex');
// 結果:482c811da5d5b4bc6d497ffa98491e38(每次都相同)
// ✅ bcrypt 雜湊(安全)
const bcryptHash = await bcrypt.hash('password123', 12);
// 結果:$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqfuHVqRr.kHxLIxJ5KRKOK
// 每次產生不同結果,但都能驗證同一個密碼
特性 | bcrypt | 傳統雜湊(MD5/SHA) |
---|---|---|
鹽值(Salt) | 自動產生隨機鹽值 | 需要手動管理 |
計算成本 | 可調整,可隨硬體升級 | 固定且運算快速 |
彩虹表攻擊 | 完全免疫 | 容易受到攻擊 |
暴力破解 | 可控制破解難度 | 幾乎無防護 |
相同密碼 | 產生不同雜湊值 | 產生相同雜湊值 |
// bcrypt 雜湊格式:$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqfuHVqRr.kHxLIxJ5KRKOK
// │ │ │ │
// │ │ └── 22 字元 Base64 編碼的鹽值
// │ └───── 成本因子(Work Factor)
// └──────── 演算法版本標識符
// └── 31 字元 Base64 編碼的雜湊值
const hashStructure = {
version: '$2b$', // 演算法版本
cost: '12', // 成本因子(2^12 = 4096 輪迭代)
salt: '22_characters', // 隨機鹽值
hash: '31_characters' // 實際雜湊值
};
// package.json
{
"dependencies": {
"bcrypt": "^5.1.1" // 主要 bcrypt 函式庫
},
"devDependencies": {
"@types/bcrypt": "^5.0.2" // TypeScript 型別定義
}
}
// models/user.ts
import bcrypt from 'bcrypt';
@Entity('users')
export class User {
@Column({
type: 'varchar',
length: 60, // bcrypt 雜湊固定長度為 60 字元
nullable: true, // OAuth 登入的使用者可能沒有密碼
select: false // 預設查詢時不包含密碼欄位(安全考量)
})
password: string;
/**
* 自動密碼雜湊處理
* 使用 TypeORM 生命週期鉤子在資料儲存前自動執行
*/
@BeforeInsert()
@BeforeUpdate()
async hashPassword() {
// 檢查密碼是否存在且尚未雜湊處理
// bcrypt 雜湊格式辨識:以 $2a$、$2b$ 或 $2y$ 開頭
if (this.password && !/^\$2[aby]\$/.test(this.password)) {
// 使用成本因子 12 進行雜湊(平衡安全性與效能)
this.password = await bcrypt.hash(this.password, 12);
}
}
/**
* 安全密碼比對方法
* @param candidatePassword 待驗證的明文密碼
* @returns 密碼是否匹配
*/
async comparePassword(candidatePassword: string): Promise<boolean> {
// 確保密碼欄位存在
if (!this.password) {
return false;
}
// 使用 bcrypt 內建的安全比對方法(防止時序攻擊)
return bcrypt.compare(candidatePassword, this.password);
}
}
// Tickeasy 使用成本因子 12
const BCRYPT_ROUNDS = 12;
// 不同成本因子對應的計算複雜度與執行時間
const costFactorAnalysis = {
10: '2^10 = 1,024 輪迭代 (~100ms) - 快速,基本安全',
11: '2^11 = 2,048 輪迭代 (~200ms) - 中等安全',
12: '2^12 = 4,096 輪迭代 (~400ms) - 高安全',
13: '2^13 = 8,192 輪迭代 (~800ms) - 非常高安全',
14: '2^14 = 16,384 輪迭代 (~1.6s) - 極高安全'
};
// controllers/auth.ts - register 函數
export const register = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, password, name } = req.body;
// 步驟 1:密碼格式驗證
const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/;
if (!passwordRegex.test(password)) {
throw ApiError.invalidPasswordFormat();
}
// 步驟 2:建立新用戶實例
const newUser = new User();
newUser.email = email;
newUser.password = password; // 此時為明文密碼
newUser.name = name;
// 步驟 3:儲存用戶(自動觸發 @BeforeInsert 鉤子執行 hashPassword)
await userRepository.save(newUser);
// 步驟 4:此時 newUser.password 已自動轉換為 bcrypt 雜湊值
console.log(newUser.password);
// 輸出範例:$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqfuHVqRr.kHxLIxJ5KRKOK
res.status(201).json({
status: 'success',
message: '註冊成功'
// 注意:回應中不包含密碼欄位
});
} catch (err) {
next(err);
}
};
// controllers/auth.ts - login 函數
export const login = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, password } = req.body;
// 步驟 1:查詢用戶(明確指定需要包含密碼欄位)
const user = await userRepository.findOne({
where: { email },
select: [
'userId', 'email', 'password', 'role', 'name',
'avatar', 'isEmailVerified'
],
});
// 步驟 2:驗證用戶存在性與密碼正確性
// 使用統一錯誤訊息,防止帳號枚舉攻擊
if (!user || !(await user.comparePassword(password))) {
throw ApiError.invalidCredentials();
}
// 步驟 3:登入成功,產生 JWT Token
const token = generateToken({
userId: user.userId,
role: user.role,
});
// 步驟 4:回傳用戶資料(排除密碼欄位)
const userData = {
userId: user.userId,
email: user.email,
name: user.name,
// 密碼欄位已排除
};
res.status(200).json({
status: 'success',
data: { token, user: userData }
});
} catch (err) {
next(err);
}
};
// controllers/auth.ts - resetPassword 函數
export const resetPassword = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, code, newPassword } = req.body;
// 步驟 1:新密碼格式驗證
const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/;
if (!passwordRegex.test(newPassword)) {
throw ApiError.invalidPasswordFormat();
}
// 步驟 2:驗證重置碼的有效性
const user = await userRepository.findOne({
where: {
email,
passwordResetToken: Not(IsNull()),
passwordResetExpires: MoreThan(new Date()),
},
});
if (!user || user.passwordResetToken !== code) {
throw ApiError.create(400, '重置碼無效或已過期');
}
// 步驟 3:更新密碼(自動觸發 @BeforeUpdate 鉤子執行雜湊)
user.password = newPassword; // 設定新的明文密碼
user.passwordResetToken = '';
user.passwordResetExpires = new Date(0);
await userRepository.save(user);
// 儲存時密碼已自動轉換為 bcrypt 雜湊
res.status(200).json({
status: 'success',
message: '密碼重置成功'
});
} catch (err) {
next(err);
}
};
// controllers/auth.ts - changePassword 函數
export const changePassword = async (req: Request, res: Response) => {
const { oldPassword, newPassword } = req.body;
const authenticated = req.user as { userId: string };
// 步驟 1:新密碼格式驗證
const pwRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/;
if (!pwRegex.test(newPassword)) {
throw ApiError.invalidPasswordFormat();
}
// 步驟 2:取得用戶資料(包含密碼欄位)
const user = await userRepository.findOne({
where: { userId: authenticated.userId },
select: ['userId', 'password'],
});
if (!user) {
throw ApiError.notFound('用戶');
}
// 步驟 3:驗證舊密碼正確性
if (!(await user.comparePassword(oldPassword))) {
throw ApiError.invalidOldPassword();
}
// 步驟 4:更新為新密碼(自動觸發雜湊處理)
user.password = newPassword;
await userRepository.save(user);
res.status(200).json({
status: 'success',
message: '密碼變更成功'
});
};
// bcrypt 內部運作原理示範
const demonstrateSaltGeneration = async () => {
const password = 'mySecretPassword';
// 相同密碼每次雜湊都會產生不同結果
const hash1 = await bcrypt.hash(password, 12);
const hash2 = await bcrypt.hash(password, 12);
const hash3 = await bcrypt.hash(password, 12);
console.log(hash1); // $2b$12$abc123...(鹽值:abc123)
console.log(hash2); // $2b$12$def456...(鹽值:def456)← 不同鹽值
console.log(hash3); // $2b$12$ghi789...(鹽值:ghi789)← 不同鹽值
// 儘管雜湊值不同,但都能正確驗證相同密碼
console.log(await bcrypt.compare(password, hash1)); // true
console.log(await bcrypt.compare(password, hash2)); // true
console.log(await bcrypt.compare(password, hash3)); // true
};
// 彩虹表攻擊原理與防護
const rainbowTableAttack = {
principle: '預先計算並儲存常見密碼的雜湊值對照表',
vulnerability: '相同密碼產生相同雜湊值,可快速反查',
// 傳統雜湊易受彩虹表攻擊
simpleHash: {
'password123': 'ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f',
'secret456': 'b3c5c9e7f1a2d8e4f6a9b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x'
// 攻擊者可建立龐大的對照表進行快速比對
},
};
// 計算成本分析與防護效果
const bruteForceProtection = {
// 傳統 SHA-256 雜湊(幾乎無防護)
sha256: {
hashesPerSecond: 1_000_000_000, // 每秒可嘗試 10 億次
timeFor8Chars: '數分鐘即可破解',
gpuAcceleration: '使用 GPU 可大幅加速'
},
// bcrypt(成本因子 12)- 強大的防護
bcrypt12: {
hashesPerSecond: 2_500, // 每秒僅能嘗試 2,500 次
timeFor8Chars: '需耗時數千年',
scalability: '可調整成本因子應對未來硬體升級',
gpuResistance: '刻意設計為對 GPU 加速不友善'
}
};
// bcrypt.compare 內建時序攻擊防護機制
const timingAttackProtection = async () => {
const validHash = '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqfuHVqRr.kHxLIxJ5KRKOK';
// 測試:不同錯誤密碼的比對時間應該相近
const time1 = Date.now();
await bcrypt.compare('wrong1', validHash);
const elapsed1 = Date.now() - time1;
const time2 = Date.now();
await bcrypt.compare('wrong2', validHash);
const elapsed2 = Date.now() - time2;
// elapsed1 ≈ elapsed2(時間差異極小)
// 攻擊者無法透過執行時間差異推測密碼的正確程度
console.log(`時間差異: ${Math.abs(elapsed1 - elapsed2)}ms`);
};
https://www.npmjs.com/package/bcrypt