diff --git a/gatekeeper/forms.py b/gatekeeper/forms.py index 2d247e0..5e92110 100644 --- a/gatekeeper/forms.py +++ b/gatekeeper/forms.py @@ -163,6 +163,18 @@ class AuthMethodForm(forms.ModelForm): 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 @@ -185,6 +197,13 @@ class AuthMethodForm(forms.ModelForm): 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( @@ -207,6 +226,11 @@ class AuthMethodForm(forms.ModelForm): 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'), @@ -232,7 +256,7 @@ class AuthMethodForm(forms.ModelForm): 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) @@ -260,8 +284,24 @@ class AuthMethodForm(forms.ModelForm): 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 diff --git a/gatekeeper/migrations/0008_authmethod_session_expiration_minutes.py b/gatekeeper/migrations/0008_authmethod_session_expiration_minutes.py new file mode 100644 index 0000000..0c555ea --- /dev/null +++ b/gatekeeper/migrations/0008_authmethod_session_expiration_minutes.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gatekeeper', '0007_remove_authmethod_totp_before_auth_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='authmethod', + name='session_expiration_minutes', + field=models.PositiveIntegerField(default=720, help_text='Session expiration time in minutes (only for Local Password and OIDC)'), + ), + ] diff --git a/gatekeeper/migrations/0009_alter_authmethod_session_expiration_minutes.py b/gatekeeper/migrations/0009_alter_authmethod_session_expiration_minutes.py new file mode 100644 index 0000000..eced587 --- /dev/null +++ b/gatekeeper/migrations/0009_alter_authmethod_session_expiration_minutes.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-03-15 20:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gatekeeper', '0008_authmethod_session_expiration_minutes'), + ] + + operations = [ + migrations.AlterField( + model_name='authmethod', + name='session_expiration_minutes', + field=models.PositiveIntegerField(default=720, help_text='Session expiration time in minutes'), + ), + ] diff --git a/gatekeeper/migrations/0010_alter_gatekeeperuser_email.py b/gatekeeper/migrations/0010_alter_gatekeeperuser_email.py new file mode 100644 index 0000000..99be286 --- /dev/null +++ b/gatekeeper/migrations/0010_alter_gatekeeperuser_email.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-03-15 22:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gatekeeper', '0009_alter_authmethod_session_expiration_minutes'), + ] + + operations = [ + migrations.AlterField( + model_name='gatekeeperuser', + name='email', + field=models.EmailField(blank=True, max_length=254), + ), + ] diff --git a/gatekeeper/models.py b/gatekeeper/models.py index c44d79f..e8b534a 100644 --- a/gatekeeper/models.py +++ b/gatekeeper/models.py @@ -16,6 +16,12 @@ class AuthMethod(models.Model): # TOTP-specific fields totp_secret = models.CharField(max_length=255, blank=True, help_text=_("Shared/global TOTP secret key")) + # Session expiration (Local Password and OIDC only) + session_expiration_minutes = models.PositiveIntegerField( + default=720, + help_text=_("Session expiration time in minutes") + ) + # OIDC-specific fields oidc_provider = models.CharField(max_length=64, blank=True) oidc_client_id = models.CharField(max_length=255, blank=True) @@ -64,7 +70,7 @@ class AuthMethodAllowedEmail(models.Model): class GatekeeperUser(models.Model): username = models.SlugField(max_length=64, unique=True) - email = models.EmailField(unique=True, blank=True) + email = models.EmailField(blank=True) password = models.CharField(blank=True, max_length=250, help_text=_("Password for local authentication (leave blank if not using)")) totp_secret = models.CharField(max_length=255, blank=True, help_text=_("Per-user TOTP secret key")) diff --git a/gatekeeper/views.py b/gatekeeper/views.py index 2947fe8..245224e 100644 --- a/gatekeeper/views.py +++ b/gatekeeper/views.py @@ -200,7 +200,7 @@ def view_manage_auth_method(request):

Users will authenticate using a standard username and password stored locally. Only one of this type can be created.

OIDC (OpenID Connect)
-

Users will authenticate via an external identity provider (like Keycloak, Google, or Authelia). Requires Provider URL, Client ID, and Client Secret.

+

Users will authenticate via an external identity provider (like Keycloak or Google). Requires Provider URL, Client ID, and Client Secret.

TOTP (Time-Based One-Time Password)

Users will need to enter a rotating token from an authenticator app. If a user does not have a personal TOTP configured, the Global TOTP Secret will be used instead.

diff --git a/templates/gatekeeper/gatekeeper_auth_method_form.html b/templates/gatekeeper/gatekeeper_auth_method_form.html index 1b8f847..6c7d7bc 100644 --- a/templates/gatekeeper/gatekeeper_auth_method_form.html +++ b/templates/gatekeeper/gatekeeper_auth_method_form.html @@ -41,18 +41,22 @@ $(document).ready(function () { function toggleFields() { var authType = $('#id_auth_type').val(); - if (authType === 'local_password' || authType === 'ip_address') { + if (authType === 'local_password') { $('.totp-group').hide(); $('.oidc-group').hide(); + $('.expiration-group').show(); } else if (authType === 'totp') { $('.totp-group').show(); $('.oidc-group').hide(); + $('.expiration-group').hide(); } else if (authType === 'oidc') { $('.totp-group').hide(); $('.oidc-group').show(); + $('.expiration-group').show(); } else { $('.totp-group').hide(); $('.oidc-group').hide(); + $('.expiration-group').hide(); } }