Commit 4e566b9c5a2a3a6e51f56d4a4fb9a7db6b4d017f

Authored by Willen Borges Coelho
1 parent f7286917
Exists in main

Adiciona auditoria, STARTTLS no LDAP e melhorias na gestão de admins

- Dashboard: mostra nome completo (displayName) em vez do sobrenome
- Ldap: negocia STARTTLS automaticamente quando o servidor não é ldaps://,
  corrigindo "Server is unwilling to perform" ao trocar senha de aluno
- Admins: permite editar o papel de um administrador existente e renomeia
  "Remover" para "Desativar" (nunca existiu exclusão real, só desativação)
- Nova página Auditoria: lista logins, falhas de login, e toda alteração
  em alunos/admins/configurações, com paginação
- Auth: passa a registrar tentativas de login com senha incorreta também
  no audit_log, não só na tabela de rate limit
- Título e marca atualizados para "Gestão de Alunos Lab. Informática"

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
public_html/admins.php
@@ -23,11 +23,11 @@ if ($_SERVER[&#39;REQUEST_METHOD&#39;] === &#39;POST&#39;) { @@ -23,11 +23,11 @@ if ($_SERVER[&#39;REQUEST_METHOD&#39;] === &#39;POST&#39;) {
23 $ldapObj = new Ldap(); 23 $ldapObj = new Ldap();
24 $ldapObj->bindService(); 24 $ldapObj->bindService();
25 $entry = $ldapObj->findUserByLogin($login); 25 $entry = $ldapObj->findUserByLogin($login);
26 - $nome = $entry['displayname'][0] ?? null;  
27 26
28 if ($entry === null) { 27 if ($entry === null) {
29 $erro = 'Login não encontrado no AD. Confira e tente novamente.'; 28 $erro = 'Login não encontrado no AD. Confira e tente novamente.';
30 } else { 29 } else {
  30 + $nome = $entry['displayname'][0] ?? null;
31 AdminRepository::add($login, $nome, $papel, $quemFez); 31 AdminRepository::add($login, $nome, $papel, $quemFez);
32 AuditLog::record($quemFez, 'adicionou_admin', $login, "papel={$papel}"); 32 AuditLog::record($quemFez, 'adicionou_admin', $login, "papel={$papel}");
33 $ok = 'Administrador adicionado com sucesso.'; 33 $ok = 'Administrador adicionado com sucesso.';
@@ -36,18 +36,18 @@ if ($_SERVER[&#39;REQUEST_METHOD&#39;] === &#39;POST&#39;) { @@ -36,18 +36,18 @@ if ($_SERVER[&#39;REQUEST_METHOD&#39;] === &#39;POST&#39;) {
36 $erro = 'Erro ao consultar o AD: ' . $e->getMessage(); 36 $erro = 'Erro ao consultar o AD: ' . $e->getMessage();
37 } 37 }
38 } 38 }
39 - } elseif ($acao === 'remover') { 39 + } elseif ($acao === 'desativar') {
40 $id = (int) ($_POST['id'] ?? 0); 40 $id = (int) ($_POST['id'] ?? 0);
41 $admin = AdminRepository::find($id); 41 $admin = AdminRepository::find($id);
42 42
43 if (!$admin) { 43 if (!$admin) {
44 $erro = 'Administrador não encontrado.'; 44 $erro = 'Administrador não encontrado.';
45 } elseif ($admin['papel'] === 'admin' && AdminRepository::countActiveAdmins($id) === 0) { 45 } elseif ($admin['papel'] === 'admin' && AdminRepository::countActiveAdmins($id) === 0) {
46 - $erro = 'Não é possível remover o último administrador ativo do sistema.'; 46 + $erro = 'Não é possível desativar o último administrador ativo do sistema.';
47 } else { 47 } else {
48 AdminRepository::setActive($id, false); 48 AdminRepository::setActive($id, false);
49 - AuditLog::record($quemFez, 'removeu_admin', $admin['login']);  
50 - $ok = 'Administrador removido (desativado) com sucesso.'; 49 + AuditLog::record($quemFez, 'desativou_admin', $admin['login']);
  50 + $ok = 'Administrador desativado com sucesso.';
51 } 51 }
52 } elseif ($acao === 'reativar') { 52 } elseif ($acao === 'reativar') {
53 $id = (int) ($_POST['id'] ?? 0); 53 $id = (int) ($_POST['id'] ?? 0);
@@ -57,6 +57,22 @@ if ($_SERVER[&#39;REQUEST_METHOD&#39;] === &#39;POST&#39;) { @@ -57,6 +57,22 @@ if ($_SERVER[&#39;REQUEST_METHOD&#39;] === &#39;POST&#39;) {
57 AuditLog::record($quemFez, 'reativou_admin', $admin['login']); 57 AuditLog::record($quemFez, 'reativou_admin', $admin['login']);
58 $ok = 'Administrador reativado.'; 58 $ok = 'Administrador reativado.';
59 } 59 }
  60 + } elseif ($acao === 'atualizar_papel') {
  61 + $id = (int) ($_POST['id'] ?? 0);
  62 + $papel = ($_POST['papel'] ?? 'operador') === 'admin' ? 'admin' : 'operador';
  63 + $admin = AdminRepository::find($id);
  64 +
  65 + if (!$admin) {
  66 + $erro = 'Administrador não encontrado.';
  67 + } elseif ($admin['papel'] === 'admin' && $papel === 'operador' && AdminRepository::countActiveAdmins($id) === 0) {
  68 + $erro = 'Não é possível rebaixar o último administrador ativo do sistema.';
  69 + } elseif ($admin['papel'] === $papel) {
  70 + $ok = 'Nenhuma alteração: o papel já era esse.';
  71 + } else {
  72 + AdminRepository::setPapel($id, $papel);
  73 + AuditLog::record($quemFez, 'alterou_papel_admin', $admin['login'], "de={$admin['papel']} para={$papel}");
  74 + $ok = 'Papel atualizado com sucesso.';
  75 + }
60 } 76 }
61 } 77 }
62 78
@@ -94,7 +110,21 @@ View::header(&#39;Administradores&#39;); @@ -94,7 +110,21 @@ View::header(&#39;Administradores&#39;);
94 <tr> 110 <tr>
95 <td><?= View::escape($a['login']) ?></td> 111 <td><?= View::escape($a['login']) ?></td>
96 <td><?= View::escape($a['nome']) ?></td> 112 <td><?= View::escape($a['nome']) ?></td>
97 - <td><?= View::escape($a['papel']) ?></td> 113 + <td>
  114 + <?php if ($a['ativo']): ?>
  115 + <form method="post" action="admins.php" style="display:flex;gap:6px;align-items:center;margin:0">
  116 + <input type="hidden" name="acao" value="atualizar_papel">
  117 + <input type="hidden" name="id" value="<?= (int) $a['id'] ?>">
  118 + <select name="papel" style="margin:0">
  119 + <option value="operador" <?= $a['papel'] === 'operador' ? 'selected' : '' ?>>Operador</option>
  120 + <option value="admin" <?= $a['papel'] === 'admin' ? 'selected' : '' ?>>Administrador</option>
  121 + </select>
  122 + <button type="submit" class="secondary">Salvar</button>
  123 + </form>
  124 + <?php else: ?>
  125 + <?= View::escape($a['papel']) ?>
  126 + <?php endif; ?>
  127 + </td>
98 <td> 128 <td>
99 <?php if ($a['ativo']): ?> 129 <?php if ($a['ativo']): ?>
100 <span class="badge ok">Ativo</span> 130 <span class="badge ok">Ativo</span>
@@ -106,8 +136,8 @@ View::header(&#39;Administradores&#39;); @@ -106,8 +136,8 @@ View::header(&#39;Administradores&#39;);
106 <form method="post" action="admins.php" style="display:inline"> 136 <form method="post" action="admins.php" style="display:inline">
107 <input type="hidden" name="id" value="<?= (int) $a['id'] ?>"> 137 <input type="hidden" name="id" value="<?= (int) $a['id'] ?>">
108 <?php if ($a['ativo']): ?> 138 <?php if ($a['ativo']): ?>
109 - <input type="hidden" name="acao" value="remover">  
110 - <button type="submit" class="danger">Remover</button> 139 + <input type="hidden" name="acao" value="desativar">
  140 + <button type="submit" class="danger">Desativar</button>
111 <?php else: ?> 141 <?php else: ?>
112 <input type="hidden" name="acao" value="reativar"> 142 <input type="hidden" name="acao" value="reativar">
113 <button type="submit">Reativar</button> 143 <button type="submit">Reativar</button>
public_html/auditoria.php 0 → 100644
@@ -0,0 +1,86 @@ @@ -0,0 +1,86 @@
  1 +<?php
  2 +
  3 +declare(strict_types=1);
  4 +
  5 +require __DIR__ . '/../src/bootstrap.php';
  6 +Auth::requireAdmin();
  7 +
  8 +$perPage = 50;
  9 +$page = max(1, (int) ($_GET['p'] ?? 1));
  10 +$offset = ($page - 1) * $perPage;
  11 +
  12 +$total = (int) Database::pdo()->query('SELECT COUNT(*) FROM audit_log')->fetchColumn();
  13 +
  14 +$stmt = Database::pdo()->prepare(
  15 + 'SELECT * FROM audit_log ORDER BY criado_em DESC, id DESC LIMIT :limit OFFSET :offset'
  16 +);
  17 +$stmt->bindValue('limit', $perPage, PDO::PARAM_INT);
  18 +$stmt->bindValue('offset', $offset, PDO::PARAM_INT);
  19 +$stmt->execute();
  20 +$logs = $stmt->fetchAll();
  21 +
  22 +$totalPages = max(1, (int) ceil($total / $perPage));
  23 +
  24 +$acaoLabels = [
  25 + 'login' => 'Login',
  26 + 'login_negado' => 'Login negado (sem permissão)',
  27 + 'login_falhou' => 'Login falhou (usuário/senha)',
  28 + 'logout' => 'Logoff',
  29 + 'habilitou_aluno' => 'Habilitou aluno',
  30 + 'desabilitou_aluno' => 'Desabilitou aluno',
  31 + 'alterou_senha_aluno' => 'Alterou senha de aluno',
  32 + 'adicionou_admin' => 'Adicionou administrador',
  33 + 'desativou_admin' => 'Desativou administrador',
  34 + 'reativou_admin' => 'Reativou administrador',
  35 + 'alterou_papel_admin' => 'Alterou papel de administrador',
  36 + 'atualizou_settings' => 'Atualizou configurações',
  37 +];
  38 +
  39 +View::header('Auditoria');
  40 +?>
  41 +<div class="card">
  42 + <h1>Auditoria</h1>
  43 + <p style="color:#6b7280;font-size:13px">
  44 + Histórico de logins (inclusive tentativas com senha incorreta), alterações em
  45 + alunos, administradores e configurações. Mostrando <?= count($logs) ?> de <?= $total ?> registros.
  46 + </p>
  47 +
  48 + <table>
  49 + <tr>
  50 + <th>Data/hora</th>
  51 + <th>Usuário</th>
  52 + <th>Ação</th>
  53 + <th>Alvo</th>
  54 + <th>Detalhe</th>
  55 + <th>IP</th>
  56 + </tr>
  57 + <?php foreach ($logs as $log): ?>
  58 + <tr>
  59 + <td><?= View::escape($log['criado_em']) ?></td>
  60 + <td><?= View::escape($log['usuario']) ?></td>
  61 + <td><?= View::escape($acaoLabels[$log['acao']] ?? $log['acao']) ?></td>
  62 + <td><?= View::escape($log['alvo'] ?? '') ?></td>
  63 + <td><?= View::escape($log['detalhe'] ?? '') ?></td>
  64 + <td><?= View::escape($log['ip'] ?? '') ?></td>
  65 + </tr>
  66 + <?php endforeach; ?>
  67 + <?php if (!$logs): ?>
  68 + <tr><td colspan="6">Nenhum registro ainda.</td></tr>
  69 + <?php endif; ?>
  70 + </table>
  71 +
  72 + <?php if ($totalPages > 1): ?>
  73 + <p style="margin-top:12px">
  74 + <?php for ($p = 1; $p <= $totalPages; $p++): ?>
  75 + <?php if ($p === $page): ?>
  76 + <strong><?= $p ?></strong>
  77 + <?php else: ?>
  78 + <a href="auditoria.php?p=<?= $p ?>"><?= $p ?></a>
  79 + <?php endif; ?>
  80 + <?php if ($p < $totalPages): ?> | <?php endif; ?>
  81 + <?php endfor; ?>
  82 + </p>
  83 + <?php endif; ?>
  84 +</div>
  85 +<?php
  86 +View::footer();
public_html/dashboard.php
@@ -24,7 +24,7 @@ try { @@ -24,7 +24,7 @@ try {
24 $accountControl = (int) ($entry['useraccountcontrol'][0] ?? 0); 24 $accountControl = (int) ($entry['useraccountcontrol'][0] ?? 0);
25 $alunos[] = [ 25 $alunos[] = [
26 'cn' => $cn, 26 'cn' => $cn,
27 - 'nome' => $entry['sn'][0] ?? $cn, 27 + 'nome' => $entry['displayname'][0] ?? $entry['cn'][0] ?? $cn,
28 'habilitado' => ($accountControl & 2) !== 2, 28 'habilitado' => ($accountControl & 2) !== 2,
29 'descricao' => $entry['description'][0] ?? '', 29 'descricao' => $entry['description'][0] ?? '',
30 ]; 30 ];
public_html/index.php
@@ -33,13 +33,13 @@ if ($_SERVER[&#39;REQUEST_METHOD&#39;] === &#39;POST&#39;) { @@ -33,13 +33,13 @@ if ($_SERVER[&#39;REQUEST_METHOD&#39;] === &#39;POST&#39;) {
33 <html lang="pt-br"> 33 <html lang="pt-br">
34 <head> 34 <head>
35 <meta charset="utf-8"> 35 <meta charset="utf-8">
36 - <title>Gestão de Alunos - LabInfo</title> 36 + <title>Gestão de Alunos Lab. Informática</title>
37 <link rel="stylesheet" href="assets/style.css"> 37 <link rel="stylesheet" href="assets/style.css">
38 </head> 38 </head>
39 <body> 39 <body>
40 <div class="login-wrap"> 40 <div class="login-wrap">
41 <div class="card"> 41 <div class="card">
42 - <h1>Gestão de Alunos (AD)</h1> 42 + <h1>Gestão de Alunos Lab. Informática</h1>
43 <?php if ($erro): ?> 43 <?php if ($erro): ?>
44 <div class="alert error"><?= htmlspecialchars($erro, ENT_QUOTES) ?></div> 44 <div class="alert error"><?= htmlspecialchars($erro, ENT_QUOTES) ?></div>
45 <?php endif; ?> 45 <?php endif; ?>
src/AdminRepository.php
@@ -50,6 +50,12 @@ final class AdminRepository @@ -50,6 +50,12 @@ final class AdminRepository
50 $stmt->execute(['ativo' => $active ? 1 : 0, 'id' => $id]); 50 $stmt->execute(['ativo' => $active ? 1 : 0, 'id' => $id]);
51 } 51 }
52 52
  53 + public static function setPapel(int $id, string $papel): void
  54 + {
  55 + $stmt = Database::pdo()->prepare('UPDATE admins SET papel = :papel WHERE id = :id');
  56 + $stmt->execute(['papel' => $papel, 'id' => $id]);
  57 + }
  58 +
53 public static function find(int $id): ?array 59 public static function find(int $id): ?array
54 { 60 {
55 $stmt = Database::pdo()->prepare('SELECT * FROM admins WHERE id = :id'); 61 $stmt = Database::pdo()->prepare('SELECT * FROM admins WHERE id = :id');
@@ -34,6 +34,7 @@ final class Auth @@ -34,6 +34,7 @@ final class Auth
34 self::logAttempt($login, $ip, $bindOk); 34 self::logAttempt($login, $ip, $bindOk);
35 35
36 if (!$bindOk) { 36 if (!$bindOk) {
  37 + AuditLog::record($login, 'login_falhou', null, 'usuário ou senha incorretos');
37 return ['ok' => false, 'error' => 'Usuário ou senha incorretos.']; 38 return ['ok' => false, 'error' => 'Usuário ou senha incorretos.'];
38 } 39 }
39 40
@@ -19,6 +19,18 @@ final class Ldap @@ -19,6 +19,18 @@ final class Ldap
19 ldap_set_option($this->connect, LDAP_OPT_PROTOCOL_VERSION, 3); 19 ldap_set_option($this->connect, LDAP_OPT_PROTOCOL_VERSION, 3);
20 ldap_set_option($this->connect, LDAP_OPT_REFERRALS, 0); 20 ldap_set_option($this->connect, LDAP_OPT_REFERRALS, 0);
21 ldap_set_option($this->connect, LDAP_OPT_NETWORK_TIMEOUT, 5); 21 ldap_set_option($this->connect, LDAP_OPT_NETWORK_TIMEOUT, 5);
  22 +
  23 + // O AD recusa operações sensíveis (troca de senha via unicodePwd) em canal
  24 + // não criptografado ("Server is unwilling to perform"). Se o servidor não
  25 + // for ldaps://, negocia STARTTLS antes de qualquer bind.
  26 + if (!str_starts_with(strtolower($server), 'ldaps://')) {
  27 + if (!@ldap_start_tls($this->connect)) {
  28 + throw new LdapException(
  29 + 'Falha ao negociar STARTTLS com o LDAP: ' . ldap_error($this->connect) .
  30 + '. Troque ldap.server para ldaps:// no config/local.php se o servidor não suportar STARTTLS.'
  31 + );
  32 + }
  33 + }
22 } 34 }
23 35
24 /** 36 /**
@@ -8,17 +8,18 @@ final class View @@ -8,17 +8,18 @@ final class View
8 <html lang="pt-br"> 8 <html lang="pt-br">
9 <head> 9 <head>
10 <meta charset="utf-8"> 10 <meta charset="utf-8">
11 - <title><?= htmlspecialchars($title, ENT_QUOTES) ?> - LabInfo</title> 11 + <title><?= htmlspecialchars($title, ENT_QUOTES) ?> - Gestão de Alunos Lab. Informática</title>
12 <link rel="stylesheet" href="assets/style.css"> 12 <link rel="stylesheet" href="assets/style.css">
13 </head> 13 </head>
14 <body> 14 <body>
15 <div class="topbar"> 15 <div class="topbar">
16 - <strong>Gestão de Alunos (AD)</strong> 16 + <strong>Gestão de Alunos Lab. Informática</strong>
17 <nav> 17 <nav>
18 <span><?= htmlspecialchars(Auth::currentName() ?? '', ENT_QUOTES) ?></span> 18 <span><?= htmlspecialchars(Auth::currentName() ?? '', ENT_QUOTES) ?></span>
19 <a href="dashboard.php">Alunos</a> 19 <a href="dashboard.php">Alunos</a>
20 <?php if (Auth::isAdmin()): ?> 20 <?php if (Auth::isAdmin()): ?>
21 <a href="admins.php">Administradores</a> 21 <a href="admins.php">Administradores</a>
  22 + <a href="auditoria.php">Auditoria</a>
22 <a href="settings.php">Configurações</a> 23 <a href="settings.php">Configurações</a>
23 <?php endif; ?> 24 <?php endif; ?>
24 <a href="logout.php">Sair</a> 25 <a href="logout.php">Sair</a>