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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/Classes/LDAP/Attribute/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class Factory
'uniquemember' => Member::class,
'usercertificate' => Binary\Certificate::class,
'userpassword' => Password::class,
'pwdreset' => PwdReset::class,
];

/**
Expand Down
119 changes: 119 additions & 0 deletions app/Classes/LDAP/Attribute/PwdReset.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

namespace App\Classes\LDAP\Attribute;

use Illuminate\Contracts\View\View;

use App\Classes\LDAP\Attribute;
use App\Classes\Template;
use App\Interfaces\{ForceSingleValue,NoAttrTag};
use App\Ldap\Entry;

/**
* Represents the pwdReset attribute from OpenLDAP ppolicy overlay
*/
final class PwdReset extends Attribute implements ForceSingleValue,NoAttrTag
{
public function __construct(string $dn, string $name, array $values, array $oc = [])
{
parent::__construct($dn, $name, $values, $oc);
$this->_is_internal = FALSE;
}

/**
* Override properties to handle NULL schema gracefully for this virtual attribute
*/
public function __get(string $key): mixed
{
// Handle schema-based properties if schema exists
if ($this->schema !== NULL) {
switch ($key) {
case 'description':
case 'name':
case 'name_lc':
case 'is_editable':
case 'required_by':
case 'used_in':
return parent::__get($key);
}
}

// Fallback values when schema is NULL (operational attribute not in LDAP schema)
return match ($key) {
'description' => 'Password Reset Flag - Forces user to change password at next login (ppolicy overlay)',
'name' => 'pwdReset',
'name_lc' => 'pwdreset',
'is_editable' => TRUE,
'required_by' => collect(),
'used_in' => collect(),
default => parent::__get($key),
};
}

/* METHODS */

public function isDirty(): bool
{
$old = $this->values_old->dot()->filter(fn($item)=>! is_null($item) && $item !== '');
$new = $this->values->dot()->filter(fn($item)=>! is_null($item) && $item !== '');

return $old->count() !== $new->count() || $old->diff($new)->count() !== 0;
}

/**
* pwdReset is an operational attribute (ppolicy overlay) that:
* - Can only be set to TRUE (server manages FALSE/removal automatically)
* - When set to TRUE, user must change password at next login
* - When set to FALSE we keep the attribute present with value FALSE to remain editable
*/
public function getDirty(): array
{
if (! $this->isDirty())
return [];

$normalized = $this->values
->map(fn($values)=>array_values(array_map(fn($v)=>strtoupper(trim($v)) === 'TRUE' ? 'TRUE' : 'FALSE',$values)));

// If any TRUE values exist, send only the TRUEs; otherwise send FALSE to keep attribute present
$trueValues = $normalized
->map(fn($values)=>array_values(array_filter($values,fn($v)=>$v === 'TRUE')))
->filter(fn($values)=>count($values) > 0);

return $trueValues->isNotEmpty()
? [$this->name_lc => $trueValues->toArray()]
: [$this->name_lc => [Entry::TAG_NOTAG => ['FALSE']]];
}

public function render_item_old(string $dotkey): ?string
{
$value = $this->values_old->dot()->get($dotkey);

if ($value === NULL || $value === '')
return NULL;

return strtoupper($value) === 'TRUE' ? 'TRUE' : 'FALSE';
}

public function render_item_new(string $dotkey): ?string
{
$value = $this->values->dot()->get($dotkey);

if ($value === NULL || $value === '')
return NULL;

return strtoupper($value) === 'TRUE' ? 'TRUE' : 'FALSE';
}

public function render(string $attrtag,int $index,?View $view=NULL,bool $edit=FALSE,bool $editable=FALSE,bool $new=FALSE,bool $updated=FALSE,?Template $template=NULL): View
{
return parent::render(
attrtag: $attrtag,
index: $index,
view: view('components.attribute.value.pwdreset'),
edit: $edit,
editable: $editable,
new: $new,
updated: $updated,
template: $template);
}
}
26 changes: 25 additions & 1 deletion app/Http/Controllers/EntryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,19 @@ public function add(EntryAddRequest $request): \Illuminate\View\View
$o->{$ao->name} = [Entry::TAG_NOTAG=>['']];
}

// Add pwdReset if defined in template (it's an operational attribute not in objectclass)
if ($template->attributes->keys()->map('strtolower')->contains('pwdreset'))
$o->pwdReset = [Entry::TAG_NOTAG=>['FALSE']];

} elseif (count($x=collect(old('objectclass',$request->validated('objectclass')))->dot()->filter())) {
$o->objectclass = Arr::undot($x);

// Also add in our required attributes
foreach ($o->getAvailableAttributes()->filter(fn($item)=>$item->is_must && ($item->name_lc !== 'objectclass')) as $ao)
$o->{$ao->name} = [Entry::TAG_NOTAG=>['']];

// Add ppolicy virtual attributes for user entries
$this->addPPolicyAttributesIfNeeded($o);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about this and will need to review it in more detail - not all server implement ppolicy and/or pwdreset attributes.

}
}

Expand Down Expand Up @@ -519,4 +526,21 @@ public function update_pending(EntryRequest $request): \Illuminate\Http\Redirect
abort(500,$e->getMessage());
}
}
}

/**
* Add ppolicy virtual attributes to user entries if applicable
*
* @param Entry $o
* @return void
*/
private function addPPolicyAttributesIfNeeded(Entry $o): void
{
// Only add for user entries (uses Entry::isUserEntry())
if (! $o->isUserEntry())
return;

// Add pwdReset attribute with default value FALSE if not already present
if (! $o->hasAttribute('pwdreset'))
$o->pwdReset = [Entry::TAG_NOTAG=>['FALSE']];
}
}
3 changes: 2 additions & 1 deletion app/Http/Controllers/HomeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ public function frame(Request $request,?Collection $old=NULL): \Illuminate\View\
$o->setDN($key['dn']);

} elseif ($key['dn']) {
$o = config('server')->fetch($key['dn']);
// Request ppolicy operational attributes explicitly when viewing an entry
$o = config('server')->fetch($key['dn'], ['*','+','pwdReset']);
}

