Files
ADconsle/app/Http/Controllers/Admin/ComputerController.php
T
kzbkowskiandClaude Sonnet 5 1126d96271
CI / PHP 8.2 (push) Failing after 2m46s
CI / PHP 8.3 (push) Failing after 59s
CI / PHP 8.4 (push) Failing after 1m7s
ADconsole v1.0.0
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
2026-09-08 23:14:39 +02:00

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')),
];
}
}