Senhas
Uma senha nunca deve ser guardada como foi digitada, nem com criptografia que dê para desfazer. O que vai para o banco é um hash: um resumo calculado de um jeito lento e com um valor aleatório (o salt), para que um vazamento do banco não entregue as senhas e para que adivinhá-las uma a uma custe caro. Este capítulo mostra o PasswordHasher do yiisoft/security, como atualizar hashes antigos e como impor uma política de senha num formulário de troca de senha.
PasswordHasher
O Yiisoft\Security\PasswordHasher é uma camada fina sobre as funções password_hash(), password_verify() e password_needs_rehash() do PHP. Ele tem três métodos:
$hasher = new PasswordHasher();
$hash = $hasher->hash('senha-de-exemplo');
// $2y$13$...
$hasher->validate('senha-de-exemplo', $hash); // true
$hasher->validate('outra-senha', $hash); // false
$hasher->needsRehash($hash); // falsehash()gera o hash, com um salt novo a cada chamada. A mesma senha gera hashes diferentes cada vez, e todos são válidos.validate()confere uma senha com um hash. Ele lançaInvalidArgumentExceptionse a senha for uma string vazia, por isso o formulário de login temRequiredna senha.needsRehash()diz se o hash foi gerado com um algoritmo ou custo diferente do configurado agora.
Sem argumentos, o algoritmo é o PASSWORD_DEFAULT do PHP, que no PHP 8.4 é o bcrypt, e o custo é 13. O prefixo $2y$13$ do hash guarda o algoritmo e o custo. Cada ponto a mais no custo dobra o tempo de cálculo. Na máquina usada para escrever este guia, um hash com custo 13 levou cerca de 380 ms: lento o bastante para atrapalhar quem tenta milhões de senhas, e aceitável para um login.
O PasswordHasher não precisa de configuração. Peça no construtor da action e o container cria um com os valores padrão. Para trocar o algoritmo, por exemplo para o Argon2id, defina no container:
use Yiisoft\Security\PasswordHasher;
return [
PasswordHasher::class => [
'__construct()' => ['algorithm' => PASSWORD_ARGON2ID],
],
];Os hashes antigos continuam funcionando depois da troca: o password_verify() lê o algoritmo do próprio hash. O needsRehash() dos hashes antigos passa a devolver true, e é isso que permite migrar aos poucos.
Atualizando hashes no login
O momento de atualizar um hash é o login, porque é o único momento em que o app tem a senha em texto. A action de login do capítulo Autenticação faz isso logo depois de validar a senha (trecho):
if ($usuario !== null && $this->passwordHasher->validate($form->senha, $usuario->senhaHash)) {
if ($this->passwordHasher->needsRehash($usuario->senhaHash)) {
$this->usuarios->alterarSenha($usuario->id, $this->passwordHasher->hash($form->senha));
}
$this->currentUser->login($usuario);
return $this->irParaOPainel();
}Para testar, troque o hash da Ana por um gerado com custo 10 (password_hash('senha-de-exemplo', PASSWORD_BCRYPT, ['cost' => 10])) e faça login. Antes do login, o hash começa com $2y$10$. Depois, com $2y$13$.
O limite de 72 bytes do bcrypt
O bcrypt só usa os primeiros 72 bytes da senha. No PHP 8.4, o resto é ignorado em silêncio:
$hasher = new PasswordHasher();
$base = str_repeat('a', 72);
$hash = $hasher->hash($base . 'PRIMEIRO');
$hasher->validate($base . 'OUTRO-FINAL', $hash); // truePor isso a política de senha abaixo limita o tamanho. Com o Argon2id, esse limite não existe.
Política de senha
As recomendações atuais (como as do NIST, na publicação SP 800-63B) privilegiam o comprimento sobre regras de composição como "uma maiúscula, um número e um símbolo". Na prática:
- Exija um tamanho mínimo razoável (12 caracteres, por exemplo) e aceite senhas longas, como frases.
- Recuse senhas comuns e senhas que contêm dados do próprio usuário.
- Não obrigue a troca periódica sem motivo.
Essas regras viram regras de validação num formulário. Crie src/Web/Seguranca/Senha/TrocarSenhaForm.php:
<?php
declare(strict_types=1);
namespace App\Web\Seguranca\Senha;
use Yiisoft\FormModel\FormModel;
use Yiisoft\Validator\Label;
use Yiisoft\Validator\Result;
use Yiisoft\Validator\Rule\Callback;
use Yiisoft\Validator\Rule\CompareType;
use Yiisoft\Validator\Rule\Equal;
use Yiisoft\Validator\Rule\Length;
use Yiisoft\Validator\Rule\Required;
final class TrocarSenhaForm extends FormModel
{
private const SENHAS_COMUNS = [
'123456789012',
'senha1234567',
'qwertyuiopas',
'abcdefghijkl',
];
#[Label('Senha atual')]
#[Required(message: 'Informe a senha atual.')]
public string $senhaAtual = '';
#[Label('Nova senha')]
#[Required(message: 'Informe a nova senha.')]
#[Length(
min: 12,
max: 64,
lessThanMinMessage: 'A senha precisa ter pelo menos {min} caracteres.',
greaterThanMaxMessage: 'A senha pode ter no máximo {max} caracteres.',
skipOnEmpty: true,
)]
#[Callback(method: 'validarNovaSenha', skipOnEmpty: true, skipOnError: true)]
public string $novaSenha = '';
#[Label('Repita a nova senha')]
#[Equal(targetProperty: 'novaSenha', type: CompareType::STRING, message: 'As senhas não conferem.')]
public string $confirmacao = '';
public function __construct(
private readonly string $email,
) {}
private function validarNovaSenha(string $senha): Result
{
$result = new Result();
$senhaMinuscula = mb_strtolower($senha);
$usuarioDoEmail = mb_strtolower(explode('@', $this->email)[0]);
if (in_array($senhaMinuscula, self::SENHAS_COMUNS, true)) {
$result->addError('Esta senha é comum demais. Escolha outra.');
} elseif (str_contains($senhaMinuscula, $usuarioDoEmail)) {
$result->addError('A senha não pode conter o seu e-mail.');
} elseif ($senha === $this->senhaAtual) {
$result->addError('A nova senha precisa ser diferente da atual.');
}
return $result;
}
}Pontos a notar:
- O
max: 64conta caracteres, e o limite do bcrypt é em bytes. Com acentos e outros caracteres de mais de um byte, 64 caracteres podem passar de 72 bytes. Se isso importa no seu caso, use o Argon2id ou confira o tamanho comstrlen()noCallback. - O
#[Callback(method: ...)]chama um método do próprio formulário, que pode até ser privado. O método recebe o valor e devolve umResult. OskipOnError: truefaz oCallbacksó rodar se oLengthpassou. - O e-mail chega pelo construtor. Como a propriedade
$emailnão tem regra, oFormHydratornão a preenche com o que vier do POST. - A lista de senhas comuns aqui é só ilustrativa. Num sistema real, use uma lista grande (as listas de senhas vazadas mais usadas têm milhares de itens) ou consulte um serviço como o Have I Been Pwned, que permite checar uma senha sem enviá-la inteira.
A troca de senha
A action confere a senha atual antes de trocar. Crie src/Web/Seguranca/Senha/Action.php:
<?php
declare(strict_types=1);
namespace App\Web\Seguranca\Senha;
use App\Seguranca\Usuario;
use App\Seguranca\UsuarioRepositorio;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Yiisoft\FormModel\FormHydrator;
use Yiisoft\Http\Status;
use Yiisoft\Router\UrlGeneratorInterface;
use Yiisoft\Security\PasswordHasher;
use Yiisoft\Session\Flash\FlashInterface;
use Yiisoft\User\CurrentUser;
use Yiisoft\Yii\View\Renderer\WebViewRenderer;
final readonly class Action
{
public function __construct(
private WebViewRenderer $viewRenderer,
private FormHydrator $formHydrator,
private CurrentUser $currentUser,
private UsuarioRepositorio $usuarios,
private PasswordHasher $passwordHasher,
private FlashInterface $flash,
private ResponseFactoryInterface $responseFactory,
private UrlGeneratorInterface $urlGenerator,
) {}
public function __invoke(ServerRequestInterface $request): ResponseInterface
{
/** @var Usuario $usuario */
$usuario = $this->currentUser->getIdentity();
$form = new TrocarSenhaForm($usuario->email);
if ($this->formHydrator->populateFromPostAndValidate($form, $request)) {
if ($this->passwordHasher->validate($form->senhaAtual, $usuario->senhaHash)) {
$this->usuarios->alterarSenha($usuario->id, $this->passwordHasher->hash($form->novaSenha));
$this->flash->set('aviso', 'Senha alterada.');
return $this->responseFactory
->createResponse(Status::FOUND)
->withHeader('Location', $this->urlGenerator->generate('seguranca/painel'));
}
$form->addError('A senha atual está incorreta.', ['senhaAtual']);
}
// Senhas nunca voltam preenchidas para o navegador
$form->senhaAtual = $form->novaSenha = $form->confirmacao = '';
return $this->viewRenderer->render(__DIR__ . '/formulario', ['form' => $form]);
}
}Pedir a senha atual impede que alguém que encontrou uma sessão aberta troque a senha e tome a conta. A mensagem de sucesso vai como mensagem flash e aparece no painel.
A view, em src/Web/Seguranca/Senha/formulario.php:
<?php
declare(strict_types=1);
use Yiisoft\FormModel\Field;
use Yiisoft\Html\Html;
/**
* @var Yiisoft\View\WebView $this
* @var Yiisoft\Router\UrlGeneratorInterface $urlGenerator
* @var Yiisoft\Yii\View\Renderer\Csrf $csrf
* @var App\Web\Seguranca\Senha\TrocarSenhaForm $form
*/
$this->setTitle('Trocar a senha');
?>
<h1>Trocar a senha</h1>
<?= Html::form()->post($urlGenerator->generate('seguranca/senha'))->csrf($csrf)->open() ?>
<?= Field::password($form, 'senhaAtual') ?>
<?= Field::password($form, 'novaSenha')->hint('De 12 a 64 caracteres. Uma frase com várias palavras funciona bem.') ?>
<?= Field::password($form, 'confirmacao') ?>
<?= Field::submitButton('Trocar') ?>
<?= Html::form()->close() ?>A rota fica no grupo que exige login:
Route::methods([Method::GET, Method::POST], '/senha')
->action(Web\Seguranca\Senha\Action::class)
->name('seguranca/senha'),Testando
Entre como bruno@exemplo.com e abra /seguranca/senha. Com a senha atual senha-de-exemplo:
- Nova senha
curta: "A senha precisa ter pelo menos 12 caracteres.". 123456789012: "Esta senha é comum demais. Escolha outra.".bruno-e-legal-2026: "A senha não pode conter o seu e-mail.".senha-de-exemplo: "A nova senha precisa ser diferente da atual.".- Confirmação diferente da nova senha: "As senhas não conferem.".
- Senha atual errada: "A senha atual está incorreta.".
cavalo correto bateria gramponos dois campos: o painel abre com "Senha alterada.", e daí em diante o login só funciona com a senha nova.
Comparando com o Yii2
- O
Yii::$app->security->generatePasswordHash()e ovalidatePassword()viraramPasswordHasher::hash()evalidate(), numa classe só para isso. - O Yii2 usava bcrypt com o custo do
passwordHashCost(13 por padrão). OPasswordHasherusa o algoritmo padrão do PHP com o mesmo custo, e aceita outros algoritmos no construtor. - O
needsRehash()não tinha equivalente direto no componentesecuritydo Yii2.