From 5c0993ba7bb21d42dd937b5a82a5267b71b347c8 Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Tue, 6 Oct 2026 17:21:47 -0300 Subject: [PATCH 1/6] feat(email): interface de envio com driver de log e sem provedor por padrao MAIL_DRIVER vazio nao manda nada e avisa no log (sem o conteudo); "log" grava o e-mail em MAIL_LOG_FILE para dev e testes. Provedor, SPF e DKIM ficam para a escolha do time. Task 55 (parte de codigo). Co-Authored-By: Claude Opus 5.5 --- .env.example | 6 +++ CLAUDE.md | 2 + docs/operacao.md | 1 + src/Mail/LogMailer.php | 38 ++++++++++++++++ src/Mail/Mailer.php | 9 ++++ src/Mail/MailerFactory.php | 18 ++++++++ src/Mail/Message.php | 13 ++++++ src/Mail/NullMailer.php | 16 +++++++ src/Support/Env.php | 1 + tests/Support/HttpTestCase.php | 13 ++++++ tests/Unit/MailerTest.php | 83 ++++++++++++++++++++++++++++++++++ 11 files changed, 200 insertions(+) create mode 100644 src/Mail/LogMailer.php create mode 100644 src/Mail/Mailer.php create mode 100644 src/Mail/MailerFactory.php create mode 100644 src/Mail/Message.php create mode 100644 src/Mail/NullMailer.php create mode 100644 tests/Unit/MailerTest.php diff --git a/.env.example b/.env.example index 1769c19..060002f 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,12 @@ BACKUP_DIR= BACKUP_KEEP_DAYS= MYSQLDUMP_BIN= +# e-mail transacional (recuperacao de senha, confirmacao de e-mail). Vazio = nenhum e-mail sai e o log avisa +# (o provedor de producao ainda nao foi escolhido). Em desenvolvimento use MAIL_DRIVER=log: cada e-mail vira uma +# linha json em MAIL_LOG_FILE (vazio = storage/mail.log), com os links. Nunca use "log" em producao. +MAIL_DRIVER= +MAIL_LOG_FILE= + # so para o docker compose: senha do root do MySQL do container e porta publicada no host (vazio = 8080). # No compose, DB_PASS acima e a senha do usuario "fidelityx" do banco e nao pode ficar vazia. DB_ROOT_PASS= diff --git a/CLAUDE.md b/CLAUDE.md index 4862df2..6ca502c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,8 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **Limite de tentativas** — `App\Support\RateLimiter` conta tentativas na tabela `rate_limit_hits` (chave guardada só como hash SHA-256), então o limite sobrevive a apagar o cookie. Usos: login (em 15 min, 5 erros do mesmo e-mail vindos do mesmo IP ou 20 erros do mesmo IP em qualquer conta → 429), troca de senha (5 senhas atuais erradas por conta) e consulta pública (5/min por IP). **O login nunca bloqueia só pelo e-mail**: isso deixaria qualquer pessoa trancar a dona da conta errando a senha de propósito (task 38). O bucket `login_account` (por conta, somando todos os IPs) só gera aviso no log ao chegar em `LoginGuard::MAX_FAILURES_PER_ACCOUNT` (50); o CAPTCHA que deve entrar aí ainda não foi escolhido (task 45). E-mail inexistente roda `password_verify` contra `LoginGuard::DUMMY_HASH` (mesmo tempo da senha errada), e o login gera token CSRF novo (`Csrf::regenerate`). Nos buckets a chave do IP é `RateLimiter::clientKey()`, que em IPv6 vale pela rede /64 (um assinante tem o bloco inteiro e trocaria de endereço a cada tentativa). O endereço exato vem de `RateLimiter::clientIp()` (`App\Support\ClientIp`): é o `REMOTE_ADDR`, e o `X-Forwarded-For` só vale quando a requisição chega de um proxy listado em `TRUSTED_PROXIES` (o mesmo vale para o `X-Forwarded-Proto` em `RequestGuard::isHttps()`). Nunca leia esses cabeçalhos direto. +**E-mail** (task 55) — envie sempre por `App\Mail\MailerFactory::fromEnv()->send(new Message(...))`, nunca com `mail()` direto. `MAIL_DRIVER` vazio = `NullMailer` (nada sai, o log registra só assunto e hash do destinatário); `log` = `LogMailer`, que grava o e-mail inteiro em `MAIL_LOG_FILE` (dev e testes: o `HttpTestCase` liga e o teste lê com `sentMails()`). O provedor de produção ainda não foi escolhido. + **Migrations** — `App\Support\Migrator` + `bin/migrate.php` registram em `schema_migrations` o nome de cada arquivo aplicado (o controle é pelo nome, não pelo maior número: migration de outro PR com número menor ainda roda). Migration nova = arquivo `NNN_descricao.sql` + a mesma mudança no `schema.sql` + o nome no `INSERT INTO schema_migrations` do fim do `schema.sql` (o `MigratorTest` cobra). **Produção** — `Dockerfile` + `docker-compose.yml` (Apache, PHP 8.3, MySQL 8; `docker/php.ini` desliga `display_errors`), backup por `bin/backup.php` (`App\Support\Backup`) e o guia em `docs/operacao.md`. Mudou variável de ambiente, comando de operação ou volume: atualize esse guia. diff --git a/docs/operacao.md b/docs/operacao.md index 236ad14..bd411e4 100644 --- a/docs/operacao.md +++ b/docs/operacao.md @@ -15,6 +15,7 @@ Toda a configuração vem de **variáveis de ambiente**. O arquivo `.env` na rai | `TRUSTED_PROXIES` | não | IPs ou faixas CIDR dos proxies confiáveis, separados por vírgula. Ver [Proxy reverso](#proxy-reverso). | | `LOG_FILE` | não | Arquivo do log de erros. Vazio usa o destino do `php.ini`. | | `BACKUP_DIR`, `BACKUP_KEEP_DAYS`, `MYSQLDUMP_BIN` | não | Backup: pasta (padrão `storage/backups`), dias guardados (padrão `14`, `0` nunca apaga) e caminho do `mysqldump`. | +| `MAIL_DRIVER`, `MAIL_LOG_FILE` | não | E-mail transacional (task 55). Vazio: nenhum e-mail sai e o log registra cada envio perdido (sem o conteúdo). `log`: grava os e-mails inteiros em `MAIL_LOG_FILE` (padrão `storage/mail.log`), **só para desenvolvimento** (tem os links com token). O provedor de produção (SMTP/API, SPF e DKIM do domínio) ainda não foi escolhido. | | `DB_ROOT_PASS`, `APP_PORT` | só no Compose | Senha do root do MySQL do container e porta publicada no host (padrão `8080`). | ## Docker diff --git a/src/Mail/LogMailer.php b/src/Mail/LogMailer.php new file mode 100644 index 0000000..4af3e8d --- /dev/null +++ b/src/Mail/LogMailer.php @@ -0,0 +1,38 @@ + date('c'), + 'to' => $message->to, + 'subject' => $message->subject, + 'body' => $message->body, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + + $dir = dirname($this->file); + if (!is_dir($dir)) { + @mkdir($dir, 0750, true); + } + if (@file_put_contents($this->file, $line, FILE_APPEND | LOCK_EX) === false) { + error_log('[mail] nao deu pra gravar em MAIL_LOG_FILE: ' . $this->file); + return false; + } + return true; + } + + // mensagens gravadas no arquivo, da mais antiga pra mais nova (usado pelos testes) + public static function read(string $file): array { + $messages = []; + foreach (@file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { + $messages[] = json_decode($line, true); + } + return $messages; + } +} diff --git a/src/Mail/Mailer.php b/src/Mail/Mailer.php new file mode 100644 index 0000000..8533aac --- /dev/null +++ b/src/Mail/Mailer.php @@ -0,0 +1,9 @@ + new LogMailer(Env::get('MAIL_LOG_FILE', dirname(__DIR__, 2) . '/storage/mail.log')), + default => new NullMailer(), + }; + } +} diff --git a/src/Mail/Message.php b/src/Mail/Message.php new file mode 100644 index 0000000..14f1176 --- /dev/null +++ b/src/Mail/Message.php @@ -0,0 +1,13 @@ +subject, + substr(hash('sha256', mb_strtolower(trim($message->to))), 0, 12) + )); + return false; + } +} diff --git a/src/Support/Env.php b/src/Support/Env.php index d980fcd..1a61d42 100644 --- a/src/Support/Env.php +++ b/src/Support/Env.php @@ -12,6 +12,7 @@ final class Env { 'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASS', 'APP_URL', 'APP_TIMEZONE', 'TRUSTED_PROXIES', 'LOG_FILE', 'BACKUP_DIR', 'BACKUP_KEEP_DAYS', 'MYSQLDUMP_BIN', + 'MAIL_DRIVER', 'MAIL_LOG_FILE', ]; // sem estas o banco nao conecta (DB_PASS pode ser vazia, DB_PORT tem padrao) diff --git a/tests/Support/HttpTestCase.php b/tests/Support/HttpTestCase.php index c199be6..e28df6d 100644 --- a/tests/Support/HttpTestCase.php +++ b/tests/Support/HttpTestCase.php @@ -9,6 +9,8 @@ abstract class HttpTestCase extends DatabaseTestCase { private static $server = null; private static string $baseUrl; + // e-mails "enviados" pelo servidor de teste (MAIL_DRIVER=log), uma linha json por mensagem + private static string $mailLog; private string $cookieJar; protected string $lastBody = ''; // cabecalhos da ultima resposta: nome em minusculas => lista de valores (set-cookie pode vir repetido) @@ -30,6 +32,10 @@ public static function setUpBeforeClass(): void { // o teste e o "proxy confiavel" do servidor: com isso um teste pode se passar por clientes de // ips diferentes mandando X-Forwarded-For (ver fromIp). sem o cabecalho nada muda: vale o 127.0.0.1. $env['TRUSTED_PROXIES'] = '127.0.0.1'; + // e-mail transacional vai pra um arquivo que o teste le (sentMails), nunca pra fora + self::$mailLog = tempnam(sys_get_temp_dir(), 'fx-mail'); + $env['MAIL_DRIVER'] = 'log'; + $env['MAIL_LOG_FILE'] = self::$mailLog; // comando em array: roda o php direto, sem shell no meio (assim o proc_terminate mata o servidor mesmo) self::$server = proc_open( @@ -59,12 +65,14 @@ public static function tearDownAfterClass(): void { proc_close(self::$server); self::$server = null; } + @unlink(self::$mailLog); } protected function setUp(): void { parent::setUp(); $this->cookieJar = tempnam(sys_get_temp_dir(), 'fx-cookie'); $this->clientIp = null; + file_put_contents(self::$mailLog, ''); $this->extraHeaders = []; } @@ -84,6 +92,11 @@ protected function tearDown(): void { @unlink($this->cookieJar); } + // e-mails que o sistema mandou neste teste: [['to' =>, 'subject' =>, 'body' =>, 'at' =>], ...] + protected function sentMails(): array { + return \App\Mail\LogMailer::read(self::$mailLog); + } + // sessao nova (como apagar o cookie no navegador) protected function newSession(): void { @unlink($this->cookieJar); diff --git a/tests/Unit/MailerTest.php b/tests/Unit/MailerTest.php new file mode 100644 index 0000000..cd6254e --- /dev/null +++ b/tests/Unit/MailerTest.php @@ -0,0 +1,83 @@ +envBackup = $_ENV; + $this->log = sys_get_temp_dir() . '/fx-mailer-' . bin2hex(random_bytes(4)) . '.log'; + // o que o codigo manda pro error_log vem pra ca (e nao pra saida do teste) + $this->previousErrorLog = ini_set('error_log', $this->log . '.err'); + } + + protected function tearDown(): void { + $_ENV = $this->envBackup; + ini_set('error_log', (string)$this->previousErrorLog); + @unlink($this->log); + @unlink($this->log . '.err'); + } + + private function message(): Message { + return new Message('ana@teste.test', 'Redefinir senha', "Abra o link:\nhttps://x/?token=segredo"); + } + + public function testSemDriverNenhumEmailSaiEOLogNaoTemOConteudo(): void { + unset($_ENV['MAIL_DRIVER']); + $mailer = MailerFactory::fromEnv(); + + $this->assertInstanceOf(NullMailer::class, $mailer); + $this->assertFalse($mailer->send($this->message())); + + $errors = (string)file_get_contents($this->log . '.err'); + $this->assertStringContainsString('[mail] e-mail nao enviado', $errors); + $this->assertStringContainsString('Redefinir senha', $errors); + $this->assertStringNotContainsString('segredo', $errors); + $this->assertStringNotContainsString('ana@teste.test', $errors); + } + + public function testDriverDesconhecidoNaoEnvia(): void { + $_ENV['MAIL_DRIVER'] = 'smtp'; + $this->assertInstanceOf(NullMailer::class, MailerFactory::fromEnv()); + } + + public function testDriverLogGravaUmaLinhaPorMensagem(): void { + $_ENV['MAIL_DRIVER'] = 'log'; + $_ENV['MAIL_LOG_FILE'] = $this->log; + $mailer = MailerFactory::fromEnv(); + + $this->assertInstanceOf(LogMailer::class, $mailer); + $this->assertTrue($mailer->send($this->message())); + $this->assertTrue($mailer->send(new Message('bia@teste.test', 'Outro', 'Corpo'))); + + $sent = LogMailer::read($this->log); + $this->assertCount(2, $sent); + $this->assertSame('ana@teste.test', $sent[0]['to']); + $this->assertSame('Redefinir senha', $sent[0]['subject']); + $this->assertSame("Abra o link:\nhttps://x/?token=segredo", $sent[0]['body']); + $this->assertSame('bia@teste.test', $sent[1]['to']); + } + + public function testArquivoQueNaoDaPraGravarDevolveFalseEAvisa(): void { + // a "pasta" do arquivo e um arquivo comum: nao da pra criar nem gravar + file_put_contents($this->log, ''); + $mailer = new LogMailer($this->log . '/mail.log'); + + $this->assertFalse($mailer->send($this->message())); + $this->assertStringContainsString('[mail] nao deu pra gravar', (string)file_get_contents($this->log . '.err')); + } + + public function testArquivoSemMensagens(): void { + $this->assertSame([], LogMailer::read($this->log . '-nao-existe')); + } +} From 8f76199fef7b15e716b546e45fb6ca45f2241574 Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Tue, 6 Oct 2026 17:27:55 -0300 Subject: [PATCH 2/6] feat(senha): recuperacao de senha por e-mail Token aleatorio de uso unico, valido por 1 hora e guardado so como hash (migration 008). O pedido responde igual exista o e-mail ou nao e tem limite por ip e por e-mail; trocar a senha derruba as sessoes abertas. Task 21. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 + database/migrations/008_password_resets.sql | 27 ++++ database/schema.sql | 26 +++- docs/db/schema-explanation.md | 8 ++ phpstan-baseline.neon | 24 ++++ public/css/auth.css | 7 + public/index.php | 2 + src/Controllers/MerchantController.php | 100 +++++++++++++ src/Models/PasswordResetModel.php | 87 ++++++++++++ tests/Feature/PasswordResetTest.php | 147 ++++++++++++++++++++ tests/Support/TestDatabase.php | 2 +- views/auth/merchant/forgot.php | 43 ++++++ views/auth/merchant/login.php | 2 + views/auth/merchant/reset.php | 44 ++++++ views/partials/flash.php | 4 + 15 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 database/migrations/008_password_resets.sql create mode 100644 src/Models/PasswordResetModel.php create mode 100644 tests/Feature/PasswordResetTest.php create mode 100644 views/auth/merchant/forgot.php create mode 100644 views/auth/merchant/reset.php diff --git a/CLAUDE.md b/CLAUDE.md index 6ca502c..2e7895a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,8 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **Perfil** — `merchant/profile` tem dois formulários na mesma rota: dados da loja (padrão) e troca de senha (`action=password`, exige a senha atual, com limite de erros no bucket `password_change`, e a nova não pode ser igual à atual). Cadastro e troca seguem `App\Support\PasswordPolicy`: de 8 a 72 **bytes** (o limite do bcrypt); o login não confere tamanho, então senha antiga fora da regra continua entrando. E-mail e CPF/CNPJ aparecem só para leitura e nunca são lidos do POST. A sessão usa `use_strict_mode` (id inventado não é aceito), `cookie_secure` quando a requisição é HTTPS e `gc_maxlifetime` igual ao limite de inatividade. +**Esqueci a senha** (task 21) — `merchant/forgot` manda por e-mail um link `merchant/reset&token=...` (`PasswordResetModel`: token de 32 bytes aleatórios, só o SHA-256 no banco, vale `TTL_SECONDS` = 1 h e uma vez; pedir outro cancela o anterior). A resposta do pedido é sempre a mesma, exista o e-mail ou não; limite de 10 pedidos/h por IP e 3/h por e-mail. Trocar a senha pelo link derruba as sessões abertas (mesmo `password_sig`). + **Proteções de toda requisição** — o `index.php` chama `RequestGuard` antes de rotear: cabeçalhos de segurança (`X-Frame-Options: DENY`, CSP `RequestGuard::CSP` com `default-src 'self'` e sem nada inline, HSTS em HTTPS via `RequestGuard::sendHsts` depois do `Env::load`, `nosniff`, `Referrer-Policy: same-origin`, sem `X-Powered-By`), `Cache-Control: no-store` em toda rota `merchant/*` e `customer/*` (`RequestGuard::sendNoStore`, depois do `session_start`) e parâmetros em formato de lista (`campo[]=`) viram texto vazio, então nenhum `(string)` de `$_GET`/`$_POST` gera warning. O logout é POST com CSRF (o "Sair" do menu é um formulário). Os testes de fluxo rodam com `display_errors` ligado (`tests/Support/router.php`): qualquer warning aparece no HTML e quebra o teste. **Erros** — `ErrorController::handle($code)` renderiza `views/errors/{code}.php` (fallback `default.php`) e entrega às views o botão de voltar (`$backUrl`, `$backLabel`): lojista logado volta ao painel, qualquer outra pessoa à home pública; página de erro nova usa essas variáveis, nunca link fixo. Exceções não tratadas e erros fatais são logados e viram 500 pelo `App\Support\ErrorLog` (registrado no `index.php`): uma linha por erro com um código curto, que a página 500 também mostra. Do contexto da requisição o log leva só método e rota, nunca query string, corpo ou argumentos (têm telefone e senha). Detalhes técnicos vão só para `error_log`, cujo destino é `LOG_FILE` ou o do `php.ini`. diff --git a/database/migrations/008_password_resets.sql b/database/migrations/008_password_resets.sql new file mode 100644 index 0000000..33d4798 --- /dev/null +++ b/database/migrations/008_password_resets.sql @@ -0,0 +1,27 @@ +-- fidelityx - migration 008 (recuperacao de senha por e-mail, task 21) +-- para bancos criados antes desta versao do schema.sql. rodar uma unica vez: +-- php bin/migrate.php +-- (ou, a mao: mysql -u root -p fidelityx < database/migrations/008_password_resets.sql) + +USE fidelityx; + +-- link de redefinicao de senha: o token vai so no e-mail; aqui fica o hash sha-256 dele +-- (quem ler o banco nao consegue usar). vale 1 hora e uma vez so. +CREATE TABLE IF NOT EXISTS password_resets ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + merchant_id BIGINT UNSIGNED NOT NULL, + token_hash CHAR(64) NOT NULL, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP NULL, -- usado ou cancelado (pedido novo ou senha trocada) + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uq_password_resets_token (token_hash), + KEY idx_password_resets_merchant (merchant_id), + + CONSTRAINT fk_password_resets_merchant + FOREIGN KEY (merchant_id) REFERENCES merchants(id) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/schema.sql b/database/schema.sql index 70c3e0f..1cb33da 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -182,6 +182,29 @@ CREATE TABLE IF NOT EXISTS rate_limit_hits ( KEY idx_rate_limit_created (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- ========================= +-- password_resets // links de redefinicao de senha (task 21) +-- ========================= +-- o token vai so no e-mail; aqui fica o hash sha-256 dele. vale 1 hora e uma vez so. +CREATE TABLE IF NOT EXISTS password_resets ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + merchant_id BIGINT UNSIGNED NOT NULL, + token_hash CHAR(64) NOT NULL, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP NULL, -- usado ou cancelado (pedido novo ou senha trocada) + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uq_password_resets_token (token_hash), + KEY idx_password_resets_merchant (merchant_id), + + CONSTRAINT fk_password_resets_merchant + FOREIGN KEY (merchant_id) REFERENCES merchants(id) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- ========================= -- schema_migrations // quais migrations de database/migrations ja rodaram neste banco -- ========================= @@ -200,4 +223,5 @@ INSERT IGNORE INTO schema_migrations (version) VALUES ('004_estorno'), ('005_regra_pontos'), ('006_points_log_extrato'), - ('007_customers_so_telefone'); + ('007_customers_so_telefone'), + ('008_password_resets'); diff --git a/docs/db/schema-explanation.md b/docs/db/schema-explanation.md index 9927d7e..1301922 100644 --- a/docs/db/schema-explanation.md +++ b/docs/db/schema-explanation.md @@ -85,3 +85,11 @@ O `schema.sql` já insere todas as migrations existentes: banco novo nasce sem p - **Lançar e resgatar** rodam em transação: o saldo do cartão e a linha do `points_log` são gravados juntos, ou nenhum dos dois é gravado. - **Resgate** trava a linha do cartão com `SELECT ... FOR UPDATE` antes de checar o saldo. Assim, dois resgates simultâneos não conseguem gastar os mesmos pontos. - **Cartão novo** é criado com `INSERT ... ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`, que devolve o id existente sem risco de duplicar. + +### `password_resets` — links de nova senha (task 21) +| Coluna | Observação | +|---|---| +| `merchant_id` | Dono do link. `ON DELETE CASCADE`. | +| `token_hash` | `UNIQUE`. SHA-256 do token; o token em si só existe no e-mail. | +| `expires_at` | 1 hora depois do pedido. | +| `used_at` | Preenchido quando o link é usado, quando um pedido novo é feito ou quando a senha é trocada por ele: depois disso não vale mais. | diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f88dfbc..91fdc17 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,11 @@ parameters: ignoreErrors: + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 2 + path: src/Controllers/MerchantController.php + - message: '#^Variable \$categories might not be defined\.$#' identifier: variable.undefined @@ -12,6 +18,24 @@ parameters: count: 1 path: views/auth/merchant/register.php + - + message: '#^Variable \$maxPassword might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: views/auth/merchant/reset.php + + - + message: '#^Variable \$minPassword might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: views/auth/merchant/reset.php + + - + message: '#^Variable \$token might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/auth/merchant/reset.php + - message: '#^Variable \$card might not be defined\.$#' identifier: variable.undefined diff --git a/public/css/auth.css b/public/css/auth.css index fb0714a..09ead9f 100644 --- a/public/css/auth.css +++ b/public/css/auth.css @@ -100,3 +100,10 @@ .auth-container.auth-container-wide { max-width: 600px; } + +/* link "Esqueci minha senha" logo abaixo do botao de entrar (task 21) */ +.auth-forgot { + margin-top: 1rem; + text-align: center; + font-size: 0.9rem; +} diff --git a/public/index.php b/public/index.php index a5fe29f..c8348e9 100644 --- a/public/index.php +++ b/public/index.php @@ -102,6 +102,8 @@ match ($action ?? 'dashboard') { 'login' => $controller->renderLogin(), 'register' => $controller->renderRegister(), + 'forgot' => $controller->renderForgot(), + 'reset' => $controller->renderReset(), 'customer-new' => $controller->renderCustomerNew(), 'logout' => $controller->logout(), 'dashboard' => $controller->renderDashboard(), diff --git a/src/Controllers/MerchantController.php b/src/Controllers/MerchantController.php index 7be1792..67b1661 100644 --- a/src/Controllers/MerchantController.php +++ b/src/Controllers/MerchantController.php @@ -4,7 +4,10 @@ use App\Models\CustomerModel; use App\Models\LoyaltyCardModel; +use App\Mail\MailerFactory; +use App\Mail\Message; use App\Models\MerchantModel; +use App\Models\PasswordResetModel; use App\Models\PointsLogModel; use App\Models\RewardModel; use App\Support\Csrf; @@ -56,6 +59,10 @@ class MerchantController { // senha atual errada na troca de senha: mesmo limite do login, contado por conta private const MAX_PASSWORD_FAILURES = 5; private const PASSWORD_WINDOW_SECONDS = 900; + // pedidos de link de senha (task 21): por ip (quem testa varios e-mails) e por e-mail (nao lotar a caixa de ninguem) + private const MAX_RESET_REQUESTS_PER_IP = 10; + private const MAX_RESET_REQUESTS_PER_EMAIL = 3; + private const RESET_WINDOW_SECONDS = 3600; private $db; private $merchantModel; @@ -97,6 +104,99 @@ public function renderLogin() { View::render('auth/merchant/login'); } + // esqueci a senha (task 21): pede o e-mail e manda o link. a resposta e sempre a mesma, exista + // o e-mail ou nao: a tela nao revela quais e-mails sao lojas. + public function renderForgot() { + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->handleForgot(); + return; + } + + View::render('auth/merchant/forgot'); + } + + private function handleForgot(): never { + Csrf::verify(); + + $email = trim((string)($_POST['email'] ?? '')); + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + redirect('merchant/forgot', ['error' => 'email_invalido']); + } + + $limiter = new RateLimiter($this->db); + $ip = RateLimiter::clientKey(); + if ($limiter->tooMany('password_reset_ip', $ip, self::MAX_RESET_REQUESTS_PER_IP, self::RESET_WINDOW_SECONDS) + || $limiter->tooMany('password_reset_email', $email, self::MAX_RESET_REQUESTS_PER_EMAIL, self::RESET_WINDOW_SECONDS)) { + redirect('merchant/forgot', ['error' => 'muitos_pedidos']); + } + $limiter->hit('password_reset_ip', $ip); + $limiter->hit('password_reset_email', $email); + + // conta desativada nao recebe link (nao conseguiria entrar mesmo) + $merchant = $this->merchantModel->findByEmail($email); + if ($merchant && $merchant['status'] === 'active') { + $token = (new PasswordResetModel($this->db))->create((int)$merchant['id']); + $link = $this->publicBaseUrl() . url('merchant/reset', ['token' => $token]); + MailerFactory::fromEnv()->send(new Message( + $email, + 'FidelityX: criar uma nova senha', + "Olá, {$merchant['owner_name']}.\n\n" + . "Recebemos um pedido para criar uma nova senha no painel da loja {$merchant['store_name']}.\n" + . "Abra o link abaixo em até 1 hora (ele só funciona uma vez):\n\n$link\n\n" + . "Se não foi você, ignore este e-mail: a senha atual continua valendo." + )); + } + + redirect('merchant/forgot', ['success' => 'reset_enviado']); + } + + // link do e-mail: escolhe a senha nova. o token vem na url (GET) e volta num campo escondido (POST). + public function renderReset() { + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->handleReset(); + return; + } + + $token = (string)($_GET['token'] ?? ''); + if (!(new PasswordResetModel($this->db))->isValid($token)) { + redirect('merchant/forgot', ['error' => 'link_invalido']); + } + + View::render('auth/merchant/reset', [ + 'token' => $token, + 'minPassword' => PasswordPolicy::MIN_BYTES, + 'maxPassword' => PasswordPolicy::MAX_BYTES, + ]); + } + + private function handleReset(): never { + Csrf::verify(); + + $token = (string)($_POST['token'] ?? ''); + $new = (string)($_POST['new_password'] ?? ''); + $confirm = (string)($_POST['new_password_confirm'] ?? ''); + $resets = new PasswordResetModel($this->db); + + if (!$resets->isValid($token)) { + redirect('merchant/forgot', ['error' => 'link_invalido']); + } + + $passwordProblem = PasswordPolicy::problem($new); + if ($passwordProblem !== null) { + redirect('merchant/reset', ['token' => $token, 'error' => $passwordProblem]); + } + if (!hash_equals($new, $confirm)) { + redirect('merchant/reset', ['token' => $token, 'error' => 'senhas_diferentes']); + } + + // a senha nova derruba as sessoes abertas com a antiga (password_sig, ver authGuard) + if ($resets->resetPassword($token, password_hash($new, PASSWORD_BCRYPT)) === null) { + redirect('merchant/forgot', ['error' => 'link_invalido']); + } + + redirect('merchant/login', ['success' => 'senha_redefinida']); + } + // RENDERS (privadas, exigem login) ----------------------------------- // home do lojista: um campo de telefone que decide o destino. diff --git a/src/Models/PasswordResetModel.php b/src/Models/PasswordResetModel.php new file mode 100644 index 0000000..b71b10f --- /dev/null +++ b/src/Models/PasswordResetModel.php @@ -0,0 +1,87 @@ +db = $db; + } + + public static function hash(string $token): string { + return hash('sha256', $token); + } + + // cria um link novo e devolve o token (o que vai no e-mail). pedidos anteriores ainda abertos + // deixam de valer: so o ultimo link enviado funciona. + public function create(int $merchantId): string { + $token = bin2hex(random_bytes(32)); + + $this->db->beginTransaction(); + try { + $this->db->prepare('UPDATE password_resets SET used_at = CURRENT_TIMESTAMP WHERE merchant_id = :id AND used_at IS NULL') + ->execute([':id' => $merchantId]); + $this->db->prepare( + 'INSERT INTO password_resets (merchant_id, token_hash, expires_at) + VALUES (:merchant_id, :token_hash, NOW() + INTERVAL ' . self::TTL_SECONDS . ' SECOND)' + )->execute([':merchant_id' => $merchantId, ':token_hash' => self::hash($token)]); + $this->db->commit(); + } catch (\Throwable $e) { + $this->db->rollBack(); + throw $e; + } + + return $token; + } + + // o link ainda vale (existe, nao foi usado, nao venceu e a conta esta ativa) + public function isValid(string $token): bool { + return $token !== '' && $this->findValid(self::hash($token), false) !== false; + } + + // troca a senha pelo link, tudo ou nada: o link (e qualquer outro aberto da conta) deixa de valer. + // FOR UPDATE: dois envios do mesmo link ao mesmo tempo nao trocam a senha duas vezes. + // devolve o id do lojista ou null se o link nao vale. + public function resetPassword(string $token, string $passwordHash): ?int { + if ($token === '') { + return null; + } + + $this->db->beginTransaction(); + try { + $merchantId = $this->findValid(self::hash($token), true); + if ($merchantId === false) { + $this->db->rollBack(); + return null; + } + + $this->db->prepare('UPDATE merchants SET password_hash = :hash WHERE id = :id') + ->execute([':hash' => $passwordHash, ':id' => $merchantId]); + $this->db->prepare('UPDATE password_resets SET used_at = CURRENT_TIMESTAMP WHERE merchant_id = :id AND used_at IS NULL') + ->execute([':id' => $merchantId]); + + $this->db->commit(); + return (int)$merchantId; + } catch (\Throwable $e) { + $this->db->rollBack(); + throw $e; + } + } + + // id do lojista dono do link valido, ou false + private function findValid(string $tokenHash, bool $lock) { + $stmt = $this->db->prepare( + "SELECT pr.merchant_id FROM password_resets pr + JOIN merchants m ON m.id = pr.merchant_id + WHERE pr.token_hash = :token_hash AND pr.used_at IS NULL AND pr.expires_at > NOW() + AND m.status = 'active'" . ($lock ? ' FOR UPDATE' : '') + ); + $stmt->execute([':token_hash' => $tokenHash]); + return $stmt->fetchColumn(); + } +} diff --git a/tests/Feature/PasswordResetTest.php b/tests/Feature/PasswordResetTest.php new file mode 100644 index 0000000..f3702a5 --- /dev/null +++ b/tests/Feature/PasswordResetTest.php @@ -0,0 +1,147 @@ +post('merchant/forgot', ['email' => $email])[1]; + } + + // token do link do ultimo e-mail enviado + private function tokenFromMail(): string { + $mails = $this->sentMails(); + $this->assertNotEmpty($mails, 'nenhum e-mail enviado'); + $this->assertMatchesRegularExpression('#http://127\.0\.0\.1:\d+/index\.php\?url=merchant%2Freset&token=([a-f0-9]{64})#', end($mails)['body']); + preg_match('#token=([a-f0-9]{64})#', end($mails)['body'], $m); + return $m[1]; + } + + private function reset(string $token, string $password, ?string $confirm = null): ?string { + return $this->post( + 'merchant/reset', + ['token' => $token, 'new_password' => $password, 'new_password_confirm' => $confirm ?? $password], + true, + 'merchant/reset&token=' . $token + )[1]; + } + + public function testLoginTemOLinkDeEsqueciASenha(): void { + $this->get('merchant/login'); + $this->assertStringContainsString('merchant%2Fforgot', $this->lastBody); + $this->assertSame(200, $this->get('merchant/forgot')[0]); + } + + public function testFluxoCompletoTrocaASenhaEOLinkSoValeUmaVez(): void { + $this->createMerchant('loja@teste.test'); + + $this->assertSame('merchant/forgot&success=reset_enviado', $this->requestLink('loja@teste.test')); + $mail = $this->sentMails()[0]; + $this->assertSame('loja@teste.test', $mail['to']); + $this->assertStringContainsString('1 hora', $mail['body']); + $token = $this->tokenFromMail(); + + // o banco guarda so o hash do token + $this->assertSame(0, (int)$this->scalar('SELECT COUNT(*) FROM password_resets WHERE token_hash = ?', [$token])); + $this->assertSame(1, (int)$this->scalar('SELECT COUNT(*) FROM password_resets WHERE token_hash = ?', [hash('sha256', $token)])); + + [$status] = $this->get('merchant/reset&token=' . $token); + $this->assertSame(200, $status); + $this->assertStringContainsString('name="token" value="' . $token . '"', $this->lastBody); + + $this->assertSame('merchant/reset&token=' . $token . '&error=senha_curta', $this->reset($token, '1234567')); + $this->assertSame('merchant/reset&token=' . $token . '&error=senhas_diferentes', $this->reset($token, 'nova-senha-1', 'outra-senha-1')); + $this->assertSame('merchant/login&success=senha_redefinida', $this->reset($token, 'nova-senha-1')); + + $this->newSession(); + [, $location] = $this->post('merchant/login', ['email' => 'loja@teste.test', 'password' => 'teste123']); + $this->assertSame('merchant/login&error=credenciais_invalidas', $location); + $this->loginAs('loja@teste.test', 'nova-senha-1'); + + // de novo: o link ja foi usado + $this->newSession(); + [, $location] = $this->get('merchant/reset&token=' . $token); + $this->assertSame('merchant/forgot&error=link_invalido', $location); + } + + public function testEmailQueNaoELojaRespondeIgualENaoMandaNada(): void { + $this->assertSame('merchant/forgot&success=reset_enviado', $this->requestLink('ninguem@teste.test')); + $this->assertSame([], $this->sentMails()); + $this->assertSame('merchant/forgot&error=email_invalido', $this->requestLink('nao-e-email')); + } + + public function testContaDesativadaNaoRecebeLink(): void { + $id = $this->createMerchant('loja@teste.test'); + $this->db->exec("UPDATE merchants SET status = 'inactive' WHERE id = $id"); + + $this->assertSame('merchant/forgot&success=reset_enviado', $this->requestLink('loja@teste.test')); + $this->assertSame([], $this->sentMails()); + } + + public function testLinkVencidoOuInventadoNaoVale(): void { + $this->createMerchant('loja@teste.test'); + $this->requestLink('loja@teste.test'); + $token = $this->tokenFromMail(); + + foreach (['', 'abc', str_repeat('a', 64)] as $fake) { + [, $location] = $this->get('merchant/reset&token=' . $fake); + $this->assertSame('merchant/forgot&error=link_invalido', $location, $fake); + } + + $this->db->exec('UPDATE password_resets SET expires_at = NOW() - INTERVAL 1 SECOND'); + [, $location] = $this->get('merchant/reset&token=' . $token); + $this->assertSame('merchant/forgot&error=link_invalido', $location); + + // POST direto com o token vencido tambem nao troca + $this->get('merchant/forgot'); + [, $location] = $this->post('merchant/reset', ['token' => $token, 'new_password' => 'nova-senha-1', 'new_password_confirm' => 'nova-senha-1'], true, 'merchant/forgot'); + $this->assertSame('merchant/forgot&error=link_invalido', $location); + $this->loginAs('loja@teste.test'); + } + + public function testSoOUltimoLinkPedidoVale(): void { + $this->createMerchant('loja@teste.test'); + $this->requestLink('loja@teste.test'); + $first = $this->tokenFromMail(); + $this->requestLink('loja@teste.test'); + $second = $this->tokenFromMail(); + + $this->assertNotSame($first, $second); + $this->assertSame('merchant/forgot&error=link_invalido', $this->get('merchant/reset&token=' . $first)[1]); + $this->assertSame(200, $this->get('merchant/reset&token=' . $second)[0]); + } + + // a senha nova derruba quem estava logado com a antiga + public function testRedefinirDerrubaAsSessoesAbertas(): void { + $this->createMerchant('loja@teste.test'); + $this->loginAs('loja@teste.test'); + $logged = $this->cookie('PHPSESSID'); + + $this->newSession(); + $this->requestLink('loja@teste.test'); + $this->reset($this->tokenFromMail(), 'nova-senha-1'); + + $this->newSession(); + $this->setCookie('PHPSESSID', (string)$logged); + [, $location] = $this->get('merchant/dashboard'); + $this->assertSame('merchant/login&error=sessao_expirada', $location); + } + + public function testPedidosDemaisParaOMesmoEmailSaoBarrados(): void { + $this->createMerchant('loja@teste.test'); + for ($i = 0; $i < 3; $i++) { + $this->assertSame('merchant/forgot&success=reset_enviado', $this->requestLink('loja@teste.test')); + } + $this->assertSame('merchant/forgot&error=muitos_pedidos', $this->requestLink('loja@teste.test')); + $this->assertCount(3, $this->sentMails()); + } + + public function testPedidoExigeCsrf(): void { + $this->createMerchant('loja@teste.test'); + [$status] = $this->post('merchant/forgot', ['email' => 'loja@teste.test'], false); + $this->assertSame(403, $status); + $this->assertSame([], $this->sentMails()); + } +} diff --git a/tests/Support/TestDatabase.php b/tests/Support/TestDatabase.php index 7f45796..c944d35 100644 --- a/tests/Support/TestDatabase.php +++ b/tests/Support/TestDatabase.php @@ -7,7 +7,7 @@ // banco de teste: recriado do schema.sql no inicio da rodada e esvaziado antes de cada teste final class TestDatabase { // ordem respeita as chaves estrangeiras (filhos antes dos pais) - private const TABLES = ['points_log', 'rewards', 'loyalty_cards', 'customers', 'merchants', 'rate_limit_hits']; + private const TABLES = ['points_log', 'rewards', 'loyalty_cards', 'customers', 'password_resets', 'merchants', 'rate_limit_hits']; public static function name(): string { return $_ENV['DB_NAME']; diff --git a/views/auth/merchant/forgot.php b/views/auth/merchant/forgot.php new file mode 100644 index 0000000..28e67b7 --- /dev/null +++ b/views/auth/merchant/forgot.php @@ -0,0 +1,43 @@ + + + + + + + Esqueci minha senha | FidelityX + + + + + + + +
+
+ +

Criar uma nova senha

+
+ + + +

Informe o e-mail de acesso da loja. Enviamos um link para criar uma nova senha, válido por 1 hora.

+ +
+ + +
+ + +
+ + +
+ + +
+ + + + diff --git a/views/auth/merchant/login.php b/views/auth/merchant/login.php index f255110..2b73ee5 100644 --- a/views/auth/merchant/login.php +++ b/views/auth/merchant/login.php @@ -36,6 +36,8 @@ +

Esqueci minha senha

+