diff --git a/gatekeeper/forms.py b/gatekeeper/forms.py index 0e57e24..2d247e0 100644 --- a/gatekeeper/forms.py +++ b/gatekeeper/forms.py @@ -1,7 +1,10 @@ +import re + import pyotp +from argon2 import PasswordHasher from crispy_forms.bootstrap import PrependedText from crispy_forms.helper import FormHelper -from crispy_forms.layout import Layout, Submit, HTML, Div, Field +from crispy_forms.layout import Layout, Submit, HTML, Div from django import forms from django.utils.translation import gettext_lazy as _ @@ -10,13 +13,30 @@ from gatekeeper.models import GatekeeperUser, GatekeeperGroup, AuthMethod, AuthM class GatekeeperUserForm(forms.ModelForm): + password = forms.CharField( + label=_('Password'), + required=False, + widget=forms.PasswordInput(render_value=False), + help_text=_('Minimum 8 characters, with at least one uppercase letter, one lowercase letter, and one number.'), + ) + password_confirm = forms.CharField( + label=_('Confirm Password'), + required=False, + widget=forms.PasswordInput(render_value=False), + ) + 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 = GatekeeperUser - fields = ['username', 'email', 'password', 'totp_secret'] + fields = ['username', 'email', 'totp_secret'] labels = { 'username': _('Username'), 'email': _('Email'), - 'password': _('Password'), 'totp_secret': _('TOTP Secret'), } @@ -24,6 +44,10 @@ class GatekeeperUserForm(forms.ModelForm): cancel_url = kwargs.pop('cancel_url', '#') super().__init__(*args, **kwargs) + is_edit = bool(self.instance and self.instance.pk) + self.fields['password'].required = not is_edit + self.fields['password_confirm'].required = not is_edit + self.helper = FormHelper() self.helper.layout = Layout( Div( @@ -32,8 +56,13 @@ class GatekeeperUserForm(forms.ModelForm): css_class='row' ), Div( - Div(Field('password', type='password'), css_class='col-xl-6'), + Div('password', css_class='col-xl-6'), + Div('password_confirm', css_class='col-xl-6'), + css_class='row' + ), + Div( Div('totp_secret', css_class='col-xl-6'), + Div('totp_pin', css_class='col-xl-6'), css_class='row' ), Div( @@ -46,6 +75,52 @@ class GatekeeperUserForm(forms.ModelForm): ) ) + def clean(self): + cleaned_data = super().clean() + password = cleaned_data.get('password') + password_confirm = cleaned_data.get('password_confirm') + totp_secret = cleaned_data.get('totp_secret') + is_new = not (self.instance and self.instance.pk) + + if password or is_new: + if not password: + self.add_error('password', _('Password is required.')) + else: + if len(password) < 8: + self.add_error('password', _('Password must be at least 8 characters long.')) + elif not re.search(r'[a-z]', password): + self.add_error('password', _('Password must contain at least one lowercase letter.')) + elif not re.search(r'[A-Z]', password): + self.add_error('password', _('Password must contain at least one uppercase letter.')) + elif not re.search(r'[0-9]', password): + self.add_error('password', _('Password must contain at least one number.')) + elif password != password_confirm: + self.add_error('password_confirm', _('Passwords do not match.')) + + if totp_secret: + 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.')) + + return cleaned_data + + def save(self, commit=True): + user = super().save(commit=False) + password = self.cleaned_data.get('password') + if password: + ph = PasswordHasher() + user.password = ph.hash(password) + if commit: + user.save() + return user + class GatekeeperGroupForm(forms.ModelForm): class Meta: diff --git a/gatekeeper/views.py b/gatekeeper/views.py index 842ba6f..60afdce 100644 --- a/gatekeeper/views.py +++ b/gatekeeper/views.py @@ -64,12 +64,32 @@ def view_manage_gatekeeper_user(request): messages.success(request, _('Gatekeeper User saved successfully.')) return redirect(cancel_url) + form_description = { + 'size': 'col-lg-6', + 'content': _(''' +
Gatekeeper users are used for authenticating against protected applications managed by this gateway.
+ +Required when creating a user. When editing, leave both password fields blank to keep the current password. + Passwords are stored using Argon2id hashing.
+ +Optional per-user TOTP secret. When set, this user will authenticate using their own secret instead of the + global TOTP secret configured on the Authentication Method. Use the buttons below the field to generate a + random secret and scan the QR code with your authenticator app. Validate the secret by entering the current + 6-digit PIN before saving.
+ ''') + } + context = { 'form': form, + 'form_size': 'col-lg-6', 'title': title, 'page_title': title, + 'form_description': form_description, } - return render(request, 'generic_form.html', context) + return render(request, 'gatekeeper/gatekeeper_user_form.html', context) @login_required diff --git a/templates/gatekeeper/gatekeeper_user_form.html b/templates/gatekeeper/gatekeeper_user_form.html new file mode 100644 index 0000000..5536fa5 --- /dev/null +++ b/templates/gatekeeper/gatekeeper_user_form.html @@ -0,0 +1,83 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags %} +{% load i18n %} + +{% block content %} +