factors/email(minor): start rebuilding email integration as factor
This commit is contained in:
parent
171c5b9759
commit
d91a852eda
|
@ -1,5 +0,0 @@
|
||||||
"""core settings"""
|
|
||||||
|
|
||||||
PASSBOOK_CORE_FACTORS = [
|
|
||||||
|
|
||||||
]
|
|
|
@ -1,28 +1,15 @@
|
||||||
"""passbook core tasks"""
|
"""passbook core tasks"""
|
||||||
from datetime import datetime
|
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 structlog import get_logger
|
||||||
|
|
||||||
from passbook.core.models import Nonce
|
from passbook.core.models import Nonce
|
||||||
from passbook.lib.config import CONFIG
|
|
||||||
from passbook.root.celery import CELERY_APP
|
from passbook.root.celery import CELERY_APP
|
||||||
|
|
||||||
LOGGER = get_logger()
|
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()
|
@CELERY_APP.task()
|
||||||
def clean_nonces():
|
def clean_nonces():
|
||||||
"""Remove expired nonces"""
|
"""Remove expired nonces"""
|
||||||
amount, _ = Nonce.objects.filter(expires__lt=datetime.now(), expiring=True).delete()
|
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)
|
||||||
|
|
|
@ -15,7 +15,6 @@ from structlog import get_logger
|
||||||
from passbook.core.forms.authentication import LoginForm, SignUpForm
|
from passbook.core.forms.authentication import LoginForm, SignUpForm
|
||||||
from passbook.core.models import Invitation, Nonce, Source, User
|
from passbook.core.models import Invitation, Nonce, Source, User
|
||||||
from passbook.core.signals import invitation_used, user_signed_up
|
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.password.exceptions import PasswordPolicyInvalid
|
||||||
from passbook.factors.view import AuthenticationView, _redirect_with_qs
|
from passbook.factors.view import AuthenticationView, _redirect_with_qs
|
||||||
from passbook.lib.config import CONFIG
|
from passbook.lib.config import CONFIG
|
||||||
|
@ -97,7 +96,7 @@ class SignUpView(UserPassesTestMixin, FormView):
|
||||||
template_name = 'login/form.html'
|
template_name = 'login/form.html'
|
||||||
form_class = SignUpForm
|
form_class = SignUpForm
|
||||||
success_url = '.'
|
success_url = '.'
|
||||||
# Invitation insatnce, if invitation link was used
|
# Invitation instance, if invitation link was used
|
||||||
_invitation = None
|
_invitation = None
|
||||||
# Instance of newly created user
|
# Instance of newly created user
|
||||||
_user = None
|
_user = None
|
||||||
|
@ -152,23 +151,23 @@ class SignUpView(UserPassesTestMixin, FormView):
|
||||||
for error in exc.messages:
|
for error in exc.messages:
|
||||||
errors.append(error)
|
errors.append(error)
|
||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
needs_confirmation = True
|
# needs_confirmation = True
|
||||||
if self._invitation and not self._invitation.needs_confirmation:
|
# if self._invitation and not self._invitation.needs_confirmation:
|
||||||
needs_confirmation = False
|
# needs_confirmation = False
|
||||||
if needs_confirmation:
|
# if needs_confirmation:
|
||||||
nonce = Nonce.objects.create(user=self._user)
|
# nonce = Nonce.objects.create(user=self._user)
|
||||||
LOGGER.debug(str(nonce.uuid))
|
# LOGGER.debug(str(nonce.uuid))
|
||||||
# Send email to user
|
# # Send email to user
|
||||||
send_email.delay(self._user.email, _('Confirm your account.'),
|
# send_email.delay(self._user.email, _('Confirm your account.'),
|
||||||
'email/account_confirm.html', {
|
# 'email/account_confirm.html', {
|
||||||
'url': self.request.build_absolute_uri(
|
# 'url': self.request.build_absolute_uri(
|
||||||
reverse('passbook_core:auth-sign-up-confirm', kwargs={
|
# reverse('passbook_core:auth-sign-up-confirm', kwargs={
|
||||||
'nonce': nonce.uuid
|
# 'nonce': nonce.uuid
|
||||||
})
|
# })
|
||||||
)
|
# )
|
||||||
})
|
# })
|
||||||
self._user.is_active = False
|
# self._user.is_active = False
|
||||||
self._user.save()
|
# self._user.save()
|
||||||
self.consume_invitation()
|
self.consume_invitation()
|
||||||
messages.success(self.request, _("Successfully signed up!"))
|
messages.success(self.request, _("Successfully signed up!"))
|
||||||
LOGGER.debug("Successfully signed up %s",
|
LOGGER.debug("Successfully signed up %s",
|
||||||
|
|
|
@ -14,13 +14,14 @@ class AuthenticationFactor(TemplateView):
|
||||||
|
|
||||||
form: ModelForm = None
|
form: ModelForm = None
|
||||||
required: bool = True
|
required: bool = True
|
||||||
authenticator: AuthenticationView = None
|
authenticator: AuthenticationView
|
||||||
pending_user: User = None
|
pending_user: User
|
||||||
request: HttpRequest = None
|
request: HttpRequest = None
|
||||||
template_name = 'login/form_with_user.html'
|
template_name = 'login/form_with_user.html'
|
||||||
|
|
||||||
def __init__(self, authenticator: AuthenticationView):
|
def __init__(self, authenticator: AuthenticationView):
|
||||||
self.authenticator = authenticator
|
self.authenticator = authenticator
|
||||||
|
self.pending_user = None
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs['config'] = CONFIG.y('passbook')
|
kwargs['config'] = CONFIG.y('passbook')
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""email factor admin"""
|
||||||
|
|
||||||
|
from passbook.lib.admin import admin_autoregister
|
||||||
|
|
||||||
|
admin_autoregister('passbook_factors_email')
|
|
@ -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')
|
|
@ -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()
|
|
@ -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)'),
|
||||||
|
}
|
|
@ -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',),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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')
|
|
@ -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()
|
|
@ -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")
|
|
@ -1,20 +1,18 @@
|
||||||
"""passbook multi-factor authentication engine"""
|
"""passbook multi-factor authentication engine"""
|
||||||
from inspect import Signature
|
from inspect import Signature
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from django.contrib import messages
|
|
||||||
from django.contrib.auth import _clean_credentials
|
from django.contrib.auth import _clean_credentials
|
||||||
from django.contrib.auth.signals import user_login_failed
|
from django.contrib.auth.signals import user_login_failed
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.forms.utils import ErrorList
|
from django.forms.utils import ErrorList
|
||||||
from django.shortcuts import redirect, reverse
|
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django.views.generic import FormView
|
from django.views.generic import FormView
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
from passbook.core.forms.authentication import PasswordFactorForm
|
from passbook.core.models import User
|
||||||
from passbook.core.models import Nonce
|
|
||||||
from passbook.core.tasks import send_email
|
|
||||||
from passbook.factors.base import AuthenticationFactor
|
from passbook.factors.base import AuthenticationFactor
|
||||||
|
from passbook.factors.password.forms import PasswordForm
|
||||||
from passbook.factors.view import AuthenticationView
|
from passbook.factors.view import AuthenticationView
|
||||||
from passbook.lib.config import CONFIG
|
from passbook.lib.config import CONFIG
|
||||||
from passbook.lib.utils.reflection import path_to_class
|
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()
|
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.
|
"""If the given credentials are valid, return a User object.
|
||||||
|
|
||||||
Customized version of django's authenticate, which accepts a list of backends"""
|
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):
|
class PasswordFactor(FormView, AuthenticationFactor):
|
||||||
"""Authentication factor which authenticates against django's AuthBackend"""
|
"""Authentication factor which authenticates against django's AuthBackend"""
|
||||||
|
|
||||||
form_class = PasswordFactorForm
|
form_class = PasswordForm
|
||||||
template_name = 'login/factors/backend.html'
|
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):
|
def form_valid(self, form):
|
||||||
"""Authenticate against django's authentication backend"""
|
"""Authenticate against django's authentication backend"""
|
||||||
uid_fields = CONFIG.y('passbook.uid_fields')
|
uid_fields = CONFIG.y('passbook.uid_fields')
|
||||||
|
|
|
@ -32,7 +32,7 @@ class PasswordFactorForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = PasswordFactor
|
model = PasswordFactor
|
||||||
fields = GENERAL_FIELDS + ['backends', 'password_policies']
|
fields = GENERAL_FIELDS + ['backends', 'password_policies', 'reset_factors']
|
||||||
widgets = {
|
widgets = {
|
||||||
'name': forms.TextInput(),
|
'name': forms.TextInput(),
|
||||||
'order': forms.NumberInput(),
|
'order': forms.NumberInput(),
|
||||||
|
@ -40,4 +40,5 @@ class PasswordFactorForm(forms.ModelForm):
|
||||||
'backends': FilteredSelectMultiple(_('backends'), False,
|
'backends': FilteredSelectMultiple(_('backends'), False,
|
||||||
choices=get_authentication_backends()),
|
choices=get_authentication_backends()),
|
||||||
'password_policies': FilteredSelectMultiple(_('password policies'), False),
|
'password_policies': FilteredSelectMultiple(_('password policies'), False),
|
||||||
|
'reset_factors': FilteredSelectMultiple(_('reset factors'), False),
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -11,6 +11,7 @@ class PasswordFactor(Factor):
|
||||||
|
|
||||||
backends = ArrayField(models.TextField())
|
backends = ArrayField(models.TextField())
|
||||||
password_policies = models.ManyToManyField(Policy, blank=True)
|
password_policies = models.ManyToManyField(Policy, blank=True)
|
||||||
|
reset_factors = models.ManyToManyField(Factor, blank=True, related_name='reset_factors')
|
||||||
|
|
||||||
type = 'passbook.factors.password.factor.PasswordFactor'
|
type = 'passbook.factors.password.factor.PasswordFactor'
|
||||||
form = 'passbook.factors.password.forms.PasswordFactorForm'
|
form = 'passbook.factors.password.forms.PasswordFactorForm'
|
||||||
|
|
|
@ -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
|
|
|
@ -1,7 +1,8 @@
|
||||||
"""passbook app_gw views"""
|
"""passbook app_gw views"""
|
||||||
from pprint import pprint
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
@ -12,15 +13,26 @@ from passbook.providers.app_gw.models import ApplicationGatewayProvider
|
||||||
ORIGINAL_URL = 'HTTP_X_ORIGINAL_URL'
|
ORIGINAL_URL = 'HTTP_X_ORIGINAL_URL'
|
||||||
LOGGER = get_logger()
|
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):
|
class NginxCheckView(AccessMixin, View):
|
||||||
|
"""View used by nginx's auth_request module"""
|
||||||
|
|
||||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
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))
|
parsed_url = urlparse(request.META.get(ORIGINAL_URL))
|
||||||
# request.session[AuthenticationView.SESSION_ALLOW_ABSOLUTE_NEXT] = True
|
# request.session[AuthenticationView.SESSION_ALLOW_ABSOLUTE_NEXT] = True
|
||||||
# request.session[AuthenticationView.SESSION_FORCE_COOKIE_HOSTNAME] = parsed_url.hostname
|
# request.session[AuthenticationView.SESSION_FORCE_COOKIE_HOSTNAME] = parsed_url.hostname
|
||||||
print(request.user)
|
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return HttpResponse(status=401)
|
return HttpResponse(status=401)
|
||||||
matching = ApplicationGatewayProvider.objects.filter(
|
matching = ApplicationGatewayProvider.objects.filter(
|
||||||
|
@ -31,6 +43,7 @@ class NginxCheckView(AccessMixin, View):
|
||||||
application = self.provider_to_application(matching.first())
|
application = self.provider_to_application(matching.first())
|
||||||
has_access, _ = self.user_has_access(application, request.user)
|
has_access, _ = self.user_has_access(application, request.user)
|
||||||
if has_access:
|
if has_access:
|
||||||
|
cache.set(_cache_key, True)
|
||||||
return HttpResponse(status=202)
|
return HttpResponse(status=202)
|
||||||
LOGGER.debug("User not passing", user=request.user)
|
LOGGER.debug("User not passing", user=request.user)
|
||||||
return HttpResponse(status=401)
|
return HttpResponse(status=401)
|
||||||
|
|
|
@ -15,6 +15,7 @@ import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
from celery.schedules import crontab
|
||||||
from sentry_sdk import init as sentry_init
|
from sentry_sdk import init as sentry_init
|
||||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||||
from sentry_sdk.integrations.django import DjangoIntegration
|
from sentry_sdk.integrations.django import DjangoIntegration
|
||||||
|
@ -84,6 +85,7 @@ INSTALLED_APPS = [
|
||||||
'passbook.factors.captcha.apps.PassbookFactorCaptchaConfig',
|
'passbook.factors.captcha.apps.PassbookFactorCaptchaConfig',
|
||||||
'passbook.factors.password.apps.PassbookFactorPasswordConfig',
|
'passbook.factors.password.apps.PassbookFactorPasswordConfig',
|
||||||
'passbook.factors.dummy.apps.PassbookFactorDummyConfig',
|
'passbook.factors.dummy.apps.PassbookFactorDummyConfig',
|
||||||
|
'passbook.factors.email.apps.PassbookFactorEmailConfig',
|
||||||
|
|
||||||
'passbook.policies.expiry.apps.PassbookPolicyExpiryConfig',
|
'passbook.policies.expiry.apps.PassbookPolicyExpiryConfig',
|
||||||
'passbook.policies.reputation.apps.PassbookPolicyReputationConfig',
|
'passbook.policies.reputation.apps.PassbookPolicyReputationConfig',
|
||||||
|
@ -197,7 +199,12 @@ USE_TZ = True
|
||||||
# Celery settings
|
# Celery settings
|
||||||
# Add a 10 minute timeout to all Celery tasks.
|
# Add a 10 minute timeout to all Celery tasks.
|
||||||
CELERY_TASK_SOFT_TIME_LIMIT = 600
|
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_CREATE_MISSING_QUEUES = True
|
||||||
CELERY_TASK_DEFAULT_QUEUE = 'passbook'
|
CELERY_TASK_DEFAULT_QUEUE = 'passbook'
|
||||||
CELERY_BROKER_URL = (f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}"
|
CELERY_BROKER_URL = (f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}"
|
||||||
|
|
Reference in New Issue