import ipaddress 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 from django import forms from django.utils.translation import gettext_lazy as _ from gatekeeper.models import GatekeeperUser, GatekeeperGroup, AuthMethod, AuthMethodAllowedDomain, \ AuthMethodAllowedEmail, GatekeeperIPAddress 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', 'totp_secret'] labels = { 'username': _('Username'), 'email': _('Email'), 'totp_secret': _('TOTP Secret'), } def __init__(self, *args, **kwargs): 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( Div('username', css_class='col-xl-6'), Div('email', css_class='col-xl-6'), css_class='row' ), Div( 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( Div( Submit('submit', _('Save'), css_class='btn btn-primary'), HTML(f'{_("Cancel")}'), css_class='col-xl-12' ), css_class='row' ) ) 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: model = GatekeeperGroup fields = ['display_name', 'users'] labels = { 'display_name': _('Group Name'), 'users': _('Members'), } def __init__(self, *args, **kwargs): cancel_url = kwargs.pop('cancel_url', '#') super().__init__(*args, **kwargs) self.fields['display_name'].required = True self.helper = FormHelper() self.helper.layout = Layout( Div( Div('display_name', css_class='col-xl-12'), css_class='row' ), Div( Div('users', css_class='col-xl-12'), css_class='row' ), Div( Div( Submit('submit', _('Save'), css_class='btn btn-primary'), HTML(f'{_("Cancel")}'), css_class='col-xl-12' ), css_class='row' ) ) class AuthMethodForm(forms.ModelForm): 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.') ) session_expiration_value = forms.IntegerField( label=_('Session Expiration'), min_value=1, required=False, initial=12, ) session_expiration_unit = forms.ChoiceField( label=_('Unit'), choices=[('hours', _('Hour(s)')), ('days', _('Day(s)'))], required=False, initial='hours', ) class Meta: model = AuthMethod fields = [ 'display_name', 'auth_type', 'totp_secret', 'oidc_provider', 'oidc_client_id', 'oidc_client_secret' ] labels = { 'display_name': _('Name'), 'auth_type': _('Authentication Type'), 'totp_secret': _('Global TOTP Secret'), 'oidc_provider': _('OIDC Provider URL'), 'oidc_client_id': _('OIDC Client ID'), 'oidc_client_secret': _('OIDC Client Secret'), } def __init__(self, *args, **kwargs): cancel_url = kwargs.pop('cancel_url', '#') super().__init__(*args, **kwargs) self.fields['display_name'].required = True if self.instance and self.instance.pk: self.fields['auth_type'].disabled = True exp_min = self.instance.session_expiration_minutes if exp_min % 1440 == 0: self.initial['session_expiration_value'] = exp_min // 1440 self.initial['session_expiration_unit'] = 'days' else: self.initial['session_expiration_value'] = max(1, round(exp_min / 60)) self.initial['session_expiration_unit'] = 'hours' self.helper = FormHelper() self.helper.layout = Layout( Div( Div('display_name', css_class='col-xl-6'), Div('auth_type', css_class='col-xl-6'), css_class='row auth-type-group' ), Div( Div('totp_secret', css_class='col-xl-6'), Div('totp_pin', css_class='col-xl-6'), css_class='row totp-group' ), Div( Div('oidc_provider', css_class='col-xl-12'), css_class='row oidc-group' ), Div( Div('oidc_client_id', css_class='col-xl-6'), Div('oidc_client_secret', css_class='col-xl-6'), css_class='row oidc-group' ), Div( Div('session_expiration_value', css_class='col-xl-6'), Div('session_expiration_unit', css_class='col-xl-6'), css_class='row expiration-group' ), Div( Div( Submit('submit', _('Save'), css_class='btn btn-primary'), HTML(f'{_("Cancel")}'), css_class='col-xl-12' ), css_class='row' ) ) def clean(self): cleaned_data = super().clean() auth_type = cleaned_data.get('auth_type') totp_secret = cleaned_data.get('totp_secret') oidc_provider = cleaned_data.get('oidc_provider') oidc_client_id = cleaned_data.get('oidc_client_id') oidc_client_secret = cleaned_data.get('oidc_client_secret') if auth_type == 'local_password': if totp_secret: self.add_error('totp_secret', _('TOTP secret must be empty for Local Password authentication.')) if cleaned_data.get('totp_pin'): self.add_error('totp_pin', _('TOTP validation PIN must be empty for Local Password authentication.')) if oidc_provider or oidc_client_id or oidc_client_secret: self.add_error(None, _('OIDC fields must be empty for Local Password authentication.')) existing_local = AuthMethod.objects.filter(auth_type='local_password') if self.instance and self.instance.pk: existing_local = existing_local.exclude(pk=self.instance.pk) if existing_local.exists(): self.add_error('auth_type', _('Only one Local Password authentication method can be configured.')) elif auth_type == 'totp': if oidc_provider or oidc_client_id or oidc_client_secret: self.add_error(None, _('OIDC fields must be empty for TOTP authentication.')) if not totp_secret: self.add_error('totp_secret', _('TOTP secret is required for TOTP authentication.')) else: 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.')) elif auth_type == 'oidc': self.add_error('auth_type', _('OIDC authentication is temporarily unavailable and will be available soon.')) if totp_secret: self.add_error('totp_secret', _('TOTP secret must be empty for OIDC authentication.')) if cleaned_data.get('totp_pin'): self.add_error('totp_pin', _('TOTP validation PIN must be empty for OIDC authentication.')) if auth_type in ('local_password', 'oidc'): value = cleaned_data.get('session_expiration_value') or 12 unit = cleaned_data.get('session_expiration_unit') or 'hours' if unit == 'days': cleaned_data['_session_expiration_minutes'] = value * 1440 else: cleaned_data['_session_expiration_minutes'] = value * 60 return cleaned_data def save(self, commit=True): instance = super().save(commit=False) if instance.auth_type in ('local_password', 'oidc'): instance.session_expiration_minutes = self.cleaned_data.get('_session_expiration_minutes', 720) if commit: instance.save() return instance class GatekeeperIPAddressForm(forms.ModelForm): class Meta: model = GatekeeperIPAddress fields = ['auth_method', 'address', 'prefix_length', 'action', 'description'] labels = { 'auth_method': _('Authentication Method'), 'address': _('IP Address'), 'prefix_length': _('Prefix Length'), 'action': _('Action'), 'description': _('Description'), } def clean(self): cleaned_data = super().clean() address = cleaned_data.get('address') prefix_length = cleaned_data.get('prefix_length') if address and prefix_length is not None: try: ip = ipaddress.ip_address(address) max_prefix = 32 if ip.version == 4 else 128 if prefix_length > max_prefix: self.add_error( 'prefix_length', _('Prefix length for IPv%(version)d must be between 0 and %(max)d.') % { 'version': ip.version, 'max': max_prefix, }, ) except ValueError: pass # address field validation handles invalid IPs return cleaned_data def __init__(self, *args, **kwargs): cancel_url = kwargs.pop('cancel_url', '#') super().__init__(*args, **kwargs) self.helper = FormHelper() self.helper.layout = Layout( Div( Div('auth_method', css_class='col-xl-12'), css_class='row' ), Div( Div('address', css_class='col-xl-6'), Div(PrependedText('prefix_length', '/'), css_class='col-xl-6'), css_class='row' ), Div( Div('action', css_class='col-xl-4'), Div('description', css_class='col-xl-8'), css_class='row' ), Div( Div( Submit('submit', _('Save'), css_class='btn btn-primary'), HTML(f'{_("Cancel")}'), css_class='col-xl-12' ), css_class='row' ) ) class AuthMethodAllowedDomainForm(forms.ModelForm): class Meta: model = AuthMethodAllowedDomain fields = ['auth_method', 'domain'] labels = { 'auth_method': _('Authentication Method'), 'domain': _('Domain'), } def __init__(self, *args, **kwargs): cancel_url = kwargs.pop('cancel_url', '#') super().__init__(*args, **kwargs) self.helper = FormHelper() self.helper.layout = Layout( Div( Div('auth_method', css_class='col-xl-6'), Div(PrependedText('domain', '@'), css_class='col-xl-6'), css_class='row' ), Div( Div( Submit('submit', _('Save'), css_class='btn btn-primary'), HTML(f'{_("Cancel")}'), css_class='col-xl-12' ), css_class='row' ) ) class AuthMethodAllowedEmailForm(forms.ModelForm): class Meta: model = AuthMethodAllowedEmail fields = ['auth_method', 'email'] labels = { 'auth_method': _('Authentication Method'), 'email': _('Email'), } def __init__(self, *args, **kwargs): cancel_url = kwargs.pop('cancel_url', '#') super().__init__(*args, **kwargs) self.helper = FormHelper() self.helper.layout = Layout( Div( Div('auth_method', css_class='col-xl-6'), Div('email', css_class='col-xl-6'), css_class='row' ), Div( Div( Submit('submit', _('Save'), css_class='btn btn-primary'), HTML(f'{_("Cancel")}'), css_class='col-xl-12' ), css_class='row' ) )