哈希

本指南介绍 AdonisJS 应用中的密码哈希。你将学习如何:

  • 哈希和验证密码
  • 选择并配置哈希算法
  • 在配置变更后检测并执行重新哈希
  • 通过伪造(fake)哈希服务来加速测试
  • 创建自定义的哈希驱动

概述

密码哈希将明文密码转换为不可逆的字符串,可以安全地存储在你的数据库中。与加密不同,哈希是一个单向过程。你无法将哈希转换回原始密码。相反,你通过对输入进行哈希并将其与存储的哈希进行比对来验证密码。

AdonisJS 提供了一个哈希服务,内置支持三种行业标准的算法:Argon2、Bcrypt 和 Scrypt。该服务以 PHC 字符串格式 存储哈希,这是一种将算法参数直接嵌入到哈希输出中的标准化编码。

Note

如果你正在将 @adonisjs/auth 模块与 Lucid 模型一起使用,密码的哈希和验证会由 AuthFinder mixin 自动处理。本指南侧重于在你需要更多控制权,或未使用身份验证模块的情况下,直接使用哈希服务。

安装

哈希服务包含在 @adonisjs/core 中,默认的 Scrypt 驱动无需额外安装。Scrypt 使用 Node.js 内置的 crypto 模块,因此无需外部依赖即可立即使用。

对于 Argon2 或 Bcrypt,你必须安装它们各自的 npm 包。

# 对于 Argon2(推荐用于新应用)
npm i argon2

# 对于 Bcrypt
npm i bcrypt

安装包之后,更新你的哈希配置以使用新的驱动。

基本用法

哈希服务提供了两个主要方法:hash.make 用于创建哈希,hash.verify 用于根据现有哈希验证密码。

创建哈希

hash.make 方法接受明文字符串,并返回 PHC 格式的哈希。

app/services/user_service.ts
import hash from '@adonisjs/core/services/hash'

export default class UserService {
  async createUser(email: string, password: string) {
    /**
     * 在存储之前对密码进行哈希。输出包含
     * 算法、参数、盐值和哈希,全部在一个字符串中。
     */
    const hashedPassword = await hash.make(password)
    
    // hashedPassword 形如:
    // $scrypt$n=16384,r=8,p=1$randomsalt$hashoutput...
    
    return User.create({ email, password: hashedPassword })
  }
}

验证密码

hash.verify 方法将明文密码与存储的哈希进行比对。如果匹配则返回 true,否则返回 false

app/services/auth_service.ts
import hash from '@adonisjs/core/services/hash'
import User from '#models/user'

export default class AuthService {
  async validateCredentials(email: string, password: string) {
    const user = await User.findBy('email', email)
    if (!user) {
      return null
    }

    /**
     * 将明文密码与存储的哈希进行比对。
     * verify 方法会从哈希本身提取算法参数,
     * 因此即使你更改了配置,它也能正常工作。
     */
    const isValid = await hash.verify(user.password, password)
    
    return isValid ? user : null
  }
}

选择算法

每种哈希算法在安全性、性能和兼容性之间都有不同的权衡。正确的选择取决于你应用的需求。

何时选择 Argon2

Argon2 是新应用推荐的选择。它赢得了 2015 年密码哈希竞赛(Password Hashing Competition),并提供可配置的内存硬度,使其既能抵抗基于 GPU 的攻击,也能抵抗专用硬件攻击。id 变体(默认)结合了对 GPU 攻击和侧信道攻击的防护。

何时选择 Bcrypt

当你需要兼容现有系统或其他平台时,Bcrypt 仍然是一个可靠的选择。经过数十年的分析,其安全特性已广为人知。但是请注意,Bcrypt 会在 72 字节处截断密码,因此较长的密码在哈希前实际上会被缩短。

Warning

Bcrypt 会静默地截断超过 72 字节的密码。如果你的应用接受非常长的密码或口令,用户可能仅凭其密码的前 72 字节就能通过身份验证。如果这是个问题,请考虑使用 Argon2 或 Scrypt。

何时选择 Scrypt

Scrypt 是默认驱动,因为它不需要额外的 npm 包。它使用 Node.js 内置的 crypto 模块,非常适合那些最小化依赖很重要的应用。经过适当的配置,Scrypt 能提供与 Argon2 相当的安全性。

配置

哈希配置位于 config/hash.ts 中。你可以定义可用的驱动,并通过 default 指定默认使用哪个。

config/hash.ts
import { defineConfig, drivers } from '@adonisjs/core/hash'

