From ead18d45b84be51c8801d35d21d3e059e12357d9 Mon Sep 17 00:00:00 2001 From: Tobias Winkler Date: Sat, 31 Jan 2026 08:49:25 +0100 Subject: [PATCH 1/3] #416: Add support for ppolicy pwdReset attribute --- app/Classes/LDAP/Attribute/Factory.php | 1 + app/Classes/LDAP/Attribute/PwdReset.php | 115 ++++++++++++++++++ app/Classes/LDAP/Server.php | 6 +- app/Http/Controllers/EntryController.php | 26 +++- app/Ldap/Entry.php | 34 +++++- .../attribute/value/pwdreset.blade.php | 59 +++++++++ templates/user_account.json | 4 + 7 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 app/Classes/LDAP/Attribute/PwdReset.php create mode 100644 resources/views/components/attribute/value/pwdreset.blade.php diff --git a/app/Classes/LDAP/Attribute/Factory.php b/app/Classes/LDAP/Attribute/Factory.php index fec07748..f2e8795a 100644 --- a/app/Classes/LDAP/Attribute/Factory.php +++ b/app/Classes/LDAP/Attribute/Factory.php @@ -48,6 +48,7 @@ class Factory 'uniquemember' => Member::class, 'usercertificate' => Binary\Certificate::class, 'userpassword' => Password::class, + 'pwdreset' => PwdReset::class, ]; /** diff --git a/app/Classes/LDAP/Attribute/PwdReset.php b/app/Classes/LDAP/Attribute/PwdReset.php new file mode 100644 index 00000000..2f0cba66 --- /dev/null +++ b/app/Classes/LDAP/Attribute/PwdReset.php @@ -0,0 +1,115 @@ +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 + * - FALSE values or empty values should not be sent to the server + */ + public function getDirty(): array + { + $dirty = []; + + if (! $this->isDirty()) + return $dirty; + + // Only send TRUE values - FALSE/empty is managed by server + $trueValues = collect($this->values->toArray()) + ->map(fn($values)=>collect($values)->filter(fn($v)=>strtoupper(trim($v)) === 'TRUE')->values()->toArray()) + ->filter(fn($values)=>count($values) > 0); + + if ($trueValues->isNotEmpty()) + $dirty = [$this->name_lc => $trueValues->toArray()]; + + return $dirty; + } + + 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); + } +} \ No newline at end of file diff --git a/app/Classes/LDAP/Server.php b/app/Classes/LDAP/Server.php index 0496b034..b0f7e1fe 100644 --- a/app/Classes/LDAP/Server.php +++ b/app/Classes/LDAP/Server.php @@ -236,10 +236,10 @@ private static function cachetime(): Carbon * Generic Builder method to setup our queries consistently - mainly to ensure we cache results * * @param string $dn - * @param array $attrs + * @param array $attrs Includes pwdReset explicitly as ppolicy operational attributes aren't returned by '+' * @return Builder */ - private static function get(string $dn,array $attrs=['*','+']): Builder + private static function get(string $dn,array $attrs=['*','+','pwdReset']): Builder { Log::debug(sprintf('%s:Getting [%s]',self::LOGKEY,$dn)); @@ -309,7 +309,7 @@ public function children(string $dn,array $attrs=['dn']): ?LDAPCollection * @param array $attrs * @return Model|null */ - public function fetch(string $dn,array $attrs=['*','+']): ?Model + public function fetch(string $dn,array $attrs=['*','+','pwdReset']): ?Model { static $depth = []; $cd = Arr::get($depth,$dn,0); diff --git a/app/Http/Controllers/EntryController.php b/app/Http/Controllers/EntryController.php index 116e36b1..d8232baf 100644 --- a/app/Http/Controllers/EntryController.php +++ b/app/Http/Controllers/EntryController.php @@ -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); } } @@ -519,4 +526,21 @@ public function update_pending(EntryRequest $request): \Illuminate\Http\Redirect abort(500,$e->getMessage()); } } -} \ No newline at end of file + + /** + * 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']]; + } +} diff --git a/app/Ldap/Entry.php b/app/Ldap/Entry.php index d138450f..c4450226 100644 --- a/app/Ldap/Entry.php +++ b/app/Ldap/Entry.php @@ -497,8 +497,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']; + + 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; } /** @@ -523,6 +541,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']; + + $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 * diff --git a/resources/views/components/attribute/value/pwdreset.blade.php b/resources/views/components/attribute/value/pwdreset.blade.php new file mode 100644 index 00000000..b4df6c5f --- /dev/null +++ b/resources/views/components/attribute/value/pwdreset.blade.php @@ -0,0 +1,59 @@ + + +
+ @if($edit || ($editable ?? false)) +
+ + + +
+ @if(isset($errors) && $errors->any()) + + @endif + @else + @if(strtoupper($value ?? '') === 'TRUE') + @lang('Yes - Password must be changed') + @else + @lang('No') + @endif + @endif +
+ +@section($o->name_lc.'-scripts') + +@endsection \ No newline at end of file diff --git a/templates/user_account.json b/templates/user_account.json index 61c67f04..60978ba0 100644 --- a/templates/user_account.json +++ b/templates/user_account.json @@ -78,6 +78,10 @@ }, "value": "/bin/zsh", "order": 9 + }, + "pwdReset": { + "display": "Password must be changed at next login", + "order": 10 } } } From d1fc673cfe93a30b5545112591b5745616547822 Mon Sep 17 00:00:00 2001 From: Tobias Winkler Date: Sun, 1 Feb 2026 22:20:27 +0100 Subject: [PATCH 2/3] adjustments to changes to main branch and fix minore bugs --- app/Classes/LDAP/Attribute/PwdReset.php | 37 +++++++++++++++---------- app/Ldap/Entry.php | 9 ++++++ 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/app/Classes/LDAP/Attribute/PwdReset.php b/app/Classes/LDAP/Attribute/PwdReset.php index 2f0cba66..db340310 100644 --- a/app/Classes/LDAP/Attribute/PwdReset.php +++ b/app/Classes/LDAP/Attribute/PwdReset.php @@ -6,15 +6,19 @@ 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 +final class PwdReset extends Attribute implements ForceSingleValue,NoAttrTag { - protected(set) bool $no_attr_tags = TRUE; - protected(set) int $max_values_count = 1; - protected ?bool $_is_internal = FALSE; + 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 @@ -60,24 +64,27 @@ public function isDirty(): bool * 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 - * - FALSE values or empty values should not be sent to the server + * - When set to FALSE we keep the attribute present with value FALSE to remain editable */ public function getDirty(): array { - $dirty = []; - if (! $this->isDirty()) - return $dirty; + return []; - // Only send TRUE values - FALSE/empty is managed by server - $trueValues = collect($this->values->toArray()) - ->map(fn($values)=>collect($values)->filter(fn($v)=>strtoupper(trim($v)) === 'TRUE')->values()->toArray()) - ->filter(fn($values)=>count($values) > 0); + $normalized = collect($this->values->toArray()) + ->map(fn($values)=>collect($values) + ->map(fn($v)=>strtoupper(trim($v)) === 'TRUE' ? 'TRUE' : 'FALSE') + ->values() + ->toArray()); - if ($trueValues->isNotEmpty()) - $dirty = [$this->name_lc => $trueValues->toArray()]; + // If any TRUE values exist, send only the TRUEs; otherwise send FALSE to keep attribute present + $trueValues = $normalized + ->map(fn($values)=>collect($values)->filter(fn($v)=>$v === 'TRUE')->values()->toArray()) + ->filter(fn($values)=>count($values) > 0); - return $dirty; + return $trueValues->isNotEmpty() + ? [$this->name_lc => $trueValues->toArray()] + : [$this->name_lc => [Entry::TAG_NOTAG => ['FALSE']]]; } public function render_item_old(string $dotkey): ?string diff --git a/app/Ldap/Entry.php b/app/Ldap/Entry.php index 652fd959..a2f4df75 100644 --- a/app/Ldap/Entry.php +++ b/app/Ldap/Entry.php @@ -352,6 +352,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 From 6499b2aaed9a1620e614ea811992f64cf484fad0 Mon Sep 17 00:00:00 2001 From: Tobias Winkler Date: Mon, 2 Feb 2026 08:46:16 +0100 Subject: [PATCH 3/3] solve first comments --> remove collection in collection and change receiving attribut to HomeController --- app/Classes/LDAP/Attribute/PwdReset.php | 9 +++------ app/Classes/LDAP/Server.php | 6 +++--- app/Http/Controllers/HomeController.php | 3 ++- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/Classes/LDAP/Attribute/PwdReset.php b/app/Classes/LDAP/Attribute/PwdReset.php index db340310..da79642b 100644 --- a/app/Classes/LDAP/Attribute/PwdReset.php +++ b/app/Classes/LDAP/Attribute/PwdReset.php @@ -71,15 +71,12 @@ public function getDirty(): array if (! $this->isDirty()) return []; - $normalized = collect($this->values->toArray()) - ->map(fn($values)=>collect($values) - ->map(fn($v)=>strtoupper(trim($v)) === 'TRUE' ? 'TRUE' : 'FALSE') - ->values() - ->toArray()); + $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)=>collect($values)->filter(fn($v)=>$v === 'TRUE')->values()->toArray()) + ->map(fn($values)=>array_values(array_filter($values,fn($v)=>$v === 'TRUE'))) ->filter(fn($values)=>count($values) > 0); return $trueValues->isNotEmpty() diff --git a/app/Classes/LDAP/Server.php b/app/Classes/LDAP/Server.php index a3a571bc..4bdbd277 100644 --- a/app/Classes/LDAP/Server.php +++ b/app/Classes/LDAP/Server.php @@ -236,10 +236,10 @@ private static function cachetime(): Carbon * Generic Builder method to setup our queries consistently - mainly to ensure we cache results * * @param string $dn - * @param array $attrs Includes pwdReset explicitly as ppolicy operational attributes aren't returned by '+' + * @param array $attrs * @return Builder */ - private static function get(string $dn,array $attrs=['*','+','pwdReset']): Builder + private static function get(string $dn,array $attrs=['*','+']): Builder { Log::debug(sprintf('%s:Getting [%s]',self::LOGKEY,$dn)); @@ -309,7 +309,7 @@ public function children(string $dn,array $attrs=['dn']): ?LDAPCollection * @param array $attrs * @return Model|null */ - public function fetch(string $dn,array $attrs=['*','+','pwdReset']): ?Model + public function fetch(string $dn,array $attrs=['*','+']): ?Model { static $depth = []; $cd = Arr::get($depth,$dn,0); diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index cfc18329..72d35b5a 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -49,7 +49,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) {