add gatekeeper management views, forms, and templates

This commit is contained in:
Eduardo Silva
2026-03-12 09:22:00 -03:00
parent 6b4b2fd41d
commit e57bce8495
6 changed files with 857 additions and 1 deletions

248
gatekeeper/forms.py Normal file
View File

@@ -0,0 +1,248 @@
import pyotp
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Submit, HTML, Div, Field
from django import forms
from django.utils.translation import gettext_lazy as _
from gatekeeper.models import GatekeeperUser, GatekeeperGroup, AuthMethod, AuthMethodAllowedDomain, \
AuthMethodAllowedEmail
class GatekeeperUserForm(forms.ModelForm):
class Meta:
model = GatekeeperUser
fields = ['username', 'email', 'password', 'totp_secret']
labels = {
'username': _('Username'),
'email': _('Email'),
'password': _('Password'),
'totp_secret': _('TOTP Secret'),
}
def __init__(self, *args, **kwargs):
cancel_url = kwargs.pop('cancel_url', '#')
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.layout = Layout(
Div(
Div('username', css_class='col-md-6'),
Div('email', css_class='col-md-6'),
css_class='row'
),
Div(
Div(Field('password', type='password'), css_class='col-md-6'),
Div('totp_secret', css_class='col-md-6'),
css_class='row'
),
Div(
Div(
Submit('submit', _('Save'), css_class='btn btn-primary'),
HTML(f'<a href="{cancel_url}" class="btn btn-secondary">{_("Cancel")}</a>'),
css_class='col-12 d-flex justify-content-end gap-2 mt-3'
),
css_class='row'
)
)
class GatekeeperGroupForm(forms.ModelForm):
class Meta:
model = GatekeeperGroup
fields = ['name', 'users']
labels = {
'name': _('Group Name'),
'users': _('Members'),
}
def __init__(self, *args, **kwargs):
cancel_url = kwargs.pop('cancel_url', '#')
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.layout = Layout(
Div(
Div('name', css_class='col-md-12'),
css_class='row'
),
Div(
Div('users', css_class='col-md-12'),
css_class='row'
),
Div(
Div(
Submit('submit', _('Save'), css_class='btn btn-primary'),
HTML(f'<a href="{cancel_url}" class="btn btn-secondary">{_("Cancel")}</a>'),
css_class='col-12 d-flex justify-content-end gap-2 mt-3'
),
css_class='row'
)
)
class AuthMethodForm(forms.ModelForm):
totp_pin = forms.CharField(
label=_('TOTP Validation PIN'),
max_length=6,
required=False,
help_text=_('Enter a 6-digit PIN generated by your authenticator app to validate the secret.')
)
class Meta:
model = AuthMethod
fields = [
'name', 'auth_type', 'totp_secret',
'oidc_provider', 'oidc_client_id', 'oidc_client_secret'
]
labels = {
'name': _('Name'),
'auth_type': _('Authentication Type'),
'totp_secret': _('Global TOTP Secret'),
'oidc_provider': _('OIDC Provider URL'),
'oidc_client_id': _('OIDC Client ID'),
'oidc_client_secret': _('OIDC Client Secret'),
}
def __init__(self, *args, **kwargs):
cancel_url = kwargs.pop('cancel_url', '#')
super().__init__(*args, **kwargs)
if self.instance and self.instance.pk:
self.fields['auth_type'].disabled = True
self.helper = FormHelper()
self.helper.layout = Layout(
Div(
Div('name', css_class='col-md-6'),
Div('auth_type', css_class='col-md-6'),
css_class='row'
),
Div(
Div('totp_secret', css_class='col-md-6'),
Div('totp_pin', css_class='col-md-6'),
css_class='row'
),
Div(
Div('oidc_provider', css_class='col-md-12'),
css_class='row'
),
Div(
Div('oidc_client_id', css_class='col-md-6'),
Div('oidc_client_secret', css_class='col-md-6'),
css_class='row'
),
Div(
Div(
Submit('submit', _('Save'), css_class='btn btn-primary'),
HTML(f'<a href="{cancel_url}" class="btn btn-secondary">{_("Cancel")}</a>'),
css_class='col-12 d-flex justify-content-end gap-2 mt-3'
),
css_class='row'
)
)
def clean(self):
cleaned_data = super().clean()
auth_type = cleaned_data.get('auth_type')
totp_secret = cleaned_data.get('totp_secret')
oidc_provider = cleaned_data.get('oidc_provider')
oidc_client_id = cleaned_data.get('oidc_client_id')
oidc_client_secret = cleaned_data.get('oidc_client_secret')
if auth_type == 'local_password':
if totp_secret:
self.add_error('totp_secret', _('TOTP secret must be empty for Local Password authentication.'))
if cleaned_data.get('totp_pin'):
self.add_error('totp_pin', _('TOTP validation PIN must be empty for Local Password authentication.'))
if oidc_provider or oidc_client_id or oidc_client_secret:
self.add_error(None, _('OIDC fields must be empty for Local Password authentication.'))
existing_local = AuthMethod.objects.filter(auth_type='local_password')
if self.instance and self.instance.pk:
existing_local = existing_local.exclude(pk=self.instance.pk)
if existing_local.exists():
self.add_error('auth_type', _('Only one Local Password authentication method can be configured.'))
elif auth_type == 'totp':
if oidc_provider or oidc_client_id or oidc_client_secret:
self.add_error(None, _('OIDC fields must be empty for TOTP authentication.'))
if not totp_secret:
self.add_error('totp_secret', _('TOTP secret is required for TOTP authentication.'))
else:
totp_pin = cleaned_data.get('totp_pin')
if not totp_pin:
self.add_error('totp_pin', _('Please provide a PIN to validate the TOTP secret.'))
else:
try:
totp = pyotp.TOTP(totp_secret)
if not totp.verify(totp_pin):
self.add_error('totp_pin', _('Invalid TOTP PIN.'))
except Exception:
self.add_error('totp_secret', _('Invalid TOTP secret format. Must be a valid Base32 string.'))
elif auth_type == 'oidc':
if totp_secret:
self.add_error('totp_secret', _('TOTP secret must be empty for OIDC authentication.'))
if cleaned_data.get('totp_pin'):
self.add_error('totp_pin', _('TOTP validation PIN must be empty for OIDC authentication.'))
return cleaned_data
class AuthMethodAllowedDomainForm(forms.ModelForm):
class Meta:
model = AuthMethodAllowedDomain
fields = ['auth_method', 'domain']
labels = {
'auth_method': _('Authentication Method'),
'domain': _('Domain'),
}
def __init__(self, *args, **kwargs):
cancel_url = kwargs.pop('cancel_url', '#')
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.layout = Layout(
Div(
Div('auth_method', css_class='col-md-6'),
Div('domain', css_class='col-md-6'),
css_class='row'
),
Div(
Div(
Submit('submit', _('Save'), css_class='btn btn-primary'),
HTML(f'<a href="{cancel_url}" class="btn btn-secondary">{_("Cancel")}</a>'),
css_class='col-12 d-flex justify-content-end gap-2 mt-3'
),
css_class='row'
)
)
class AuthMethodAllowedEmailForm(forms.ModelForm):
class Meta:
model = AuthMethodAllowedEmail
fields = ['auth_method', 'email']
labels = {
'auth_method': _('Authentication Method'),
'email': _('Email'),
}
def __init__(self, *args, **kwargs):
cancel_url = kwargs.pop('cancel_url', '#')
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.layout = Layout(
Div(
Div('auth_method', css_class='col-md-6'),
Div('email', css_class='col-md-6'),
css_class='row'
),
Div(
Div(
Submit('submit', _('Save'), css_class='btn btn-primary'),
HTML(f'<a href="{cancel_url}" class="btn btn-secondary">{_("Cancel")}</a>'),
css_class='col-12 d-flex justify-content-end gap-2 mt-3'
),
css_class='row'
)
)

