From 4ba360e7afe836a1a41a898b2b4278943f564463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Elizeche=20Land=C3=B3?= Date: Mon, 17 Feb 2025 11:16:58 -0300 Subject: [PATCH] stages/authenticator_email: Email OTP (#12630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * stages/authenticator_email: Add basic structure for stages/authenticator_email * stages/authenticator_email: Add stages/authenticator_email django app to settings.py * stages/authenticator_email: Fix imports due changes introduced in #12598 * stages/authenticator_email: fix linting * stages/authenticator_email: Add tests for token verification * Add UI structure for authenticator_email * Add autheticator_email to AuthenticatorValidateStageForm.ts and create AuthenticatorEmailStageForm.ts * Add serializer property to emaildevice * Add DeviceClasses.EMAIL to DeviceClasses * Add migration file for DeviceClasses change (added email) * Add new schema.yml and blueprints/schema.json to refelct email authenticator * Fix UI to show the Email Authenticator * Add support for email templates for the email authenticator * Add templates * Add DeviceClasses.EMAIL option to authenticator_validate/stage.py * Fix logic for sending emails in stage.py and use the proper class AuthenticatorEmailStage in tasks.py * Fix token expiration display in the email templates * Fix authenticator email stage set up * Add template and email to api response for Authenticator Email stage * Fix Authenticator Email stage set up form * Use different flow if the user has an email configured or not for Authenticator Email stage UI * Use the correct field for the token in AuthenticatorEmailStage.ts * Fix linting and code style * Use the correct assertions in tests * Fix mask email helper * Add missing cases for Email Authenticator in the UI * Fix email sending, add _compose_email() method to EmailDevice * Fix cosmetic changes * Add support for email device challenge validation in validate_selected_challenge * Fix tests * Add from_address to email template * Refactor tests * Update API Schema * Refactor AuthenticatorEmailStage UI for cleaner code * Fix saving token_expiry in the stage configuration * Remove debug statements * Add email connection settings to the Email authenticator stage configuration UI * Remove unused field activate_on_success from AuthenticatorEmailStage * Add tests for duplicate email, token expiration and template error * cosmetic/styling changes * Use authentik's GroupMemberSerializer and ManagedAppConfig in api and apps for email authenticathor * stages/authenticator_email: Fix typos, styling and unused fields * stages/authenticator_email: remove unused field responseStatus * stages/authenticator_email: regen migrations * Fix linting issues * Fix app label issue, typos, missing user field * Add a trailing space in email_otp.txt RFC 3676 sec. 4.3 Co-authored-by: Marc 'risson' Schmitt Signed-off-by: Marcelo Elizeche Landó * Move mask_email method to a helper function in authentik.lib.utils.email * Remove unused function * Use authentik.stages.email.tasks instead of authentik.stages.authenticator_email.tasks, delete authentik.stages.authenticator_email.tasks * Fix use global settings not using the global setting if there's a default * Revert "Fix use global settings not using the global setting if there's a default" This reverts commit 3825248bb462565f246df290801db34beabcc488. * Use user email from user attributes if exists * Show masked email in AuthenticatorValidateStageCode * Remove unused base.html template * Fix linting issues * Change token_expiry from integer to TextField, use timedelta_string_validator where necessary to process the change * Move 'use global connection settings' up in the Email Authenticator Stage Configuration * Show expanded connections settings when 'use global settings' is not activated for better UX * Fix migration file, add missing validator * Fix test for no prefilled email address * Add tests to check session management, challenge generation and challenge response validation * fix linting * Add default value EmailStage for stage_class in stage.email.tasks.send_mail * Change string representation for EmailDevice to handle authentik/events/tests/test_models.py::TestModels, add tests for the new __str__ method * Add #nosec to skip false positive in linting validation Signed-off-by: Marcelo Elizeche Landó * Change Email Authenticator Setup Stage name for consistency with other authenticators * Add tests to test properties and methods of EmailDevice and AuthenticatorEmailStage, add test for email tasks * Add tests for email challenge in authenticator_validate * Update migration to reflect new verbose name for AuthenticatorEmailStage * Update schema.yml to reflect new verbose name for AuthenticatorEmailStage * Add default email subject in Email Authenticator Setup Stage configuration * Remove from_address from email template to ensure global settings use if use global settings is on * Add flow-default-authenticator-email-setup.yaml blueprint * Move email authenticator blueprint to the examples folder * Update authentik/stages/authenticator_email/models.py Signed-off-by: Jens L. * Change self.user_pk to self.user_id because user_pk doesn't exists here * Remove unused logger import * Remove more unused logger import * Add error handling to authentik.lib.utils.email.mask_email * fix linting * don't catch Exception Signed-off-by: Jens Langhammer * update icons Signed-off-by: Jens Langhammer --------- Signed-off-by: Marcelo Elizeche Landó Signed-off-by: Jens L. Signed-off-by: Jens Langhammer Co-authored-by: Marc 'risson' Schmitt Co-authored-by: Jens L. Co-authored-by: Jens Langhammer --- authentik/lib/utils/email.py | 54 + authentik/root/settings.py | 1 + .../stages/authenticator_email/__init__.py | 0 authentik/stages/authenticator_email/api.py | 85 ++ authentik/stages/authenticator_email/apps.py | 12 + .../migrations/0001_initial.py | 132 ++ .../migrations/__init__.py | 0 .../stages/authenticator_email/models.py | 167 +++ authentik/stages/authenticator_email/stage.py | 177 +++ .../templates/email/email_otp.html | 44 + .../templates/email/email_otp.txt | 13 + authentik/stages/authenticator_email/tests.py | 340 +++++ authentik/stages/authenticator_email/urls.py | 17 + .../authenticator_validate/challenge.py | 14 + ...thenticatorvalidatestage_device_classes.py | 37 + .../stages/authenticator_validate/models.py | 2 + .../stages/authenticator_validate/stage.py | 22 +- .../tests/test_email.py | 183 +++ authentik/stages/email/tasks.py | 30 +- .../flows-authenticator-email-setup.yaml | 30 + blueprints/schema.json | 335 ++++- schema.yml | 1113 +++++++++++++++++ web/src/admin/stages/StageListPage.ts | 1 + web/src/admin/stages/StageWizard.ts | 1 + .../AuthenticatorEmailStageForm.ts | 283 +++++ .../AuthenticatorValidateStageForm.ts | 1 + web/src/admin/users/UserDevicesTable.ts | 2 + web/src/flow/FlowExecutor.ts | 8 + .../AuthenticatorEmailStage.ts | 173 +++ .../AuthenticatorValidateStage.ts | 7 + .../AuthenticatorValidateStageCode.ts | 12 +- .../user/user-settings/mfa/MFADeviceForm.ts | 6 + .../user/user-settings/mfa/MFADevicesPage.ts | 2 + 33 files changed, 3286 insertions(+), 18 deletions(-) create mode 100644 authentik/lib/utils/email.py create mode 100644 authentik/stages/authenticator_email/__init__.py create mode 100644 authentik/stages/authenticator_email/api.py create mode 100644 authentik/stages/authenticator_email/apps.py create mode 100644 authentik/stages/authenticator_email/migrations/0001_initial.py create mode 100644 authentik/stages/authenticator_email/migrations/__init__.py create mode 100644 authentik/stages/authenticator_email/models.py create mode 100644 authentik/stages/authenticator_email/stage.py create mode 100644 authentik/stages/authenticator_email/templates/email/email_otp.html create mode 100644 authentik/stages/authenticator_email/templates/email/email_otp.txt create mode 100644 authentik/stages/authenticator_email/tests.py create mode 100644 authentik/stages/authenticator_email/urls.py create mode 100644 authentik/stages/authenticator_validate/migrations/0014_alter_authenticatorvalidatestage_device_classes.py create mode 100644 authentik/stages/authenticator_validate/tests/test_email.py create mode 100644 blueprints/example/flows-authenticator-email-setup.yaml create mode 100644 web/src/admin/stages/authenticator_email/AuthenticatorEmailStageForm.ts create mode 100644 web/src/flow/stages/authenticator_email/AuthenticatorEmailStage.ts diff --git a/authentik/lib/utils/email.py b/authentik/lib/utils/email.py new file mode 100644 index 0000000000..1ee9adb127 --- /dev/null +++ b/authentik/lib/utils/email.py @@ -0,0 +1,54 @@ +"""Email utility functions""" + + +def mask_email(email: str | None) -> str | None: + """Mask email address for privacy + + Args: + email: Email address to mask + Returns: + Masked email address or None if input is None + Example: + mask_email("myname@company.org") + 'm*****@c******.org' + """ + if not email: + return None + + # Basic email format validation + if email.count("@") != 1: + raise ValueError("Invalid email format: Must contain exactly one '@' symbol") + + local, domain = email.split("@") + if not local or not domain: + raise ValueError("Invalid email format: Local and domain parts cannot be empty") + + domain_parts = domain.split(".") + if len(domain_parts) < 2: # noqa: PLR2004 + raise ValueError("Invalid email format: Domain must contain at least one dot") + + limit = 2 + + # Mask local part (keep first char) + if len(local) <= limit: + masked_local = "*" * len(local) + else: + masked_local = local[0] + "*" * (len(local) - 1) + + # Mask each domain part except the last one (TLD) + masked_domain_parts = [] + for _i, part in enumerate(domain_parts[:-1]): # Process all parts except TLD + if not part: # Check for empty parts (consecutive dots) + raise ValueError("Invalid email format: Domain parts cannot be empty") + if len(part) <= limit: + masked_part = "*" * len(part) + else: + masked_part = part[0] + "*" * (len(part) - 1) + masked_domain_parts.append(masked_part) + + # Add TLD unchanged + if not domain_parts[-1]: # Check if TLD is empty + raise ValueError("Invalid email format: TLD cannot be empty") + masked_domain_parts.append(domain_parts[-1]) + + return f"{masked_local}@{'.'.join(masked_domain_parts)}" diff --git a/authentik/root/settings.py b/authentik/root/settings.py index b2afa554ed..6eddf6c98d 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -100,6 +100,7 @@ TENANT_APPS = [ "authentik.sources.scim", "authentik.stages.authenticator", "authentik.stages.authenticator_duo", + "authentik.stages.authenticator_email", "authentik.stages.authenticator_sms", "authentik.stages.authenticator_static", "authentik.stages.authenticator_totp", diff --git a/authentik/stages/authenticator_email/__init__.py b/authentik/stages/authenticator_email/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/stages/authenticator_email/api.py b/authentik/stages/authenticator_email/api.py new file mode 100644 index 0000000000..a3dca329c1 --- /dev/null +++ b/authentik/stages/authenticator_email/api.py @@ -0,0 +1,85 @@ +"""AuthenticatorEmailStage API Views""" + +from rest_framework import mixins +from rest_framework.viewsets import GenericViewSet, ModelViewSet + +from authentik.core.api.groups import GroupMemberSerializer +from authentik.core.api.used_by import UsedByMixin +from authentik.core.api.utils import ModelSerializer +from authentik.flows.api.stages import StageSerializer +from authentik.stages.authenticator_email.models import AuthenticatorEmailStage, EmailDevice + + +class AuthenticatorEmailStageSerializer(StageSerializer): + """AuthenticatorEmailStage Serializer""" + + class Meta: + model = AuthenticatorEmailStage + fields = StageSerializer.Meta.fields + [ + "configure_flow", + "friendly_name", + "use_global_settings", + "host", + "port", + "username", + "password", + "use_tls", + "use_ssl", + "timeout", + "from_address", + "subject", + "token_expiry", + "template", + ] + + +class AuthenticatorEmailStageViewSet(UsedByMixin, ModelViewSet): + """AuthenticatorEmailStage Viewset""" + + queryset = AuthenticatorEmailStage.objects.all() + serializer_class = AuthenticatorEmailStageSerializer + filterset_fields = "__all__" + ordering = ["name"] + search_fields = ["name"] + + +class EmailDeviceSerializer(ModelSerializer): + """Serializer for email authenticator devices""" + + user = GroupMemberSerializer(read_only=True) + + class Meta: + model = EmailDevice + fields = ["name", "pk", "email", "user"] + depth = 2 + extra_kwargs = { + "email": {"read_only": True}, + } + + +class EmailDeviceViewSet( + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + UsedByMixin, + mixins.ListModelMixin, + GenericViewSet, +): + """Viewset for email authenticator devices""" + + queryset = EmailDevice.objects.all() + serializer_class = EmailDeviceSerializer + search_fields = ["name"] + filterset_fields = ["name"] + ordering = ["name"] + owner_field = "user" + + +class EmailAdminDeviceViewSet(ModelViewSet): + """Viewset for email authenticator devices (for admins)""" + + queryset = EmailDevice.objects.all() + serializer_class = EmailDeviceSerializer + search_fields = ["name"] + filterset_fields = ["name"] + ordering = ["name"] diff --git a/authentik/stages/authenticator_email/apps.py b/authentik/stages/authenticator_email/apps.py new file mode 100644 index 0000000000..e70e11f8ff --- /dev/null +++ b/authentik/stages/authenticator_email/apps.py @@ -0,0 +1,12 @@ +"""Email Authenticator""" + +from authentik.blueprints.apps import ManagedAppConfig + + +class AuthentikStageAuthenticatorEmailConfig(ManagedAppConfig): + """Email Authenticator App config""" + + name = "authentik.stages.authenticator_email" + label = "authentik_stages_authenticator_email" + verbose_name = "authentik Stages.Authenticator.Email" + default = True diff --git a/authentik/stages/authenticator_email/migrations/0001_initial.py b/authentik/stages/authenticator_email/migrations/0001_initial.py new file mode 100644 index 0000000000..fcd691e761 --- /dev/null +++ b/authentik/stages/authenticator_email/migrations/0001_initial.py @@ -0,0 +1,132 @@ +# Generated by Django 5.0.10 on 2025-01-27 20:05 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + +import authentik.lib.utils.time + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0027_auto_20231028_1424"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="AuthenticatorEmailStage", + fields=[ + ( + "stage_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="authentik_flows.stage", + ), + ), + ("friendly_name", models.TextField(null=True)), + ( + "use_global_settings", + models.BooleanField( + default=False, + help_text="When enabled, global Email connection settings will be used and connection settings below will be ignored.", + ), + ), + ("host", models.TextField(default="localhost")), + ("port", models.IntegerField(default=25)), + ("username", models.TextField(blank=True, default="")), + ("password", models.TextField(blank=True, default="")), + ("use_tls", models.BooleanField(default=False)), + ("use_ssl", models.BooleanField(default=False)), + ("timeout", models.IntegerField(default=10)), + ( + "from_address", + models.EmailField(default="system@authentik.local", max_length=254), + ), + ( + "token_expiry", + models.TextField( + default="minutes=30", + help_text="Time the token sent is valid (Format: hours=3,minutes=17,seconds=300).", + validators=[authentik.lib.utils.time.timedelta_string_validator], + ), + ), + ("subject", models.TextField(default="authentik Sign-in code")), + ("template", models.TextField(default="email/email_otp.html")), + ( + "configure_flow", + models.ForeignKey( + blank=True, + help_text="Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="authentik_flows.flow", + ), + ), + ], + options={ + "verbose_name": "Email Authenticator Setup Stage", + "verbose_name_plural": "Email Authenticator Setup Stages", + }, + bases=("authentik_flows.stage", models.Model), + ), + migrations.CreateModel( + name="EmailDevice", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ( + "name", + models.CharField( + help_text="The human-readable name of this device.", max_length=64 + ), + ), + ( + "confirmed", + models.BooleanField(default=True, help_text="Is this device ready for use?"), + ), + ("token", models.CharField(blank=True, max_length=16, null=True)), + ( + "valid_until", + models.DateTimeField( + default=django.utils.timezone.now, + help_text="The timestamp of the moment of expiry of the saved token.", + ), + ), + ("email", models.EmailField(max_length=254)), + ("last_used", models.DateTimeField(auto_now=True)), + ( + "stage", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_stages_authenticator_email.authenticatoremailstage", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "verbose_name": "Email Device", + "verbose_name_plural": "Email Devices", + "unique_together": {("user", "email")}, + }, + ), + ] diff --git a/authentik/stages/authenticator_email/migrations/__init__.py b/authentik/stages/authenticator_email/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/stages/authenticator_email/models.py b/authentik/stages/authenticator_email/models.py new file mode 100644 index 0000000000..d9e8c9a62c --- /dev/null +++ b/authentik/stages/authenticator_email/models.py @@ -0,0 +1,167 @@ +from django.contrib.auth import get_user_model +from django.core.mail.backends.base import BaseEmailBackend +from django.core.mail.backends.smtp import EmailBackend +from django.db import models +from django.template import TemplateSyntaxError +from django.utils.translation import gettext_lazy as _ +from django.views import View +from rest_framework.serializers import BaseSerializer + +from authentik.events.models import Event, EventAction +from authentik.flows.exceptions import StageInvalidException +from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage +from authentik.lib.config import CONFIG +from authentik.lib.models import SerializerModel +from authentik.lib.utils.errors import exception_to_string +from authentik.lib.utils.time import timedelta_string_validator +from authentik.stages.authenticator.models import SideChannelDevice +from authentik.stages.email.utils import TemplateEmailMessage + + +class EmailTemplates(models.TextChoices): + """Templates used for rendering the Email""" + + EMAIL_OTP = ( + "email/email_otp.html", + _("Email OTP"), + ) # nosec + + +class AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage): + """Use Email-based authentication instead of authenticator-based.""" + + use_global_settings = models.BooleanField( + default=False, + help_text=_( + "When enabled, global Email connection settings will be used and " + "connection settings below will be ignored." + ), + ) + + host = models.TextField(default="localhost") + port = models.IntegerField(default=25) + username = models.TextField(default="", blank=True) + password = models.TextField(default="", blank=True) + use_tls = models.BooleanField(default=False) + use_ssl = models.BooleanField(default=False) + timeout = models.IntegerField(default=10) + from_address = models.EmailField(default="system@authentik.local") + + token_expiry = models.TextField( + default="minutes=30", + validators=[timedelta_string_validator], + help_text=_("Time the token sent is valid (Format: hours=3,minutes=17,seconds=300)."), + ) + subject = models.TextField(default="authentik Sign-in code") + template = models.TextField(default=EmailTemplates.EMAIL_OTP) + + @property + def serializer(self) -> type[BaseSerializer]: + from authentik.stages.authenticator_email.api import AuthenticatorEmailStageSerializer + + return AuthenticatorEmailStageSerializer + + @property + def view(self) -> type[View]: + from authentik.stages.authenticator_email.stage import AuthenticatorEmailStageView + + return AuthenticatorEmailStageView + + @property + def component(self) -> str: + return "ak-stage-authenticator-email-form" + + @property + def backend_class(self) -> type[BaseEmailBackend]: + """Get the email backend class to use""" + return EmailBackend + + @property + def backend(self) -> BaseEmailBackend: + """Get fully configured Email Backend instance""" + if self.use_global_settings: + CONFIG.refresh("email.password") + return self.backend_class( + host=CONFIG.get("email.host"), + port=CONFIG.get_int("email.port"), + username=CONFIG.get("email.username"), + password=CONFIG.get("email.password"), + use_tls=CONFIG.get_bool("email.use_tls", False), + use_ssl=CONFIG.get_bool("email.use_ssl", False), + timeout=CONFIG.get_int("email.timeout"), + ) + return self.backend_class( + host=self.host, + port=self.port, + username=self.username, + password=self.password, + use_tls=self.use_tls, + use_ssl=self.use_ssl, + timeout=self.timeout, + ) + + def send(self, device: "EmailDevice"): + # Lazy import here to avoid circular import + from authentik.stages.email.tasks import send_mails + + # Compose the message using templates + message = device._compose_email() + return send_mails(device.stage, message) + + def __str__(self): + return f"Email Authenticator Stage {self.name}" + + class Meta: + verbose_name = _("Email Authenticator Setup Stage") + verbose_name_plural = _("Email Authenticator Setup Stages") + + +class EmailDevice(SerializerModel, SideChannelDevice): + """Email Device""" + + user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + email = models.EmailField() + stage = models.ForeignKey(AuthenticatorEmailStage, on_delete=models.CASCADE) + last_used = models.DateTimeField(auto_now=True) + + @property + def serializer(self) -> type[BaseSerializer]: + from authentik.stages.authenticator_email.api import EmailDeviceSerializer + + return EmailDeviceSerializer + + def _compose_email(self) -> TemplateEmailMessage: + try: + pending_user = self.user + stage = self.stage + email = self.email + + message = TemplateEmailMessage( + subject=_(stage.subject), + to=[(pending_user.name, email)], + template_name=stage.template, + template_context={ + "user": pending_user, + "expires": self.valid_until, + "token": self.token, + }, + ) + return message + except TemplateSyntaxError as exc: + Event.new( + EventAction.CONFIGURATION_ERROR, + message=_("Exception occurred while rendering E-mail template"), + error=exception_to_string(exc), + template=stage.template, + ).from_http(self.request) + raise StageInvalidException from exc + + def __str__(self): + if not self.pk: + return "New Email Device" + return f"Email Device for {self.user_id}" + + class Meta: + verbose_name = _("Email Device") + verbose_name_plural = _("Email Devices") + unique_together = (("user", "email"),) diff --git a/authentik/stages/authenticator_email/stage.py b/authentik/stages/authenticator_email/stage.py new file mode 100644 index 0000000000..9a3b403823 --- /dev/null +++ b/authentik/stages/authenticator_email/stage.py @@ -0,0 +1,177 @@ +"""Email Setup stage""" + +from django.db.models import Q +from django.http import HttpRequest, HttpResponse +from django.http.request import QueryDict +from django.template.exceptions import TemplateSyntaxError +from django.utils.translation import gettext_lazy as _ +from rest_framework.exceptions import ValidationError +from rest_framework.fields import BooleanField, CharField, IntegerField + +from authentik.events.models import Event, EventAction +from authentik.flows.challenge import ( + Challenge, + ChallengeResponse, + WithUserInfoChallenge, +) +from authentik.flows.exceptions import StageInvalidException +from authentik.flows.stage import ChallengeStageView +from authentik.lib.utils.email import mask_email +from authentik.lib.utils.errors import exception_to_string +from authentik.lib.utils.time import timedelta_from_string +from authentik.stages.authenticator_email.models import ( + AuthenticatorEmailStage, + EmailDevice, +) +from authentik.stages.email.tasks import send_mails +from authentik.stages.email.utils import TemplateEmailMessage +from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT + +SESSION_KEY_EMAIL_DEVICE = "authentik/stages/authenticator_email/email_device" +PLAN_CONTEXT_EMAIL = "email" +PLAN_CONTEXT_EMAIL_SENT = "email_sent" +PLAN_CONTEXT_EMAIL_OVERRIDE = "email" + + +class AuthenticatorEmailChallenge(WithUserInfoChallenge): + """Authenticator Email Setup challenge""" + + # Set to true if no previous prompt stage set the email + # this stage will also check prompt_data.email + email = CharField(default=None, allow_blank=True, allow_null=True) + email_required = BooleanField(default=True) + component = CharField(default="ak-stage-authenticator-email") + + +class AuthenticatorEmailChallengeResponse(ChallengeResponse): + """Authenticator Email Challenge response, device is set by get_response_instance""" + + device: EmailDevice + + code = IntegerField(required=False) + email = CharField(required=False) + + component = CharField(default="ak-stage-authenticator-email") + + def validate(self, attrs: dict) -> dict: + """Check""" + if "code" not in attrs: + if "email" not in attrs: + raise ValidationError("email required") + self.device.email = attrs["email"] + self.stage.validate_and_send(attrs["email"]) + return super().validate(attrs) + if not self.device.verify_token(str(attrs["code"])): + raise ValidationError(_("Code does not match")) + self.device.confirmed = True + return super().validate(attrs) + + +class AuthenticatorEmailStageView(ChallengeStageView): + """Authenticator Email Setup stage""" + + response_class = AuthenticatorEmailChallengeResponse + + def validate_and_send(self, email: str): + """Validate email and send message""" + pending_user = self.get_pending_user() + + stage: AuthenticatorEmailStage = self.executor.current_stage + if EmailDevice.objects.filter(Q(email=email), stage=stage.pk).exists(): + raise ValidationError(_("Invalid email")) + + device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE] + + try: + message = TemplateEmailMessage( + subject=_(stage.subject), + to=[(pending_user.name, email)], + language=pending_user.locale(self.request), + template_name=stage.template, + template_context={ + "user": pending_user, + "expires": device.valid_until, + "token": device.token, + }, + ) + + send_mails(stage, message) + except TemplateSyntaxError as exc: + Event.new( + EventAction.CONFIGURATION_ERROR, + message=_("Exception occurred while rendering E-mail template"), + error=exception_to_string(exc), + template=stage.template, + ).from_http(self.request) + raise StageInvalidException from exc + + def _has_email(self) -> str | None: + context = self.executor.plan.context + + # Check user's email attribute + user = self.get_pending_user() + if user.email: + self.logger.debug("got email from user attributes") + return user.email + # Check plan context for email + if PLAN_CONTEXT_EMAIL in context.get(PLAN_CONTEXT_PROMPT, {}): + self.logger.debug("got email from plan context") + return context.get(PLAN_CONTEXT_PROMPT, {}).get(PLAN_CONTEXT_EMAIL) + # Check device for email + if SESSION_KEY_EMAIL_DEVICE in self.request.session: + self.logger.debug("got email from device in session") + device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE] + if device.email == "": + return None + return device.email + return None + + def get_challenge(self, *args, **kwargs) -> Challenge: + email = self._has_email() + return AuthenticatorEmailChallenge( + data={ + "email": mask_email(email), + "email_required": email is None, + } + ) + + def get_response_instance(self, data: QueryDict) -> ChallengeResponse: + response = super().get_response_instance(data) + response.device = self.request.session[SESSION_KEY_EMAIL_DEVICE] + return response + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + user = self.get_pending_user() + + stage: AuthenticatorEmailStage = self.executor.current_stage + if SESSION_KEY_EMAIL_DEVICE not in self.request.session: + device = EmailDevice(user=user, confirmed=False, stage=stage, name="Email Device") + valid_secs: int = timedelta_from_string(stage.token_expiry).total_seconds() + device.generate_token(valid_secs=valid_secs, commit=False) + self.request.session[SESSION_KEY_EMAIL_DEVICE] = device + if email := self._has_email(): + device.email = email + try: + self.validate_and_send(email) + except ValidationError as exc: + # We had an email given already (at this point only possible from flow + # context), but an error occurred while sending (most likely) + # due to a duplicate device, so delete the email we got given, reset the state + # (ish) and retry + device.email = "" + self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}).pop( + PLAN_CONTEXT_EMAIL, None + ) + self.request.session.pop(SESSION_KEY_EMAIL_DEVICE, None) + self.logger.warning("failed to send email to pre-set address", exc=exc) + return self.get(request, *args, **kwargs) + return super().get(request, *args, **kwargs) + + def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: + """Email Token is validated by challenge""" + device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE] + if not device.confirmed: + return self.challenge_invalid(response) + device.save() + del self.request.session[SESSION_KEY_EMAIL_DEVICE] + return self.executor.stage_ok() diff --git a/authentik/stages/authenticator_email/templates/email/email_otp.html b/authentik/stages/authenticator_email/templates/email/email_otp.html new file mode 100644 index 0000000000..18688d145e --- /dev/null +++ b/authentik/stages/authenticator_email/templates/email/email_otp.html @@ -0,0 +1,44 @@ +{% extends "email/base.html" %} + +{% load i18n %} +{% load humanize %} + +{% block content %} + + +

+ {% blocktrans with username=user.username %} + Hi {{ username }}, + {% endblocktrans %} +

+ + + + + + + + + + + +
+ {% blocktrans %} + Email MFA code. + {% endblocktrans %} +
+ {{ token }} +
+ + +{% endblock %} + +{% block sub_content %} + + + {% blocktrans with expires=expires|timeuntil %} + If you did not request this code, please ignore this email. The code above is valid for {{ expires }}. + {% endblocktrans %} + + +{% endblock %} diff --git a/authentik/stages/authenticator_email/templates/email/email_otp.txt b/authentik/stages/authenticator_email/templates/email/email_otp.txt new file mode 100644 index 0000000000..ab7355f712 --- /dev/null +++ b/authentik/stages/authenticator_email/templates/email/email_otp.txt @@ -0,0 +1,13 @@ +{% load i18n %}{% load humanize %}{% autoescape off %}{% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %} + +{% blocktrans %} +Email MFA code +{% endblocktrans %} +{{ token }} +{% blocktrans with expires=expires|timeuntil %} +If you did not request this code, please ignore this email. The code above is valid for {{ expires }}. +{% endblocktrans %} + +-- +Powered by goauthentik.io. +{% endautoescape %} diff --git a/authentik/stages/authenticator_email/tests.py b/authentik/stages/authenticator_email/tests.py new file mode 100644 index 0000000000..66facdf0b7 --- /dev/null +++ b/authentik/stages/authenticator_email/tests.py @@ -0,0 +1,340 @@ +"""Test Email Authenticator API""" + +from datetime import timedelta +from unittest.mock import MagicMock, PropertyMock, patch + +from django.core import mail +from django.core.mail.backends.smtp import EmailBackend +from django.db.utils import IntegrityError +from django.template.exceptions import TemplateDoesNotExist +from django.urls import reverse +from django.utils.timezone import now + +from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_user +from authentik.flows.models import FlowStageBinding +from authentik.flows.tests import FlowTestCase +from authentik.lib.config import CONFIG +from authentik.lib.utils.email import mask_email +from authentik.stages.authenticator_email.api import ( + AuthenticatorEmailStageSerializer, + EmailDeviceSerializer, +) +from authentik.stages.authenticator_email.models import AuthenticatorEmailStage, EmailDevice +from authentik.stages.authenticator_email.stage import ( + SESSION_KEY_EMAIL_DEVICE, +) +from authentik.stages.email.utils import TemplateEmailMessage + + +class TestAuthenticatorEmailStage(FlowTestCase): + """Test Email Authenticator stage""" + + def setUp(self): + super().setUp() + self.flow = create_test_flow() + self.user = create_test_admin_user() + self.user_noemail = create_test_user(email="") + self.stage = AuthenticatorEmailStage.objects.create( + name="email-authenticator", + use_global_settings=True, + from_address="test@authentik.local", + configure_flow=self.flow, + token_expiry="minutes=30", + ) # nosec + self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0) + self.device = EmailDevice.objects.create( + user=self.user, + stage=self.stage, + email="test@authentik.local", + ) + self.client.force_login(self.user) + + def test_device_str(self): + """Test string representation of device""" + self.assertEqual(str(self.device), f"Email Device for {self.user.pk}") + # Test unsaved device + unsaved_device = EmailDevice( + user=self.user, + stage=self.stage, + email="test@authentik.local", + ) + self.assertEqual(str(unsaved_device), "New Email Device") + + def test_stage_str(self): + """Test string representation of stage""" + self.assertEqual(str(self.stage), f"Email Authenticator Stage {self.stage.name}") + + def test_token_lifecycle(self): + """Test token generation, validation and expiry""" + # Initially no token + self.assertIsNone(self.device.token) + + # Generate token + self.device.generate_token() + token = self.device.token + self.assertIsNotNone(token) + self.assertIsNotNone(self.device.valid_until) + self.assertTrue(self.device.valid_until > now()) + + # Verify invalid token + self.assertFalse(self.device.verify_token("000000")) + + # Verify correct token (should clear token after verification) + self.assertTrue(self.device.verify_token(token)) + self.assertIsNone(self.device.token) + + def test_stage_no_prefill(self): + """Test stage without prefilled email""" + self.client.force_login(self.user_noemail) + with patch( + "authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class", + PropertyMock(return_value=EmailBackend), + ): + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + ) + self.assertStageResponse( + response, + self.flow, + self.user_noemail, + component="ak-stage-authenticator-email", + email_required=True, + ) + + def test_stage_submit(self): + """Test stage email submission""" + # Initialize the flow + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + ) + self.assertStageResponse( + response, + self.flow, + self.user, + component="ak-stage-authenticator-email", + email_required=False, + ) + + # Test email submission with locmem backend + def mock_send_mails(stage, *messages): + """Mock send_mails to send directly""" + for message in messages: + message.send() + + with ( + patch( + "authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class", + return_value=EmailBackend, + ), + patch( + "authentik.stages.authenticator_email.stage.send_mails", + side_effect=mock_send_mails, + ), + ): + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + data={"component": "ak-stage-authenticator-email", "email": "test@example.com"}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(mail.outbox), 1) + sent_mail = mail.outbox[0] + self.assertEqual(sent_mail.subject, self.stage.subject) + self.assertEqual(sent_mail.to, [f"{self.user} "]) + # Get from_address from global email config to test if global settings are being used + from_address_global = CONFIG.get("email.from") + self.assertEqual(sent_mail.from_email, from_address_global) + + self.assertStageResponse( + response, + self.flow, + self.user, + component="ak-stage-authenticator-email", + response_errors={}, + email_required=False, + ) + + def test_email_template(self): + """Test email template rendering""" + self.device.generate_token() + message = self.device._compose_email() + + self.assertIsInstance(message, TemplateEmailMessage) + self.assertEqual(message.subject, self.stage.subject) + self.assertEqual(message.to, [f"{self.user.name} <{self.device.email}>"]) + self.assertTrue(self.device.token in message.body) + + def test_duplicate_email(self): + """Test attempting to use same email twice""" + email = "test2@authentik.local" + # First device + EmailDevice.objects.create( + user=self.user, + stage=self.stage, + email=email, + ) + # Attempt to create second device with same email + with self.assertRaises(IntegrityError): + EmailDevice.objects.create( + user=self.user, + stage=self.stage, + email=email, + ) + + def test_token_expiry(self): + """Test token expiration behavior""" + self.device.generate_token() + token = self.device.token + # Set token as expired + self.device.valid_until = now() - timedelta(minutes=1) + self.device.save() + # Verify expired token fails + self.assertFalse(self.device.verify_token(token)) + + def test_template_errors(self): + """Test handling of template errors""" + self.stage.template = "{% invalid template %}" + with self.assertRaises(TemplateDoesNotExist): + self.stage.send(self.device) + + def test_challenge_response_validation(self): + """Test challenge response validation""" + # Initialize the flow + self.client.force_login(self.user_noemail) + with patch( + "authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class", + PropertyMock(return_value=EmailBackend), + ): + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + ) + + # Test missing code and email + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + data={"component": "ak-stage-authenticator-email"}, + ) + self.assertIn("email required", str(response.content)) + + # Test invalid code + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + data={"component": "ak-stage-authenticator-email", "code": "000000"}, + ) + self.assertIn("Code does not match", str(response.content)) + + # Test valid code + self.client.force_login(self.user) + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + ) + device = self.device + token = device.token + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + data={"component": "ak-stage-authenticator-email", "code": token}, + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(device.confirmed) + + def test_challenge_generation(self): + """Test challenge generation""" + # Test with masked email + with patch( + "authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class", + PropertyMock(return_value=EmailBackend), + ): + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + ) + self.assertStageResponse( + response, + self.flow, + self.user, + component="ak-stage-authenticator-email", + email_required=False, + ) + masked_email = mask_email(self.user.email) + self.assertEqual(masked_email, response.json()["email"]) + + # Test without email + self.client.force_login(self.user_noemail) + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + ) + self.assertStageResponse( + response, + self.flow, + self.user_noemail, + component="ak-stage-authenticator-email", + email_required=True, + ) + self.assertIsNone(response.json()["email"]) + + def test_session_management(self): + """Test session device management""" + # Test device creation in session + with patch( + "authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class", + PropertyMock(return_value=EmailBackend), + ): + # Delete any existing devices for this test + EmailDevice.objects.filter(user=self.user).delete() + + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + ) + self.assertIn(SESSION_KEY_EMAIL_DEVICE, self.client.session) + device = self.client.session[SESSION_KEY_EMAIL_DEVICE] + self.assertIsInstance(device, EmailDevice) + self.assertFalse(device.confirmed) + self.assertEqual(device.user, self.user) + + # Test device confirmation and cleanup + device.confirmed = True + device.email = "new_test@authentik.local" # Use a different email + self.client.session[SESSION_KEY_EMAIL_DEVICE] = device + self.client.session.save() + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + data={"component": "ak-stage-authenticator-email", "code": device.token}, + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(device.confirmed) + # Session key should be removed after device is saved + device.save() + self.assertNotIn(SESSION_KEY_EMAIL_DEVICE, self.client.session) + + def test_model_properties_and_methods(self): + """Test model properties""" + device = self.device + stage = self.stage + + self.assertEqual(stage.serializer, AuthenticatorEmailStageSerializer) + self.assertIsInstance(stage.backend, EmailBackend) + self.assertEqual(device.serializer, EmailDeviceSerializer) + + # Test AuthenticatorEmailStage send method + with patch( + "authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class", + return_value=EmailBackend, + ): + self.device.generate_token() + # Test EmailDevice _compose_email method + message = self.device._compose_email() + self.assertIsInstance(message, TemplateEmailMessage) + self.assertEqual(message.subject, self.stage.subject) + self.assertEqual(message.to, [f"{self.user.name} <{self.device.email}>"]) + self.assertTrue(self.device.token in message.body) + # Test AuthenticatorEmailStage send method + self.stage.send(device) + + def test_email_tasks(self): + + email_send_mock = MagicMock() + with patch( + "authentik.stages.email.tasks.send_mails", + email_send_mock, + ): + # Test AuthenticatorEmailStage send method + self.stage.send(self.device) + email_send_mock.assert_called_once() diff --git a/authentik/stages/authenticator_email/urls.py b/authentik/stages/authenticator_email/urls.py new file mode 100644 index 0000000000..6d93234991 --- /dev/null +++ b/authentik/stages/authenticator_email/urls.py @@ -0,0 +1,17 @@ +"""API URLs""" + +from authentik.stages.authenticator_email.api import ( + AuthenticatorEmailStageViewSet, + EmailAdminDeviceViewSet, + EmailDeviceViewSet, +) + +api_urlpatterns = [ + ("authenticators/email", EmailDeviceViewSet), + ( + "authenticators/admin/email", + EmailAdminDeviceViewSet, + "admin-emaildevice", + ), + ("stages/authenticator/email", AuthenticatorEmailStageViewSet), +] diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py index 1f9a656a38..b5e2767b3d 100644 --- a/authentik/stages/authenticator_validate/challenge.py +++ b/authentik/stages/authenticator_validate/challenge.py @@ -26,10 +26,13 @@ from authentik.events.middleware import audit_ignore from authentik.events.models import Event, EventAction from authentik.flows.stage import StageView from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE +from authentik.lib.utils.email import mask_email +from authentik.lib.utils.time import timedelta_from_string 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_email.models import EmailDevice 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 @@ -54,6 +57,8 @@ def get_challenge_for_device( """Generate challenge for a single device""" if isinstance(device, WebAuthnDevice): return get_webauthn_challenge(request, stage, device) + if isinstance(device, EmailDevice): + return {"email": mask_email(device.email)} # Code-based challenges have no hints return {} @@ -103,6 +108,8 @@ def select_challenge(request: HttpRequest, device: Device): """Callback when the user selected a challenge in the frontend.""" if isinstance(device, SMSDevice): select_challenge_sms(request, device) + elif isinstance(device, EmailDevice): + select_challenge_email(request, device) def select_challenge_sms(request: HttpRequest, device: SMSDevice): @@ -111,6 +118,13 @@ def select_challenge_sms(request: HttpRequest, device: SMSDevice): device.stage.send(device.token, device) +def select_challenge_email(request: HttpRequest, device: EmailDevice): + """Send Email""" + valid_secs: int = timedelta_from_string(device.stage.token_expiry).total_seconds() + device.generate_token(valid_secs=valid_secs) + device.stage.send(device) + + def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Device: """Validate code-based challenges. We test against every device, on purpose, as the user mustn't choose between totp and static devices.""" diff --git a/authentik/stages/authenticator_validate/migrations/0014_alter_authenticatorvalidatestage_device_classes.py b/authentik/stages/authenticator_validate/migrations/0014_alter_authenticatorvalidatestage_device_classes.py new file mode 100644 index 0000000000..f9f931ba6b --- /dev/null +++ b/authentik/stages/authenticator_validate/migrations/0014_alter_authenticatorvalidatestage_device_classes.py @@ -0,0 +1,37 @@ +# Generated by Django 5.0.10 on 2025-01-16 02:48 + +import authentik.stages.authenticator_validate.models +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "authentik_stages_authenticator_validate", + "0013_authenticatorvalidatestage_webauthn_allowed_device_types", + ), + ] + + operations = [ + migrations.AlterField( + model_name="authenticatorvalidatestage", + name="device_classes", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.TextField( + choices=[ + ("static", "Static"), + ("totp", "TOTP"), + ("webauthn", "WebAuthn"), + ("duo", "Duo"), + ("sms", "SMS"), + ("email", "Email"), + ] + ), + default=authentik.stages.authenticator_validate.models.default_device_classes, + help_text="Device classes which can be used to authenticate", + size=None, + ), + ), + ] diff --git a/authentik/stages/authenticator_validate/models.py b/authentik/stages/authenticator_validate/models.py index b0c6133685..5f53ade295 100644 --- a/authentik/stages/authenticator_validate/models.py +++ b/authentik/stages/authenticator_validate/models.py @@ -20,6 +20,7 @@ class DeviceClasses(models.TextChoices): WEBAUTHN = "webauthn", _("WebAuthn") DUO = "duo", _("Duo") SMS = "sms", _("SMS") + EMAIL = "email", _("Email") def default_device_classes() -> list: @@ -30,6 +31,7 @@ def default_device_classes() -> list: DeviceClasses.WEBAUTHN, DeviceClasses.DUO, DeviceClasses.SMS, + DeviceClasses.EMAIL, ] diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index 01d69b4386..aa5c14a2b5 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -23,6 +23,7 @@ from authentik.flows.stage import ChallengeStageView from authentik.lib.utils.time import timedelta_from_string from authentik.stages.authenticator import devices_for_user from authentik.stages.authenticator.models import Device +from authentik.stages.authenticator_email.models import EmailDevice from authentik.stages.authenticator_sms.models import SMSDevice from authentik.stages.authenticator_validate.challenge import ( DeviceChallenge, @@ -84,7 +85,9 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse): def validate_code(self, code: str) -> str: """Validate code-based response, raise error if code isn't allowed""" - self._challenge_allowed([DeviceClasses.TOTP, DeviceClasses.STATIC, DeviceClasses.SMS]) + self._challenge_allowed( + [DeviceClasses.TOTP, DeviceClasses.STATIC, DeviceClasses.SMS, DeviceClasses.EMAIL] + ) self.device = validate_challenge_code(code, self.stage, self.stage.get_pending_user()) return code @@ -117,12 +120,17 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse): if not allowed: raise ValidationError("invalid challenge selected") - if challenge.get("device_class", "") != "sms": - return challenge - devices = SMSDevice.objects.filter(pk=int(challenge.get("device_uid", "0"))) - if not devices.exists(): - raise ValidationError("invalid challenge selected") - select_challenge(self.stage.request, devices.first()) + device_class = challenge.get("device_class", "") + if device_class == "sms": + devices = SMSDevice.objects.filter(pk=int(challenge.get("device_uid", "0"))) + if not devices.exists(): + raise ValidationError("invalid challenge selected") + select_challenge(self.stage.request, devices.first()) + elif device_class == "email": + devices = EmailDevice.objects.filter(pk=int(challenge.get("device_uid", "0"))) + if not devices.exists(): + raise ValidationError("invalid challenge selected") + select_challenge(self.stage.request, devices.first()) return challenge def validate_selected_stage(self, stage_pk: str) -> str: diff --git a/authentik/stages/authenticator_validate/tests/test_email.py b/authentik/stages/authenticator_validate/tests/test_email.py new file mode 100644 index 0000000000..03f32f993a --- /dev/null +++ b/authentik/stages/authenticator_validate/tests/test_email.py @@ -0,0 +1,183 @@ +"""Test validator stage for Email devices""" + +from django.test.client import RequestFactory +from django.urls.base import reverse + +from authentik.core.tests.utils import create_test_admin_user, create_test_flow +from authentik.flows.models import FlowStageBinding, NotConfiguredAction +from authentik.flows.tests import FlowTestCase +from authentik.lib.generators import generate_id +from authentik.lib.utils.email import mask_email +from authentik.stages.authenticator_email.models import AuthenticatorEmailStage, EmailDevice +from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses +from authentik.stages.identification.models import IdentificationStage, UserFields + + +class AuthenticatorValidateStageEmailTests(FlowTestCase): + """Test validator stage for Email devices""" + + def setUp(self) -> None: + self.user = create_test_admin_user() + self.request_factory = RequestFactory() + # Create email authenticator stage + self.stage = AuthenticatorEmailStage.objects.create( + name="email-authenticator", + use_global_settings=True, + from_address="test@authentik.local", + ) + # Create identification stage + self.ident_stage = IdentificationStage.objects.create( + name=generate_id(), + user_fields=[UserFields.USERNAME], + ) + # Create validation stage + self.validate_stage = AuthenticatorValidateStage.objects.create( + name=generate_id(), + device_classes=[DeviceClasses.EMAIL], + ) + # Create flow with both stages + self.flow = create_test_flow() + FlowStageBinding.objects.create(target=self.flow, stage=self.ident_stage, order=0) + FlowStageBinding.objects.create(target=self.flow, stage=self.validate_stage, order=1) + + def _identify_user(self): + """Helper to identify user in flow""" + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + {"uid_field": self.user.username}, + follow=True, + ) + self.assertEqual(response.status_code, 200) + return response + + def _send_challenge(self, device): + """Helper to send challenge for device""" + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + { + "component": "ak-stage-authenticator-validate", + "selected_challenge": { + "device_class": "email", + "device_uid": str(device.pk), + "challenge": {}, + "last_used": device.last_used.isoformat() if device.last_used else None, + }, + }, + ) + self.assertEqual(response.status_code, 200) + return response + + def test_happy_path(self): + """Test validator stage with valid code""" + # Create a device for our user + device = EmailDevice.objects.create( + user=self.user, + confirmed=True, + stage=self.stage, + email="xx@0.co", + ) # Short email for testing purposes + + # First identify the user + self._identify_user() + + # Send the challenge + response = self._send_challenge(device) + response_data = self.assertStageResponse( + response, + flow=self.flow, + component="ak-stage-authenticator-validate", + ) + + # Get the device challenge from the response and verify it matches + device_challenge = response_data["device_challenges"][0] + self.assertEqual(device_challenge["device_class"], "email") + self.assertEqual(device_challenge["device_uid"], str(device.pk)) + self.assertEqual(device_challenge["challenge"], {"email": mask_email(device.email)}) + + # Generate a token for the device + device.generate_token() + + # Submit the valid code + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + {"component": "ak-stage-authenticator-validate", "code": device.token}, + ) + # Should redirect to root since this is the last stage + self.assertStageRedirects(response, "/") + + def test_no_device(self): + """Test validator stage without configured device""" + configuration_stage = AuthenticatorEmailStage.objects.create( + name=generate_id(), + use_global_settings=True, + from_address="test@authentik.local", + ) + stage = AuthenticatorValidateStage.objects.create( + name=generate_id(), + not_configured_action=NotConfiguredAction.CONFIGURE, + device_classes=[DeviceClasses.EMAIL], + ) + stage.configuration_stages.set([configuration_stage]) + flow = create_test_flow() + FlowStageBinding.objects.create(target=flow, stage=stage, order=2) + + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + {"component": "ak-stage-authenticator-validate"}, + ) + self.assertEqual(response.status_code, 200) + response_data = self.assertStageResponse( + response, + flow=flow, + component="ak-stage-authenticator-validate", + ) + self.assertEqual(response_data["configuration_stages"], []) + self.assertEqual(response_data["device_challenges"], []) + self.assertEqual( + response_data["response_errors"], + {"non_field_errors": [{"code": "invalid", "string": "Empty response"}]}, + ) + + def test_invalid_code(self): + """Test validator stage with invalid code""" + # Create a device for our user + device = EmailDevice.objects.create( + user=self.user, + confirmed=True, + stage=self.stage, + email="test@authentik.local", + ) + + # First identify the user + self._identify_user() + + # Send the challenge + self._send_challenge(device) + + # Generate a token for the device + device.generate_token() + + # Try invalid code and verify error message + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + {"component": "ak-stage-authenticator-validate", "code": "invalid"}, + ) + response_data = self.assertStageResponse( + response, + flow=self.flow, + component="ak-stage-authenticator-validate", + ) + self.assertEqual( + response_data["response_errors"], + { + "code": [ + { + "code": "invalid", + "string": ( + "Invalid Token. Please ensure the time on your device " + "is accurate and try again." + ), + } + ], + }, + ) diff --git a/authentik/stages/email/tasks.py b/authentik/stages/email/tasks.py index 8a6089ea11..1fb0473f5d 100644 --- a/authentik/stages/email/tasks.py +++ b/authentik/stages/email/tasks.py @@ -13,17 +13,28 @@ from structlog.stdlib import get_logger from authentik.events.models import Event, EventAction, TaskStatus from authentik.events.system_tasks import SystemTask from authentik.root.celery import CELERY_APP +from authentik.stages.authenticator_email.models import AuthenticatorEmailStage from authentik.stages.email.models import EmailStage from authentik.stages.email.utils import logo_data LOGGER = get_logger() -def send_mails(stage: EmailStage, *messages: list[EmailMultiAlternatives]): - """Wrapper to convert EmailMessage to dict and send it from worker""" +def send_mails( + stage: EmailStage | AuthenticatorEmailStage, *messages: list[EmailMultiAlternatives] +): + """Wrapper to convert EmailMessage to dict and send it from worker + + Args: + stage: Either an EmailStage or AuthenticatorEmailStage instance + messages: List of email messages to send + Returns: + Celery group promise for the email sending tasks + """ tasks = [] + stage_class = stage.__class__ for message in messages: - tasks.append(send_mail.s(message.__dict__, str(stage.pk))) + tasks.append(send_mail.s(message.__dict__, stage_class, str(stage.pk))) lazy_group = group(*tasks) promise = lazy_group() return promise @@ -47,23 +58,28 @@ def get_email_body(email: EmailMultiAlternatives) -> str: retry_backoff=True, base=SystemTask, ) -def send_mail(self: SystemTask, message: dict[Any, Any], email_stage_pk: str | None = None): +def send_mail( + self: SystemTask, + message: dict[Any, Any], + stage_class: EmailStage | AuthenticatorEmailStage = EmailStage, + email_stage_pk: str | None = None, +): """Send Email for Email Stage. Retries are scheduled automatically.""" self.save_on_success = False message_id = make_msgid(domain=DNS_NAME) self.set_uid(slugify(message_id.replace(".", "_").replace("@", "_"))) try: if not email_stage_pk: - stage: EmailStage = EmailStage(use_global_settings=True) + stage: EmailStage | AuthenticatorEmailStage = stage_class(use_global_settings=True) else: - stages = EmailStage.objects.filter(pk=email_stage_pk) + stages = stage_class.objects.filter(pk=email_stage_pk) if not stages.exists(): self.set_status( TaskStatus.WARNING, "Email stage does not exist anymore. Discarding message.", ) return - stage: EmailStage = stages.first() + stage: EmailStage | AuthenticatorEmailStage = stages.first() try: backend = stage.backend except ValueError as exc: diff --git a/blueprints/example/flows-authenticator-email-setup.yaml b/blueprints/example/flows-authenticator-email-setup.yaml new file mode 100644 index 0000000000..ad733fffe5 --- /dev/null +++ b/blueprints/example/flows-authenticator-email-setup.yaml @@ -0,0 +1,30 @@ +version: 1 +metadata: + labels: + blueprints.goauthentik.io/instantiate: "false" + name: Example - Email MFA setup flow +entries: +- attrs: + designation: stage_configuration + name: Default Email Authenticator Flow + title: Setup Email Two-Factor Authentication + authentication: require_authenticated + identifiers: + slug: default-authenticator-email-setup + model: authentik_flows.flow + id: flow +- attrs: + configure_flow: !KeyOf flow + friendly_name: Email Authenticator + use_global_settings: true + token_expiry: minutes=30 + subject: authentik Sign-in code + identifiers: + name: default-authenticator-email-setup + id: default-authenticator-email-setup + model: authentik_stages_authenticator_email.authenticatoremailstage +- identifiers: + order: 0 + stage: !KeyOf default-authenticator-email-setup + target: !KeyOf flow + model: authentik_flows.flowstagebinding diff --git a/blueprints/schema.json b/blueprints/schema.json index 4e0ec4c68a..9bdc1aef92 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -1961,6 +1961,86 @@ } } }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_stages_authenticator_email.authenticatoremailstage" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "permissions": { + "$ref": "#/$defs/model_authentik_stages_authenticator_email.authenticatoremailstage_permissions" + }, + "attrs": { + "$ref": "#/$defs/model_authentik_stages_authenticator_email.authenticatoremailstage" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_stages_authenticator_email.authenticatoremailstage" + } + } + }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_stages_authenticator_email.emaildevice" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "permissions": { + "$ref": "#/$defs/model_authentik_stages_authenticator_email.emaildevice_permissions" + }, + "attrs": { + "$ref": "#/$defs/model_authentik_stages_authenticator_email.emaildevice" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_stages_authenticator_email.emaildevice" + } + } + }, { "type": "object", "required": [ @@ -4596,6 +4676,7 @@ "authentik.sources.scim", "authentik.stages.authenticator", "authentik.stages.authenticator_duo", + "authentik.stages.authenticator_email", "authentik.stages.authenticator_sms", "authentik.stages.authenticator_static", "authentik.stages.authenticator_totp", @@ -4686,6 +4767,8 @@ "authentik_sources_scim.scimsourcepropertymapping", "authentik_stages_authenticator_duo.authenticatorduostage", "authentik_stages_authenticator_duo.duodevice", + "authentik_stages_authenticator_email.authenticatoremailstage", + "authentik_stages_authenticator_email.emaildevice", "authentik_stages_authenticator_sms.authenticatorsmsstage", "authentik_stages_authenticator_sms.smsdevice", "authentik_stages_authenticator_static.authenticatorstaticstage", @@ -6847,6 +6930,14 @@ "authentik_stages_authenticator_duo.delete_duodevice", "authentik_stages_authenticator_duo.view_authenticatorduostage", "authentik_stages_authenticator_duo.view_duodevice", + "authentik_stages_authenticator_email.add_authenticatoremailstage", + "authentik_stages_authenticator_email.add_emaildevice", + "authentik_stages_authenticator_email.change_authenticatoremailstage", + "authentik_stages_authenticator_email.change_emaildevice", + "authentik_stages_authenticator_email.delete_authenticatoremailstage", + "authentik_stages_authenticator_email.delete_emaildevice", + "authentik_stages_authenticator_email.view_authenticatoremailstage", + "authentik_stages_authenticator_email.view_emaildevice", "authentik_stages_authenticator_endpoint_gdtc.add_authenticatorendpointgdtcstage", "authentik_stages_authenticator_endpoint_gdtc.add_endpointdevice", "authentik_stages_authenticator_endpoint_gdtc.add_endpointdeviceconnection", @@ -8972,6 +9063,239 @@ } } }, + "model_authentik_stages_authenticator_email.authenticatoremailstage": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Name" + }, + "flow_set": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Name" + }, + "slug": { + "type": "string", + "maxLength": 50, + "minLength": 1, + "pattern": "^[-a-zA-Z0-9_]+$", + "title": "Slug", + "description": "Visible in the URL." + }, + "title": { + "type": "string", + "minLength": 1, + "title": "Title", + "description": "Shown as the Title in Flow pages." + }, + "designation": { + "type": "string", + "enum": [ + "authentication", + "authorization", + "invalidation", + "enrollment", + "unenrollment", + "recovery", + "stage_configuration" + ], + "title": "Designation", + "description": "Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik." + }, + "policy_engine_mode": { + "type": "string", + "enum": [ + "all", + "any" + ], + "title": "Policy engine mode" + }, + "compatibility_mode": { + "type": "boolean", + "title": "Compatibility mode", + "description": "Enable compatibility mode, increases compatibility with password managers on mobile devices." + }, + "layout": { + "type": "string", + "enum": [ + "stacked", + "content_left", + "content_right", + "sidebar_left", + "sidebar_right" + ], + "title": "Layout" + }, + "denied_action": { + "type": "string", + "enum": [ + "message_continue", + "message", + "continue" + ], + "title": "Denied action", + "description": "Configure what should happen when a flow denies access to a user." + } + }, + "required": [ + "name", + "slug", + "title", + "designation" + ] + }, + "title": "Flow set" + }, + "configure_flow": { + "type": "string", + "format": "uuid", + "title": "Configure flow", + "description": "Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage." + }, + "friendly_name": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "title": "Friendly name" + }, + "use_global_settings": { + "type": "boolean", + "title": "Use global settings", + "description": "When enabled, global Email connection settings will be used and connection settings below will be ignored." + }, + "host": { + "type": "string", + "minLength": 1, + "title": "Host" + }, + "port": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647, + "title": "Port" + }, + "username": { + "type": "string", + "title": "Username" + }, + "password": { + "type": "string", + "title": "Password" + }, + "use_tls": { + "type": "boolean", + "title": "Use tls" + }, + "use_ssl": { + "type": "boolean", + "title": "Use ssl" + }, + "timeout": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647, + "title": "Timeout" + }, + "from_address": { + "type": "string", + "format": "email", + "maxLength": 254, + "minLength": 1, + "title": "From address" + }, + "subject": { + "type": "string", + "minLength": 1, + "title": "Subject" + }, + "token_expiry": { + "type": "string", + "minLength": 1, + "title": "Token expiry", + "description": "Time the token sent is valid (Format: hours=3,minutes=17,seconds=300)." + }, + "template": { + "type": "string", + "minLength": 1, + "title": "Template" + } + }, + "required": [] + }, + "model_authentik_stages_authenticator_email.authenticatoremailstage_permissions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "permission" + ], + "properties": { + "permission": { + "type": "string", + "enum": [ + "add_authenticatoremailstage", + "change_authenticatoremailstage", + "delete_authenticatoremailstage", + "view_authenticatoremailstage" + ] + }, + "user": { + "type": "integer" + }, + "role": { + "type": "string" + } + } + } + }, + "model_authentik_stages_authenticator_email.emaildevice": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "title": "Name", + "description": "The human-readable name of this device." + } + }, + "required": [] + }, + "model_authentik_stages_authenticator_email.emaildevice_permissions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "permission" + ], + "properties": { + "permission": { + "type": "string", + "enum": [ + "add_emaildevice", + "change_emaildevice", + "delete_emaildevice", + "view_emaildevice" + ] + }, + "user": { + "type": "integer" + }, + "role": { + "type": "string" + } + } + } + }, "model_authentik_stages_authenticator_sms.authenticatorsmsstage": { "type": "object", "properties": { @@ -9661,7 +9985,8 @@ "totp", "webauthn", "duo", - "sms" + "sms", + "email" ], "title": "Device classes" }, @@ -13108,6 +13433,14 @@ "authentik_stages_authenticator_duo.delete_duodevice", "authentik_stages_authenticator_duo.view_authenticatorduostage", "authentik_stages_authenticator_duo.view_duodevice", + "authentik_stages_authenticator_email.add_authenticatoremailstage", + "authentik_stages_authenticator_email.add_emaildevice", + "authentik_stages_authenticator_email.change_authenticatoremailstage", + "authentik_stages_authenticator_email.change_emaildevice", + "authentik_stages_authenticator_email.delete_authenticatoremailstage", + "authentik_stages_authenticator_email.delete_emaildevice", + "authentik_stages_authenticator_email.view_authenticatoremailstage", + "authentik_stages_authenticator_email.view_emaildevice", "authentik_stages_authenticator_endpoint_gdtc.add_authenticatorendpointgdtcstage", "authentik_stages_authenticator_endpoint_gdtc.add_endpointdevice", "authentik_stages_authenticator_endpoint_gdtc.add_endpointdeviceconnection", diff --git a/schema.yml b/schema.yml index 452bbb5c10..ebb6732b40 100644 --- a/schema.yml +++ b/schema.yml @@ -638,6 +638,234 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /authenticators/admin/email/: + get: + operationId: authenticators_admin_email_list + description: Viewset for email authenticator devices (for admins) + parameters: + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + tags: + - authenticators + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedEmailDeviceList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: authenticators_admin_email_create + description: Viewset for email authenticator devices (for admins) + tags: + - authenticators + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EmailDeviceRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/EmailDevice' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /authenticators/admin/email/{id}/: + get: + operationId: authenticators_admin_email_retrieve + description: Viewset for email authenticator devices (for admins) + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Email Device. + required: true + tags: + - authenticators + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/EmailDevice' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: authenticators_admin_email_update + description: Viewset for email authenticator devices (for admins) + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Email Device. + required: true + tags: + - authenticators + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EmailDeviceRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/EmailDevice' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: authenticators_admin_email_partial_update + description: Viewset for email authenticator devices (for admins) + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Email Device. + required: true + tags: + - authenticators + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedEmailDeviceRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/EmailDevice' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: authenticators_admin_email_destroy + description: Viewset for email authenticator devices (for admins) + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Email Device. + required: true + tags: + - authenticators + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /authenticators/admin/endpoint/: get: operationId: authenticators_admin_endpoint_list @@ -2043,6 +2271,238 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /authenticators/email/: + get: + operationId: authenticators_email_list + description: Viewset for email authenticator devices + parameters: + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + tags: + - authenticators + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedEmailDeviceList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /authenticators/email/{id}/: + get: + operationId: authenticators_email_retrieve + description: Viewset for email authenticator devices + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Email Device. + required: true + tags: + - authenticators + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/EmailDevice' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: authenticators_email_update + description: Viewset for email authenticator devices + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Email Device. + required: true + tags: + - authenticators + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EmailDeviceRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/EmailDevice' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: authenticators_email_partial_update + description: Viewset for email authenticator devices + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Email Device. + required: true + tags: + - authenticators + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedEmailDeviceRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/EmailDevice' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: authenticators_email_destroy + description: Viewset for email authenticator devices + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Email Device. + required: true + tags: + - authenticators + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /authenticators/email/{id}/used_by/: + get: + operationId: authenticators_email_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this Email Device. + required: true + tags: + - authenticators + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /authenticators/endpoint/: get: operationId: authenticators_endpoint_list @@ -23927,6 +24387,8 @@ paths: - authentik_sources_scim.scimsourcepropertymapping - authentik_stages_authenticator_duo.authenticatorduostage - authentik_stages_authenticator_duo.duodevice + - authentik_stages_authenticator_email.authenticatoremailstage + - authentik_stages_authenticator_email.emaildevice - authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage - authentik_stages_authenticator_sms.authenticatorsmsstage - authentik_stages_authenticator_sms.smsdevice @@ -24169,6 +24631,8 @@ paths: - authentik_sources_scim.scimsourcepropertymapping - authentik_stages_authenticator_duo.authenticatorduostage - authentik_stages_authenticator_duo.duodevice + - authentik_stages_authenticator_email.authenticatoremailstage + - authentik_stages_authenticator_email.emaildevice - authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage - authentik_stages_authenticator_sms.authenticatorsmsstage - authentik_stages_authenticator_sms.smsdevice @@ -31288,6 +31752,337 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /stages/authenticator/email/: + get: + operationId: stages_authenticator_email_list + description: AuthenticatorEmailStage Viewset + parameters: + - in: query + name: configure_flow + schema: + type: string + format: uuid + - in: query + name: friendly_name + schema: + type: string + - in: query + name: from_address + schema: + type: string + - in: query + name: host + schema: + type: string + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: query + name: password + schema: + type: string + - in: query + name: port + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: stage_uuid + schema: + type: string + format: uuid + - in: query + name: subject + schema: + type: string + - in: query + name: template + schema: + type: string + - in: query + name: timeout + schema: + type: integer + - in: query + name: token_expiry + schema: + type: string + - in: query + name: use_global_settings + schema: + type: boolean + - in: query + name: use_ssl + schema: + type: boolean + - in: query + name: use_tls + schema: + type: boolean + - in: query + name: username + schema: + type: string + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedAuthenticatorEmailStageList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: stages_authenticator_email_create + description: AuthenticatorEmailStage Viewset + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatorEmailStageRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatorEmailStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /stages/authenticator/email/{stage_uuid}/: + get: + operationId: stages_authenticator_email_retrieve + description: AuthenticatorEmailStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Email Authenticator Setup Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatorEmailStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: stages_authenticator_email_update + description: AuthenticatorEmailStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Email Authenticator Setup Stage. + required: true + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatorEmailStageRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatorEmailStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: stages_authenticator_email_partial_update + description: AuthenticatorEmailStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Email Authenticator Setup Stage. + required: true + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedAuthenticatorEmailStageRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatorEmailStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: stages_authenticator_email_destroy + description: AuthenticatorEmailStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Email Authenticator Setup Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /stages/authenticator/email/{stage_uuid}/used_by/: + get: + operationId: stages_authenticator_email_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Email Authenticator Setup Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /stages/authenticator/endpoint_gdtc/: get: operationId: stages_authenticator_endpoint_gdtc_list @@ -38700,6 +39495,7 @@ components: - authentik.sources.scim - authentik.stages.authenticator - authentik.stages.authenticator_duo + - authentik.stages.authenticator_email - authentik.stages.authenticator_sms - authentik.stages.authenticator_static - authentik.stages.authenticator_totp @@ -39227,6 +40023,188 @@ components: - client_id - client_secret - name + AuthenticatorEmailChallenge: + type: object + description: Authenticator Email Setup challenge + properties: + flow_info: + $ref: '#/components/schemas/ContextualFlowInfo' + component: + type: string + default: ak-stage-authenticator-email + response_errors: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/ErrorDetail' + pending_user: + type: string + pending_user_avatar: + type: string + email: + type: string + nullable: true + email_required: + type: boolean + default: true + required: + - pending_user + - pending_user_avatar + AuthenticatorEmailChallengeResponseRequest: + type: object + description: Authenticator Email Challenge response, device is set by get_response_instance + properties: + component: + type: string + minLength: 1 + default: ak-stage-authenticator-email + code: + type: integer + email: + type: string + minLength: 1 + AuthenticatorEmailStage: + type: object + description: AuthenticatorEmailStage Serializer + properties: + pk: + type: string + format: uuid + readOnly: true + title: Stage uuid + name: + type: string + component: + type: string + description: Get object type so that we know how to edit the object + readOnly: true + verbose_name: + type: string + description: Return object's verbose_name + readOnly: true + verbose_name_plural: + type: string + description: Return object's plural verbose_name + readOnly: true + meta_model_name: + type: string + description: Return internal model name + readOnly: true + flow_set: + type: array + items: + $ref: '#/components/schemas/FlowSet' + configure_flow: + type: string + format: uuid + nullable: true + description: Flow used by an authenticated user to configure this Stage. + If empty, user will not be able to configure this stage. + friendly_name: + type: string + nullable: true + use_global_settings: + type: boolean + description: When enabled, global Email connection settings will be used + and connection settings below will be ignored. + host: + type: string + port: + type: integer + maximum: 2147483647 + minimum: -2147483648 + username: + type: string + password: + type: string + use_tls: + type: boolean + use_ssl: + type: boolean + timeout: + type: integer + maximum: 2147483647 + minimum: -2147483648 + from_address: + type: string + format: email + maxLength: 254 + subject: + type: string + token_expiry: + type: string + description: 'Time the token sent is valid (Format: hours=3,minutes=17,seconds=300).' + template: + type: string + required: + - component + - meta_model_name + - name + - pk + - verbose_name + - verbose_name_plural + AuthenticatorEmailStageRequest: + type: object + description: AuthenticatorEmailStage Serializer + properties: + name: + type: string + minLength: 1 + flow_set: + type: array + items: + $ref: '#/components/schemas/FlowSetRequest' + configure_flow: + type: string + format: uuid + nullable: true + description: Flow used by an authenticated user to configure this Stage. + If empty, user will not be able to configure this stage. + friendly_name: + type: string + nullable: true + minLength: 1 + use_global_settings: + type: boolean + description: When enabled, global Email connection settings will be used + and connection settings below will be ignored. + host: + type: string + minLength: 1 + port: + type: integer + maximum: 2147483647 + minimum: -2147483648 + username: + type: string + password: + type: string + use_tls: + type: boolean + use_ssl: + type: boolean + timeout: + type: integer + maximum: 2147483647 + minimum: -2147483648 + from_address: + type: string + format: email + minLength: 1 + maxLength: 254 + subject: + type: string + minLength: 1 + token_expiry: + type: string + minLength: 1 + description: 'Time the token sent is valid (Format: hours=3,minutes=17,seconds=300).' + template: + type: string + minLength: 1 + required: + - name AuthenticatorEndpointGDTCStage: type: object description: AuthenticatorEndpointGDTCStage Serializer @@ -40547,6 +41525,7 @@ components: - $ref: '#/components/schemas/AccessDeniedChallenge' - $ref: '#/components/schemas/AppleLoginChallenge' - $ref: '#/components/schemas/AuthenticatorDuoChallenge' + - $ref: '#/components/schemas/AuthenticatorEmailChallenge' - $ref: '#/components/schemas/AuthenticatorSMSChallenge' - $ref: '#/components/schemas/AuthenticatorStaticChallenge' - $ref: '#/components/schemas/AuthenticatorTOTPChallenge' @@ -40575,6 +41554,7 @@ components: ak-stage-access-denied: '#/components/schemas/AccessDeniedChallenge' ak-source-oauth-apple: '#/components/schemas/AppleLoginChallenge' ak-stage-authenticator-duo: '#/components/schemas/AuthenticatorDuoChallenge' + ak-stage-authenticator-email: '#/components/schemas/AuthenticatorEmailChallenge' ak-stage-authenticator-sms: '#/components/schemas/AuthenticatorSMSChallenge' ak-stage-authenticator-static: '#/components/schemas/AuthenticatorStaticChallenge' ak-stage-authenticator-totp: '#/components/schemas/AuthenticatorTOTPChallenge' @@ -41339,6 +42319,7 @@ components: - webauthn - duo - sms + - email type: string DigestAlgorithmEnum: enum: @@ -41701,6 +42682,42 @@ components: type: string minLength: 1 default: ak-stage-email + EmailDevice: + type: object + description: Serializer for email authenticator devices + properties: + name: + type: string + description: The human-readable name of this device. + maxLength: 64 + pk: + type: integer + readOnly: true + title: ID + email: + type: string + format: email + readOnly: true + user: + allOf: + - $ref: '#/components/schemas/GroupMember' + readOnly: true + required: + - email + - name + - pk + - user + EmailDeviceRequest: + type: object + description: Serializer for email authenticator devices + properties: + name: + type: string + minLength: 1 + description: The human-readable name of this device. + maxLength: 64 + required: + - name EmailStage: type: object description: EmailStage Serializer @@ -42501,6 +43518,7 @@ components: oneOf: - $ref: '#/components/schemas/AppleChallengeResponseRequest' - $ref: '#/components/schemas/AuthenticatorDuoChallengeResponseRequest' + - $ref: '#/components/schemas/AuthenticatorEmailChallengeResponseRequest' - $ref: '#/components/schemas/AuthenticatorSMSChallengeResponseRequest' - $ref: '#/components/schemas/AuthenticatorStaticChallengeResponseRequest' - $ref: '#/components/schemas/AuthenticatorTOTPChallengeResponseRequest' @@ -42525,6 +43543,7 @@ components: mapping: ak-source-oauth-apple: '#/components/schemas/AppleChallengeResponseRequest' ak-stage-authenticator-duo: '#/components/schemas/AuthenticatorDuoChallengeResponseRequest' + ak-stage-authenticator-email: '#/components/schemas/AuthenticatorEmailChallengeResponseRequest' ak-stage-authenticator-sms: '#/components/schemas/AuthenticatorSMSChallengeResponseRequest' ak-stage-authenticator-static: '#/components/schemas/AuthenticatorStaticChallengeResponseRequest' ak-stage-authenticator-totp: '#/components/schemas/AuthenticatorTOTPChallengeResponseRequest' @@ -45593,6 +46612,8 @@ components: - authentik_sources_scim.scimsourcepropertymapping - authentik_stages_authenticator_duo.authenticatorduostage - authentik_stages_authenticator_duo.duodevice + - authentik_stages_authenticator_email.authenticatoremailstage + - authentik_stages_authenticator_email.emaildevice - authentik_stages_authenticator_sms.authenticatorsmsstage - authentik_stages_authenticator_sms.smsdevice - authentik_stages_authenticator_static.authenticatorstaticstage @@ -46754,6 +47775,18 @@ components: required: - pagination - results + PaginatedAuthenticatorEmailStageList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/AuthenticatorEmailStage' + required: + - pagination + - results PaginatedAuthenticatorEndpointGDTCStageList: type: object properties: @@ -46970,6 +48003,18 @@ components: required: - pagination - results + PaginatedEmailDeviceList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/EmailDevice' + required: + - pagination + - results PaginatedEmailStageList: type: object properties: @@ -48726,6 +49771,65 @@ components: admin_secret_key: type: string writeOnly: true + PatchedAuthenticatorEmailStageRequest: + type: object + description: AuthenticatorEmailStage Serializer + properties: + name: + type: string + minLength: 1 + flow_set: + type: array + items: + $ref: '#/components/schemas/FlowSetRequest' + configure_flow: + type: string + format: uuid + nullable: true + description: Flow used by an authenticated user to configure this Stage. + If empty, user will not be able to configure this stage. + friendly_name: + type: string + nullable: true + minLength: 1 + use_global_settings: + type: boolean + description: When enabled, global Email connection settings will be used + and connection settings below will be ignored. + host: + type: string + minLength: 1 + port: + type: integer + maximum: 2147483647 + minimum: -2147483648 + username: + type: string + password: + type: string + use_tls: + type: boolean + use_ssl: + type: boolean + timeout: + type: integer + maximum: 2147483647 + minimum: -2147483648 + from_address: + type: string + format: email + minLength: 1 + maxLength: 254 + subject: + type: string + minLength: 1 + token_expiry: + type: string + minLength: 1 + description: 'Time the token sent is valid (Format: hours=3,minutes=17,seconds=300).' + template: + type: string + minLength: 1 PatchedAuthenticatorEndpointGDTCStageRequest: type: object description: AuthenticatorEndpointGDTCStage Serializer @@ -49177,6 +50281,15 @@ components: minLength: 1 description: The human-readable name of this device. maxLength: 64 + PatchedEmailDeviceRequest: + type: object + description: Serializer for email authenticator devices + properties: + name: + type: string + minLength: 1 + description: The human-readable name of this device. + maxLength: 64 PatchedEmailStageRequest: type: object description: EmailStage Serializer diff --git a/web/src/admin/stages/StageListPage.ts b/web/src/admin/stages/StageListPage.ts index 19e9b5e2b2..3056d41d03 100644 --- a/web/src/admin/stages/StageListPage.ts +++ b/web/src/admin/stages/StageListPage.ts @@ -2,6 +2,7 @@ import "@goauthentik/admin/rbac/ObjectPermissionModal"; import "@goauthentik/admin/stages/StageWizard"; import "@goauthentik/admin/stages/authenticator_duo/AuthenticatorDuoStageForm"; import "@goauthentik/admin/stages/authenticator_duo/DuoDeviceImportForm"; +import "@goauthentik/admin/stages/authenticator_email/AuthenticatorEmailStageForm"; import "@goauthentik/admin/stages/authenticator_endpoint_gdtc/AuthenticatorEndpointGDTCStageForm"; import "@goauthentik/admin/stages/authenticator_sms/AuthenticatorSMSStageForm"; import "@goauthentik/admin/stages/authenticator_static/AuthenticatorStaticStageForm"; diff --git a/web/src/admin/stages/StageWizard.ts b/web/src/admin/stages/StageWizard.ts index 1335ac280e..46d99f4a16 100644 --- a/web/src/admin/stages/StageWizard.ts +++ b/web/src/admin/stages/StageWizard.ts @@ -1,6 +1,7 @@ import "@goauthentik/admin/common/ak-license-notice"; import { StageBindingForm } from "@goauthentik/admin/flows/StageBindingForm"; import "@goauthentik/admin/stages/authenticator_duo/AuthenticatorDuoStageForm"; +import "@goauthentik/admin/stages/authenticator_email/AuthenticatorEmailStageForm"; import "@goauthentik/admin/stages/authenticator_sms/AuthenticatorSMSStageForm"; import "@goauthentik/admin/stages/authenticator_static/AuthenticatorStaticStageForm"; import "@goauthentik/admin/stages/authenticator_totp/AuthenticatorTOTPStageForm"; diff --git a/web/src/admin/stages/authenticator_email/AuthenticatorEmailStageForm.ts b/web/src/admin/stages/authenticator_email/AuthenticatorEmailStageForm.ts new file mode 100644 index 0000000000..50a473169f --- /dev/null +++ b/web/src/admin/stages/authenticator_email/AuthenticatorEmailStageForm.ts @@ -0,0 +1,283 @@ +import { RenderFlowOption } from "@goauthentik/admin/flows/utils"; +import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { first } from "@goauthentik/common/utils"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import "@goauthentik/elements/forms/Radio"; +import "@goauthentik/elements/forms/SearchSelect"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + AuthenticatorEmailStage, + Flow, + FlowsApi, + FlowsInstancesListDesignationEnum, + FlowsInstancesListRequest, + StagesApi, +} from "@goauthentik/api"; + +@customElement("ak-stage-authenticator-email-form") +export class AuthenticatorEmailStageForm extends BaseStageForm { + async loadInstance(pk: string): Promise { + const stage = await new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorEmailRetrieve({ + stageUuid: pk, + }); + this.showConnectionSettings = !stage.useGlobalSettings; + return stage; + } + + @property({ type: Boolean }) + showConnectionSettings = false; + + async send(data: AuthenticatorEmailStage): Promise { + if (this.instance) { + return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorEmailUpdate({ + stageUuid: this.instance.pk || "", + authenticatorEmailStageRequest: data, + }); + } else { + return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorEmailCreate({ + authenticatorEmailStageRequest: data, + }); + } + } + + renderConnectionSettings(): TemplateResult { + if (!this.showConnectionSettings) { + return html``; + } + return html` + ${msg("Connection settings")} +
+ + + + + + + + + + + + + + + + + + + + + + + +

+ ${msg("Email address the verification email will be sent from.")} +

+
+
+
`; + } + + renderForm(): TemplateResult { + return html` ${msg("Stage used to configure an email-based authenticator.")} + + + + + +

+ ${msg( + "Display name of this authenticator, used by users when they enroll an authenticator.", + )} +

+
+ + +

+ ${msg( + "When enabled, global email connection settings will be used and connection settings below will be ignored.", + )} +

+
+ ${this.renderConnectionSettings()} + + ${msg("Stage-specific settings")} +
+ + +

+ ${msg("Subject of the verification email.")} +

+
+ + +

+ ${msg( + "Time the token sent is valid (Format: hours=3,minutes=17,seconds=300).", + )} +

+
+ + => { + const args: FlowsInstancesListRequest = { + ordering: "slug", + designation: + FlowsInstancesListDesignationEnum.StageConfiguration, + }; + if (query !== undefined) { + args.search = query; + } + const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList( + args, + ); + return flows.results; + }} + .renderElement=${(flow: Flow): string => { + return RenderFlowOption(flow); + }} + .renderDescription=${(flow: Flow): TemplateResult => { + return html`${flow.name}`; + }} + .value=${(flow: Flow | undefined): string | undefined => { + return flow?.pk; + }} + .selected=${(flow: Flow): boolean => { + return this.instance?.configureFlow === flow.pk; + }} + ?blankable=${true} + > + +