export default defineConfig({
  /**
   * 当未显式指定驱动时,hash.make() 和 hash.verify()
   * 使用的默认驱动。
   */
  default: 'scrypt',

  list: {
    scrypt: drivers.scrypt({
      cost: 16384,
      blockSize: 8,
      parallelization: 1,
      saltSize: 16,
      maxMemory: 33554432,
      keyLength: 64,
    }),

    /**
     * 在安装后取消注释:npm i argon2
     */
    // argon: drivers.argon2({
    //   version: 0x13,
    //   variant: 'id',
    //   iterations: 3,
    //   memory: 65536,
    //   parallelism: 4,
    //   saltSize: 16,
    //   hashLength: 32,
    // }),

    /**
     * 在安装后取消注释:npm i bcrypt
     */
    // bcrypt: drivers.bcrypt({
    //   rounds: 10,
    //   saltSize: 16,
    //   version: '2b',
    // }),
  },
})

Argon2 配置

Argon2 提供了对内存使用、迭代次数和并行度的细粒度控制。这些参数直接影响安全性和性能。

config/hash.ts
import { defineConfig, drivers } from '@adonisjs/core/hash'

export default defineConfig({
  default: 'argon',

  list: {
    argon: drivers.argon2({
      version: 0x13,
      variant: 'id',
      iterations: 3,
      memory: 65536,
      parallelism: 4,
      saltSize: 16,
      hashLength: 32,
    }),
  },
})
variant
string id

定义 Argon2 变体。

  • 'd' 抵抗 GPU 攻击(用于加密货币)。
  • 'i' 抵抗侧信道攻击(较慢)。
  • 'id' 结合了两种防护(推荐用于密码)。
version
number 0x13

以十六进制定义的算法版本。0x10(1.0)或 0x13(1.3)。

iterations
number 3

时间成本。值越高,计算时间和安全性越大。

memory
number 65536

以 KiB 为单位的内存成本。每个并行线程都会使用此数量。值越高越能抵抗 GPU 攻击。

parallelism
number 4

用于计算哈希的并行线程数。

saltSize
number 16

随机盐值的字节长度。

hashLength
number 32

原始哈希输出的字节长度。最终的 PHC 字符串会更长。

在 Argon2 中使用密钥(secret)

Argon2 支持一个可选的密钥(有时称为「pepper」),用于增加一层额外的防护。与随哈希一起存储的盐值不同,密钥是单独保存的,通常存储在环境变量中。即使攻击者获取了你的数据库,没有密钥他们也无法破解这些哈希。

Warning

如果你向现有应用添加密钥,所有之前哈希过的密码都会失效,因为它们是在没有密钥的情况下创建的,无法用密钥进行验证。你要么必须重置所有密码,要么实现一种在下次登录时重新哈希密码的迁移策略。

config/hash.ts
import { defineConfig, drivers } from '@adonisjs/core/hash'
import env from '#start/env'

export default defineConfig({
  default: 'argon',

  list: {
    argon: drivers.argon2({
      variant: 'id',
      iterations: 3,
      memory: 65536,
      parallelism: 4,
      /**
       * 密钥提供了超出数据库中所存储内容的额外防护。
       * 请将其存储在你的环境变量中,切勿写在代码里。
       */
      secret: env.get('HASH_SECRET'),
    }),
  },
})

Bcrypt 配置

Bcrypt 配置围绕 rounds 参数展开,该参数通过指数级缩放来控制计算成本。

config/hash.ts
import { defineConfig, drivers } from '@adonisjs/core/hash'

export default defineConfig({
  default: 'bcrypt',

  list: {
    bcrypt: drivers.bcrypt({
      rounds: 10,
      saltSize: 16,
      version: '2b',
    }),
  },
})
rounds
number 10

以 2 为底的代价因子。值为 10 表示 2^10(1024)次迭代。每次递增都会使计算时间翻倍。

saltSize
number 16

随机盐值的字节长度。

version
string 2b

Bcrypt 版本标识符。除非你需要兼容旧的 '2a' 哈希,否则请使用 '2b'(当前版本)。

Scrypt 配置

Scrypt 使用内存困难的(memory-hard)函数,使得在 GPU 和专用硬件上进行攻击的成本都很高昂。

config/hash.ts
import { defineConfig, drivers } from '@adonisjs/core/hash'

export default defineConfig({
  default: 'scrypt',

  list: {
    scrypt: drivers.scrypt({
      cost: 16384,
      blockSize: 8,
      parallelization: 1,
      saltSize: 16,
      maxMemory: 33554432,
      keyLength: 64,
    }),
  },
})
cost
number 16384

CPU/内存成本参数(N)。必须是 2 的幂。值越高,安全性和资源消耗越大。

blockSize
number 8

块大小参数(r)。线性增加内存使用量。

parallelization
number 1

并行化参数(p)。大于 1 的值允许并行计算。

saltSize
number 16

随机盐值的字节长度。

maxMemory
number 33554432

最大内存(以字节为单位,默认 32 MiB)。如果计算出的内存超出此值,Node.js 会抛出异常。

keyLength
number 64

派生密钥的字节长度。

