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:
Dominic Roy
2026-07-23 12:16:50 -04:00
committed by GitHub
parent 9478a91a87
commit 020fe6d5e7
9 changed files with 109 additions and 3 deletions

View File

@@ -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",
),
),
]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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",
),
),
]

View File

@@ -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

View File

@@ -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

View File

@@ -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",
),
),
]

View File

@@ -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()

View File

@@ -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(