diff --git a/passbook/core/settings.py b/passbook/core/settings.py deleted file mode 100644 index 3cd27d42f..000000000 --- a/passbook/core/settings.py +++ /dev/null @@ -1,5 +0,0 @@ -"""core settings""" - -PASSBOOK_CORE_FACTORS = [ - -] diff --git a/passbook/core/tasks.py b/passbook/core/tasks.py index bc9960ec2..205572b65 100644 --- a/passbook/core/tasks.py +++ b/passbook/core/tasks.py @@ -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) diff --git a/passbook/core/views/authentication.py b/passbook/core/views/authentication.py index c2767467e..528f94256 100644 --- a/passbook/core/views/authentication.py +++ b/passbook/core/views/authentication.py @@ -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", diff --git a/passbook/factors/base.py b/passbook/factors/base.py index f4754bf53..e3219f7b3 100644 --- a/passbook/factors/base.py +++ b/passbook/factors/base.py @@ -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') diff --git a/passbook/factors/email/__init__.py b/passbook/factors/email/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/factors/email/admin.py b/passbook/factors/email/admin.py new file mode 100644 index 000000000..305cc3acd --- /dev/null +++ b/passbook/factors/email/admin.py @@ -0,0 +1,5 @@ +"""email factor admin""" + +from passbook.lib.admin import admin_autoregister + +admin_autoregister('passbook_factors_email') diff --git a/passbook/factors/email/apps.py b/passbook/factors/email/apps.py new file mode 100644 index 000000000..f6f04c9bc --- /dev/null +++ b/passbook/factors/email/apps.py @@ -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') diff --git a/passbook/factors/email/factor.py b/passbook/factors/email/factor.py new file mode 100644 index 000000000..7085b841b --- /dev/null +++ b/passbook/factors/email/factor.py @@ -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() diff --git a/passbook/factors/email/forms.py b/passbook/factors/email/forms.py new file mode 100644 index 000000000..030f2efaa --- /dev/null +++ b/passbook/factors/email/forms.py @@ -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)'), + } diff --git a/passbook/factors/email/migrations/0001_initial.py b/passbook/factors/email/migrations/0001_initial.py new file mode 100644 index 000000000..5514a4f7c --- /dev/null +++ b/passbook/factors/email/migrations/0001_initial.py @@ -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',), + ), + ] diff --git a/passbook/factors/email/migrations/__init__.py b/passbook/factors/email/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/factors/email/models.py b/passbook/factors/email/models.py new file mode 100644 index 000000000..6becb241f --- /dev/null +++ b/passbook/factors/email/models.py @@ -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') diff --git a/passbook/factors/email/tasks.py b/passbook/factors/email/tasks.py new file mode 100644 index 000000000..7b03d3584 --- /dev/null +++ b/passbook/factors/email/tasks.py @@ -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() diff --git a/passbook/factors/email/utils.py b/passbook/factors/email/utils.py new file mode 100644 index 000000000..3816df29d --- /dev/null +++ b/passbook/factors/email/utils.py @@ -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") diff --git a/passbook/factors/password/factor.py b/passbook/factors/password/factor.py index afccdc242..0d5325be1 100644 --- a/passbook/factors/password/factor.py +++ b/passbook/factors/password/factor.py @@ -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') diff --git a/passbook/factors/password/forms.py b/passbook/factors/password/forms.py index 32850fa70..ae0804445 100644 --- a/passbook/factors/password/forms.py +++ b/passbook/factors/password/forms.py @@ -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), } diff --git a/passbook/factors/password/migrations/0003_passwordfactor_reset_factors.py b/passbook/factors/password/migrations/0003_passwordfactor_reset_factors.py new file mode 100644 index 000000000..19531171d --- /dev/null +++ b/passbook/factors/password/migrations/0003_passwordfactor_reset_factors.py @@ -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'), + ), + ] diff --git a/passbook/factors/password/models.py b/passbook/factors/password/models.py index b255e87e6..31b7ada9d 100644 --- a/passbook/factors/password/models.py +++ b/passbook/factors/password/models.py @@ -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' diff --git a/passbook/providers/app_gw/middleware.py b/passbook/providers/app_gw/middleware.py deleted file mode 100644 index 9aa94c506..000000000 --- a/passbook/providers/app_gw/middleware.py +++ /dev/null @@ -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 diff --git a/passbook/providers/app_gw/views.py b/passbook/providers/app_gw/views.py index 5090bf61f..56148b940 100644 --- a/passbook/providers/app_gw/views.py +++ b/passbook/providers/app_gw/views.py @@ -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) diff --git a/passbook/root/settings.py b/passbook/root/settings.py index 51a815bcb..dab4d9021 100644 --- a/passbook/root/settings.py +++ b/passbook/root/settings.py @@ -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')}"