+ ${msg( + "Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.", + )} +

+
+
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-stage-authenticator-email-form": AuthenticatorEmailStageForm; + } +} diff --git a/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts b/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts index 079f810f4b..f7d8df8ff2 100644 --- a/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts +++ b/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts @@ -79,6 +79,7 @@ export class AuthenticatorValidateStageForm extends BaseStageForm { switch (device.type) { case "authentik_stages_authenticator_duo.DuoDevice": return api.authenticatorsAdminDuoDestroy({ id: parseInt(device.pk, 10) }); + case "authentik_stages_authenticator_email.EmailDevice": + return api.authenticatorsAdminEmailDestroy({ id: parseInt(device.pk, 10) }); case "authentik_stages_authenticator_sms.SMSDevice": return api.authenticatorsAdminSmsDestroy({ id: parseInt(device.pk, 10) }); case "authentik_stages_authenticator_totp.TOTPDevice": diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index 63efdeff18..522470ca3b 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -392,6 +392,14 @@ export class FlowExecutor extends Interface implements StageHost { .host=${this as StageHost} .challenge=${this.challenge} >`; + case "ak-stage-authenticator-email": + await import( + "@goauthentik/flow/stages/authenticator_email/AuthenticatorEmailStage" + ); + return html``; case "ak-stage-authenticator-sms": await import("@goauthentik/flow/stages/authenticator_sms/AuthenticatorSMSStage"); return html` { + static get styles(): CSSResult[] { + return [PFBase, PFAlert, PFLogin, PFForm, PFFormControl, PFTitle, PFButton]; + } + + renderEmailInput(): TemplateResult { + return html` + +
+ +
`; + } + + renderEmailOTPInput(): TemplateResult { + return html` + +
+ +
`; + } + + render(): TemplateResult { + console.debug( + "authentik/stages/authenticator_email:", + this.challenge ? this.challenge.emailRequired : undefined, + ); + + if (!this.challenge) { + console.debug( + "authentik/stages/authenticator_email: AuthenticatorEmailStage.render() called without challenge", + ); + + return html` `; + } + if (this.challenge.emailRequired) { + console.debug( + "authentik/stages/authenticator_email: AuthenticatorEmailStage.render() called with challenge", + this.challenge, + ); + + return this.renderEmailInput(); + } + console.debug( + "authentik/stages/authenticator_email: AuthenticatorEmailStage.render() called without emailRequired challenge", + this.challenge, + ); + + return this.renderEmailOTPInput(); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-stage-authenticator-email": AuthenticatorEmailStage; + } +} diff --git a/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStage.ts b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStage.ts index 3bfad7def0..79eb3febc3 100644 --- a/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStage.ts +++ b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStage.ts @@ -185,6 +185,12 @@ export class AuthenticatorValidateStage

${msg("SMS")}

${msg("Tokens sent via SMS.")} `; + case DeviceClassesEnum.Email: + return html` +
+

${msg("Email")}

+ ${msg("Tokens sent via email.")} +
`; default: break; } @@ -240,6 +246,7 @@ export class AuthenticatorValidateStage switch (this.selectedDeviceChallenge?.deviceClass) { case DeviceClassesEnum.Static: case DeviceClassesEnum.Totp: + case DeviceClassesEnum.Email: case DeviceClassesEnum.Sms: return html` { duoDeviceRequest: device, }); break; + case "authentik_stages_authenticator_email.EmailDevice": + await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsEmailUpdate({ + id: parseInt(this.instance?.pk, 10), + emailDeviceRequest: device, + }); + break; case "authentik_stages_authenticator_sms.SMSDevice": await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsSmsUpdate({ id: parseInt(this.instance?.pk, 10), diff --git a/web/src/user/user-settings/mfa/MFADevicesPage.ts b/web/src/user/user-settings/mfa/MFADevicesPage.ts index d9d76d26eb..52fc6f12f3 100644 --- a/web/src/user/user-settings/mfa/MFADevicesPage.ts +++ b/web/src/user/user-settings/mfa/MFADevicesPage.ts @@ -95,6 +95,8 @@ export class MFADevicesPage extends Table { switch (device.type) { case "authentik_stages_authenticator_duo.DuoDevice": return api.authenticatorsDuoDestroy(id); + case "authentik_stages_authenticator_email.EmailDevice": + return api.authenticatorsEmailDestroy(id); case "authentik_stages_authenticator_sms.SMSDevice": return api.authenticatorsSmsDestroy(id); case "authentik_stages_authenticator_totp.TOTPDevice":