mirror of
https://github.com/eduardogsilva/wireguard_webadmin.git
synced 2026-03-17 14:26:18 +00:00
add password and TOTP PIN fields to user form with validation and QR code generation
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
83
templates/gatekeeper/gatekeeper_user_form.html
Normal file
83
templates/gatekeeper/gatekeeper_user_form.html
Normal file
@@ -0,0 +1,83 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class='row'>
|
||||
<div class='{% if form_size %}{{ form_size }}{% else %}col-lg-6{% endif %}'>
|
||||
<div class="card card-primary card-outline">
|
||||
{% if page_title %}
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">{{ page_title }}</h3>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body row">
|
||||
<div class="col-lg-12">
|
||||
{% csrf_token %}
|
||||
{% crispy form %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if form_description %}
|
||||
<div class='{% if form_description.size %}{{ form_description.size }}{% else %}col-lg-6{% endif %}'>
|
||||
<div class="card card-primary card-outline">
|
||||
|
||||
<div class="card-body row">
|
||||
<div class="col-lg-12">
|
||||
{{ form_description.content|safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block custom_page_scripts %}
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
var qrContainer = $('<div class="mt-3 text-center" style="display:none;" id="qrCodeContainer"><img id="qrCodeImg" src="" class="img-fluid" style="border: 2px solid #ddd; border-radius: 8px; max-width: 250px;"/></div>');
|
||||
var btnShowQr = $('<button type="button" class="btn btn-sm btn-info mt-2 mr-1" id="btnShowQr"><i class="fas fa-qrcode"></i> {% trans 'View QR Code' %}</button>');
|
||||
var btnGenerate = $('<button type="button" class="btn btn-sm btn-secondary mt-2" id="btnGenerateTotp"><i class="fas fa-sync-alt"></i> {% trans 'Generate TOTP Secret' %}</button>');
|
||||
|
||||
$('#div_id_totp_secret').append(btnShowQr);
|
||||
$('#div_id_totp_secret').append(btnGenerate);
|
||||
$('#div_id_totp_secret').append(qrContainer);
|
||||
|
||||
function generateBase32Secret() {
|
||||
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
var randomBytes = new Uint8Array(32);
|
||||
window.crypto.getRandomValues(randomBytes);
|
||||
var result = '';
|
||||
for (var digit = 0; digit < 32; digit++) {
|
||||
result += chars[randomBytes[digit] % 32];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
$('#btnGenerateTotp').click(function (e) {
|
||||
e.preventDefault();
|
||||
$('#id_totp_secret').val(generateBase32Secret());
|
||||
$('#qrCodeContainer').slideUp();
|
||||
});
|
||||
|
||||
$('#btnShowQr').click(function (e) {
|
||||
e.preventDefault();
|
||||
var secret = $('#id_totp_secret').val();
|
||||
var name = $('#id_username').val() || 'Gatekeeper';
|
||||
|
||||
if (!secret) {
|
||||
alert("{% trans 'Please enter a TOTP Secret first to generate the QR code.' %}");
|
||||
return;
|
||||
}
|
||||
|
||||
var url = '/gatekeeper/auth_method/qr/?secret=' + encodeURIComponent(secret) + '&name=' + encodeURIComponent(name);
|
||||
$('#qrCodeImg').attr('src', url);
|
||||
$('#qrCodeContainer').slideToggle();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user