login_attempts.php

main 134 lines · 4.7 KB Raw
Alex Alex Commit Initial commit 01/10/2026 09:20
1<?php
2/**
3 * Title: Login Attempts
4 * Icon: fa-lock
5 * Group: Operations
6 * Order: 50
7 * Description: Recent login attempts and which IPs are currently locked out.
8 */
9
10if (!defined('ACP_ROOT')) {
11 http_response_code(403);
12 die('Direct access denied.');
13}
14
15if (!znote_table_exists('znote_login_attempts')) {
16 acp_flash_error(t_default('acp.lattempt.no_table', 'Run the pending database migration first (Admin Panel > Migrations).'));
17 acp_redirect('migrations');
18}
19
20if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['do'] ?? '') === 'clear_ip') {
21 $ip = substr(trim((string)($_POST['ip'] ?? '')), 0, 45);
22 if ($ip !== '') {
23 db()->execute("DELETE FROM `znote_login_attempts` WHERE `ip` = ? AND `success` = 0;", [$ip]);
24 acp_log('login_guard.clear_ip', $ip);
25 acp_flash_success(t_default('acp.lattempt.cleared', 'Cleared - that IP can log in again immediately.'));
26 }
27 acp_redirect('login_attempts');
28}
29
30$cfg = znote_login_guard_config();
31
32$lockedRows = db()->fetchAll("
33 SELECT `ip`, COUNT(*) AS `failures`, MAX(`created_at`) AS `last_failure`
34 FROM `znote_login_attempts`
35 WHERE `success` = 0 AND `created_at` >= ?
36 GROUP BY `ip`
37 HAVING `failures` >= ?
38 ORDER BY `last_failure` DESC;
39", [time() - $cfg['window_seconds'], $cfg['threshold']]);
40$lockedRows = is_array($lockedRows) ? $lockedRows : array();
41
42$recent = db()->fetchAll("
43 SELECT `ip`, `username`, `success`, `created_at`
44 FROM `znote_login_attempts`
45 ORDER BY `id` DESC
46 LIMIT 100;
47");
48$recent = is_array($recent) ? $recent : array();
49?>
50
51<div class="acp-stats">
52 <?php
53 acp_stat(t_default('acp.lattempt.stat_locked', 'IPs locked out now'), count($lockedRows), 'fa-lock', null, count($lockedRows) > 0 ? 'red' : 'green');
54 acp_stat(t_default('acp.lattempt.stat_guard', 'Protection'), $cfg['enabled'] ? t_default('acp.lattempt.on', 'On') : t_default('acp.lattempt.off', 'Off'), 'fa-shield', acp_url('settings'), $cfg['enabled'] ? 'green' : 'grey');
55 ?>
56</div>
57
58<?php if ($lockedRows): ?>
59 <section class="acp-card">
60 <header class="acp-card-head">
61 <h2><?= t_default('acp.lattempt.locked_title', 'Currently locked out') ?></h2>
62 </header>
63 <div class="acp-card-body is-flush">
64 <div class="acp-table-wrap">
65 <table class="acp-table">
66 <thead>
67 <tr>
68 <th><?= t_default('acp.lattempt.col_ip', 'IP') ?></th>
69 <th class="is-num"><?= t_default('acp.lattempt.col_failures', 'Failures') ?></th>
70 <th><?= t_default('acp.lattempt.col_last', 'Last attempt') ?></th>
71 <th class="is-num"></th>
72 </tr>
73 </thead>
74 <tbody>
75 <?php foreach ($lockedRows as $row): ?>
76 <tr>
77 <td><code><?= h((string)$row['ip']) ?></code></td>
78 <td class="is-num"><?= (int)$row['failures'] ?></td>
79 <td class="is-nowrap is-muted"><?= h(getClock((int)$row['last_failure'], true)) ?></td>
80 <td class="is-num">
81 <form class="acp-inline-form" method="post" data-confirm="<?= h(t_default('acp.lattempt.confirm_clear', 'Let this IP try again immediately?')) ?>">
82 <?= acp_csrf_field() ?>
83 <input type="hidden" name="do" value="clear_ip">
84 <input type="hidden" name="ip" value="<?= h((string)$row['ip']) ?>">
85 <button class="acp-btn acp-btn--ghost acp-btn--sm" type="submit"><?= t_default('acp.lattempt.clear_btn', 'Unlock') ?></button>
86 </form>
87 </td>
88 </tr>
89 <?php endforeach; ?>
90 </tbody>
91 </table>
92 </div>
93 </div>
94 </section>
95<?php endif; ?>
96
97<section class="acp-card">
98 <header class="acp-card-head">
99 <h2><?= t_default('acp.lattempt.recent_title', 'Last 100 attempts') ?></h2>
100 </header>
101 <div class="acp-card-body is-flush">
102 <?php if ($recent): ?>
103 <div class="acp-table-wrap">
104 <table class="acp-table" data-sortable>
105 <thead>
106 <tr>
107 <th><?= t_default('acp.lattempt.col_when', 'When') ?></th>
108 <th><?= t_default('acp.lattempt.col_ip', 'IP') ?></th>
109 <th><?= t_default('acp.lattempt.col_username', 'Username tried') ?></th>
110 <th><?= t_default('acp.lattempt.col_result', 'Result') ?></th>
111 </tr>
112 </thead>
113 <tbody>
114 <?php foreach ($recent as $row): ?>
115 <tr>
116 <td class="is-nowrap is-muted"><?= h(getClock((int)$row['created_at'], true)) ?></td>
117 <td><code><?= h((string)$row['ip']) ?></code></td>
118 <td><?= h((string)$row['username']) ?></td>
119 <td>
120 <span class="acp-pill acp-pill--<?= $row['success'] ? 'green' : 'red' ?>">
121 <?= $row['success'] ? t_default('acp.lattempt.success', 'Success') : t_default('acp.lattempt.failed', 'Failed') ?>
122 </span>
123 </td>
124 </tr>
125 <?php endforeach; ?>
126 </tbody>
127 </table>
128 </div>
129 <?php else: ?>
130 <?php acp_empty(t_default('acp.lattempt.empty', 'No login attempts recorded yet.'), 'fa-lock'); ?>
131 <?php endif; ?>
132 </div>
133</section>
134
Top