重新哈希

安全最佳实践会随着时间推移而演进,你可能需要通过增加迭代次数、内存使用量,或完全切换算法来加强你的哈希参数。PHC 格式让这件事变得简单,因为每份哈希都包含了创建它所用的参数。

hash.needsReHash 方法检查存储的哈希是否是用与你当前配置不同的参数创建的。

app/services/auth_service.ts
import hash from '@adonisjs/core/services/hash'
import User from '#models/user'

export default class AuthService {
  async login(email: string, password: string) {
    const user = await User.findBy('email', email)
    if (!user) {
      return null
    }

    const isValid = await hash.verify(user.password, password)
    if (!isValid) {
      return null
    }

    if (hash.needsReHash(user.password)) {
      user.password = await hash.make(password)
      await user.save()
    }

    return user
  }
}

在登录期间重新哈希是标准做法,因为那是你唯一能访问到明文密码的时刻。随着时间的推移,随着用户登录,他们的密码会逐渐迁移到更新后的配置。

在算法之间迁移

从一种算法切换到另一种算法,遵循与参数更新相同的模式。当你更改默认驱动时,对于任何用不同算法创建的哈希,needsReHash 都会返回 true

Warning

在你的所有用户都已登录并且他们的密码已重新哈希之前,请保持旧驱动配置。在迁移完成之前移除旧驱动,将导致拥有旧哈希的用户无法身份验证。在移除旧配置之前,请监控你的数据库以跟踪迁移进度。

config/hash.ts
import { defineConfig, drivers } from '@adonisjs/core/hash'

export default defineConfig({
  /**
   * 从 'scrypt' 改为 'argon'。现有的 scrypt 哈希
   * 会从 needsReHash() 返回 true。
   */
  default: 'argon',

  list: {
    /**
     * 保持旧驱动配置,以便在迁移期间
     * 仍然能够验证现有的哈希。
     */
    scrypt: drivers.scrypt(),
    argon: drivers.argon2({
      variant: 'id',
      iterations: 3,
      memory: 65536,
      parallelism: 4,
    }),
  },
})

使用多个驱动

某些应用需要验证由不同系统创建的哈希,或同时支持多种哈希策略。hash.use 方法让你可以显式选择一个驱动。

app/services/migration_service.ts
import hash from '@adonisjs/core/services/hash'

export default class MigrationService {
  /**
   * 验证一个可能由使用 bcrypt 的
   * 遗留系统哈希过的密码。
   */
  async verifyLegacyPassword(password: string, storedHash: string) {
    return hash.use('bcrypt').verify(storedHash, password)
  }

  /**
   * 无论默认驱动是什么,都使用 Argon2 创建一个新哈希。
   */
  async hashWithArgon(password: string) {
    return hash.use('argon').make(password)
  }
}

hash.use() 中指定的每个驱动都必须在你的 config/hash.ts 文件的 list 对象中配置。

使用模型钩子进行哈希

如果你没有使用 @adonisjs/auth 模块的 AuthFinder mixin,你可以通过 Lucid 模型钩子自动对密码进行哈希。使用 $dirty 检查可以确保密码仅在该字段实际发生变化时才被哈希,防止每次保存都重新哈希。

app/models/user.ts
import { BaseModel, beforeSave, column } from '@adonisjs/lucid/orm'
import hash from '@adonisjs/core/services/hash'

export default class User extends BaseModel {
  @column()
  declare email: string

  @column()
  declare password: string

  @beforeSave()
  static async hashPassword(user: User) {
    if (user.$dirty.password) {
      user.password = await hash.make(user.password)
    }
  }
}

使用伪造进行测试

密码哈希在刻意设计上就很慢,而这正是它安全的原因。然而,这会显著拖慢你的测试套件,尤其是在通过工厂创建许多用户时。与真实实现不同,hash.fake 方法会用一种快速的、不安全但仅适用于测试的版本来替代真实实现。

tests/functional/users.spec.ts
import { test } from '@japa/runner'
import hash from '@adonisjs/core/services/hash'
import { UserFactory } from '#database/factories/user_factory'

test.group('Users list', () => {
  test('lists all users', async ({ client }) => {
    /**
     * 用一个不执行实际哈希的伪造实现
     * 替换哈希服务。这使得用户创建瞬间完成。
     * `using` 关键字会在测试结束时自动恢复
     * 真实实现。
     */
    using _hash = hash.fake()

    /**
     * 如果不伪造,使用 bcrypt(10 轮)创建 50 个用户
     * 大约需要 5 秒。使用伪造后,几乎是瞬时的。
     */
    await UserFactory.createMany(50)

    const response = await client.get('/users')
    response.assertStatus(200)
  })
})

伪造的实现会存储明文并直接比较字符串。如果你需要更精细地控制真实实现何时恢复,也可以手动调用 hash.restore()

