操作指南:密码哈希
使用 PHP 原生的 password_hash() / password_verify() 配合 NENE2 安全存储和验证密码。
快速入门
// 注册——存储前哈希
$hash = password_hash($password, PASSWORD_ARGON2ID);
$user = $this->repo->create($email, $hash);
// 登录——恒定时间验证
if (!password_verify($inputPassword, $user->passwordHash)) {
// 返回 401
}算法:始终使用 PASSWORD_ARGON2ID
PASSWORD_DEFAULT 在 PHP 8.4 中仍是 bcrypt。Argon2id 是内存密集型的,能抵抗 GPU/ASIC 攻击。
// ❌ PASSWORD_DEFAULT = bcrypt — 更容易受到 GPU 暴力破解
$hash = password_hash($password, PASSWORD_DEFAULT);
// ✅ Argon2id — 内存密集型,推荐用于新项目
$hash = password_hash($password, PASSWORD_ARGON2ID);Argon2id 需要 PHP 7.3+。NENE2 要求 PHP 8.4,因此始终可用。
检测 UNIQUE 违规:DatabaseConstraintException
NENE2 的 PdoDatabaseQueryExecutor 在重新抛出之前会将所有约束违规(UNIQUE、FK、NOT NULL)包装成 DatabaseConstraintException。直接捕获 \PDOException 不起作用。
use Nene2\Database\DatabaseConstraintException;
// ❌ 永远不会到达这里——PDOException 已被包装
catch (\PDOException $e) {
if (str_contains($e->getMessage(), 'UNIQUE constraint failed')) { ... }
}
// ✅ 捕获 NENE2 包装类
catch (DatabaseConstraintException) {
throw new DuplicateEmailException($email);
}DatabaseConstraintException 是稳定公开 API 的一部分(ADR 0009)。
完整仓储模式:
use Nene2\Database\DatabaseConstraintException;
use Nene2\Database\DatabaseQueryExecutorInterface;
final class UserRepository
{
public function __construct(private readonly DatabaseQueryExecutorInterface $executor) {}
/** @throws DuplicateEmailException */
public function create(string $email, string $passwordHash): User
{
try {
$now = (new \DateTimeImmutable())->format('Y-m-d\TH:i:s\Z');
$id = $this->executor->insert(
'INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)',
[$email, $passwordHash, $now],
);
return new User(id: $id, email: $email, passwordHash: $passwordHash, createdAt: $now);
} catch (DatabaseConstraintException) {
throw new DuplicateEmailException($email);
}
}
}用户枚举防护(时序攻击)
如果在邮箱未找到时立即返回 401,时序差异就会揭示邮箱是否存在——未找到的响应立即返回,而密码错误的响应需要完整的 Argon2id 计算时间。
// ❌ 时序泄露——未找到时明显更快
if ($user === null) {
return $this->problems->create($request, 'invalid-credentials', ...);
}
if (!password_verify($password, $user->passwordHash)) {
return $this->problems->create($request, 'invalid-credentials', ...);
}
// ✅ 始终运行 password_verify——无论用户是否存在,时间恒定
$dummyHash = '$argon2id$v=19$m=65536,t=4,p=1$dummysaltdummysaltdummysalt$dummyhashvaluedummyhashvaluedummyh';
$hashToCheck = $user !== null ? $user->passwordHash : $dummyHash;
if (!password_verify($password, $hashToCheck) || $user === null) {
return $this->problems->create($request, 'invalid-credentials', 'Invalid Credentials', 401,
'The email or password is incorrect.');
}虚拟哈希必须是以 $argon2id$ 开头的有效 Argon2id 格式字符串。如果不是,password_verify() 会短路并立即返回 false,重新引入时序泄露。
password_verify() 是算法无关的
password_verify() 读取哈希前缀以确定算法。从 bcrypt 迁移到 Argon2id 时无需更改验证代码。
// 对 bcrypt 和 Argon2id 哈希都适用
$result = password_verify($plaintext, $storedHash); // 始终正确在成功登录时使用 password_needs_rehash() 透明地升级旧哈希:
if (password_verify($password, $user->passwordHash)) {
if (password_needs_rehash($user->passwordHash, PASSWORD_ARGON2ID)) {
$newHash = password_hash($password, PASSWORD_ARGON2ID);
$this->repo->updatePasswordHash($user->id, $newHash);
}
// 继续处理已认证用户
}绝不在响应中包含 password_hash
toArray() 或类似的辅助方法可能包含每一列。只明确列出你打算返回的字段。
// ❌ 如果 $user 有 toArray() 方法,可能泄露 password_hash
return $this->json->create($user->toArray(), 201);
// ✅ 明确字段列表——password_hash 永不出现
return $this->json->create([
'id' => $user->id,
'email' => $user->email,
'created_at' => $user->createdAt,
], 201);RouteRegistrar::register() 命名冲突
NENE2 的 RouteRegistrar 契约要求一个公开的 register(Router $router) 方法。不要将路由处理器命名为 register()——PHP 会拒绝重复的方法名。
// ❌ 致命错误:无法重新声明 RouteRegistrar::register()
$router->post('/register', $this->register(...));
private function register(...) { ... }
// ✅ 使用不同的处理器名称
$router->post('/register', $this->handleRegister(...));
private function handleRegister(...) { ... }代码审查清单
- [ ] 使用
password_hash()配合PASSWORD_ARGON2ID(不是 MD5、SHA-1、bcrypt 或PASSWORD_DEFAULT) - [ ] 使用
password_verify()进行比较(不是===、hash_equals()或自定义比较) - [ ] 用户未找到时也运行
password_verify()(虚拟哈希模式) - [ ] 捕获
DatabaseConstraintException以检测重复邮箱/用户名 - [ ] 从所有 API 响应中排除
password_hash/password字段 - [ ] 登录对未知邮箱返回 401(而非 404)——绝不透露邮箱是否存在
- [ ] 明文密码不写入日志