First public release. - docs: document the audit log, user offboarding, CSV export and the "require a reason for destructive actions" setting across README, docs/ and CLAUDE/AGENTS.md; note retention for panel_audit - docs: fill the publishing placeholders (repo URL, security contact, issue-template links) and add a semantic-versioning policy - move agent/contributor context into CLAUDE/ (AGENTS.md + a README); root CLAUDE.md is now a thin @-import pointer - add a favicon (shield-lock, matching the login screen) to the login page and the app layout - CHANGELOG: promote [Unreleased] to [1.0.0]; keep the earlier internal build as [0.1.0] Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UfuvfwexztdS7LDpGuKsuK
350 lines
14 KiB
PHP
350 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Admin;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Ldap\User;
|
|
use App\Services\LdapAttributeHelper;
|
|
use App\Services\PanelSettingsService;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use LdapRecord\Models\ActiveDirectory\Computer;
|
|
use LdapRecord\Models\ActiveDirectory\Group;
|
|
use LdapRecord\Query\Model\Builder as LdapBuilder;
|
|
|
|
class DashboardController extends Controller
|
|
{
|
|
/** Czas życia cache pulpitu (sekundy). */
|
|
protected const CACHE_TTL = 300;
|
|
|
|
/** Konfigurowalne karty raportów (pokaż/ukryj + kolejność - preferencje w cookie). */
|
|
public const REPORT_BLOCKS = [
|
|
'r_expiring', 'r_pwd_never', 'r_locked', 'r_no_manager',
|
|
'r_inactive', 'r_computers_inactive', 'r_recent',
|
|
];
|
|
|
|
/**
|
|
* Pulpit z podsumowaniem stanu domeny + krótkie raporty.
|
|
* Wynik cache'owany na 5 minut (przelicza wszystkie konta w PHP).
|
|
*/
|
|
public function index(Request $request, PanelSettingsService $settings)
|
|
{
|
|
$data = Cache::store('file')->remember(
|
|
'dashboard:summary',
|
|
self::CACHE_TTL,
|
|
fn () => $this->build($settings->passwordMaxAgeDays()),
|
|
);
|
|
|
|
$tsRaw = Cache::store('file')->get('dashboard:summary_ts');
|
|
$refreshedAt = $tsRaw ? Carbon::parse($tsRaw) : now();
|
|
|
|
Carbon::setLocale(app()->getLocale());
|
|
|
|
$data['refreshed_at'] = $refreshedAt->format('Y-m-d H:i');
|
|
$data['refreshed_at_human'] = $refreshedAt->diffForHumans();
|
|
// Dane powinny pochodzić z ostatniego cyklu cache; większy odstęp = coś
|
|
// nie przelicza się w tle (np. błąd LDAP przy odświeżaniu).
|
|
$data['refreshed_stale'] = $refreshedAt->lt(now()->subSeconds(self::CACHE_TTL + 60));
|
|
|
|
// Pusty stan globalny - LDAP nic nie zwrócił (błąd połączenia / złe OU).
|
|
$data['empty'] = ($data['stats']['users_total'] ?? 0) === 0
|
|
&& ($data['computers_total'] ?? 0) === 0
|
|
&& ($data['groups_total'] ?? 0) === 0;
|
|
|
|
// Preferencje układu pulpitu (widoczność sekcji + kolejność kart) - cookie.
|
|
$data['layout'] = $this->dashboardLayout($request);
|
|
|
|
return view('admin.dashboard.index', $data);
|
|
}
|
|
|
|
public function refresh()
|
|
{
|
|
Cache::store('file')->forget('dashboard:summary');
|
|
Cache::store('file')->forget('dashboard:summary_ts');
|
|
|
|
return redirect()->route('dashboard.index');
|
|
}
|
|
|
|
/**
|
|
* Zapisuje preferencje układu pulpitu w cookie (rok ważności).
|
|
*/
|
|
public function savePreferences(Request $request): RedirectResponse
|
|
{
|
|
$order = array_values(array_filter(
|
|
(array) $request->input('report_order', []),
|
|
fn ($k) => in_array($k, self::REPORT_BLOCKS, true),
|
|
));
|
|
|
|
// Dołóż karty pominięte w formularzu (np. dodane w nowej wersji).
|
|
foreach (self::REPORT_BLOCKS as $k) {
|
|
if (! in_array($k, $order, true)) {
|
|
$order[] = $k;
|
|
}
|
|
}
|
|
|
|
$visible = array_flip((array) $request->input('visible', []));
|
|
|
|
$payload = [
|
|
'tiles' => $request->boolean('section_tiles'),
|
|
'pwd_age' => $request->boolean('section_pwd_age'),
|
|
'reports' => array_map(
|
|
fn ($k) => ['key' => $k, 'visible' => isset($visible[$k])],
|
|
$order,
|
|
),
|
|
];
|
|
|
|
return redirect()
|
|
->route('dashboard.index')
|
|
->withCookie(cookie('dashboard_layout', json_encode($payload), 60 * 24 * 365));
|
|
}
|
|
|
|
/**
|
|
* Odczytuje preferencje układu z cookie i uzupełnia brakami z domyślnych.
|
|
*
|
|
* @return array{tiles:bool, pwd_age:bool, reports:list<array{key:string,visible:bool}>}
|
|
*/
|
|
protected function dashboardLayout(Request $request): array
|
|
{
|
|
$stored = json_decode((string) $request->cookie('dashboard_layout'), true);
|
|
$stored = is_array($stored) ? $stored : [];
|
|
|
|
$layout = [
|
|
'tiles' => ! array_key_exists('tiles', $stored) || (bool) $stored['tiles'],
|
|
'pwd_age' => ! array_key_exists('pwd_age', $stored) || (bool) $stored['pwd_age'],
|
|
'reports' => [],
|
|
];
|
|
|
|
$seen = [];
|
|
foreach ((array) ($stored['reports'] ?? []) as $r) {
|
|
$key = is_array($r) ? ($r['key'] ?? null) : null;
|
|
if (in_array($key, self::REPORT_BLOCKS, true) && ! isset($seen[$key])) {
|
|
$seen[$key] = true;
|
|
$layout['reports'][] = ['key' => $key, 'visible' => (bool) ($r['visible'] ?? true)];
|
|
}
|
|
}
|
|
foreach (self::REPORT_BLOCKS as $key) {
|
|
if (! isset($seen[$key])) {
|
|
$layout['reports'][] = ['key' => $key, 'visible' => true];
|
|
}
|
|
}
|
|
|
|
return $layout;
|
|
}
|
|
|
|
protected function build(int $maxPasswordAgeDays): array
|
|
{
|
|
$userOu = config('ldap.defaults.user_ou');
|
|
|
|
$now = now();
|
|
$cutInactive = $now->copy()->subDays(90);
|
|
$cutRecent = $now->copy()->subDays(30);
|
|
$expiryHorizon = $now->copy()->addDays(7);
|
|
|
|
$stats = [
|
|
'users_total' => 0,
|
|
'users_disabled' => 0,
|
|
'users_locked' => 0,
|
|
'pwd_never_expires' => 0,
|
|
'pwd_expiring_7d' => 0,
|
|
'pwd_expired' => 0,
|
|
'inactive_90d' => 0,
|
|
'account_expired' => 0,
|
|
];
|
|
|
|
$stats['users_no_manager'] = 0;
|
|
|
|
// Histogram wieku hasła (dni od pwdLastSet).
|
|
$pwdAge = ['lt30' => 0, 'd30_60' => 0, 'd60_90' => 0, 'gt90' => 0, 'unknown' => 0];
|
|
|
|
$expiringSoon = [];
|
|
$inactive = [];
|
|
$recent = [];
|
|
$pwdNever = [];
|
|
$locked = [];
|
|
$noManager = [];
|
|
|
|
// chunk() strumieniuje kolejne strony (paged results) - nie trzymamy w
|
|
// pamięci wszystkich obiektów naraz i liczymy KAŻDE konto w domenie.
|
|
User::query()
|
|
->in($userOu)->recursive()
|
|
->select(['cn', 'samaccountname', 'userAccountControl', 'pwdLastSet', 'lockoutTime', 'lastLogonTimestamp', 'accountExpires', 'whenCreated', 'mail', 'manager', 'directReports'])
|
|
->orderBy('cn')
|
|
->chunk(1000, function ($users) use (
|
|
&$stats, &$pwdAge, &$expiringSoon, &$inactive, &$recent, &$pwdNever, &$locked, &$noManager,
|
|
$now, $cutInactive, $cutRecent, $expiryHorizon, $maxPasswordAgeDays
|
|
) {
|
|
foreach ($users as $u) {
|
|
$stats['users_total']++;
|
|
|
|
$disabled = LdapAttributeHelper::isDisabled($u);
|
|
if ($disabled) {
|
|
$stats['users_disabled']++;
|
|
}
|
|
if (LdapAttributeHelper::isLocked($u)) {
|
|
$stats['users_locked']++;
|
|
$lockedSince = LdapAttributeHelper::windowsTimestampToCarbon($u->getFirstAttribute('lockouttime'));
|
|
$locked[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname'), 'since' => $lockedSince?->format('Y-m-d H:i') ?? '—'];
|
|
}
|
|
if (LdapAttributeHelper::passwordNeverExpires($u)) {
|
|
$stats['pwd_never_expires']++;
|
|
if (! $disabled) {
|
|
$pwdNever[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname')];
|
|
}
|
|
}
|
|
|
|
// "Dziura w org-chart": aktywne konto bez przełożonego i bez podwładnych.
|
|
if (! $disabled
|
|
&& trim((string) $u->getFirstAttribute('manager')) === ''
|
|
&& count(array_filter(array_map('trim', $u->getAttribute('directreports') ?? []))) === 0) {
|
|
$stats['users_no_manager']++;
|
|
$noManager[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname')];
|
|
}
|
|
|
|
$exp = LdapAttributeHelper::passwordExpiresAt($u, $maxPasswordAgeDays);
|
|
if ($exp) {
|
|
if ($exp->isPast()) {
|
|
$stats['pwd_expired']++;
|
|
} elseif ($exp->lte($expiryHorizon)) {
|
|
$stats['pwd_expiring_7d']++;
|
|
$expiringSoon[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname'), 'date' => $exp->format('Y-m-d')];
|
|
}
|
|
}
|
|
|
|
$lastSet = LdapAttributeHelper::passwordLastSet($u);
|
|
if (! $lastSet) {
|
|
$pwdAge['unknown']++;
|
|
} else {
|
|
$ageDays = (int) abs($lastSet->diffInDays($now));
|
|
if ($ageDays < 30) {
|
|
$pwdAge['lt30']++;
|
|
} elseif ($ageDays < 60) {
|
|
$pwdAge['d30_60']++;
|
|
} elseif ($ageDays < 90) {
|
|
$pwdAge['d60_90']++;
|
|
} else {
|
|
$pwdAge['gt90']++;
|
|
}
|
|
}
|
|
|
|
$lastLogon = LdapAttributeHelper::windowsTimestampToCarbon($u->getFirstAttribute('lastlogontimestamp'));
|
|
if (! $disabled && (! $lastLogon || $lastLogon->lt($cutInactive))) {
|
|
$stats['inactive_90d']++;
|
|
$inactive[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname'), 'last' => $lastLogon?->format('Y-m-d') ?? 'nigdy'];
|
|
}
|
|
|
|
$accExp = LdapAttributeHelper::accountExpiresAt($u);
|
|
if ($accExp && $accExp->isPast()) {
|
|
$stats['account_expired']++;
|
|
}
|
|
|
|
$created = $this->parseWhenCreated($u->getFirstAttribute('whencreated'));
|
|
if ($created && $created->gt($cutRecent)) {
|
|
$recent[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname'), 'created' => $created->format('Y-m-d')];
|
|
}
|
|
}
|
|
});
|
|
|
|
usort($expiringSoon, fn ($a, $b) => strcmp($a['date'], $b['date']));
|
|
usort($inactive, fn ($a, $b) => strcmp($a['last'], $b['last']));
|
|
usort($recent, fn ($a, $b) => strcmp($b['created'], $a['created']));
|
|
usort($pwdNever, fn ($a, $b) => strcasecmp((string) $a['cn'], (string) $b['cn']));
|
|
usort($locked, fn ($a, $b) => strcmp($b['since'], $a['since']));
|
|
usort($noManager, fn ($a, $b) => strcasecmp((string) $a['cn'], (string) $b['cn']));
|
|
|
|
$computersInactive = $this->scanInactiveComputers($cutInactive);
|
|
|
|
Cache::store('file')->put('dashboard:summary_ts', $now->format('Y-m-d H:i:s'), self::CACHE_TTL);
|
|
|
|
$stats['computers_inactive_90d'] = $computersInactive['count'];
|
|
|
|
return [
|
|
'stats' => $stats,
|
|
'pwd_age' => $pwdAge,
|
|
'computers_total' => $this->countAll(
|
|
Computer::query()->in(config('ldap.defaults.computer_ou'))->recursive()->select(['cn'])
|
|
),
|
|
'groups_total' => $this->countAll(
|
|
Group::query()->in(config('ldap.defaults.groups_ou') ?: $userOu)->recursive()->select(['cn'])
|
|
),
|
|
'expiring_soon' => array_slice($expiringSoon, 0, 15),
|
|
'inactive' => array_slice($inactive, 0, 15),
|
|
'recent' => array_slice($recent, 0, 15),
|
|
'pwd_never_list' => array_slice($pwdNever, 0, 50),
|
|
'locked_list' => array_slice($locked, 0, 50),
|
|
'no_manager_list' => array_slice($noManager, 0, 50),
|
|
'computers_inactive' => array_slice($computersInactive['list'], 0, 50),
|
|
'max_password_age_days' => $maxPasswordAgeDays,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Skan kont komputerów: aktywne, których lastLogonTimestamp jest starszy niż
|
|
* próg (lub w ogóle brak logowania). Strumieniuje strony jak countAll().
|
|
*
|
|
* @return array{count:int, list:list<array{cn:string,last:string}>}
|
|
*/
|
|
protected function scanInactiveComputers(Carbon $cutInactive): array
|
|
{
|
|
$count = 0;
|
|
$list = [];
|
|
|
|
Computer::query()
|
|
->in(config('ldap.defaults.computer_ou'))->recursive()
|
|
->select(['cn', 'lastLogonTimestamp', 'userAccountControl'])
|
|
->orderBy('cn')
|
|
->chunk(1000, function ($computers) use (&$count, &$list, $cutInactive) {
|
|
foreach ($computers as $c) {
|
|
if ((int) ($c->getFirstAttribute('useraccountcontrol') ?? 0) & LdapAttributeHelper::UAC_ACCOUNTDISABLE) {
|
|
continue;
|
|
}
|
|
|
|
$last = LdapAttributeHelper::windowsTimestampToCarbon($c->getFirstAttribute('lastlogontimestamp'));
|
|
if ($last && $last->gte($cutInactive)) {
|
|
continue;
|
|
}
|
|
|
|
$count++;
|
|
$list[] = ['cn' => $c->getFirstAttribute('cn'), 'last' => $last?->format('Y-m-d') ?? '—'];
|
|
}
|
|
});
|
|
|
|
usort($list, fn ($a, $b) => strcmp($a['last'], $b['last']));
|
|
|
|
return ['count' => $count, 'list' => $list];
|
|
}
|
|
|
|
/**
|
|
* Liczy wszystkie obiekty pasujące do zapytania, strumieniując strony -
|
|
* bez materializowania całej listy modeli w pamięci.
|
|
*/
|
|
protected function countAll(LdapBuilder $query): int
|
|
{
|
|
$count = 0;
|
|
|
|
$query->chunk(1000, function ($rows) use (&$count) {
|
|
$count += count($rows);
|
|
});
|
|
|
|
return $count;
|
|
}
|
|
|
|
protected function parseWhenCreated(?string $value): ?Carbon
|
|
{
|
|
if (! $value) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return Carbon::createFromFormat('YmdHis.0Z', $value);
|
|
} catch (\Throwable) {
|
|
try {
|
|
return Carbon::parse($value);
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
}
|