factors/email(minor): start rebuilding email integration as factor

This commit is contained in:
Langhammer, Jens 2019-10-08 14:30:17 +02:00
parent 171c5b9759
commit d91a852eda
21 changed files with 333 additions and 130 deletions

View file

@ -1,5 +0,0 @@
"""core settings"""
PASSBOOK_CORE_FACTORS = [
]

View file

@ -1,28 +1,15 @@
"""passbook core tasks"""
from datetime import datetime
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from structlog import get_logger
from passbook.core.models import Nonce
from passbook.lib.config import CONFIG
from passbook.root.celery import CELERY_APP
LOGGER = get_logger()
@CELERY_APP.task()
def send_email(to_address, subject, template, context):
"""Send Email to user(s)"""
html_content = render_to_string(template, context=context)
text_content = strip_tags(html_content)
msg = EmailMultiAlternatives(subject, text_content, CONFIG.y('email.from'), [to_address])
msg.attach_alternative(html_content, "text/html")
msg.send()
@CELERY_APP.task()
def clean_nonces():
"""Remove expired nonces"""
amount, _ = Nonce.objects.filter(expires__lt=datetime.now(), expiring=True).delete()
LOGGER.debug("Deleted expired %d nonces", amount)
LOGGER.debug("Deleted expired nonces", amount=amount)

View file

@ -15,7 +15,6 @@ from structlog import get_logger
from passbook.core.forms.authentication import LoginForm, SignUpForm
from passbook.core.models import Invitation, Nonce, Source, User
from passbook.core.signals import invitation_used, user_signed_up
from passbook.core.tasks import send_email
from passbook.factors.password.exceptions import PasswordPolicyInvalid
from passbook.factors.view import AuthenticationView, _redirect_with_qs
from passbook.lib.config import CONFIG
@ -97,7 +96,7 @@ class SignUpView(UserPassesTestMixin, FormView):
template_name = 'login/form.html'
form_class = SignUpForm
success_url = '.'
# Invitation insatnce, if invitation link was used
# Invitation instance, if invitation link was used
_invitation = None
# Instance of newly created user
_user = None
@ -152,23 +151,23 @@ class SignUpView(UserPassesTestMixin, FormView):
for error in exc.messages:
errors.append(error)
return self.form_invalid(form)
needs_confirmation = True
if self._invitation and not self._invitation.needs_confirmation:
needs_confirmation = False
if needs_confirmation:
nonce = Nonce.objects.create(user=self._user)
LOGGER.debug(str(nonce.uuid))
# Send email to user
send_email.delay(self._user.email, _('Confirm your account.'),
'email/account_confirm.html', {
'url': self.request.build_absolute_uri(
reverse('passbook_core:auth-sign-up-confirm', kwargs={
'nonce': nonce.uuid
})
)
})
self._user.is_active = False
self._user.save()
# needs_confirmation = True
# if self._invitation and not self._invitation.needs_confirmation:
# needs_confirmation = False
# if needs_confirmation:
# nonce = Nonce.objects.create(user=self._user)
# LOGGER.debug(str(nonce.uuid))
# # Send email to user
# send_email.delay(self._user.email, _('Confirm your account.'),
# 'email/account_confirm.html', {
# 'url': self.request.build_absolute_uri(
# reverse('passbook_core:auth-sign-up-confirm', kwargs={
# 'nonce': nonce.uuid
# })
# )
# })
# self._user.is_active = False
# self._user.save()
self.consume_invitation()
messages.success(self.request, _("Successfully signed up!"))
LOGGER.debug("Successfully signed up %s",

View file

@ -14,13 +14,14 @@ class AuthenticationFactor(TemplateView):
form: ModelForm = None
required: bool = True
authenticator: AuthenticationView = None
pending_user: User = None
authenticator: AuthenticationView
pending_user: User
request: HttpRequest = None
template_name = 'login/form_with_user.html'
def __init__(self, authenticator: AuthenticationView):
self.authenticator = authenticator
self.pending_user = None
def get_context_data(self, **kwargs):
kwargs['config'] = CONFIG.y('passbook')

View file

View file

@ -0,0 +1,5 @@
"""email factor admin"""
from passbook.lib.admin import admin_autoregister
admin_autoregister('passbook_factors_email')

View file

@ -0,0 +1,15 @@
"""passbook email factor config"""
from importlib import import_module
from django.apps import AppConfig
class PassbookFactorEmailConfig(AppConfig):
"""passbook email factor config"""
name = 'passbook.factors.email'
label = 'passbook_factors_email'
verbose_name = 'passbook Factors.Email'
def ready(self):
import_module('passbook.factors.email.tasks')

View file

@ -0,0 +1,45 @@
"""passbook multi-factor authentication engine"""
from django.contrib import messages
from django.http import HttpRequest
from django.shortcuts import redirect, reverse
from django.utils.translation import gettext as _
from structlog import get_logger
from passbook.core.models import Nonce
from passbook.factors.base import AuthenticationFactor
from passbook.factors.email.tasks import send_mails
from passbook.factors.email.utils import TemplateEmailMessage
from passbook.lib.config import CONFIG
LOGGER = get_logger()
class EmailFactorView(AuthenticationFactor):
"""Dummy factor for testing with multiple factors"""
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):
nonce = Nonce.objects.create(user=self.pending_user)
LOGGER.debug("DEBUG %s", str(nonce.uuid))
# Send mail to user
message = TemplateEmailMessage(
subject=_('Forgotten password'),
template_name='email/account_password_reset.html',
template_context={
'url': self.request.build_absolute_uri(
reverse('passbook_core:auth-password-reset',
kwargs={
'nonce': nonce.uuid
})
)})
send_mails(self.authenticator.current_factor, message)
self.authenticator.cleanup()
messages.success(request, _('Check your E-Mails for a password reset link.'))
return redirect('passbook_core:auth-login')
def post(self, request: HttpRequest):
"""Just redirect to next factor"""
return self.authenticator.user_ok()

