Repository: goauthentik/authentik

Issue:
Bug: Authenticator SMS Challenge response doesn't have _errors attribute
**Describe the bug**
It seems that something has changed in the regards to Authenticator SMS Challenge Response.

**To Reproduce**
Steps to reproduce the behavior:
1. Have an perfectly working SMS Authenicator stage prior to upgrading to 2023.8.3
2. Upgrade
3. Don't see any immediate errors.
4. See error when user tries to register using said flow.

**Expected behavior**
I would have expected it to not error out.

**Logs**
<details>
    <summary>Stacktrace from authentik</summary>

```
Traceback (most recent call last):
  File "/authentik/flows/views/executor.py", line 298, in get
    stage_response = self.current_stage_view.get(request, *args, **kwargs)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/authentik/stages/authenticator_sms/stage.py", line 117, in get
    response._errors.setdefault("phone_number", [])
    ^^^^^^^^^^^^^^^^
builtins.AttributeError: 'AuthenticatorSMSChallengeResponse' object has no attribute '_errors'
```
</details>


**Version and Deployment (please complete the following information):**
- authentik version: 2023.8.3
- Deployment: Ansible with Docker
        


Discussion:

Resolving patch:
diff --git a/authentik/stages/authenticator_sms/stage.py b/authentik/stages/authenticator_sms/stage.py
--- a/authentik/stages/authenticator_sms/stage.py
+++ b/authentik/stages/authenticator_sms/stage.py
@@ -12,7 +12,6 @@
     Challenge,
     ChallengeResponse,
     ChallengeTypes,
-    ErrorDetailSerializer,
     WithUserInfoChallenge,
 )
 from authentik.flows.stage import ChallengeStageView
@@ -24,6 +23,7 @@
 from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
 
 SESSION_KEY_SMS_DEVICE = "authentik/stages/authenticator_sms/sms_device"
+PLAN_CONTEXT_PHONE = "phone"
 
 
 class AuthenticatorSMSChallenge(WithUserInfoChallenge):
@@ -48,6 +48,8 @@ class AuthenticatorSMSChallengeResponse(ChallengeResponse):
     def validate(self, attrs: dict) -> dict:
         """Check"""
         if "code" not in attrs:
+            if "phone_number" not in attrs:
+                raise ValidationError("phone_number required")
             self.device.phone_number = attrs["phone_number"]
             self.stage.validate_and_send(attrs["phone_number"])
             return super().validate(attrs)
@@ -67,6 +69,7 @@ def validate_and_send(self, phone_number: str):
         stage: AuthenticatorSMSStage = self.executor.current_stage
         hashed_number = hash_phone_number(phone_number)
         query = Q(phone_number=hashed_number) | Q(phone_number=phone_number)
+        print(SMSDevice.objects.filter(query, stage=stage.pk))
         if SMSDevice.objects.filter(query, stage=stage.pk).exists():
             raise ValidationError(_("Invalid phone number"))
         # No code yet, but we have a phone number, so send a verification message
@@ -75,9 +78,9 @@ def validate_and_send(self, phone_number: str):
 
     def _has_phone_number(self) -> Optional[str]:
         context = self.executor.plan.context
-        if "phone" in context.get(PLAN_CONTEXT_PROMPT, {}):
+        if PLAN_CONTEXT_PHONE in context.get(PLAN_CONTEXT_PROMPT, {}):
             self.logger.debug("got phone number from plan context")
-            return context.get(PLAN_CONTEXT_PROMPT, {}).get("phone")
+            return context.get(PLAN_CONTEXT_PROMPT, {}).get(PLAN_CONTEXT_PHONE)
         if SESSION_KEY_SMS_DEVICE in self.request.session:
             self.logger.debug("got phone number from device in session")
             device: SMSDevice = self.request.session[SESSION_KEY_SMS_DEVICE]
@@ -113,10 +116,17 @@ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
                 try:
                     self.validate_and_send(phone_number)
                 except ValidationError as exc:
-                    response = AuthenticatorSMSChallengeResponse()
-                    response._errors.setdefault("phone_number", [])
-                    response._errors["phone_number"].append(ErrorDetailSerializer(exc.detail))
-                    return self.challenge_invalid(response)
+                    # We had a phone number given already (at this point only possible from flow
+                    # context), but an error occurred while sending a number (most likely)
+                    # due to a duplicate device, so delete the number we got given, reset the state
+                    # (ish) and retry
+                    device.phone_number = ""
+                    self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}).pop(
+                        PLAN_CONTEXT_PHONE, None
+                    )
+                    self.request.session.pop(SESSION_KEY_SMS_DEVICE, None)
+                    self.logger.warning("failed to send SMS message to pre-set number", exc=exc)
+                    return self.get(request, *args, **kwargs)
         return super().get(request, *args, **kwargs)
 
     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:


Test patch:
diff --git a/authentik/stages/authenticator_sms/tests.py b/authentik/stages/authenticator_sms/tests.py
--- a/authentik/stages/authenticator_sms/tests.py
+++ b/authentik/stages/authenticator_sms/tests.py
@@ -7,7 +7,9 @@
 
 from authentik.core.tests.utils import create_test_admin_user, create_test_flow
 from authentik.flows.models import FlowStageBinding
+from authentik.flows.planner import FlowPlan
 from authentik.flows.tests import FlowTestCase
+from authentik.flows.views.executor import SESSION_KEY_PLAN
 from authentik.lib.generators import generate_id
 from authentik.stages.authenticator_sms.models import (
     AuthenticatorSMSStage,
@@ -15,7 +17,8 @@
     SMSProviders,
     hash_phone_number,
 )
-from authentik.stages.authenticator_sms.stage import SESSION_KEY_SMS_DEVICE
+from authentik.stages.authenticator_sms.stage import PLAN_CONTEXT_PHONE, SESSION_KEY_SMS_DEVICE
+from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
 
 
 class AuthenticatorSMSStageTests(FlowTestCase):
@@ -172,6 +175,45 @@ def test_stage_context_data(self):
             phone_number_required=False,
         )
 
+    def test_stage_context_data_duplicate(self):
+        """test stage context data (phone number exists already)"""
+        self.client.get(
+            reverse("authentik_flows:configure", kwargs={"stage_uuid": self.stage.stage_uuid}),
+        )
+        plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
+        plan.context[PLAN_CONTEXT_PROMPT] = {
+            PLAN_CONTEXT_PHONE: "1234",
+        }
+        session = self.client.session
+        session[SESSION_KEY_PLAN] = plan
+        session.save()
+        SMSDevice.objects.create(
+            phone_number="1234",
+            user=self.user,
+            stage=self.stage,
+        )
+        sms_send_mock = MagicMock()
+        with (
+            patch(
+                "authentik.stages.authenticator_sms.models.AuthenticatorSMSStage.send",
+                sms_send_mock,
+            ),
+        ):
+            print(self.client.session[SESSION_KEY_PLAN])
+            response = self.client.get(
+                reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
+            )
+        print(response.content.decode())
+        self.assertStageResponse(
+            response,
+            self.flow,
+            self.user,
+            component="ak-stage-authenticator-sms",
+            phone_number_required=True,
+        )
+        plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
+        self.assertEqual(plan.context[PLAN_CONTEXT_PROMPT], {})
+
     def test_stage_submit_full(self):
         """test stage (submit)"""
         self.client.get(