factors: -> stage

This commit is contained in:
Jens Langhammer
2020-05-08 19:46:39 +02:00
parent 08c0eb2ec6
commit 212e966dd4
99 changed files with 745 additions and 958 deletions

View File

View File

@ -0,0 +1,35 @@
"""EmailStage API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from passbook.stages.email.models import EmailStage
class EmailStageSerializer(ModelSerializer):
"""EmailStage Serializer"""
class Meta:
model = EmailStage
fields = [
"pk",
"name",
"host",
"port",
"username",
"password",
"use_tls",
"use_ssl",
"timeout",
"from_address",
"ssl_keyfile",
"ssl_certfile",
]
extra_kwargs = {"password": {"write_only": True}}
class EmailStageViewSet(ModelViewSet):
"""EmailStage Viewset"""
queryset = EmailStage.objects.all()
serializer_class = EmailStageSerializer

View File

@ -0,0 +1,15 @@
"""passbook email stage config"""
from importlib import import_module
from django.apps import AppConfig
class PassbookStageEmailConfig(AppConfig):
"""passbook email stage config"""
name = "passbook.stages.email"
label = "passbook_stages_email"
verbose_name = "passbook Stages.Email"
def ready(self):
import_module("passbook.stages.email.tasks")

View File

@ -0,0 +1,40 @@
"""passbook administration forms"""
from django import forms
from django.utils.translation import gettext_lazy as _
from passbook.stages.email.models import EmailStage
class EmailStageForm(forms.ModelForm):
"""Form to create/edit Dummy Stage"""
class Meta:
model = EmailStage
fields = [
"name",
"host",
"port",
"username",
"password",
"use_tls",
"use_ssl",
"timeout",
"from_address",
"ssl_keyfile",
"ssl_certfile",
]
widgets = {
"name": forms.TextInput(),
"host": forms.TextInput(),
"username": forms.TextInput(),
"password": forms.TextInput(),
"ssl_keyfile": forms.TextInput(),
"ssl_certfile": forms.TextInput(),
}
labels = {
"use_tls": _("Use TLS"),
"use_ssl": _("Use SSL"),
"ssl_keyfile": _("SSL Keyfile (optional)"),
"ssl_certfile": _("SSL Certfile (optional)"),
}

View File

@ -0,0 +1,50 @@
# Generated by Django 3.0.3 on 2020-05-08 17:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("passbook_flows", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="EmailStage",
fields=[
(
"stage_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="passbook_flows.Stage",
),
),
("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)),
("ssl_keyfile", models.TextField(blank=True, default=None, null=True)),
("ssl_certfile", models.TextField(blank=True, default=None, null=True)),
(
"from_address",
models.EmailField(default="system@passbook.local", max_length=254),
),
],
options={
"verbose_name": "Email Stage",
"verbose_name_plural": "Email Stages",
},
bases=("passbook_flows.stage",),
),
]

View File

@ -0,0 +1,49 @@
"""email stage models"""
from django.core.mail.backends.smtp import EmailBackend
from django.db import models
from django.utils.translation import gettext as _
from passbook.flows.models import Stage
class EmailStage(Stage):
"""email stage"""
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)
ssl_keyfile = models.TextField(default=None, blank=True, null=True)
ssl_certfile = models.TextField(default=None, blank=True, null=True)
from_address = models.EmailField(default="system@passbook.local")
type = "passbook.stages.email.stage.EmailStageView"
form = "passbook.stages.email.forms.EmailStageForm"
@property
def backend(self) -> EmailBackend:
"""Get fully configured EMail Backend instance"""
return EmailBackend(
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,
ssl_certfile=self.ssl_certfile,
ssl_keyfile=self.ssl_keyfile,
)
def __str__(self):
return f"Email Stage {self.name}"
class Meta:
verbose_name = _("Email Stage")
verbose_name_plural = _("Email Stages")

View File

@ -0,0 +1,50 @@
"""passbook multi-stage authentication engine"""
from django.contrib import messages
from django.http import HttpRequest
from django.shortcuts import reverse
from django.utils.translation import gettext as _
from structlog import get_logger
from passbook.core.models import Nonce
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
from passbook.flows.stage import AuthenticationStage
from passbook.lib.config import CONFIG
from passbook.stages.email.tasks import send_mails
from passbook.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger()
class EmailStageView(AuthenticationStage):
"""E-Mail stage which sends E-Mail for verification"""
def get_context_data(self, **kwargs):
kwargs["show_password_forget_notice"] = CONFIG.y(
"passbook.password_reset.enabled"
)
return super().get_context_data(**kwargs)
def get(self, request, *args, **kwargs):
pending_user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
nonce = Nonce.objects.create(user=pending_user)
# Send mail to user
message = TemplateEmailMessage(
subject=_("Forgotten password"),
template_name="email/account_password_reset.html",
to=[pending_user.email],
template_context={
"url": self.request.build_absolute_uri(
reverse(
"passbook_core:auth-password-reset",
kwargs={"nonce": nonce.uuid},
)
)
},
)
send_mails(self.executor.current_stage, message)
messages.success(request, _("Check your E-Mails for a password reset link."))
return self.executor.cancel()
def post(self, request: HttpRequest):
"""Just redirect to next stage"""
return self.executor.stage_ok()

View File

@ -0,0 +1,44 @@
"""email stage tasks"""
from smtplib import SMTPException
from typing import Any, Dict, List
from celery import group
from django.core.mail import EmailMessage
from structlog import get_logger
from passbook.root.celery import CELERY_APP
from passbook.stages.email.models import EmailStage
LOGGER = get_logger()
def send_mails(stage: EmailStage, *messages: List[EmailMessage]):
"""Wrapper to convert EmailMessage to dict and send it from worker"""
tasks = []
for message in messages:
tasks.append(_send_mail_task.s(stage.pk, message.__dict__))
lazy_group = group(*tasks)
promise = lazy_group()
return promise
@CELERY_APP.task(bind=True)
def _send_mail_task(self, email_stage_pk: int, message: Dict[Any, Any]):
"""Send E-Mail according to EmailStage parameters from background worker.
Automatically retries if message couldn't be sent."""
stage: EmailStage = EmailStage.objects.get(pk=email_stage_pk)
backend = stage.backend
backend.open()
# Since django's EmailMessage objects are not JSON serialisable,
# we need to rebuild them from a dict
message_object = EmailMessage()
for key, value in message.items():
setattr(message_object, key, value)
message_object.from_email = stage.from_address
LOGGER.debug("Sending mail", to=message_object.to)
try:
num_sent = stage.backend.send_messages([message_object])
except SMTPException as exc:
raise self.retry(exc=exc)
if num_sent != 1:
raise self.retry()

View File

@ -0,0 +1,41 @@
"""email utils"""
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
class TemplateEmailMessage(EmailMultiAlternatives):
"""Wrapper around EmailMultiAlternatives with integrated template rendering"""
# pylint: disable=too-many-arguments
def __init__(
self,
subject="",
body=None,
from_email=None,
to=None,
bcc=None,
connection=None,
attachments=None,
headers=None,
cc=None,
reply_to=None,
template_name=None,
template_context=None,
):
html_content = render_to_string(template_name, template_context)
if not body:
body = strip_tags(html_content)
super().__init__(
subject=subject,
body=body,
from_email=from_email,
to=to,
bcc=bcc,
connection=connection,
attachments=attachments,
headers=headers,
cc=cc,
reply_to=reply_to,
)
self.attach_alternative(html_content, "text/html")