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
367 lines
14 KiB
PHP
367 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Admin;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Services\AuditLogger;
|
|
use App\Services\LdapAttributeHelper;
|
|
use App\Services\OuTreeService;
|
|
use App\Services\PanelSettingsService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Carbon;
|
|
use LdapRecord\Models\ActiveDirectory\Computer;
|
|
use LdapRecord\Models\ActiveDirectory\Entry;
|
|
use LdapRecord\Models\ModelNotFoundException;
|
|
|
|
class ComputerController extends Controller
|
|
{
|
|
public function __construct(protected AuditLogger $audit) {}
|
|
|
|
public function index(Request $request, OuTreeService $ouTree)
|
|
{
|
|
if ($request->boolean('refresh_ou')) {
|
|
OuTreeService::flush();
|
|
}
|
|
|
|
$allOu = $request->boolean('all_ou');
|
|
$ouDn = $request->query('ou') ?: ($allOu ? null : $ouTree->rootDn('computers'));
|
|
|
|
$search = trim((string) $request->query('q', ''));
|
|
$os = $request->query('os');
|
|
$inactiveOnly = $request->boolean('inactive');
|
|
$perPage = max(10, min(200, (int) $request->query('per_page', 25)));
|
|
|
|
$query = Computer::query();
|
|
|
|
if ($ouDn) {
|
|
$query->in($ouDn)->recursive();
|
|
}
|
|
|
|
if ($search !== '') {
|
|
$searchAttrs = ['cn', 'dnshostname', 'operatingsystem', 'operatingsystemversion', 'description', 'samaccountname'];
|
|
$query->orFilter(function ($q) use ($searchAttrs, $search) {
|
|
foreach ($searchAttrs as $attr) {
|
|
$q->whereContains($attr, $search);
|
|
}
|
|
});
|
|
}
|
|
|
|
if ($os) {
|
|
$query->whereContains('operatingsystem', $os);
|
|
}
|
|
|
|
// 1. Pobieramy dopasowane komputery z LDAP (paged results control).
|
|
$allComputers = $query
|
|
->select(['cn', 'dnshostname', 'operatingsystem', 'operatingsystemversion', 'description', 'distinguishedname', 'useraccountcontrol', 'lastlogontimestamp'])
|
|
->orderBy('cn')
|
|
->paginate(500);
|
|
|
|
// 2. Mapujemy obiekty LdapRecord do formatu tabeli
|
|
$allRows = collect($allComputers)->map(fn (Computer $c) => $this->presentComputer($c));
|
|
|
|
// 2a. Filtr "nieaktywne >= 90 dni" (z kafelka pulpitu) - liczony w PHP.
|
|
if ($inactiveOnly) {
|
|
$cut = now()->subDays(90);
|
|
$allRows = $allRows->filter(
|
|
fn (array $r) => ! $r['disabled'] && (! $r['last_logon'] || $r['last_logon']->lt($cut))
|
|
)->values();
|
|
}
|
|
|
|
// 3. Budujemy natywny LengthAwarePaginator dla Laravela
|
|
$page = LengthAwarePaginator::resolveCurrentPage();
|
|
$total = $allRows->count();
|
|
|
|
$computers = new LengthAwarePaginator(
|
|
$allRows->forPage($page, $perPage)->values(),
|
|
$total,
|
|
$perPage,
|
|
$page,
|
|
['path' => LengthAwarePaginator::resolveCurrentPath()]
|
|
);
|
|
|
|
$computers->appends(collect($request->query())->except(['partial', 'page'])->all());
|
|
|
|
$viewData = [
|
|
'currentOu' => $ouDn,
|
|
'allOu' => $allOu,
|
|
'search' => $search,
|
|
'osFilter' => $os,
|
|
'inactiveOnly' => $inactiveOnly,
|
|
'computers' => $computers,
|
|
'rows' => collect($computers->items()),
|
|
];
|
|
|
|
if ($request->boolean('partial')) {
|
|
return view('admin.computers.partials.results', $viewData);
|
|
}
|
|
|
|
return view('admin.computers.index', $viewData + [
|
|
'ouTree' => $ouTree->tree('computers'),
|
|
'requireReason' => app(PanelSettingsService::class)->requireReason(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Włącza / wyłącza obiekt komputera w AD.
|
|
*/
|
|
/**
|
|
* Szczegóły komputera: LAPS (hasło lokalnego administratora, oba warianty)
|
|
* oraz klucze odzyskiwania BitLocker (obiekty msFVE-RecoveryInformation).
|
|
*/
|
|
public function show(string $cn): JsonResponse
|
|
{
|
|
$computer = Computer::query()
|
|
->select([
|
|
'cn', 'dnshostname', 'operatingsystem', 'operatingsystemversion',
|
|
'distinguishedname', 'useraccountcontrol', 'lastlogontimestamp', 'whencreated', 'description',
|
|
'ms-Mcs-AdmPwd', 'ms-Mcs-AdmPwdExpirationTime',
|
|
'msLAPS-Password', 'msLAPS-EncryptedPassword', 'msLAPS-PasswordExpirationTime',
|
|
])
|
|
->where('cn', '=', $cn)
|
|
->first();
|
|
|
|
if (! $computer) {
|
|
throw new ModelNotFoundException("Nie znaleziono komputera: {$cn}");
|
|
}
|
|
|
|
// LAPS - wariant klasyczny (ms-Mcs-AdmPwd) i Windows LAPS (msLAPS-Password: JSON).
|
|
$lapsLegacy = $computer->getFirstAttribute('ms-mcs-admpwd');
|
|
$lapsLegacyExp = LdapAttributeHelper::windowsTimestampToCarbon($computer->getFirstAttribute('ms-mcs-admpwdexpirationtime'));
|
|
|
|
$winLapsRaw = $computer->getFirstAttribute('mslaps-password');
|
|
$winLaps = null;
|
|
if ($winLapsRaw) {
|
|
$decoded = json_decode($winLapsRaw, true);
|
|
$winLaps = is_array($decoded)
|
|
? ['account' => $decoded['n'] ?? null, 'password' => $decoded['p'] ?? null, 'updated' => $decoded['t'] ?? null]
|
|
: ['account' => null, 'password' => $winLapsRaw, 'updated' => null];
|
|
}
|
|
$winLapsEncrypted = (bool) $computer->getFirstAttribute('mslaps-encryptedpassword');
|
|
$winLapsExp = LdapAttributeHelper::windowsTimestampToCarbon($computer->getFirstAttribute('mslaps-passwordexpirationtime'));
|
|
|
|
// BitLocker - obiekty potomne msFVE-RecoveryInformation pod obiektem komputera.
|
|
$bitlocker = [];
|
|
try {
|
|
$recovery = Entry::query()
|
|
->in($computer->getDn())
|
|
->whereEquals('objectclass', 'msFVE-RecoveryInformation')
|
|
->select(['cn', 'msfve-recoverypassword', 'msfve-recoveryguid', 'whencreated'])
|
|
->paginate(200);
|
|
|
|
foreach ($recovery as $rec) {
|
|
$guid = $rec->getFirstAttribute('msfve-recoveryguid');
|
|
$bitlocker[] = [
|
|
'password' => $rec->getFirstAttribute('msfve-recoverypassword'),
|
|
'guid' => $guid ? bin2hex($guid) : ($rec->getFirstAttribute('cn')),
|
|
'created' => $this->parseLdapDate($rec->getFirstAttribute('whencreated')),
|
|
];
|
|
}
|
|
} catch (\Throwable) {
|
|
// brak dostępu / brak obiektów
|
|
}
|
|
|
|
$lapsAvailable = (bool) ($lapsLegacy || $winLaps || $winLapsEncrypted);
|
|
|
|
// Odczyt wrażliwych danych też trafia do dziennika (kto zobaczył hasło
|
|
// lokalnego admina / klucze BitLocker komputera).
|
|
if ($lapsAvailable) {
|
|
$this->audit->log('computer.laps_view', [
|
|
'target_type' => 'computer', 'target' => $computer->getFirstAttribute('cn'), 'target_dn' => $computer->getDn(),
|
|
]);
|
|
}
|
|
if ($bitlocker !== []) {
|
|
$this->audit->log('computer.bitlocker_view', [
|
|
'target_type' => 'computer', 'target' => $computer->getFirstAttribute('cn'), 'target_dn' => $computer->getDn(),
|
|
'context' => ['keys' => count($bitlocker)],
|
|
]);
|
|
}
|
|
|
|
return response()->json([
|
|
'cn' => $computer->getFirstAttribute('cn'),
|
|
'dns_hostname' => $computer->getFirstAttribute('dnshostname'),
|
|
'os' => trim(($computer->getFirstAttribute('operatingsystem') ?? '').' '.($computer->getFirstAttribute('operatingsystemversion') ?? '')),
|
|
'dn' => $computer->getDn(),
|
|
'description' => $computer->getFirstAttribute('description'),
|
|
'last_logon' => optional(LdapAttributeHelper::windowsTimestampToCarbon($computer->getFirstAttribute('lastlogontimestamp')))->format('Y-m-d H:i'),
|
|
'created' => $this->parseLdapDate($computer->getFirstAttribute('whencreated')),
|
|
'laps' => [
|
|
'legacy_password' => $lapsLegacy,
|
|
'legacy_expires' => optional($lapsLegacyExp)->format('Y-m-d H:i'),
|
|
'windows' => $winLaps,
|
|
'windows_encrypted' => $winLapsEncrypted,
|
|
'windows_expires' => optional($winLapsExp)->format('Y-m-d H:i'),
|
|
'available' => $lapsAvailable,
|
|
],
|
|
'bitlocker' => $bitlocker,
|
|
]);
|
|
}
|
|
|
|
protected function parseLdapDate(?string $ldapDate): ?string
|
|
{
|
|
if (! $ldapDate) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return Carbon::createFromFormat('YmdHis.0Z', $ldapDate)->format('Y-m-d H:i');
|
|
} catch (\Throwable) {
|
|
return $ldapDate;
|
|
}
|
|
}
|
|
|
|
public function toggleEnabled(Request $request, string $cn): RedirectResponse
|
|
{
|
|
$computer = $this->findByCn($cn);
|
|
$uac = (int) ($computer->getFirstAttribute('userAccountControl') ?? 0);
|
|
|
|
if ($uac & LdapAttributeHelper::UAC_ACCOUNTDISABLE) {
|
|
$uac &= ~LdapAttributeHelper::UAC_ACCOUNTDISABLE;
|
|
$message = __('computers.flash.enabled', ['cn' => $cn]);
|
|
$action = 'computer.enable';
|
|
} else {
|
|
$uac |= LdapAttributeHelper::UAC_ACCOUNTDISABLE;
|
|
$message = __('computers.flash.disabled', ['cn' => $cn]);
|
|
$action = 'computer.disable';
|
|
}
|
|
|
|
$computer->userAccountControl = $uac;
|
|
$computer->save();
|
|
|
|
$this->audit->log($action, [
|
|
'target_type' => 'computer', 'target' => $cn, 'target_dn' => $computer->getDn(),
|
|
'reason' => $request->input('reason') ?: null,
|
|
]);
|
|
|
|
return back()->with('status', $message);
|
|
}
|
|
|
|
/**
|
|
* Resetuje konto zaufania komputera (nowe losowe hasło konta maszyny).
|
|
* Odpowiednik "Reset Account" w ADUC - wymusza ponowne dołączenie do
|
|
* domeny przy następnym starcie, jeśli sekret jest niezgodny.
|
|
*/
|
|
public function resetTrustAccount(Request $request, string $cn): RedirectResponse
|
|
{
|
|
$computer = $this->findByCn($cn);
|
|
|
|
$randomPassword = bin2hex(random_bytes(16));
|
|
$computer->unicodepwd = iconv('UTF-8', 'UTF-16LE', '"'.$randomPassword.'"');
|
|
$computer->save();
|
|
|
|
$this->audit->log('computer.reset_trust', [
|
|
'target_type' => 'computer', 'target' => $cn, 'target_dn' => $computer->getDn(),
|
|
'reason' => $request->input('reason') ?: null,
|
|
]);
|
|
|
|
return back()->with('status', __('computers.flash.trust_reset', ['cn' => $cn]));
|
|
}
|
|
|
|
public function destroy(Request $request, string $cn): RedirectResponse
|
|
{
|
|
$computer = $this->findByCn($cn);
|
|
$dn = $computer->getDn();
|
|
$computer->delete();
|
|
|
|
$this->audit->log('computer.delete', [
|
|
'target_type' => 'computer', 'target' => $cn, 'target_dn' => $dn,
|
|
'reason' => $request->input('reason') ?: null,
|
|
]);
|
|
|
|
return back()->with('status', __('computers.flash.deleted', ['cn' => $cn]));
|
|
}
|
|
|
|
/**
|
|
* Akcje masowe (wg zaznaczonych wierszy listy komputerów).
|
|
*/
|
|
public function bulkSetEnabled(Request $request): RedirectResponse
|
|
{
|
|
$data = $request->validate([
|
|
'cns' => ['required', 'array', 'min:1'],
|
|
'cns.*' => ['string'],
|
|
'enabled' => ['nullable', 'boolean'],
|
|
'reason' => ['nullable', 'string', 'max:1000'],
|
|
]);
|
|
|
|
$enabled = (bool) ($data['enabled'] ?? false);
|
|
|
|
foreach ($data['cns'] as $cn) {
|
|
$computer = $this->findByCn($cn);
|
|
$uac = (int) ($computer->getFirstAttribute('userAccountControl') ?? 0);
|
|
|
|
if ($enabled) {
|
|
$uac &= ~LdapAttributeHelper::UAC_ACCOUNTDISABLE;
|
|
} else {
|
|
$uac |= LdapAttributeHelper::UAC_ACCOUNTDISABLE;
|
|
}
|
|
|
|
$computer->userAccountControl = $uac;
|
|
$computer->save();
|
|
}
|
|
|
|
$this->audit->log($enabled ? 'computer.bulk_enable' : 'computer.bulk_disable', [
|
|
'target_type' => 'computer',
|
|
'reason' => $data['reason'] ?? null,
|
|
'context' => ['count' => count($data['cns']), 'computers' => array_slice($data['cns'], 0, 200)],
|
|
]);
|
|
|
|
return back()->with('status', __(
|
|
$enabled ? 'computers.flash.bulk_enabled' : 'computers.flash.bulk_disabled',
|
|
['count' => count($data['cns'])],
|
|
));
|
|
}
|
|
|
|
public function bulkMove(Request $request): RedirectResponse
|
|
{
|
|
$data = $request->validate([
|
|
'cns' => ['required', 'array', 'min:1'],
|
|
'cns.*' => ['string'],
|
|
'target_ou' => ['required', 'string'],
|
|
'reason' => ['nullable', 'string', 'max:1000'],
|
|
]);
|
|
|
|
foreach ($data['cns'] as $cn) {
|
|
$this->findByCn($cn)->move($data['target_ou']);
|
|
}
|
|
|
|
$this->audit->log('computer.bulk_move', [
|
|
'target_type' => 'computer',
|
|
'reason' => $data['reason'] ?? null,
|
|
'context' => ['count' => count($data['cns']), 'target_ou' => $data['target_ou'], 'computers' => array_slice($data['cns'], 0, 200)],
|
|
]);
|
|
|
|
return back()->with('status', __('computers.flash.bulk_moved', ['count' => count($data['cns'])]));
|
|
}
|
|
|
|
protected function findByCn(string $cn): Computer
|
|
{
|
|
$computer = Computer::query()->where('cn', '=', $cn)->first();
|
|
|
|
if (! $computer) {
|
|
throw new ModelNotFoundException("Nie znaleziono komputera: {$cn}");
|
|
}
|
|
|
|
return $computer;
|
|
}
|
|
|
|
protected function presentComputer(Computer $computer): array
|
|
{
|
|
$uac = (int) ($computer->getFirstAttribute('userAccountControl') ?? 0);
|
|
$disabled = (bool) ($uac & LdapAttributeHelper::UAC_ACCOUNTDISABLE);
|
|
|
|
return [
|
|
'cn' => $computer->getFirstAttribute('cn'),
|
|
'dns_hostname' => $computer->getFirstAttribute('dnshostname'),
|
|
'os' => $computer->getFirstAttribute('operatingsystem'),
|
|
'os_version' => $computer->getFirstAttribute('operatingsystemversion'),
|
|
'dn' => $computer->getDn(),
|
|
'disabled' => $disabled,
|
|
'status_label' => $disabled ? __('common.account_status.disabled') : __('common.account_status.active'),
|
|
'status_css' => $disabled ? 'badge-status-disabled' : 'badge-status-active',
|
|
'last_logon' => LdapAttributeHelper::windowsTimestampToCarbon($computer->getFirstAttribute('lastlogontimestamp')),
|
|
];
|
|
}
|
|
}
|