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")}
+
+ `;
+ }
+
+ 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.",
+ )}
+
+
+
+
+ {
+ const target = ev.target as HTMLInputElement;
+ this.showConnectionSettings = !target.checked;
+ }}
+ />
+
+
+
+
+
+ ${msg("Use global connection settings")}
+
+
+ ${msg(
+ "When enabled, global email connection settings will be used and connection settings below will be ignored.",
+ )}
+
+
+ ${this.renderConnectionSettings()}
+
+ ${msg("Stage-specific settings")}
+
+ `;
+ }
+}
+
+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`
+ ${this.challenge.flowInfo?.title}
+
+
+ `;
+ }
+
+ renderEmailOTPInput(): TemplateResult {
+ return html`
+ ${this.challenge.flowInfo?.title}
+
+
+
+
+
+ A verification token has been sent to your configured email address
+ ${ifDefined(this.challenge.email)}
+
+
+ `;
+ }
+
+ 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":