Skip to content

Commit d4517f8

Browse files
authored
Merge pull request #6603 from christianbeeznest/GH-4498
Admin: Security section, report on failed logins - refs #4498
2 parents 82acb17 + 0153c82 commit d4517f8

File tree

6 files changed

+381
-0
lines changed

6 files changed

+381
-0
lines changed

assets/vue/composables/admin/indexBlocks.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export function useIndexBlocks() {
7878
const blockSettings = ref(null)
7979
const blockPlatform = ref(null)
8080
const blockChamilo = ref(null)
81+
const blockSecurity = ref(null)
8182

8283
async function loadBlocks() {
8384
const blocks = await adminService.findBlocks()
@@ -91,6 +92,7 @@ export function useIndexBlocks() {
9192
blockSettings.value = blocks.settings || null
9293
blockPlatform.value = blocks.platform || null
9394
blockChamilo.value = blocks.chamilo || null
95+
blockSecurity.value = blocks.security || null
9496
}
9597

9698
return {
@@ -105,6 +107,7 @@ export function useIndexBlocks() {
105107
blockSettings,
106108
blockPlatform,
107109
blockChamilo,
110+
blockSecurity,
108111
loadBlocks,
109112
blockNewsStatusEl,
110113
loadNews,

assets/vue/views/admin/AdminIndex.vue

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@
168168
:title="t('Platform management')"
169169
icon="admin-settings"
170170
/>
171+
<AdminBlock
172+
v-if="blockSecurity"
173+
:id="blockSecurity.id"
174+
v-model:extra-content="blockSecurity.extraContent"
175+
:description="t('Security tools and reports')"
176+
:editable="blockSecurity.editable"
177+
:items="blockSecurity.items"
178+
:title="t('Security')"
179+
icon="shield-check"
180+
/>
171181

172182
<AdminBlock
173183
v-if="blockChamilo"
@@ -258,6 +268,7 @@ const {
258268
blockSettings,
259269
blockPlatform,
260270
blockChamilo,
271+
blockSecurity,
261272
loadBlocks,
262273
blockNewsStatusEl,
263274
blockSupportStatusEl,

src/CoreBundle/Controller/Admin/IndexBlocksController.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,13 @@ public function __invoke(): JsonResponse
120120
];
121121
}
122122

123+
$json['security'] = [
124+
'id' => 'block-admin-security',
125+
'editable' => false,
126+
'items' => $this->getItemsSecurity(),
127+
'extraContent' => $this->getExtraContent('block-admin-security'),
128+
];
129+
123130
/* Chamilo.org */
124131
$json['chamilo'] = [
125132
'id' => 'block-admin-chamilo',
@@ -147,6 +154,17 @@ public function __invoke(): JsonResponse
147154
return $this->json($json);
148155
}
149156

157+
private function getItemsSecurity(): array
158+
{
159+
return [
160+
[
161+
'class' => 'item-security-login-attempts',
162+
'url' => $this->generateUrl('admin_security_login_attempts'),
163+
'label' => $this->translator->trans('Login attempts'),
164+
],
165+
];
166+
}
167+
150168
private function getItemsUsers(): array
151169
{
152170
$items = [];
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
/* For licensing terms, see /license.txt */
3+
4+
declare(strict_types=1);
5+
6+
namespace Chamilo\CoreBundle\Controller\Admin;
7+
8+
use Chamilo\CoreBundle\Controller\BaseController;
9+
use Chamilo\CoreBundle\Repository\TrackELoginRecordRepository;
10+
use Symfony\Component\HttpFoundation\Request;
11+
use Symfony\Component\HttpFoundation\Response;
12+
use Symfony\Component\Routing\Annotation\Route;
13+
use Symfony\Component\Security\Http\Attribute\IsGranted;
14+
15+
#[Route('/admin/security')]
16+
final class SecurityController extends BaseController
17+
{
18+
public function __construct(private readonly TrackELoginRecordRepository $repo) {}
19+
20+
#[IsGranted('ROLE_ADMIN')]
21+
#[Route('/login-attempts', name: 'admin_security_login_attempts', methods: ['GET'])]
22+
public function loginAttempts(Request $r): Response
23+
{
24+
$page = max(1, $r->query->getInt('page', 1));
25+
$pageSize = min(100, max(1, $r->query->getInt('pageSize', 25)));
26+
$filters = [
27+
'username' => trim((string) $r->query->get('username', '')),
28+
'ip' => trim((string) $r->query->get('ip', '')),
29+
'from' => $r->query->get('from'),
30+
'to' => $r->query->get('to'),
31+
];
32+
33+
$list = $this->repo->findFailedPaginated($page, $pageSize, $filters);
34+
35+
$stats = [
36+
'byDay' => $this->repo->failedByDay(7),
37+
'byMonth' => $this->repo->failedByMonth(12),
38+
'topUsernames' => $this->repo->topUsernames(30, 5),
39+
'topIps' => $this->repo->topIps(30, 5),
40+
'successVsFailed' => $this->repo->successVsFailedByDay(30),
41+
'byHour' => $this->repo->failedByHourOfDay(7),
42+
'uniqueIps' => $this->repo->uniqueIpsByDay(30),
43+
];
44+
45+
return $this->render('@ChamiloCore/Admin/Security/login_attempts.html.twig', [
46+
'items' => $list['items'],
47+
'total' => $list['total'],
48+
'page' => $list['page'],
49+
'pageSize' => $list['pageSize'],
50+
'filters' => $filters,
51+
'stats' => $stats,
52+
]);
53+
}
54+
}

src/CoreBundle/Repository/TrackELoginRecordRepository.php

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,134 @@ public function addTrackLogin(string $username, string $userIp, bool $success):
3131
$this->_em->persist($trackELoginRecord);
3232
$this->_em->flush();
3333
}
34+
35+
public function failedByMonth(int $months = 12): array
36+
{
37+
$sql = "
38+
SELECT DATE_FORMAT(login_date, '%Y-%m-01') AS month, COUNT(*) AS failed
39+
FROM track_e_login_record
40+
WHERE success = 0 AND login_date >= (CURRENT_DATE - INTERVAL :m MONTH)
41+
GROUP BY month ORDER BY month ASC
42+
";
43+
return $this->getEntityManager()->getConnection()
44+
->executeQuery($sql, ['m' => $months])
45+
->fetchAllAssociative();
46+
}
47+
48+
public function topUsernames(int $days = 30, int $limit = 5): array
49+
{
50+
$conn = $this->getEntityManager()->getConnection();
51+
$stmt = $conn->prepare("
52+
SELECT username, COUNT(*) AS failed
53+
FROM track_e_login_record
54+
WHERE success = 0 AND login_date >= (CURRENT_DATE - INTERVAL :d DAY)
55+
GROUP BY username ORDER BY failed DESC LIMIT :l
56+
");
57+
$stmt->bindValue('d', $days, \PDO::PARAM_INT);
58+
$stmt->bindValue('l', $limit, \PDO::PARAM_INT);
59+
return $stmt->executeQuery()->fetchAllAssociative();
60+
}
61+
62+
public function topIps(int $days = 30, int $limit = 5): array
63+
{
64+
$conn = $this->getEntityManager()->getConnection();
65+
$stmt = $conn->prepare("
66+
SELECT user_ip AS ip, COUNT(*) AS failed
67+
FROM track_e_login_record
68+
WHERE success = 0 AND login_date >= (CURRENT_DATE - INTERVAL :d DAY)
69+
GROUP BY user_ip ORDER BY failed DESC LIMIT :l
70+
");
71+
$stmt->bindValue('d', $days, \PDO::PARAM_INT);
72+
$stmt->bindValue('l', $limit, \PDO::PARAM_INT);
73+
return $stmt->executeQuery()->fetchAllAssociative();
74+
}
75+
76+
public function findFailedPaginated(int $page, int $pageSize, array $filters = []): array
77+
{
78+
$where = ["success = 0"];
79+
$params = [];
80+
81+
if (!empty($filters['username'])) { $where[] = "username = :u"; $params['u'] = $filters['username']; }
82+
if (!empty($filters['ip'])) { $where[] = "user_ip = :ip"; $params['ip'] = $filters['ip']; }
83+
if (!empty($filters['from'])) { $where[] = "login_date >= :fr"; $params['fr'] = $filters['from']; }
84+
if (!empty($filters['to'])) { $where[] = "login_date <= :to"; $params['to'] = $filters['to']; }
85+
86+
$whereSql = $where ? ('WHERE '.implode(' AND ', $where)) : '';
87+
$offset = ($page - 1) * $pageSize;
88+
$conn = $this->getEntityManager()->getConnection();
89+
90+
$rows = $conn->executeQuery(
91+
"SELECT login_date, user_ip, username
92+
FROM track_e_login_record
93+
$whereSql
94+
ORDER BY login_date DESC
95+
LIMIT :lim OFFSET :off",
96+
$params + ['lim' => $pageSize, 'off' => $offset],
97+
['lim' => \PDO::PARAM_INT, 'off' => \PDO::PARAM_INT]
98+
)->fetchAllAssociative();
99+
100+
$total = (int) $conn->executeQuery(
101+
"SELECT COUNT(*) FROM track_e_login_record $whereSql", $params
102+
)->fetchOne();
103+
104+
return ['items' => $rows, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize];
105+
}
106+
107+
public function failedByDay(int $days = 7): array
108+
{
109+
$conn = $this->getEntityManager()->getConnection();
110+
$stmt = $conn->prepare("
111+
SELECT DATE(login_date) AS day, COUNT(*) AS failed
112+
FROM track_e_login_record
113+
WHERE success = 0
114+
AND login_date >= (CURRENT_DATE - INTERVAL :d DAY)
115+
GROUP BY day
116+
ORDER BY day ASC
117+
");
118+
$stmt->bindValue('d', $days, \PDO::PARAM_INT);
119+
120+
return $stmt->executeQuery()->fetchAllAssociative();
121+
}
122+
123+
public function successVsFailedByDay(int $days = 30): array
124+
{
125+
$sql = "
126+
SELECT DATE(login_date) AS day,
127+
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS success_cnt,
128+
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS failed_cnt
129+
FROM track_e_login_record
130+
WHERE login_date >= (CURRENT_DATE - INTERVAL :d DAY)
131+
GROUP BY day
132+
ORDER BY day ASC";
133+
$stmt = $this->getEntityManager()->getConnection()->prepare($sql);
134+
$stmt->bindValue('d', $days, \PDO::PARAM_INT);
135+
136+
return $stmt->executeQuery()->fetchAllAssociative();
137+
}
138+
139+
public function failedByHourOfDay(int $days = 7): array
140+
{
141+
$sql = "
142+
SELECT HOUR(login_date) AS hour, COUNT(*) AS failed
143+
FROM track_e_login_record
144+
WHERE success = 0
145+
AND login_date >= (CURRENT_DATE - INTERVAL :d DAY)
146+
GROUP BY hour ORDER BY hour";
147+
$stmt = $this->getEntityManager()->getConnection()->prepare($sql);
148+
$stmt->bindValue('d', $days, \PDO::PARAM_INT);
149+
return $stmt->executeQuery()->fetchAllAssociative();
150+
}
151+
152+
public function uniqueIpsByDay(int $days = 30): array
153+
{
154+
$sql = "
155+
SELECT DATE(login_date) AS day, COUNT(DISTINCT user_ip) AS unique_ips
156+
FROM track_e_login_record
157+
WHERE success = 0
158+
AND login_date >= (CURRENT_DATE - INTERVAL :d DAY)
159+
GROUP BY day ORDER BY day";
160+
$stmt = $this->getEntityManager()->getConnection()->prepare($sql);
161+
$stmt->bindValue('d', $days, \PDO::PARAM_INT);
162+
return $stmt->executeQuery()->fetchAllAssociative();
163+
}
34164
}

0 commit comments

Comments
 (0)