Commit 4e566b9c5a2a3a6e51f56d4a4fb9a7db6b4d017f
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>
Showing
8 changed files
with
149 additions
and
13 deletions
Show diff stats
public_html/admins.php
| @@ -23,11 +23,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { | @@ -23,11 +23,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { | ||
| 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['REQUEST_METHOD'] === 'POST') { | @@ -36,18 +36,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { | ||
| 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['REQUEST_METHOD'] === 'POST') { | @@ -57,6 +57,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { | ||
| 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('Administradores'); | @@ -94,7 +110,21 @@ View::header('Administradores'); | ||
| 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('Administradores'); | @@ -106,8 +136,8 @@ View::header('Administradores'); | ||
| 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> |
| @@ -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['REQUEST_METHOD'] === 'POST') { | @@ -33,13 +33,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { | ||
| 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'); |
src/Auth.php
| @@ -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 |
src/Ldap.php
| @@ -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 | /** |
src/View.php
| @@ -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> |