if ($o) {
Expand Down
43 changes: 42 additions & 1 deletion app/Ldap/Entry.php
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,15 @@ private function getAttributesAsObjects(): Collection
$result->put($attribute,$o);
}

// Ensure ppolicy operational attributes exist for user entries so they stay editable even when absent on the server
if ($this->isUserEntry() && (! $result->has('pwdreset')))
$result->put('pwdreset',Factory::create(
dn: $this->dn,
attribute: 'pwdReset',
values: [self::TAG_NOTAG=>['FALSE']],
oc: $entry_oc,
));

$sort = collect(config('pla.attr_display_order',[]))->map(fn($item)=>strtolower($item));

// Order the attributes
Expand Down Expand Up @@ -508,8 +517,26 @@ public function getOtherTags(): Collection
*/
public function getMissingAttributes(): Collection
{
return $this->getAvailableAttributes()
$missing = $this->getAvailableAttributes()
->filter(fn($a)=>(! $this->getVisibleAttributes()->contains(fn($b)=>($a->name === $b->name))));

// Add ppolicy operational attributes for user entries
if ($this->isUserEntry()) {
$ppolicyAttrs = ['pwdReset'];
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not all LDAP servers implement pwdReset, so this needs to be externalised as a configurable item if the functionality can be supported by other ldapservers, and they use a different attribute name, eg: pwdLastSet.


foreach ($ppolicyAttrs as $attrName) {
$attrLower = strtolower($attrName);

// Only add if not already present in entry or missing list
if (! $this->hasAttribute($attrLower) && ! $missing->contains(fn($item)=>strtolower($item->name) === $attrLower)) {
$schema = config('server')->schema('attributetypes',$attrName);
if ($schema)
$missing->push($schema);
}
}
}

return $missing;
}

/**
Expand All @@ -534,6 +561,20 @@ public function hasAttribute(int|string $key): bool
->has($key);
}

/**
* Check if this entry is a user-like entry based on objectclasses
*
* @return bool
*/
public function isUserEntry(): bool
{
static $userObjectClasses = ['posixaccount','inetorgperson','person','account','organizationalperson'];
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will need to be an external configurable item, to allow for users who (also) use custom schemas.


$entryOCs = $this->getObject('objectclass')?->tagValues()->map(fn($item)=>strtolower(trim($item))) ?? collect();

return $entryOCs->intersect($userObjectClasses)->isNotEmpty();
}

/**
* Did this query generate a size limit exception
*
Expand Down
59 changes: 59 additions & 0 deletions resources/views/components/attribute/value/pwdreset.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<!-- $o=PwdReset::class -->

<div class="input-group has-validation">
@if($edit || ($editable ?? false))
<div class="form-check form-switch">
<input type="hidden"
name="{{ $o->name_lc }}[{{ $attrtag }}][]"
value="{{ strtoupper($value ?? '') === 'TRUE' ? 'TRUE' : 'FALSE' }}"
id="{{ $o->name_lc }}_hidden_{{ $index ?? 0 }}">
<input class="form-check-input pwdreset-toggle"
type="checkbox"
role="switch"
id="{{ $o->name_lc }}_{{ $index ?? 0 }}"
data-hidden-id="{{ $o->name_lc }}_hidden_{{ $index ?? 0 }}"
@checked(strtoupper($value ?? '') === 'TRUE')>
<label class="form-check-label" for="{{ $o->name_lc }}_{{ $index ?? 0 }}">
@if(strtoupper($value ?? '') === 'TRUE')
<span class="text-danger"><i class="fas fa-exclamation-triangle"></i> @lang('Password must be changed')</span>
@else
<span class="text-success"><i class="fas fa-check-circle"></i> @lang('No password reset required')</span>
@endif
</label>
</div>
@if(isset($errors) && $errors->any())
<x-form.invalid-feedback :errors="$errors->get($o->name_lc.'.'.($dotkey ?? '')) ?? []"/>
@endif
@else
@if(strtoupper($value ?? '') === 'TRUE')
<span class="text-danger"><i class="fas fa-exclamation-triangle"></i> @lang('Yes - Password must be changed')</span>
@else
<span class="text-success"><i class="fas fa-check-circle"></i> @lang('No')</span>
@endif
@endif
</div>

@section($o->name_lc.'-scripts')
<script type="text/javascript">
function initPwdResetToggle() {
$('.pwdreset-toggle').off('change').on('change', function() {
var hiddenId = $(this).data('hidden-id');
var newValue = $(this).is(':checked') ? 'TRUE' : 'FALSE';
$('#' + hiddenId).val(newValue);

var label = $(this).next('label');
if ($(this).is(':checked')) {
label.html('<span class="text-danger"><i class="fas fa-exclamation-triangle"></i> {{ __("Password must be changed") }}</span>');
} else {
label.html('<span class="text-success"><i class="fas fa-check-circle"></i> {{ __("No password reset required") }}</span>');
}
});
}

if (window.$ === undefined) {
document.addEventListener('DOMContentLoaded', () => initPwdResetToggle());
} else {
initPwdResetToggle();
}
</script>
@endsection
4 changes: 4 additions & 0 deletions templates/user_account.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@
},
"value": "/bin/zsh",
"order": 9
},
"pwdReset": {
"display": "Password must be changed at next login",
"order": 10
}
}
}