Commit f728691795a6ab624dd89bb8d30a907680680db6
0 parents
Exists in
main
Initial commit: sistema de gestão de alunos no AD
Substitui o painel embutido no WordPress por um sistema independente com controle de acesso gerenciado pelo próprio sistema (tabela admins no banco em vez de lista fixa de SIAPE no código), escaping de filtros LDAP, sessão segura e log de auditoria. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
21 changed files
with
1210 additions
and
0 deletions
Show diff stats
| 1 | +++ a/README.md | |
| ... | ... | @@ -0,0 +1,63 @@ |
| 1 | +# Gestão de Alunos (AD) - LabInfo | |
| 2 | + | |
| 3 | +Sistema independente (sem WordPress) para habilitar/desabilitar contas de aluno no AD | |
| 4 | +e resetar senha, com controle de acesso gerenciado pela própria aplicação. | |
| 5 | + | |
| 6 | +## Deploy no pandora | |
| 7 | + | |
| 8 | +1. No pandora, clonar o repositório (é privado — use uma chave SSH já autorizada | |
| 9 | + no GitLab, ou um deploy token) direto em `/home/labinfo/`, de forma que | |
| 10 | + `public_html/` deste projeto substitua (ou vire) o `/home/labinfo/public_html` do site, | |
| 11 | + e `src/`, `config/`, `storage/`, `sql/` fiquem um nível acima dele (fora da árvore | |
| 12 | + servida publicamente): | |
| 13 | + ```bash | |
| 14 | + git clone git@git.cachoeiro.ifes.edu.br:1652445/lab-info.git /home/labinfo/app | |
| 15 | + ``` | |
| 16 | + Depois ajuste os links/symlinks ou mova `public_html` conforme a estrutura real | |
| 17 | + do docroot do site. | |
| 18 | + | |
| 19 | +2. Criar as tabelas no banco: | |
| 20 | + ```bash | |
| 21 | + mysql -h <host> -u '<usuario_do_banco>' -p <nome_do_banco> < sql/schema.sql | |
| 22 | + ``` | |
| 23 | + | |
| 24 | +3. Criar `config/local.php` a partir de `config/config.example.php` preenchendo: | |
| 25 | + - `db.pass` com a senha gerada pelo Puppet (me repassa por fora do catálogo). | |
| 26 | + - `ldap.service_user` / `ldap.service_pass` com uma conta de serviço (peça delegação | |
| 27 | + restrita à OU/grupo de alunos — não domain admin). | |
| 28 | + - `app.session_secret` com `bin2hex(random_bytes(32))`. | |
| 29 | + | |
| 30 | +4. Garantir que `storage/` tem permissão de escrita pelo usuário do PHP-FPM do site | |
| 31 | + (para `audit.log`), e que `config/local.php` está fora do alcance público | |
| 32 | + (confirmar que `.php` nunca é servido como texto puro — padrão do PHP já cobre isso). | |
| 33 | + | |
| 34 | +5. Cadastrar o primeiro administrador direto no banco (seed), já que a tela de | |
| 35 | + administradores exige estar logado como admin: | |
| 36 | + ```sql | |
| 37 | + INSERT INTO admins (login, papel, criado_por) VALUES ('<seu-login-do-ad>', 'admin', 'seed'); | |
| 38 | + ``` | |
| 39 | + | |
| 40 | +6. Acessar `https://labinfo.cachoeiro.ifes.edu.br/`, logar com usuário/senha do AD | |
| 41 | + e confirmar que o dashboard carrega os alunos do grupo configurado. | |
| 42 | + | |
| 43 | +## Estrutura | |
| 44 | + | |
| 45 | +- `public_html/` — document root (único diretório exposto na web). | |
| 46 | +- `src/` — classes PHP (LDAP, Auth, repositórios, log). | |
| 47 | +- `config/` — config.example.php versionado; `local.php` (real) nunca versionado. | |
| 48 | +- `sql/schema.sql` — schema do banco (admins, settings, audit_log, login_attempts). | |
| 49 | +- `storage/` — log de auditoria em arquivo (complementar ao banco). | |
| 50 | + | |
| 51 | +## Papéis | |
| 52 | + | |
| 53 | +- **admin**: tudo que operador pode, mais gerenciar `admins.php` e `settings.php`. | |
| 54 | +- **operador**: só habilita/desabilita aluno e reseta senha no `dashboard.php`. | |
| 55 | + | |
| 56 | +A autenticação de quem usa o sistema é sempre feita com login e senha do próprio AD | |
| 57 | +(bind LDAP) — o banco **não armazena nenhuma senha**. A tabela `admins` só guarda | |
| 58 | +quem está autorizado a entrar e com qual papel (login, nome, papel), funcionando como | |
| 59 | +uma lista de permissão: se o login/senha do AD estiverem corretos mas o login não | |
| 60 | +estiver cadastrado (e ativo) em `admins`, o acesso ao sistema é negado mesmo assim. | |
| 61 | + | |
| 62 | +Não existe mais lista de SIAPE fixa no código — todo acesso é via tabela `admins`, | |
| 63 | +editável pela tela (exceto o primeiro admin, que precisa de uma inserção manual inicial). | ... | ... |
| 1 | +++ a/config/config.example.php | |
| ... | ... | @@ -0,0 +1,35 @@ |
| 1 | +<?php | |
| 2 | +/** | |
| 3 | + * Copie este arquivo para config/local.php e preencha os valores reais. | |
| 4 | + * config/local.php NUNCA deve ser versionado (está no .gitignore). | |
| 5 | + */ | |
| 6 | + | |
| 7 | +return [ | |
| 8 | + 'ldap' => [ | |
| 9 | + 'server' => 'ldap://ad.example.org', | |
| 10 | + 'base_dn' => 'OU=Example OU,DC=example,DC=org', | |
| 11 | + 'domain_suffix' => '@example.org', // usado para montar o login de bind: <usuario>@example.org | |
| 12 | + 'group_prof' => 'CN=grp-example-prof,OU=Groups,OU=Example OU,DC=example,DC=org', | |
| 13 | + 'group_aluno' => 'cn=grp-example-aluno', | |
| 14 | + 'filter_prof' => 'cn=grp-example-prof', | |
| 15 | + // Conta de serviço usada para buscas e para as alterações (habilitar/desabilitar, | |
| 16 | + // resetar senha) na OU de alunos. Peça ao pessoal de AD uma conta com delegação | |
| 17 | + // restrita só a essa OU/grupo — nunca uma conta de domain admin. | |
| 18 | + 'service_user' => 'CHANGE_ME', | |
| 19 | + 'service_pass' => 'CHANGE_ME', | |
| 20 | + ], | |
| 21 | + | |
| 22 | + 'db' => [ | |
| 23 | + 'host' => 'CHANGE_ME', | |
| 24 | + 'name' => 'CHANGE_ME', | |
| 25 | + 'user' => 'CHANGE_ME', | |
| 26 | + 'pass' => 'CHANGE_ME', | |
| 27 | + ], | |
| 28 | + | |
| 29 | + 'app' => [ | |
| 30 | + // Gere com: bin2hex(random_bytes(32)) | |
| 31 | + 'session_name' => 'app_session', | |
| 32 | + 'session_secret' => 'CHANGE_ME', | |
| 33 | + 'log_file' => __DIR__ . '/../storage/audit.log', | |
| 34 | + ], | |
| 35 | +]; | ... | ... |
| 1 | +++ a/public_html/action_status.php | |
| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | +<?php | |
| 2 | + | |
| 3 | +declare(strict_types=1); | |
| 4 | + | |
| 5 | +require __DIR__ . '/../src/bootstrap.php'; | |
| 6 | +Auth::requireLogin(); | |
| 7 | + | |
| 8 | +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { | |
| 9 | + header('Location: dashboard.php'); | |
| 10 | + exit; | |
| 11 | +} | |
| 12 | + | |
| 13 | +$user = trim((string) ($_POST['user'] ?? '')); | |
| 14 | +$enable = ($_POST['enable'] ?? '') === '1'; | |
| 15 | + | |
| 16 | +if ($user === '') { | |
| 17 | + header('Location: dashboard.php?erro=1&msg=' . urlencode('Usuário inválido.')); | |
| 18 | + exit; | |
| 19 | +} | |
| 20 | + | |
| 21 | +try { | |
| 22 | + $ldap = new Ldap(); | |
| 23 | + $ldap->bindService(); | |
| 24 | + | |
| 25 | + $entry = $ldap->findUserByCn($user); | |
| 26 | + if ($entry === null) { | |
| 27 | + throw new RuntimeException('Aluno não encontrado no AD.'); | |
| 28 | + } | |
| 29 | + | |
| 30 | + $dn = $ldap->dnOf($entry); | |
| 31 | + $accountControl = (int) ($entry['useraccountcontrol'][0] ?? 0); | |
| 32 | + $new = $enable ? ($accountControl & ~2) : ($accountControl | 2); | |
| 33 | + | |
| 34 | + $quemFez = Auth::currentLogin() ?? 'desconhecido'; | |
| 35 | + $ldap->modify($dn, [ | |
| 36 | + 'useraccountcontrol' => $new, | |
| 37 | + 'description' => $quemFez, | |
| 38 | + ]); | |
| 39 | + | |
| 40 | + AuditLog::record($quemFez, $enable ? 'habilitou_aluno' : 'desabilitou_aluno', $user); | |
| 41 | + | |
| 42 | + header('Location: dashboard.php?ok=1'); | |
| 43 | + exit; | |
| 44 | +} catch (Throwable $e) { | |
| 45 | + header('Location: dashboard.php?erro=1&msg=' . urlencode($e->getMessage())); | |
| 46 | + exit; | |
| 47 | +} | ... | ... |
| 1 | +++ a/public_html/admins.php | |
| ... | ... | @@ -0,0 +1,122 @@ |
| 1 | +<?php | |
| 2 | + | |
| 3 | +declare(strict_types=1); | |
| 4 | + | |
| 5 | +require __DIR__ . '/../src/bootstrap.php'; | |
| 6 | +Auth::requireAdmin(); | |
| 7 | + | |
| 8 | +$erro = null; | |
| 9 | +$ok = null; | |
| 10 | +$quemFez = Auth::currentLogin() ?? 'desconhecido'; | |
| 11 | + | |
| 12 | +if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 13 | + $acao = $_POST['acao'] ?? ''; | |
| 14 | + | |
| 15 | + if ($acao === 'adicionar') { | |
| 16 | + $login = trim((string) ($_POST['login'] ?? '')); | |
| 17 | + $papel = ($_POST['papel'] ?? 'operador') === 'admin' ? 'admin' : 'operador'; | |
| 18 | + | |
| 19 | + if ($login === '') { | |
| 20 | + $erro = 'Informe o login.'; | |
| 21 | + } else { | |
| 22 | + try { | |
| 23 | + $ldapObj = new Ldap(); | |
| 24 | + $ldapObj->bindService(); | |
| 25 | + $entry = $ldapObj->findUserByLogin($login); | |
| 26 | + $nome = $entry['displayname'][0] ?? null; | |
| 27 | + | |
| 28 | + if ($entry === null) { | |
| 29 | + $erro = 'Login não encontrado no AD. Confira e tente novamente.'; | |
| 30 | + } else { | |
| 31 | + AdminRepository::add($login, $nome, $papel, $quemFez); | |
| 32 | + AuditLog::record($quemFez, 'adicionou_admin', $login, "papel={$papel}"); | |
| 33 | + $ok = 'Administrador adicionado com sucesso.'; | |
| 34 | + } | |
| 35 | + } catch (Throwable $e) { | |
| 36 | + $erro = 'Erro ao consultar o AD: ' . $e->getMessage(); | |
| 37 | + } | |
| 38 | + } | |
| 39 | + } elseif ($acao === 'remover') { | |
| 40 | + $id = (int) ($_POST['id'] ?? 0); | |
| 41 | + $admin = AdminRepository::find($id); | |
| 42 | + | |
| 43 | + if (!$admin) { | |
| 44 | + $erro = 'Administrador não encontrado.'; | |
| 45 | + } elseif ($admin['papel'] === 'admin' && AdminRepository::countActiveAdmins($id) === 0) { | |
| 46 | + $erro = 'Não é possível remover o último administrador ativo do sistema.'; | |
| 47 | + } else { | |
| 48 | + AdminRepository::setActive($id, false); | |
| 49 | + AuditLog::record($quemFez, 'removeu_admin', $admin['login']); | |
| 50 | + $ok = 'Administrador removido (desativado) com sucesso.'; | |
| 51 | + } | |
| 52 | + } elseif ($acao === 'reativar') { | |
| 53 | + $id = (int) ($_POST['id'] ?? 0); | |
| 54 | + $admin = AdminRepository::find($id); | |
| 55 | + if ($admin) { | |
| 56 | + AdminRepository::setActive($id, true); | |
| 57 | + AuditLog::record($quemFez, 'reativou_admin', $admin['login']); | |
| 58 | + $ok = 'Administrador reativado.'; | |
| 59 | + } | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +$admins = AdminRepository::all(); | |
| 64 | + | |
| 65 | +View::header('Administradores'); | |
| 66 | +?> | |
| 67 | +<div class="card"> | |
| 68 | + <h1>Administradores do sistema</h1> | |
| 69 | + | |
| 70 | + <?php if ($erro): ?><div class="alert error"><?= View::escape($erro) ?></div><?php endif; ?> | |
| 71 | + <?php if ($ok): ?><div class="alert success"><?= View::escape($ok) ?></div><?php endif; ?> | |
| 72 | + | |
| 73 | + <form method="post" action="admins.php"> | |
| 74 | + <input type="hidden" name="acao" value="adicionar"> | |
| 75 | + <label for="login">Login do AD (sem o domínio <?= View::escape(Config::get('ldap.domain_suffix', '')) ?>)</label> | |
| 76 | + <input type="text" id="login" name="login" required> | |
| 77 | + <label for="papel">Papel</label> | |
| 78 | + <select id="papel" name="papel"> | |
| 79 | + <option value="operador">Operador (só habilita/desabilita e reseta senha)</option> | |
| 80 | + <option value="admin">Administrador (também gerencia quem tem acesso)</option> | |
| 81 | + </select> | |
| 82 | + <button type="submit">Adicionar</button> | |
| 83 | + </form> | |
| 84 | + | |
| 85 | + <table> | |
| 86 | + <tr> | |
| 87 | + <th>Login</th> | |
| 88 | + <th>Nome</th> | |
| 89 | + <th>Papel</th> | |
| 90 | + <th>Status</th> | |
| 91 | + <th>Ações</th> | |
| 92 | + </tr> | |
| 93 | + <?php foreach ($admins as $a): ?> | |
| 94 | + <tr> | |
| 95 | + <td><?= View::escape($a['login']) ?></td> | |
| 96 | + <td><?= View::escape($a['nome']) ?></td> | |
| 97 | + <td><?= View::escape($a['papel']) ?></td> | |
| 98 | + <td> | |
| 99 | + <?php if ($a['ativo']): ?> | |
| 100 | + <span class="badge ok">Ativo</span> | |
| 101 | + <?php else: ?> | |
| 102 | + <span class="badge off">Inativo</span> | |
| 103 | + <?php endif; ?> | |
| 104 | + </td> | |
| 105 | + <td> | |
| 106 | + <form method="post" action="admins.php" style="display:inline"> | |
| 107 | + <input type="hidden" name="id" value="<?= (int) $a['id'] ?>"> | |
| 108 | + <?php if ($a['ativo']): ?> | |
| 109 | + <input type="hidden" name="acao" value="remover"> | |
| 110 | + <button type="submit" class="danger">Remover</button> | |
| 111 | + <?php else: ?> | |
| 112 | + <input type="hidden" name="acao" value="reativar"> | |
| 113 | + <button type="submit">Reativar</button> | |
| 114 | + <?php endif; ?> | |
| 115 | + </form> | |
| 116 | + </td> | |
| 117 | + </tr> | |
| 118 | + <?php endforeach; ?> | |
| 119 | + </table> | |
| 120 | +</div> | |
| 121 | +<?php | |
| 122 | +View::footer(); | ... | ... |
| 1 | +++ a/public_html/assets/style.css | |
| ... | ... | @@ -0,0 +1,101 @@ |
| 1 | +:root { | |
| 2 | + color-scheme: light; | |
| 3 | + --bg: #f4f5f7; | |
| 4 | + --card: #ffffff; | |
| 5 | + --text: #1f2430; | |
| 6 | + --muted: #6b7280; | |
| 7 | + --border: #e2e4e9; | |
| 8 | + --accent: #2563eb; | |
| 9 | + --accent-contrast: #ffffff; | |
| 10 | + --danger: #dc2626; | |
| 11 | + --success: #16a34a; | |
| 12 | +} | |
| 13 | + | |
| 14 | +* { box-sizing: border-box; } | |
| 15 | + | |
| 16 | +body { | |
| 17 | + margin: 0; | |
| 18 | + font-family: -apple-system, Segoe UI, Roboto, Arial, sans-serif; | |
| 19 | + background: var(--bg); | |
| 20 | + color: var(--text); | |
| 21 | +} | |
| 22 | + | |
| 23 | +.topbar { | |
| 24 | + display: flex; | |
| 25 | + justify-content: space-between; | |
| 26 | + align-items: center; | |
| 27 | + padding: 14px 24px; | |
| 28 | + background: var(--card); | |
| 29 | + border-bottom: 1px solid var(--border); | |
| 30 | +} | |
| 31 | + | |
| 32 | +.topbar nav a { | |
| 33 | + margin-left: 16px; | |
| 34 | + color: var(--text); | |
| 35 | + text-decoration: none; | |
| 36 | + font-size: 14px; | |
| 37 | +} | |
| 38 | + | |
| 39 | +.topbar nav a:hover { color: var(--accent); } | |
| 40 | + | |
| 41 | +.container { | |
| 42 | + max-width: 960px; | |
| 43 | + margin: 32px auto; | |
| 44 | + padding: 0 16px; | |
| 45 | +} | |
| 46 | + | |
| 47 | +.card { | |
| 48 | + background: var(--card); | |
| 49 | + border: 1px solid var(--border); | |
| 50 | + border-radius: 10px; | |
| 51 | + padding: 24px; | |
| 52 | +} | |
| 53 | + | |
| 54 | +.login-wrap { | |
| 55 | + display: flex; | |
| 56 | + min-height: 100vh; | |
| 57 | + align-items: center; | |
| 58 | + justify-content: center; | |
| 59 | +} | |
| 60 | + | |
| 61 | +.login-wrap .card { width: 100%; max-width: 360px; } | |
| 62 | + | |
| 63 | +h1 { font-size: 20px; margin: 0 0 16px; } | |
| 64 | + | |
| 65 | +label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; } | |
| 66 | + | |
| 67 | +input[type=text], input[type=password], select { | |
| 68 | + width: 100%; | |
| 69 | + padding: 8px 10px; | |
| 70 | + margin-bottom: 14px; | |
| 71 | + border: 1px solid var(--border); | |
| 72 | + border-radius: 6px; | |
| 73 | + font-size: 14px; | |
| 74 | +} | |
| 75 | + | |
| 76 | +button, .btn { | |
| 77 | + display: inline-block; | |
| 78 | + background: var(--accent); | |
| 79 | + color: var(--accent-contrast); | |
| 80 | + border: none; | |
| 81 | + padding: 8px 14px; | |
| 82 | + border-radius: 6px; | |
| 83 | + font-size: 14px; | |
| 84 | + cursor: pointer; | |
| 85 | + text-decoration: none; | |
| 86 | +} | |
| 87 | + | |
| 88 | +button.danger, .btn.danger { background: var(--danger); } | |
| 89 | +button.secondary, .btn.secondary { background: var(--muted); } | |
| 90 | + | |
| 91 | +table { width: 100%; border-collapse: collapse; margin-top: 12px; } | |
| 92 | +th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); font-size: 14px; } | |
| 93 | +th { color: var(--muted); font-weight: 600; } | |
| 94 | + | |
| 95 | +.badge { padding: 2px 8px; border-radius: 999px; font-size: 12px; } | |
| 96 | +.badge.ok { background: #dcfce7; color: var(--success); } | |
| 97 | +.badge.off { background: #fee2e2; color: var(--danger); } | |
| 98 | + | |
| 99 | +.alert { padding: 10px 14px; border-radius: 6px; margin-bottom: 16px; font-size: 14px; } | |
| 100 | +.alert.error { background: #fee2e2; color: var(--danger); } | |
| 101 | +.alert.success { background: #dcfce7; color: var(--success); } | ... | ... |
| 1 | +++ a/public_html/dashboard.php | |
| ... | ... | @@ -0,0 +1,91 @@ |
| 1 | +<?php | |
| 2 | + | |
| 3 | +declare(strict_types=1); | |
| 4 | + | |
| 5 | +require __DIR__ . '/../src/bootstrap.php'; | |
| 6 | +Auth::requireLogin(); | |
| 7 | + | |
| 8 | +$erro = null; | |
| 9 | +$ok = null; | |
| 10 | + | |
| 11 | +try { | |
| 12 | + $ldap = new Ldap(); | |
| 13 | + $ldap->bindService(); | |
| 14 | + | |
| 15 | + $groupFilter = SettingsRepository::get('group_aluno_filter', 'cn=grpCI-Aluno-Info'); | |
| 16 | + $logins = $ldap->groupMembers($groupFilter); | |
| 17 | + | |
| 18 | + $alunos = []; | |
| 19 | + foreach ($logins as $cn) { | |
| 20 | + $entry = $ldap->findUserByCn($cn); | |
| 21 | + if ($entry === null) { | |
| 22 | + continue; | |
| 23 | + } | |
| 24 | + $accountControl = (int) ($entry['useraccountcontrol'][0] ?? 0); | |
| 25 | + $alunos[] = [ | |
| 26 | + 'cn' => $cn, | |
| 27 | + 'nome' => $entry['sn'][0] ?? $cn, | |
| 28 | + 'habilitado' => ($accountControl & 2) !== 2, | |
| 29 | + 'descricao' => $entry['description'][0] ?? '', | |
| 30 | + ]; | |
| 31 | + } | |
| 32 | +} catch (Throwable $e) { | |
| 33 | + $erro = 'Não foi possível consultar o AD agora: ' . $e->getMessage(); | |
| 34 | + $alunos = []; | |
| 35 | +} | |
| 36 | + | |
| 37 | +if (($_GET['ok'] ?? '') === '1') { | |
| 38 | + $ok = 'Ação realizada com sucesso.'; | |
| 39 | +} elseif (($_GET['erro'] ?? '') === '1') { | |
| 40 | + $erro = $_GET['msg'] ?? 'Ocorreu um erro ao processar a ação.'; | |
| 41 | +} | |
| 42 | + | |
| 43 | +View::header('Alunos'); | |
| 44 | +?> | |
| 45 | +<div class="card"> | |
| 46 | + <h1>Alunos do grupo</h1> | |
| 47 | + | |
| 48 | + <?php if ($erro): ?> | |
| 49 | + <div class="alert error"><?= View::escape($erro) ?></div> | |
| 50 | + <?php endif; ?> | |
| 51 | + <?php if ($ok): ?> | |
| 52 | + <div class="alert success"><?= View::escape($ok) ?></div> | |
| 53 | + <?php endif; ?> | |
| 54 | + | |
| 55 | + <table> | |
| 56 | + <tr> | |
| 57 | + <th>Aluno</th> | |
| 58 | + <th>Status</th> | |
| 59 | + <th>Última alteração por</th> | |
| 60 | + <th>Ações</th> | |
| 61 | + </tr> | |
| 62 | + <?php foreach ($alunos as $aluno): ?> | |
| 63 | + <tr> | |
| 64 | + <td><?= View::escape($aluno['nome']) ?></td> | |
| 65 | + <td> | |
| 66 | + <?php if ($aluno['habilitado']): ?> | |
| 67 | + <span class="badge ok">Habilitado</span> | |
| 68 | + <?php else: ?> | |
| 69 | + <span class="badge off">Desabilitado</span> | |
| 70 | + <?php endif; ?> | |
| 71 | + </td> | |
| 72 | + <td><?= View::escape($aluno['descricao']) ?></td> | |
| 73 | + <td> | |
| 74 | + <form method="post" action="action_status.php" style="display:inline"> | |
| 75 | + <input type="hidden" name="user" value="<?= View::escape($aluno['cn']) ?>"> | |
| 76 | + <input type="hidden" name="enable" value="<?= $aluno['habilitado'] ? '0' : '1' ?>"> | |
| 77 | + <button type="submit" class="<?= $aluno['habilitado'] ? 'danger' : '' ?>"> | |
| 78 | + <?= $aluno['habilitado'] ? 'Desabilitar' : 'Habilitar' ?> | |
| 79 | + </button> | |
| 80 | + </form> | |
| 81 | + <a class="btn secondary" href="newpasswd.php?user=<?= urlencode($aluno['cn']) ?>">Nova senha</a> | |
| 82 | + </td> | |
| 83 | + </tr> | |
| 84 | + <?php endforeach; ?> | |
| 85 | + <?php if (!$alunos && !$erro): ?> | |
| 86 | + <tr><td colspan="4">Nenhum aluno encontrado no grupo.</td></tr> | |
| 87 | + <?php endif; ?> | |
| 88 | + </table> | |
| 89 | +</div> | |
| 90 | +<?php | |
| 91 | +View::footer(); | ... | ... |
| 1 | +++ a/public_html/index.php | |
| ... | ... | @@ -0,0 +1,58 @@ |
| 1 | +<?php | |
| 2 | + | |
| 3 | +declare(strict_types=1); | |
| 4 | + | |
| 5 | +require __DIR__ . '/../src/bootstrap.php'; | |
| 6 | + | |
| 7 | +if (!empty($_SESSION['login'])) { | |
| 8 | + header('Location: dashboard.php'); | |
| 9 | + exit; | |
| 10 | +} | |
| 11 | + | |
| 12 | +$erro = null; | |
| 13 | + | |
| 14 | +if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 15 | + $login = trim((string) ($_POST['login'] ?? '')); | |
| 16 | + $senha = (string) ($_POST['senha'] ?? ''); | |
| 17 | + | |
| 18 | + if ($login === '' || $senha === '') { | |
| 19 | + $erro = 'Preencha login e senha.'; | |
| 20 | + } else { | |
| 21 | + $result = Auth::attempt($login, $senha); | |
| 22 | + if ($result['ok']) { | |
| 23 | + header('Location: dashboard.php'); | |
| 24 | + exit; | |
| 25 | + } | |
| 26 | + $erro = $result['error']; | |
| 27 | + } | |
| 28 | +} elseif (($_GET['erro'] ?? '') === 'sessao_expirada') { | |
| 29 | + $erro = 'Sessão expirada. Faça login novamente.'; | |
| 30 | +} | |
| 31 | +?> | |
| 32 | +<!doctype html> | |
| 33 | +<html lang="pt-br"> | |
| 34 | +<head> | |
| 35 | + <meta charset="utf-8"> | |
| 36 | + <title>Gestão de Alunos - LabInfo</title> | |
| 37 | + <link rel="stylesheet" href="assets/style.css"> | |
| 38 | +</head> | |
| 39 | +<body> | |
| 40 | + <div class="login-wrap"> | |
| 41 | + <div class="card"> | |
| 42 | + <h1>Gestão de Alunos (AD)</h1> | |
| 43 | + <?php if ($erro): ?> | |
| 44 | + <div class="alert error"><?= htmlspecialchars($erro, ENT_QUOTES) ?></div> | |
| 45 | + <?php endif; ?> | |
| 46 | + <form method="post" action="index.php"> | |
| 47 | + <label for="login">Login</label> | |
| 48 | + <input type="text" id="login" name="login" autocomplete="username" required autofocus> | |
| 49 | + | |
| 50 | + <label for="senha">Senha</label> | |
| 51 | + <input type="password" id="senha" name="senha" autocomplete="current-password" required> | |
| 52 | + | |
| 53 | + <button type="submit">Entrar</button> | |
| 54 | + </form> | |
| 55 | + </div> | |
| 56 | + </div> | |
| 57 | +</body> | |
| 58 | +</html> | ... | ... |
| 1 | +++ a/public_html/newpasswd.php | |
| ... | ... | @@ -0,0 +1,68 @@ |
| 1 | +<?php | |
| 2 | + | |
| 3 | +declare(strict_types=1); | |
| 4 | + | |
| 5 | +require __DIR__ . '/../src/bootstrap.php'; | |
| 6 | +Auth::requireLogin(); | |
| 7 | + | |
| 8 | +$user = trim((string) ($_GET['user'] ?? $_POST['user'] ?? '')); | |
| 9 | +$erro = null; | |
| 10 | +$ok = null; | |
| 11 | + | |
| 12 | +if ($user === '') { | |
| 13 | + $erro = 'Usuário inválido.'; | |
| 14 | +} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 15 | + $pass1 = (string) ($_POST['passwd1'] ?? ''); | |
| 16 | + $pass2 = (string) ($_POST['passwd2'] ?? ''); | |
| 17 | + | |
| 18 | + if ($pass1 === '' || $pass1 !== $pass2) { | |
| 19 | + $erro = 'As senhas não coincidem ou estão em branco.'; | |
| 20 | + } elseif (strlen($pass1) < 8) { | |
| 21 | + $erro = 'A senha precisa ter pelo menos 8 caracteres.'; | |
| 22 | + } else { | |
| 23 | + try { | |
| 24 | + $ldap = new Ldap(); | |
| 25 | + $ldap->bindService(); | |
| 26 | + | |
| 27 | + $entry = $ldap->findUserByCn($user); | |
| 28 | + if ($entry === null) { | |
| 29 | + throw new RuntimeException('Aluno não encontrado no AD.'); | |
| 30 | + } | |
| 31 | + $dn = $ldap->dnOf($entry); | |
| 32 | + $ldap->modify($dn, [ | |
| 33 | + 'unicodePwd' => Ldap::encodePassword($pass1), | |
| 34 | + ]); | |
| 35 | + | |
| 36 | + $quemFez = Auth::currentLogin() ?? 'desconhecido'; | |
| 37 | + AuditLog::record($quemFez, 'alterou_senha_aluno', $user); | |
| 38 | + | |
| 39 | + $ok = 'Senha alterada com sucesso!'; | |
| 40 | + } catch (Throwable $e) { | |
| 41 | + $erro = 'Erro ao alterar a senha: ' . $e->getMessage(); | |
| 42 | + } | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +View::header('Nova senha'); | |
| 47 | +?> | |
| 48 | +<div class="card"> | |
| 49 | + <h1>Nova senha para <?= View::escape($user) ?></h1> | |
| 50 | + | |
| 51 | + <?php if ($erro): ?><div class="alert error"><?= View::escape($erro) ?></div><?php endif; ?> | |
| 52 | + <?php if ($ok): ?><div class="alert success"><?= View::escape($ok) ?></div><?php endif; ?> | |
| 53 | + | |
| 54 | + <?php if (!$ok): ?> | |
| 55 | + <form method="post" action="newpasswd.php"> | |
| 56 | + <input type="hidden" name="user" value="<?= View::escape($user) ?>"> | |
| 57 | + <label for="passwd1">Nova senha</label> | |
| 58 | + <input type="password" id="passwd1" name="passwd1" required minlength="8"> | |
| 59 | + <label for="passwd2">Confirmar senha</label> | |
| 60 | + <input type="password" id="passwd2" name="passwd2" required minlength="8"> | |
| 61 | + <button type="submit">Alterar</button> | |
| 62 | + </form> | |
| 63 | + <?php endif; ?> | |
| 64 | + | |
| 65 | + <p><a href="dashboard.php">← Voltar</a></p> | |
| 66 | +</div> | |
| 67 | +<?php | |
| 68 | +View::footer(); | ... | ... |
| 1 | +++ a/public_html/settings.php | |
| ... | ... | @@ -0,0 +1,45 @@ |
| 1 | +<?php | |
| 2 | + | |
| 3 | +declare(strict_types=1); | |
| 4 | + | |
| 5 | +require __DIR__ . '/../src/bootstrap.php'; | |
| 6 | +Auth::requireAdmin(); | |
| 7 | + | |
| 8 | +$erro = null; | |
| 9 | +$ok = null; | |
| 10 | + | |
| 11 | +if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 12 | + $groupFilter = trim((string) ($_POST['group_aluno_filter'] ?? '')); | |
| 13 | + if ($groupFilter === '') { | |
| 14 | + $erro = 'Informe o filtro/CN do grupo de alunos.'; | |
| 15 | + } else { | |
| 16 | + SettingsRepository::set('group_aluno_filter', $groupFilter, Auth::currentLogin() ?? 'desconhecido'); | |
| 17 | + AuditLog::record(Auth::currentLogin() ?? 'desconhecido', 'atualizou_settings', 'group_aluno_filter', $groupFilter); | |
| 18 | + $ok = 'Configurações salvas.'; | |
| 19 | + } | |
| 20 | +} | |
| 21 | + | |
| 22 | +$settings = SettingsRepository::all(); | |
| 23 | + | |
| 24 | +View::header('Configurações'); | |
| 25 | +?> | |
| 26 | +<div class="card"> | |
| 27 | + <h1>Configurações</h1> | |
| 28 | + | |
| 29 | + <?php if ($erro): ?><div class="alert error"><?= View::escape($erro) ?></div><?php endif; ?> | |
| 30 | + <?php if ($ok): ?><div class="alert success"><?= View::escape($ok) ?></div><?php endif; ?> | |
| 31 | + | |
| 32 | + <form method="post" action="settings.php"> | |
| 33 | + <label for="group_aluno_filter">Grupo (CN) de alunos monitorado no dashboard</label> | |
| 34 | + <input type="text" id="group_aluno_filter" name="group_aluno_filter" | |
| 35 | + value="<?= View::escape($settings['group_aluno_filter'] ?? '') ?>" required> | |
| 36 | + <button type="submit">Salvar</button> | |
| 37 | + </form> | |
| 38 | + | |
| 39 | + <p style="color:#6b7280;font-size:13px"> | |
| 40 | + As demais configurações de conexão (servidor LDAP, conta de serviço, credenciais de banco) | |
| 41 | + ficam em <code>config/local.php</code>, fora do versionamento, por segurança. | |
| 42 | + </p> | |
| 43 | +</div> | |
| 44 | +<?php | |
| 45 | +View::footer(); | ... | ... |
| 1 | +++ a/sql/schema.sql | |
| ... | ... | @@ -0,0 +1,36 @@ |
| 1 | +CREATE TABLE IF NOT EXISTS admins ( | |
| 2 | + id INT AUTO_INCREMENT PRIMARY KEY, | |
| 3 | + login VARCHAR(64) NOT NULL UNIQUE, -- login do AD, sem domínio (ex: jsilva) | |
| 4 | + nome VARCHAR(190) NULL, -- cache do displayName, atualizado no login | |
| 5 | + papel ENUM('admin','operador') NOT NULL DEFAULT 'operador', | |
| 6 | + ativo TINYINT(1) NOT NULL DEFAULT 1, | |
| 7 | + criado_por VARCHAR(64) NULL, | |
| 8 | + criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 9 | + atualizado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | |
| 10 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; | |
| 11 | + | |
| 12 | +CREATE TABLE IF NOT EXISTS settings ( | |
| 13 | + chave VARCHAR(64) PRIMARY KEY, | |
| 14 | + valor TEXT NOT NULL, | |
| 15 | + atualizado_por VARCHAR(64) NULL, | |
| 16 | + atualizado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | |
| 17 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; | |
| 18 | + | |
| 19 | +CREATE TABLE IF NOT EXISTS audit_log ( | |
| 20 | + id INT AUTO_INCREMENT PRIMARY KEY, | |
| 21 | + usuario VARCHAR(64) NOT NULL, | |
| 22 | + acao VARCHAR(64) NOT NULL, | |
| 23 | + alvo VARCHAR(190) NULL, | |
| 24 | + detalhe VARCHAR(255) NULL, | |
| 25 | + ip VARCHAR(45) NULL, | |
| 26 | + criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| 27 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; | |
| 28 | + | |
| 29 | +CREATE TABLE IF NOT EXISTS login_attempts ( | |
| 30 | + id INT AUTO_INCREMENT PRIMARY KEY, | |
| 31 | + login VARCHAR(64) NOT NULL, | |
| 32 | + ip VARCHAR(45) NOT NULL, | |
| 33 | + sucesso TINYINT(1) NOT NULL, | |
| 34 | + criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 35 | + INDEX idx_login_ip_time (login, ip, criado_em) | |
| 36 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; | ... | ... |
| 1 | +++ a/src/AdminRepository.php | |
| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | +<?php | |
| 2 | + | |
| 3 | +final class AdminRepository | |
| 4 | +{ | |
| 5 | + public static function findActiveByLogin(string $login): ?array | |
| 6 | + { | |
| 7 | + $stmt = Database::pdo()->prepare('SELECT * FROM admins WHERE login = :login AND ativo = 1'); | |
| 8 | + $stmt->execute(['login' => $login]); | |
| 9 | + $row = $stmt->fetch(); | |
| 10 | + return $row ?: null; | |
| 11 | + } | |
| 12 | + | |
| 13 | + public static function all(): array | |
| 14 | + { | |
| 15 | + return Database::pdo() | |
| 16 | + ->query('SELECT * FROM admins ORDER BY ativo DESC, login ASC') | |
| 17 | + ->fetchAll(); | |
| 18 | + } | |
| 19 | + | |
| 20 | + public static function countActiveAdmins(?int $excludeId = null): int | |
| 21 | + { | |
| 22 | + $sql = "SELECT COUNT(*) FROM admins WHERE ativo = 1 AND papel = 'admin'"; | |
| 23 | + $params = []; | |
| 24 | + if ($excludeId !== null) { | |
| 25 | + $sql .= ' AND id != :id'; | |
| 26 | + $params['id'] = $excludeId; | |
| 27 | + } | |
| 28 | + $stmt = Database::pdo()->prepare($sql); | |
| 29 | + $stmt->execute($params); | |
| 30 | + return (int) $stmt->fetchColumn(); | |
| 31 | + } | |
| 32 | + | |
| 33 | + public static function add(string $login, ?string $nome, string $papel, string $criadoPor): void | |
| 34 | + { | |
| 35 | + $stmt = Database::pdo()->prepare( | |
| 36 | + 'INSERT INTO admins (login, nome, papel, criado_por) VALUES (:login, :nome, :papel, :criado_por) | |
| 37 | + ON DUPLICATE KEY UPDATE ativo = 1, papel = VALUES(papel), nome = VALUES(nome)' | |
| 38 | + ); | |
| 39 | + $stmt->execute([ | |
| 40 | + 'login' => $login, | |
| 41 | + 'nome' => $nome, | |
| 42 | + 'papel' => $papel, | |
| 43 | + 'criado_por' => $criadoPor, | |
| 44 | + ]); | |
| 45 | + } | |
| 46 | + | |
| 47 | + public static function setActive(int $id, bool $active): void | |
| 48 | + { | |
| 49 | + $stmt = Database::pdo()->prepare('UPDATE admins SET ativo = :ativo WHERE id = :id'); | |
| 50 | + $stmt->execute(['ativo' => $active ? 1 : 0, 'id' => $id]); | |
| 51 | + } | |
| 52 | + | |
| 53 | + public static function find(int $id): ?array | |
| 54 | + { | |
| 55 | + $stmt = Database::pdo()->prepare('SELECT * FROM admins WHERE id = :id'); | |
| 56 | + $stmt->execute(['id' => $id]); | |
| 57 | + $row = $stmt->fetch(); | |
| 58 | + return $row ?: null; | |
| 59 | + } | |
| 60 | + | |
| 61 | + public static function touchLoginCache(string $login, ?string $nome): void | |
| 62 | + { | |
| 63 | + $stmt = Database::pdo()->prepare('UPDATE admins SET nome = :nome WHERE login = :login'); | |
| 64 | + $stmt->execute(['nome' => $nome, 'login' => $login]); | |
| 65 | + } | |
| 66 | +} | ... | ... |
| 1 | +++ a/src/AuditLog.php | |
| ... | ... | @@ -0,0 +1,39 @@ |
| 1 | +<?php | |
| 2 | + | |
| 3 | +final class AuditLog | |
| 4 | +{ | |
| 5 | + public static function record(string $usuario, string $acao, ?string $alvo = null, ?string $detalhe = null): void | |
| 6 | + { | |
| 7 | + $ip = $_SERVER['REMOTE_ADDR'] ?? null; | |
| 8 | + | |
| 9 | + try { | |
| 10 | + $stmt = Database::pdo()->prepare( | |
| 11 | + 'INSERT INTO audit_log (usuario, acao, alvo, detalhe, ip) VALUES (:usuario, :acao, :alvo, :detalhe, :ip)' | |
| 12 | + ); | |
| 13 | + $stmt->execute([ | |
| 14 | + 'usuario' => $usuario, | |
| 15 | + 'acao' => $acao, | |
| 16 | + 'alvo' => $alvo, | |
| 17 | + 'detalhe' => $detalhe, | |
| 18 | + 'ip' => $ip, | |
| 19 | + ]); | |
| 20 | + } catch (Throwable $e) { | |
| 21 | + // Nunca deixa uma falha de log derrubar a ação principal. | |
| 22 | + error_log('AuditLog falhou: ' . $e->getMessage()); | |
| 23 | + } | |
| 24 | + | |
| 25 | + $logFile = Config::get('app.log_file'); | |
| 26 | + if ($logFile) { | |
| 27 | + $line = sprintf( | |
| 28 | + "%s|%s|%s|%s|%s|%s\n", | |
| 29 | + date('Y-m-d H:i:s'), | |
| 30 | + $ip, | |
| 31 | + $usuario, | |
| 32 | + $acao, | |
| 33 | + $alvo ?? '-', | |
| 34 | + $detalhe ?? '-' | |
| 35 | + ); | |
| 36 | + @file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX); | |
| 37 | + } | |
| 38 | + } | |
| 39 | +} | ... | ... |
| 1 | +++ a/src/Auth.php | |
| ... | ... | @@ -0,0 +1,134 @@ |
| 1 | +<?php | |
| 2 | + | |
| 3 | +final class Auth | |
| 4 | +{ | |
| 5 | + private const MAX_ATTEMPTS = 5; | |
| 6 | + private const WINDOW_MINUTES = 15; | |
| 7 | + | |
| 8 | + public static function start(): void | |
| 9 | + { | |
| 10 | + if (session_status() === PHP_SESSION_NONE) { | |
| 11 | + session_name(Config::get('app.session_name', 'ad_session')); | |
| 12 | + session_set_cookie_params([ | |
| 13 | + 'lifetime' => 0, | |
| 14 | + 'path' => '/', | |
| 15 | + 'secure' => (!empty($_SERVER['HTTPS'])), | |
| 16 | + 'httponly' => true, | |
| 17 | + 'samesite' => 'Lax', | |
| 18 | + ]); | |
| 19 | + session_start(); | |
| 20 | + } | |
| 21 | + } | |
| 22 | + | |
| 23 | + public static function attempt(string $login, string $password): array | |
| 24 | + { | |
| 25 | + $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; | |
| 26 | + | |
| 27 | + if (self::isRateLimited($login, $ip)) { | |
| 28 | + return ['ok' => false, 'error' => 'Muitas tentativas. Aguarde alguns minutos e tente novamente.']; | |
| 29 | + } | |
| 30 | + | |
| 31 | + $ldap = new Ldap(); | |
| 32 | + $bindOk = $ldap->tryUserBind($login, $password); | |
| 33 | + | |
| 34 | + self::logAttempt($login, $ip, $bindOk); | |
| 35 | + | |
| 36 | + if (!$bindOk) { | |
| 37 | + return ['ok' => false, 'error' => 'Usuário ou senha incorretos.']; | |
| 38 | + } | |
| 39 | + | |
| 40 | + $admin = AdminRepository::findActiveByLogin($login); | |
| 41 | + if (!$admin) { | |
| 42 | + AuditLog::record($login, 'login_negado', null, 'usuário autenticado no AD mas sem permissão no sistema'); | |
| 43 | + return ['ok' => false, 'error' => 'Seu usuário não tem permissão para acessar este sistema.']; | |
| 44 | + } | |
| 45 | + | |
| 46 | + // Atualiza o nome em cache a partir do AD, sem depender da conta de serviço falhar. | |
| 47 | + try { | |
| 48 | + $ldap->bindService(); | |
| 49 | + $entry = $ldap->findUserByLogin($login); | |
| 50 | + $nome = $entry['displayname'][0] ?? null; | |
| 51 | + if ($nome) { | |
| 52 | + AdminRepository::touchLoginCache($login, $nome); | |
| 53 | + $admin['nome'] = $nome; | |
| 54 | + } | |
| 55 | + } catch (Throwable $e) { | |
| 56 | + // Não bloqueia o login por causa da conta de serviço. | |
| 57 | + } | |
| 58 | + | |
| 59 | + self::start(); | |
| 60 | + session_regenerate_id(true); | |
| 61 | + $_SESSION['login'] = $login; | |
| 62 | + $_SESSION['nome'] = $admin['nome'] ?? $login; | |
| 63 | + $_SESSION['papel'] = $admin['papel']; | |
| 64 | + | |
| 65 | + AuditLog::record($login, 'login', null, 'login efetuado'); | |
| 66 | + | |
| 67 | + return ['ok' => true]; | |
| 68 | + } | |
| 69 | + | |
| 70 | + public static function logout(): void | |
| 71 | + { | |
| 72 | + self::start(); | |
| 73 | + $login = $_SESSION['login'] ?? 'desconhecido'; | |
| 74 | + AuditLog::record($login, 'logout'); | |
| 75 | + $_SESSION = []; | |
| 76 | + session_destroy(); | |
| 77 | + } | |
| 78 | + | |
| 79 | + public static function requireLogin(): void | |
| 80 | + { | |
| 81 | + self::start(); | |
| 82 | + if (empty($_SESSION['login'])) { | |
| 83 | + header('Location: index.php?erro=sessao_expirada'); | |
| 84 | + exit; | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + public static function requireAdmin(): void | |
| 89 | + { | |
| 90 | + self::requireLogin(); | |
| 91 | + if (($_SESSION['papel'] ?? null) !== 'admin') { | |
| 92 | + http_response_code(403); | |
| 93 | + echo 'Acesso restrito a administradores.'; | |
| 94 | + exit; | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 98 | + public static function currentLogin(): ?string | |
| 99 | + { | |
| 100 | + return $_SESSION['login'] ?? null; | |
| 101 | + } | |
| 102 | + | |
| 103 | + public static function currentName(): ?string | |
| 104 | + { | |
| 105 | + return $_SESSION['nome'] ?? null; | |
| 106 | + } | |
| 107 | + | |
| 108 | + public static function isAdmin(): bool | |
| 109 | + { | |
| 110 | + return ($_SESSION['papel'] ?? null) === 'admin'; | |
| 111 | + } | |
| 112 | + | |
| 113 | + private static function isRateLimited(string $login, string $ip): bool | |
| 114 | + { | |
| 115 | + $stmt = Database::pdo()->prepare( | |
| 116 | + 'SELECT COUNT(*) FROM login_attempts | |
| 117 | + WHERE login = :login AND ip = :ip AND sucesso = 0 | |
| 118 | + AND criado_em >= (NOW() - INTERVAL :minutes MINUTE)' | |
| 119 | + ); | |
| 120 | + $stmt->bindValue('login', $login); | |
| 121 | + $stmt->bindValue('ip', $ip); | |
| 122 | + $stmt->bindValue('minutes', self::WINDOW_MINUTES, PDO::PARAM_INT); | |
| 123 | + $stmt->execute(); | |
| 124 | + return (int) $stmt->fetchColumn() >= self::MAX_ATTEMPTS; | |
| 125 | + } | |
| 126 | + | |
| 127 | + private static function logAttempt(string $login, string $ip, bool $success): void | |
| 128 | + { | |
| 129 | + $stmt = Database::pdo()->prepare( | |
| 130 | + 'INSERT INTO login_attempts (login, ip, sucesso) VALUES (:login, :ip, :sucesso)' | |
| 131 | + ); | |
| 132 | + $stmt->execute(['login' => $login, 'ip' => $ip, 'sucesso' => $success ? 1 : 0]); | |
| 133 | + } | |
| 134 | +} | ... | ... |
| 1 | +++ a/src/Config.php | |
| ... | ... | @@ -0,0 +1,40 @@ |
| 1 | +<?php | |
| 2 | + | |
| 3 | +final class Config | |
| 4 | +{ | |
| 5 | + private static ?array $data = null; | |
| 6 | + | |
| 7 | + public static function get(string $path, $default = null) | |
| 8 | + { | |
| 9 | + if (self::$data === null) { | |
| 10 | + self::$data = self::load(); | |
| 11 | + } | |
| 12 | + | |
| 13 | + $value = self::$data; | |
| 14 | + foreach (explode('.', $path) as $segment) { | |
| 15 | + if (!is_array($value) || !array_key_exists($segment, $value)) { | |
| 16 | + return $default; | |
| 17 | + } | |
| 18 | + $value = $value[$segment]; | |
| 19 | + } | |
| 20 | + | |
| 21 | + return $value; | |
| 22 | + } | |
| 23 | + | |
| 24 | + private static function load(): array | |
| 25 | + { | |
| 26 | + $localFile = __DIR__ . '/../config/local.php'; | |
| 27 | + if (!is_file($localFile)) { | |
| 28 | + throw new RuntimeException( | |
| 29 | + 'config/local.php não encontrado. Copie config/config.example.php para config/local.php e preencha os valores.' | |
| 30 | + ); | |
| 31 | + } | |
| 32 | + | |
| 33 | + $data = require $localFile; | |
| 34 | + if (!is_array($data)) { | |
| 35 | + throw new RuntimeException('config/local.php deve retornar um array.'); | |
| 36 | + } | |
| 37 | + | |
| 38 | + return $data; | |
| 39 | + } | |
| 40 | +} | ... | ... |
| 1 | +++ a/src/Database.php | |
| ... | ... | @@ -0,0 +1,25 @@ |
| 1 | +<?php | |
| 2 | + | |
| 3 | +final class Database | |
| 4 | +{ | |
| 5 | + private static ?PDO $pdo = null; | |
| 6 | + | |
| 7 | + public static function pdo(): PDO | |
| 8 | + { | |
| 9 | + if (self::$pdo === null) { | |
| 10 | + $host = Config::get('db.host'); | |
| 11 | + $name = Config::get('db.name'); | |
| 12 | + $user = Config::get('db.user'); | |
| 13 | + $pass = Config::get('db.pass'); | |
| 14 | + | |
| 15 | + $dsn = "mysql:host={$host};dbname={$name};charset=utf8mb4"; | |
| 16 | + self::$pdo = new PDO($dsn, $user, $pass, [ | |
| 17 | + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, | |
| 18 | + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, | |
| 19 | + PDO::ATTR_EMULATE_PREPARES => false, | |
| 20 | + ]); | |
| 21 | + } | |
| 22 | + | |
| 23 | + return self::$pdo; | |
| 24 | + } | |
| 25 | +} | ... | ... |
| 1 | +++ a/src/Ldap.php | |
| ... | ... | @@ -0,0 +1,125 @@ |
| 1 | +<?php | |
| 2 | + | |
| 3 | +final class LdapException extends RuntimeException | |
| 4 | +{ | |
| 5 | +} | |
| 6 | + | |
| 7 | +final class Ldap | |
| 8 | +{ | |
| 9 | + /** @var resource|\LDAP\Connection */ | |
| 10 | + private $connect; | |
| 11 | + | |
| 12 | + public function __construct() | |
| 13 | + { | |
| 14 | + $server = Config::get('ldap.server'); | |
| 15 | + $this->connect = ldap_connect($server); | |
| 16 | + if (!$this->connect) { | |
| 17 | + throw new LdapException('Não foi possível conectar ao servidor LDAP.'); | |
| 18 | + } | |
| 19 | + ldap_set_option($this->connect, LDAP_OPT_PROTOCOL_VERSION, 3); | |
| 20 | + ldap_set_option($this->connect, LDAP_OPT_REFERRALS, 0); | |
| 21 | + ldap_set_option($this->connect, LDAP_OPT_NETWORK_TIMEOUT, 5); | |
| 22 | + } | |
| 23 | + | |
| 24 | + /** | |
| 25 | + * Tenta autenticar o usuário com a própria senha dele (bind direto). | |
| 26 | + * Retorna true/false, nunca lança por credencial errada. | |
| 27 | + */ | |
| 28 | + public function tryUserBind(string $login, string $password): bool | |
| 29 | + { | |
| 30 | + if ($login === '' || $password === '') { | |
| 31 | + return false; | |
| 32 | + } | |
| 33 | + $userPrincipal = $login . Config::get('ldap.domain_suffix', '@example.org'); | |
| 34 | + set_error_handler(static function () { | |
| 35 | + return true; | |
| 36 | + }); // silencia warning nativo do ldap_bind em credencial inválida, sem usar @ | |
| 37 | + try { | |
| 38 | + return (bool) ldap_bind($this->connect, $userPrincipal, $password); | |
| 39 | + } finally { | |
| 40 | + restore_error_handler(); | |
| 41 | + } | |
| 42 | + } | |
| 43 | + | |
| 44 | + /** | |
| 45 | + * Faz bind com a conta de serviço (somente leitura), usada para todas as buscas. | |
| 46 | + */ | |
| 47 | + public function bindService(): void | |
| 48 | + { | |
| 49 | + $user = Config::get('ldap.service_user'); | |
| 50 | + $pass = Config::get('ldap.service_pass'); | |
| 51 | + $userPrincipal = $user . Config::get('ldap.domain_suffix', '@example.org'); | |
| 52 | + $ok = @ldap_bind($this->connect, $userPrincipal, $pass); | |
| 53 | + if (!$ok) { | |
| 54 | + throw new LdapException('Falha ao autenticar a conta de serviço no LDAP.'); | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + public function search(string $filter): array | |
| 59 | + { | |
| 60 | + $baseDn = Config::get('ldap.base_dn'); | |
| 61 | + $result = @ldap_search($this->connect, $baseDn, $filter); | |
| 62 | + if ($result === false) { | |
| 63 | + throw new LdapException('Erro na busca LDAP: ' . ldap_error($this->connect)); | |
| 64 | + } | |
| 65 | + $entries = @ldap_get_entries($this->connect, $result); | |
| 66 | + if ($entries === false) { | |
| 67 | + throw new LdapException('Erro ao ler entradas LDAP: ' . ldap_error($this->connect)); | |
| 68 | + } | |
| 69 | + return $entries; | |
| 70 | + } | |
| 71 | + | |
| 72 | + public function findUserByCn(string $cn): ?array | |
| 73 | + { | |
| 74 | + $filter = '(cn=' . ldap_escape($cn, '', LDAP_ESCAPE_FILTER) . ')'; | |
| 75 | + $entries = $this->search($filter); | |
| 76 | + return $entries['count'] > 0 ? $entries[0] : null; | |
| 77 | + } | |
| 78 | + | |
| 79 | + public function findUserByLogin(string $login): ?array | |
| 80 | + { | |
| 81 | + $filter = '(sAMAccountName=' . ldap_escape($login, '', LDAP_ESCAPE_FILTER) . ')'; | |
| 82 | + $entries = $this->search($filter); | |
| 83 | + return $entries['count'] > 0 ? $entries[0] : null; | |
| 84 | + } | |
| 85 | + | |
| 86 | + public function groupMembers(string $groupDnOrFilter): array | |
| 87 | + { | |
| 88 | + // aceita tanto um filtro pronto ("cn=grpX") quanto um CN cru | |
| 89 | + $filter = str_contains($groupDnOrFilter, '=') | |
| 90 | + ? $groupDnOrFilter | |
| 91 | + : '(cn=' . ldap_escape($groupDnOrFilter, '', LDAP_ESCAPE_FILTER) . ')'; | |
| 92 | + $entries = $this->search($filter); | |
| 93 | + if ($entries['count'] === 0) { | |
| 94 | + return []; | |
| 95 | + } | |
| 96 | + $members = []; | |
| 97 | + $count = (int) ($entries[0]['member']['count'] ?? 0); | |
| 98 | + for ($i = 0; $i < $count; $i++) { | |
| 99 | + $dn = $entries[0]['member'][$i]; | |
| 100 | + $parts = explode(',', $dn); | |
| 101 | + $cn = str_replace('CN=', '', $parts[0]); | |
| 102 | + $members[] = $cn; | |
| 103 | + } | |
| 104 | + return $members; | |
| 105 | + } | |
| 106 | + | |
| 107 | + public function modify(string $dn, array $attributes): void | |
| 108 | + { | |
| 109 | + $ok = @ldap_mod_replace($this->connect, $dn, $attributes); | |
| 110 | + if (!$ok) { | |
| 111 | + throw new LdapException('Erro ao modificar LDAP: ' . ldap_error($this->connect)); | |
| 112 | + } | |
| 113 | + } | |
| 114 | + | |
| 115 | + public function dnOf(array $entry): ?string | |
| 116 | + { | |
| 117 | + return $entry['dn'] ?? null; | |
| 118 | + } | |
| 119 | + | |
| 120 | + public static function encodePassword(string $plain): string | |
| 121 | + { | |
| 122 | + // AD exige a senha entre aspas e em UTF-16LE para unicodePwd. | |
| 123 | + return iconv('UTF-8', 'UTF-16LE', '"' . $plain . '"'); | |
| 124 | + } | |
| 125 | +} | ... | ... |
| 1 | +++ a/src/SettingsRepository.php | |
| ... | ... | @@ -0,0 +1,38 @@ |
| 1 | +<?php | |
| 2 | + | |
| 3 | +final class SettingsRepository | |
| 4 | +{ | |
| 5 | + private const DEFAULTS = [ | |
| 6 | + 'group_aluno_filter' => 'cn=grpCI-Aluno-Info', | |
| 7 | + ]; | |
| 8 | + | |
| 9 | + public static function get(string $key, $default = null) | |
| 10 | + { | |
| 11 | + $stmt = Database::pdo()->prepare('SELECT valor FROM settings WHERE chave = :chave'); | |
| 12 | + $stmt->execute(['chave' => $key]); | |
| 13 | + $value = $stmt->fetchColumn(); | |
| 14 | + if ($value !== false) { | |
| 15 | + return $value; | |
| 16 | + } | |
| 17 | + return self::DEFAULTS[$key] ?? $default; | |
| 18 | + } | |
| 19 | + | |
| 20 | + public static function set(string $key, string $value, string $usuario): void | |
| 21 | + { | |
| 22 | + $stmt = Database::pdo()->prepare( | |
| 23 | + 'INSERT INTO settings (chave, valor, atualizado_por) VALUES (:chave, :valor, :usuario) | |
| 24 | + ON DUPLICATE KEY UPDATE valor = VALUES(valor), atualizado_por = VALUES(atualizado_por)' | |
| 25 | + ); | |
| 26 | + $stmt->execute(['chave' => $key, 'valor' => $value, 'usuario' => $usuario]); | |
| 27 | + } | |
| 28 | + | |
| 29 | + public static function all(): array | |
| 30 | + { | |
| 31 | + $rows = Database::pdo()->query('SELECT chave, valor FROM settings')->fetchAll(); | |
| 32 | + $result = self::DEFAULTS; | |
| 33 | + foreach ($rows as $row) { | |
| 34 | + $result[$row['chave']] = $row['valor']; | |
| 35 | + } | |
| 36 | + return $result; | |
| 37 | + } | |
| 38 | +} | ... | ... |
| 1 | +++ a/src/View.php | |
| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | +<?php | |
| 2 | + | |
| 3 | +final class View | |
| 4 | +{ | |
| 5 | + public static function header(string $title): void | |
| 6 | + { | |
| 7 | + ?><!doctype html> | |
| 8 | +<html lang="pt-br"> | |
| 9 | +<head> | |
| 10 | + <meta charset="utf-8"> | |
| 11 | + <title><?= htmlspecialchars($title, ENT_QUOTES) ?> - LabInfo</title> | |
| 12 | + <link rel="stylesheet" href="assets/style.css"> | |
| 13 | +</head> | |
| 14 | +<body> | |
| 15 | +<div class="topbar"> | |
| 16 | + <strong>Gestão de Alunos (AD)</strong> | |
| 17 | + <nav> | |
| 18 | + <span><?= htmlspecialchars(Auth::currentName() ?? '', ENT_QUOTES) ?></span> | |
| 19 | + <a href="dashboard.php">Alunos</a> | |
| 20 | + <?php if (Auth::isAdmin()): ?> | |
| 21 | + <a href="admins.php">Administradores</a> | |
| 22 | + <a href="settings.php">Configurações</a> | |
| 23 | + <?php endif; ?> | |
| 24 | + <a href="logout.php">Sair</a> | |
| 25 | + </nav> | |
| 26 | +</div> | |
| 27 | +<div class="container"> | |
| 28 | +<?php | |
| 29 | + } | |
| 30 | + | |
| 31 | + public static function footer(): void | |
| 32 | + { | |
| 33 | + ?> | |
| 34 | +</div> | |
| 35 | +</body> | |
| 36 | +</html> | |
| 37 | +<?php | |
| 38 | + } | |
| 39 | + | |
| 40 | + public static function escape(?string $value): string | |
| 41 | + { | |
| 42 | + return htmlspecialchars($value ?? '', ENT_QUOTES); | |
| 43 | + } | |
| 44 | +} | ... | ... |
| 1 | +++ a/src/bootstrap.php | |
| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | +<?php | |
| 2 | + | |
| 3 | +declare(strict_types=1); | |
| 4 | + | |
| 5 | +error_reporting(E_ALL); | |
| 6 | +ini_set('display_errors', '0'); // nunca mostrar erro cru em produção | |
| 7 | + | |
| 8 | +spl_autoload_register(static function (string $class): void { | |
| 9 | + $file = __DIR__ . '/' . $class . '.php'; | |
| 10 | + if (is_file($file)) { | |
| 11 | + require $file; | |
| 12 | + } | |
| 13 | +}); | |
| 14 | + | |
| 15 | +set_exception_handler(static function (Throwable $e): void { | |
| 16 | + error_log('Uncaught: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); | |
| 17 | + http_response_code(500); | |
| 18 | + echo 'Ocorreu um erro inesperado. Tente novamente mais tarde.'; | |
| 19 | +}); | |
| 20 | + | |
| 21 | +Auth::start(); | ... | ... |