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(set) string $file;
private array $template;
private Collection $template;
private(set) bool $invalid = FALSE;
private(set) string $reason = '';
private Collection $on_change_target;
@ -31,7 +31,7 @@ class Template
try {
// @todo Load in the proper attribute objects and objectclass objects
// @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) {
$this->invalid = TRUE;
@ -42,12 +42,11 @@ class Template
public function __get(string $key): mixed
{
return match ($key) {
'attributes' => collect(Arr::get($this->template,$key))->keys(),
'enabled' => Arr::get($this->template,$key,FALSE) && (! $this->invalid),
'icon','regexp','title' => Arr::get($this->template,$key),
'attributes','objectclasses' => collect($this->template->get($key)),
'enabled' => $this->template->get($key,FALSE) && (! $this->invalid),
'icon','regexp','title' => $this->template->get($key),
'name' => Str::replaceEnd('.json','',$this->file),
'objectclasses' => collect(Arr::get($this->template,$key)),
'order' => collect(Arr::get($this->template,'attributes'))->map(fn($item)=>$item['order']),
'order' => $this->attributes->map(fn($item)=>Arr::get($item,'order')),
default => throw new \Exception('Unknown key: '.$key),
};
@ -55,7 +54,32 @@ class Template
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
{
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
{
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\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use LdapRecord\Auth\BindException;
use LdapRecord\Container;
use App\Exceptions\InvalidUsage;
use App\Http\Controllers\Controller;
use App\Ldap\Entry;
@ -57,8 +58,9 @@ class LoginController extends Controller
* When attempt to login
*
* @param Request $request
* @return void
* @throws InvalidUsage
* @return bool
* @throws \LdapRecord\ConnectionException
* @throws \LdapRecord\ContainerException
*/
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 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)) {
// 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);
$o = new Entry;
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()];
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)
{
$o->{$ao->name} = [Entry::TAG_NOTAG=>''];

View File

@ -49,7 +49,7 @@ class Entry extends Model
$this->objects = collect();
// Load any templates
$this->templates = Cache::remember('templates'.Session::id(),config('ldap.cache.time'),function() {
$this->templates = Cache::remember('templates'.Session::id(),config('ldap.cache.time'),function() {
$template_dir = Storage::disk(config('pla.template.dir'));
$templates = collect();

View File

@ -14,11 +14,20 @@ attribute#objectclass .input-group-end:not(input.form-control) {
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-top-right-radius: unset;
}
.select2-container .select2-selection--single .select2-selection__rendered {
font-size: 0.88em;
}
input.form-control.input-group-end {
border-bottom-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>
@if($new)
@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
@if($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>
@if($ca=$template?->onChangeAttribute($o->name_lc))
<sup data-bs-toggle="tooltip" title="@lang('Value triggers an update to another attribute')"><i class="fas fa-keyboard"></i></sup>
@endif
@if ($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>
@if ($ct=$template?->onChangeTarget($o->name_lc))
<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
@ -59,7 +62,14 @@
</div>
</div>
<x-attribute :o="$o" :edit="(! $template?->attributeReadOnly($o->name)) && $edit" :new="$new" :updated="$updated"/>
@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"/>
@endswitch
</div>
</div>

View File

@ -4,7 +4,7 @@
@foreach(($o->tagValues($langtag)->count() ? $o->tagValues($langtag) : [$langtag => NULL]) as $key => $value)
@if($edit)
<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)>
<div class="invalid-feedback pb-2">
@ -24,7 +24,7 @@
<div class="row">
<div class="offset-1 col-4">
<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>
</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')
@section('error')
501: @lang('LDAP Authentication Error')
501: @lang('LDAP User Error')
@endsection
@section('content')

View File

@ -8,7 +8,7 @@
<div class="tab-content">
@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)"/>
@endforeach
</div>

View File

@ -282,27 +282,30 @@
})
break;
case 'entry-userpassword-check':
$.ajax({
method: 'GET',
url: '{{ url('modal/userpassword-check') }}/'+dn,
dataType: 'html',
cache: false,
beforeSend: function() {
that.empty().append('<span class="p-3"><i class="fas fa-3x fa-spinner fa-pulse"></i></span>');
},
success: function(data) {
that.empty().html(data);
},
error: function(e) {
if (e.status !== 412)
alert('That didnt work? Please try again....');
},
})
break;
default:
console.log('No action for button:'+$(item.relatedTarget).attr('id'));
switch ($(item.relatedTarget).attr('name')) {
case 'entry-userpassword-check':
$.ajax({
method: 'GET',
url: '{{ url('modal/userpassword-check') }}/'+dn,
dataType: 'html',
cache: false,
beforeSend: function() {
that.empty().append('<span class="p-3"><i class="fas fa-3x fa-spinner fa-pulse"></i></span>');
},
success: function(data) {
that.empty().html(data);
},
error: function(e) {
if (e.status !== 412)
alert('That didnt work? Please try again....');
},
})
break;
default:
console.log('No action for button:'+$(item.relatedTarget).attr('id'));
}
}
});

View File

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