mirror of
https://github.com/goauthentik/authentik.git
synced 2026-08-30 18:51:39 -07:00
stages/authenticator: protect devices from stage deletion (#24324)
* stages/authenticator: protect devices from stage deletion Deleting an SMS, email, Duo, or GDTC authenticator stage currently cascades into its associated devices or connections, allowing an administrator to unintentionally remove enrolled authenticators. * Remove deprecated
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-23 15:06
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_stages_authenticator_duo", "0007_alter_authenticatorduostage_friendly_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="duodevice",
|
||||
name="stage",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="authentik_stages_authenticator_duo.authenticatorduostage",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -85,7 +85,7 @@ class DuoDevice(SerializerModel, Device):
|
||||
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
|
||||
# Connect to the stage to when validating access we know the API Credentials
|
||||
stage = models.ForeignKey(AuthenticatorDuoStage, on_delete=models.CASCADE)
|
||||
stage = models.ForeignKey(AuthenticatorDuoStage, on_delete=models.PROTECT)
|
||||
duo_user_id = models.TextField()
|
||||
last_t = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db.models.deletion import ProtectedError
|
||||
from django.test.client import RequestFactory
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -37,6 +38,22 @@ class AuthenticatorDuoStageTests(FlowTestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
self.assertEqual(stage.admin_client().ikey, stage.admin_integration_key)
|
||||
|
||||
def test_stage_deletion_is_protected(self):
|
||||
"""A setup stage with enrolled devices cannot be deleted."""
|
||||
stage = AuthenticatorDuoStage.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_id(),
|
||||
api_hostname=generate_id(),
|
||||
)
|
||||
device = DuoDevice.objects.create(user=self.user, stage=stage)
|
||||
|
||||
with self.assertRaises(ProtectedError):
|
||||
stage.delete()
|
||||
|
||||
self.assertTrue(AuthenticatorDuoStage.objects.filter(pk=stage.pk).exists())
|
||||
self.assertTrue(DuoDevice.objects.filter(pk=device.pk).exists())
|
||||
|
||||
def test_api_enrollment_invalid(self):
|
||||
"""Test `enrollment_status`"""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-23 15:06
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"authentik_stages_authenticator_email",
|
||||
"0003_emaildevice_throttling_failure_count_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="emaildevice",
|
||||
name="stage",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="authentik_stages_authenticator_email.authenticatoremailstage",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -121,7 +121,7 @@ class EmailDevice(SerializerModel, ThrottlingMixin, SideChannelDevice):
|
||||
|
||||
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
email = models.EmailField()
|
||||
stage = models.ForeignKey(AuthenticatorEmailStage, on_delete=models.CASCADE)
|
||||
stage = models.ForeignKey(AuthenticatorEmailStage, on_delete=models.PROTECT)
|
||||
last_used = models.DateTimeField(auto_now=True)
|
||||
|
||||
@property
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import PropertyMock, patch
|
||||
from django.core import mail
|
||||
from django.core.mail.backends.locmem import EmailBackend
|
||||
from django.core.mail.backends.smtp import EmailBackend as SMTPEmailBackend
|
||||
from django.db.models.deletion import ProtectedError
|
||||
from django.db.utils import IntegrityError
|
||||
from django.template.exceptions import TemplateDoesNotExist
|
||||
from django.test import TestCase
|
||||
@@ -65,6 +66,14 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
||||
"""Test string representation of stage"""
|
||||
self.assertEqual(str(self.stage), f"Email Authenticator Stage {self.stage.name}")
|
||||
|
||||
def test_stage_deletion_is_protected(self):
|
||||
"""A setup stage with enrolled devices cannot be deleted."""
|
||||
with self.assertRaises(ProtectedError):
|
||||
self.stage.delete()
|
||||
|
||||
self.assertTrue(AuthenticatorEmailStage.objects.filter(pk=self.stage.pk).exists())
|
||||
self.assertTrue(EmailDevice.objects.filter(pk=self.device.pk).exists())
|
||||
|
||||
def test_token_lifecycle(self):
|
||||
"""Test token generation, validation and expiry"""
|
||||
# Initially no token
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-23 15:06
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_stages_authenticator_sms", "0009_smsdevice_throttling_failure_count_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="smsdevice",
|
||||
name="stage",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="authentik_stages_authenticator_sms.authenticatorsmsstage",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -203,7 +203,7 @@ class SMSDevice(SerializerModel, ThrottlingMixin, SideChannelDevice):
|
||||
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
|
||||
# Connect to the stage to when validating access we know the API Credentials
|
||||
stage = models.ForeignKey(AuthenticatorSMSStage, on_delete=models.CASCADE)
|
||||
stage = models.ForeignKey(AuthenticatorSMSStage, on_delete=models.PROTECT)
|
||||
|
||||
phone_number = models.TextField()
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from django.db.models.deletion import ProtectedError
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from requests_mock import Mocker
|
||||
@@ -55,6 +56,16 @@ class AuthenticatorSMSStageTests(FlowTestCase):
|
||||
phone_number_required=True,
|
||||
)
|
||||
|
||||
def test_stage_deletion_is_protected(self):
|
||||
"""A setup stage with enrolled devices cannot be deleted."""
|
||||
device = SMSDevice.objects.create(user=self.user, stage=self.stage, phone_number="+1234")
|
||||
|
||||
with self.assertRaises(ProtectedError):
|
||||
self.stage.delete()
|
||||
|
||||
self.assertTrue(AuthenticatorSMSStage.objects.filter(pk=self.stage.pk).exists())
|
||||
self.assertTrue(SMSDevice.objects.filter(pk=device.pk).exists())
|
||||
|
||||
def test_stage_submit(self):
|
||||
"""test stage (submit)"""
|
||||
self.client.get(
|
||||
|
||||
Reference in New Issue
Block a user