mirror of
https://github.com/snipe/snipe-it.git
synced 2026-08-18 11:15:42 +00:00
1437 lines
57 KiB
PHP
1437 lines
57 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire;
|
|
|
|
use App\Models\Setting;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Livewire\Attributes\Computed;
|
|
use Livewire\Attributes\Url;
|
|
use Livewire\Component;
|
|
|
|
/**
|
|
* Multi-step Livewire wizard replacing the monolithic settings/ldap.blade.php.
|
|
*
|
|
* Four-step wizard: Connection, Authenticate + Scope (bind creds +
|
|
* base DN + filters), Attribute Mapping, and Sync + Defaults.
|
|
*
|
|
* Per-step dispatch: saveAndAdvance() and canAdvance() both switch on
|
|
* $currentStep to the appropriate step-specific method. Adding a new
|
|
* step means implementing saveStep{N}(), canAdvanceStep{N}(), and
|
|
* runStep{N}NetworkTest() and appending them to the match() arms.
|
|
*/
|
|
class LdapSettings extends Component
|
|
{
|
|
// Test connection button ceiling. 10 attempts/minute per user per step is
|
|
// comfortable for real debugging and useless for probing a meaningful
|
|
// port range across even a single /24. Each step gets its own budget
|
|
// via its own rate-limit key so a user legitimately debugging bind
|
|
// credentials doesn't exhaust the connection-test budget.
|
|
private const TEST_RATE_LIMIT_ATTEMPTS = 10;
|
|
|
|
private const TEST_RATE_LIMIT_DECAY_SECONDS = 60;
|
|
|
|
// Cap on ldap_bind() network activity. 5s is generous enough for a
|
|
// real LDAPS handshake (Google Cloud Identity, Microsoft Entra) which
|
|
// can push past 3s on the TLS negotiation alone.
|
|
//
|
|
// Note: there is no uniform response-time floor. An earlier iteration
|
|
// padded every response to a fixed duration to defeat stopwatch
|
|
// enumeration of internal targets, but the rate limit (10 tests/min
|
|
// per user per step) + IP filter (blocks all RFC1918 / loopback /
|
|
// cloud-metadata ranges) already cap the practical enumeration
|
|
// surface. Adding a multi-second wait to every legit save-and-
|
|
// continue click for a defense that rate-limit already handles is
|
|
// a bad UX trade-off.
|
|
private const TEST_NETWORK_TIMEOUT_SECONDS = 5;
|
|
|
|
// Wizard nav state. currentStep is URL-synced (?step=N) so a page
|
|
// reload keeps the user where they were. mount() clamps to
|
|
// highestStepReached so URL fuzzing can't skip past locked steps,
|
|
// though that would be kinda dumb to do anyway, since you'd
|
|
// just be saving bad data that won't work with LDAP
|
|
#[Url(as: 'step')]
|
|
public int $currentStep = 1;
|
|
|
|
public int $highestStepReached = 1;
|
|
|
|
// Flipped true by any updated() hook and back to false on successful
|
|
// save. Blade uses this to arm wire:confirm on wizard-header buttons
|
|
// so users with unsaved edits get prompted before losing them to a
|
|
// step jump. saveStep{N}() uses it to skip network tests when nothing
|
|
// has changed since the last successful save.
|
|
public bool $dirty = false;
|
|
|
|
// Alert state (top-of-body). recordTestResult() populates.
|
|
public ?string $testStatus = null;
|
|
|
|
public ?string $testMessage = null;
|
|
|
|
// Which step number was just completed on this render cycle.
|
|
// Populated by persistAndAdvance() the moment before it advances
|
|
// to the next step, consumed by the stepper's CSS animation so
|
|
// the newly-checked step spins a yellow star that morphs into a
|
|
// green checkmark. Cleared on goToStep, disableLdap, and
|
|
// finishWizard so back-nav doesn't retrigger the animation.
|
|
public ?int $justCompletedStep = null;
|
|
|
|
// Step 1: Connection
|
|
public bool $ldap_enabled = false;
|
|
|
|
public bool $is_ad = false;
|
|
|
|
public string $ad_domain = '';
|
|
|
|
public string $ldap_server = '';
|
|
|
|
public bool $ldap_tls = false;
|
|
|
|
public bool $ldap_server_cert_ignore = false;
|
|
|
|
public string $ldap_client_tls_key = '';
|
|
|
|
public string $ldap_client_tls_cert = '';
|
|
|
|
// Step 2: Bind credentials
|
|
public string $ldap_uname = '';
|
|
|
|
// Password is ALWAYS empty on mount. Never round-trip a stored
|
|
// password to the browser. An empty value on save means "keep the
|
|
// persisted encrypted password". Non-empty replaces it.
|
|
public string $ldap_pword = '';
|
|
|
|
// Merged into step 2 (Auth + Scope). Kept as separate properties for clarity.
|
|
public string $ldap_basedn = '';
|
|
|
|
public string $ldap_filter = '';
|
|
|
|
public string $ldap_auth_filter_query = '';
|
|
|
|
// Step 3: Attribute mapping. LDAP attribute names Snipe-IT reads
|
|
// from each entry. Only ldap_username_field + ldap_fname_field are
|
|
// required per the legacy StoreLdapSettings rules.
|
|
public string $ldap_username_field = '';
|
|
|
|
public string $ldap_fname_field = '';
|
|
|
|
public string $ldap_lname_field = '';
|
|
|
|
public string $ldap_display_name = '';
|
|
|
|
public string $ldap_email = '';
|
|
|
|
public string $ldap_emp_num = '';
|
|
|
|
public string $ldap_phone_field = '';
|
|
|
|
public string $ldap_mobile = '';
|
|
|
|
public string $ldap_jobtitle = '';
|
|
|
|
public string $ldap_manager = '';
|
|
|
|
public string $ldap_dept = '';
|
|
|
|
public string $ldap_address = '';
|
|
|
|
public string $ldap_city = '';
|
|
|
|
public string $ldap_state = '';
|
|
|
|
public string $ldap_zip = '';
|
|
|
|
public string $ldap_country = '';
|
|
|
|
public string $ldap_location = '';
|
|
|
|
public string $ldap_active_flag = '';
|
|
|
|
public bool $ldap_invert_active_flag = false;
|
|
|
|
// Step 4: Sync + defaults
|
|
public bool $ldap_pw_sync = false;
|
|
|
|
public ?int $ldap_default_group = null;
|
|
|
|
public string $custom_forgot_pass_url = '';
|
|
|
|
// Not persisted. Just the input for the step-4 "look up sample user"
|
|
// test. Populated by the user, consumed by runStep4NetworkTest to
|
|
// fetch a real LDAP entry so they can preview the mapping.
|
|
public string $test_sample_username = '';
|
|
|
|
// Populated by a successful step-4 test with the mapped attribute
|
|
// values from the fetched LDAP entry. Blade renders as a preview
|
|
// table so the user can verify their mapping picks up the right
|
|
// values before advancing.
|
|
public array $step3TestAttributes = [];
|
|
|
|
public string $step3TestDn = '';
|
|
|
|
/**
|
|
* mount() only fires on the initial page render, not on subsequent
|
|
* POST /livewire/update requests. Route-level middleware on the LDAP
|
|
* settings wizard requires superadmin, but a snapshot replay lands
|
|
* here without going through that middleware. boot() runs on every
|
|
* Livewire request, so it catches both surfaces.
|
|
*/
|
|
public function boot(): void
|
|
{
|
|
abort_unless(Gate::allows('superadmin'), 403);
|
|
}
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->hydrateFromPersisted();
|
|
|
|
// Restore in-flight wizard progress from the session so a page
|
|
// reload mid-wizard doesn't slam the user back to step 1. The
|
|
// session key is user-scoped, only bumps forward, never back.
|
|
// Cleared once ldap_enabled flips true (step 5 complete): from
|
|
// that point the ldap_enabled=true branch below unlocks everything
|
|
// on every mount, so the session pointer becomes redundant.
|
|
$this->highestStepReached = max($this->highestStepReached, (int) session($this->progressSessionKey(), 1));
|
|
|
|
// When LDAP is already enabled the whole wizard is unlocked
|
|
// including step 5 (the completion / help screen). Return
|
|
// visitors land on step 5 by default so they can reference
|
|
// the sync-scheduling copy without walking through the four
|
|
// config steps again. The Back button on step 5 and the
|
|
// wizard stepper at the top both let them jump back to
|
|
// earlier steps if they need to edit config.
|
|
if ($this->ldap_enabled) {
|
|
$this->highestStepReached = 5;
|
|
if (! request()->has('step')) {
|
|
$this->currentStep = 5;
|
|
}
|
|
}
|
|
|
|
// Clamp against total step count in case a session pointer
|
|
// was seeded when the wizard had a different step layout.
|
|
$this->highestStepReached = min($this->highestStepReached, 5);
|
|
|
|
// Clamp URL-restored step to [1, highestStepReached]. Blocks
|
|
// both direct URL tampering (?step=99) and stale bookmarks
|
|
// pointing at a step the user hasn't unlocked yet.
|
|
$this->currentStep = max(1, min($this->currentStep, $this->highestStepReached));
|
|
}
|
|
|
|
protected function progressSessionKey(): string
|
|
{
|
|
return 'ldap_wizard_highest_step:'.auth()->id();
|
|
}
|
|
|
|
/**
|
|
* Load every wizard prop from the persisted Setting row. Called by
|
|
* mount() on first render and by goToStep() when the user opts to
|
|
* discard unsaved changes via the wire:confirm dialog. Password is
|
|
* intentionally NOT hydrated (see property docstring).
|
|
*/
|
|
protected function hydrateFromPersisted(): void
|
|
{
|
|
$setting = Setting::getSettings();
|
|
|
|
$this->ldap_enabled = (bool) $setting->ldap_enabled;
|
|
$this->is_ad = (bool) $setting->is_ad;
|
|
$this->ad_domain = (string) $setting->ad_domain;
|
|
$this->ldap_server = (string) $setting->ldap_server;
|
|
$this->ldap_tls = (bool) $setting->ldap_tls;
|
|
$this->ldap_server_cert_ignore = (bool) $setting->ldap_server_cert_ignore;
|
|
$this->ldap_client_tls_key = (string) $setting->ldap_client_tls_key;
|
|
$this->ldap_client_tls_cert = (string) $setting->ldap_client_tls_cert;
|
|
|
|
$this->ldap_uname = (string) $setting->ldap_uname;
|
|
// ldap_pword stays ''. See property docstring.
|
|
|
|
$this->ldap_basedn = (string) $setting->ldap_basedn;
|
|
$this->ldap_filter = (string) $setting->ldap_filter;
|
|
$this->ldap_auth_filter_query = (string) $setting->ldap_auth_filter_query;
|
|
|
|
$this->ldap_username_field = (string) $setting->ldap_username_field;
|
|
$this->ldap_fname_field = (string) $setting->ldap_fname_field;
|
|
$this->ldap_lname_field = (string) $setting->ldap_lname_field;
|
|
$this->ldap_display_name = (string) $setting->ldap_display_name;
|
|
$this->ldap_email = (string) $setting->ldap_email;
|
|
$this->ldap_emp_num = (string) $setting->ldap_emp_num;
|
|
$this->ldap_phone_field = (string) $setting->ldap_phone_field;
|
|
$this->ldap_mobile = (string) $setting->ldap_mobile;
|
|
$this->ldap_jobtitle = (string) $setting->ldap_jobtitle;
|
|
$this->ldap_manager = (string) $setting->ldap_manager;
|
|
$this->ldap_dept = (string) $setting->ldap_dept;
|
|
$this->ldap_address = (string) $setting->ldap_address;
|
|
$this->ldap_city = (string) $setting->ldap_city;
|
|
$this->ldap_state = (string) $setting->ldap_state;
|
|
$this->ldap_zip = (string) $setting->ldap_zip;
|
|
$this->ldap_country = (string) $setting->ldap_country;
|
|
$this->ldap_location = (string) $setting->ldap_location;
|
|
$this->ldap_active_flag = (string) $setting->ldap_active_flag;
|
|
$this->ldap_invert_active_flag = (bool) $setting->ldap_invert_active_flag;
|
|
|
|
$this->ldap_pw_sync = (bool) $setting->ldap_pw_sync;
|
|
$this->ldap_default_group = $setting->ldap_default_group ? (int) $setting->ldap_default_group : null;
|
|
$this->custom_forgot_pass_url = (string) $setting->custom_forgot_pass_url;
|
|
}
|
|
|
|
#[Computed]
|
|
public function stepTitles(): array
|
|
{
|
|
return [
|
|
1 => trans('admin/settings/general.ldap_wizard.step_connection'),
|
|
2 => trans('admin/settings/general.ldap_wizard.step_authscope'),
|
|
3 => trans('admin/settings/general.ldap_wizard.step_mapping'),
|
|
4 => trans('admin/settings/general.ldap_wizard.step_sync'),
|
|
];
|
|
}
|
|
|
|
public function goToStep(int $step): void
|
|
{
|
|
if ($step >= 1 && $step <= $this->highestStepReached) {
|
|
// If the user had unsaved changes when they clicked the
|
|
// stepper (dirty=true), the confirm dialog said "discard
|
|
// them" and they accepted. Actually discard by re-loading
|
|
// every prop from the persisted Setting row. Without this,
|
|
// the cleared-but-not-saved in-memory values would still
|
|
// be there when the user walked forward and hit Save &
|
|
// Continue on the same step, silently persisting the
|
|
// "discarded" edits (data loss).
|
|
if ($this->dirty) {
|
|
$this->hydrateFromPersisted();
|
|
}
|
|
|
|
$this->currentStep = $step;
|
|
$this->dirty = false;
|
|
$this->justCompletedStep = null;
|
|
$this->clearTestResult();
|
|
$this->dispatch('wizard-step-changed');
|
|
}
|
|
}
|
|
|
|
// === Save & Continue dispatch ============================================
|
|
|
|
public function saveAndAdvance()
|
|
{
|
|
if (config('app.lock_passwords')) {
|
|
return null;
|
|
}
|
|
|
|
// Step 4 returns a redirect so Livewire client-side navigates to
|
|
// the settings index once the final save succeeds. The earlier
|
|
// steps advance in place and return null.
|
|
return match ($this->currentStep) {
|
|
1 => $this->saveStep1(),
|
|
2 => $this->saveStep2(),
|
|
3 => $this->saveStep3(),
|
|
4 => $this->saveStep4(),
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
#[Computed]
|
|
public function canAdvance(): bool
|
|
{
|
|
return match ($this->currentStep) {
|
|
1 => $this->canAdvanceStep1(),
|
|
2 => $this->canAdvanceStep2(),
|
|
3 => $this->canAdvanceStep3(),
|
|
4 => $this->canAdvanceStep4(),
|
|
default => false,
|
|
};
|
|
}
|
|
|
|
// === Step 1: Connection ==================================================
|
|
|
|
protected function saveStep1(): void
|
|
{
|
|
$this->validate(
|
|
$this->step1SyntaxRules() + [
|
|
'ad_domain' => 'nullable|required_if_accepted:is_ad',
|
|
],
|
|
attributes: $this->step1SyntaxAttributes() + [
|
|
'ad_domain' => trans('admin/settings/general.ad_domain'),
|
|
],
|
|
);
|
|
$this->validateTlsPair();
|
|
|
|
if ($this->dirty) {
|
|
$this->runStep1NetworkTest();
|
|
if ($this->testStatus !== 'success') {
|
|
return;
|
|
}
|
|
}
|
|
|
|
$setting = Setting::getSettings();
|
|
$setting->is_ad = $this->is_ad ? '1' : '0';
|
|
$setting->ad_domain = $this->ad_domain;
|
|
$setting->ldap_server = $this->ldap_server;
|
|
$setting->ldap_tls = $this->ldap_tls ? '1' : '0';
|
|
$setting->ldap_server_cert_ignore = $this->ldap_server_cert_ignore ? '1' : '0';
|
|
$setting->ldap_client_tls_key = $this->ldap_client_tls_key;
|
|
$setting->ldap_client_tls_cert = $this->ldap_client_tls_cert;
|
|
|
|
$this->persistAndAdvance($setting);
|
|
}
|
|
|
|
protected function canAdvanceStep1(): bool
|
|
{
|
|
// Only cross-field syntactic gates go here. Empty-required-field
|
|
// checks are intentionally NOT gated on the button. A disabled
|
|
// button gives the user no feedback about WHY nothing happened,
|
|
// so instead we let the click fire, let saveStep1()'s validate()
|
|
// throw, and render inline errors under the offending field.
|
|
//
|
|
// The TLS pair XOR stays: it's a cross-field rule that would be
|
|
// confusing to render under just one of the two file fields.
|
|
if (($this->ldap_client_tls_key !== '') !== ($this->ldap_client_tls_cert !== '')) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Step 1's syntax rules. Called from saveStep1(). Step 1's network
|
|
* test path is not user-callable (we got rid of the button) so it doesn't need to run these
|
|
* separately.
|
|
*/
|
|
protected function step1SyntaxRules(): array
|
|
{
|
|
return [
|
|
'ldap_server' => 'required|starts_with:ldap://,ldaps://',
|
|
'ldap_client_tls_key' => [
|
|
'nullable',
|
|
'required_with:ldap_client_tls_cert',
|
|
/** @SuppressWarnings(PHPMD.UnusedFormalParameter) */
|
|
function ($attribute, $value, $fail) {
|
|
if (trim((string) $value) === '') {
|
|
return;
|
|
}
|
|
if (! @openssl_pkey_get_private($value)) {
|
|
$fail(trans('admin/settings/general.ldap_wizard.tls.key_parse_failed'));
|
|
}
|
|
},
|
|
],
|
|
'ldap_client_tls_cert' => [
|
|
'nullable',
|
|
'required_with:ldap_client_tls_key',
|
|
/** @SuppressWarnings(PHPMD.UnusedFormalParameter) */
|
|
function ($attribute, $value, $fail) {
|
|
if (trim((string) $value) === '') {
|
|
return;
|
|
}
|
|
$cert = @openssl_x509_read($value);
|
|
if (! $cert) {
|
|
$fail(trans('admin/settings/general.ldap_wizard.tls.cert_parse_failed'));
|
|
|
|
return;
|
|
}
|
|
$info = @openssl_x509_parse($cert);
|
|
if ($info && isset($info['validTo_time_t']) && $info['validTo_time_t'] < time()) {
|
|
$fail(trans('admin/settings/general.ldap_wizard.tls.cert_expired', [
|
|
'date' => date('Y-m-d', $info['validTo_time_t']),
|
|
]));
|
|
}
|
|
},
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function step1SyntaxAttributes(): array
|
|
{
|
|
return [
|
|
'ldap_server' => trans('admin/settings/general.ldap_server'),
|
|
'ldap_client_tls_key' => trans('admin/settings/general.ldap_client_tls_key'),
|
|
'ldap_client_tls_cert' => trans('admin/settings/general.ldap_client_tls_cert'),
|
|
];
|
|
}
|
|
|
|
protected function validateTlsPair(): void
|
|
{
|
|
if (trim($this->ldap_client_tls_key) === '' || trim($this->ldap_client_tls_cert) === '') {
|
|
return;
|
|
}
|
|
$key = @openssl_pkey_get_private($this->ldap_client_tls_key);
|
|
$cert = @openssl_x509_read($this->ldap_client_tls_cert);
|
|
if (! $key || ! $cert) {
|
|
return;
|
|
}
|
|
if (! @openssl_x509_check_private_key($cert, $key)) {
|
|
throw \Illuminate\Validation\ValidationException::withMessages([
|
|
'ldap_client_tls_key' => trans('admin/settings/general.ldap_wizard.tls.pair_mismatch'),
|
|
]);
|
|
}
|
|
}
|
|
|
|
protected function runStep1NetworkTest(): void
|
|
{
|
|
$this->clearTestResult();
|
|
|
|
if (! $this->checkRateLimit('ldap-test-step1', 'ldap connection test')) {
|
|
return;
|
|
}
|
|
|
|
$ipError = $this->serverPassesIpPolicy($this->ldap_server);
|
|
if ($ipError !== null) {
|
|
$this->recordFieldError('ldap_server', $ipError, 'ldap connection test');
|
|
|
|
return;
|
|
}
|
|
|
|
if ($this->ldap_server_cert_ignore) {
|
|
putenv('LDAPTLS_REQCERT=never');
|
|
}
|
|
|
|
$conn = @ldap_connect($this->ldap_server);
|
|
if (! $conn) {
|
|
$this->recordTestResult(
|
|
'error',
|
|
trans('admin/settings/general.ldap_wizard.test.connect_failed', ['server' => $this->ldap_server]),
|
|
'ldap connection test',
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
ldap_set_option($conn, LDAP_OPT_REFERRALS, 0);
|
|
ldap_set_option($conn, LDAP_OPT_PROTOCOL_VERSION, 3);
|
|
ldap_set_option($conn, LDAP_OPT_NETWORK_TIMEOUT, self::TEST_NETWORK_TIMEOUT_SECONDS);
|
|
|
|
if ($this->ldap_tls) {
|
|
if (! @ldap_start_tls($conn)) {
|
|
$this->recordTestResult(
|
|
'error',
|
|
trans('admin/settings/general.ldap_wizard.test.starttls_failed', ['error' => ldap_error($conn)]),
|
|
'ldap connection test',
|
|
);
|
|
@ldap_unbind($conn);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
$bindOk = @ldap_bind($conn);
|
|
if (! $bindOk) {
|
|
$errno = ldap_errno($conn);
|
|
$ldapError = ldap_error($conn);
|
|
@ldap_unbind($conn);
|
|
|
|
if ($errno === -1) {
|
|
$this->recordTestResult(
|
|
'error',
|
|
trans('admin/settings/general.ldap_wizard.test.connect_failed', ['server' => $this->ldap_server]).' ('.$ldapError.')',
|
|
'ldap connection test',
|
|
);
|
|
|
|
return;
|
|
}
|
|
// Any other errno means the server SPOKE to us in LDAP
|
|
// protocol before rejecting. That's a successful connectivity
|
|
// test. Auth belongs in step 2.
|
|
$this->recordTestResult(
|
|
'success',
|
|
trans('admin/settings/general.ldap_wizard.test.connected_no_anon', ['server' => $this->ldap_server, 'error' => $ldapError]),
|
|
'ldap connection test',
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
@ldap_unbind($conn);
|
|
$this->recordTestResult(
|
|
'success',
|
|
trans('admin/settings/general.ldap_wizard.test.connected', ['server' => $this->ldap_server]),
|
|
'ldap connection test',
|
|
);
|
|
}
|
|
|
|
// === Step 2: Authenticate + scope ========================================
|
|
// Merged step: admin bind credentials + base DN + filter + auth-filter
|
|
// query. Combined test = bind with the supplied credentials AND run a
|
|
// search under the supplied base DN with the supplied filter. Either
|
|
// failing aborts save. The old wizard had these as separate steps
|
|
// (bind, then search) but the base DN is part of a full admin DN so
|
|
// asking for the bind DN without the base DN in front of the user
|
|
// was confusing.
|
|
|
|
protected function saveStep2(): void
|
|
{
|
|
$this->validate($this->step2SyntaxRules(), attributes: $this->step2SyntaxAttributes());
|
|
|
|
if ($this->dirty) {
|
|
$this->runStep2NetworkTest();
|
|
if ($this->testStatus !== 'success') {
|
|
return;
|
|
}
|
|
}
|
|
|
|
$setting = Setting::getSettings();
|
|
$setting->ldap_uname = $this->ldap_uname;
|
|
// Only overwrite the persisted encrypted password when the user
|
|
// provided a new value. Blank pword = keep-what's-in-DB.
|
|
if ($this->ldap_pword !== '') {
|
|
$setting->ldap_pword = Crypt::encrypt($this->ldap_pword);
|
|
}
|
|
$setting->ldap_basedn = $this->ldap_basedn;
|
|
$setting->ldap_filter = $this->ldap_filter;
|
|
$setting->ldap_auth_filter_query = $this->ldap_auth_filter_query;
|
|
|
|
// Clear the local password field so the next re-render doesn't
|
|
// send the value back to the browser after a successful save.
|
|
$this->ldap_pword = '';
|
|
|
|
$this->persistAndAdvance($setting);
|
|
}
|
|
|
|
protected function canAdvanceStep2(): bool
|
|
{
|
|
if (trim($this->ldap_uname) === '') {
|
|
return false;
|
|
}
|
|
if (trim($this->ldap_basedn) === '') {
|
|
return false;
|
|
}
|
|
if (trim($this->ldap_auth_filter_query) === '') {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
protected function step2SyntaxRules(): array
|
|
{
|
|
// Password reuse-persisted logic: blank pword is OK when the
|
|
// username is unchanged AND the DB has an encrypted password
|
|
// to fall back on. Rule::when() rather than a closure because
|
|
// Laravel's validator skips closure rules on empty values by
|
|
// default (same reason `nullable` works implicitly), which
|
|
// would let a blank pword sail past a closure-based required.
|
|
$persisted = Setting::getSettings();
|
|
$unameUnchanged = trim($this->ldap_uname) === trim((string) $persisted->ldap_uname);
|
|
$canReusePersisted = $unameUnchanged && ! empty($persisted->ldap_pword);
|
|
|
|
// Normalize DNs for comparison (lowercase + strip whitespace around
|
|
// component separators). Used by the base-DN-equals-bind-DN closure
|
|
// so `cn=admin, dc=example, dc=com` and `CN=Admin,dc=example,dc=com`
|
|
// both match when they should.
|
|
$normalizeDn = fn ($dn) => strtolower(preg_replace('/\s*,\s*/', ',', trim((string) $dn)));
|
|
$bindDn = $normalizeDn($this->ldap_uname);
|
|
|
|
return [
|
|
'ldap_uname' => 'required|max:191',
|
|
'ldap_pword' => \Illuminate\Validation\Rule::when(! $canReusePersisted, 'required'),
|
|
'ldap_basedn' => [
|
|
'required',
|
|
// Guard against the common misconfiguration where the base
|
|
// DN is set to the bind account itself (a leaf entry).
|
|
// A subtree search under a leaf DN returns just that entry,
|
|
// which is enough to sneak past the step-2 "at least one
|
|
// match" check but will always return zero users on any
|
|
// real filter. Catch it here with an explicit inline error
|
|
// rather than letting the wizard advance and confuse the
|
|
// user on step 3.
|
|
/** @SuppressWarnings(PHPMD.UnusedFormalParameter) */
|
|
function ($attribute, $value, $fail) use ($normalizeDn, $bindDn) {
|
|
if ($bindDn !== '' && $normalizeDn($value) === $bindDn) {
|
|
$fail(trans('admin/settings/general.ldap_wizard.search.basedn_equals_binddn'));
|
|
}
|
|
},
|
|
],
|
|
'ldap_filter' => ['nullable', 'regex:/^[^(]/'],
|
|
'ldap_auth_filter_query' => 'required|not_in:uid=samaccountname',
|
|
];
|
|
}
|
|
|
|
protected function step2SyntaxAttributes(): array
|
|
{
|
|
return [
|
|
'ldap_uname' => trans('admin/settings/general.ldap_uname'),
|
|
'ldap_pword' => trans('admin/settings/general.ldap_pword'),
|
|
'ldap_basedn' => trans('admin/settings/general.ldap_basedn'),
|
|
'ldap_filter' => trans('admin/settings/general.ldap_filter'),
|
|
'ldap_auth_filter_query' => trans('admin/settings/general.ldap_auth_filter_query'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Combined bind + search test. Binds with the form credentials
|
|
* (never trusts persisted, the whole point is to verify what the
|
|
* user just typed), then runs a search under the form base DN with
|
|
* the form filter. Success = bind AND search both succeeded AND
|
|
* search returned at least one entry (zero entries is functionally
|
|
* broken for sync). Any failure surfaces a specific message
|
|
* pointing at the actual problem (bind vs search).
|
|
*/
|
|
protected function runStep2NetworkTest(): void
|
|
{
|
|
$this->clearTestResult();
|
|
|
|
if (! $this->checkRateLimit('ldap-test-step2', 'ldap bind test')) {
|
|
return;
|
|
}
|
|
|
|
$conn = $this->openLdapConnectionForTest('ldap bind test');
|
|
if (! $conn) {
|
|
return;
|
|
}
|
|
|
|
// Bind, always with credentials (uname required in step2SyntaxRules).
|
|
// Password resolution: form value if provided, otherwise fall back
|
|
// to the persisted encrypted password when the username matches.
|
|
$settings = Setting::getSettings();
|
|
$server = (string) $settings->ldap_server;
|
|
$uname = trim($this->ldap_uname);
|
|
$pword = $this->ldap_pword;
|
|
if ($pword === '' && $uname === trim((string) $settings->ldap_uname) && $settings->ldap_pword) {
|
|
try {
|
|
$pword = Crypt::decrypt($settings->ldap_pword);
|
|
} catch (\Exception $e) {
|
|
@ldap_unbind($conn);
|
|
$this->recordTestResult(
|
|
'error',
|
|
trans('admin/settings/general.ldap_wizard.bind.pword_decrypt_failed'),
|
|
'ldap bind test',
|
|
);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
$bindOk = @ldap_bind($conn, $uname, $pword);
|
|
if (! $bindOk) {
|
|
$errno = ldap_errno($conn);
|
|
$ldapError = ldap_error($conn);
|
|
@ldap_unbind($conn);
|
|
|
|
if ($errno === -1) {
|
|
$this->recordTestResult(
|
|
'error',
|
|
trans('admin/settings/general.ldap_wizard.test.connect_failed', ['server' => $server]).' ('.$ldapError.')',
|
|
'ldap bind test',
|
|
);
|
|
|
|
return;
|
|
}
|
|
$this->recordTestResult(
|
|
'error',
|
|
trans('admin/settings/general.ldap_wizard.bind.rejected', ['error' => $ldapError]),
|
|
'ldap bind test',
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
// Bind succeeded. Proceed to search under the base DN. Empty
|
|
// filter falls back to (objectClass=*) which returns everything
|
|
// under the base DN (matches legacy "no filter" semantics).
|
|
$filter = trim($this->ldap_filter) !== ''
|
|
? '('.$this->ldap_filter.')'
|
|
: '(objectClass=*)';
|
|
|
|
$searchResult = @ldap_search($conn, $this->ldap_basedn, $filter, ['dn']);
|
|
if (! $searchResult) {
|
|
$ldapError = ldap_error($conn);
|
|
@ldap_unbind($conn);
|
|
$this->recordTestResult(
|
|
'error',
|
|
trans('admin/settings/general.ldap_wizard.search.search_failed', ['error' => $ldapError]),
|
|
'ldap search test',
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
$count = ldap_count_entries($conn, $searchResult);
|
|
@ldap_unbind($conn);
|
|
|
|
if ($count === 0) {
|
|
$this->recordTestResult(
|
|
'error',
|
|
trans('admin/settings/general.ldap_wizard.search.no_results'),
|
|
'ldap search test',
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
$entriesLabel = $count === 1
|
|
? trans('admin/settings/general.ldap_wizard.search.entries_one')
|
|
: trans('admin/settings/general.ldap_wizard.search.entries_other');
|
|
$this->recordTestResult(
|
|
'success',
|
|
trans('admin/settings/general.ldap_wizard.combined.success', [
|
|
'user' => $uname,
|
|
'count' => $count,
|
|
'entries' => $entriesLabel,
|
|
]),
|
|
'ldap bind test',
|
|
);
|
|
}
|
|
|
|
// === Step 3: Attribute mapping ========================================= =============================
|
|
|
|
/**
|
|
* Snipe-IT field name → LDAP-attribute-name Livewire property. Used
|
|
* both by the preview-table render and by any future sync-side code
|
|
* that wants a canonical map of "what field goes where." Order here
|
|
* defines the order in the preview table.
|
|
*
|
|
* Any new mappings we create would need to go here too.
|
|
*/
|
|
protected function attributeMap(): array
|
|
{
|
|
return [
|
|
'username' => $this->ldap_username_field,
|
|
'first_name' => $this->ldap_fname_field,
|
|
'last_name' => $this->ldap_lname_field,
|
|
'display_name' => $this->ldap_display_name,
|
|
'email' => $this->ldap_email,
|
|
'employee_num' => $this->ldap_emp_num,
|
|
'phone' => $this->ldap_phone_field,
|
|
'mobile' => $this->ldap_mobile,
|
|
'job_title' => $this->ldap_jobtitle,
|
|
'manager' => $this->ldap_manager,
|
|
'department' => $this->ldap_dept,
|
|
'address' => $this->ldap_address,
|
|
'city' => $this->ldap_city,
|
|
'state' => $this->ldap_state,
|
|
'zip' => $this->ldap_zip,
|
|
'country' => $this->ldap_country,
|
|
'location' => $this->ldap_location,
|
|
'active_flag' => $this->ldap_active_flag,
|
|
];
|
|
}
|
|
|
|
protected function saveStep3(): void
|
|
{
|
|
$this->validate($this->step3SyntaxRules(), attributes: $this->step3SyntaxAttributes());
|
|
|
|
// No auto-test on Save. Attribute mapping is a client-input-only
|
|
// thing. The Look up button previews it against a real user, but
|
|
// that's an optional sanity check the user runs on demand to make sure
|
|
// their syncs will look right and map all of the right fields, not
|
|
// a network gate we run for them. Save + advance to step 5.
|
|
$setting = Setting::getSettings();
|
|
$setting->ldap_username_field = $this->ldap_username_field;
|
|
$setting->ldap_fname_field = $this->ldap_fname_field;
|
|
$setting->ldap_lname_field = $this->ldap_lname_field;
|
|
$setting->ldap_display_name = $this->ldap_display_name;
|
|
$setting->ldap_email = $this->ldap_email;
|
|
$setting->ldap_emp_num = $this->ldap_emp_num;
|
|
$setting->ldap_phone_field = $this->ldap_phone_field;
|
|
$setting->ldap_mobile = $this->ldap_mobile;
|
|
$setting->ldap_jobtitle = $this->ldap_jobtitle;
|
|
$setting->ldap_manager = $this->ldap_manager;
|
|
$setting->ldap_dept = $this->ldap_dept;
|
|
$setting->ldap_address = $this->ldap_address;
|
|
$setting->ldap_city = $this->ldap_city;
|
|
$setting->ldap_state = $this->ldap_state;
|
|
$setting->ldap_zip = $this->ldap_zip;
|
|
$setting->ldap_country = $this->ldap_country;
|
|
$setting->ldap_location = $this->ldap_location;
|
|
$setting->ldap_active_flag = $this->ldap_active_flag;
|
|
$setting->ldap_invert_active_flag = $this->ldap_invert_active_flag ? '1' : '0';
|
|
|
|
$this->persistAndAdvance($setting);
|
|
}
|
|
|
|
protected function canAdvanceStep3(): bool
|
|
{
|
|
if (trim($this->ldap_username_field) === '') {
|
|
return false;
|
|
}
|
|
if (trim($this->ldap_fname_field) === '') {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Step 3 rules mirror the legacy StoreLdapSettings:
|
|
* - ldap_username_field: required + not_in (sAMAccountName in
|
|
* LDAP is case-insensitive, storing the CamelCase form has
|
|
* been a recurring support case, so we deny it explicitly and
|
|
* the legacy custom validation message points at the fix).
|
|
* - ldap_fname_field: required (needed to populate user.first_name).
|
|
* - All other mapping fields are optional. Directories that don't
|
|
* carry that attribute just skip syncing it.
|
|
*/
|
|
protected function step3SyntaxRules(): array
|
|
{
|
|
return [
|
|
'ldap_username_field' => 'required|not_in:sAMAccountName',
|
|
'ldap_fname_field' => 'required',
|
|
];
|
|
}
|
|
|
|
protected function step3SyntaxAttributes(): array
|
|
{
|
|
return [
|
|
'ldap_username_field' => trans('admin/settings/general.ldap_username_field'),
|
|
'ldap_fname_field' => trans('admin/settings/general.ldap_fname_field'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Step 3's optional preview: bind + search for a specific sample
|
|
* username, fetch that entry's attributes, populate
|
|
* $step3TestAttributes so the blade can render a preview table. Not
|
|
* called from saveStep3. This is a user-triggered lookup only,
|
|
* exposed via wire:click on the Look Up button. Public because
|
|
* Livewire can't route wire:click to protected methods.
|
|
*/
|
|
public function runStep3NetworkTest(): void
|
|
{
|
|
$this->clearTestResult();
|
|
$this->step3TestAttributes = [];
|
|
$this->step3TestDn = '';
|
|
|
|
if (trim($this->test_sample_username) === '') {
|
|
$this->recordFieldError(
|
|
'test_sample_username',
|
|
trans('admin/settings/general.ldap_wizard.mapping.no_sample'),
|
|
'ldap search test',
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
if (! $this->checkRateLimit('ldap-test-step3', 'ldap search test')) {
|
|
return;
|
|
}
|
|
|
|
$conn = $this->openLdapConnectionForTest('ldap search test');
|
|
if (! $conn) {
|
|
return;
|
|
}
|
|
|
|
if (! $this->bindWithPersistedCredentials($conn, 'ldap search test')) {
|
|
return;
|
|
}
|
|
|
|
// Build the lookup filter: combine the step-3 base filter (if any)
|
|
// with a "username_field = sample" clause escaped against LDAP
|
|
// filter injection.
|
|
$escaped = ldap_escape($this->test_sample_username, '', LDAP_ESCAPE_FILTER);
|
|
$userClause = '('.$this->ldap_username_field.'='.$escaped.')';
|
|
$lookupFilter = trim($this->ldap_filter) !== ''
|
|
? '(&('.$this->ldap_filter.')'.$userClause.')'
|
|
: $userClause;
|
|
|
|
// Fetch only the attributes we're mapping. Avoids pulling the
|
|
// whole entry when we only care about a handful.
|
|
$requestedAttrs = array_values(array_filter(array_map(
|
|
fn ($attr) => trim(strtolower((string) $attr)),
|
|
$this->attributeMap(),
|
|
)));
|
|
|
|
$searchResult = @ldap_search($conn, $this->ldap_basedn, $lookupFilter, $requestedAttrs);
|
|
if (! $searchResult) {
|
|
$ldapError = ldap_error($conn);
|
|
@ldap_unbind($conn);
|
|
$this->recordTestResult(
|
|
'error',
|
|
trans('admin/settings/general.ldap_wizard.search.search_failed', ['error' => $ldapError]),
|
|
'ldap search test',
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
$count = ldap_count_entries($conn, $searchResult);
|
|
if ($count === 0) {
|
|
@ldap_unbind($conn);
|
|
// Route lookup-shaped errors to the sample-username field's
|
|
// inline slot rather than the top-of-body alert. They're
|
|
// scoped to that field's value, and rendering them where
|
|
// the results-would-go slot lives keeps the feedback local
|
|
// to the lookup control. Blade renders via <x-form.error>
|
|
// which escapes {{ $message }}, so no e() needed here.
|
|
$this->recordFieldError(
|
|
'test_sample_username',
|
|
trans('admin/settings/general.ldap_wizard.mapping.not_found', [
|
|
'username' => $this->test_sample_username,
|
|
'filter' => $lookupFilter,
|
|
'basedn' => $this->ldap_basedn,
|
|
]),
|
|
'ldap search test',
|
|
);
|
|
|
|
return;
|
|
}
|
|
if ($count > 1) {
|
|
@ldap_unbind($conn);
|
|
$this->recordFieldError(
|
|
'test_sample_username',
|
|
trans('admin/settings/general.ldap_wizard.mapping.multiple_found', [
|
|
'count' => $count,
|
|
'username' => $this->test_sample_username,
|
|
'filter' => $lookupFilter,
|
|
]),
|
|
'ldap search test',
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
$entry = ldap_first_entry($conn, $searchResult);
|
|
$dn = ldap_get_dn($conn, $entry);
|
|
$attributes = array_change_key_case((array) ldap_get_attributes($conn, $entry));
|
|
@ldap_unbind($conn);
|
|
|
|
// Build the preview table: for each Snipe-IT field, resolve the
|
|
// configured LDAP attribute name to its actual value (or a
|
|
// "not mapped" / "not present" marker for blade to render
|
|
// muted). Attribute names are compared lowercase. LDAP is
|
|
// case-insensitive on attribute names.
|
|
$preview = [];
|
|
foreach ($this->attributeMap() as $snipeField => $ldapAttr) {
|
|
$ldapAttrLower = trim(strtolower((string) $ldapAttr));
|
|
if ($ldapAttrLower === '') {
|
|
$preview[$snipeField] = ['attr' => null, 'value' => null];
|
|
|
|
continue;
|
|
}
|
|
$value = null;
|
|
if (isset($attributes[$ldapAttrLower][0])) {
|
|
$value = $attributes[$ldapAttrLower][0];
|
|
}
|
|
$preview[$snipeField] = ['attr' => $ldapAttr, 'value' => $value];
|
|
}
|
|
|
|
$this->step3TestDn = (string) $dn;
|
|
$this->step3TestAttributes = $preview;
|
|
|
|
$this->recordTestResult(
|
|
'success',
|
|
trans('admin/settings/general.ldap_wizard.mapping.found', ['dn' => $dn]),
|
|
'ldap search test',
|
|
);
|
|
}
|
|
|
|
// === Step 4: Sync + defaults (was step 5) ============================================
|
|
|
|
protected function saveStep4()
|
|
{
|
|
$this->validate($this->step4SyntaxRules(), attributes: $this->step4SyntaxAttributes());
|
|
|
|
// Reaching step 4 means the earlier steps verified the connection,
|
|
// bind, and mapping, so enabling LDAP is the whole point of the
|
|
// final save. ldap_enabled is not user-toggleable in the wizard.
|
|
// It's forced to 1 here.
|
|
$setting = Setting::getSettings();
|
|
$setting->ldap_enabled = '1';
|
|
$setting->ldap_pw_sync = $this->ldap_pw_sync ? '1' : '0';
|
|
$setting->ldap_default_group = $this->ldap_default_group;
|
|
$setting->custom_forgot_pass_url = $this->custom_forgot_pass_url;
|
|
|
|
return $this->persistAndAdvance($setting);
|
|
}
|
|
|
|
protected function canAdvanceStep4(): bool
|
|
{
|
|
// No syntactic preconditions. User can save at any time to
|
|
// toggle enable on/off. URL format validation runs at save.
|
|
return true;
|
|
}
|
|
|
|
protected function step4SyntaxRules(): array
|
|
{
|
|
return [
|
|
'custom_forgot_pass_url' => 'nullable|url',
|
|
'ldap_default_group' => 'nullable|integer|exists:permission_groups,id',
|
|
];
|
|
}
|
|
|
|
protected function step4SyntaxAttributes(): array
|
|
{
|
|
return [
|
|
'custom_forgot_pass_url' => trans('admin/settings/general.custom_forgot_pass_url'),
|
|
'ldap_default_group' => trans('admin/settings/general.ldap_default_group'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Permission groups available for the step-5 default-group select.
|
|
* Computed so the query only runs when the blade dereferences it
|
|
* (steps 1-4 don't need it). Ordered alphabetically for pick-list
|
|
* ergonomics.
|
|
*/
|
|
#[Computed]
|
|
public function permissionGroups(): array
|
|
{
|
|
return \App\Models\Group::orderBy('name')->pluck('name', 'id')->toArray();
|
|
}
|
|
|
|
/**
|
|
* Row descriptors for the step-3 mapping loop. Each entry is
|
|
* [ prop name, label trans key, placeholder attribute example,
|
|
* required, optional per-field help trans key ].
|
|
*
|
|
* Placeholder examples toggle based on $is_ad because the two
|
|
* common LDAP schemas name several attributes differently:
|
|
* - AD: samaccountname, streetaddress, department, co (full
|
|
* country name), useraccountcontrol
|
|
* - inetOrgPerson / posixAccount: uid, street, departmentNumber,
|
|
* c (ISO country code), no standard active-flag attribute
|
|
* The rest (givenname, sn, mail, telephonenumber, mobile, title,
|
|
* manager, l, st, postalcode) are shared across both.
|
|
*
|
|
* Computed so the placeholder set re-evaluates when a user toggles
|
|
* the AD box on step 1.
|
|
*/
|
|
#[Computed]
|
|
public function mappingFields(): array
|
|
{
|
|
$usernameField = $this->is_ad ? 'samaccountname' : 'uid';
|
|
$displayName = $this->is_ad ? 'displayname' : 'cn';
|
|
$dept = $this->is_ad ? 'department' : 'departmentNumber';
|
|
$address = $this->is_ad ? 'streetaddress' : 'street';
|
|
$country = $this->is_ad ? 'co' : 'c';
|
|
$activeFlag = $this->is_ad ? 'useraccountcontrol' : '';
|
|
|
|
return [
|
|
['ldap_username_field', 'ldap_username_field', $usernameField, true, null],
|
|
['ldap_fname_field', 'ldap_fname_field', 'givenname', true, null],
|
|
['ldap_lname_field', 'ldap_lname_field', 'sn', false, null],
|
|
['ldap_display_name', 'ldap_display_name', $displayName, false, 'ldap_display_name_help'],
|
|
['ldap_email', 'ldap_email', 'mail', false, null],
|
|
['ldap_emp_num', 'ldap_emp_num', 'employeenumber', false, null],
|
|
['ldap_phone_field', 'ldap_phone', 'telephonenumber', false, null],
|
|
['ldap_mobile', 'ldap_mobile', 'mobile', false, null],
|
|
['ldap_jobtitle', 'ldap_jobtitle', 'title', false, null],
|
|
['ldap_manager', 'ldap_manager', 'manager', false, null],
|
|
['ldap_dept', 'ldap_dept', $dept, false, null],
|
|
['ldap_address', 'ldap_address', $address, false, null],
|
|
['ldap_city', 'ldap_city', 'l', false, null],
|
|
['ldap_state', 'ldap_state', 'st', false, null],
|
|
['ldap_zip', 'ldap_zip', 'postalcode', false, null],
|
|
['ldap_country', 'ldap_country', $country, false, null],
|
|
['ldap_location', 'ldap_location', 'physicaldeliveryofficename', false, 'ldap_location_help'],
|
|
['ldap_active_flag', 'ldap_active_flag', $activeFlag, false, 'ldap_activated_flag_help'],
|
|
];
|
|
}
|
|
|
|
// === Global wizard actions ==============================================
|
|
|
|
/**
|
|
* "Back to Settings" from the step 5 completion screen. Clears the
|
|
* wizard-progress session key so a return visit lands on step 1
|
|
* with a fresh state rather than dumping the user back on the
|
|
* summary screen mid-flow.
|
|
*/
|
|
public function finishWizard()
|
|
{
|
|
session()->forget($this->progressSessionKey());
|
|
|
|
return $this->redirect(route('settings.index'));
|
|
}
|
|
|
|
/**
|
|
* Turn LDAP off from anywhere in the wizard. Preserves every other
|
|
* setting (bind creds, base DN, mapping) so re-enabling later is a
|
|
* matter of walking back through the wizard. Rendered as a small
|
|
* banner at the top of the wizard when LDAP is currently on. The
|
|
* wizard is otherwise a one-way enable path.
|
|
*/
|
|
public function disableLdap()
|
|
{
|
|
if (config('app.lock_passwords')) {
|
|
return null;
|
|
}
|
|
|
|
$setting = Setting::getSettings();
|
|
$setting->ldap_enabled = '0';
|
|
if (! $setting->save()) {
|
|
$this->addError('save', trans('admin/settings/message.update.error'));
|
|
|
|
return null;
|
|
}
|
|
|
|
session()->forget($this->progressSessionKey());
|
|
session()->flash('success', trans('admin/settings/general.ldap_wizard.disabled_success'));
|
|
|
|
return $this->redirect(route('settings.index'));
|
|
}
|
|
|
|
// === Shared helpers ======================================================
|
|
|
|
/**
|
|
* Check + hit the per-step rate limiter. Returns false when blocked,
|
|
* having already called recordTestResult() with the rate-limit
|
|
* message so the caller just needs to return.
|
|
*/
|
|
protected function checkRateLimit(string $keyPrefix, string $actionType): bool
|
|
{
|
|
$key = $keyPrefix.':'.auth()->id();
|
|
if (RateLimiter::tooManyAttempts($key, self::TEST_RATE_LIMIT_ATTEMPTS)) {
|
|
$wait = RateLimiter::availableIn($key);
|
|
$this->recordTestResult(
|
|
'error',
|
|
trans('admin/settings/general.ldap_wizard.test.rate_limited', ['seconds' => $wait]),
|
|
$actionType,
|
|
);
|
|
|
|
return false;
|
|
}
|
|
RateLimiter::hit($key, self::TEST_RATE_LIMIT_DECAY_SECONDS);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Shared connect / set_option / start_tls sequence for the step 2
|
|
* and step 3 network tests. Returns the LDAP connection on success
|
|
* or null on any failure (with the appropriate error message
|
|
* already recorded via recordTestResult, so the caller just needs
|
|
* to return). Reads server + cert-ignore + tls flags off the
|
|
* persisted Setting.
|
|
*/
|
|
protected function openLdapConnectionForTest(string $actionType): ?\LDAP\Connection
|
|
{
|
|
$settings = Setting::getSettings();
|
|
$server = (string) $settings->ldap_server;
|
|
if ($settings->ldap_server_cert_ignore) {
|
|
putenv('LDAPTLS_REQCERT=never');
|
|
}
|
|
|
|
$conn = @ldap_connect($server);
|
|
if (! $conn) {
|
|
$this->recordTestResult(
|
|
'error',
|
|
trans('admin/settings/general.ldap_wizard.test.connect_failed', ['server' => $server]),
|
|
$actionType,
|
|
);
|
|
|
|
return null;
|
|
}
|
|
|
|
ldap_set_option($conn, LDAP_OPT_REFERRALS, 0);
|
|
ldap_set_option($conn, LDAP_OPT_PROTOCOL_VERSION, 3);
|
|
ldap_set_option($conn, LDAP_OPT_NETWORK_TIMEOUT, self::TEST_NETWORK_TIMEOUT_SECONDS);
|
|
|
|
if ($settings->ldap_tls && ! @ldap_start_tls($conn)) {
|
|
$this->recordTestResult(
|
|
'error',
|
|
trans('admin/settings/general.ldap_wizard.test.starttls_failed', ['error' => ldap_error($conn)]),
|
|
$actionType,
|
|
);
|
|
@ldap_unbind($conn);
|
|
|
|
return null;
|
|
}
|
|
|
|
return $conn;
|
|
}
|
|
|
|
/**
|
|
* Bind $conn using the persisted (encrypted) bind username and
|
|
* password. Used by step 3 which trusts step 2 has already
|
|
* verified those credentials. Returns true on success, false
|
|
* after recording the appropriate error and unbinding. Handles
|
|
* password decrypt failure and bind rejection.
|
|
*/
|
|
protected function bindWithPersistedCredentials(\LDAP\Connection $conn, string $actionType): bool
|
|
{
|
|
$settings = Setting::getSettings();
|
|
$uname = (string) $settings->ldap_uname;
|
|
try {
|
|
$pword = $settings->ldap_pword ? Crypt::decrypt($settings->ldap_pword) : '';
|
|
} catch (\Exception $e) {
|
|
@ldap_unbind($conn);
|
|
$this->recordTestResult(
|
|
'error',
|
|
trans('admin/settings/general.ldap_wizard.search.pword_decrypt_failed'),
|
|
$actionType,
|
|
);
|
|
|
|
return false;
|
|
}
|
|
|
|
$bindOk = $uname !== '' ? @ldap_bind($conn, $uname, $pword) : @ldap_bind($conn);
|
|
if (! $bindOk) {
|
|
$ldapError = ldap_error($conn);
|
|
@ldap_unbind($conn);
|
|
$this->recordTestResult(
|
|
'error',
|
|
trans('admin/settings/general.ldap_wizard.search.bind_failed', ['error' => $ldapError]),
|
|
$actionType,
|
|
);
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Persist $setting and advance to the next step. Extracted so
|
|
* saveStep1 and saveStep2 share the tail. New step handlers just
|
|
* populate their own fields on $setting and call through.
|
|
*/
|
|
protected function persistAndAdvance(Setting $setting)
|
|
{
|
|
if (! $setting->save()) {
|
|
$this->addError('save', trans('admin/settings/message.update.error'));
|
|
|
|
return null;
|
|
}
|
|
|
|
$this->dirty = false;
|
|
|
|
// Remember which step number we're leaving so the stepper CSS
|
|
// animation can run on that step's dot (spinning yellow star
|
|
// that morphs into the green checkmark). Cleared elsewhere so
|
|
// manual back-nav via goToStep doesn't retrigger it.
|
|
$this->justCompletedStep = $this->currentStep;
|
|
|
|
// Step 4 is the final config step, but it advances INTO a
|
|
// completion summary (step 5) instead of redirecting away.
|
|
// The summary lists sync-scheduling options. The wizard only
|
|
// enabled login, not the recurring user sync, so pointing the
|
|
// admin at cron / Task Scheduler / manual `snipeit:ldap-sync`
|
|
// right after enable is the useful hand-off.
|
|
if ($this->currentStep === 4) {
|
|
$this->highestStepReached = 5;
|
|
session()->put($this->progressSessionKey(), 5);
|
|
$this->currentStep = 5;
|
|
$this->clearTestResult();
|
|
$this->dispatch('wizard-step-changed');
|
|
|
|
return null;
|
|
}
|
|
|
|
$this->highestStepReached = max($this->highestStepReached, $this->currentStep + 1);
|
|
session()->put($this->progressSessionKey(), $this->highestStepReached);
|
|
$this->currentStep++;
|
|
$this->clearTestResult();
|
|
$this->dispatch('wizard-step-changed');
|
|
|
|
return null;
|
|
}
|
|
|
|
protected function serverPassesIpPolicy(string $url): ?string
|
|
{
|
|
$parsed = parse_url($url);
|
|
if (! $parsed || empty($parsed['host'])) {
|
|
return trans('validation.starts_with', [
|
|
'attribute' => trans('admin/settings/general.ldap_server'),
|
|
'values' => 'ldap://, ldaps://',
|
|
]);
|
|
}
|
|
|
|
$scheme = strtolower($parsed['scheme'] ?? '');
|
|
if (! in_array($scheme, ['ldap', 'ldaps'], true)) {
|
|
return trans('validation.starts_with', [
|
|
'attribute' => trans('admin/settings/general.ldap_server'),
|
|
'values' => 'ldap://, ldaps://',
|
|
]);
|
|
}
|
|
|
|
$host = trim($parsed['host'], '[]');
|
|
$ip = gethostbyname($host);
|
|
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
|
|
return trans('admin/settings/general.ldap_wizard.test.dns_failed', ['host' => $host]);
|
|
}
|
|
|
|
if (! config('app.test_allow_private_ips')) {
|
|
// FILTER_FLAG_NO_PRIV_RANGE blocks 10/8, 172.16/12, 192.168/16, fc00::/7.
|
|
// FILTER_FLAG_NO_RES_RANGE blocks loopback (127/8, ::1), link-local
|
|
// (169.254/16 - cloud metadata!), multicast, broadcast, and other
|
|
// IETF-reserved ranges.
|
|
// While this makes sense in some cases, we control that via the
|
|
// TEST_ALLOW_PRIVATE_IPS env var, since some folks will legitimately need
|
|
// their install of Snipe-IT to talk to internal networks
|
|
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
|
return trans('admin/settings/general.ldap_wizard.test.private_ip_blocked', ['host' => $host, 'ip' => $ip]);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
protected function recordTestResult(string $status, string $message, string $actionType): void
|
|
{
|
|
$this->testStatus = $status;
|
|
$this->testMessage = $message;
|
|
$this->writeTestAuditLog($status, $message, $actionType);
|
|
}
|
|
|
|
protected function recordFieldError(string $field, string $message, string $actionType): void
|
|
{
|
|
$this->addError($field, $message);
|
|
$this->writeTestAuditLog('error', $message, $actionType);
|
|
}
|
|
|
|
protected function writeTestAuditLog(string $status, string $message, string $actionType): void
|
|
{
|
|
// Wizard test runs write to the dedicated 'admin' log channel
|
|
// (storage/logs/admin.log) rather than the action_logs table.
|
|
// Test messages can echo LDAP server URLs, bind DNs, base DNs,
|
|
// and error text that reflects user-controlled input. None of
|
|
// that should be reachable from any in-app log viewer that
|
|
// reads action_logs until we add the admin/settings logging system.
|
|
// Keeping this in a file the server operator
|
|
// controls is the right blast-radius for now. If we later want
|
|
// an admin-only DB log we can add one without churning the
|
|
// wizard.
|
|
Log::channel('admin')->info('ldap_wizard.test', [
|
|
'step' => $this->currentStep,
|
|
'action_type' => $actionType,
|
|
'status' => $status,
|
|
'server' => $this->ldap_server,
|
|
'user_id' => auth()->id(),
|
|
'message' => $message,
|
|
]);
|
|
}
|
|
|
|
protected function clearTestResult(): void
|
|
{
|
|
$this->testStatus = null;
|
|
$this->testMessage = null;
|
|
}
|
|
|
|
public function updated(string $property): void
|
|
{
|
|
// Trim string values on assignment so pasted-with-whitespace
|
|
// inputs get normalized both in the visible field and in the
|
|
// saved config. Without this a leading space on ldap_server
|
|
// silently fails starts_with:ldap://, and a trailing newline in
|
|
// a bind username produces a mysterious LDAP-side rejection at
|
|
// auth time. Textareas (TLS key/cert) tolerate the trim because
|
|
// PEM parsers accept both terminating-newline and no-terminating-
|
|
// newline forms.
|
|
if (in_array($property, ['ldap_server', 'ad_domain', 'ldap_client_tls_key', 'ldap_client_tls_cert', 'ldap_uname', 'ldap_pword', 'ldap_basedn', 'ldap_filter', 'ldap_auth_filter_query', 'ldap_username_field', 'ldap_fname_field', 'ldap_lname_field', 'ldap_display_name', 'ldap_email', 'ldap_emp_num', 'ldap_phone_field', 'ldap_mobile', 'ldap_jobtitle', 'ldap_manager', 'ldap_dept', 'ldap_address', 'ldap_city', 'ldap_state', 'ldap_zip', 'ldap_country', 'ldap_location', 'ldap_active_flag', 'custom_forgot_pass_url', 'test_sample_username'], true) && is_string($this->{$property})) {
|
|
$this->{$property} = trim($this->{$property});
|
|
}
|
|
|
|
// Any edit to a connection-shape field invalidates the prior
|
|
// network test result (a stale "connected" indicator sitting
|
|
// under a since-edited server URL would be actively misleading).
|
|
if (in_array($property, ['ldap_server', 'ldap_tls', 'ldap_server_cert_ignore', 'ldap_client_tls_key', 'ldap_client_tls_cert', 'ldap_uname', 'ldap_pword', 'ldap_basedn', 'ldap_filter', 'ldap_auth_filter_query', 'ldap_username_field', 'ldap_fname_field', 'ldap_lname_field', 'ldap_display_name', 'ldap_email', 'ldap_emp_num', 'ldap_phone_field', 'ldap_mobile', 'ldap_jobtitle', 'ldap_manager', 'ldap_dept', 'ldap_address', 'ldap_city', 'ldap_state', 'ldap_zip', 'ldap_country', 'ldap_location', 'ldap_active_flag', 'ldap_invert_active_flag', 'ldap_enabled', 'ldap_pw_sync', 'ldap_default_group', 'custom_forgot_pass_url', 'test_sample_username'], true)) {
|
|
$this->clearTestResult();
|
|
// Also clear the step-4 preview table since it references the
|
|
// old values.
|
|
$this->step3TestAttributes = [];
|
|
$this->step3TestDn = '';
|
|
}
|
|
|
|
// Clear this field's inline validation error so re-editing the
|
|
// field un-greys the Save button (which keys off canAdvance,
|
|
// which checks the error bag).
|
|
if (in_array($property, ['ldap_server', 'ldap_client_tls_key', 'ldap_client_tls_cert', 'ad_domain', 'ldap_uname', 'ldap_pword', 'ldap_basedn', 'ldap_filter', 'ldap_auth_filter_query', 'ldap_username_field', 'ldap_fname_field', 'custom_forgot_pass_url', 'test_sample_username'], true)) {
|
|
$this->resetValidation($property);
|
|
}
|
|
|
|
if (! in_array($property, ['currentStep', 'highestStepReached', 'dirty', 'testStatus', 'testMessage'], true)) {
|
|
$this->dirty = true;
|
|
}
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
return view('livewire.ldap-settings');
|
|
}
|
|
}
|