diff --git a/CLAUDE.md b/CLAUDE.md index c6acece..1558043 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,15 +37,17 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **Post/Redirect/Get com códigos de flash** — handlers terminam com `redirect('rota', ['error' => 'codigo'])` ou `['success' => 'codigo']` (`redirect()` tem tipo `never`, faz `exit`). `views/partials/flash.php` traduz o código via um mapa fixo; **código novo precisa ser adicionado nesse mapa**, senão nada aparece. Nunca exibir o valor cru da query string. -**Views** — PHP puro em `views/`, recebem variáveis via `extract()` do `View::render($view, $data)`. Toda saída passa por `e()`. Links e actions usam `url('rota', [...])`; formulários POST incluem ``. Páginas do painel incluem `partials/merchant-header.php` / `merchant-footer.php` (definir `$title` antes). Helpers globais (`e`, `redirect`, `url`, `format_phone`, `format_datetime`) ficam em `src/Support/helpers.php`, carregado pelo autoload `files` do Composer. +**Views** — PHP puro em `views/`, recebem variáveis via `extract()` do `View::render($view, $data)`. Toda saída passa por `e()`. Links e actions usam `url('rota', [...])`; formulários POST incluem ``. Páginas do painel incluem `partials/merchant-header.php` / `merchant-footer.php` (definir `$title` antes). Helpers globais (`e`, `redirect`, `url`, `format_phone`, `format_document`, `format_datetime`) ficam em `src/Support/helpers.php`, carregado pelo autoload `files` do Composer. **Fuso horário** — `app_timezone()` (`APP_TIMEZONE` do `.env`, padrão `America/Sao_Paulo`) é aplicado no PHP pelo `index.php` e pelo `tests/bootstrap.php`, e o `Database` faz `SET time_zone` com o mesmo offset em toda conexão. Não dependa do fuso do `php.ini` nem do servidor MySQL. -**Sessão do lojista** — o `authGuard` confere a cada requisição se a conta ainda existe e está `active` (desativada encerra a sessão na hora, com `conta_inativa`), expira a sessão parada há mais de `SessionGuard::IDLE_SECONDS` (8 h) e atualiza nome da loja e `last_seen`. 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. +**Sessão do lojista** — o `authGuard` confere a cada requisição se a conta ainda existe e está `active` (desativada encerra a sessão na hora, com `conta_inativa`), expira a sessão parada há mais de `SessionGuard::IDLE_SECONDS` (8 h) e atualiza nome da loja e `last_seen`. O login guarda na sessão `password_sig` (`SessionGuard::passwordSignature`) e o `authGuard` compara com a senha do banco: senha trocada derruba as sessões abertas com a antiga. Quem altera `password_hash` precisa atualizar o `password_sig` da própria sessão (ver `handlePasswordChange`), senão se desloga. + +**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-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. **Proteções de toda requisição** — o `index.php` chama `RequestGuard` antes de rotear: cabeçalhos de segurança (`X-Frame-Options: DENY`, `frame-ancestors 'none'`, `nosniff`, `Referrer-Policy: same-origin`, sem `X-Powered-By`) 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`). 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`. +**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`. **Modelo de dados** (detalhes em `docs/db/schema-explanation.md`): - `customers` é **só o telefone**, global e único (só dígitos, 10–11, via `PhoneValidator::sanitize`); o mesmo cliente pode ter cartão em várias lojas. @@ -56,7 +58,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **Área pública** — a home (`HomeController`, view `views/home.php`, `public/css/home.css`) apresenta o produto e leva ao cadastro; lojista logado vai direto ao painel. `customer/balance&loja=CODIGO` consulta o saldo pelo telefone sem login, **só na loja do código** (`merchants.public_code`, impresso no cartaz e embutido no QR), com limite por IP (5/min → 429) e exibindo só o primeiro nome do cartão daquela loja. Sem código válido, a página pede o código (nunca lista lojas). `privacy` é a política de privacidade (rascunho pendente de revisão jurídica). -**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 (5 erros em 15 min por e-mail ou IP → 429) e consulta pública (5/min por IP). O IP 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. +**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). 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. **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). diff --git a/README.md b/README.md index 0435e26..5954313 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Recentemente refatorado para adotar o padrão **MVC com separação clara de res **Painel do lojista** - ✅ **Cadastro de Lojistas** — Registro completo com validação de documentos (CPF/CNPJ), e-mail, telefone e confirmação de senha; máscaras de telefone e documento -- ✅ **Autenticação Segura** — Login com hash BCRYPT, renovação do ID de sessão, logout, bloqueio de contas inativas e limite de 5 erros em 15 min +- ✅ **Autenticação Segura** — Login com hash BCRYPT, renovação do ID de sessão, logout, bloqueio de contas inativas e limite de tentativas que trava só quem errou a senha, nunca a dona da conta - ✅ **Atendimento pelo telefone** — A home é um único campo de telefone: cliente da loja vai direto para a tela do cliente; qualquer outro telefone vai para o cadastro rápido - ✅ **Tela do cliente** — Nome e saldo no topo, progresso até o próximo prêmio, lançar pontos (atalhos +1/+5/+10) e resgatar os prêmios que o saldo já paga - ✅ **Cadastro rápido** — Telefone já preenchido, só o nome e o consentimento do cliente (gravado com data e versão do texto) @@ -33,6 +33,7 @@ Recentemente refatorado para adotar o padrão **MVC com separação clara de res - ✅ **Clientes** — Lista paginada com busca por nome ou telefone; extrato de pontos de cada cliente - ✅ **Relatórios** — Clientes, pontos emitidos, resgates, pontos em circulação e histórico paginado - ✅ **Cartaz com QR code** — Pronto para imprimir, leva o cliente à consulta de saldo da loja +- ✅ **Perfil** — O lojista edita os dados da loja e troca a senha informando a atual; a troca encerra as sessões abertas em outros aparelhos - ✅ **Primeiros passos** — Guia para a loja nova até o primeiro prêmio e os primeiros pontos - ✅ **Funciona do celular ao PC** — Layout mobile-first (360 px a 1440 px), menu de celular e alvos de toque de 44 px @@ -55,6 +56,7 @@ Recentemente refatorado para adotar o padrão **MVC com separação clara de res | `merchant/rewards` · `merchant/reward-edit?id=` | lojista | Catálogo de prêmios e edição | | `merchant/reports?page=` | lojista | Indicadores e histórico de movimentações | | `merchant/poster` | lojista | Cartaz com QR code e código da loja | +| `merchant/profile` | lojista | Dados da loja e troca de senha | | `customer/balance?loja=` | público | Consulta de saldo pelo telefone, na loja do código | --- @@ -318,7 +320,7 @@ public function renderCustomer() { - ✅ **CSRF** — Token por sessão em todos os formulários POST - ✅ **XSS** — Toda saída nas views passa por `e()` (`htmlspecialchars`) - ✅ **Sessão** — Cookie `HttpOnly` + `SameSite=Lax` e `session_regenerate_id()` no login -- ✅ **Limite de tentativas** — Login bloqueia com 429 após 5 erros em 15 min por e-mail ou IP; consulta pública limitada a 5/min por IP. Contagem no banco (sobrevive a apagar o cookie), com e-mail e IP guardados só como hash SHA-256 +- ✅ **Limite de tentativas** — Login bloqueia com 429, por 15 min, quem errar 5 vezes a senha de uma conta (contado por e-mail + IP) ou 20 vezes somando todas as contas (por IP). O bloqueio nunca é só pelo e-mail, então errar a senha de uma loja de propósito não tranca a dona dela. Consulta pública limitada a 5/min por IP. Em IPv6 o limite vale pela rede /64. Contagem no banco (sobrevive a apagar o cookie), com e-mail e IP guardados só como hash SHA-256 - ✅ **Isolamento entre lojas** — Consultas filtram pelo `merchant_id` da sessão - ✅ **Erros** — Detalhes técnicos vão para o log (sem query string nem dados de formulário); o usuário vê só as páginas de erro, com um código para localizar o erro no log - ✅ **Atrás de proxy** — O IP do cliente só é lido do `X-Forwarded-For` quando a requisição vem de um proxy listado em `TRUSTED_PROXIES` @@ -348,7 +350,8 @@ public function renderCustomer() { ### Próximos passos - [ ] Análise estática (PHPStan) e padronização de código (PHP-CS-Fixer) -- [ ] Edição de perfil do lojista e recuperação de senha +- [x] Perfil do lojista e troca de senha +- [ ] Recuperação de senha por e-mail - [ ] Máscaras de input no front-end (TypeScript) - [ ] Planos Free/Pro com limites - [ ] Área do cliente com login diff --git a/docs/db/schema-explanation.md b/docs/db/schema-explanation.md index 050d66c..2ce4bc8 100644 --- a/docs/db/schema-explanation.md +++ b/docs/db/schema-explanation.md @@ -66,8 +66,8 @@ merchants 1───N loyalty_cards N───1 customers ### `rate_limit_hits` — tentativas (limite de abuso) | Coluna | Observação | |---|---| -| `bucket` | Tipo de limite: `login_email`, `login_ip`, `balance_ip`. | -| `key_hash` | SHA-256 da chave (e-mail ou IP em minúsculas). O dado pessoal não é gravado. | +| `bucket` | Tipo de limite: `login_pair` (erros de login do mesmo e-mail vindos do mesmo IP), `login_ip` (erros de login do mesmo IP em qualquer conta), `balance_ip` (consulta pública) e `password_change` (senha atual errada na troca de senha, por conta). Não existe bloqueio de login só por e-mail. | +| `key_hash` | SHA-256 da chave em minúsculas (e-mail + IP, IP ou id da conta, conforme o bucket; IP em IPv6 entra como a rede /64). O dado pessoal não é gravado. | | `created_at` | Cada linha é uma tentativa; só contam as que estão dentro da janela. Linhas com mais de 1 dia são apagadas aos poucos. | ### `schema_migrations` — controle das migrations diff --git a/docs/operacao.md b/docs/operacao.md index de3e656..cff0e11 100644 --- a/docs/operacao.md +++ b/docs/operacao.md @@ -45,6 +45,8 @@ Para colocar na internet, ponha um proxy com HTTPS na frente (Nginx, Caddy, Trae Atrás de proxy, a conexão que o PHP enxerga vem do proxy, não do cliente. Sem configurar, o limite de tentativas contaria todos os clientes como um só IP e o cookie de sessão não seria marcado como `Secure`. +Isso pesa no login: o bloqueio por tentativas vale para quem errou a senha (e-mail + IP), justamente para ninguém conseguir trancar a dona de uma conta errando a senha dela de propósito. Se todos os clientes aparecerem com o IP do proxy, esse cuidado se perde (o erro de um estranho volta a bloquear a dona) e 20 erros de login de qualquer pessoa bloqueiam o login de todas as lojas por 15 minutos. **Atrás de proxy, `TRUSTED_PROXIES` é obrigatório.** + `TRUSTED_PROXIES` lista quem pode informar o IP e o protocolo reais: ``` diff --git a/public/css/components.css b/public/css/components.css index 2270e8b..b33a781 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -31,6 +31,13 @@ box-shadow: 0 0 0 3px var(--focus-ring); } +/* campo so de leitura (ex.: e-mail e documento no perfil): mesma caixa, texto apagado */ +.form-group input:disabled { + color: var(--text-muted); + background: var(--bg-body); + cursor: not-allowed; +} + /* Botão */ .btn-primary { width: 100%; diff --git a/public/index.php b/public/index.php index 27ef691..8496294 100644 --- a/public/index.php +++ b/public/index.php @@ -111,6 +111,7 @@ 'rewards' => $controller->renderRewards(), 'reward-edit' => $controller->renderRewardEdit(), 'customers' => $controller->renderCustomers(), + 'profile' => $controller->renderProfile(), default => (new ErrorController())->handle(404), }; break; diff --git a/src/Controllers/CustomerController.php b/src/Controllers/CustomerController.php index adfa9f4..4a8b31c 100644 --- a/src/Controllers/CustomerController.php +++ b/src/Controllers/CustomerController.php @@ -49,7 +49,7 @@ public function renderBalance() { $data = ['store' => $store, 'phone' => '', 'card' => null, 'error' => null]; if ($isPost) { - if ($this->tooManyLookups(RateLimiter::clientIp())) { + if ($this->tooManyLookups(RateLimiter::clientKey())) { (new ErrorController())->handle(429); return; } diff --git a/src/Controllers/ErrorController.php b/src/Controllers/ErrorController.php index 0fecbb3..f00547c 100644 --- a/src/Controllers/ErrorController.php +++ b/src/Controllers/ErrorController.php @@ -6,6 +6,10 @@ class ErrorController { public function handle($code = 404, $errorId = null) { http_response_code($code); + // botao de voltar de todas as paginas de erro ($backUrl e $backLabel sao usados nas views). + // a sessao pode nem ter comecado (erro antes do session_start): ai vale como visitante. + [$backUrl, $backLabel] = self::backLink(isset($_SESSION['merchant_id'])); + $viewPath = __DIR__ . "/../../views/errors/{$code}.php"; if (file_exists($viewPath)) { @@ -16,4 +20,12 @@ public function handle($code = 404, $errorId = null) { } return; } + + // para onde o botao de voltar leva: lojista logado volta pro painel; qualquer outra pessoa + // (ex.: cliente na consulta publica de saldo) volta pra home publica, e nao pro login do lojista. + public static function backLink(bool $loggedIn): array { + return $loggedIn + ? [url('merchant/dashboard'), 'Voltar ao Painel'] + : [url('home'), 'Voltar ao início']; + } } diff --git a/src/Controllers/MerchantController.php b/src/Controllers/MerchantController.php index f92a636..7c29d4a 100644 --- a/src/Controllers/MerchantController.php +++ b/src/Controllers/MerchantController.php @@ -37,8 +37,13 @@ class MerchantController { private const MAX_POINTS_PER_ENTRY = 10000; private const PER_PAGE = 20; - private const MAX_LOGIN_FAILURES = 5; + private const MAX_LOGIN_FAILURES = 5; // mesmo e-mail + mesmo ip + private const MAX_LOGIN_FAILURES_PER_IP = 20; // mesmo ip, somando todas as contas private const LOGIN_WINDOW_SECONDS = 900; + private const MIN_PASSWORD_LENGTH = 6; + // 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; private $db; private $merchantModel; @@ -388,7 +393,7 @@ public function handleRegister(){ redirect('merchant/register', ['error' => 'documento_invalido']); } - if (strlen($password) < 6) { + if (strlen($password) < self::MIN_PASSWORD_LENGTH) { redirect('merchant/register', ['error' => 'senha_curta']); } @@ -470,11 +475,16 @@ public function handleLogin(){ redirect('merchant/login', ['error' => 'campos_obrigatorios']); } - // 5 erros em 15 min pro mesmo e-mail ou ip bloqueiam o login ate a janela passar + // limite de erros em 15 min, ate a janela passar: + // - 5 pro mesmo e-mail vindos do mesmo ip: quem erra a senha trava so a si mesmo naquela conta. + // o bloqueio nunca e so pelo e-mail, senao qualquer pessoa que soubesse o e-mail de uma loja + // trancaria a dona pra fora errando a senha de proposito. + // - 20 pro mesmo ip, em qualquer conta: segura quem testa senhas de varias lojas. $limiter = new RateLimiter($this->db); - $ip = RateLimiter::clientIp(); - if ($limiter->tooMany('login_email', $email, self::MAX_LOGIN_FAILURES, self::LOGIN_WINDOW_SECONDS) - || $limiter->tooMany('login_ip', $ip, self::MAX_LOGIN_FAILURES, self::LOGIN_WINDOW_SECONDS)) { + $ip = RateLimiter::clientKey(); + $pair = $email . '|' . $ip; + if ($limiter->tooMany('login_pair', $pair, self::MAX_LOGIN_FAILURES, self::LOGIN_WINDOW_SECONDS) + || $limiter->tooMany('login_ip', $ip, self::MAX_LOGIN_FAILURES_PER_IP, self::LOGIN_WINDOW_SECONDS)) { (new ErrorController())->handle(429); exit; } @@ -482,13 +492,13 @@ public function handleLogin(){ $merchant = $this->merchantModel->findByEmail($email); if(!$merchant || !password_verify($password, $merchant['password_hash'])){ - $limiter->hit('login_email', $email); + $limiter->hit('login_pair', $pair); $limiter->hit('login_ip', $ip); redirect('merchant/login', ['error' => 'credenciais_invalidas']); } - // login certo zera os erros do e-mail (os do ip continuam contando) - $limiter->clear('login_email', $email); + // login certo zera os erros deste e-mail neste ip (os do ip em geral continuam contando) + $limiter->clear('login_pair', $pair); if($merchant['status'] === 'inactive'){ redirect('merchant/login', ['error' => 'conta_inativa']); @@ -501,6 +511,7 @@ public function handleLogin(){ $_SESSION['merchant_name'] = $merchant['owner_name']; $_SESSION['store_name'] = $merchant['store_name']; $_SESSION['last_seen'] = time(); + $_SESSION['password_sig'] = SessionGuard::passwordSignature($merchant['password_hash']); redirect('merchant/dashboard', ['success' => 'logged']); } @@ -637,6 +648,104 @@ private function handleRewardEdit($merchantId, RewardModel $rewardModel) { redirect('merchant/rewards', ['success' => 'premio_editado']); } + // perfil do lojista: dados da loja e troca de senha (dois formularios na mesma tela) + public function renderProfile() { + $merchantId = $this->authGuard(); + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->handleProfile($merchantId); + return; + } + + View::render('merchant/profile', [ + 'profile' => $this->merchantModel->findProfile($merchantId), + 'categories' => self::CATEGORIES, + 'states' => self::STATES, + 'minPassword' => self::MIN_PASSWORD_LENGTH, + ]); + } + + private function handleProfile($merchantId) { + Csrf::verify(); + + if (($_POST['action'] ?? '') === 'password') { + $this->handlePasswordChange($merchantId); + } + + // as mesmas regras do cadastro. e-mail e cpf/cnpj nao sao editaveis aqui: mesmo que venham no post, sao ignorados. + $fields = [ + 'owner_name' => trim((string)($_POST['owner_name'] ?? '')), + 'store_name' => trim((string)($_POST['shop_name'] ?? '')), + 'phone' => PhoneValidator::sanitize($_POST['phone'] ?? ''), + 'category' => (string)($_POST['category'] ?? ''), + 'address' => trim((string)($_POST['address'] ?? '')), + 'city' => trim((string)($_POST['city'] ?? '')), + 'state' => (string)($_POST['state'] ?? ''), + ]; + + if ($fields['owner_name'] === '' || $fields['store_name'] === '' || $fields['address'] === '' || $fields['city'] === '' + || !in_array($fields['state'], self::STATES, true) + || !array_key_exists($fields['category'], self::CATEGORIES)) { + redirect('merchant/profile', ['error' => 'campos_invalidos']); + } + + // tamanho das colunas conferido aqui: o mysql sem modo estrito cortaria o texto sem avisar + if (mb_strlen($fields['owner_name']) > 255 || mb_strlen($fields['store_name']) > 255 + || mb_strlen($fields['address']) > 255 || mb_strlen($fields['city']) > 100) { + redirect('merchant/profile', ['error' => 'dados_muito_longos']); + } + + if (!PhoneValidator::isValid($fields['phone'])) { + redirect('merchant/profile', ['error' => 'telefone_invalido']); + } + + $this->merchantModel->updateProfile($merchantId, $fields); + + redirect('merchant/profile', ['success' => 'perfil_atualizado']); + } + + // troca de senha: so com a senha atual (sessao aberta num aparelho esquecido nao basta pra tomar a conta) + private function handlePasswordChange($merchantId): never { + $current = (string)($_POST['current_password'] ?? ''); + $new = (string)($_POST['new_password'] ?? ''); + $confirm = (string)($_POST['new_password_confirm'] ?? ''); + + if ($current === '' || $new === '' || $confirm === '') { + redirect('merchant/profile', ['error' => 'campos_obrigatorios']); + } + + // limite de erros da senha atual, pra ninguem ficar testando senhas por este formulario + $limiter = new RateLimiter($this->db); + $key = 'merchant:' . $merchantId; + if ($limiter->tooMany('password_change', $key, self::MAX_PASSWORD_FAILURES, self::PASSWORD_WINDOW_SECONDS)) { + redirect('merchant/profile', ['error' => 'muitas_tentativas']); + } + + $merchant = $this->merchantModel->findById($merchantId); + if (!$merchant || !password_verify($current, $merchant['password_hash'])) { + $limiter->hit('password_change', $key); + redirect('merchant/profile', ['error' => 'senha_atual_incorreta']); + } + + if (strlen($new) < self::MIN_PASSWORD_LENGTH) { + redirect('merchant/profile', ['error' => 'senha_curta']); + } + + if (!hash_equals($new, $confirm)) { + redirect('merchant/profile', ['error' => 'senhas_diferentes']); + } + + $hash = password_hash($new, PASSWORD_BCRYPT); + $this->merchantModel->updatePasswordHash($merchantId, $hash); + $limiter->clear('password_change', $key); + + // esta sessao continua valendo (com id novo); as outras, abertas com a senha antiga, caem no authGuard + session_regenerate_id(true); + $_SESSION['password_sig'] = SessionGuard::passwordSignature($hash); + + redirect('merchant/profile', ['success' => 'senha_alterada']); + } + // nome, descricao e custo do formulario de premio; null se algo estiver invalido private function readRewardFields(): ?array { $name = trim((string)($_POST['name'] ?? '')); @@ -672,6 +781,12 @@ private function authGuard(): int { redirect('merchant/login', ['error' => 'conta_inativa']); } + // a senha foi trocada depois que esta sessao abriu (em outro aparelho): precisa entrar de novo + if (SessionGuard::passwordChanged($_SESSION['password_sig'] ?? null, $merchant['password_hash'])) { + SessionGuard::destroy(); + redirect('merchant/login', ['error' => 'sessao_expirada']); + } + // nome da loja sempre atualizado no menu (pode ter mudado desde o login) $_SESSION['last_seen'] = time(); $_SESSION['merchant_name'] = $merchant['owner_name']; diff --git a/src/Models/MerchantModel.php b/src/Models/MerchantModel.php index 9855fd1..3552d44 100644 --- a/src/Models/MerchantModel.php +++ b/src/Models/MerchantModel.php @@ -39,14 +39,51 @@ public function findByEmail($email){ return $stmt->fetch(\PDO::FETCH_ASSOC); // array associativo } - // dados que o authGuard confere a cada requisicao + // dados que o authGuard confere a cada requisicao (o hash da senha serve pra derrubar + // as sessoes antigas quando a senha e trocada, ver SessionGuard::passwordSignature) public function findById($merchantId) { - $stmt = $this->db->prepare('SELECT id, owner_name, store_name, status FROM merchants WHERE id = :id'); + $stmt = $this->db->prepare('SELECT id, owner_name, store_name, status, password_hash FROM merchants WHERE id = :id'); $stmt->execute([':id' => $merchantId]); return $stmt->fetch(\PDO::FETCH_ASSOC); } + // dados exibidos na tela de perfil; false se a conta nao existir + public function findProfile($merchantId) { + $stmt = $this->db->prepare( + 'SELECT owner_name, store_name, email, phone, category, cpf, cnpj, address, city, state + FROM merchants WHERE id = :id' + ); + $stmt->execute([':id' => $merchantId]); + + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + + // dados da loja que o proprio lojista edita. e-mail (login) e cpf/cnpj (identidade) ficam de fora de proposito. + public function updateProfile($merchantId, array $fields): void { + $stmt = $this->db->prepare( + 'UPDATE merchants + SET owner_name = :owner_name, store_name = :store_name, phone = :phone, category = :category, + address = :address, city = :city, state = :state + WHERE id = :id' + ); + $stmt->execute([ + ':owner_name' => $fields['owner_name'], + ':store_name' => $fields['store_name'], + ':phone' => $fields['phone'], + ':category' => $fields['category'], + ':address' => $fields['address'], + ':city' => $fields['city'], + ':state' => $fields['state'], + ':id' => $merchantId, + ]); + } + + public function updatePasswordHash($merchantId, string $hash): void { + $stmt = $this->db->prepare('UPDATE merchants SET password_hash = :hash WHERE id = :id'); + $stmt->execute([':hash' => $hash, ':id' => $merchantId]); + } + public function findPublicCode($merchantId): ?string { $stmt = $this->db->prepare('SELECT public_code FROM merchants WHERE id = :id'); $stmt->execute([':id' => $merchantId]); diff --git a/src/Support/ClientIp.php b/src/Support/ClientIp.php index 68078bc..4875c67 100644 --- a/src/Support/ClientIp.php +++ b/src/Support/ClientIp.php @@ -53,6 +53,23 @@ public static function isHttps(array $server, string $trustedProxies): bool { return $proto === 'https'; } + // chave usada nos limites de tentativas por ip. + // ipv4: o proprio endereco. ipv6: a rede /64 do endereco, porque cada assinante recebe um bloco + // inteiro desses e, se o limite fosse por endereco, bastaria trocar de endereco a cada tentativa. + public static function rateLimitKey(string $ip): string { + $packed = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false ? false : inet_pton($ip); + if ($packed === false) { + return $ip; // ipv4 (ou "desconhecido") + } + + // ipv4 escrito como ipv6 ("::ffff:203.0.113.9"): vale o ipv4, senao todos cairiam na mesma rede + if (substr($packed, 0, 12) === str_repeat("\0", 10) . "\xff\xff") { + return inet_ntop(substr($packed, 12)); + } + + return inet_ntop(substr($packed, 0, 8) . str_repeat("\0", 8)) . '/64'; + } + // "10.0.0.1, 172.16.0.0/12" -> ['10.0.0.1', '172.16.0.0/12'] (so entradas validas) public static function parseList(string $list): array { $entries = []; diff --git a/src/Support/RateLimiter.php b/src/Support/RateLimiter.php index eb85817..9f0c405 100644 --- a/src/Support/RateLimiter.php +++ b/src/Support/RateLimiter.php @@ -4,8 +4,8 @@ // limite de tentativas guardado no banco (tabela rate_limit_hits), entao vale // mesmo se a pessoa apagar o cookie ou abrir outra sessao. -// cada "bucket" e um tipo de limite (login_email, login_ip, balance_ip...) e a chave -// e quem esta sendo limitado (e-mail, ip). a chave so e gravada como hash. +// cada "bucket" e um tipo de limite (login_pair, login_ip, balance_ip...) e a chave +// e quem esta sendo limitado (e-mail + ip, ip, conta). a chave so e gravada como hash. class RateLimiter { private $db; @@ -52,6 +52,12 @@ public static function clientIp(): string { return ClientIp::resolve($_SERVER, Env::get('TRUSTED_PROXIES')); } + // chave pros limites por ip: o proprio ip ou, em ipv6, a rede /64 dele (ver ClientIp::rateLimitKey). + // use esta nos buckets; clientIp() e pra registrar o endereco exato. + public static function clientKey(): string { + return ClientIp::rateLimitKey(self::clientIp()); + } + private function hash(string $key): string { return hash('sha256', mb_strtolower(trim($key))); } diff --git a/src/Support/SessionGuard.php b/src/Support/SessionGuard.php index b594d59..a869d11 100644 --- a/src/Support/SessionGuard.php +++ b/src/Support/SessionGuard.php @@ -13,6 +13,19 @@ public static function isExpired(?int $lastSeen, int $now, int $idle = self::IDL return $lastSeen !== null && ($now - $lastSeen) > $idle; } + // "assinatura" da senha atual, guardada na sessao no login. o authGuard compara com a do banco + // a cada requisicao: trocou a senha, as sessoes abertas com a senha antiga (outro aparelho, + // alguem que entrou sem permissao) deixam de valer. e um hash do hash: nao revela nada da senha. + public static function passwordSignature(string $passwordHash): string { + return hash('sha256', $passwordHash); + } + + // true se a sessao foi aberta com uma senha que nao e mais a da conta. + // sessao sem assinatura (aberta antes desta regra existir) nao e derrubada. + public static function passwordChanged(?string $sessionSignature, string $passwordHash): bool { + return $sessionSignature !== null && !hash_equals(self::passwordSignature($passwordHash), $sessionSignature); + } + // apaga os dados e o cookie da sessao (logout, conta desativada, sessao expirada) public static function destroy(): void { $_SESSION = []; diff --git a/src/Support/helpers.php b/src/Support/helpers.php index 183598b..bdd8433 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -27,6 +27,18 @@ function format_phone($phone): string { return $digits; } +// "52998224725" -> "529.982.247-25"; "11222333000181" -> "11.222.333/0001-81" (so pra exibicao) +function format_document($document): string { + $digits = preg_replace('/\D/', '', (string)$document); + if (strlen($digits) === 11) { + return sprintf('%s.%s.%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6, 3), substr($digits, 9)); + } + if (strlen($digits) === 14) { + return sprintf('%s.%s.%s/%s-%s', substr($digits, 0, 2), substr($digits, 2, 3), substr($digits, 5, 3), substr($digits, 8, 4), substr($digits, 12)); + } + return $digits; +} + // fuso da aplicacao: APP_TIMEZONE do .env (nome IANA) ou horario de Brasilia. // o index.php e o bootstrap dos testes aplicam no PHP, e o Database usa o mesmo na conexao, // entao PHP e MySQL nunca ficam em fusos diferentes (independe do php.ini e do servidor de banco). diff --git a/tests/Feature/ErrorPagesTest.php b/tests/Feature/ErrorPagesTest.php new file mode 100644 index 0000000..42401fa --- /dev/null +++ b/tests/Feature/ErrorPagesTest.php @@ -0,0 +1,89 @@ +get('rota/que-nao-existe'); + + $this->assertSame(404, $status); + $this->assertStringContainsString(self::HOME, $this->lastBody); + $this->assertStringContainsString('Voltar ao início', $this->lastBody); + $this->assertStringNotContainsString('merchant', $this->lastBody, 'visitante nao e mandado pro login do lojista'); + } + + public function testLojistaLogadoNoErro404VoltaParaOPainel(): void { + $this->createMerchant('erro@teste.test'); + $this->loginAs('erro@teste.test'); + + [$status] = $this->get('merchant/rota-que-nao-existe'); + + $this->assertSame(404, $status); + $this->assertStringContainsString(self::PAINEL, $this->lastBody); + $this->assertStringContainsString('Voltar ao Painel', $this->lastBody); + } + + // depois do logout a pessoa volta a ser visitante + public function testDepoisDeSairOBotaoVoltaALevarParaAHome(): void { + $this->createMerchant('erro@teste.test'); + $this->loginAs('erro@teste.test'); + $this->post('merchant/logout', [], true, 'merchant/dashboard'); + + $this->get('rota/que-nao-existe'); + + $this->assertStringContainsString(self::HOME, $this->lastBody); + } + + // o cliente da consulta publica que estoura o limite ve a 429: nao pode cair no login do lojista + public function testClienteBloqueadoNaConsultaPublicaVoltaParaAHome(): void { + $code = $this->publicCode($this->createMerchant('erro@teste.test')); + $page = 'customer/balance&loja=' . $code; + $fields = ['phone' => '11900000000', 'loja' => $code]; + for ($i = 0; $i < 5; $i++) { + $this->post('customer/balance', $fields, true, $page); + } + + [$status] = $this->post('customer/balance', $fields, true, $page); + + $this->assertSame(429, $status); + $this->assertStringContainsString(self::HOME, $this->lastBody); + $this->assertStringNotContainsString('merchant', $this->lastBody); + } + + // formulario sem o token csrf (403), com ou sem login + public function testErro403SegueAMesmaRegra(): void { + [$status] = $this->post('merchant/login', ['email' => 'x@teste.test', 'password' => 'x'], false); + $this->assertSame(403, $status); + $this->assertStringContainsString(self::HOME, $this->lastBody); + + $this->createMerchant('erro@teste.test'); + $this->loginAs('erro@teste.test'); + [$status] = $this->post('merchant/rewards', ['name' => 'Cafe'], false); + $this->assertSame(403, $status); + $this->assertStringContainsString(self::PAINEL, $this->lastBody); + } + + // nenhuma pagina de erro ficou com o link fixo antigo + public function testTodasAsPaginasDeErroUsamODestinoCalculado(): void { + $views = glob(dirname(__DIR__, 2) . '/views/errors/*.php'); + + $this->assertNotEmpty($views); + foreach ($views as $view) { + $html = (string)file_get_contents($view); + $this->assertStringContainsString('', $html, basename($view)); + $this->assertStringNotContainsString('url=merchant/dashboard', $html, basename($view)); + } + } + + public function testDestinoDoBotao(): void { + $this->assertSame(['/index.php?url=home', 'Voltar ao início'], ErrorController::backLink(false)); + $this->assertSame(['/index.php?url=merchant%2Fdashboard', 'Voltar ao Painel'], ErrorController::backLink(true)); + } +} diff --git a/tests/Feature/LoginLockoutTest.php b/tests/Feature/LoginLockoutTest.php new file mode 100644 index 0000000..72b0dbf --- /dev/null +++ b/tests/Feature/LoginLockoutTest.php @@ -0,0 +1,144 @@ +post('merchant/login', ['email' => $email, 'password' => $password]); + } + + private function failLogin(string $email, int $times): void { + for ($i = 0; $i < $times; $i++) { + [, $location] = $this->tryLogin($email, 'senha-errada'); + $this->assertSame('merchant/login&error=credenciais_invalidas', $location, 'tentativa ' . ($i + 1)); + } + } + + public function testErrarASenhaDeOutraPessoaNaoTrancaADonaDaConta(): void { + $this->createMerchant('loja@teste.test'); + + // alguem que so sabe o e-mail da loja erra a senha ate ser bloqueado + $this->fromIp(self::ATACANTE); + $this->failLogin('loja@teste.test', 5); + $this->assertSame(429, $this->tryLogin('loja@teste.test', 'senha-errada')[0], 'quem errou fica bloqueado'); + + // a dona, no aparelho dela, entra normalmente + $this->fromIp(self::DONA); + $this->loginAs('loja@teste.test'); + $this->assertSame(200, $this->get('merchant/dashboard')[0]); + } + + // o ataque pode continuar o dia inteiro: a dona nunca e afetada + public function testAtaqueRepetidoDeVariosIpsNaoBloqueiaADona(): void { + $this->createMerchant('loja@teste.test'); + + foreach (['203.0.113.1', '203.0.113.2', '203.0.113.3'] as $ip) { + $this->fromIp($ip); + $this->failLogin('loja@teste.test', 5); + } + + $this->fromIp(self::DONA); + $this->loginAs('loja@teste.test'); + } + + public function testQuemErraCincoVezesFicaBloqueadoMesmoComASenhaCerta(): void { + $this->createMerchant('loja@teste.test'); + $this->fromIp(self::ATACANTE); + $this->failLogin('loja@teste.test', 5); + + // acertar na 6a nao adianta: senao o limite nao seguraria quem esta adivinhando a senha + $this->assertSame(429, $this->tryLogin('loja@teste.test', 'teste123')[0]); + + // e o bloqueio nao depende do cookie: sessao nova, mesmo ip, continua bloqueado + $this->fromIp(self::ATACANTE); + $this->assertSame(429, $this->tryLogin('loja@teste.test', 'teste123')[0]); + } + + // o bloqueio e daquela conta naquele ip: outra loja na mesma rede (shopping, galeria) nao e afetada + public function testBloqueioDeUmaContaNaoImpedeOutraContaNoMesmoIp(): void { + $this->createMerchant('loja@teste.test'); + $this->createMerchant('vizinha@teste.test'); + $this->fromIp(self::ATACANTE); + $this->failLogin('loja@teste.test', 5); + + $this->loginAs('vizinha@teste.test'); + } + + public function testLoginCertoZeraOsErrosDaquelaContaNaqueleIp(): void { + $this->createMerchant('loja@teste.test'); + $this->fromIp(self::DONA); + + // a dona erra 4 vezes, acerta, sai e erra mais 4: nao soma 8, porque o acerto zerou a contagem + $this->failLogin('loja@teste.test', 4); + $this->loginAs('loja@teste.test'); + $this->newSession(); + $this->failLogin('loja@teste.test', 4); + + $this->loginAs('loja@teste.test'); + } + + // maiusculas no e-mail nao rendem tentativas extras (o banco acha a mesma conta nos dois casos) + public function testVariarMaiusculasDoEmailNaoBurlaOLimite(): void { + $this->createMerchant('loja@teste.test'); + $this->fromIp(self::ATACANTE); + + $this->failLogin('loja@teste.test', 3); + $this->failLogin('LOJA@Teste.Test', 2); + + $this->assertSame(429, $this->tryLogin('Loja@teste.test', 'teste123')[0]); + } + + // quem testa senhas em varias contas e barrado pelo limite do ip (20), mesmo sem chegar a 5 em nenhuma + public function testLimitePorIpSomaAsTentativasDeTodasAsContas(): void { + $this->createMerchant('loja@teste.test'); + $this->fromIp(self::ATACANTE); + + for ($i = 1; $i <= 5; $i++) { + $this->failLogin("alvo$i@teste.test", 4); + } + + $this->assertSame(429, $this->tryLogin('loja@teste.test', 'teste123')[0], '21a tentativa do mesmo ip'); + + // outro ip nao tem nada a ver com isso + $this->fromIp(self::DONA); + $this->loginAs('loja@teste.test'); + } + + // em ipv6 cada assinante tem uma rede /64 inteira: trocar de endereco dentro dela nao zera o limite + public function testTrocarDeEnderecoIpv6NaMesmaRedeNaoBurlaOLimite(): void { + $this->createMerchant('loja@teste.test'); + + for ($i = 1; $i <= 5; $i++) { + $this->fromIp('2001:db8:aaaa:bbbb::' . $i); + $this->failLogin('loja@teste.test', 1); + } + + $this->fromIp('2001:db8:aaaa:bbbb:1234:5678:9abc:def0'); + $this->assertSame(429, $this->tryLogin('loja@teste.test', 'teste123')[0]); + + // outra rede ipv6 (outro assinante) entra normalmente + $this->fromIp('2001:db8:aaaa:cccc::1'); + $this->loginAs('loja@teste.test'); + } + + // cada erro conta em dois lugares (e-mail + ip, e ip), e a chave continua gravada so como hash + public function testTentativasFicamGravadasSoComoHash(): void { + $this->createMerchant('loja@teste.test'); + $this->fromIp(self::ATACANTE); + $this->failLogin('loja@teste.test', 1); + + $rows = $this->db->query('SELECT bucket, key_hash FROM rate_limit_hits ORDER BY bucket')->fetchAll(\PDO::FETCH_KEY_PAIR); + + $this->assertSame(['login_ip', 'login_pair'], array_keys($rows), 'nao existe mais o bloqueio so por e-mail'); + $this->assertSame(hash('sha256', self::ATACANTE), $rows['login_ip']); + $this->assertSame(hash('sha256', 'loja@teste.test|' . self::ATACANTE), $rows['login_pair']); + } +} diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php new file mode 100644 index 0000000..1deae29 --- /dev/null +++ b/tests/Feature/ProfileTest.php @@ -0,0 +1,198 @@ + 'Ana Souza', + 'shop_name' => 'Padaria da Ana', + 'phone' => '(11) 97777-6666', + 'category' => 'alimentacao', + 'address' => 'Rua Nova, 10', + 'city' => 'Cotia', + 'state' => 'SP', + ]; + + private function merchantField(int $id, string $field) { + return $this->scalar("SELECT $field FROM merchants WHERE id = :id", [':id' => $id]); + } + + private function changePassword(string $current, string $new, ?string $confirm = null): ?string { + return $this->post('merchant/profile', [ + 'action' => 'password', + 'current_password' => $current, + 'new_password' => $new, + 'new_password_confirm' => $confirm ?? $new, + ])[1]; + } + + public function testPerfilExigeLogin(): void { + [$status, $location] = $this->get('merchant/profile'); + + $this->assertSame(302, $status); + $this->assertSame('merchant/login&error=sessao_expirada', $location); + } + + public function testTelaMostraOsDadosAtuaisEOMenuTemOPerfil(): void { + $this->createMerchant('loja@teste.test'); + $this->loginAs('loja@teste.test'); + + [$status] = $this->get('merchant/profile'); + + $this->assertSame(200, $status); + $this->assertStringContainsString('value="Loja Teste"', $this->lastBody); + $this->assertStringContainsString('value="Dona Teste"', $this->lastBody); + $this->assertStringContainsString('value="(11) 98888-7777"', $this->lastBody); + $this->assertStringContainsString('value="loja@teste.test" disabled', $this->lastBody); + $this->assertStringContainsString('