Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions assets/vue/composables/admin/indexBlocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export function useIndexBlocks() {
const blockSettings = ref(null)
const blockPlatform = ref(null)
const blockChamilo = ref(null)
const blockSecurity = ref(null)

async function loadBlocks() {
const blocks = await adminService.findBlocks()
Expand All @@ -91,6 +92,7 @@ export function useIndexBlocks() {
blockSettings.value = blocks.settings || null
blockPlatform.value = blocks.platform || null
blockChamilo.value = blocks.chamilo || null
blockSecurity.value = blocks.security || null
}

return {
Expand All @@ -105,6 +107,7 @@ export function useIndexBlocks() {
blockSettings,
blockPlatform,
blockChamilo,
blockSecurity,
loadBlocks,
blockNewsStatusEl,
loadNews,
Expand Down
11 changes: 11 additions & 0 deletions assets/vue/views/admin/AdminIndex.vue
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,16 @@
:title="t('Platform management')"
icon="admin-settings"
/>
<AdminBlock
v-if="blockSecurity"
:id="blockSecurity.id"
v-model:extra-content="blockSecurity.extraContent"
:description="t('Security tools and reports')"
:editable="blockSecurity.editable"
:items="blockSecurity.items"
:title="t('Security')"
icon="shield-check"
/>

<AdminBlock
v-if="blockChamilo"
Expand Down Expand Up @@ -258,6 +268,7 @@ const {
blockSettings,
blockPlatform,
blockChamilo,
blockSecurity,
loadBlocks,
blockNewsStatusEl,
blockSupportStatusEl,
Expand Down
18 changes: 18 additions & 0 deletions src/CoreBundle/Controller/Admin/IndexBlocksController.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ public function __invoke(): JsonResponse
];
}

$json['security'] = [
'id' => 'block-admin-security',
'editable' => false,
'items' => $this->getItemsSecurity(),
'extraContent' => $this->getExtraContent('block-admin-security'),
];

/* Chamilo.org */
$json['chamilo'] = [
'id' => 'block-admin-chamilo',
Expand Down Expand Up @@ -147,6 +154,17 @@ public function __invoke(): JsonResponse
return $this->json($json);
}

private function getItemsSecurity(): array
{
return [
[
'class' => 'item-security-login-attempts',
'url' => $this->generateUrl('admin_security_login_attempts'),
'label' => $this->translator->trans('Login attempts'),
],
];
}

private function getItemsUsers(): array
{
$items = [];
Expand Down
54 changes: 54 additions & 0 deletions src/CoreBundle/Controller/Admin/SecurityController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php
/* For licensing terms, see /license.txt */

declare(strict_types=1);

namespace Chamilo\CoreBundle\Controller\Admin;

use Chamilo\CoreBundle\Controller\BaseController;
use Chamilo\CoreBundle\Repository\TrackELoginRecordRepository;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/admin/security')]
final class SecurityController extends BaseController
{
public function __construct(private readonly TrackELoginRecordRepository $repo) {}

#[IsGranted('ROLE_ADMIN')]
#[Route('/login-attempts', name: 'admin_security_login_attempts', methods: ['GET'])]
public function loginAttempts(Request $r): Response
{
$page = max(1, $r->query->getInt('page', 1));
$pageSize = min(100, max(1, $r->query->getInt('pageSize', 25)));
$filters = [
'username' => trim((string) $r->query->get('username', '')),
'ip' => trim((string) $r->query->get('ip', '')),
'from' => $r->query->get('from'),
'to' => $r->query->get('to'),
];

$list = $this->repo->findFailedPaginated($page, $pageSize, $filters);

$stats = [
'byDay' => $this->repo->failedByDay(7),
'byMonth' => $this->repo->failedByMonth(12),
'topUsernames' => $this->repo->topUsernames(30, 5),
'topIps' => $this->repo->topIps(30, 5),
'successVsFailed' => $this->repo->successVsFailedByDay(30),
'byHour' => $this->repo->failedByHourOfDay(7),
'uniqueIps' => $this->repo->uniqueIpsByDay(30),
];

return $this->render('@ChamiloCore/Admin/Security/login_attempts.html.twig', [
'items' => $list['items'],
'total' => $list['total'],
'page' => $list['page'],
'pageSize' => $list['pageSize'],
'filters' => $filters,
'stats' => $stats,
]);
}
}
130 changes: 130 additions & 0 deletions src/CoreBundle/Repository/TrackELoginRecordRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,134 @@ public function addTrackLogin(string $username, string $userIp, bool $success):
$this->_em->persist($trackELoginRecord);
$this->_em->flush();
}

public function failedByMonth(int $months = 12): array
{
$sql = "
SELECT DATE_FORMAT(login_date, '%Y-%m-01') AS month, COUNT(*) AS failed
FROM track_e_login_record
WHERE success = 0 AND login_date >= (CURRENT_DATE - INTERVAL :m MONTH)
GROUP BY month ORDER BY month ASC
";
return $this->getEntityManager()->getConnection()
->executeQuery($sql, ['m' => $months])
->fetchAllAssociative();
}

public function topUsernames(int $days = 30, int $limit = 5): array
{
$conn = $this->getEntityManager()->getConnection();
$stmt = $conn->prepare("
SELECT username, COUNT(*) AS failed
FROM track_e_login_record
WHERE success = 0 AND login_date >= (CURRENT_DATE - INTERVAL :d DAY)
GROUP BY username ORDER BY failed DESC LIMIT :l
");
$stmt->bindValue('d', $days, \PDO::PARAM_INT);
$stmt->bindValue('l', $limit, \PDO::PARAM_INT);
return $stmt->executeQuery()->fetchAllAssociative();
}

public function topIps(int $days = 30, int $limit = 5): array
{
$conn = $this->getEntityManager()->getConnection();
$stmt = $conn->prepare("
SELECT user_ip AS ip, COUNT(*) AS failed
FROM track_e_login_record
WHERE success = 0 AND login_date >= (CURRENT_DATE - INTERVAL :d DAY)
GROUP BY user_ip ORDER BY failed DESC LIMIT :l
");
$stmt->bindValue('d', $days, \PDO::PARAM_INT);
$stmt->bindValue('l', $limit, \PDO::PARAM_INT);
return $stmt->executeQuery()->fetchAllAssociative();
}

public function findFailedPaginated(int $page, int $pageSize, array $filters = []): array
{
$where = ["success = 0"];
$params = [];

if (!empty($filters['username'])) { $where[] = "username = :u"; $params['u'] = $filters['username']; }
if (!empty($filters['ip'])) { $where[] = "user_ip = :ip"; $params['ip'] = $filters['ip']; }
if (!empty($filters['from'])) { $where[] = "login_date >= :fr"; $params['fr'] = $filters['from']; }
if (!empty($filters['to'])) { $where[] = "login_date <= :to"; $params['to'] = $filters['to']; }

$whereSql = $where ? ('WHERE '.implode(' AND ', $where)) : '';
$offset = ($page - 1) * $pageSize;
$conn = $this->getEntityManager()->getConnection();

$rows = $conn->executeQuery(
"SELECT login_date, user_ip, username
FROM track_e_login_record
$whereSql
ORDER BY login_date DESC
LIMIT :lim OFFSET :off",
$params + ['lim' => $pageSize, 'off' => $offset],
['lim' => \PDO::PARAM_INT, 'off' => \PDO::PARAM_INT]
)->fetchAllAssociative();

$total = (int) $conn->executeQuery(
"SELECT COUNT(*) FROM track_e_login_record $whereSql", $params
)->fetchOne();

return ['items' => $rows, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize];
}

public function failedByDay(int $days = 7): array
{
$conn = $this->getEntityManager()->getConnection();
$stmt = $conn->prepare("
SELECT DATE(login_date) AS day, COUNT(*) AS failed
FROM track_e_login_record
WHERE success = 0
AND login_date >= (CURRENT_DATE - INTERVAL :d DAY)
GROUP BY day
ORDER BY day ASC
");
$stmt->bindValue('d', $days, \PDO::PARAM_INT);

return $stmt->executeQuery()->fetchAllAssociative();
}

public function successVsFailedByDay(int $days = 30): array
{
$sql = "
SELECT DATE(login_date) AS day,
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS success_cnt,
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS failed_cnt
FROM track_e_login_record
WHERE login_date >= (CURRENT_DATE - INTERVAL :d DAY)
GROUP BY day
ORDER BY day ASC";
$stmt = $this->getEntityManager()->getConnection()->prepare($sql);
$stmt->bindValue('d', $days, \PDO::PARAM_INT);

return $stmt->executeQuery()->fetchAllAssociative();
}

public function failedByHourOfDay(int $days = 7): array
{
$sql = "
SELECT HOUR(login_date) AS hour, COUNT(*) AS failed
FROM track_e_login_record
WHERE success = 0
AND login_date >= (CURRENT_DATE - INTERVAL :d DAY)
GROUP BY hour ORDER BY hour";
$stmt = $this->getEntityManager()->getConnection()->prepare($sql);
$stmt->bindValue('d', $days, \PDO::PARAM_INT);
return $stmt->executeQuery()->fetchAllAssociative();
}

public function uniqueIpsByDay(int $days = 30): array
{
$sql = "
SELECT DATE(login_date) AS day, COUNT(DISTINCT user_ip) AS unique_ips
FROM track_e_login_record
WHERE success = 0
AND login_date >= (CURRENT_DATE - INTERVAL :d DAY)
GROUP BY day ORDER BY day";
$stmt = $this->getEntityManager()->getConnection()->prepare($sql);
$stmt->bindValue('d', $days, \PDO::PARAM_INT);
return $stmt->executeQuery()->fetchAllAssociative();
}
}
Loading
Loading