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.
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.
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(); } }