View file

@ -0,0 +1,43 @@
"""passbook administration forms"""
from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext as _
from passbook.factors.email.models import EmailFactor
from passbook.factors.forms import GENERAL_FIELDS
class EmailFactorForm(forms.ModelForm):
"""Form to create/edit Dummy Factor"""
class Meta:
model = EmailFactor
fields = GENERAL_FIELDS + [
'host',
'port',
'username',
'password',
'use_tls',
'use_ssl',
'timeout',
'from_address',
'ssl_keyfile',
'ssl_certfile',
]
widgets = {
'name': forms.TextInput(),
'order': forms.NumberInput(),
'policies': FilteredSelectMultiple(_('policies'), False),
'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,37 @@
# Generated by Django 2.2.6 on 2019-10-08 12:23
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('passbook_core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='EmailFactor',
fields=[
('factor_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Factor')),
('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=0)),
('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 Factor',
'verbose_name_plural': 'Email Factors',
},
bases=('passbook_core.factor',),
),
]

View file

@ -0,0 +1,48 @@
"""email factor models"""
from django.core.mail.backends.smtp import EmailBackend
from django.db import models
from django.utils.translation import gettext as _
from passbook.core.models import Factor
class EmailFactor(Factor):
"""email factor"""
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=0)
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.factors.email.factor.EmailFactorView'
form = 'passbook.factors.email.forms.EmailFactorForm'
@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 Factor {self.slug}"
class Meta:
verbose_name = _('Email Factor')
verbose_name_plural = _('Email Factors')

View file

