Skip to content

Commit 4ba360e

Browse files
melizecherisssonBeryJu
authored
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 <[email protected]> Signed-off-by: Marcelo Elizeche Landó <[email protected]> * 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 3825248. * 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ó <[email protected]> * 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. <[email protected]> * 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 <[email protected]> * update icons Signed-off-by: Jens Langhammer <[email protected]> --------- Signed-off-by: Marcelo Elizeche Landó <[email protected]> Signed-off-by: Jens L. <[email protected]> Signed-off-by: Jens Langhammer <[email protected]> Co-authored-by: Marc 'risson' Schmitt <[email protected]> Co-authored-by: Jens L. <[email protected]> Co-authored-by: Jens Langhammer <[email protected]>
1 parent a8fd0c3 commit 4ba360e

33 files changed

+3286
-18
lines changed

authentik/lib/utils/email.py

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Email utility functions"""
2+
3+
4+
def mask_email(email: str | None) -> str | None:
5+
"""Mask email address for privacy
6+
7+
Args:
8+
email: Email address to mask
9+
Returns:
10+
Masked email address or None if input is None
11+
Example:
12+
mask_email("[email protected]")
13+
'm*****@c******.org'
14+
"""
15+
if not email:
16+
return None
17+
18+
# Basic email format validation
19+
if email.count("@") != 1:
20+
raise ValueError("Invalid email format: Must contain exactly one '@' symbol")
21+
22+
local, domain = email.split("@")
23+
if not local or not domain:
24+
raise ValueError("Invalid email format: Local and domain parts cannot be empty")
25+
26+
domain_parts = domain.split(".")
27+
if len(domain_parts) < 2: # noqa: PLR2004
28+
raise ValueError("Invalid email format: Domain must contain at least one dot")
29+
30+
limit = 2
31+
32+
# Mask local part (keep first char)
33+
if len(local) <= limit:
34+
masked_local = "*" * len(local)
35+
else:
36+
masked_local = local[0] + "*" * (len(local) - 1)
37+
38+
# Mask each domain part except the last one (TLD)
39+
masked_domain_parts = []
40+
for _i, part in enumerate(domain_parts[:-1]): # Process all parts except TLD
41+
if not part: # Check for empty parts (consecutive dots)
42+
raise ValueError("Invalid email format: Domain parts cannot be empty")
43+
if len(part) <= limit:
44+
masked_part = "*" * len(part)
45+
else:
46+
masked_part = part[0] + "*" * (len(part) - 1)
47+
masked_domain_parts.append(masked_part)
48+
49+
# Add TLD unchanged
50+
if not domain_parts[-1]: # Check if TLD is empty
51+
raise ValueError("Invalid email format: TLD cannot be empty")
52+
masked_domain_parts.append(domain_parts[-1])
53+
54+
return f"{masked_local}@{'.'.join(masked_domain_parts)}"

authentik/root/settings.py

+1
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
"authentik.sources.scim",
101101
"authentik.stages.authenticator",
102102
"authentik.stages.authenticator_duo",
103+
"authentik.stages.authenticator_email",
103104
"authentik.stages.authenticator_sms",
104105
"authentik.stages.authenticator_static",
105106
"authentik.stages.authenticator_totp",

authentik/stages/authenticator_email/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""AuthenticatorEmailStage API Views"""
2+
3+
from rest_framework import mixins
4+
from rest_framework.viewsets import GenericViewSet, ModelViewSet
5+
6+
from authentik.core.api.groups import GroupMemberSerializer
7+
from authentik.core.api.used_by import UsedByMixin
8+
from authentik.core.api.utils import ModelSerializer
9+
from authentik.flows.api.stages import StageSerializer
10+
from authentik.stages.authenticator_email.models import AuthenticatorEmailStage, EmailDevice
11+
12+
13+
class AuthenticatorEmailStageSerializer(StageSerializer):
14+
"""AuthenticatorEmailStage Serializer"""
15+
16+
class Meta:
17+
model = AuthenticatorEmailStage
18+
fields = StageSerializer.Meta.fields + [
19+
"configure_flow",
20+
"friendly_name",
21+
"use_global_settings",
22+
"host",
23+
"port",
24+
"username",
25+
"password",
26+
"use_tls",
27+
"use_ssl",
28+
"timeout",
29+
"from_address",
30+
"subject",
31+
"token_expiry",
32+
"template",
33+
]
34+
35+
36+
class AuthenticatorEmailStageViewSet(UsedByMixin, ModelViewSet):
37+
"""AuthenticatorEmailStage Viewset"""
38+
39+
queryset = AuthenticatorEmailStage.objects.all()
40+
serializer_class = AuthenticatorEmailStageSerializer
41+
filterset_fields = "__all__"
42+
ordering = ["name"]
43+
search_fields = ["name"]
44+
45+
46+
class EmailDeviceSerializer(ModelSerializer):
47+
"""Serializer for email authenticator devices"""
48+
49+
user = GroupMemberSerializer(read_only=True)
50+
51+
class Meta:
52+
model = EmailDevice
53+
fields = ["name", "pk", "email", "user"]
54+
depth = 2
55+
extra_kwargs = {
56+
"email": {"read_only": True},
57+
}
58+
59+
60+
class EmailDeviceViewSet(
61+
mixins.RetrieveModelMixin,
62+
mixins.UpdateModelMixin,
63+
mixins.DestroyModelMixin,
64+
UsedByMixin,
65+
mixins.ListModelMixin,
66+
GenericViewSet,
67+
):
68+
"""Viewset for email authenticator devices"""
69+
70+
queryset = EmailDevice.objects.all()
71+
serializer_class = EmailDeviceSerializer
72+
search_fields = ["name"]
73+
filterset_fields = ["name"]
74+
ordering = ["name"]
75+
owner_field = "user"
76+
77+
78+
class EmailAdminDeviceViewSet(ModelViewSet):
79+
"""Viewset for email authenticator devices (for admins)"""
80+
81+
queryset = EmailDevice.objects.all()
82+
serializer_class = EmailDeviceSerializer
83+
search_fields = ["name"]
84+
filterset_fields = ["name"]
85+
ordering = ["name"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Email Authenticator"""
2+
3+
from authentik.blueprints.apps import ManagedAppConfig
4+
5+
6+
class AuthentikStageAuthenticatorEmailConfig(ManagedAppConfig):
7+
"""Email Authenticator App config"""
8+
9+
name = "authentik.stages.authenticator_email"
10+
label = "authentik_stages_authenticator_email"
11+
verbose_name = "authentik Stages.Authenticator.Email"
12+
default = True
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Generated by Django 5.0.10 on 2025-01-27 20:05
2+
3+
import django.db.models.deletion
4+
import django.utils.timezone
5+
from django.conf import settings
6+
from django.db import migrations, models
7+
8+
import authentik.lib.utils.time
9+
10+
11+
class Migration(migrations.Migration):
12+
13+
initial = True
14+
15+
dependencies = [
16+
("authentik_flows", "0027_auto_20231028_1424"),
17+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
18+
]
19+
20+
operations = [
21+
migrations.CreateModel(
22+
name="AuthenticatorEmailStage",
23+
fields=[
24+
(
25+
"stage_ptr",
26+
models.OneToOneField(
27+
auto_created=True,
28+
on_delete=django.db.models.deletion.CASCADE,
29+
parent_link=True,
30+
primary_key=True,
31+
serialize=False,
32+
to="authentik_flows.stage",
33+
),
34+
),
35+
("friendly_name", models.TextField(null=True)),
36+
(
37+
"use_global_settings",
38+
models.BooleanField(
39+
default=False,
40+
help_text="When enabled, global Email connection settings will be used and connection settings below will be ignored.",
41+
),
42+
),
43+
("host", models.TextField(default="localhost")),
44+
("port", models.IntegerField(default=25)),
45+
("username", models.TextField(blank=True, default="")),
46+
("password", models.TextField(blank=True, default="")),
47+
("use_tls", models.BooleanField(default=False)),
48+
("use_ssl", models.BooleanField(default=False)),
49+
("timeout", models.IntegerField(default=10)),
50+
(
51+
"from_address",
52+
models.EmailField(default="[email protected]", max_length=254),
53+
),
54+
(
55+
"token_expiry",
56+
models.TextField(
57+
default="minutes=30",
58+
help_text="Time the token sent is valid (Format: hours=3,minutes=17,seconds=300).",
59+
validators=[authentik.lib.utils.time.timedelta_string_validator],
60+
),
61+
),
62+
("subject", models.TextField(default="authentik Sign-in code")),
63+
("template", models.TextField(default="email/email_otp.html")),
64+
(
65+
"configure_flow",
66+
models.ForeignKey(
67+
blank=True,
68+
help_text="Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.",
69+
null=True,
70+
on_delete=django.db.models.deletion.SET_NULL,
71+
to="authentik_flows.flow",
72+
),
73+
),
74+
],
75+
options={
76+
"verbose_name": "Email Authenticator Setup Stage",
77+
"verbose_name_plural": "Email Authenticator Setup Stages",
78+
},
79+
bases=("authentik_flows.stage", models.Model),
80+
),
81+
migrations.CreateModel(
82+
name="EmailDevice",
83+
fields=[
84+
(
85+
"id",
86+
models.AutoField(
87+
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
88+
),
89+
),
90+
("created", models.DateTimeField(auto_now_add=True)),
91+
("last_updated", models.DateTimeField(auto_now=True)),
92+
(
93+
"name",
94+
models.CharField(
95+
help_text="The human-readable name of this device.", max_length=64
96+
),
97+
),
98+
(
99+
"confirmed",
100+
models.BooleanField(default=True, help_text="Is this device ready for use?"),
101+
),
102+
("token", models.CharField(blank=True, max_length=16, null=True)),
103+
(
104+
"valid_until",
105+
models.DateTimeField(
106+
default=django.utils.timezone.now,
107+
help_text="The timestamp of the moment of expiry of the saved token.",
108+
),
109+
),
110+
("email", models.EmailField(max_length=254)),
111+
("last_used", models.DateTimeField(auto_now=True)),
112+
(
113+
"stage",
114+
models.ForeignKey(
115+
on_delete=django.db.models.deletion.CASCADE,
116+
to="authentik_stages_authenticator_email.authenticatoremailstage",
117+
),
118+
),
119+
(
120+
"user",
121+
models.ForeignKey(
122+
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
123+
),
124+
),
125+
],
126+
options={
127+
"verbose_name": "Email Device",
128+
"verbose_name_plural": "Email Devices",
129+
"unique_together": {("user", "email")},
130+
},
131+
),
132+
]

authentik/stages/authenticator_email/migrations/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)