Como Fazer: Hashing de Senha
Armazenando e verificando senhas com segurança usando password_hash() / password_verify() nativo do PHP com NENE2.
Início rápido
// Registro — hash antes de armazenar
$hash = password_hash($password, PASSWORD_ARGON2ID);
$user = $this->repo->create($email, $hash);
// Login — verificação em tempo constante
if (!password_verify($inputPassword, $user->passwordHash)) {
// retornar 401
}Algoritmo: sempre use PASSWORD_ARGON2ID
PASSWORD_DEFAULT ainda é bcrypt no PHP 8.4. Argon2id é memory-hard e resiste a ataques por GPU/ASIC.
// ❌ PASSWORD_DEFAULT = bcrypt — mais vulnerável a força bruta por GPU
$hash = password_hash($password, PASSWORD_DEFAULT);
// ✅ Argon2id — memory-hard, recomendado para novos projetos
$hash = password_hash($password, PASSWORD_ARGON2ID);Argon2id requer PHP 7.3+. O NENE2 requer PHP 8.4, então está sempre disponível.
Detectando violações de UNIQUE: DatabaseConstraintException
O PdoDatabaseQueryExecutor do NENE2 encapsula todas as violações de restrição (UNIQUE, FK, NOT NULL) em DatabaseConstraintException antes de relançar. Capturar \PDOException diretamente não funciona.
use Nene2\Database\DatabaseConstraintException;
// ❌ Nunca chega aqui — PDOException já está encapsulada
catch (\PDOException $e) {
if (str_contains($e->getMessage(), 'UNIQUE constraint failed')) { ... }
}
// ✅ Capturar o wrapper do NENE2
catch (DatabaseConstraintException) {
throw new DuplicateEmailException($email);
}DatabaseConstraintException faz parte da API pública estável (ADR 0009).
Padrão completo de repositório:
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);
}
}
}Prevenção de enumeração de usuário (ataque de timing)
Se você retornar 401 imediatamente quando o e-mail não é encontrado, uma diferença de timing revela se o e-mail existe — respostas de não encontrado retornam instantaneamente, enquanto respostas de senha errada levam o tempo completo de computação do Argon2id.
// ❌ Vazamento de timing — não encontrado é mensuravelmente mais rápido
if ($user === null) {
return $this->problems->create($request, 'invalid-credentials', ...);
}
if (!password_verify($password, $user->passwordHash)) {
return $this->problems->create($request, 'invalid-credentials', ...);
}
// ✅ Sempre executar password_verify — tempo constante independente de o usuário existir
$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.');
}O hash fictício deve ser uma string válida no formato Argon2id começando com $argon2id$. Se não for, password_verify() retorna false imediatamente, recriando o vazamento de timing.
password_verify() é agnóstico de algoritmo
password_verify() lê o prefixo do hash para determinar o algoritmo. Você não precisa alterar o código de verificação ao migrar de bcrypt para Argon2id.
// Funciona tanto em hashes bcrypt quanto Argon2id
$result = password_verify($plaintext, $storedHash); // sempre corretoUse password_needs_rehash() no login bem-sucedido para fazer upgrade de hashes legados de forma transparente:
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);
}
// prosseguir com usuário autenticado
}Nunca incluir password_hash na resposta
Helpers como toArray() podem incluir todas as colunas. Liste explicitamente apenas os campos que pretende retornar.
// ❌ Pode vazar password_hash se $user tiver um método toArray()
return $this->json->create($user->toArray(), 201);
// ✅ Lista explícita de campos — password_hash nunca está presente
return $this->json->create([
'id' => $user->id,
'email' => $user->email,
'created_at' => $user->createdAt,
], 201);Conflito de nome RouteRegistrar::register()
O contrato RouteRegistrar do NENE2 requer um método público register(Router $router). Não nomeie um handler de rota como register() — o PHP rejeitará o nome de método duplicado.
// ❌ Erro fatal: Cannot redeclare RouteRegistrar::register()
$router->post('/register', $this->register(...));
private function register(...) { ... }
// ✅ Use um nome de handler distinto
$router->post('/register', $this->handleRegister(...));
private function handleRegister(...) { ... }Checklist de revisão de código
- [ ]
password_hash()comPASSWORD_ARGON2IDé usado (não MD5, SHA-1, bcrypt, ouPASSWORD_DEFAULT) - [ ]
password_verify()é usado para comparação (não===,hash_equals(), ou comparação customizada) - [ ]
password_verify()é executado mesmo quando o usuário não é encontrado (padrão de hash fictício) - [ ]
DatabaseConstraintExceptioné capturado para detecção de e-mail/nome de usuário duplicado - [ ] Campos
password_hash/passwordsão excluídos de todas as respostas da API - [ ] Login retorna 401 (não 404) para e-mail desconhecido — nunca revelar se o e-mail existe
- [ ] Senha em texto puro não é escrita nos logs