stages/authenticator_email: Email OTP (#12630)

* stages/authenticator_email: Add basic structure for stages/authenticator_email

* stages/authenticator_email: Add stages/authenticator_email django app to settings.py

* stages/authenticator_email: Fix imports due changes introduced in #12598

* stages/authenticator_email: fix linting

* stages/authenticator_email: Add tests for token verification

* Add UI structure for authenticator_email

* Add autheticator_email to AuthenticatorValidateStageForm.ts and create AuthenticatorEmailStageForm.ts

* Add serializer property to emaildevice

* Add DeviceClasses.EMAIL to DeviceClasses

* Add migration file for DeviceClasses change (added email)

* Add new schema.yml and blueprints/schema.json to refelct email authenticator

* Fix UI to show the Email Authenticator

* Add support for email templates for the email authenticator

* Add templates

* Add DeviceClasses.EMAIL option to authenticator_validate/stage.py

* Fix logic for sending emails in stage.py and use the proper class AuthenticatorEmailStage in tasks.py

* Fix token expiration display in the email templates

* Fix authenticator email stage set up

* Add template and email to api response for Authenticator Email stage

* Fix  Authenticator Email stage set up form

* Use different flow if the user has an email configured or not for Authenticator Email stage UI

* Use the correct field for the token in AuthenticatorEmailStage.ts

* Fix linting and code style

* Use the correct assertions in tests

* Fix mask email helper

* Add missing cases for Email Authenticator in the UI

* Fix email sending, add _compose_email() method to EmailDevice

* Fix cosmetic changes

* Add support for email device challenge validation in validate_selected_challenge

* Fix tests

* Add from_address to email template

* Refactor tests

* Update API Schema

* Refactor AuthenticatorEmailStage UI for cleaner code

* Fix saving token_expiry in the stage configuration

* Remove debug statements

* Add email connection settings to the Email authenticator stage configuration UI

* Remove unused field activate_on_success from AuthenticatorEmailStage

* Add tests for duplicate email, token expiration and template error

* cosmetic/styling changes

* Use authentik's GroupMemberSerializer and ManagedAppConfig in api and apps for email authenticathor

* stages/authenticator_email: Fix typos, styling and unused fields

* stages/authenticator_email: remove unused field responseStatus

* stages/authenticator_email: regen migrations

* Fix linting issues

* Fix app label issue, typos, missing user field

* Add a trailing space in email_otp.txt RFC 3676 sec. 4.3

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Marcelo Elizeche Landó <marce@melizeche.com>

* Move mask_email method to a helper function in authentik.lib.utils.email

* Remove unused function

* Use authentik.stages.email.tasks instead of authentik.stages.authenticator_email.tasks, delete authentik.stages.authenticator_email.tasks

* Fix use global settings not using the global setting if there's a default

* Revert "Fix use global settings not using the global setting if there's a default"

This reverts commit 3825248bb4.

* Use user email from user attributes if exists

* Show masked email in AuthenticatorValidateStageCode

* Remove unused base.html template

* Fix linting issues

* Change token_expiry from integer to TextField, use timedelta_string_validator where necessary to process the change

* Move 'use global connection settings' up in the Email Authenticator Stage Configuration

* Show expanded connections settings when 'use global settings' is not activated for better UX

* Fix migration file, add missing validator

* Fix test for no prefilled email address

* Add tests to check session management, challenge generation and challenge response validation

* fix linting

* Add default value EmailStage for stage_class in stage.email.tasks.send_mail

* Change string representation for EmailDevice to handle authentik/events/tests/test_models.py::TestModels, add tests for the new __str__ method

* Add #nosec to skip false positive in linting validation

Signed-off-by: Marcelo Elizeche Landó <marce@melizeche.com>

* Change Email Authenticator Setup Stage name for consistency with other authenticators

* Add tests to test properties and methods of EmailDevice and AuthenticatorEmailStage, add test for email tasks

* Add tests for email challenge in authenticator_validate

* Update migration to reflect new verbose name for AuthenticatorEmailStage

* Update schema.yml to reflect new verbose name for AuthenticatorEmailStage

* Add default email subject in Email Authenticator Setup Stage configuration

* Remove from_address from email template to ensure global settings use if use global settings is on

* Add flow-default-authenticator-email-setup.yaml blueprint

* Move email authenticator blueprint to the examples folder

* Update authentik/stages/authenticator_email/models.py

Signed-off-by: Jens L. <jens@beryju.org>

* Change self.user_pk to self.user_id because user_pk doesn't exists here

* Remove unused logger import

* Remove more unused logger import

* Add error handling to authentik.lib.utils.email.mask_email

* fix linting

* don't catch Exception

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update icons

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Marcelo Elizeche Landó <marce@melizeche.com>
Signed-off-by: Jens L. <jens@beryju.org>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: Jens L. <jens@beryju.org>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Marcelo Elizeche Landó
2025-02-17 11:16:58 -03:00
committed by GitHub
parent a8fd0c376f
commit 4ba360e7af
33 changed files with 3286 additions and 18 deletions

View File

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

View File

@ -100,6 +100,7 @@ TENANT_APPS = [
"authentik.sources.scim", "authentik.sources.scim",
"authentik.stages.authenticator", "authentik.stages.authenticator",
"authentik.stages.authenticator_duo", "authentik.stages.authenticator_duo",
"authentik.stages.authenticator_email",
"authentik.stages.authenticator_sms", "authentik.stages.authenticator_sms",
"authentik.stages.authenticator_static", "authentik.stages.authenticator_static",
"authentik.stages.authenticator_totp", "authentik.stages.authenticator_totp",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,44 @@
{% extends "email/base.html" %}
{% load i18n %}
{% load humanize %}
{% block content %}
<tr>
<td align="center">
<h1>
{% blocktrans with username=user.username %}
Hi {{ username }},
{% endblocktrans %}
</h1>
</td>
</tr>
<tr>
<td align="center">
<table border="0">
<tr>
<td align="center" style="max-width: 300px; padding: 20px 0; color: #212124;">
{% blocktrans %}
Email MFA code.
{% endblocktrans %}
</td>
</tr>
<tr>
<td align="center" class="btn btn-primary">
{{ token }}
</td>
</tr>
</table>
</td>
</tr>
{% endblock %}
{% block sub_content %}
<tr>
<td style="padding: 20px; font-size: 12px; color: #212124;" align="center">
{% blocktrans with expires=expires|timeuntil %}
If you did not request this code, please ignore this email. The code above is valid for {{ expires }}.
{% endblocktrans %}
</td>
</tr>
{% endblock %}

View File

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

View File

@ -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} <test@example.com>"])
# 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()

View File

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

View File

@ -26,10 +26,13 @@ from authentik.events.middleware import audit_ignore
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.flows.stage import StageView from authentik.flows.stage import StageView
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE 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.root.middleware import ClientIPMiddleware
from authentik.stages.authenticator import match_token from authentik.stages.authenticator import match_token
from authentik.stages.authenticator.models import Device from authentik.stages.authenticator.models import Device
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice 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_sms.models import SMSDevice
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
@ -54,6 +57,8 @@ def get_challenge_for_device(
"""Generate challenge for a single device""" """Generate challenge for a single device"""
if isinstance(device, WebAuthnDevice): if isinstance(device, WebAuthnDevice):
return get_webauthn_challenge(request, stage, device) return get_webauthn_challenge(request, stage, device)
if isinstance(device, EmailDevice):
return {"email": mask_email(device.email)}
# Code-based challenges have no hints # Code-based challenges have no hints
return {} return {}
@ -103,6 +108,8 @@ def select_challenge(request: HttpRequest, device: Device):
"""Callback when the user selected a challenge in the frontend.""" """Callback when the user selected a challenge in the frontend."""
if isinstance(device, SMSDevice): if isinstance(device, SMSDevice):
select_challenge_sms(request, device) select_challenge_sms(request, device)
elif isinstance(device, EmailDevice):
select_challenge_email(request, device)
def select_challenge_sms(request: HttpRequest, device: SMSDevice): 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) 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: def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Device:
"""Validate code-based challenges. We test against every device, on purpose, as """Validate code-based challenges. We test against every device, on purpose, as
the user mustn't choose between totp and static devices.""" the user mustn't choose between totp and static devices."""

View File

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

View File

@ -20,6 +20,7 @@ class DeviceClasses(models.TextChoices):
WEBAUTHN = "webauthn", _("WebAuthn") WEBAUTHN = "webauthn", _("WebAuthn")
DUO = "duo", _("Duo") DUO = "duo", _("Duo")
SMS = "sms", _("SMS") SMS = "sms", _("SMS")
EMAIL = "email", _("Email")
def default_device_classes() -> list: def default_device_classes() -> list:
@ -30,6 +31,7 @@ def default_device_classes() -> list:
DeviceClasses.WEBAUTHN, DeviceClasses.WEBAUTHN,
DeviceClasses.DUO, DeviceClasses.DUO,
DeviceClasses.SMS, DeviceClasses.SMS,
DeviceClasses.EMAIL,
] ]

View File

@ -23,6 +23,7 @@ from authentik.flows.stage import ChallengeStageView
from authentik.lib.utils.time import timedelta_from_string from authentik.lib.utils.time import timedelta_from_string
from authentik.stages.authenticator import devices_for_user from authentik.stages.authenticator import devices_for_user
from authentik.stages.authenticator.models import Device 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_sms.models import SMSDevice
from authentik.stages.authenticator_validate.challenge import ( from authentik.stages.authenticator_validate.challenge import (
DeviceChallenge, DeviceChallenge,
@ -84,7 +85,9 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
def validate_code(self, code: str) -> str: def validate_code(self, code: str) -> str:
"""Validate code-based response, raise error if code isn't allowed""" """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()) self.device = validate_challenge_code(code, self.stage, self.stage.get_pending_user())
return code return code
@ -117,12 +120,17 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
if not allowed: if not allowed:
raise ValidationError("invalid challenge selected") raise ValidationError("invalid challenge selected")
if challenge.get("device_class", "") != "sms": device_class = challenge.get("device_class", "")
return challenge if device_class == "sms":
devices = SMSDevice.objects.filter(pk=int(challenge.get("device_uid", "0"))) devices = SMSDevice.objects.filter(pk=int(challenge.get("device_uid", "0")))
if not devices.exists(): if not devices.exists():
raise ValidationError("invalid challenge selected") raise ValidationError("invalid challenge selected")
select_challenge(self.stage.request, devices.first()) 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 return challenge
def validate_selected_stage(self, stage_pk: str) -> str: def validate_selected_stage(self, stage_pk: str) -> str:

View File

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

View File

@ -13,17 +13,28 @@ from structlog.stdlib import get_logger
from authentik.events.models import Event, EventAction, TaskStatus from authentik.events.models import Event, EventAction, TaskStatus
from authentik.events.system_tasks import SystemTask from authentik.events.system_tasks import SystemTask
from authentik.root.celery import CELERY_APP 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.models import EmailStage
from authentik.stages.email.utils import logo_data from authentik.stages.email.utils import logo_data
LOGGER = get_logger() LOGGER = get_logger()
def send_mails(stage: EmailStage, *messages: list[EmailMultiAlternatives]): def send_mails(
"""Wrapper to convert EmailMessage to dict and send it from worker""" 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 = [] tasks = []
stage_class = stage.__class__
for message in messages: 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) lazy_group = group(*tasks)
promise = lazy_group() promise = lazy_group()
return promise return promise
@ -47,23 +58,28 @@ def get_email_body(email: EmailMultiAlternatives) -> str:
retry_backoff=True, retry_backoff=True,
base=SystemTask, 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.""" """Send Email for Email Stage. Retries are scheduled automatically."""
self.save_on_success = False self.save_on_success = False
message_id = make_msgid(domain=DNS_NAME) message_id = make_msgid(domain=DNS_NAME)
self.set_uid(slugify(message_id.replace(".", "_").replace("@", "_"))) self.set_uid(slugify(message_id.replace(".", "_").replace("@", "_")))
try: try:
if not email_stage_pk: if not email_stage_pk:
stage: EmailStage = EmailStage(use_global_settings=True) stage: EmailStage | AuthenticatorEmailStage = stage_class(use_global_settings=True)
else: else:
stages = EmailStage.objects.filter(pk=email_stage_pk) stages = stage_class.objects.filter(pk=email_stage_pk)
if not stages.exists(): if not stages.exists():
self.set_status( self.set_status(
TaskStatus.WARNING, TaskStatus.WARNING,
"Email stage does not exist anymore. Discarding message.", "Email stage does not exist anymore. Discarding message.",
) )
return return
stage: EmailStage = stages.first() stage: EmailStage | AuthenticatorEmailStage = stages.first()
try: try:
backend = stage.backend backend = stage.backend
except ValueError as exc: except ValueError as exc:

View File

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

View File

@ -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", "type": "object",
"required": [ "required": [
@ -4596,6 +4676,7 @@
"authentik.sources.scim", "authentik.sources.scim",
"authentik.stages.authenticator", "authentik.stages.authenticator",
"authentik.stages.authenticator_duo", "authentik.stages.authenticator_duo",
"authentik.stages.authenticator_email",
"authentik.stages.authenticator_sms", "authentik.stages.authenticator_sms",
"authentik.stages.authenticator_static", "authentik.stages.authenticator_static",
"authentik.stages.authenticator_totp", "authentik.stages.authenticator_totp",
@ -4686,6 +4767,8 @@
"authentik_sources_scim.scimsourcepropertymapping", "authentik_sources_scim.scimsourcepropertymapping",
"authentik_stages_authenticator_duo.authenticatorduostage", "authentik_stages_authenticator_duo.authenticatorduostage",
"authentik_stages_authenticator_duo.duodevice", "authentik_stages_authenticator_duo.duodevice",
"authentik_stages_authenticator_email.authenticatoremailstage",
"authentik_stages_authenticator_email.emaildevice",
"authentik_stages_authenticator_sms.authenticatorsmsstage", "authentik_stages_authenticator_sms.authenticatorsmsstage",
"authentik_stages_authenticator_sms.smsdevice", "authentik_stages_authenticator_sms.smsdevice",
"authentik_stages_authenticator_static.authenticatorstaticstage", "authentik_stages_authenticator_static.authenticatorstaticstage",
@ -6847,6 +6930,14 @@
"authentik_stages_authenticator_duo.delete_duodevice", "authentik_stages_authenticator_duo.delete_duodevice",
"authentik_stages_authenticator_duo.view_authenticatorduostage", "authentik_stages_authenticator_duo.view_authenticatorduostage",
"authentik_stages_authenticator_duo.view_duodevice", "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_authenticatorendpointgdtcstage",
"authentik_stages_authenticator_endpoint_gdtc.add_endpointdevice", "authentik_stages_authenticator_endpoint_gdtc.add_endpointdevice",
"authentik_stages_authenticator_endpoint_gdtc.add_endpointdeviceconnection", "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": { "model_authentik_stages_authenticator_sms.authenticatorsmsstage": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -9661,7 +9985,8 @@
"totp", "totp",
"webauthn", "webauthn",
"duo", "duo",
"sms" "sms",
"email"
], ],
"title": "Device classes" "title": "Device classes"
}, },
@ -13108,6 +13433,14 @@
"authentik_stages_authenticator_duo.delete_duodevice", "authentik_stages_authenticator_duo.delete_duodevice",
"authentik_stages_authenticator_duo.view_authenticatorduostage", "authentik_stages_authenticator_duo.view_authenticatorduostage",
"authentik_stages_authenticator_duo.view_duodevice", "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_authenticatorendpointgdtcstage",
"authentik_stages_authenticator_endpoint_gdtc.add_endpointdevice", "authentik_stages_authenticator_endpoint_gdtc.add_endpointdevice",
"authentik_stages_authenticator_endpoint_gdtc.add_endpointdeviceconnection", "authentik_stages_authenticator_endpoint_gdtc.add_endpointdeviceconnection",

1113
schema.yml

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@ import "@goauthentik/admin/rbac/ObjectPermissionModal";
import "@goauthentik/admin/stages/StageWizard"; import "@goauthentik/admin/stages/StageWizard";
import "@goauthentik/admin/stages/authenticator_duo/AuthenticatorDuoStageForm"; import "@goauthentik/admin/stages/authenticator_duo/AuthenticatorDuoStageForm";
import "@goauthentik/admin/stages/authenticator_duo/DuoDeviceImportForm"; 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_endpoint_gdtc/AuthenticatorEndpointGDTCStageForm";
import "@goauthentik/admin/stages/authenticator_sms/AuthenticatorSMSStageForm"; import "@goauthentik/admin/stages/authenticator_sms/AuthenticatorSMSStageForm";
import "@goauthentik/admin/stages/authenticator_static/AuthenticatorStaticStageForm"; import "@goauthentik/admin/stages/authenticator_static/AuthenticatorStaticStageForm";

View File

@ -1,6 +1,7 @@
import "@goauthentik/admin/common/ak-license-notice"; import "@goauthentik/admin/common/ak-license-notice";
import { StageBindingForm } from "@goauthentik/admin/flows/StageBindingForm"; import { StageBindingForm } from "@goauthentik/admin/flows/StageBindingForm";
import "@goauthentik/admin/stages/authenticator_duo/AuthenticatorDuoStageForm"; 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_sms/AuthenticatorSMSStageForm";
import "@goauthentik/admin/stages/authenticator_static/AuthenticatorStaticStageForm"; import "@goauthentik/admin/stages/authenticator_static/AuthenticatorStaticStageForm";
import "@goauthentik/admin/stages/authenticator_totp/AuthenticatorTOTPStageForm"; import "@goauthentik/admin/stages/authenticator_totp/AuthenticatorTOTPStageForm";

View File

@ -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<AuthenticatorEmailStage> {
async loadInstance(pk: string): Promise<AuthenticatorEmailStage> {
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<AuthenticatorEmailStage> {
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`<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("Connection settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal label=${msg("SMTP Host")} ?required=${true} name="host">
<input
type="text"
value="${ifDefined(this.instance?.host || "")}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("SMTP Port")} ?required=${true} name="port">
<input
type="number"
value="${first(this.instance?.port, 25)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("SMTP Username")} name="username">
<input
type="text"
value="${ifDefined(this.instance?.username || "")}"
class="pf-c-form-control"
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("SMTP Password")}
?writeOnly=${this.instance !== undefined}
name="password"
>
<input type="text" value="" class="pf-c-form-control" />
</ak-form-element-horizontal>
<ak-form-element-horizontal name="useTls">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.useTls, true)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Use TLS")}</span>
</label>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="useSsl">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.useSsl, false)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Use SSL")}</span>
</label>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Timeout")}
?required=${true}
name="timeout"
>
<input
type="number"
value="${first(this.instance?.timeout, 30)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("From address")}
?required=${true}
name="fromAddress"
>
<input
type="text"
value="${ifDefined(this.instance?.fromAddress || "system@authentik.local")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg("Email address the verification email will be sent from.")}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>`;
}
renderForm(): TemplateResult {
return html` <span> ${msg("Stage used to configure an email-based authenticator.")} </span>
<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
<input
type="text"
value="${first(this.instance?.name, "")}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Authenticator type name")}
?required=${false}
name="friendlyName"
>
<input
type="text"
value="${first(this.instance?.friendlyName, "")}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${msg(
"Display name of this authenticator, used by users when they enroll an authenticator.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="useGlobalSettings">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.useGlobalSettings, true)}
@change=${(ev: Event) => {
const target = ev.target as HTMLInputElement;
this.showConnectionSettings = !target.checked;
}}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Use global connection settings")}</span>
</label>
<p class="pf-c-form__helper-text">
${msg(
"When enabled, global email connection settings will be used and connection settings below will be ignored.",
)}
</p>
</ak-form-element-horizontal>
${this.renderConnectionSettings()}
<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("Stage-specific settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Subject")}
?required=${true}
name="subject"
>
<input
type="text"
value="${first(this.instance?.subject, "authentik Sign-in code")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg("Subject of the verification email.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Token expiration")}
?required=${true}
name="tokenExpiry"
>
<input
type="text"
value="${first(this.instance?.tokenExpiry, "minutes=15")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg(
"Time the token sent is valid (Format: hours=3,minutes=17,seconds=300).",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Configuration flow")}
name="configureFlow"
>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Flow[]> => {
const args: FlowsInstancesListRequest = {
ordering: "slug",
designation:
FlowsInstancesListDesignationEnum.StageConfiguration,
};
if (query !== undefined) {
args.search = query;
}
const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(
args,
);
return flows.results;
}}
.renderElement=${(flow: Flow): string => {
return RenderFlowOption(flow);
}}
.renderDescription=${(flow: Flow): TemplateResult => {
return html`${flow.name}`;
}}
.value=${(flow: Flow | undefined): string | undefined => {
return flow?.pk;
}}
.selected=${(flow: Flow): boolean => {
return this.instance?.configureFlow === flow.pk;
}}
?blankable=${true}
>
</ak-search-select>
<p class="pf-c-form__helper-text">
${msg(
"Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.",
)}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-stage-authenticator-email-form": AuthenticatorEmailStageForm;
}
}

View File

@ -79,6 +79,7 @@ export class AuthenticatorValidateStageForm extends BaseStageForm<AuthenticatorV
[DeviceClassesEnum.Webauthn, msg("WebAuthn Authenticators")], [DeviceClassesEnum.Webauthn, msg("WebAuthn Authenticators")],
[DeviceClassesEnum.Duo, msg("Duo Authenticators")], [DeviceClassesEnum.Duo, msg("Duo Authenticators")],
[DeviceClassesEnum.Sms, msg("SMS-based Authenticators")], [DeviceClassesEnum.Sms, msg("SMS-based Authenticators")],
[DeviceClassesEnum.Email, msg("Email-based Authenticators")],
]; ];
return html` return html`

View File

@ -58,6 +58,8 @@ export class UserDeviceTable extends Table<Device> {
switch (device.type) { switch (device.type) {
case "authentik_stages_authenticator_duo.DuoDevice": case "authentik_stages_authenticator_duo.DuoDevice":
return api.authenticatorsAdminDuoDestroy({ id: parseInt(device.pk, 10) }); 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": case "authentik_stages_authenticator_sms.SMSDevice":
return api.authenticatorsAdminSmsDestroy({ id: parseInt(device.pk, 10) }); return api.authenticatorsAdminSmsDestroy({ id: parseInt(device.pk, 10) });
case "authentik_stages_authenticator_totp.TOTPDevice": case "authentik_stages_authenticator_totp.TOTPDevice":

View File

@ -392,6 +392,14 @@ export class FlowExecutor extends Interface implements StageHost {
.host=${this as StageHost} .host=${this as StageHost}
.challenge=${this.challenge} .challenge=${this.challenge}
></ak-stage-authenticator-webauthn>`; ></ak-stage-authenticator-webauthn>`;
case "ak-stage-authenticator-email":
await import(
"@goauthentik/flow/stages/authenticator_email/AuthenticatorEmailStage"
);
return html`<ak-stage-authenticator-email
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-authenticator-email>`;
case "ak-stage-authenticator-sms": case "ak-stage-authenticator-sms":
await import("@goauthentik/flow/stages/authenticator_sms/AuthenticatorSMSStage"); await import("@goauthentik/flow/stages/authenticator_sms/AuthenticatorSMSStage");
return html`<ak-stage-authenticator-sms return html`<ak-stage-authenticator-sms

View File

@ -0,0 +1,173 @@
import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/forms/FormElement";
import "@goauthentik/flow/FormStatic";
import { BaseStage } from "@goauthentik/flow/stages/base";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
AuthenticatorEmailChallenge,
AuthenticatorEmailChallengeResponseRequest,
} from "@goauthentik/api";
@customElement("ak-stage-authenticator-email")
export class AuthenticatorEmailStage extends BaseStage<
AuthenticatorEmailChallenge,
AuthenticatorEmailChallengeResponseRequest
> {
static get styles(): CSSResult[] {
return [PFBase, PFAlert, PFLogin, PFForm, PFFormControl, PFTitle, PFButton];
}
renderEmailInput(): TemplateResult {
return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
</header>
<div class="pf-c-login__main-body">
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
<ak-form-element
label="${msg("Configure your email")}"
required
class="pf-c-form__group"
.errors=${(this.challenge?.responseErrors || {})["email"]}
>
<input
type="email"
name="email"
placeholder="${msg("Please enter your email address.")}"
autofocus=""
autocomplete="email"
class="pf-c-form-control"
required
/>
</ak-form-element>
${this.renderNonFieldErrors()}
<div class="pf-c-form__group pf-m-action">
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
${msg("Continue")}
</button>
</div>
</form>
</div>
<footer class="pf-c-login__main-footer">
<ul class="pf-c-login__main-footer-links"></ul>
</footer>`;
}
renderEmailOTPInput(): TemplateResult {
return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
</header>
<div class="pf-c-login__main-body">
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
A verification token has been sent to your configured email address
${ifDefined(this.challenge.email)}
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
<ak-form-element
label="${msg("Code")}"
required
class="pf-c-form__group"
.errors=${(this.challenge?.responseErrors || {})["code"]}
>
<input
type="text"
name="code"
inputmode="numeric"
pattern="[0-9]*"
placeholder="${msg("Please enter the code you received via email")}"
autofocus=""
autocomplete="one-time-code"
class="pf-c-form-control"
required
/>
</ak-form-element>
${this.renderNonFieldErrors()}
<div class="pf-c-form__group pf-m-action">
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
${msg("Continue")}
</button>
</div>
</form>
</div>
<footer class="pf-c-login__main-footer">
<ul class="pf-c-login__main-footer-links"></ul>
</footer>`;
}
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`<ak-empty-state loading> </ak-empty-state>`;
}
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;
}
}

View File

@ -185,6 +185,12 @@ export class AuthenticatorValidateStage
<p>${msg("SMS")}</p> <p>${msg("SMS")}</p>
<small>${msg("Tokens sent via SMS.")}</small> <small>${msg("Tokens sent via SMS.")}</small>
</div>`; </div>`;
case DeviceClassesEnum.Email:
return html`<i class="fas fa-envelope-o"></i>
<div class="right">
<p>${msg("Email")}</p>
<small>${msg("Tokens sent via email.")}</small>
</div>`;
default: default:
break; break;
} }
@ -240,6 +246,7 @@ export class AuthenticatorValidateStage
switch (this.selectedDeviceChallenge?.deviceClass) { switch (this.selectedDeviceChallenge?.deviceClass) {
case DeviceClassesEnum.Static: case DeviceClassesEnum.Static:
case DeviceClassesEnum.Totp: case DeviceClassesEnum.Totp:
case DeviceClassesEnum.Email:
case DeviceClassesEnum.Sms: case DeviceClassesEnum.Sms:
return html` <ak-stage-authenticator-validate-code return html` <ak-stage-authenticator-validate-code
.host=${this} .host=${this}

View File

@ -33,6 +33,10 @@ export class AuthenticatorValidateStageWebCode extends BaseDeviceStage<
deviceMessage(): string { deviceMessage(): string {
switch (this.deviceChallenge?.deviceClass) { switch (this.deviceChallenge?.deviceClass) {
case DeviceClassesEnum.Email: {
const email = this.deviceChallenge.challenge?.email;
return msg(`A code has been sent to you via email${email ? ` ${email}` : ""}`);
}
case DeviceClassesEnum.Sms: case DeviceClassesEnum.Sms:
return msg("A code has been sent to you via SMS."); return msg("A code has been sent to you via SMS.");
case DeviceClassesEnum.Totp: case DeviceClassesEnum.Totp:
@ -48,12 +52,14 @@ export class AuthenticatorValidateStageWebCode extends BaseDeviceStage<
deviceIcon(): string { deviceIcon(): string {
switch (this.deviceChallenge?.deviceClass) { switch (this.deviceChallenge?.deviceClass) {
case DeviceClassesEnum.Email:
return "fa-envelope-o";
case DeviceClassesEnum.Sms: case DeviceClassesEnum.Sms:
return "fa-key";
case DeviceClassesEnum.Totp:
return "fa-mobile-alt"; return "fa-mobile-alt";
case DeviceClassesEnum.Totp:
return "fa-clock";
case DeviceClassesEnum.Static: case DeviceClassesEnum.Static:
return "fa-sticky-note"; return "fa-key";
} }
return "fa-mobile-alt"; return "fa-mobile-alt";

View File

@ -34,6 +34,12 @@ export class MFADeviceForm extends ModelForm<Device, string> {
duoDeviceRequest: device, duoDeviceRequest: device,
}); });
break; 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": case "authentik_stages_authenticator_sms.SMSDevice":
await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsSmsUpdate({ await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsSmsUpdate({
id: parseInt(this.instance?.pk, 10), id: parseInt(this.instance?.pk, 10),

View File

@ -95,6 +95,8 @@ export class MFADevicesPage extends Table<Device> {
switch (device.type) { switch (device.type) {
case "authentik_stages_authenticator_duo.DuoDevice": case "authentik_stages_authenticator_duo.DuoDevice":
return api.authenticatorsDuoDestroy(id); return api.authenticatorsDuoDestroy(id);
case "authentik_stages_authenticator_email.EmailDevice":
return api.authenticatorsEmailDestroy(id);
case "authentik_stages_authenticator_sms.SMSDevice": case "authentik_stages_authenticator_sms.SMSDevice":
return api.authenticatorsSmsDestroy(id); return api.authenticatorsSmsDestroy(id);
case "authentik_stages_authenticator_totp.TOTPDevice": case "authentik_stages_authenticator_totp.TOTPDevice":