root: global email settings (#448)

* root: make global email settings configurable

* stages/email: add use_global_settings

* stages/email: add test_email command to test email sending

* stages/email: update email template

* stages/email: simplify email template path

* stages/email: add support for user-supplied email templates

* stages/email: add tests for sending and templates

* stages/email: only add custom template if permissions are correct

* docs: add custom email template docs

* root: add /templates volume in docker-compose by default

* stages/email: fix form not allowing custom templates

* stages/email: use relative path for custom templates

* stages/email: check if all templates exist on startup, reset

* docs: add global email docs for docker-compose

* helm: add email config to helm chart

* helm: load all secrets with env prefix

* helm: move s3 and smtp secret to secret

* stages/email: fix test for relative name

* stages/email: add argument to send email from existing stage

* stages/email: set uid using slug of message id

* stages/email: ensure template validation ignores migration runs

* docs: add email troubleshooting docs

* stages/email: fix long task_name breaking task list
This commit is contained in:
Jens L 2021-01-05 00:41:10 +01:00 committed by GitHub
parent 774eb0388b
commit 82bb179bc2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 791 additions and 423 deletions

View File

@ -38,7 +38,7 @@
{% for task in object_list %}
<tr role="row">
<th role="columnheader">
<pre>{{ task.task_name }}</pre>
<span>{{ task.html_name|join:"_&shy;" }}</span>
</th>
<td role="cell">
<span>

View File

@ -21,6 +21,17 @@ error_reporting:
environment: customer
send_pii: false
# Global email settings
email:
host: localhost
port: 25
username: ""
password: ""
use_tls: false
use_ssl: false
timeout: 10
from: authentik@localhost
outposts:
docker_image_base: "beryju/authentik" # this is prepended to -proxy:version

View File

@ -52,6 +52,11 @@ class TaskInfo:
task_description: Optional[str] = field(default=None)
@property
def html_name(self) -> list[str]:
"""Get task_name, but split on underscores, so we can join in the html template."""
return self.task_name.split("_")
@staticmethod
def all() -> Dict[str, "TaskInfo"]:
"""Get all TaskInfo objects"""

View File

@ -196,7 +196,7 @@ ROOT_URLCONF = "authentik.root.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"DIRS": ["/templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
@ -238,6 +238,18 @@ DATABASES = {
}
}
# Email
EMAIL_HOST = CONFIG.y("email.host")
EMAIL_PORT = int(CONFIG.y("email.port"))
EMAIL_HOST_USER = CONFIG.y("email.username")
EMAIL_HOST_PASSWORD = CONFIG.y("email.password")
EMAIL_USE_TLS = CONFIG.y("email.use_tls")
EMAIL_USE_SSL = CONFIG.y("email.use_ssl")
EMAIL_TIMEOUT = int(CONFIG.y("email.timeout"))
DEFAULT_FROM_EMAIL = CONFIG.y("email.from")
SERVER_EMAIL = DEFAULT_FROM_EMAIL
EMAIL_SUBJECT_PREFIX = "[authentik] "
# Password validation
# https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators

View File

