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
|
import pyotp
|
||||||
|
from argon2 import PasswordHasher
|
||||||
from crispy_forms.bootstrap import PrependedText
|
from crispy_forms.bootstrap import PrependedText
|
||||||
from crispy_forms.helper import FormHelper
|
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 import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@@ -10,13 +13,30 @@ from gatekeeper.models import GatekeeperUser, GatekeeperGroup, AuthMethod, AuthM
|
|||||||
|
|
||||||
|
|
||||||
class GatekeeperUserForm(forms.ModelForm):
|
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:
|
class Meta:
|
||||||
model = GatekeeperUser
|
model = GatekeeperUser
|
||||||
fields = ['username', 'email', 'password', 'totp_secret']
|
fields = ['username', 'email', 'totp_secret']
|
||||||
labels = {
|
labels = {
|
||||||
'username': _('Username'),
|
'username': _('Username'),
|
||||||
'email': _('Email'),
|
'email': _('Email'),
|
||||||
'password': _('Password'),
|
|
||||||
'totp_secret': _('TOTP Secret'),
|
'totp_secret': _('TOTP Secret'),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +44,10 @@ class GatekeeperUserForm(forms.ModelForm):
|
|||||||
cancel_url = kwargs.pop('cancel_url', '#')
|
cancel_url = kwargs.pop('cancel_url', '#')
|
||||||
super().__init__(*args, **kwargs)
|
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 = FormHelper()
|
||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
Div(
|
Div(
|
||||||
@@ -32,8 +56,13 @@ class GatekeeperUserForm(forms.ModelForm):
|
|||||||
css_class='row'
|
css_class='row'
|
||||||
),
|
),
|
||||||
Div(
|
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_secret', css_class='col-xl-6'),
|
||||||
|
Div('totp_pin', css_class='col-xl-6'),
|
||||||
css_class='row'
|
css_class='row'
|
||||||
),
|
),
|
||||||
Div(
|
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 GatekeeperGroupForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -64,12 +64,32 @@ def view_manage_gatekeeper_user(request):
|
|||||||
messages.success(request, _('Gatekeeper User saved successfully.'))
|
messages.success(request, _('Gatekeeper User saved successfully.'))
|
||||||
return redirect(cancel_url)
|
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 = {
|
context = {
|
||||||
'form': form,
|
'form': form,
|
||||||
|
'form_size': 'col-lg-6',
|
||||||
'title': title,
|
'title': title,
|
||||||
'page_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
|
@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