理解 PHC 格式

PHC(Password Hashing Competition) 字符串格式是一种标准化的哈希编码方式,它内嵌了验证哈希所需的全部信息。一个 PHC 字符串形如:

$scrypt$n=16384,r=8,p=1$c2FsdHZhbHVl$aGFzaG91dHB1dC4uLg==

该格式被拆分为由 $ 分隔的部分:

  1. 算法标识符scryptargon2idbcrypt 等。
  2. 参数:特定于算法的设置,如 cost、memory、iterations。
  3. 盐值:Base64 编码的随机盐。
  4. 哈希:Base64 编码的哈希输出。

这种自描述格式带来了几个好处。验证过程可以直接从哈希中读取参数,使你在更改配置后仍能验证旧哈希。needsReHash 方法将内嵌的参数与当前设置进行比对,以检测哈希何时需要更新。你也可以在不丢失验证现有密码能力的情况下切换算法。

创建自定义驱动

对于特殊需求,你可以创建自定义的哈希驱动。一个驱动必须实现 HashDriverContract 接口,包含四个方法:makeverifyisValidHashneedsReHash

app/hash_drivers/pbkdf2.ts
import crypto from 'node:crypto'
import {
  HashDriverContract,
  ManagerDriverFactory,
} from '@adonisjs/core/types/hash'

/**
 * 驱动接受的配置选项。
 */
export type Pbkdf2Config = {
  iterations: number
  keyLength: number
  digest: 'sha256' | 'sha512'
  saltSize: number
}

/**
 * 使用 Node 的 PBKDF2 的驱动实现。
 * 此处仅作说明。生产环境中优先使用 Argon2/Bcrypt/Scrypt。
 */
export class Pbkdf2Driver implements HashDriverContract {
  constructor(private config: Pbkdf2Config) {}

  /**
   * 检查一个字符串是否看起来像来自此驱动的哈希。
   * 用于确定应由哪个驱动来验证哈希。
   */
  isValidHash(value: string): boolean {
    return value.startsWith('$pbkdf2$')
  }

  /**
   * 对明文值进行哈希,并返回 PHC 格式的字符串。
   */
  async make(value: string): Promise<string> {
    const salt = crypto.randomBytes(this.config.saltSize)
    const hash = crypto.pbkdf2Sync(
      value,
      salt,
      this.config.iterations,
      this.config.keyLength,
      this.config.digest
    )

    /**
     * 以 PHC 格式编码,包含验证所需的所有参数。
     */
    const params = `i=${this.config.iterations},l=${this.config.keyLength},d=${this.config.digest}`
    return `$pbkdf2$${params}$${salt.toString('base64')}$${hash.toString('base64')}`
  }

  /**
   * 根据存储的哈希验证明文值。
   */
  async verify(hashedValue: string, plainValue: string): Promise<boolean> {
    const parts = hashedValue.split('$')
    const params = this.#parseParams(parts[2])
    const salt = Buffer.from(parts[3], 'base64')
    const storedHash = Buffer.from(parts[4], 'base64')

    const computedHash = crypto.pbkdf2Sync(
      plainValue,
      salt,
      params.iterations,
      params.keyLength,
      params.digest
    )

    /**
     * 使用时间安全的比较来防止计时攻击。
     */
    return crypto.timingSafeEqual(storedHash, computedHash)
  }

  /**
   * 检查哈希是否因为配置已更改
   * 而需要重新生成。
   */
  needsReHash(value: string): boolean {
    const parts = value.split('$')
    const params = this.#parseParams(parts[2])

    return (
      params.iterations !== this.config.iterations ||
      params.keyLength !== this.config.keyLength ||
      params.digest !== this.config.digest
    )
  }

  #parseParams(paramString: string) {
    const params: Record<string, string> = {}
    for (const pair of paramString.split(',')) {
      const [key, val] = pair.split('=')
      params[key] = val
    }
    return {
      iterations: parseInt(params.i, 10),
      keyLength: parseInt(params.l, 10),
      digest: params.d as 'sha256' | 'sha512',
    }
  }
}

/**
 * 在配置中引用驱动的工厂函数。
 * 返回一个惰性创建驱动实例的闭包。
 */
export function pbkdf2Driver(config: Pbkdf2Config): ManagerDriverFactory {
  return () => new Pbkdf2Driver(config)
}

在你的哈希配置中注册你的自定义驱动。

config/hash.ts
import { defineConfig, drivers } from '@adonisjs/core/hash'
import { pbkdf2Driver } from '#app/hash_drivers/pbkdf2'

export default defineConfig({
  default: 'pbkdf2',

  list: {
    pbkdf2: pbkdf2Driver({
      iterations: 100000,
      keyLength: 64,
      digest: 'sha512',
      saltSize: 16,
    }),
  },
})