@ -0,0 +1,39 @@
"""email factor tasks"""
from smtplib import SMTPException
from typing import Any, Dict, List
from celery import group
from django.core.mail import EmailMessage
from passbook.factors.email.models import EmailFactor
from passbook.root.celery import CELERY_APP
def send_mails(factor: EmailFactor, *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(factor.pk, message.__dict__))
lazy_group = group(*tasks)
promise = lazy_group()
return promise
@CELERY_APP.task(bind=True)
def _send_mail_task(self, email_factor_pk: int, message: Dict[Any, Any]):
"""Send E-Mail according to EmailFactor parameters from background worker.
Automatically retries if message couldn't be sent."""
factor: EmailFactor = EmailFactor.objects.get(pk=email_factor_pk)
backend = factor.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 = factor.from_address
try:
num_sent = factor.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,28 @@
"""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")

View file

@ -1,20 +1,18 @@
"""passbook multi-factor authentication engine"""
from inspect import Signature
from typing import Optional
from django.contrib import messages
from django.contrib.auth import _clean_credentials
from django.contrib.auth.signals import user_login_failed
from django.core.exceptions import PermissionDenied
from django.forms.utils import ErrorList
from django.shortcuts import redirect, reverse
from django.utils.translation import gettext as _
from django.views.generic import FormView
from structlog import get_logger
from passbook.core.forms.authentication import PasswordFactorForm
from passbook.core.models import Nonce
from passbook.core.tasks import send_email
from passbook.core.models import User
from passbook.factors.base import AuthenticationFactor
from passbook.factors.password.forms import PasswordForm
from passbook.factors.view import AuthenticationView
from passbook.lib.config import CONFIG
from passbook.lib.utils.reflection import path_to_class
@ -22,7 +20,7 @@ from passbook.lib.utils.reflection import path_to_class
LOGGER = get_logger()
def authenticate(request, backends, **credentials):
def authenticate(request, backends, **credentials) -> Optional[User]:
"""If the given credentials are valid, return a User object.
Customized version of django's authenticate, which accepts a list of backends"""
@ -55,32 +53,9 @@ def authenticate(request, backends, **credentials):
class PasswordFactor(FormView, AuthenticationFactor):
"""Authentication factor which authenticates against django's AuthBackend"""
form_class = PasswordFactorForm
form_class = PasswordForm
template_name = 'login/factors/backend.html'
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):
if 'password-forgotten' in request.GET:
nonce = Nonce.objects.create(user=self.pending_user)
LOGGER.debug("DEBUG %s", str(nonce.uuid))
# Send mail to user
send_email.delay(self.pending_user.email, _('Forgotten password'),
'email/account_password_reset.html', {
'url': self.request.build_absolute_uri(
reverse('passbook_core:auth-password-reset',
kwargs={
'nonce': nonce.uuid
})
)
})
self.authenticator.cleanup()
messages.success(request, _('Check your E-Mails for a password reset link.'))
return redirect('passbook_core:auth-login')
return super().get(request, *args, **kwargs)
def form_valid(self, form):
"""Authenticate against django's authentication backend"""
uid_fields = CONFIG.y('passbook.uid_fields')

View file

@ -32,7 +32,7 @@ class PasswordFactorForm(forms.ModelForm):
class Meta:
model = PasswordFactor
fields = GENERAL_FIELDS + ['backends', 'password_policies']
fields = GENERAL_FIELDS + ['backends', 'password_policies', 'reset_factors']
widgets = {
'name': forms.TextInput(),
'order': forms.NumberInput(),
@ -40,4 +40,5 @@ class PasswordFactorForm(forms.ModelForm):
'backends': FilteredSelectMultiple(_('backends'), False,
choices=get_authentication_backends()),
'password_policies': FilteredSelectMultiple(_('password policies'), False),
'reset_factors': FilteredSelectMultiple(_('reset factors'), False),
}

View file

@ -0,0 +1,19 @@
# Generated by Django 2.2.6 on 2019-10-08 09:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('passbook_core', '0001_initial'),
('passbook_factors_password', '0002_auto_20191007_1411'),
]
operations = [
migrations.AddField(
model_name='passwordfactor',
name='reset_factors',
field=models.ManyToManyField(blank=True, related_name='reset_factors', to='passbook_core.Factor'),
),
]

View file

@ -11,6 +11,7 @@ class PasswordFactor(Factor):
backends = ArrayField(models.TextField())
password_policies = models.ManyToManyField(Policy, blank=True)
reset_factors = models.ManyToManyField(Factor, blank=True, related_name='reset_factors')
type = 'passbook.factors.password.factor.PasswordFactor'
form = 'passbook.factors.password.forms.PasswordFactorForm'

View file