28
gatekeeper/urls.py Normal file
View File

@@ -0,0 +1,28 @@
from django.urls import path
from gatekeeper import views
urlpatterns = [
# Main Dashboard / List
path('', views.view_gatekeeper_list, name='gatekeeper_list'),
# Gatekeeper Users
path('user/manage/', views.view_manage_gatekeeper_user, name='manage_gatekeeper_user'),
path('user/delete/', views.view_delete_gatekeeper_user, name='delete_gatekeeper_user'),
# Gatekeeper Groups
path('group/manage/', views.view_manage_gatekeeper_group, name='manage_gatekeeper_group'),
path('group/delete/', views.view_delete_gatekeeper_group, name='delete_gatekeeper_group'),
# Auth Methods
path('auth_method/manage/', views.view_manage_auth_method, name='manage_gatekeeper_auth_method'),
path('auth_method/delete/', views.view_delete_auth_method, name='delete_gatekeeper_auth_method'),
# Auth Method Allowed Domains
path('domain/manage/', views.view_manage_auth_domain, name='manage_gatekeeper_domain'),
path('domain/delete/', views.view_delete_auth_domain, name='delete_gatekeeper_domain'),
# Auth Method Allowed Emails
path('email/manage/', views.view_manage_auth_email, name='manage_gatekeeper_email'),
path('email/delete/', views.view_delete_auth_email, name='delete_gatekeeper_email'),
]