@ -2,18 +2,23 @@
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.stages.email.models import EmailStage
from authentik.stages.email.models import EmailStage, get_template_choices
class EmailStageSerializer(ModelSerializer):
"""EmailStage Serializer"""
def __init__(self, *args, **kwrags):
super().__init__(*args, **kwrags)
self.fields["template"].choices = get_template_choices()
class Meta:
model = EmailStage
fields = [
"pk",
"name",
"use_global_settings",
"host",
"port",
"username",

View File

@ -2,6 +2,12 @@
from importlib import import_module
from django.apps import AppConfig
from django.db import ProgrammingError
from django.template.exceptions import TemplateDoesNotExist
from django.template.loader import get_template
from structlog.stdlib import get_logger
LOGGER = get_logger()
class AuthentikStageEmailConfig(AppConfig):
@ -13,3 +19,30 @@ class AuthentikStageEmailConfig(AppConfig):
def ready(self):
import_module("authentik.stages.email.tasks")
try:
self.validate_stage_templates()
except ProgrammingError:
pass
def validate_stage_templates(self):
"""Ensure all stage's templates actually exist"""
from authentik.stages.email.models import EmailStage, EmailTemplates
from authentik.events.models import Event, EventAction
for stage in EmailStage.objects.all():
try:
get_template(stage.template)
except TemplateDoesNotExist:
LOGGER.warning(
"Stage template does not exist, resetting", path=stage.template
)
Event.new(
EventAction.CONFIGURATION_ERROR,
stage=stage,
message=(
f"Template {stage.template} does not exist, resetting to default."
f" (Stage {stage.name})"
),
).save()
stage.template = EmailTemplates.ACCOUNT_CONFIRM
stage.save()

View File

@ -2,7 +2,7 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from authentik.stages.email.models import EmailStage
from authentik.stages.email.models import EmailStage, get_template_choices
class EmailStageSendForm(forms.Form):
@ -14,11 +14,17 @@ class EmailStageSendForm(forms.Form):
class EmailStageForm(forms.ModelForm):
"""Form to create/edit Email Stage"""
template = forms.ChoiceField(choices=get_template_choices)
class Meta:
model = EmailStage
fields = [
"name",
"use_global_settings",
"token_expiry",
"subject",
"template",
"host",
"port",
"username",
@ -27,9 +33,6 @@ class EmailStageForm(forms.ModelForm):
"use_ssl",
"timeout",
"from_address",
"token_expiry",
"subject",
"template",
]
widgets = {
"name": forms.TextInput(),

View File

@ -0,0 +1,42 @@
"""Send a test-email with global settings"""
from uuid import uuid4
from django.core.management.base import BaseCommand, no_translations
from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mail
from authentik.stages.email.utils import TemplateEmailMessage
class Command(BaseCommand): # pragma: no cover
"""Send a test-email with global settings"""
@no_translations
def handle(self, *args, **options):
"""Send a test-email with global settings"""
delete_stage = False
if options["stage"]:
stage = EmailStage.objects.get(name=options["stage"])
else:
stage = EmailStage.objects.create(
name=f"temp-global-stage-{uuid4()}", use_global_settings=True
)
delete_stage = True
message = TemplateEmailMessage(
subject="authentik Test-Email",
template_name="email/setup.html",
to=[options["to"]],
template_context={},
)
try:
# pyright: reportGeneralTypeIssues=false
send_mail( # pylint: disable=no-value-for-parameter
stage.pk, message.__dict__
)
finally:
if delete_stage:
stage.delete()
def add_arguments(self, parser):
parser.add_argument("to", type=str)
parser.add_argument("-s", "--stage", type=str)

View File

@ -50,15 +50,15 @@ class Migration(migrations.Migration):
models.TextField(
choices=[
(
"stages/email/for_email/password_reset.html",
"email/password_reset.html",
"Password Reset",
),
(
"stages/email/for_email/account_confirmation.html",
"email/account_confirmation.html",
"Account Confirmation",
),
],
default="stages/email/for_email/password_reset.html",
default="email/password_reset.html",
),
),
],

View File

