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..1599541 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ node tests/js/customer.test.js # conta do valor da compra do public/js/c tsc # compila src/ts -> public/js (tsconfig.json) php bin/migrate.php # roda as migrations pendentes (status | baseline NNN) php bin/backup.php # backup do banco (mysqldump .sql.gz em BACKUP_DIR) +php bin/create-admin.php email "Nome" # cria um administrador (senha pela entrada padrão) docker compose up -d --build # aplicacao + mysql em docker (docs/operacao.md) ``` @@ -36,7 +37,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já ## Arquitetura -**Roteamento** — tudo entra por `public/index.php` com `?url=dominio/acao`. Um `switch` no domínio (`merchant`, `customer`, mais `home` e `privacy` sem ação) instancia o controller e um `match` na ação chama o método `render*()`. Rota nova = novo braço no `match` + método no controller. A exceção é `?url=health` (`App\Support\Health`, task 56): responde antes da sessão, só `ok`/`erro` em JSON, conferindo o banco. O `index.php` abre a conexão com o banco antes de rotear, então sem MySQL (ou sem as variáveis obrigatórias do banco) toda rota devolve 503. +**Roteamento** — tudo entra por `public/index.php` com `?url=dominio/acao`. Um `switch` no domínio (`merchant`, `customer`, `admin`, mais `home` e `privacy` sem ação) instancia o controller e um `match` na ação chama o método `render*()`. Rota nova = novo braço no `match` + método no controller. A exceção é `?url=health` (`App\Support\Health`, task 56): responde antes da sessão, só `ok`/`erro` em JSON, conferindo o banco. O `index.php` abre a conexão com o banco antes de rotear, então sem MySQL (ou sem as variáveis obrigatórias do banco) toda rota devolve 503. **Configuração** — `App\Support\Env::load()` (chamado pelo `index.php`, pelo bootstrap dos testes e pelos scripts de `bin/`) põe tudo no `$_ENV`: variável de ambiente real primeiro, e o `.env`, se existir, só completa o que falta (`safeLoad`: a aplicação sobe sem o arquivo). Variável nova entra em `Env::KEYS` e no `.env.example`; leia com `Env::get()` ou `$_ENV`, nunca com `getenv()` direto. @@ -48,12 +49,16 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **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`. 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. +**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. Conta com `merchants.email_verified_at` vazio (cadastro novo, task 50) entra, mas o `authGuard` manda toda rota para `merchant/confirm-email` (reenvia o link, 3/h); só essa tela chama `authGuard(true)`. O link `merchant/verify-email&token=...` (`EmailVerificationModel`, 24 h, uma vez) não exige login. Os links por e-mail (confirmação e nova senha) herdam de `MerchantTokenModel`. **Balcão** — a home do painel (`merchant/dashboard`) busca o cliente pelo telefone e mostra os 5 últimos atendidos (só cartão com movimentação). Na tela do cliente (`merchant/customer`) o lançamento é em pontos ou, se a loja tem regra (`merchants.points_rule_cents`, configurada em `merchant/points-rule`), pelo valor da compra: `Money::toCents` lê o valor digitado e `Money::pointsFor` converte arredondando para baixo. Depois de lançar, a tela mostra o novo saldo com **Desfazer** por `UNDO_SECONDS` (30 s; o aviso só aparece se o lançamento tiver até `UNDO_BANNER_SECONDS`). O estorno (`action=reverse`, do Desfazer ou do extrato `merchant/statement`) vale por `LoyaltyCardModel::REVERSAL_WINDOW_HOURS` (24 h) e uma vez por movimentação: lançamento só se o saldo atual cobrir os pontos; resgate (task 44, só pelo extrato) devolve os pontos ao saldo sem mexer no total acumulado e deixa de contar nos relatórios. A tela do cliente também corrige o nome e move o cartão para um telefone novo (`LoyaltyCardModel::rename` / `changePhone`, task 43): só o cartão desta loja muda, telefone que já tem cartão nesta loja é recusado e o número antigo some quando não sobra cartão dele. Listas longas (clientes, extrato, relatórios) paginam com `App\Support\Paginator` + `views/partials/pagination.php`, 20 por página. Os relatórios filtram por período (`App\Support\Period`: hoje, 7 dias, mês, intervalo; o saldo em circulação é sempre o atual) e `merchant/export` baixa CSV do histórico do período ou da lista de clientes (`App\Support\Csv`: `;` + BOM, texto que começa com `=`/`+`/`-`/`@` ganha `'` contra injeção de fórmula), sempre só desta loja (task 59). **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. +**Administração** (task 30) — `admin/login` e `admin/dashboard` (`AdminController`): tabela `admins` própria, conta criada só pela linha de comando (`php bin/create-admin.php email "Nome"`, senha pela entrada padrão). Sessão separada da do lojista (`admin_id`, `admin_sig`, expira com 1 h parada) e o mesmo limite de tentativas do login do lojista (buckets `admin_login_*`). O admin vê as lojas (dados da loja e quantos clientes têm, nunca os clientes) e ativa/desativa uma loja (`MerchantModel::setStatus`); desativada perde o acesso na hora. + +**Esqueci a senha** (task 21) — `merchant/forgot` manda por e-mail um link `merchant/reset&token=...` (`PasswordResetModel`, sobre o `MerchantTokenModel`: 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`. @@ -69,6 +74,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/bin/create-admin.php b/bin/create-admin.php new file mode 100644 index 0000000..32e355d --- /dev/null +++ b/bin/create-admin.php @@ -0,0 +1,51 @@ +create($email, $name, $password); + fwrite(STDOUT, "\nadmin criado (id $id). entre em /index.php?url=admin/login\n"); +} catch (PDOException $e) { + fwrite(STDERR, (($e->errorInfo[1] ?? null) === 1062 ? 'erro: ja existe admin com esse e-mail' : 'erro no banco: ' . $e->getMessage()) . "\n"); + exit(1); +} catch (Throwable $e) { + fwrite(STDERR, 'erro: ' . $e->getMessage() . "\n"); + exit(1); +} 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/migrations/009_email_verifications.sql b/database/migrations/009_email_verifications.sql new file mode 100644 index 0000000..50ccc90 --- /dev/null +++ b/database/migrations/009_email_verifications.sql @@ -0,0 +1,32 @@ +-- fidelityx - migration 009 (confirmacao do e-mail do lojista, task 50) +-- 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/009_email_verifications.sql) + +USE fidelityx; + +-- conta nova so usa o painel depois de confirmar o e-mail (NULL = ainda nao confirmou) +ALTER TABLE merchants ADD COLUMN email_verified_at TIMESTAMP NULL AFTER email; + +-- contas que ja existiam contam como confirmadas: nao da pra travar quem ja usa o sistema +UPDATE merchants SET email_verified_at = created_at WHERE email_verified_at IS NULL; + +-- link de confirmacao: como o de nova senha, so o hash do token fica aqui. vale 24 horas e uma vez. +CREATE TABLE IF NOT EXISTS email_verifications ( + 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 (reenvio) + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uq_email_verifications_token (token_hash), + KEY idx_email_verifications_merchant (merchant_id), + + CONSTRAINT fk_email_verifications_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/migrations/010_admins.sql b/database/migrations/010_admins.sql new file mode 100644 index 0000000..eaeb566 --- /dev/null +++ b/database/migrations/010_admins.sql @@ -0,0 +1,21 @@ +-- fidelityx - migration 010 (painel administrativo, task 30) +-- 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/010_admins.sql) + +USE fidelityx; + +-- administradores do FidelityX (nao sao lojistas): login proprio em admin/login, conta criada +-- pela linha de comando (php bin/create-admin.php). veem as lojas, nunca os clientes delas. +CREATE TABLE IF NOT EXISTS admins ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + email VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uq_admins_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/schema.sql b/database/schema.sql index 70c3e0f..20309b9 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS merchants ( owner_name VARCHAR(255) NOT NULL, store_name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, + email_verified_at TIMESTAMP NULL, -- conta nova so usa o painel depois de confirmar o e-mail (task 50) phone VARCHAR(30) NOT NULL, category VARCHAR(30) NOT NULL, password_hash VARCHAR(255) NOT NULL, @@ -182,6 +183,69 @@ 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; + +-- ========================= +-- email_verifications // links de confirmacao do e-mail do lojista (task 50) +-- ========================= +-- como o de nova senha: so o hash do token fica aqui. vale 24 horas e uma vez. +CREATE TABLE IF NOT EXISTS email_verifications ( + 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 (reenvio) + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uq_email_verifications_token (token_hash), + KEY idx_email_verifications_merchant (merchant_id), + + CONSTRAINT fk_email_verifications_merchant + FOREIGN KEY (merchant_id) REFERENCES merchants(id) + ON DELETE CASCADE + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ========================= +-- admins // administradores do FidelityX (task 30) +-- ========================= +-- login proprio em admin/login, conta criada por php bin/create-admin.php. veem as lojas, nunca os clientes. +CREATE TABLE IF NOT EXISTS admins ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + email VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uq_admins_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- ========================= -- schema_migrations // quais migrations de database/migrations ja rodaram neste banco -- ========================= @@ -200,4 +264,7 @@ 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'), + ('009_email_verifications'), + ('010_admins'); diff --git a/docs/adr/003-atendentes-com-login-proprio.md b/docs/adr/003-atendentes-com-login-proprio.md new file mode 100644 index 0000000..e157149 --- /dev/null +++ b/docs/adr/003-atendentes-com-login-proprio.md @@ -0,0 +1,81 @@ +# ADR 003 — Atendentes com login próprio + +Status: **proposta** (2026-10-06, rascunho para o time decidir) · Task 58 do backlog · Sem código ainda + +## Problema encontrado + +A loja tem uma senha só. Quem está no balcão usa a conta do dono, então: + +1. **Não fica registrado quem fez o quê.** `points_log.responsible_user` existe desde o MVP, mas nunca foi preenchido. Um lançamento errado, um resgate indevido ou um estorno não têm autor. +2. **Todo mundo pode tudo.** Quem lança pontos também apaga prêmio, muda a regra de pontos, troca a senha da conta e exclui dados de cliente. +3. **Tirar o acesso de alguém custa caro.** Quando um funcionário sai, o dono precisa trocar a senha e repassar a nova para todos os outros. + +## Decisão proposta + +### 1. Quem entra vira "usuário"; a loja continua sendo a loja + +Tabela nova `users`, com e-mail e senha saindo de `merchants`: + +| Coluna | Observação | +|---|---| +| `id` | | +| `merchant_id` | Loja a que o usuário pertence (FK). Um usuário, uma loja. Ver "Filiais" abaixo. | +| `role` | `owner` (dono) ou `attendant` (atendente). | +| `name`, `email` | E-mail `UNIQUE` na plataforma inteira: o login continua sendo só e-mail + senha. | +| `password_hash`, `email_verified_at`, `status` | Os mesmos de hoje em `merchants`, agora por pessoa. `status` = `active`/`inactive`. | + +A migration cria um `owner` para cada loja existente, copiando `owner_name`, `email`, `password_hash` e `email_verified_at`, e só depois tira essas colunas de `merchants`. `merchants.status` continua valendo para a loja inteira: o admin (task 30) desativa a loja, e o dono desativa um atendente. + +**Por que uma tabela única e não uma tabela `attendants` ao lado de `merchants`:** com duas tabelas, cada login, sessão, limite de tentativas, recuperação de senha (task 21), confirmação de e-mail (task 50) e o autor de cada movimentação precisariam saber "de qual tabela" é a pessoa. Com `users`, tudo isso passa a apontar para um `user_id`, e o caso do dono deixa de ser especial. + +### 2. Sessão e permissões + +- A sessão guarda `user_id`, `merchant_id` e `role`. O `authGuard` passa a conferir, a cada requisição, se o usuário **e** a loja estão ativos (desativar um atendente derruba a sessão dele na hora, como hoje acontece com a loja). +- `password_sig` passa a ser do usuário: trocar a senha de um atendente não derruba o dono. +- Um método `requireOwner()` no controller, chamado no começo das rotas só do dono. Rota nova nasce restrita ao dono; liberar para atendente é decisão explícita. + +| Ação | Dono | Atendente | +|---|---|---| +| Buscar cliente, cadastro rápido, lançar, resgatar, estornar (24 h) | sim | sim | +| Extrato do cliente, registrar consentimento | sim | sim | +| Corrigir nome do cliente (task 43) | sim | sim *(a confirmar)* | +| Trocar telefone do cliente (task 43) | sim | não *(a confirmar)* | +| Excluir dados do cliente (LGPD) | sim | não | +| Prêmios, regra de pontos, perfil da loja, cartaz | sim | não | +| Relatórios e exportação CSV (task 59) | sim | não *(a confirmar)* | +| Cadastrar, desativar e reativar atendentes | sim | não | + +### 3. Quem fez cada movimentação + +`points_log.responsible_user VARCHAR(255)` vira `user_id BIGINT UNSIGNED NULL` com FK para `users` (`ON DELETE SET NULL`). `addPoints`, `redeem` e `reverse` recebem o `user_id` da sessão e gravam na mesma transação. Movimentações antigas ficam com `NULL` e aparecem como "—". O extrato, o histórico dos relatórios e o CSV ganham a coluna "Feito por". + +O nome mostrado é o do usuário **hoje** (não uma cópia na hora do lançamento): se o atendente for removido (ver LGPD), o histórico passa a mostrar "Atendente removido". + +### 4. Cadastro de atendente + +O dono informa nome e e-mail numa tela nova (`merchant/team`). O sistema cria o usuário sem senha e manda por e-mail um link para ele criar a própria senha, reaproveitando o `MerchantTokenModel` (tasks 21 e 50): o dono nunca sabe a senha do atendente, e o e-mail já sai confirmado pelo uso do link. **Depende do provedor de e-mail (task 55)**, que ainda não foi escolhido. + +### 5. LGPD + +Os atendentes são titulares de dados (nome e e-mail) e a loja é a controladora. Desativar mantém o registro (o histórico precisa do autor). Para atender um pedido de exclusão, o dono "remove" o atendente: o usuário fica `inactive`, com nome trocado por "Atendente removido" e e-mail por um valor inválido único, e o `user_id` continua nas movimentações. Nenhuma tela de uma loja mostra atendente de outra. + +## Consequências + +- **Migration grande e com cópia de dados** (`merchants` → `users`). Precisa de teste de migration sobre um banco no formato antigo, como a 007. +- **Login, recuperação de senha, confirmação de e-mail e limite de tentativas** passam a falar de usuário, não de loja. Os buckets do `RateLimiter` continuam por e-mail, então nada muda para quem usa. +- **Toda rota do painel ganha uma decisão de permissão.** A tabela acima vira teste de fluxo: atendente recebe 403 em cada rota só do dono. +- **Abre caminho para filiais (task 60):** "um usuário, várias lojas" vira uma tabela de vínculo `user_stores` no lugar de `users.merchant_id`, sem mexer em login nem em `points_log`. + +## Alternativas descartadas + +- **Tabela `attendants` separada, dono continua em `merchants`:** menos migration agora, mas dois caminhos em tudo que envolve login e autoria (ver item 1). +- **PIN de 4 dígitos por atendente, sem login:** registra o autor e é rápido no balcão, mas a sessão continua sendo a do dono (o atendente vê e faz tudo) e PIN curto é fácil de adivinhar. +- **Só um campo "atendente" de texto livre no lançamento:** não controla acesso e qualquer um digita qualquer nome. + +## Perguntas em aberto (para o time) + +1. Atendente pode corrigir nome, trocar telefone e ver relatórios? (marcados "a confirmar" na tabela) +2. Limite de atendentes por loja? Liga com os planos Free/Pro (task 32). +3. Dono pode redefinir a senha de um atendente, ou só reenviar o link para ele criar outra? +4. A sessão do atendente expira em menos tempo que a do dono (hoje 8 h)? +5. Sem provedor de e-mail (task 55), aceitamos um caminho provisório (dono define a senha inicial e o atendente é obrigado a trocar no primeiro acesso)? diff --git a/docs/db/schema-explanation.md b/docs/db/schema-explanation.md index 9927d7e..3b4a7d6 100644 --- a/docs/db/schema-explanation.md +++ b/docs/db/schema-explanation.md @@ -29,6 +29,7 @@ merchants 1───N loyalty_cards N───1 customers | `password_hash` | BCRYPT (`password_hash`). | | `plan` | `free`/`pro`. Ainda não é usado pelo código (pós-MVP). | | `status` | Padrão `active`. Contas `inactive` são barradas no login. | +| `email_verified_at` | Quando o e-mail foi confirmado pelo link (task 50). `NULL` = conta nova ainda sem confirmar: entra, mas só vê a tela de confirmação. A migration 009 marcou as contas antigas como confirmadas. | | `category`, `state` | Validados contra as listas de `MerchantController::CATEGORIES` e `STATES`. | ### `customers` — clientes @@ -85,3 +86,22 @@ 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. | + +### `email_verifications` — links de confirmação do e-mail (task 50) +Mesma forma da `password_resets` (`merchant_id`, `token_hash` com `UNIQUE`, `expires_at`, `used_at`), com validade de 24 horas. Reenviar o link cancela o anterior. + +### `admins` — administradores do FidelityX (task 30) +| Coluna | Observação | +|---|---| +| `email` | `UNIQUE`. Login do painel `admin/login`. | +| `password_hash` | bcrypt. A conta é criada por `php bin/create-admin.php`. | + +Separada de `merchants` de propósito: um admin não é uma loja e um lojista nunca vira admin por um campo trocado. diff --git a/docs/operacao.md b/docs/operacao.md index 236ad14..19a656c 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 @@ -61,6 +62,17 @@ TRUSTED_PROXIES=127.0.0.1,172.16.0.0/12 Com o Compose, o proxy no host chega ao container pelo gateway da rede do Docker, normalmente um endereço em `172.16.0.0/12`. +## Administrador + +O painel de administração (`/index.php?url=admin/login`) tem contas próprias, que não são lojistas. Para criar uma (no container, `docker compose exec app php bin/create-admin.php ...`): + +```bash +php bin/create-admin.php admin@seudominio.com.br "Nome da pessoa" +# a senha (8 a 72 bytes) é pedida em seguida; não passe a senha como argumento +``` + +O admin vê todas as lojas e ativa ou desativa um lojista (a desativação derruba a sessão dele na hora). Não há tela para criar ou remover admins: remover é `DELETE FROM admins WHERE email = ...` no banco. + ## Rota de saúde `GET /index.php?url=health` responde `200 {"status":"ok"}` quando a aplicação consegue falar com o banco e `503 {"status":"erro"}` quando não (o motivo vai para o log, nunca para a resposta). Ela roda antes da sessão, então checar a cada 30 s não cria arquivo no volume de sessões, e sai com `Cache-Control: no-store`. É o endereço para o monitor externo e para o `HEALTHCHECK` do container (task 56; a troca do healthcheck no `Dockerfile` e o monitor com alerta ainda estão pendentes). diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f88dfbc..e36a43b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,17 @@ parameters: ignoreErrors: + - + message: '#^Variable \$merchants might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/admin/dashboard.php + + - + message: '#^Variable \$paginator might not be defined\.$#' + identifier: variable.undefined + count: 3 + path: views/admin/dashboard.php + - message: '#^Variable \$categories might not be defined\.$#' identifier: variable.undefined @@ -12,6 +24,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 @@ -156,6 +186,12 @@ parameters: count: 1 path: views/errors/default.php + - + message: '#^Variable \$email might not be defined\.$#' + identifier: variable.undefined + count: 1 + path: views/merchant/confirm-email.php + - message: '#^Variable \$phone 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..1da89fd 100644 --- a/public/index.php +++ b/public/index.php @@ -95,6 +95,19 @@ }; break; + case 'admin': + // painel administrativo (task 30): login e sessao proprios, separados dos do lojista + RequestGuard::sendNoStore(); + $controller = new \App\Controllers\AdminController($db); + + match ($action ?? 'dashboard') { + 'login' => $controller->renderLogin(), + 'logout' => $controller->logout(), + 'dashboard' => $controller->renderDashboard(), + default => (new ErrorController())->handle(404), + }; + break; + case 'merchant': RequestGuard::sendNoStore(); $controller = new \App\Controllers\MerchantController($db); @@ -102,6 +115,10 @@ match ($action ?? 'dashboard') { 'login' => $controller->renderLogin(), 'register' => $controller->renderRegister(), + 'forgot' => $controller->renderForgot(), + 'reset' => $controller->renderReset(), + 'confirm-email' => $controller->renderConfirmEmail(), + 'verify-email' => $controller->renderVerifyEmail(), 'customer-new' => $controller->renderCustomerNew(), 'logout' => $controller->logout(), 'dashboard' => $controller->renderDashboard(), diff --git a/src/Controllers/AdminController.php b/src/Controllers/AdminController.php new file mode 100644 index 0000000..8afdd22 --- /dev/null +++ b/src/Controllers/AdminController.php @@ -0,0 +1,132 @@ +db = $db; + $this->admins = new AdminModel($db); + } + + public function renderLogin() { + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->handleLogin(); + return; + } + if (isset($_SESSION['admin_id'])) { + redirect('admin/dashboard'); + } + View::render('admin/login'); + } + + // mesmas protecoes do login do lojista: limite por e-mail + ip e por ip, mesmo tempo pra e-mail inexistente + private function handleLogin() { + Csrf::verify(); + + $email = trim((string)($_POST['email'] ?? '')); + $password = (string)($_POST['password'] ?? ''); + if ($email === '' || $password === '') { + redirect('admin/login', ['error' => 'campos_obrigatorios']); + } + + $limiter = new RateLimiter($this->db); + $ip = RateLimiter::clientKey(); + $pair = $email . '|' . $ip; + if ($limiter->tooMany('admin_login_pair', $pair, self::MAX_LOGIN_FAILURES, self::LOGIN_WINDOW_SECONDS) + || $limiter->tooMany('admin_login_ip', $ip, self::MAX_LOGIN_FAILURES_PER_IP, self::LOGIN_WINDOW_SECONDS)) { + (new ErrorController())->handle(429); + exit; + } + + $admin = $this->admins->findByEmail($email) ?: null; + if (!LoginGuard::verify($admin, $password)) { + $limiter->hit('admin_login_pair', $pair); + $limiter->hit('admin_login_ip', $ip); + redirect('admin/login', ['error' => 'credenciais_invalidas']); + } + $limiter->clear('admin_login_pair', $pair); + + session_regenerate_id(true); + Csrf::regenerate(); + $_SESSION['admin_id'] = (int)$admin['id']; + $_SESSION['admin_name'] = $admin['name']; + $_SESSION['admin_last_seen'] = time(); + $_SESSION['admin_sig'] = SessionGuard::passwordSignature($admin['password_hash']); + + redirect('admin/dashboard'); + } + + public function logout() { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + redirect(isset($_SESSION['admin_id']) ? 'admin/dashboard' : 'admin/login'); + } + Csrf::verify(); + SessionGuard::destroy(); + redirect('admin/login', ['success' => 'logout']); + } + + // lista das lojas, paginada; POST ativa ou desativa uma + public function renderDashboard() { + $this->adminGuard(); + $merchants = new MerchantModel($this->db); + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + Csrf::verify(); + $merchantId = filter_var($_POST['merchant_id'] ?? '', FILTER_VALIDATE_INT); + $status = (string)($_POST['status'] ?? ''); + $page = filter_var($_POST['page'] ?? '', FILTER_VALIDATE_INT) ?: 1; + + if (!$merchantId || !$merchants->setStatus($merchantId, $status)) { + redirect('admin/dashboard', ['page' => $page, 'error' => 'loja_invalida']); + } + redirect('admin/dashboard', ['page' => $page, 'success' => $status === 'active' ? 'loja_ativada' : 'loja_desativada']); + } + + $paginator = new Paginator($merchants->countAll(), $_GET['page'] ?? 1, self::PER_PAGE); + View::render('admin/dashboard', [ + 'merchants' => $merchants->pageForAdmin($paginator->perPage, $paginator->offset()), + 'paginator' => $paginator, + ]); + } + + // barra quem nao e admin logado; a cada requisicao confere se o admin existe, se a sessao nao + // ficou parada demais e se a senha nao mudou desde o login + private function adminGuard(): int { + if (!isset($_SESSION['admin_id'])) { + redirect('admin/login', ['error' => 'sessao_expirada']); + } + + $admin = $this->admins->findById((int)$_SESSION['admin_id']); + if (!$admin + || SessionGuard::isExpired($_SESSION['admin_last_seen'] ?? null, time(), self::IDLE_SECONDS) + || SessionGuard::passwordChanged($_SESSION['admin_sig'] ?? null, $admin['password_hash'])) { + SessionGuard::destroy(); + redirect('admin/login', ['error' => 'sessao_expirada']); + } + + $_SESSION['admin_last_seen'] = time(); + return (int)$admin['id']; + } +} diff --git a/src/Controllers/MerchantController.php b/src/Controllers/MerchantController.php index 7be1792..8ceb1a7 100644 --- a/src/Controllers/MerchantController.php +++ b/src/Controllers/MerchantController.php @@ -3,8 +3,12 @@ namespace App\Controllers; use App\Models\CustomerModel; +use App\Models\EmailVerificationModel; 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 +60,12 @@ 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; + // reenvio do link de confirmacao do e-mail (task 50), por conta + private const MAX_VERIFY_RESENDS = 3; private $db; private $merchantModel; @@ -97,6 +107,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() { + 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() { + 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. @@ -550,9 +653,7 @@ public function handleRegister(){ ]; $this->merchantModel->create($data); - - // se deu certo, redireciona usuario para o login - redirect('merchant/login', ['success' => 'cadastrado']); + $merchantId = (int)$this->db->lastInsertId(); } catch (\PDOException $e) { $sqlState = $e->errorInfo[0] ?? null; $driverCode = (int)($e->errorInfo[1] ?? 0); @@ -588,6 +689,59 @@ public function handleRegister(){ redirect('merchant/register', ['error' => 'erro_servidor']); } + // conta nova so usa o painel depois de confirmar o e-mail (task 50) + $this->sendEmailVerification($merchantId, $email, $owner_name); + + // se deu certo, redireciona usuario para o login + redirect('merchant/login', ['success' => 'cadastrado']); + } + + // manda (ou reenvia) o link de confirmacao do e-mail; o anterior deixa de valer + private function sendEmailVerification(int $merchantId, string $email, string $ownerName): void { + $token = (new EmailVerificationModel($this->db))->create($merchantId); + $link = $this->publicBaseUrl() . url('merchant/verify-email', ['token' => $token]); + MailerFactory::fromEnv()->send(new Message( + $email, + 'FidelityX: confirme seu e-mail', + "Olá, $ownerName.\n\n" + . "Para começar a usar o painel do FidelityX, confirme que este e-mail é seu abrindo o link abaixo\n" + . "(ele vale por 24 horas):\n\n$link\n\n" + . "Se você não criou uma conta no FidelityX, ignore este e-mail." + )); + } + + // tela de quem entrou mas ainda nao confirmou o e-mail: explica e reenvia o link (task 50) + public function renderConfirmEmail() { + $merchantId = $this->authGuard(true); + if ($this->merchant['email_verified_at'] !== null) { + redirect('merchant/dashboard'); + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + Csrf::verify(); + $limiter = new RateLimiter($this->db); + $key = 'merchant:' . $merchantId; + if ($limiter->tooMany('email_verify_resend', $key, self::MAX_VERIFY_RESENDS, self::RESET_WINDOW_SECONDS)) { + redirect('merchant/confirm-email', ['error' => 'muitos_pedidos']); + } + $limiter->hit('email_verify_resend', $key); + $this->sendEmailVerification($merchantId, $this->merchant['email'], $this->merchant['owner_name']); + redirect('merchant/confirm-email', ['success' => 'confirmacao_reenviada']); + } + + View::render('merchant/confirm-email', ['email' => $this->merchant['email']]); + } + + // link do e-mail de confirmacao (nao exige login: pode ser aberto no celular, fora da sessao do painel) + public function renderVerifyEmail() { + $verified = (new EmailVerificationModel($this->db))->verify((string)($_GET['token'] ?? '')); + $logged = isset($_SESSION['merchant_id']); + + if ($verified === null) { + redirect($logged ? 'merchant/confirm-email' : 'merchant/login', ['error' => 'confirmacao_invalida']); + } + + redirect($logged ? 'merchant/dashboard' : 'merchant/login', ['success' => 'email_confirmado']); } public function handleLogin(){ @@ -973,7 +1127,8 @@ private function readRewardFields(): ?array { // o id SEMPRE vem da sessao, nunca do formulario. // a cada requisicao: sessao parada demais expira, e a conta e conferida no banco // (lojista desativado perde o acesso na hora, nao so no proximo login). - private function authGuard(): int { + // $allowUnverified: so a tela de confirmar o e-mail aceita conta que ainda nao confirmou (task 50) + private function authGuard(bool $allowUnverified = false): int { if(!isset($_SESSION['merchant_id'])){ redirect('merchant/login', ['error' => 'sessao_expirada']); } @@ -998,6 +1153,11 @@ private function authGuard(): int { // a loja da requisicao fica a mao (ex.: regra de pontos), sem consultar o banco de novo $this->merchant = $merchant; + // conta nova que ainda nao confirmou o e-mail so ve a tela de confirmar + if (!$allowUnverified && $merchant['email_verified_at'] === null) { + redirect('merchant/confirm-email'); + } + // 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/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/Models/AdminModel.php b/src/Models/AdminModel.php new file mode 100644 index 0000000..cd48203 --- /dev/null +++ b/src/Models/AdminModel.php @@ -0,0 +1,52 @@ +db = $db; + } + + public function findByEmail(string $email) { + $stmt = $this->db->prepare('SELECT id, name, email, password_hash FROM admins WHERE email = :email'); + $stmt->execute([':email' => $email]); + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + + public function findById(int $id) { + $stmt = $this->db->prepare('SELECT id, name, email, password_hash FROM admins WHERE id = :id'); + $stmt->execute([':id' => $id]); + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + + // o que esta errado nos dados de um admin novo (texto pra linha de comando) ou null se esta tudo certo + public static function problem(string $email, string $name, string $password): ?string { + if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 255) { + return 'e-mail invalido'; + } + if (trim($name) === '' || mb_strlen($name) > 255) { + return 'informe o nome (ate 255 caracteres)'; + } + if (PasswordPolicy::problem($password) !== null) { + return 'a senha precisa ter de ' . PasswordPolicy::MIN_BYTES . ' a ' . PasswordPolicy::MAX_BYTES . ' bytes'; + } + return null; + } + + // cria o admin e devolve o id. e-mail repetido sobe como PDOException (UNIQUE). + public function create(string $email, string $name, string $password): int { + $problem = self::problem($email, $name, $password); + if ($problem !== null) { + throw new \InvalidArgumentException($problem); + } + + $stmt = $this->db->prepare('INSERT INTO admins (email, name, password_hash) VALUES (:email, :name, :hash)'); + $stmt->execute([':email' => $email, ':name' => trim($name), ':hash' => password_hash($password, PASSWORD_BCRYPT)]); + return (int)$this->db->lastInsertId(); + } +} diff --git a/src/Models/EmailVerificationModel.php b/src/Models/EmailVerificationModel.php new file mode 100644 index 0000000..b5a6d5a --- /dev/null +++ b/src/Models/EmailVerificationModel.php @@ -0,0 +1,17 @@ +consume($token, function (int $merchantId) { + $this->db->prepare('UPDATE merchants SET email_verified_at = CURRENT_TIMESTAMP WHERE id = :id AND email_verified_at IS NULL') + ->execute([':id' => $merchantId]); + }); + } +} diff --git a/src/Models/MerchantModel.php b/src/Models/MerchantModel.php index bb530a7..04dfd15 100644 --- a/src/Models/MerchantModel.php +++ b/src/Models/MerchantModel.php @@ -42,7 +42,7 @@ public function findByEmail($email){ // 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, points_rule_cents, password_hash FROM merchants WHERE id = :id'); + $stmt = $this->db->prepare('SELECT id, owner_name, store_name, email, email_verified_at, status, points_rule_cents, password_hash FROM merchants WHERE id = :id'); $stmt->execute([':id' => $merchantId]); return $stmt->fetch(\PDO::FETCH_ASSOC); @@ -107,4 +107,30 @@ public function findActiveByPublicCode(string $code) { return $stmt->fetch(\PDO::FETCH_ASSOC); } + + // painel administrativo (task 30): as lojas, da mais nova pra mais antiga, com o numero de clientes. + // so dados da loja: nada de cliente (nome, telefone) sai daqui. + public function countAll(): int { + return (int)$this->db->query('SELECT COUNT(*) FROM merchants')->fetchColumn(); + } + + public function pageForAdmin(int $limit, int $offset): array { + $sql = 'SELECT m.id, m.store_name, m.owner_name, m.email, m.cpf, m.cnpj, m.status, m.created_at, + (SELECT COUNT(*) FROM loyalty_cards lc WHERE lc.merchant_id = m.id AND lc.anonymized_at IS NULL) AS customers + FROM merchants m + ORDER BY m.created_at DESC, m.id DESC + LIMIT ' . (int)$limit . ' OFFSET ' . (int)$offset; + return $this->db->query($sql)->fetchAll(\PDO::FETCH_ASSOC); + } + + // ativa ou desativa a loja. desativada perde o acesso na hora (o authGuard confere o status a cada requisicao). + // devolve false se a loja nao existe. + public function setStatus(int $merchantId, string $status): bool { + if (!in_array($status, ['active', 'inactive'], true)) { + return false; + } + $stmt = $this->db->prepare('UPDATE merchants SET status = :status WHERE id = :id'); + $stmt->execute([':status' => $status, ':id' => $merchantId]); + return $stmt->rowCount() > 0 || $this->findById($merchantId) !== false; + } } diff --git a/src/Models/MerchantTokenModel.php b/src/Models/MerchantTokenModel.php new file mode 100644 index 0000000..087d960 --- /dev/null +++ b/src/Models/MerchantTokenModel.php @@ -0,0 +1,91 @@ +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). os anteriores ainda abertos deixam de valer. + public function create(int $merchantId): string { + $token = bin2hex(random_bytes(32)); + + $this->db->beginTransaction(); + try { + $this->cancelOpen($merchantId); + $this->db->prepare( + 'INSERT INTO ' . static::TABLE . ' (merchant_id, token_hash, expires_at) + VALUES (:merchant_id, :token_hash, NOW() + INTERVAL ' . static::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; + } + + // usa o link, tudo ou nada: $apply recebe o id do lojista e faz a mudanca; depois o link (e qualquer + // outro aberto da conta) deixa de valer. FOR UPDATE: dois envios do mesmo link ao mesmo tempo nao + // aplicam duas vezes. devolve o id do lojista ou null se o link nao vale. + protected function consume(string $token, callable $apply): ?int { + if ($token === '') { + return null; + } + + $this->db->beginTransaction(); + try { + $merchantId = $this->findValid(self::hash($token), true); + if ($merchantId === false) { + $this->db->rollBack(); + return null; + } + + $apply((int)$merchantId); + $this->cancelOpen((int)$merchantId); + + $this->db->commit(); + return (int)$merchantId; + } catch (\Throwable $e) { + $this->db->rollBack(); + throw $e; + } + } + + private function cancelOpen(int $merchantId): void { + $this->db->prepare('UPDATE ' . static::TABLE . ' SET used_at = CURRENT_TIMESTAMP WHERE merchant_id = :id AND used_at IS NULL') + ->execute([':id' => $merchantId]); + } + + // id do lojista dono do link valido, ou false + private function findValid(string $tokenHash, bool $lock) { + $stmt = $this->db->prepare( + 'SELECT t.merchant_id FROM ' . static::TABLE . " t + JOIN merchants m ON m.id = t.merchant_id + WHERE t.token_hash = :token_hash AND t.used_at IS NULL AND t.expires_at > NOW() + AND m.status = 'active'" . ($lock ? ' FOR UPDATE' : '') + ); + $stmt->execute([':token_hash' => $tokenHash]); + return $stmt->fetchColumn(); + } +} diff --git a/src/Models/PasswordResetModel.php b/src/Models/PasswordResetModel.php new file mode 100644 index 0000000..108578a --- /dev/null +++ b/src/Models/PasswordResetModel.php @@ -0,0 +1,17 @@ +consume($token, function (int $merchantId) use ($passwordHash) { + $this->db->prepare('UPDATE merchants SET password_hash = :hash WHERE id = :id') + ->execute([':hash' => $passwordHash, ':id' => $merchantId]); + }); + } +} 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/Feature/AdminTest.php b/tests/Feature/AdminTest.php new file mode 100644 index 0000000..ced5348 --- /dev/null +++ b/tests/Feature/AdminTest.php @@ -0,0 +1,158 @@ +db))->create($email, 'Admin Teste', $password); + } + + private function loginAdmin(string $email = 'admin@fidelityx.test', string $password = 'admin-senha-1'): ?string { + return $this->post('admin/login', ['email' => $email, 'password' => $password])[1]; + } + + private function merchantStatus(int $merchantId): string { + return (string)$this->scalar('SELECT status FROM merchants WHERE id = ?', [$merchantId]); + } + + public function testPainelExigeLoginDeAdmin(): void { + [$status, $location] = $this->get('admin/dashboard'); + $this->assertSame(302, $status); + $this->assertSame('admin/login&error=sessao_expirada', $location); + $this->assertSame('no-store', $this->lastHeaders['cache-control'][0]); + + // lojista logado nao e admin + $this->createMerchant('loja@teste.test'); + $this->loginAs('loja@teste.test'); + $this->assertSame('admin/login&error=sessao_expirada', $this->get('admin/dashboard')[1]); + } + + public function testLoginDeAdmin(): void { + $this->createAdmin(); + // senha de lojista nao vale no admin (outra tabela) + $this->createMerchant('admin@fidelityx.test'); + + $this->assertSame('admin/login&error=credenciais_invalidas', $this->loginAdmin('admin@fidelityx.test', 'teste123')); + $this->assertSame('admin/login&error=credenciais_invalidas', $this->loginAdmin('ninguem@fidelityx.test')); + $this->assertSame('admin/dashboard', $this->loginAdmin()); + $this->assertSame(200, $this->get('admin/dashboard')[0]); + $this->assertStringContainsString('Administração · Admin Teste', $this->lastBody); + } + + public function testListaAsLojasSemDadosDosClientes(): void { + $this->createAdmin(); + $loja = $this->createMerchant('loja@teste.test'); + $this->createCard($loja, 'Bia Cliente', '11922220002'); + $this->createCard($loja, 'Caio Cliente', '11933330003'); + $this->createMerchant('outra@teste.test'); + + $this->loginAdmin(); + $this->get('admin/dashboard'); + + $this->assertStringContainsString('2 lojas', $this->lastBody); + $this->assertStringContainsString('loja@teste.test', $this->lastBody); + $this->assertStringContainsString('outra@teste.test', $this->lastBody); + $this->assertMatchesRegularExpression('#loja@teste\.test\s*2#', $this->lastBody); + $this->assertStringNotContainsString('Bia Cliente', $this->lastBody); + $this->assertStringNotContainsString('92222', $this->lastBody); + } + + public function testDesativarDerrubaOLojistaNaHoraEAtivarDevolveOAcesso(): void { + $this->createAdmin(); + $loja = $this->createMerchant('loja@teste.test'); + + // o lojista esta logado em outro aparelho + $this->loginAs('loja@teste.test'); + $merchantSession = $this->cookie('PHPSESSID'); + + $this->newSession(); + $this->loginAdmin(); + [, $location] = $this->post('admin/dashboard', ['merchant_id' => $loja, 'status' => 'inactive', 'page' => 1]); + $this->assertSame('admin/dashboard&page=1&success=loja_desativada', $location); + $this->assertSame('inactive', $this->merchantStatus($loja)); + + $this->newSession(); + $this->setCookie('PHPSESSID', (string)$merchantSession); + $this->assertSame('merchant/login&error=conta_inativa', $this->get('merchant/dashboard')[1]); + + $this->newSession(); + $this->loginAdmin(); + [, $location] = $this->post('admin/dashboard', ['merchant_id' => $loja, 'status' => 'active', 'page' => 1]); + $this->assertSame('admin/dashboard&page=1&success=loja_ativada', $location); + $this->newSession(); + $this->loginAs('loja@teste.test'); + } + + public function testAcaoInvalidaOuSemCsrfNaoMudaNada(): void { + $this->createAdmin(); + $loja = $this->createMerchant('loja@teste.test'); + $this->loginAdmin(); + + $this->assertSame('admin/dashboard&page=1&error=loja_invalida', $this->post('admin/dashboard', ['merchant_id' => $loja, 'status' => 'apagada'])[1]); + $this->assertSame('admin/dashboard&page=1&error=loja_invalida', $this->post('admin/dashboard', ['merchant_id' => 999999, 'status' => 'inactive'])[1]); + + [$status] = $this->post('admin/dashboard', ['merchant_id' => $loja, 'status' => 'inactive'], false); + $this->assertSame(403, $status); + $this->assertSame('active', $this->merchantStatus($loja)); + } + + public function testSessaoDeAdminCaiQuandoASenhaMudaENoLogout(): void { + $id = $this->createAdmin(); + $this->loginAdmin(); + + [, $location] = $this->post('admin/logout', [], true, 'admin/dashboard'); + $this->assertSame('admin/login&success=logout', $location); + $this->assertSame('admin/login&error=sessao_expirada', $this->get('admin/dashboard')[1]); + + $this->loginAdmin(); + $this->db->prepare('UPDATE admins SET password_hash = ? WHERE id = ?')->execute([password_hash('outra-senha-1', PASSWORD_BCRYPT), $id]); + $this->assertSame('admin/login&error=sessao_expirada', $this->get('admin/dashboard')[1]); + } + + public function testErrosDemaisNoLoginDeAdminDao429(): void { + $this->createAdmin(); + for ($i = 0; $i < 5; $i++) { + $this->assertSame('admin/login&error=credenciais_invalidas', $this->loginAdmin('admin@fidelityx.test', 'errada-123')); + } + [$status] = $this->post('admin/login', ['email' => 'admin@fidelityx.test', 'password' => 'admin-senha-1']); + $this->assertSame(429, $status); + } + + // o script de linha de comando cria o admin (aqui, no banco de teste) + public function testScriptCriaOAdmin(): void { + $env = getenv(); + foreach (['DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASS'] as $key) { + $env[$key] = (string)($_ENV[$key] ?? ''); + } + $run = function (array $args, string $input) use ($env): array { + $process = proc_open( + array_merge([PHP_BINARY, dirname(__DIR__, 2) . '/bin/create-admin.php'], $args), + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + null, + $env + ); + fwrite($pipes[0], $input); + fclose($pipes[0]); + $out = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]); + return [proc_close($process), $out]; + }; + + [$code, $out] = $run(['novo@fidelityx.test', 'Nova Admin'], "curta\n"); + $this->assertSame(1, $code); + $this->assertStringContainsString('a senha precisa ter de 8 a 72 bytes', $out); + + [$code, $out] = $run(['novo@fidelityx.test', 'Nova Admin'], "senha-forte-1\n"); + $this->assertSame(0, $code, $out); + $this->assertSame('Nova Admin', $this->scalar("SELECT name FROM admins WHERE email = 'novo@fidelityx.test'")); + $this->assertSame('admin/dashboard', $this->loginAdmin('novo@fidelityx.test', 'senha-forte-1')); + + [$code, $out] = $run(['novo@fidelityx.test', 'Nova Admin'], "senha-forte-1\n"); + $this->assertSame(1, $code); + $this->assertStringContainsString('ja existe admin com esse e-mail', $out); + } +} diff --git a/tests/Feature/BalcaoFlowTest.php b/tests/Feature/BalcaoFlowTest.php index 7da6958..d6c8e03 100644 --- a/tests/Feature/BalcaoFlowTest.php +++ b/tests/Feature/BalcaoFlowTest.php @@ -16,6 +16,7 @@ public function testFluxoCompletoDoCadastroDaLojaAoResgate(): void { ]); $this->assertSame('merchant/login&success=cadastrado', $location); $this->loginAs('padaria@teste.test'); + $this->assertSame('merchant/dashboard&success=email_confirmado', $this->confirmEmailFromMail('padaria@teste.test')); // premio [, $location] = $this->post('merchant/rewards', ['action' => 'create', 'name' => 'Cafe', 'points_cost' => 20]); diff --git a/tests/Feature/EmailVerificationTest.php b/tests/Feature/EmailVerificationTest.php new file mode 100644 index 0000000..b5e3d5c --- /dev/null +++ b/tests/Feature/EmailVerificationTest.php @@ -0,0 +1,113 @@ +post('merchant/register', [ + 'owner_name' => 'Dona Ana', 'shop_name' => 'Padaria da Ana', 'address' => 'Rua 1', + 'state' => 'SP', 'city' => 'Sao Paulo', 'email' => $email, + 'phone' => '11988887777', 'category' => 'alimentacao', 'document' => '52998224725', + 'password' => 'teste123', 'password_confirm' => 'teste123', + ]); + $this->assertSame('merchant/login&success=cadastrado', $location); + } + + private function lastToken(): string { + $mails = $this->sentMails(); + preg_match('#verify-email&token=([a-f0-9]{64})#', (string)end($mails)['body'], $m); + $this->assertNotEmpty($m, 'sem link de confirmacao'); + return $m[1]; + } + + public function testCadastroMandaOLinkEOPainelEsperaAConfirmacao(): void { + $this->register(); + + $mails = $this->sentMails(); + $this->assertCount(1, $mails); + $this->assertSame('padaria@teste.test', $mails[0]['to']); + $this->assertStringContainsString('24 horas', $mails[0]['body']); + $this->assertNull($this->scalar('SELECT email_verified_at FROM merchants')); + + // entra, mas toda tela do painel leva pra confirmacao + $this->loginAs('padaria@teste.test'); + foreach (['merchant/dashboard', 'merchant/customers', 'merchant/rewards', 'merchant/profile', 'merchant/export&tipo=clientes'] as $route) { + [$status, $location] = $this->get($route); + $this->assertSame(302, $status, $route); + $this->assertSame('merchant/confirm-email', $location, $route); + } + [$status] = $this->post('merchant/rewards', ['action' => 'create', 'name' => 'Cafe', 'points_cost' => 10], true, 'merchant/confirm-email'); + $this->assertSame(302, $status); + $this->assertSame(0, (int)$this->scalar('SELECT COUNT(*) FROM rewards')); + + $this->get('merchant/confirm-email'); + $this->assertStringContainsString('padaria@teste.test', $this->lastBody); + + // confirma pelo link (logado: volta pro painel) + $this->assertSame('merchant/dashboard&success=email_confirmado', $this->confirmEmailFromMail('padaria@teste.test')); + $this->assertNotNull($this->scalar('SELECT email_verified_at FROM merchants')); + $this->assertSame(200, $this->get('merchant/dashboard')[0]); + [, $location] = $this->get('merchant/confirm-email'); + $this->assertSame('merchant/dashboard', $location, 'ja confirmado nao ve mais a tela'); + } + + public function testLinkAbertoSemLoginConfirmaEUmaVezSo(): void { + $this->register(); + $token = $this->lastToken(); + + $this->newSession(); + [, $location] = $this->get('merchant/verify-email&token=' . $token); + $this->assertSame('merchant/login&success=email_confirmado', $location); + [, $location] = $this->get('merchant/verify-email&token=' . $token); + $this->assertSame('merchant/login&error=confirmacao_invalida', $location); + } + + public function testLinkVencidoOuInventadoNaoConfirma(): void { + $this->register(); + $token = $this->lastToken(); + + $this->assertSame('merchant/login&error=confirmacao_invalida', $this->get('merchant/verify-email&token=' . str_repeat('a', 64))[1]); + $this->assertSame('merchant/login&error=confirmacao_invalida', $this->get('merchant/verify-email')[1]); + + $this->db->exec('UPDATE email_verifications SET expires_at = NOW() - INTERVAL 1 SECOND'); + $this->assertSame('merchant/login&error=confirmacao_invalida', $this->get('merchant/verify-email&token=' . $token)[1]); + $this->assertNull($this->scalar('SELECT email_verified_at FROM merchants')); + } + + public function testReenviarCancelaOLinkAnteriorETemLimite(): void { + $this->register(); + $first = $this->lastToken(); + $this->loginAs('padaria@teste.test'); + + [, $location] = $this->post('merchant/confirm-email', []); + $this->assertSame('merchant/confirm-email&success=confirmacao_reenviada', $location); + $second = $this->lastToken(); + $this->assertNotSame($first, $second); + $this->assertSame('merchant/confirm-email&error=confirmacao_invalida', $this->get('merchant/verify-email&token=' . $first)[1]); + + $this->post('merchant/confirm-email', []); + $this->post('merchant/confirm-email', []); + [, $location] = $this->post('merchant/confirm-email', []); + $this->assertSame('merchant/confirm-email&error=muitos_pedidos', $location); + $this->assertCount(4, $this->sentMails(), 'cadastro + 3 reenvios'); + + [$status] = $this->post('merchant/confirm-email', [], false); + $this->assertSame(403, $status, 'reenvio exige csrf'); + } + + // contas criadas antes da regra (e as de teste, criadas direto no banco) ja contam como confirmadas + public function testContaJaConfirmadaUsaOPainelNormalmente(): void { + $this->createMerchant('loja@teste.test'); + $this->loginAs('loja@teste.test'); + $this->assertSame(200, $this->get('merchant/dashboard')[0]); + $this->assertSame([], $this->sentMails()); + } + + public function testMigration009MarcaContasExistentesComoConfirmadas(): void { + $sql = (string)file_get_contents(dirname(__DIR__, 2) . '/database/migrations/009_email_verifications.sql'); + $this->assertStringContainsString('UPDATE merchants SET email_verified_at = created_at WHERE email_verified_at IS NULL', $sql); + } +} 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/Feature/RegisterTest.php b/tests/Feature/RegisterTest.php index 207fc68..757163a 100644 --- a/tests/Feature/RegisterTest.php +++ b/tests/Feature/RegisterTest.php @@ -35,6 +35,7 @@ public function testCadastraCnpjAlfanumerico(): void { $this->assertNull($this->scalar('SELECT cpf FROM merchants')); $this->loginAs('padaria@teste.test'); + $this->confirmEmailFromMail('padaria@teste.test'); $this->get('merchant/profile'); $this->assertStringContainsString('value="12.ABC.345/01DE-35"', $this->lastBody); $this->assertStringContainsString('>CNPJ<', $this->lastBody); diff --git a/tests/Support/DatabaseTestCase.php b/tests/Support/DatabaseTestCase.php index 9d62e80..ed5fa68 100644 --- a/tests/Support/DatabaseTestCase.php +++ b/tests/Support/DatabaseTestCase.php @@ -19,8 +19,8 @@ protected function setUp(): void { protected function createMerchant(string $email = 'loja@teste.test'): int { $stmt = $this->db->prepare( - "INSERT INTO merchants (public_code, owner_name, store_name, address, state, city, email, phone, category, cpf, password_hash) - VALUES (:code, 'Dona Teste', 'Loja Teste', 'Rua 1', 'SP', 'Sao Paulo', :email, '11988887777', 'varejo', :cpf, :hash)" + "INSERT INTO merchants (public_code, owner_name, store_name, address, state, city, email, email_verified_at, phone, category, cpf, password_hash) + VALUES (:code, 'Dona Teste', 'Loja Teste', 'Rua 1', 'SP', 'Sao Paulo', :email, CURRENT_TIMESTAMP, '11988887777', 'varejo', :cpf, :hash)" ); $stmt->execute([ ':code' => strtoupper(substr(md5($email), 0, 8)), diff --git a/tests/Support/HttpTestCase.php b/tests/Support/HttpTestCase.php index c199be6..b7ad01f 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,21 @@ 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); + } + + // abre o link do ultimo e-mail de confirmacao mandado para $email (task 50) e devolve o destino do redirect + protected function confirmEmailFromMail(string $email): ?string { + foreach (array_reverse($this->sentMails()) as $mail) { + if ($mail['to'] === $email && preg_match('#url=merchant%2Fverify-email&token=([a-f0-9]{64})#', $mail['body'], $m)) { + return $this->get('merchant/verify-email&token=' . $m[1])[1]; + } + } + $this->fail("nenhum e-mail de confirmacao para $email"); + } + // sessao nova (como apagar o cookie no navegador) protected function newSession(): void { @unlink($this->cookieJar); diff --git a/tests/Support/TestDatabase.php b/tests/Support/TestDatabase.php index 7f45796..33732f5 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', 'email_verifications', 'merchants', 'rate_limit_hits', 'admins']; public static function name(): string { return $_ENV['DB_NAME']; 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')); + } +} diff --git a/views/admin/dashboard.php b/views/admin/dashboard.php new file mode 100644 index 0000000..179c238 --- /dev/null +++ b/views/admin/dashboard.php @@ -0,0 +1,75 @@ + + + + + + + + Lojas | Administração FidelityX + + + + + + + + +
+

Lojas

+ + +
+ +

Nenhuma loja cadastrada.

+ +

total ?> total === 1 ? 'loja' : 'lojas' ?>

+
+ + + + + + + + + + + + + + + + + + + +
LojaResponsávelCPF/CNPJE-mailClientesCadastroSituaçãoAções
+
+ + + + + +
+
+
+ + + +
+
+ + + diff --git a/views/admin/login.php b/views/admin/login.php new file mode 100644 index 0000000..1db1814 --- /dev/null +++ b/views/admin/login.php @@ -0,0 +1,43 @@ + + + + + + + + Administração | FidelityX + + + + + + + +
+
+ +

Administração

+
+ + + +
+ + +
+ + +
+ +
+ + +
+ + +
+
+ + + + 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

+