Atualiza estrutura MVC e integração com banco de dados

This commit is contained in:
2026-08-30 22:18:31 -03:00
parent 6231cc46a8
commit 966f6b7587
26 changed files with 3265 additions and 8 deletions
+515
View File
@@ -0,0 +1,515 @@
<?php
declare(strict_types=1);
class AuthController
{
/**
* Model responsável pelos usuários.
*/
private User $user;
/**
* Model responsável pelas tentativas
* de autenticação.
*/
private LoginAttempt $loginAttempt;
/**
* Número máximo de tentativas inválidas
* permitidas para uma mesma conta.
*/
private const MAX_TENTATIVAS_CONTA = 5;
/**
* Número máximo de tentativas inválidas
* permitidas para um mesmo endereço IP.
*/
private const MAX_TENTATIVAS_IP = 20;
/**
* Período utilizado para contabilizar
* as tentativas inválidas.
*/
private const JANELA_MINUTOS = 10;
/**
* Recebe os Models necessários para
* executar o processo de autenticação.
*/
public function __construct(
User $user,
LoginAttempt $loginAttempt
) {
$this->user = $user;
$this->loginAttempt = $loginAttempt;
}
/**
* Processa o acesso à página de login.
*
* GET:
* Exibe o formulário.
*
* POST:
* Processa a tentativa de autenticação.
*/
public function login(): void
{
if ($this->usuarioAutenticado()) {
header('Location: /dashboard.php');
exit;
}
/*
* Garante que a sessão possua
* um token CSRF.
*/
$this->gerarTokenCsrf();
$erro = '';
$email = trim(
(string)($_POST['email'] ?? '')
);
$senha = (string)(
$_POST['senha'] ?? ''
);
/*
* Obtém o endereço IP real do cliente.
*/
$ip = $this->obterIpCliente();
/*
* Processa somente requisições POST.
*/
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$erro = $this->processarLogin(
$email,
$senha,
$ip
);
}
/*
* Carrega a View responsável
* pela apresentação do formulário.
*/
require __DIR__ . '/../Views/auth/login.php';
}
/**
* Verifica se existe um usuário autenticado
* na sessão atual.
*/
private function usuarioAutenticado(): bool
{
return isset(
$_SESSION['usuario_id']
);
}
/**
* Cria o token CSRF caso ainda
* não exista na sessão.
*/
private function gerarTokenCsrf(): void
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(
random_bytes(32)
);
}
}
/**
* Processa todas as etapas de uma
* tentativa de autenticação.
*/
private function processarLogin(
string $email,
string $senha,
string $ip
): string {
/*
* Valida o token CSRF enviado
* pelo formulário.
*/
if (!$this->validarCsrf()) {
return 'E-mail ou senha inválidos.';
}
/*
* Valida os dados básicos enviados
* pelo usuário.
*/
if (
!$this->validarDadosLogin(
$email,
$senha
)
) {
return 'E-mail ou senha inválidos.';
}
/*
* Verifica o limite de tentativas
* para a conta.
*/
if ($this->contaBloqueada($email)) {
return 'E-mail ou senha inválidos.';
}
/*
* Verifica o limite de tentativas
* para o endereço IP.
*/
if ($this->ipBloqueado($ip)) {
return 'E-mail ou senha inválidos.';
}
/*
* Solicita ao Model o usuário
* correspondente ao e-mail informado.
*/
$usuario = $this->user->buscarPorEmail(
$email
);
/*
* Verifica as credenciais fornecidas.
*/
if (
$usuario !== null &&
(int)$usuario['ativo'] === 1 &&
password_verify(
$senha,
$usuario['senha']
)
) {
/*
* Registra a tentativa bem-sucedida.
*/
$this->loginAttempt->registrar(
$email,
$ip,
true
);
/*
* Cria a sessão autenticada.
*/
$this->criarSessaoAutenticada(
$usuario
);
/*
* Atualiza o hash da senha caso
* seja necessário utilizar parâmetros
* mais atuais.
*/
$this->atualizarHashSeNecessario(
$usuario,
$senha
);
header('Location: /dashboard.php');
exit;
}
/*
* Registra a tentativa inválida.
*/
$this->loginAttempt->registrar(
$email,
$ip,
false
);
/*
* Pequeno atraso para dificultar
* ataques automatizados.
*/
usleep(300000);
return 'E-mail ou senha inválidos.';
}
/**
* Valida o token CSRF enviado
* pelo formulário de login.
*/
private function validarCsrf(): bool
{
$token = (string)(
$_POST['csrf_token'] ?? ''
);
return (
$token !== '' &&
isset($_SESSION['csrf_token']) &&
hash_equals(
$_SESSION['csrf_token'],
$token
)
);
}
/**
* Valida os dados básicos utilizados
* durante a autenticação.
*/
private function validarDadosLogin(
string $email,
string $senha
): bool {
if (strlen($email) > 150) {
return false;
}
if (
$email === '' ||
$senha === ''
) {
return false;
}
if (
filter_var(
$email,
FILTER_VALIDATE_EMAIL
) === false
) {
return false;
}
return true;
}
/**
* Obtém o endereço IP real do cliente.
*
* Quando a requisição passa pelo Traefik,
* REMOTE_ADDR corresponde ao IP do Traefik.
*
* Nesse caso utilizamos X-Real-IP,
* conforme configurado no ambiente.
*/
private function obterIpCliente(): string
{
$remoteAddr =
$_SERVER['REMOTE_ADDR'] ?? '';
if ($remoteAddr === '172.18.0.3') {
$ip =
$_SERVER['HTTP_X_REAL_IP'] ?? '';
if (
filter_var(
$ip,
FILTER_VALIDATE_IP
) !== false
) {
return $ip;
}
}
if (
filter_var(
$remoteAddr,
FILTER_VALIDATE_IP
) !== false
) {
return $remoteAddr;
}
return '0.0.0.0';
}
/**
* Verifica se o endereço de e-mail
* atingiu o limite de tentativas inválidas.
*/
private function contaBloqueada(
string $email
): bool {
$quantidade =
$this->loginAttempt
->contarFalhasPorEmail(
$email,
self::JANELA_MINUTOS
);
return $quantidade
>= self::MAX_TENTATIVAS_CONTA;
}
/**
* Verifica se o endereço IP atingiu
* o limite de tentativas inválidas.
*/
private function ipBloqueado(
string $ip
): bool {
$quantidade =
$this->loginAttempt
->contarFalhasPorIp(
$ip,
self::JANELA_MINUTOS
);
return $quantidade
>= self::MAX_TENTATIVAS_IP;
}
/**
* Cria a sessão do usuário após
* uma autenticação bem-sucedida.
*
* A regeneração do ID impede ataques
* de session fixation.
*/
private function criarSessaoAutenticada(
array $usuario
): void {
session_regenerate_id(true);
/*
* Gera um novo token CSRF depois
* da autenticação.
*/
$_SESSION['csrf_token'] = bin2hex(
random_bytes(32)
);
$_SESSION['usuario_id'] =
$usuario['id'];
$_SESSION['usuario_nome'] =
$usuario['nome'];
$_SESSION['usuario_email'] =
$usuario['email'];
$_SESSION['usuario_perfil'] =
$usuario['perfil'];
/*
* Registra os tempos utilizados
* pelo controle de expiração da sessão.
*/
$_SESSION['login_time'] = time();
$_SESSION['last_activity'] = time();
}
/**
* Atualiza o hash da senha caso o algoritmo
* ou seus parâmetros tenham evoluído.
*
* A operação de banco é realizada pelo
* Model User.
*/
private function atualizarHashSeNecessario(
array $usuario,
string $senha
): void {
if (
!password_needs_rehash(
$usuario['senha'],
PASSWORD_DEFAULT
)
) {
return;
}
$novoHash = password_hash(
$senha,
PASSWORD_DEFAULT
);
$this->user->atualizarSenha(
(int)$usuario['id'],
$novoHash
);
}
/**
* Encerra a sessão do usuário.
*
* Remove os dados da sessão, invalida
* o cookie de sessão e destrói a sessão
* existente no servidor.
*/
public function logout(): void
{
/*
* Remove todos os dados armazenados
* na sessão atual.
*/
$_SESSION = [];
/*
* Remove o cookie de sessão
* do navegador.
*/
if (ini_get('session.use_cookies')) {
$params =
session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params['path'],
$params['domain'],
(bool)$params['secure'],
(bool)$params['httponly']
);
}
/*
* Destrói a sessão no servidor.
*/
session_destroy();
/*
* Retorna o usuário para
* a tela de login.
*/
header('Location: /login.php');
exit;
}
}
+15
View File
@@ -0,0 +1,15 @@
cat > /home/opc/html/app/Controllers/Controller.php <<'EOF'
<?php
declare(strict_types=1);
/**
* Controller base da aplicação.
*
* Esta classe servirá como ponto de partida para
* os Controllers específicos do sistema.
*/
abstract class Controller
{
}
EOF
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
class DashboardController
{
/**
* Exibe a página principal do dashboard.
*
* A autenticação e o controle de expiração
* da sessão são realizados pelo security.php.
*/
public function index(): void
{
/*
* Verifica se existe um usuário autenticado.
*
* Caso contrário, redireciona para o login.
*/
if (!isset($_SESSION['usuario_id'])) {
header('Location: /login.php');
exit;
}
/*
* Dados básicos do usuário autenticado.
*
* Posteriormente esses dados poderão vir
* de um Model ou de um Service.
*/
$usuario = [
'id' => $_SESSION['usuario_id'],
'nome' => $_SESSION['usuario_nome'] ?? '',
'email' => $_SESSION['usuario_email'] ?? '',
'perfil' => $_SESSION['usuario_perfil'] ?? ''
];
/*
* Carrega a View do dashboard.
*
* A View recebe os dados através da variável
* $usuario e fica responsável somente pela
* apresentação das informações.
*/
require __DIR__ . '/../Views/dashboard/index.php';
}
}
+285
View File
@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
/**
* Controller responsável pelos mecanismos de segurança
* relacionados à sessão e às requisições HTTP.
*
* Responsabilidades:
*
* - configurar os parâmetros de segurança da sessão;
* - iniciar a sessão com segurança;
* - aplicar headers HTTP de segurança;
* - controlar o tempo de inatividade da sessão;
* - controlar o tempo máximo de uma sessão;
* - destruir sessões expiradas;
* - redirecionar o usuário quando a sessão expirar.
*/
class SecurityController
{
/**
* Tempo máximo permitido sem atividade do usuário.
*
* 1800 segundos = 30 minutos.
*/
private const SESSAO_INATIVIDADE_SEGUNDOS = 1800;
/**
* Tempo máximo absoluto de uma sessão.
*
* 28800 segundos = 8 horas.
*/
private const SESSAO_MAXIMA_SEGUNDOS = 28800;
/**
* Configura os parâmetros de segurança
* utilizados pelo cookie da sessão.
*
* Essas configurações devem ser aplicadas
* antes de session_start().
*/
public function configurarSessao(): void
{
ini_set(
'session.use_strict_mode',
'1'
);
ini_set(
'session.use_only_cookies',
'1'
);
ini_set(
'session.cookie_httponly',
'1'
);
ini_set(
'session.cookie_secure',
'1'
);
ini_set(
'session.cookie_samesite',
'Lax'
);
}
/**
* Inicia a sessão caso ainda não exista
* uma sessão ativa.
*/
public function iniciarSessao(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
}
/**
* Aplica headers HTTP destinados a reduzir
* diferentes tipos de ataques no navegador.
*
* Os headers utilizados são:
*
* - X-Content-Type-Options;
* - X-Frame-Options;
* - Referrer-Policy;
* - Permissions-Policy;
* - Content-Security-Policy;
* - Strict-Transport-Security.
*/
public function aplicarHeadersSeguranca(): void
{
header(
'X-Content-Type-Options: nosniff'
);
header(
'X-Frame-Options: DENY'
);
header(
'Referrer-Policy: strict-origin-when-cross-origin'
);
header(
'Permissions-Policy: camera=(), microphone=(), geolocation=()'
);
header(
"Content-Security-Policy: "
. "default-src 'self'; "
. "style-src 'self'; "
. "script-src 'self'; "
. "img-src 'self' data:; "
. "font-src 'self'; "
. "form-action 'self'; "
. "frame-ancestors 'none'; "
. "base-uri 'self'; "
. "object-src 'none'"
);
/*
* HSTS determina que o navegador deverá utilizar
* HTTPS durante o período definido.
*
* Esta aplicação já utiliza HTTPS através do Traefik.
*/
header(
'Strict-Transport-Security: max-age=31536000; includeSubDomains'
);
}
/**
* Verifica se a sessão autenticada ainda está dentro
* dos limites de tempo estabelecidos.
*
* São verificados dois limites:
*
* 1. tempo máximo de inatividade;
* 2. tempo máximo absoluto da sessão.
*/
public function verificarExpiracaoSessao(): void
{
/*
* Se não existir usuário autenticado,
* não há sessão protegida para verificar.
*/
if (!isset($_SESSION['usuario_id'])) {
return;
}
$agora = time();
$ultimoAcesso = (int) (
$_SESSION['last_activity'] ?? 0
);
$inicioSessao = (int) (
$_SESSION['login_time'] ?? 0
);
/*
* Verifica se o usuário ficou tempo demais
* sem realizar nenhuma atividade.
*/
if (
$ultimoAcesso > 0
&& ($agora - $ultimoAcesso)
> self::SESSAO_INATIVIDADE_SEGUNDOS
) {
$this->encerrarSessao(
'inatividade'
);
return;
}
/*
* Verifica se a sessão atingiu seu tempo
* máximo absoluto.
*/
if (
$inicioSessao > 0
&& ($agora - $inicioSessao)
> self::SESSAO_MAXIMA_SEGUNDOS
) {
$this->encerrarSessao(
'tempo_maximo'
);
return;
}
/*
* Registra a última atividade do usuário.
*/
$_SESSION['last_activity'] = $agora;
}
/**
* Encerra uma sessão expirada e redireciona
* o usuário para a tela de login.
*
* O motivo da expiração é enviado através
* de um parâmetro na URL.
*/
private function encerrarSessao(
string $motivo
): void {
/*
* Remove todos os dados armazenados
* na sessão atual.
*/
$_SESSION = [];
/*
* Remove o cookie da sessão do navegador.
*/
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params['path'],
$params['domain'],
(bool) $params['secure'],
(bool) $params['httponly']
);
}
/*
* Destrói a sessão no servidor.
*/
session_destroy();
/*
* Redireciona para o login informando
* que a sessão foi encerrada.
*/
header(
'Location: /login.php?expirada=1&motivo='
. urlencode($motivo)
);
exit;
}
/**
* Executa a sequência básica de segurança
* necessária para uma requisição da aplicação.
*
* Este método permite que as páginas utilizem
* apenas uma chamada para inicializar a segurança.
*/
public function iniciarSeguranca(): void
{
/*
* Configura o cookie antes de iniciar a sessão.
*/
$this->configurarSessao();
/*
* Inicia a sessão.
*/
$this->iniciarSessao();
/*
* Aplica os headers HTTP de segurança.
*/
$this->aplicarHeadersSeguranca();
/*
* Verifica se uma sessão autenticada
* expirou por tempo.
*/
$this->verificarExpiracaoSessao();
}
}
?>
View File
+136
View File
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
/**
* Model responsável pelas operações relacionadas
* às tentativas de autenticação.
*
* Responsabilidades:
*
* - verificar tentativas inválidas por e-mail;
* - verificar tentativas inválidas por endereço IP;
* - registrar tentativas de autenticação;
* - centralizar o acesso à tabela tentativas_login.
*/
class LoginAttempt
{
/**
* Conexão com o banco de dados.
*/
private PDO $pdo;
/**
* Recebe a conexão PDO utilizada pelo Model.
*/
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
/**
* Conta as tentativas inválidas de um endereço
* de e-mail dentro da janela de tempo informada.
*/
public function contarFalhasPorEmail(
string $email,
int $janelaMinutos
): int {
$janelaMinutos = max(
1,
$janelaMinutos
);
$sql = "
SELECT COUNT(*)
FROM tentativas_login
WHERE email = :email
AND sucesso = 0
AND criado_em >= DATE_SUB(
NOW(),
INTERVAL {$janelaMinutos} MINUTE
)
";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
'email' => $email
]);
return (int)$stmt->fetchColumn();
}
/**
* Conta as tentativas inválidas de um endereço
* IP dentro da janela de tempo informada.
*/
public function contarFalhasPorIp(
string $ip,
int $janelaMinutos
): int {
$janelaMinutos = max(
1,
$janelaMinutos
);
$sql = "
SELECT COUNT(*)
FROM tentativas_login
WHERE ip_address = :ip
AND sucesso = 0
AND criado_em >= DATE_SUB(
NOW(),
INTERVAL {$janelaMinutos} MINUTE
)
";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
'ip' => $ip
]);
return (int)$stmt->fetchColumn();
}
/**
* Registra uma tentativa de autenticação
* realizada pelo usuário.
*/
public function registrar(
string $email,
string $ip,
bool $sucesso
): bool {
$sql = "
INSERT INTO tentativas_login
(
email,
ip_address,
sucesso
)
VALUES
(
:email,
:ip,
:sucesso
)
";
$stmt = $this->pdo->prepare($sql);
return $stmt->execute([
'email' => $email,
'ip' => $ip,
'sucesso' => $sucesso ? 1 : 0
]);
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/**
* Model responsável pelas operações relacionadas
* aos usuários da aplicação.
*
* Responsabilidades:
*
* - localizar usuário pelo e-mail;
* - atualizar senha;
* - centralizar o acesso à tabela usuarios.
*/
class User
{
/**
* Conexão com o banco de dados.
*/
private PDO $pdo;
/**
* Recebe a conexão PDO utilizada pelo Model.
*/
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
/**
* Localiza um usuário através do endereço de e-mail.
*
* Retorna os dados necessários para o processo
* de autenticação.
*
* Retorna null quando o usuário não existe.
*/
public function buscarPorEmail(string $email): ?array
{
$sql = "
SELECT
id,
nome,
email,
senha,
perfil,
ativo
FROM usuarios
WHERE email = :email
LIMIT 1
";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
'email' => $email
]);
$usuario = $stmt->fetch();
if ($usuario === false) {
return null;
}
return $usuario;
}
/**
* Atualiza o hash da senha de um usuário.
*
* Utilizado quando password_needs_rehash()
* identificar que o algoritmo ou os parâmetros
* utilizados para criar o hash foram atualizados.
*/
public function atualizarSenha(
int $id,
string $novoHash
): bool {
$sql = "
UPDATE usuarios
SET senha = :senha
WHERE id = :id
";
$stmt = $this->pdo->prepare($sql);
return $stmt->execute([
'senha' => $novoHash,
'id' => $id
]);
}
}
View File
View File
+202
View File
@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<meta
name="description"
content="Acesso ao sistema Finanças+"
>
<title>Acesso - Finanças+</title>
<link
rel="stylesheet"
href="/css/style.css"
>
</head>
<body>
<header class="header">
<nav class="nav container">
<a
class="brand"
href="/"
>
<span class="mark">
F+
</span>
Finanças<span>+</span>
</a>
</nav>
</header>
<main>
<section class="section">
<div
class="container"
style="max-width: 480px;"
>
<div class="heading">
<small class="eyebrow">
ACESSO AO SISTEMA
</small>
<!--<h1>
Entrar
</h1>-->
<p>
Acesse sua área financeira.
</p>
</div>
<?php if ($erro !== ''): ?>
<div
role="alert" class="login-error"
>
<?= htmlspecialchars(
$erro,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
) ?>
</div>
<?php endif; ?>
<div class="login-card">
<form
method="post"
action="/login.php"
autocomplete="on"
>
<input
type="hidden"
name="csrf_token"
value="<?= htmlspecialchars(
$_SESSION['csrf_token'],
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
) ?>"
>
<label>
E-mail
<div class="input">
<input
type="email"
name="email"
maxlength="150"
required
autocomplete="username"
value="<?= htmlspecialchars(
$email,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
) ?>"
>
</div>
</label>
<br>
<label>
Senha
<div class="input">
<input
type="password"
name="senha"
maxlength="128"
required
autocomplete="current-password"
>
</div>
</label>
<button
type="submit"
class="btn primary full"
>
Entrar
</button>
</form>
</div>
</div>
</section>
</main>
<footer>
<div class="container footer">
<div>
<a
class="brand"
href="/"
>
<span class="mark">
F+
</span>
Finanças<span>+</span>
</a>
<p>
Conhecimento para decisões financeiras melhores.
</p>
</div>
<small>
© 2026 Finanças+.
</small>
</div>
</footer>
</body>
</html>
View File
+200
View File
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
?>
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<meta
name="description"
content="Dashboard financeiro do Finanças+"
>
<title>Dashboard - Finanças+</title>
<link
rel="stylesheet"
href="/css/style.css"
>
</head>
<body>
<header class="header">
<nav class="nav container">
<a
class="brand"
href="/"
>
<span class="mark">
F+
</span>
Finanças<span>+</span>
</a>
<div class="links">
<a href="/dashboard.php">
Dashboard
</a>
<a href="/logout.php">
Sair
</a>
</div>
</nav>
</header>
<main>
<section class="section">
<div class="container">
<div class="heading">
<small class="eyebrow">
ÁREA DO USUÁRIO
</small>
<h1>
Olá, <?= htmlspecialchars(
$usuario['nome'],
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
) ?>!
</h1>
<p>
Bem-vindo ao seu painel financeiro.
</p>
</div>
<div class="cards">
<article class="card">
<div class="icon">
??
</div>
<small>
PATRIMÔNIO
</small>
<h3>
R$ 0,00
</h3>
<p>
Seu patrimônio será exibido aqui.
</p>
</article>
<article class="card">
<div class="icon">
??
</div>
<small>
RECEITAS
</small>
<h3>
R$ 0,00
</h3>
<p>
Acompanhe suas receitas mensais.
</p>
</article>
<article class="card">
<div class="icon">
??
</div>
<small>
INVESTIMENTOS
</small>
<h3>
R$ 0,00
</h3>
<p>
Acompanhe a evolução dos seus investimentos.
</p>
</article>
</div>
</div>
</section>
</main>
<footer>
<div class="container footer">
<div>
<a
class="brand"
href="/"
>
<span class="mark">
F+
</span>
Finanças<span>+</span>
</a>
<p>
Conhecimento para decisões financeiras melhores.
</p>
</div>
<small>
© 2026 Finanças+.
</small>
</div>
</footer>
</body>
</html>
View File