@ -0,0 +1,33 @@
# Generated by Django 3.1.4 on 2021-01-04 13:15
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def update_template_path(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
EmailStage = apps.get_model("authentik_stages_email", "EmailStage")
db_alias = schema_editor.connection.alias
for stage in EmailStage.objects.using(db_alias).all():
stage.template = stage.template.replace("stages/email/for_email/", "email/")
stage.save()
class Migration(migrations.Migration):
dependencies = [
("authentik_stages_email", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="emailstage",
name="use_global_settings",
field=models.BooleanField(
default=False,
help_text="When enabled, global Email connection settings will be used and connection settings below will be ignored.",
),
),
migrations.RunPython(update_template_path),
]

View File

@ -1,6 +1,9 @@
"""email stage models"""
from os import R_OK, access
from pathlib import Path
from typing import Type
from django.conf import settings
from django.core.mail import get_connection
from django.core.mail.backends.base import BaseEmailBackend
from django.db import models
@ -8,26 +11,60 @@ from django.forms import ModelForm
from django.utils.translation import gettext as _
from django.views import View
from rest_framework.serializers import BaseSerializer
from structlog.stdlib import get_logger
from authentik.flows.models import Stage
LOGGER = get_logger()
class EmailTemplates(models.TextChoices):
"""Templates used for rendering the Email"""
PASSWORD_RESET = (
"stages/email/for_email/password_reset.html",
"email/password_reset.html",
_("Password Reset"),
) # nosec
ACCOUNT_CONFIRM = (
"stages/email/for_email/account_confirmation.html",
"email/account_confirmation.html",
_("Account Confirmation"),
)
def get_template_choices():
"""Get all available Email templates, including dynamically mounted ones.
Directories are taken from TEMPLATES.DIR setting"""
static_choices = EmailTemplates.choices
dirs = [Path(x) for x in settings.TEMPLATES[0]["DIRS"]]
for template_dir in dirs:
if not template_dir.exists():
continue
for template in template_dir.glob("**/*.html"):
path = str(template)
if not access(path, R_OK):
LOGGER.warning(
"Custom template file is not readable, check permissions", path=path
)
continue
rel_path = template.relative_to(template_dir)
static_choices.append((str(rel_path), f"Custom Template: {rel_path}"))
return static_choices
class EmailStage(Stage):
"""Sends an Email to the user with a token to confirm their Email address."""
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)
@ -42,7 +79,7 @@ class EmailStage(Stage):
)
subject = models.TextField(default="authentik")
template = models.TextField(
choices=EmailTemplates.choices, default=EmailTemplates.PASSWORD_RESET
choices=get_template_choices(), default=EmailTemplates.PASSWORD_RESET
)
@property
@ -65,7 +102,9 @@ class EmailStage(Stage):
@property
def backend(self) -> BaseEmailBackend:
"""Get fully configured EMail Backend instance"""
"""Get fully configured Email Backend instance"""
if self.use_global_settings:
return get_connection()
return get_connection(
host=self.host,
port=self.port,

View File

@ -1,186 +1,162 @@
/* -------------------------------------
GLOBAL RESETS
GLOBAL
A very basic CSS reset
------------------------------------- */
/*All the styling goes here*/
* {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
box-sizing: border-box;
font-size: 14px;
}
img {
border: none;
-ms-interpolation-mode: bicubic;
max-width: 100%;
}
body {
background-color: #fafafa;
font-family: sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
line-height: 1.4;
margin: 0;
padding: 0;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
-webkit-text-size-adjust: none;
width: 100% !important;
height: 100%;
line-height: 1.6em;
/* 1.6em * 14px = 22.4px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */
/*line-height: 22px;*/
}
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%; }
/* Let's make sure all tables have defaults */
table td {
font-family: sans-serif;
font-size: 14px;
vertical-align: top;
}
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
body {
background-color: #f6f6f6;
}
.body {
background-color: #fafafa;
.body-wrap {
background-color: #f6f6f6;
width: 100%;
}
/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
.container {
display: block;
display: block !important;
max-width: 600px !important;
margin: 0 auto !important;
/* makes it centered */
max-width: 580px;
padding: 10px;
width: 580px;
clear: both !important;
}
/* This should also be a block element, so that it will fill 100% of the .container */
.content {
box-sizing: border-box;
display: block;
max-width: 600px;
margin: 0 auto;
max-width: 580px;
padding: 10px;
display: block;
padding: 20px;
}
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.main {
background: #ffffff;
background-color: #fff;
border: 1px solid #e9e9e9;
border-radius: 3px;
width: 100%;
}
.wrapper {
box-sizing: border-box;
.content-wrap {
padding: 20px;
}
.content-block {
padding-bottom: 10px;
padding-top: 10px;
padding: 0 0 20px;
}
.header {
width: 100%;
margin-bottom: 20px;
}
.footer {
clear: both;
margin-top: 10px;
text-align: center;
width: 100%;
clear: both;
color: #999;
padding: 20px;
}
.footer td,
.footer p,
.footer span,
.footer a {
color: #999999;
.footer p, .footer a, .footer td {
color: #999;
font-size: 12px;
text-align: center;
}
/* -------------------------------------
TYPOGRAPHY
------------------------------------- */
h1,
h2,
h3,
h4 {
color: #000000;
font-family: sans-serif;
h1, h2, h3 {
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
color: #000;
margin: 40px 0 0;
line-height: 1.2em;
font-weight: 400;
line-height: 1.4;
margin: 0;
margin-bottom: 30px;
}
h1 {
font-size: 35px;
font-weight: 300;
text-align: center;
text-transform: capitalize;
font-size: 32px;
font-weight: 500;
/* 1.2em * 32px = 38.4px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */
/*line-height: 38px;*/
}
p,
ul,
ol {
font-family: sans-serif;
h2 {
font-size: 24px;
/* 1.2em * 24px = 28.8px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */
/*line-height: 29px;*/
}
h3 {
font-size: 18px;
/* 1.2em * 18px = 21.6px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */
/*line-height: 22px;*/
}
h4 {
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 15px;
}
p li,
ul li,
ol li {
list-style-position: inside;
margin-left: 5px;
font-weight: 600;
}
a {
color: #06c;
border-radius: 3px;
text-decoration: underline;
p, ul, ol {
margin-bottom: 10px;
font-weight: normal;
}
p li, ul li, ol li {
margin-left: 5px;
list-style-position: inside;
}
/* -------------------------------------
BUTTONS
LINKS & BUTTONS
------------------------------------- */
.btn {
box-sizing: border-box;
width: 100%; }
.btn > tbody > tr > td {
padding-bottom: 15px; }
.btn table {
width: auto;
a {
color: #348eda;
text-decoration: underline;
}
.btn table td {
background-color: #ffffff;
border-radius: 5px;
.btn-primary {
text-decoration: none;
color: #FFF;
background-color: #348eda;
border: solid #348eda;
border-width: 10px 20px;
line-height: 2em;
/* 2em * 14px = 28px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */
/*line-height: 28px;*/
font-weight: bold;
text-align: center;
}
.btn a {
background-color: #ffffff;
border: solid 1px #06c;
border-radius: 5px;
box-sizing: border-box;
color: #06c;
cursor: pointer;
display: inline-block;
font-size: 14px;
font-weight: bold;
margin: 0;
padding: 12px 25px;
text-decoration: none;
border-radius: 5px;
text-transform: capitalize;
}
.btn-primary table td {
background-color: #06c;
}
.btn-primary a {
background-color: #06c;
border-color: #06c;
color: #ffffff;
}
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
@ -192,15 +168,15 @@ table {
margin-top: 0;
}
.align-center {
.aligncenter {
text-align: center;
}
.align-right {
.alignright {
text-align: right;
}
.align-left {
.alignleft {
text-align: left;
}
@ -208,118 +184,102 @@ table {
clear: both;
}
.mt0 {
margin-top: 0;
/* -------------------------------------
ALERTS
Change the class depending on warning email, good email or bad email
------------------------------------- */
.alert {
font-size: 16px;
color: #fff;
font-weight: 500;
padding: 20px;
text-align: center;
border-radius: 3px 3px 0 0;
}
.mb0 {
margin-bottom: 0;
}
.preheader {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0;
}
.powered-by a {
.alert a {
color: #fff;
text-decoration: none;
font-weight: 500;
font-size: 16px;
}
.alert-brand {
background-color: #fd4b2d;
}
.alert-warning {
background-color: #F0AB00;
}
.alert-danger {
background-color: #C9190B;
}
.alert-success {
background-color: #3E8635;
}
hr {
border: 0;
border-bottom: 1px solid #fafafa;
margin: 20px 0;
/* -------------------------------------
INVOICE
Styles for the billing table
------------------------------------- */
.invoice {
margin: 40px auto;
text-align: left;
width: 80%;
}
.invoice td {
padding: 5px 0;
}
.invoice .invoice-items {
width: 100%;
}
.invoice .invoice-items td {
border-top: #eee 1px solid;
}
.invoice .invoice-items .total td {
border-top: 2px solid #333;
border-bottom: 2px solid #333;
font-weight: 700;
}
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
@media only screen and (max-width: 640px) {
body {
padding: 0 !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
h1, h2, h3, h4 {
font-weight: 800 !important;
margin: 20px 0 5px !important;
}
h1 {
font-size: 22px !important;
}
h2 {
font-size: 18px !important;
}
h3 {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
.container {
padding: 0 !important;
width: 100% !important;
}
.content {
padding: 0 !important;
}
.content-wrap {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
.invoice {
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
.btn-primary table td:hover {
background-color: #34495e !important;
}
.btn-primary a:hover {
background-color: #34495e !important;
border-color: #34495e !important;
}
}
/*# sourceMappingURL=styles.css.map */

View File

@ -6,6 +6,7 @@ from typing import Any, Dict, List
from celery import group
from django.core.mail import EmailMultiAlternatives
from django.core.mail.utils import DNS_NAME
from django.utils.text import slugify
from structlog.stdlib import get_logger
from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus
@ -38,7 +39,7 @@ def send_mail(self: MonitoredTask, email_stage_pk: int, message: Dict[Any, Any])
"""Send Email for Email Stage. Retries are scheduled automatically."""
self.save_on_success = False
message_id = make_msgid(domain=DNS_NAME)
self.set_uid(message_id)
self.set_uid(slugify(message_id.replace(".", "_").replace("@", "_")))
try:
stage: EmailStage = EmailStage.objects.get(pk=email_stage_pk)
backend = stage.backend
@ -48,6 +49,7 @@ def send_mail(self: MonitoredTask, email_stage_pk: int, message: Dict[Any, Any])
message_object = EmailMultiAlternatives()
for key, value in message.items():
setattr(message_object, key, value)
if not stage.use_global_settings:
message_object.from_email = stage.from_address
# Because we use the Message-ID as UID for the task, manually assign it
message_object.extra_headers["Message-ID"] = message_id
@ -61,5 +63,6 @@ def send_mail(self: MonitoredTask, email_stage_pk: int, message: Dict[Any, Any])
)
)
except (SMTPException, ConnectionError) as exc:
LOGGER.debug("Error sending email, retrying...", exc=exc)
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
raise exc

View File

@ -1,4 +1,4 @@
{% extends 'stages/email/for_email/base.html' %}
{% extends 'email/base.html' %}
{% load authentik_stages_email %}
{% load i18n %}

View File

@ -0,0 +1,34 @@
{% load authentik_stages_email %}
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title></title>
<style>{% inline_static_ascii "stages/email/css/base.css" %}</style>
</head>
<body itemscope itemtype="http://schema.org/EmailMessage">
<table class="body-wrap">
<tr>
<td></td>
<td class="container" width="600">
<div class="content">
<table class="main" width="100%" cellpadding="0" cellspacing="0" itemprop="action" itemscope itemtype="http://schema.org/ConfirmAction">
{% block content %}
{% endblock %}
</table>
<div class="footer">
<table width="100%">
<tr>
<td class="aligncenter content-block">Powered by <a href="https://goauthentik.io">authentik</a>.</td>
</tr>
</table>
</div>
</div>
</td>
<td></td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,20 @@
{% extends "email/base.html" %}
{% block content %}
<tr>
<td class="alert alert-brand">
{{ title }}
</td>
</tr>
<tr>
<td class="content-wrap">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block">
{{ body }}
</td>
</tr>
</table>
</td>
</tr>
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends "stages/email/for_email/base.html" %}
{% extends "email/base.html" %}
{% load authentik_utils %}
{% load i18n %}

View File

@ -0,0 +1,25 @@
{% extends "email/base.html" %}
{% load authentik_stages_email %}
{% load i18n %}
{% block content %}
<tr>
<td class="alert alert-brand">
{% trans 'authentik Test-Email' %}
</td>
</tr>
<tr>
<td class="content-wrap">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block">
{% blocktrans %}
This is a test email to inform you, that you've successfully configured authentik emails.
{% endblocktrans %}
</td>
</tr>
</table>
</td>
</tr>
{% endblock %}

View File

@ -1,65 +0,0 @@
{% load authentik_stages_email %}
{% load authentik_utils %}
{% load static %}
{% load i18n %}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title></title>
<style>{% inline_static_ascii "stages/email/css/base.css" %}</style>
</head>
<body class="">
<span class="preheader">
{% block pre_header %}
{% endblock %}
</span>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
<tr>
<td>&nbsp;</td>
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" class="main">
<img src="{% inline_static_binary config.authentik.branding.logo %}" alt="">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
{% block content %}
{% endblock %}
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED WHITE CONTAINER -->
<!-- START FOOTER -->
<div class="footer">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block powered-by">
Powered by <a href="https://beryju.github.io/authentik/">authentik</a>.
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
</div>
</td>
<td>&nbsp;</td>
</tr>
</table>
</body>
</html>

View File

@ -1,26 +0,0 @@
{% extends "stages/email/for_email/base.html" %}
{% block content %}
<tr>
<td bgcolor="#3625b7" align="center" style="padding: 0px 10px 0px 10px;">
<table border="0" cellpadding="0" cellspacing="0" width="480">
<tr>
<td bgcolor="#566572" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #8F9BA3; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<h1 style="font-size: 32px; font-weight: 400; margin: 0; color: #E9ECEF;">{{ title }}!</h1>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td bgcolor="#1b2a32" align="center" style="padding: 0px 10px 0px 10px;">
<table border="0" cellpadding="0" cellspacing="0" width="480">
<tr>
<td bgcolor="#566572" align="left" style="padding: 20px 30px 40px 30px; color: #E9ECEF; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;">
<p style="margin: 0;">{{ body }}</p>
</td>
</tr>
</table>
</td>
</tr>
{% endblock %}

View File

View File

@ -0,0 +1,83 @@
"""email tests"""
from smtplib import SMTPException
from unittest.mock import MagicMock, patch
from django.core import mail
from django.core.mail.backends.locmem import EmailBackend
from django.shortcuts import reverse
from django.test import Client, TestCase
from authentik.core.models import User
from authentik.flows.markers import StageMarker
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.views import SESSION_KEY_PLAN
from authentik.stages.email.models import EmailStage
class TestEmailStageSending(TestCase):
"""Email tests"""
def setUp(self):
super().setUp()
self.user = User.objects.create_user(
username="unittest", email="test@beryju.org"
)
self.client = Client()
self.flow = Flow.objects.create(
name="test-email",
slug="test-email",
designation=FlowDesignation.AUTHENTICATION,
)
self.stage = EmailStage.objects.create(
name="email",
)
FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
def test_pending_user(self):
"""Test with pending user"""
plan = FlowPlan(
flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
)
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
with self.settings(
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend"
):
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "authentik")
def test_send_error(self):
"""Test error during sending (sending will be retried)"""
plan = FlowPlan(
flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
)
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
url = reverse(
"authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
with self.settings(
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend"
):
with patch(
"django.core.mail.backends.locmem.EmailBackend.send_messages",
MagicMock(side_effect=[SMTPException, EmailBackend.send_messages]),
):
response = self.client.post(url)
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
self.assertTrue(len(mail.outbox) >= 1)
self.assertEqual(mail.outbox[0].subject, "authentik")

View File

@ -87,6 +87,14 @@ class TestEmailStage(TestCase):
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "authentik")
def test_use_global_settings(self):
"""Test use_global_settings"""
host = "some-unique-string"
with self.settings(
EMAIL_HOST=host, EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend"
):
self.assertEqual(EmailStage(use_global_settings=True).backend.host, host)
def test_token(self):
"""Test with token"""
# Make sure token exists

View File

@ -0,0 +1,28 @@
"""email tests"""
from os import unlink
from pathlib import Path
from tempfile import gettempdir, mkstemp
from typing import Any
from django.conf import settings
from django.test import TestCase
from authentik.stages.email.models import get_template_choices
def get_templates_setting(temp_dir: str) -> dict[str, Any]:
"""Patch settings TEMPLATE's dir property"""
templates_setting = settings.TEMPLATES
templates_setting[0]["DIRS"] = [temp_dir]
return templates_setting
class TestEmailStageTemplates(TestCase):
"""Email tests"""
def test_custom_template(self):
"""Test with custom template"""
with self.settings(TEMPLATES=get_templates_setting(gettempdir())):
_, file = mkstemp(suffix=".html")
self.assertEqual(get_template_choices()[-1][0], Path(file).name)
unlink(file)

View File

@ -29,6 +29,7 @@ services:
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
volumes:
- ./media:/media
- ./custom-templates:/templates
ports:
- 8000
networks:
@ -57,6 +58,7 @@ services:
volumes:
- ./backups:/backups
- /var/run/docker.sock:/var/run/docker.sock
- ./custom-templates:/templates
env_file:
- .env
static:

View File

@ -14,6 +14,14 @@
| config.errorReporting.environment | customer | Environment sent with the error reporting |
| config.errorReporting.sendPii | false | Whether to send Personally-identifiable data with the error reporting |
| config.logLevel | warning | Log level of authentik |
| config.email.host | localhost | SMTP Host Emails are sent to |
| config.email.port | 25 | SMTP Port Emails are sent to |
| config.email.username | | SMTP Username |
| config.email.password | | SMTP Password |
| config.email.use_tls | false | Enable StartTLS |
| config.email.use_ssl | false | Enable SSL |
| config.email.timeout | 10 | SMTP Timeout |
| config.email.from | authentik@localhost | Email address authentik will send from, should have a correct @domain |
| backup.accessKey | | Optionally enable S3 Backup, Access Key |
| backup.secretKey | | Optionally enable S3 Backup, Secret Key |
| backup.bucket | | Optionally enable S3 Backup, Bucket |

View File

@ -8,7 +8,6 @@ data:
POSTGRESQL__USER: "{{ .Values.postgresql.postgresqlUsername }}"
{{- if .Values.backup }}
POSTGRESQL__S3_BACKUP__ACCESS_KEY: "{{ .Values.backup.accessKey }}"
POSTGRESQL__S3_BACKUP__SECRET_KEY: "{{ .Values.backup.secretKey }}"
POSTGRESQL__S3_BACKUP__BUCKET: "{{ .Values.backup.bucket }}"
POSTGRESQL__S3_BACKUP__REGION: "{{ .Values.backup.region }}"
POSTGRESQL__S3_BACKUP__HOST: "{{ .Values.backup.host }}"
@ -19,3 +18,10 @@ data:
ERROR_REPORTING__SEND_PII: "{{ .Values.config.errorReporting.sendPii }}"
LOG_LEVEL: "{{ .Values.config.logLevel }}"
OUTPOSTS__DOCKER_IMAGE_BASE: "{{ .Values.image.name_outposts }}"
EMAIL__HOST: "{{ .Values.config.email.host }}"
EMAIL__PORT: "{{ .Values.config.email.port }}"
EMAIL__USERNAM: "{{ .Values.config.email.username }}"
EMAIL__USE_TLS: "{{ .Values.config.email.use_tls }}"
EMAIL__USE_SSL: "{{ .Values.config.email.use_ssl }}"
EMAIL__TIMEOUT: "{{ .Values.config.email.timeout }}"
EMAIL__FROM: "{{ .Values.config.email.from }}"

View File

@ -6,7 +6,11 @@ metadata:
data:
monitoring_username: bW9uaXRvcg== # monitor in base64
{{- if .Values.config.secretKey }}
secret_key: {{ .Values.config.secretKey | b64enc | quote }}
SECRET_KEY: {{ .Values.config.secretKey | b64enc | quote }}
{{- else }}
secret_key: {{ randAlphaNum 50 | b64enc | quote}}
SECRET_KEY: {{ randAlphaNum 50 | b64enc | quote}}
{{- end }}
{{- if .Values.backup }}
POSTGRESQL__S3_BACKUP__SECRET_KEY: "{{ .Values.backup.secretKey }}"
{{- end}}
EMAIL__PASSWOR: "{{ .Values.config.email.password }}"

View File

@ -51,12 +51,10 @@ spec:
- configMapRef:
name: {{ include "authentik.fullname" . }}-config
prefix: AUTHENTIK_
env:
- name: AUTHENTIK_SECRET_KEY
valueFrom:
secretKeyRef:
- secretRef:
name: {{ include "authentik.fullname" . }}-secret-key
key: secret_key
prefix: AUTHENTIK_
env:
- name: AUTHENTIK_REDIS__PASSWORD
valueFrom:
secretKeyRef:

View File

@ -54,12 +54,10 @@ spec:
- configMapRef:
name: "{{ include "authentik.fullname" . }}-config"
prefix: "AUTHENTIK_"
- secretRef:
name: {{ include "authentik.fullname" . }}-secret-key
prefix: AUTHENTIK_
env:
- name: AUTHENTIK_SECRET_KEY
valueFrom:
secretKeyRef:
name: "{{ include "authentik.fullname" . }}-secret-key"
key: secret_key
- name: AUTHENTIK_REDIS__PASSWORD
valueFrom:
secretKeyRef:

View File

@ -25,6 +25,21 @@ config:
# Log level used by web and worker
# Can be either debug, info, warning, error
logLevel: warning
# Global Email settings
email:
# SMTP Host Emails are sent to
host: localhost
port: 25
# Optionally authenticate
username: ""
password: ""
# Use StartTLS
useTls: false
# Use SSL
useSsl: false
timeout: 10
# Email address authentik will send from, should have a correct @domain
from: authentik@localhost
# Enable Database Backups to S3
# backup:

View File

@ -8579,6 +8579,11 @@ definitions:
title: Name
type: string
minLength: 1
use_global_settings:
title: Use global settings
description: When enabled, global Email connection settings will be used and
connection settings below will be ignored.
type: boolean
host:
title: Host
type: string
@ -8625,8 +8630,8 @@ definitions:
title: Template
type: string
enum:
- stages/email/for_email/password_reset.html
- stages/email/for_email/account_confirmation.html
- email/password_reset.html
- email/account_confirmation.html
IdentificationStage:
description: IdentificationStage Serializer
required:

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@ -5,3 +5,19 @@ title: Email stage
This stage can be used for email verification. authentik's background worker will send an email using the specified connection details. When an email can't be delivered, delivery is automatically retried periodically.
![](email-recovery.png)
## Custom Templates
You can also use custom email templates, to use your own design or layout.
Place any custom templates in the `custom-templates` Folder, which is in the same folder as your docker-compose file. Afterwards, you'll be able to select the template when creating/editing an Email stage.
:::info
This is currently only supported for docker-compose installs, and supported starting version 0.15.
:::
:::info
If you've add the line and created a file, and can't see if, check the logs using `docker-compose logs -f worker`.
:::
![](custom-template.png)

View File

@ -9,7 +9,7 @@ authentik is an open-source Identity Provider focused on flexibility and versati
## Installation
See [Docker-compose](installation/docker-compose.md) or [Kubernetes](installation/kubernetes.md)
See [Docker-compose](installation/docker-compose) or [Kubernetes](installation/kubernetes)
## Screenshots

View File

@ -9,7 +9,7 @@ This installation method is for test-setups and small-scale productive setups.
- docker
- docker-compose
## Install
## Preparation
Download the latest `docker-compose.yml` from [here](https://raw.githubusercontent.com/BeryJu/authentik/master/docker-compose.yml). Place it in a directory of your choice.
@ -25,6 +25,30 @@ echo "PG_PASS=$(pwgen 40 1)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(pwgen 50 1)" >> .env
```
## Email configuration (optional, but recommended)
It is also recommended to configure global email credentials. These are used by authentik to notify you about alerts, configuration issues. They can also be used by [Email stages](flow/stages/email/index.md) to send verification/recovery emails.
Append this block to your `.env` file
```
# SMTP Host Emails are sent to
AUTHENTIK_EMAIL__HOST=localhost
AUTHENTIK_EMAIL__PORT=25
# Optionally authenticate
AUTHENTIK_EMAIL__USERNAME=""
AUTHENTIK_EMAIL__PASSWORD=""
# Use StartTLS
AUTHENTIK_EMAIL__USE_TLS=false
# Use SSL
AUTHENTIK_EMAIL__USE_SSL=false
AUTHENTIK_EMAIL__TIMEOUT=10
# Email address authentik will send from, should have a correct @domain
AUTHENTIK_EMAIL__FROM=authentik@localhost
```
## Startup
Afterwards, run these commands to finish
```
@ -39,8 +63,6 @@ If you plan to use this setup for production, it is also advised to change the P
Now you can pull the Docker images needed by running `docker-compose pull`. After this has finished, run `docker-compose up -d` to start authentik.
authentik will then be reachable via HTTP on port 80, and HTTPS on port 443. You can optionally configure the packaged traefik to use Let's Encrypt certificates for TLS Encryption.
If you plan to access authentik via a reverse proxy which does SSL Termination, make sure you use the HTTPS port, so authentik is aware of the SSL connection.
authentik will then be reachable HTTPS on port 443. You can optionally configure the packaged traefik to use Let's Encrypt certificates for TLS Encryption.
The initial setup process also creates a default admin user, the username and password for which is `akadmin`. It is highly recommended to change this password as soon as you log in.

View File

@ -14,6 +14,8 @@ helm install authentik/authentik --devel -f values.yaml
This installation automatically applies database migrations on startup. After the installation is done, you can use `akadmin` as username and password.
It is also recommended to configure global email credentials. These are used by authentik to notify you about alerts, configuration issues. They can also be used by [Email stages](flow/stages/email/index.md) to send verification/recovery emails.
```yaml
###################################
# Values directly affecting authentik
@ -41,6 +43,21 @@ config:
# Log level used by web and worker
# Can be either debug, info, warning, error
logLevel: warning
# Global Email settings
email:
# SMTP Host Emails are sent to
host: localhost
port: 25
# Optionally authenticate
username: ""
password: ""
# Use StartTLS
useTls: false
# Use SSL
useSsl: false
timeout: 10
# Email address authentik will send from, should have a correct @domain
from: authentik@localhost
# Enable Database Backups to S3
# backup:
@ -80,6 +97,4 @@ redis:
master:
persistence:
enabled: false
# https://stackoverflow.com/a/59189742
disableCommands: []
```

View File

@ -0,0 +1,23 @@
---
title: Troubleshooting Email sending
---
To test if an email stage, or the global email settings are configured correctly, you can run the following command:
````
./manage.py test_email <to address> [-s <stage name>]
```
If you omit the `-s` parameter, the email will be sent using the global settings. Otherwise, the settings of the specified stage will be used.
To run this command with docker-compose, use
```
docker-compose exec -it worker ./manage.py test_email [...]
```
To run this command with Kubernetes, use
```
kubectl exec -it authentik-worker-xxxxx -- ./manage.py test_email [...]
```

View File

@ -135,7 +135,10 @@ module.exports = {
{
type: "category",
label: "Troubleshooting",
items: ["troubleshooting/access"],
items: [
"troubleshooting/access",
"troubleshooting/emails",
],
},
{
type: "category",

View File

@ -99,7 +99,7 @@
"from_address": "system@authentik.local",
"token_expiry": 30,
"subject": "authentik",
"template": "stages/email/for_email/account_confirmation.html"
"template": "email/account_confirmation.html"
}
},
{

View File

@ -92,7 +92,7 @@
"from_address": "system@authentik.local",
"token_expiry": 30,
"subject": "authentik",
"template": "stages/email/for_email/password_reset.html"
"template": "email/password_reset.html"
}
},
{