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: