Compare commits

..

4 Commits

Author SHA1 Message Date
543250e1fb Fix entry-userpassword-check when entry is rendered with a template
All checks were successful
Create Docker Image / Test Application (x86_64) (push) Successful in 30s
Create Docker Image / Build Docker Image (x86_64) (push) Successful in 1m27s
Create Docker Image / Build Docker Image (arm64) (push) Successful in 2m51s
Create Docker Image / Final Docker Image Manifest (push) Successful in 10s
2025-06-22 22:10:21 +10:00
3bf97fc0d1 Add the ability to use a select list for template attributes 2025-06-22 22:08:38 +10:00
3ad4c446ea Change our template attribute processing, to be collections, so we can find attributes using anycase keys 2025-06-22 17:27:56 +10:00
ee3cb395c2 Enhancement to 8fd2a43, validating authentication before rendering the DN doesnt exist error (otherwise it is an authentication issue) 2025-06-22 14:07:33 +10:00
12 changed files with 165 additions and 63 deletions

View File

@ -13,7 +13,7 @@ class Template
private const LOGKEY = 'T--'; private const LOGKEY = 'T--';
private(set) string $file; private(set) string $file;
private array $template; private Collection $template;
private(set) bool $invalid = FALSE; private(set) bool $invalid = FALSE;
private(set) string $reason = ''; private(set) string $reason = '';
private Collection $on_change_target; private Collection $on_change_target;
@ -31,7 +31,7 @@ class Template
try { try {
// @todo Load in the proper attribute objects and objectclass objects // @todo Load in the proper attribute objects and objectclass objects
// @todo Make sure we have a structural objectclass, or make the template invalid // @todo Make sure we have a structural objectclass, or make the template invalid
$this->template = json_decode($td->get($file),null,512,JSON_OBJECT_AS_ARRAY|JSON_THROW_ON_ERROR); $this->template = collect(json_decode($td->get($file),null,512,JSON_OBJECT_AS_ARRAY|JSON_THROW_ON_ERROR));
} catch (\JsonException $e) { } catch (\JsonException $e) {
$this->invalid = TRUE; $this->invalid = TRUE;
@ -42,12 +42,11 @@ class Template
public function __get(string $key): mixed public function __get(string $key): mixed
{ {
return match ($key) { return match ($key) {
'attributes' => collect(Arr::get($this->template,$key))->keys(), 'attributes','objectclasses' => collect($this->template->get($key)),
'enabled' => Arr::get($this->template,$key,FALSE) && (! $this->invalid), 'enabled' => $this->template->get($key,FALSE) && (! $this->invalid),
'icon','regexp','title' => Arr::get($this->template,$key), 'icon','regexp','title' => $this->template->get($key),
'name' => Str::replaceEnd('.json','',$this->file), 'name' => Str::replaceEnd('.json','',$this->file),
'objectclasses' => collect(Arr::get($this->template,$key)), 'order' => $this->attributes->map(fn($item)=>Arr::get($item,'order')),
'order' => collect(Arr::get($this->template,'attributes'))->map(fn($item)=>$item['order']),
default => throw new \Exception('Unknown key: '.$key), default => throw new \Exception('Unknown key: '.$key),
}; };
@ -55,7 +54,32 @@ class Template
public function __isset(string $key): bool public function __isset(string $key): bool
{ {
return array_key_exists($key,$this->template); return $this->template->has($key);
}
/**
* Return the configuration for an attribute
*
* @param string $attribute
* @return array|NULL
*/
public function attribute(string $attribute): Collection|NULL
{
$key = $this->attributes->search(fn($item,$key)=>! strcasecmp($key,$attribute));
return collect($this->attributes->get($key));
}
/**
* Return an template attributes select options
*
* @param string $attribute
* @return Collection|NULL
*/
public function attributeOptions(string $attribute): Collection|NULL
{
return ($x=$this->attribute($attribute)?->get('options'))
? collect($x)->map(fn($item,$key)=>['id'=>$key,'value'=>$item])
: NULL;
} }
/** /**
@ -66,7 +90,7 @@ class Template
*/ */
public function attributeReadOnly(string $attribute): bool public function attributeReadOnly(string $attribute): bool
{ {
return ($x=Arr::get($this->template,'attributes.'.$attribute.'.readonly')) && $x; return ($x=$this->attribute($attribute)?->get('readonly')) && $x;
} }
/** /**
@ -77,7 +101,18 @@ class Template
*/ */
public function attributeTitle(string $attribute): string|NULL public function attributeTitle(string $attribute): string|NULL
{ {
return Arr::get($this->template,'attributes.'.$attribute.'.display'); return $this->attribute($attribute)?->get('display');
}
/**
* Return the title we should use for an attribute
*
* @param string $attribute
* @return string|NULL
*/
public function attributeType(string $attribute): string|NULL
{
return $this->attribute($attribute)?->get('type');
} }
/** /**

View File

@ -7,8 +7,9 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use LdapRecord\Auth\BindException;
use LdapRecord\Container;
use App\Exceptions\InvalidUsage;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Ldap\Entry; use App\Ldap\Entry;
@ -57,8 +58,9 @@ class LoginController extends Controller
* When attempt to login * When attempt to login
* *
* @param Request $request * @param Request $request
* @return void * @return bool
* @throws InvalidUsage * @throws \LdapRecord\ConnectionException
* @throws \LdapRecord\ContainerException
*/ */
public function attemptLogin(Request $request) public function attemptLogin(Request $request)
{ {
@ -69,12 +71,26 @@ class LoginController extends Controller
// If the login failed, and PLA is set to use DN login, check if the entry exists. // If the login failed, and PLA is set to use DN login, check if the entry exists.
// If the entry doesnt exist, it might be the root DN, which cannot be used to login // If the entry doesnt exist, it might be the root DN, which cannot be used to login
if ((! $attempt) && $request->dn && config('pla.login.alert_rootdn',TRUE)) { if ((! $attempt) && $request->dn && config('pla.login.alert_rootdn',TRUE)) {
// Double check our credentials, and see if they authenticate
try {
Container::getInstance()
->getConnection()
->auth()
->bind($request->get(login_attr_name()),$request->get('password'));
} catch (BindException $e) {
// Password incorrect, fail anyway
return FALSE;
}
$dn = config('server')->fetch($request->dn); $dn = config('server')->fetch($request->dn);
$o = new Entry; $o = new Entry;
if (! $dn && $o->getConnection()->getLdapConnection()->errNo() === 32) if (! $dn && $o->getConnection()->getLdapConnection()->errNo() === 32)
abort(501,'Authentication set to DN, but the DN doesnt exist'); abort(501,'Authentication succeeded, but the DN doesnt exist');
} }
return $attempt;
} }
/** /**

View File

@ -57,7 +57,7 @@ class HomeController extends Controller
$o->objectclass = [Entry::TAG_NOTAG=>$template->objectclasses->toArray()]; $o->objectclass = [Entry::TAG_NOTAG=>$template->objectclasses->toArray()];
foreach ($o->getAvailableAttributes() foreach ($o->getAvailableAttributes()
->filter(fn($item)=>$item->names_lc->intersect($template->attributes->map('strtolower'))->count()) ->filter(fn($item)=>$item->names_lc->intersect($template->attributes->keys()->map('strtolower'))->count())
->sortBy(fn($item)=>Arr::get($template->order,$item->name)) as $ao) ->sortBy(fn($item)=>Arr::get($template->order,$item->name)) as $ao)
{ {
$o->{$ao->name} = [Entry::TAG_NOTAG=>'']; $o->{$ao->name} = [Entry::TAG_NOTAG=>''];

View File

@ -14,11 +14,20 @@ attribute#objectclass .input-group-end:not(input.form-control) {
z-index: 5; z-index: 5;
} }
.input-group:first-child .select2-container--bootstrap-5 .select2-selection { /* select forms that have nothing next to them */
.select-group:first-child .select2-container--bootstrap-5 .select2-selection {
border-radius: 4px !important;
}
.input-group:first-child:not(.select-group) .select2-container--bootstrap-5 .select2-selection {
border-bottom-right-radius: unset; border-bottom-right-radius: unset;
border-top-right-radius: unset; border-top-right-radius: unset;
} }
.select2-container .select2-selection--single .select2-selection__rendered {
font-size: 0.88em;
}
input.form-control.input-group-end { input.form-control.input-group-end {
border-bottom-right-radius: 4px !important; border-bottom-right-radius: 4px !important;
border-top-right-radius: 4px !important; border-top-right-radius: 4px !important;

View File

@ -9,13 +9,16 @@
<strong class="align-middle"><abbr title="{{ (($x=$template?->attributeTitle($o->name)) ? $o->name.': ' : '').$o->description }}">{{ $x ?: $o->name }}</abbr></strong> <strong class="align-middle"><abbr title="{{ (($x=$template?->attributeTitle($o->name)) ? $o->name.': ' : '').$o->description }}">{{ $x ?: $o->name }}</abbr></strong>
@if($new) @if($new)
@if($template?->attributeReadOnly($o->name_lc)) @if($template?->attributeReadOnly($o->name_lc))
<sup data-bs-toggle="tooltip" title="@lang('Input disabled by template')"><i class="fas fa-ban"></i></sup> <sup data-bs-toggle="tooltip" title="@lang('Input disabled')"><i class="fas fa-ban"></i></sup>
@endif @endif
@if($template?->onChangeAttribute($o->name_lc)) @if($ca=$template?->onChangeAttribute($o->name_lc))
<sup data-bs-toggle="tooltip" title="@lang('Value triggers an update to another attribute by template')"><i class="fas fa-keyboard"></i></sup> <sup data-bs-toggle="tooltip" title="@lang('Value triggers an update to another attribute')"><i class="fas fa-keyboard"></i></sup>
@endif @endif
@if ($template?->onChangeTarget($o->name_lc)) @if ($ct=$template?->onChangeTarget($o->name_lc))
<sup data-bs-toggle="tooltip" title="@lang('Value calculated by template')"><i class="fas fa-wand-magic-sparkles"></i></sup> <sup data-bs-toggle="tooltip" title="@lang('Value calculated from another attribute')"><i class="fas fa-wand-magic-sparkles"></i></sup>
@endif
@if((! $ca) && (! $ct) && $template?->attribute($o->name_lc))
<sup data-bs-toggle="tooltip" title="@lang('Attribute controlled by template')"><i class="fas fa-wand-magic"></i></sup>
@endif @endif
@endif @endif
@ -59,7 +62,14 @@
</div> </div>
</div> </div>
@switch($template?->attributeType($o->name))
@case('select')
<x-attribute.template.select :o="$o" :template="$template" :edit="(! $template?->attributeReadOnly($o->name)) && $edit" :new="$new"/>
@break;
@default
<x-attribute :o="$o" :edit="(! $template?->attributeReadOnly($o->name)) && $edit" :new="$new" :updated="$updated"/> <x-attribute :o="$o" :edit="(! $template?->attributeReadOnly($o->name)) && $edit" :new="$new" :updated="$updated"/>
@endswitch
</div> </div>
</div> </div>

View File

@ -4,7 +4,7 @@
@foreach(($o->tagValues($langtag)->count() ? $o->tagValues($langtag) : [$langtag => NULL]) as $key => $value) @foreach(($o->tagValues($langtag)->count() ? $o->tagValues($langtag) : [$langtag => NULL]) as $key => $value)
@if($edit) @if($edit)
<div class="input-group has-validation"> <div class="input-group has-validation">
<x-form.select id="userpassword_hash_{{$loop->index}}{{$template?->name ?? ''}}" name="_userpassword_hash[{{ $langtag }}][]" :value="$o->hash($new ? '' : ($value ?? ''))->id()" :options="$helpers" allowclear="false" :disabled="! $new"/> <x-form.select id="userpassword_hash_{{$loop->index}}{{$template?->name ?: ''}}" name="_userpassword_hash[{{ $langtag }}][]" :value="$o->hash($new ? '' : ($value ?? ''))->id()" :options="$helpers" allowclear="false" :disabled="! $new"/>
<input type="password" @class(['form-control','is-invalid'=>($e=$errors->get($o->name_lc.'.'.$langtag.'.'.$loop->index)),'mb-1','border-focus'=>! $o->tagValuesOld($langtag)->contains($value),'bg-success-subtle'=>$updated]) name="{{ $o->name_lc }}[{{ $langtag }}][]" value="{{ Arr::get(old($o->name_lc),$langtag.'.'.$loop->index,$value ? md5($value) : '') }}" @readonly(! $new)> <input type="password" @class(['form-control','is-invalid'=>($e=$errors->get($o->name_lc.'.'.$langtag.'.'.$loop->index)),'mb-1','border-focus'=>! $o->tagValuesOld($langtag)->contains($value),'bg-success-subtle'=>$updated]) name="{{ $o->name_lc }}[{{ $langtag }}][]" value="{{ Arr::get(old($o->name_lc),$langtag.'.'.$loop->index,$value ? md5($value) : '') }}" @readonly(! $new)>
<div class="invalid-feedback pb-2"> <div class="invalid-feedback pb-2">
@ -24,7 +24,7 @@
<div class="row"> <div class="row">
<div class="offset-1 col-4"> <div class="offset-1 col-4">
<span class="p-0 m-0"> <span class="p-0 m-0">
<button id="entry-userpassword-check" type="button" class="btn btn-sm btn-outline-dark mt-3" data-bs-toggle="modal" data-bs-target="#page-modal"><i class="fas fa-user-check"></i> @lang('Check Password')</button> <button name="entry-userpassword-check" type="button" class="btn btn-sm btn-outline-dark mt-3" data-bs-toggle="modal" data-bs-target="#page-modal"><i class="fas fa-user-check"></i> @lang('Check Password')</button>
</span> </span>
</div> </div>
</div> </div>

View File

@ -0,0 +1,28 @@
<!-- $o=Attribute::class -->
<x-attribute.layout :edit="$edit" :new="$new" :o="$o">
@foreach($o->langtags as $langtag)
@foreach(($o->tagValues($langtag)->count() ? $o->tagValues($langtag) : [$langtag => NULL]) as $key => $value)
@if($edit)
<div class="select-group">
<x-form.select
@class(['is-invalid'=>($e=$errors->get($o->name_lc.'.'.$langtag.'.'.$loop->index)),'mb-1','border-focus'=>! $o->tagValuesOld($langtag)->contains($value)])
id="{{ $o->name_lc }}_{{$loop->index}}{{$template?->name ?: ''}}"
name="{{ $o->name_lc }}[{{ $langtag }}][]"
:value="$value"
:options="$template->attributeOptions($o->name_lc)"
allowclear="true"
:disabled="! $new"
:readonly="FALSE"/>
<div class="invalid-feedback pb-2">
@if($e=$errors->get($o->name_lc.'.'.$langtag.'.'.$loop->index))
{{ join('|',$e) }}
@endif
</div>
</div>
@else
{{ $o->render_item_old($langtag.'.'.$key) }}
@endif
@endforeach
@endforeach
</x-attribute.layout>

View File

@ -1,7 +1,7 @@
@extends('architect::layouts.error') @extends('architect::layouts.error')
@section('error') @section('error')
501: @lang('LDAP Authentication Error') 501: @lang('LDAP User Error')
@endsection @endsection
@section('content') @section('content')

View File

@ -8,7 +8,7 @@
<div class="tab-content"> <div class="tab-content">
@php($up=(session()->get('updated') ?: collect())) @php($up=(session()->get('updated') ?: collect()))
@foreach($o->getVisibleAttributes()->filter(fn($item)=>$template->attributes->map('strtolower')->contains($item->name_lc)) as $ao) @foreach($o->getVisibleAttributes()->filter(fn($item)=>$template->attributes->keys()->map('strtolower')->contains($item->name_lc)) as $ao)
<x-attribute-type :o="$ao" :edit="TRUE" :new="FALSE" :template="$template" :updated="$up->contains($ao->name)"/> <x-attribute-type :o="$ao" :edit="TRUE" :new="FALSE" :template="$template" :updated="$up->contains($ao->name)"/>
@endforeach @endforeach
</div> </div>

View File

@ -282,6 +282,8 @@
}) })
break; break;
default:
switch ($(item.relatedTarget).attr('name')) {
case 'entry-userpassword-check': case 'entry-userpassword-check':
$.ajax({ $.ajax({
method: 'GET', method: 'GET',
@ -304,6 +306,7 @@
default: default:
console.log('No action for button:'+$(item.relatedTarget).attr('id')); console.log('No action for button:'+$(item.relatedTarget).attr('id'));
} }
}
}); });
$('#page-modal').on('hide.bs.modal',function() { $('#page-modal').on('hide.bs.modal',function() {

View File

@ -1,31 +1,32 @@
{ {
"title": "Example entry", "title": "Example entry", // Title shown when selecting tempaltes
"description": "This is the description", "description": "This is the description", // Unused, only for documenting
"enabled": false, "enabled": false, // Whether template is enabled or not
"icon": "fa-star-of-life", "icon": "fa-star-of-life", // Icon shown when rendering an existing entry that identifies as this template
"rdn": "o", "rdn": "o", // @todo not implemented
"regexp": "/^$/", "regexp": "/^$/", // Regular expression that restricts where this template cna be used
"objectclasses": [ "objectclasses": [ // Objectclasses that entries will have if they use this template
"organization" "organization"
], ],
"attributes": { "attributes": { // Attribute configuration
"attribute1": { "attribute1": { // LDAP attribute name
"display": "Attribute 1", "display": "Attribute 1", // Displayed when accepting input for this value
"hint": "This is an example", "hint": "This is an example", // @todo not implemented
"order": 1 "type": null, // Default is NULL, so use normal Attribute rendering type
"order": 1 // Order to show attributes
}, },
"attribute2": { "attribute2": {
"display": "Attribute 2", "display": "Attribute 2",
"hint": "This is an example", "hint": "This is an example",
"type": "input", // Default is input "type": "input", // Force attribute to use template input
"order": 2 "order": 2
}, },
"attribute3": { "attribute3": {
"display": "Attribute 3", "display": "Attribute 3",
"type": "select", "type": "select", // Force attribute to use template select
"options": { "options": { // Select options
"/bin/bash": "Bash", "/bin/bash": "Bash",
"/bin/csh": "C Shell", "/bin/csh": "C Shell",
"/bin/dash": "Dash", "/bin/dash": "Dash",