add password and TOTP PIN fields to user form with validation and QR code generation

This commit is contained in:
Eduardo Silva
2026-03-15 17:08:58 -03:00
parent 75d4fb022b
commit 2386d8fbb3
3 changed files with 183 additions and 5 deletions

View File

@@ -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:

View File

@@ -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': _('''
<h4>Gatekeeper User</h4>
<p>Gatekeeper users are used for authenticating against protected applications managed by this gateway.</p>
<h5>Password</h5>
<p>Required when creating a user. When editing, leave both password fields blank to keep the current password.
Passwords are stored using <strong>Argon2id</strong> hashing.</p>
<h5>TOTP Secret</h5>
<p>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.</p>
''')
}
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