diff --git a/authentik/stages/authenticator_mobile/api/device.py b/authentik/stages/authenticator_mobile/api/device.py index d709e86d5..d0927afb8 100644 --- a/authentik/stages/authenticator_mobile/api/device.py +++ b/authentik/stages/authenticator_mobile/api/device.py @@ -21,19 +21,13 @@ from authentik.stages.authenticator_mobile.models import ( MobileDevice, MobileDeviceToken, MobileTransaction, - TransactionStates, ) class MobileDeviceInfoSerializer(PassiveSerializer): """Info about a mobile device""" - platform = ChoiceField( - ( - ("ios", "iOS"), - ("android", "Android"), - ) - ) + platform = ChoiceField((("ios", "iOS"), ("android", "Android"), ("other", "Other"))) os_version = CharField() model = CharField() hostname = CharField() @@ -76,10 +70,7 @@ class MobileDeviceResponseSerializer(PassiveSerializer): """Response from push sent to phone""" tx_id = UUIDField(required=True) - status = ChoiceField( - TransactionStates.choices, - required=True, - ) + selected_item = CharField(required=True) class MobileDeviceViewSet( @@ -214,7 +205,7 @@ class MobileDeviceViewSet( transaction = MobileTransaction.objects.filter(tx_id=data.validated_data["tx_id"]).first() if not transaction: raise Http404 - transaction.status = data.validated_data["status"] + transaction.selected_item = data.validated_data["selected_item"] transaction.save() return Response(status=204) diff --git a/authentik/stages/authenticator_mobile/api/stage.py b/authentik/stages/authenticator_mobile/api/stage.py index 2cac56b02..ac0031cae 100644 --- a/authentik/stages/authenticator_mobile/api/stage.py +++ b/authentik/stages/authenticator_mobile/api/stage.py @@ -14,6 +14,7 @@ class AuthenticatorMobileStageSerializer(StageSerializer): fields = StageSerializer.Meta.fields + [ "configure_flow", "friendly_name", + "item_matching_mode", "firebase_config", ] diff --git a/authentik/stages/authenticator_mobile/migrations/0001_initial.py b/authentik/stages/authenticator_mobile/migrations/0001_initial.py index ea1dbc544..3f1242f93 100644 --- a/authentik/stages/authenticator_mobile/migrations/0001_initial.py +++ b/authentik/stages/authenticator_mobile/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.4 on 2023-09-04 13:21 +# Generated by Django 4.2.7 on 2023-12-14 20:06 import uuid @@ -14,8 +14,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("authentik_flows", "0025_alter_flowstagebinding_evaluate_on_plan_and_more"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("authentik_flows", "0027_auto_20231028_1424"), ] operations = [ @@ -34,6 +34,18 @@ class Migration(migrations.Migration): ), ), ("friendly_name", models.TextField(null=True)), + ( + "item_matching_mode", + models.TextField( + choices=[ + ("accept_deny", "Accept Deny"), + ("number_matching_2", "Number Matching 2"), + ("number_matching_3", "Number Matching 3"), + ], + default="number_matching_3", + ), + ), + ("firebase_config", models.JSONField(default=dict, help_text="temp")), ( "configure_flow", models.ForeignKey( @@ -67,6 +79,8 @@ class Migration(migrations.Migration): ("uuid", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), ("device_id", models.TextField(unique=True)), ("firebase_token", models.TextField(blank=True)), + ("state", models.JSONField(default=dict)), + ("last_checkin", models.DateTimeField(auto_now=True)), ( "stage", models.ForeignKey( @@ -86,6 +100,30 @@ class Migration(migrations.Migration): "verbose_name_plural": "Mobile Devices", }, ), + migrations.CreateModel( + name="MobileTransaction", + fields=[ + ( + "expires", + models.DateTimeField(default=authentik.core.models.default_token_duration), + ), + ("expiring", models.BooleanField(default=True)), + ("tx_id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ("decision_items", models.JSONField(default=list)), + ("correct_item", models.TextField()), + ("selected_item", models.TextField(default=None, null=True)), + ( + "device", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_stages_authenticator_mobile.mobiledevice", + ), + ), + ], + options={ + "abstract": False, + }, + ), migrations.CreateModel( name="MobileDeviceToken", fields=[ diff --git a/authentik/stages/authenticator_mobile/migrations/0002_mobiletransaction.py b/authentik/stages/authenticator_mobile/migrations/0002_mobiletransaction.py deleted file mode 100644 index e8358f13c..000000000 --- a/authentik/stages/authenticator_mobile/migrations/0002_mobiletransaction.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 4.2.4 on 2023-09-04 18:18 - -import uuid - -import django.db.models.deletion -from django.db import migrations, models - -import authentik.core.models - - -class Migration(migrations.Migration): - dependencies = [ - ("authentik_stages_authenticator_mobile", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="MobileTransaction", - fields=[ - ( - "expires", - models.DateTimeField(default=authentik.core.models.default_token_duration), - ), - ("expiring", models.BooleanField(default=True)), - ("tx_id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), - ( - "device", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="authentik_stages_authenticator_mobile.mobiledevice", - ), - ), - ], - options={ - "abstract": False, - }, - ), - ] diff --git a/authentik/stages/authenticator_mobile/migrations/0003_mobiletransaction_status.py b/authentik/stages/authenticator_mobile/migrations/0003_mobiletransaction_status.py deleted file mode 100644 index 99cf6b9c5..000000000 --- a/authentik/stages/authenticator_mobile/migrations/0003_mobiletransaction_status.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.2.4 on 2023-09-04 18:28 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("authentik_stages_authenticator_mobile", "0002_mobiletransaction"), - ] - - operations = [ - migrations.AddField( - model_name="mobiletransaction", - name="status", - field=models.TextField( - choices=[("wait", "Wait"), ("accept", "Accept"), ("deny", "Deny")], default="wait" - ), - ), - ] diff --git a/authentik/stages/authenticator_mobile/migrations/0004_mobiledevice_last_checkin_mobiledevice_state.py b/authentik/stages/authenticator_mobile/migrations/0004_mobiledevice_last_checkin_mobiledevice_state.py deleted file mode 100644 index 8342266f1..000000000 --- a/authentik/stages/authenticator_mobile/migrations/0004_mobiledevice_last_checkin_mobiledevice_state.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.4 on 2023-09-05 13:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("authentik_stages_authenticator_mobile", "0003_mobiletransaction_status"), - ] - - operations = [ - migrations.AddField( - model_name="mobiledevice", - name="last_checkin", - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name="mobiledevice", - name="state", - field=models.JSONField(default=dict), - ), - ] diff --git a/authentik/stages/authenticator_mobile/migrations/0005_authenticatormobilestage_firebase_config.py b/authentik/stages/authenticator_mobile/migrations/0005_authenticatormobilestage_firebase_config.py deleted file mode 100644 index f10a6e3cd..000000000 --- a/authentik/stages/authenticator_mobile/migrations/0005_authenticatormobilestage_firebase_config.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.2.5 on 2023-09-21 15:27 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ( - "authentik_stages_authenticator_mobile", - "0004_mobiledevice_last_checkin_mobiledevice_state", - ), - ] - - operations = [ - migrations.AddField( - model_name="authenticatormobilestage", - name="firebase_config", - field=models.JSONField(default=dict, help_text="temp"), - ), - ] diff --git a/authentik/stages/authenticator_mobile/models.py b/authentik/stages/authenticator_mobile/models.py index 3c2bec688..979e55fcb 100644 --- a/authentik/stages/authenticator_mobile/models.py +++ b/authentik/stages/authenticator_mobile/models.py @@ -28,7 +28,7 @@ from structlog.stdlib import get_logger from authentik.core.models import ExpiringModel, User from authentik.core.types import UserSettingSerializer from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage -from authentik.lib.generators import generate_id +from authentik.lib.generators import generate_code_fixed_length, generate_id from authentik.lib.models import SerializerModel from authentik.stages.authenticator.models import Device from authentik.tenants.utils import DEFAULT_TENANT @@ -41,11 +41,34 @@ def default_token_key(): return generate_id(40) +class ItemMatchingMode(models.TextChoices): + """Configure which items the app shows the user, and what the user must select""" + + ACCEPT_DENY = "accept_deny" + NUMBER_MATCHING_2 = "number_matching_2" + NUMBER_MATCHING_3 = "number_matching_3" + + class AuthenticatorMobileStage(ConfigurableStage, FriendlyNamedStage, Stage): """Setup Mobile authenticator devices""" + item_matching_mode = models.TextField( + choices=ItemMatchingMode.choices, default=ItemMatchingMode.NUMBER_MATCHING_3 + ) firebase_config = models.JSONField(default=dict, help_text="temp") + def create_transaction(self, device: "MobileDevice") -> "MobileTransaction": + """Create a transaction for `device` with the config of this stage.""" + transaction = MobileTransaction(device=device) + if self.item_matching_mode == ItemMatchingMode.ACCEPT_DENY: + transaction.item_matching = [TransactionStates.ACCEPT, TransactionStates.DENY] + if self.item_matching_mode == ItemMatchingMode.NUMBER_MATCHING_2: + transaction.item_matching = [generate_code_fixed_length(2)] * 3 + if self.item_matching_mode == ItemMatchingMode.NUMBER_MATCHING_3: + transaction.item_matching = [generate_code_fixed_length(3)] * 3 + transaction.save() + return transaction + @property def serializer(self) -> type[BaseSerializer]: from authentik.stages.authenticator_mobile.api.stage import ( @@ -96,6 +119,11 @@ class MobileDevice(SerializerModel, Device): state = models.JSONField(default=dict) last_checkin = models.DateTimeField(auto_now=True) + def create_transaction(self) -> "MobileTransaction": + """Create a transaction for this device with the config of its stage.""" + stage: AuthenticatorMobileStage = self.stage + return stage.create_transaction(self) + @property def serializer(self) -> Serializer: from authentik.stages.authenticator_mobile.api.device import MobileDeviceSerializer @@ -123,8 +151,18 @@ class MobileTransaction(ExpiringModel): tx_id = models.UUIDField(default=uuid4, primary_key=True) device = models.ForeignKey(MobileDevice, on_delete=models.CASCADE) + decision_items = models.JSONField(default=list) + correct_item = models.TextField() + selected_item = models.TextField(default=None, null=True) - status = models.TextField(choices=TransactionStates.choices, default=TransactionStates.WAIT) + @property + def status(self) -> TransactionStates: + """Get the status""" + if not self.selected_item: + return TransactionStates.WAIT + if self.selected_item != self.correct_item: + return TransactionStates.DENY + return TransactionStates.ACCEPT def send_message(self, request: Optional[HttpRequest], **context): """Send mobile message""" @@ -153,7 +191,7 @@ class MobileTransaction(ExpiringModel): notification=AndroidNotification(icon="stock_ticker_update", color="#f45342"), data={ "tx_id": str(self.tx_id), - "numbers": dumps([123, 456, 789]), + "user_decision_items": dumps(self.item_matching), }, ), apns=APNSConfig( @@ -167,12 +205,7 @@ class MobileTransaction(ExpiringModel): ), interruption_level="time-sensitive", tx_id=str(self.tx_id), - numbers=[ - 123, - 456, - 789, - ], - options=["foo", "bar", "baz"], + user_decision_items=self.item_matching, ), ), token=self.device.firebase_token, diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py index 802abefc5..3a0b4e4ef 100644 --- a/authentik/stages/authenticator_validate/challenge.py +++ b/authentik/stages/authenticator_validate/challenge.py @@ -26,11 +26,7 @@ from authentik.root.middleware import ClientIPMiddleware from authentik.stages.authenticator import match_token from authentik.stages.authenticator.models import Device from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice -from authentik.stages.authenticator_mobile.models import ( - MobileDevice, - MobileTransaction, - TransactionStates, -) +from authentik.stages.authenticator_mobile.models import MobileDevice, TransactionStates from authentik.stages.authenticator_sms.models import SMSDevice from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice @@ -198,7 +194,7 @@ def validate_challenge_mobile(device_pk: str, stage_view: StageView, user: User) ).name try: - transaction = MobileTransaction.objects.create(device=device) + transaction = device.create_transaction() transaction.send_message(stage_view.request, **push_context) status = transaction.wait_for_response() if status == TransactionStates.DENY: diff --git a/blueprints/schema.json b/blueprints/schema.json index 68dcddd90..32e6ea330 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -6144,6 +6144,15 @@ "minLength": 1, "title": "Friendly name" }, + "item_matching_mode": { + "type": "string", + "enum": [ + "accept_deny", + "number_matching_2", + "number_matching_3" + ], + "title": "Item matching mode" + }, "firebase_config": { "type": "object", "additionalProperties": true, diff --git a/schema.yml b/schema.yml index dea39b2f1..1eddf0dd5 100644 --- a/schema.yml +++ b/schema.yml @@ -30425,6 +30425,8 @@ components: friendly_name: type: string nullable: true + item_matching_mode: + $ref: '#/components/schemas/ItemMatchingModeEnum' firebase_config: type: object additionalProperties: {} @@ -30457,6 +30459,8 @@ components: type: string nullable: true minLength: 1 + item_matching_mode: + $ref: '#/components/schemas/ItemMatchingModeEnum' firebase_config: type: object additionalProperties: {} @@ -34420,6 +34424,16 @@ components: description: |- * `global` - Same identifier is used for all providers * `per_provider` - Each provider has a different issuer, based on the application slug. + ItemMatchingModeEnum: + enum: + - accept_deny + - number_matching_2 + - number_matching_3 + type: string + description: |- + * `accept_deny` - Accept Deny + * `number_matching_2` - Number Matching 2 + * `number_matching_3` - Number Matching 3 KubernetesServiceConnection: type: object description: KubernetesServiceConnection Serializer @@ -35426,21 +35440,12 @@ components: tx_id: type: string format: uuid - status: - $ref: '#/components/schemas/MobileDeviceResponseStatusEnum' + selected_item: + type: string + minLength: 1 required: - - status + - selected_item - tx_id - MobileDeviceResponseStatusEnum: - enum: - - wait - - accept - - deny - type: string - description: |- - * `wait` - Wait - * `accept` - Accept - * `deny` - Deny MobileDeviceSetPushKeyRequest: type: object description: Set notification key @@ -38224,6 +38229,8 @@ components: type: string nullable: true minLength: 1 + item_matching_mode: + $ref: '#/components/schemas/ItemMatchingModeEnum' firebase_config: type: object additionalProperties: {} @@ -40679,10 +40686,12 @@ components: enum: - ios - android + - other type: string description: |- * `ios` - iOS * `android` - Android + * `other` - Other PlexAuthenticationChallenge: type: object description: Challenge shown to the user in identification stage diff --git a/schemas/authentik-cloud-gateway.yml b/schemas/authentik-cloud-gateway.yml index ea8592953..d19e810aa 100644 --- a/schemas/authentik-cloud-gateway.yml +++ b/schemas/authentik-cloud-gateway.yml @@ -505,17 +505,9 @@ components: type: object properties: status: - $ref: '#/components/schemas/MobileDeviceEnrollmentStatusStatusEnum' + $ref: '#/components/schemas/StatusEnum' required: - status - MobileDeviceEnrollmentStatusStatusEnum: - enum: - - success - - waiting - type: string - description: |- - * `success` - Success - * `waiting` - Waiting MobileDeviceInfo: type: object description: Info about a mobile device @@ -593,21 +585,12 @@ components: tx_id: type: string format: uuid - status: - $ref: '#/components/schemas/MobileDeviceResponseStatusEnum' + selected_item: + type: string + minLength: 1 required: - - status + - selected_item - tx_id - MobileDeviceResponseStatusEnum: - enum: - - wait - - accept - - deny - type: string - description: |- - * `wait` - Wait - * `accept` - Accept - * `deny` - Deny MobileDeviceSetPushKeyRequest: type: object description: Set notification key @@ -674,10 +657,20 @@ components: enum: - ios - android + - other type: string description: |- * `ios` - iOS * `android` - Android + * `other` - Other + StatusEnum: + enum: + - success + - waiting + type: string + description: |- + * `success` - Success + * `waiting` - Waiting UsedBy: type: object description: A list of all objects referencing the queried object diff --git a/web/src/admin/stages/authenticator_mobile/AuthenticatorMobileStageForm.ts b/web/src/admin/stages/authenticator_mobile/AuthenticatorMobileStageForm.ts index 296a1cffe..7c7abf505 100644 --- a/web/src/admin/stages/authenticator_mobile/AuthenticatorMobileStageForm.ts +++ b/web/src/admin/stages/authenticator_mobile/AuthenticatorMobileStageForm.ts @@ -17,6 +17,7 @@ import { FlowsApi, FlowsInstancesListDesignationEnum, FlowsInstancesListRequest, + ItemMatchingModeEnum, StagesApi, } from "@goauthentik/api"; @@ -83,6 +84,31 @@ export class AuthenticatorMobileStageForm extends ModelForm ${msg("Stage-specific settings")}
+ + + +