View File

@@ -1 +1,335 @@
# Create your views here.
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.shortcuts import render, get_object_or_404, redirect
from django.urls import reverse
from django.utils.translation import gettext as _
from gatekeeper.forms import GatekeeperUserForm, GatekeeperGroupForm, AuthMethodForm, AuthMethodAllowedDomainForm, \
AuthMethodAllowedEmailForm
from gatekeeper.models import GatekeeperUser, GatekeeperGroup, AuthMethod, AuthMethodAllowedDomain, \
AuthMethodAllowedEmail
from user_manager.models import UserAcl
@login_required
def view_gatekeeper_list(request):
"""Main list view containing tabs for Users, Groups, and Auth Methods"""
if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=20).exists():
return render(request, 'access_denied.html', {'page_title': _('Access Denied')})
users = GatekeeperUser.objects.all().order_by('username')
groups = GatekeeperGroup.objects.all().order_by('name')
auth_methods = AuthMethod.objects.all().order_by('name')
auth_domains = AuthMethodAllowedDomain.objects.all().order_by('domain')
auth_emails = AuthMethodAllowedEmail.objects.all().order_by('email')
tab = request.GET.get('tab', 'users')
context = {
'users': users,
'groups': groups,
'auth_methods': auth_methods,
'auth_domains': auth_domains,
'auth_emails': auth_emails,
'active_tab': tab,
}
return render(request, 'gatekeeper/gatekeeper_list.html', context)
@login_required
def view_manage_gatekeeper_user(request):
if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists():
return render(request, 'access_denied.html', {'page_title': _('Access Denied')})
obj_uuid = request.GET.get('uuid')
if obj_uuid:
obj = get_object_or_404(GatekeeperUser, uuid=obj_uuid)
title = _('Edit Gatekeeper User')
else:
obj = None
title = _('Create Gatekeeper User')
cancel_url = reverse('gatekeeper_list') + '?tab=users'
if request.method == 'POST':
form = GatekeeperUserForm(request.POST, instance=obj, cancel_url=cancel_url)
if form.is_valid():
form.save()
messages.success(request, _('Gatekeeper User saved successfully.'))
return redirect(cancel_url)
else:
form = GatekeeperUserForm(instance=obj, cancel_url=cancel_url)
context = {
'form': form,
'title': title,
'page_title': title,
}
return render(request, 'generic_form.html', context)
@login_required
def view_delete_gatekeeper_user(request):
if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists():
return render(request, 'access_denied.html', {'page_title': _('Access Denied')})
obj_uuid = request.GET.get('uuid')
obj = get_object_or_404(GatekeeperUser, uuid=obj_uuid)
cancel_url = reverse('gatekeeper_list') + '?tab=users'
if request.method == 'POST':
obj.delete()
messages.success(request, _('Gatekeeper User deleted successfully.'))
return redirect(cancel_url)
context = {
'object': obj,
'title': _('Delete Gatekeeper User'),
'cancel_url': cancel_url,
'text': _('Are you sure you want to delete the user "%(username)s"?') % {'username': obj.username}
}
return render(request, 'generic_delete_confirmation.html', context)
@login_required
def view_manage_gatekeeper_group(request):
if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists():
return render(request, 'access_denied.html', {'page_title': _('Access Denied')})
obj_uuid = request.GET.get('uuid')
if obj_uuid:
obj = get_object_or_404(GatekeeperGroup, uuid=obj_uuid)
title = _('Edit Gatekeeper Group')
else:
obj = None
title = _('Create Gatekeeper Group')
cancel_url = reverse('gatekeeper_list') + '?tab=groups'
if request.method == 'POST':
form = GatekeeperGroupForm(request.POST, instance=obj, cancel_url=cancel_url)
if form.is_valid():
form.save()
messages.success(request, _('Gatekeeper Group saved successfully.'))
return redirect(cancel_url)
else:
form = GatekeeperGroupForm(instance=obj, cancel_url=cancel_url)
context = {
'form': form,
'title': title,
'page_title': title,
}
return render(request, 'generic_form.html', context)
@login_required
def view_delete_gatekeeper_group(request):
if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists():
return render(request, 'access_denied.html', {'page_title': _('Access Denied')})
obj_uuid = request.GET.get('uuid')
obj = get_object_or_404(GatekeeperGroup, uuid=obj_uuid)
cancel_url = reverse('gatekeeper_list') + '?tab=groups'
if request.method == 'POST':
obj.delete()
messages.success(request, _('Gatekeeper Group deleted successfully.'))
return redirect(cancel_url)
context = {
'object': obj,
'title': _('Delete Gatekeeper Group'),
'cancel_url': cancel_url,
'text': _('Are you sure you want to delete the group "%(name)s"?') % {'name': obj.name}
}
return render(request, 'generic_delete_confirmation.html', context)
@login_required
def view_manage_auth_method(request):
if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists():
return render(request, 'access_denied.html', {'page_title': _('Access Denied')})
obj_uuid = request.GET.get('uuid')
if obj_uuid:
obj = get_object_or_404(AuthMethod, uuid=obj_uuid)
title = _('Edit Authentication Method')
else:
obj = None
title = _('Create Authentication Method')
cancel_url = reverse('gatekeeper_list') + '?tab=auth_methods'
if request.method == 'POST':
form = AuthMethodForm(request.POST, instance=obj, cancel_url=cancel_url)
if form.is_valid():
form.save()
messages.success(request, _('Authentication Method saved successfully.'))
return redirect(cancel_url)
else:
form = AuthMethodForm(instance=obj, cancel_url=cancel_url)
form_description = {
'size': '',
'content': _('''
<h5>Authentication Types</h5>
<p>Select how users will authenticate through this method.</p>
<ul>
<li><strong>Local Password</strong>: Users will authenticate using a standard username and password stored locally. Only one of this type can be created.</li>
<li><strong>TOTP (Time-Based One-Time Password)</strong>: Users will need to enter a rotating token from an authenticator app. Requires setting a Global TOTP Secret.</li>
<li><strong>OIDC (OpenID Connect)</strong>: Users will authenticate via an external identity provider (like Keycloak, Google, or Authelia). Requires Provider URL, Client ID, and Client Secret.</li>
</ul>
''')
}
context = {
'form': form,
'title': title,
'page_title': title,
'form_description': form_description,
}
return render(request, 'generic_form.html', context)
@login_required
def view_delete_auth_method(request):
if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists():
return render(request, 'access_denied.html', {'page_title': _('Access Denied')})
obj_uuid = request.GET.get('uuid')
obj = get_object_or_404(AuthMethod, uuid=obj_uuid)
cancel_url = reverse('gatekeeper_list') + '?tab=auth_methods'
if request.method == 'POST':
obj.delete()
messages.success(request, _('Authentication Method deleted successfully.'))
return redirect(cancel_url)
context = {
'object': obj,
'title': _('Delete Authentication Method'),
'cancel_url': cancel_url,
'text': _('Are you sure you want to delete the authentication method "%(name)s"?') % {'name': obj.name}
}
return render(request, 'generic_delete_confirmation.html', context)
@login_required
def view_manage_auth_domain(request):
if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists():
return render(request, 'access_denied.html', {'page_title': _('Access Denied')})
obj_uuid = request.GET.get('uuid')
if obj_uuid:
obj = get_object_or_404(AuthMethodAllowedDomain, uuid=obj_uuid)
title = _('Edit Allowed Domain')
else:
obj = None
title = _('Add Allowed Domain')
cancel_url = reverse('gatekeeper_list') + '?tab=allowed_identities'
if request.method == 'POST':
form = AuthMethodAllowedDomainForm(request.POST, instance=obj, cancel_url=cancel_url)
if form.is_valid():
form.save()
messages.success(request, _('Allowed Domain saved successfully.'))
return redirect(cancel_url)
else:
form = AuthMethodAllowedDomainForm(instance=obj, cancel_url=cancel_url)
context = {
'form': form,
'title': title,
'page_title': title,
}
return render(request, 'generic_form.html', context)
@login_required
def view_delete_auth_domain(request):
if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists():
return render(request, 'access_denied.html', {'page_title': _('Access Denied')})
obj_uuid = request.GET.get('uuid')
obj = get_object_or_404(AuthMethodAllowedDomain, uuid=obj_uuid)
cancel_url = reverse('gatekeeper_list') + '?tab=allowed_identities'
if request.method == 'POST':
obj.delete()
messages.success(request, _('Allowed Domain deleted successfully.'))
return redirect(cancel_url)
context = {
'object': obj,
'title': _('Delete Allowed Domain'),
'cancel_url': cancel_url,
'text': _('Are you sure you want to delete the allowed domain "%(domain)s"?') % {'domain': obj.domain}
}
return render(request, 'generic_delete_confirmation.html', context)
@login_required
def view_manage_auth_email(request):
if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists():
return render(request, 'access_denied.html', {'page_title': _('Access Denied')})
obj_uuid = request.GET.get('uuid')
if obj_uuid:
obj = get_object_or_404(AuthMethodAllowedEmail, uuid=obj_uuid)
title = _('Edit Allowed Email')
else:
obj = None
title = _('Add Allowed Email')
cancel_url = reverse('gatekeeper_list') + '?tab=allowed_identities'
if request.method == 'POST':
form = AuthMethodAllowedEmailForm(request.POST, instance=obj, cancel_url=cancel_url)
if form.is_valid():
form.save()
messages.success(request, _('Allowed Email saved successfully.'))
return redirect(cancel_url)
else:
form = AuthMethodAllowedEmailForm(instance=obj, cancel_url=cancel_url)
context = {
'form': form,
'title': title,
'page_title': title,
}
return render(request, 'generic_form.html', context)
@login_required
def view_delete_auth_email(request):
if not UserAcl.objects.filter(user=request.user).filter(user_level__gte=50).exists():
return render(request, 'access_denied.html', {'page_title': _('Access Denied')})
obj_uuid = request.GET.get('uuid')
obj = get_object_or_404(AuthMethodAllowedEmail, uuid=obj_uuid)
cancel_url = reverse('gatekeeper_list') + '?tab=allowed_identities'
if request.method == 'POST':
obj.delete()
messages.success(request, _('Allowed Email deleted successfully.'))
return redirect(cancel_url)
context = {
'object': obj,
'title': _('Delete Allowed Email'),
'cancel_url': cancel_url,
'text': _('Are you sure you want to delete the allowed email "%(email)s"?') % {'email': obj.email}
}
return render(request, 'generic_delete_confirmation.html', context)