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:
committed by
GitHub
parent
a8fd0c376f
commit
4ba360e7af
54
authentik/lib/utils/email.py
Normal file
54
authentik/lib/utils/email.py
Normal 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)}"
|
||||||
@ -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",
|
||||||
|
|||||||
0
authentik/stages/authenticator_email/__init__.py
Normal file
0
authentik/stages/authenticator_email/__init__.py
Normal file
85
authentik/stages/authenticator_email/api.py
Normal file
85
authentik/stages/authenticator_email/api.py
Normal 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"]
|
||||||
12
authentik/stages/authenticator_email/apps.py
Normal file
12
authentik/stages/authenticator_email/apps.py
Normal 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
|
||||||
132
authentik/stages/authenticator_email/migrations/0001_initial.py
Normal file
132
authentik/stages/authenticator_email/migrations/0001_initial.py
Normal 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")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
167
authentik/stages/authenticator_email/models.py
Normal file
167
authentik/stages/authenticator_email/models.py
Normal 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"),)
|
||||||
177
authentik/stages/authenticator_email/stage.py
Normal file
177
authentik/stages/authenticator_email/stage.py
Normal 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()
|
||||||
@ -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 %}
|
||||||
@ -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 %}
|
||||||
340
authentik/stages/authenticator_email/tests.py
Normal file
340
authentik/stages/authenticator_email/tests.py
Normal 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()
|
||||||
17
authentik/stages/authenticator_email/urls.py
Normal file
17
authentik/stages/authenticator_email/urls.py
Normal 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),
|
||||||
|
]
|
||||||
@ -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."""
|
||||||
|
|||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
183
authentik/stages/authenticator_validate/tests/test_email.py
Normal file
183
authentik/stages/authenticator_validate/tests/test_email.py
Normal 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."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
@ -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:
|
||||||
|
|||||||
30
blueprints/example/flows-authenticator-email-setup.yaml
Normal file
30
blueprints/example/flows-authenticator-email-setup.yaml
Normal 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
|
||||||
@ -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
1113
schema.yml
File diff suppressed because it is too large
Load Diff
@ -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";
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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`
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
Reference in New Issue
Block a user