iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0
佛心分享-IT 人自學之術

欸欸!! 這是我的學習筆記系列 第 18

Day18 - bcrypt - 密碼加密

  • 分享至 

  • xImage
  •  

2025 鐵人賽 Day18 - bcrypt - 密碼加密

前言

前面講完了環境建立的部分,接下來的就往功能面去寫,後續幾天都會圍繞在註冊登錄的部分,今天就先整理密碼加密。

bcrypt 核心概念

bcrypt 是一種專為密碼儲存設計的自適應雜湊函數,基於 Blowfish 加密演算法。

什麼是 bcrypt?

// 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 的優勢

特性 bcrypt 傳統雜湊(MD5/SHA)
鹽值(Salt) 自動產生隨機鹽值 需要手動管理
計算成本 可調整,可隨硬體升級 固定且運算快速
彩虹表攻擊 完全免疫 容易受到攻擊
暴力破解 可控制破解難度 幾乎無防護
相同密碼 產生不同雜湊值 產生相同雜湊值

3. bcrypt 雜湊格式解析

// 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'  // 實際雜湊值
};

Tickeasy 中的 bcrypt 實現

套件配置

// package.json
{
  "dependencies": {
    "bcrypt": "^5.1.1"        // 主要 bcrypt 函式庫
  },
  "devDependencies": {
    "@types/bcrypt": "^5.0.2" // TypeScript 型別定義
  }
}

User 模型實現

// 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);
  }
}

成本因子選擇(Cost Factor)

// 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) - 極高安全'
};

認證流程中的 bcrypt 應用

1. 用戶註冊流程

// 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);
  }
};

2. 用戶登入流程

// 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);
  }
};

3. 密碼重置流程

// 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);
  }
};

4. 變更密碼流程

// 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 安全機制解析

1. 自動鹽值生成機制

// 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
};

2. 多層防護機制

彩虹表攻擊防護

// 彩虹表攻擊原理與防護
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 加速不友善'
  }
};

3. 時序攻擊防護

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


上一篇
Day17 - Express - 建立一個包含 TS 的專案
下一篇
Day19 - JWT
系列文
欸欸!! 這是我的學習筆記20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言