@ -1,55 +0,0 @@
import time
from django.conf import settings
from django.contrib.sessions.middleware import SessionMiddleware
from django.utils.cache import patch_vary_headers
from django.utils.http import cookie_date
from structlog import get_logger
from passbook.factors.view import AuthenticationView
LOGGER = get_logger()
class SessionHostDomainMiddleware(SessionMiddleware):
def process_request(self, request):
session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
request.session = self.SessionStore(session_key)
def process_response(self, request, response):
"""
If request.session was modified, or if the configuration is to save the
session every time, save the changes and set a session cookie.
"""
try:
accessed = request.session.accessed
modified = request.session.modified
except AttributeError:
pass
else:
if accessed:
patch_vary_headers(response, ('Cookie',))
if modified or settings.SESSION_SAVE_EVERY_REQUEST:
if request.session.get_expire_at_browser_close():
max_age = None
expires = None
else:
max_age = request.session.get_expiry_age()
expires_time = time.time() + max_age
expires = cookie_date(expires_time)
# Save the session data and refresh the client cookie.
# Skip session save for 500 responses, refs #3881.
if response.status_code != 500:
request.session.save()
hosts = [request.get_host().split(':')[0]]
if AuthenticationView.SESSION_FORCE_COOKIE_HOSTNAME in request.session:
hosts.append(request.session[AuthenticationView.SESSION_FORCE_COOKIE_HOSTNAME])
LOGGER.debug("Setting hosts for session", hosts=hosts)
for host in hosts:
response.set_cookie(settings.SESSION_COOKIE_NAME,
request.session.session_key, max_age=max_age,
expires=expires, domain=host,
path=settings.SESSION_COOKIE_PATH,
secure=settings.SESSION_COOKIE_SECURE or None,
httponly=settings.SESSION_COOKIE_HTTPONLY or None)
return response

View file

@ -1,7 +1,8 @@
"""passbook app_gw views"""
from pprint import pprint
from urllib.parse import urlparse
from django.conf import settings
from django.core.cache import cache
from django.http import HttpRequest, HttpResponse
from django.views import View
from structlog import get_logger
@ -12,15 +13,26 @@ from passbook.providers.app_gw.models import ApplicationGatewayProvider
ORIGINAL_URL = 'HTTP_X_ORIGINAL_URL'
LOGGER = get_logger()
def cache_key(session_cookie: str, request: HttpRequest) -> str:
"""Cache Key for request fingerprinting"""
fprint = '_'.join([
session_cookie,
request.META.get('HTTP_HOST'),
request.META.get('PATH_INFO'),
])
return f"app_gw_{fprint}"
class NginxCheckView(AccessMixin, View):
"""View used by nginx's auth_request module"""
def dispatch(self, request: HttpRequest) -> HttpResponse:
pprint(request.META)
session_cookie = request.COOKIES.get(settings.SESSION_COOKIE_NAME, '')
_cache_key = cache_key(session_cookie, request)
if cache.get(_cache_key):
return HttpResponse(status=202)
parsed_url = urlparse(request.META.get(ORIGINAL_URL))
# request.session[AuthenticationView.SESSION_ALLOW_ABSOLUTE_NEXT] = True
# request.session[AuthenticationView.SESSION_FORCE_COOKIE_HOSTNAME] = parsed_url.hostname
print(request.user)
if not request.user.is_authenticated:
return HttpResponse(status=401)
matching = ApplicationGatewayProvider.objects.filter(
@ -31,6 +43,7 @@ class NginxCheckView(AccessMixin, View):
application = self.provider_to_application(matching.first())
has_access, _ = self.user_has_access(application, request.user)
if has_access:
cache.set(_cache_key, True)
return HttpResponse(status=202)
LOGGER.debug("User not passing", user=request.user)
return HttpResponse(status=401)

View file

@ -15,6 +15,7 @@ import os
import sys
import structlog
from celery.schedules import crontab
from sentry_sdk import init as sentry_init
from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.django import DjangoIntegration
@ -84,6 +85,7 @@ INSTALLED_APPS = [
'passbook.factors.captcha.apps.PassbookFactorCaptchaConfig',
'passbook.factors.password.apps.PassbookFactorPasswordConfig',
'passbook.factors.dummy.apps.PassbookFactorDummyConfig',
'passbook.factors.email.apps.PassbookFactorEmailConfig',
'passbook.policies.expiry.apps.PassbookPolicyExpiryConfig',
'passbook.policies.reputation.apps.PassbookPolicyReputationConfig',
@ -197,7 +199,12 @@ USE_TZ = True
# Celery settings
# Add a 10 minute timeout to all Celery tasks.
CELERY_TASK_SOFT_TIME_LIMIT = 600
CELERY_BEAT_SCHEDULE = {}
CELERY_BEAT_SCHEDULE = {
'clean_nonces': {
'task': 'passbook.core.tasks.clean_nonces',
'schedule': crontab(minute='*/5') # Run every 5 minutes
}
}
CELERY_CREATE_MISSING_QUEUES = True
CELERY_TASK_DEFAULT_QUEUE = 'passbook'
CELERY_BROKER_URL = (f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}"