diff --git a/core/classes/mail.py b/core/classes/mail.py index 92d3383..cd84977 100644 --- a/core/classes/mail.py +++ b/core/classes/mail.py @@ -21,15 +21,16 @@ class BaseMailProvider: def send_mail(self, subject, content, recipients, cc=[], bcc=[], headers={}, sender=getValue("core.mail.sender", "expephalon@localhost")): message = email.message_from_string(content) headers["From"] = sender - headers["To"] = ",".join(recipients) - headers["Cc"] = ",".join(cc) - headers["Bcc"] = ",".join(bcc) + headers["To"] = recipients if type(recipients) == str else ",".join(recipients) + headers["Cc"] = cc if type(cc) == str else ",".join(cc) + headers["Bcc"] = bcc if type(bcc) == str else ",".join(bcc) headers["Subject"] = subject headers["Message-ID"] = email.utils.make_msgid("expephalon", urlparse(getValue("core.base_url", "http://localhost/").split(":")[1]).netloc) headers["Date"] = email.utils.formatdate() for header, value in headers.items(): - message.add_header(header, value) - self.send_message(message) + if value: + message.add_header(header, value) + return self.send_message(message) class SMTPMailProvider(BaseMailProvider): def __init__(self, host=getValue("core.smtp.host", "localhost"), port=int(getValue("core.smtp.port", 0)), username=getValue("core.smtp.username", "") or None, password=getValue("core.smtp.password", "")): @@ -42,4 +43,4 @@ class SMTPMailProvider(BaseMailProvider): return "SMTP Mail" def send_message(self, message): - self.smtp.send_message(message) \ No newline at end of file + return self.smtp.send_message(message) diff --git a/core/forms/auth.py b/core/forms/auth.py index 8005427..8177238 100644 --- a/core/forms/auth.py +++ b/core/forms/auth.py @@ -17,4 +17,7 @@ class OTPVerificationForm(Form): class PWResetForm(Form): password1 = CharField(widget=PasswordInput) - password2 = CharField(widget=PasswordInput) \ No newline at end of file + password2 = CharField(widget=PasswordInput) + +class PWRequestForm(Form): + email = EmailField() \ No newline at end of file diff --git a/core/helpers/auth.py b/core/helpers/auth.py new file mode 100644 index 0000000..d0a3647 --- /dev/null +++ b/core/helpers/auth.py @@ -0,0 +1,2 @@ +def generate_pwreset_mail(user, token): + pass \ No newline at end of file diff --git a/core/helpers/mail.py b/core/helpers/mail.py index fa3ef7f..50f4ec7 100644 --- a/core/helpers/mail.py +++ b/core/helpers/mail.py @@ -1,4 +1,5 @@ from core.modules.mail import providers +from core.tasks.mail import send_mail as send_mail_task from dbsettings.functions import getValue @@ -8,5 +9,12 @@ def get_provider_by_name(name, fallback=True): def get_default_provider(fallback=True): return get_provider_by_name(getValue("core.email.provider", "smtp"), fallback) -def send_mail(provider=None, *args): - return get_provider_by_name(provider)().mail(*args) \ No newline at end of file +def send_mail(provider=get_default_provider(), **kwargs): + provider = get_provider_by_name(provider) if type(provider) == str else provider + return send_mail_task.delay(provider, **kwargs) + +def simple_send_mail(subject, content, recipients, cc=[], bcc=[], headers={}): + return send_mail(subject=subject, content=content, recipients=recipients, cc=cc, bcc=bcc, headers=headers) + +def fetch_templates(template_name): + pass \ No newline at end of file diff --git a/core/tasks/__init__.py b/core/tasks/__init__.py new file mode 100644 index 0000000..33d4183 --- /dev/null +++ b/core/tasks/__init__.py @@ -0,0 +1 @@ +from core.tasks.mail import * \ No newline at end of file diff --git a/core/tasks/mail.py b/core/tasks/mail.py new file mode 100644 index 0000000..a6c8690 --- /dev/null +++ b/core/tasks/mail.py @@ -0,0 +1,5 @@ +from celery import shared_task + +@shared_task +def send_mail(provider=None, **kwargs): + return provider().send_mail(**kwargs) \ No newline at end of file diff --git a/core/views/auth.py b/core/views/auth.py index cc54810..2ea40db 100644 --- a/core/views/auth.py +++ b/core/views/auth.py @@ -1,14 +1,16 @@ from django.conf import settings from django.views.generic import FormView, View -from django.contrib.auth import authenticate, login, logout +from django.contrib.auth import authenticate, login, logout, get_user_model from django.shortcuts import redirect from django.core.exceptions import PermissionDenied from django.contrib import messages from django.utils import timezone -from core.forms import LoginForm, OTPSelectorForm, OTPVerificationForm, PWResetForm +from core.forms import LoginForm, OTPSelectorForm, OTPVerificationForm, PWResetForm, PWRequestForm from core.models.auth import LoginSession, PWResetToken from core.helpers.otp import get_user_otps, get_otp_choices, get_otp_by_name +from core.helpers.mail import send_mail +from core.helpers.auth import generate_pwreset_mail from dbsettings.functions import getValue @@ -152,4 +154,17 @@ class PWResetView(FormView): user.set_password(form.cleaned_data["password1"]) user.save() messages.success(self.request, "Your password has been changed. You can now login with your new password.") - return redirect("login") \ No newline at end of file + return redirect("login") + +class PWRequestView(FormView): + template_name = f"{settings.EXPEPHALON_BACKEND}/auth/pwrequest.html" + form_class = PWRequestForm + + def form_valid(self, form): + try: + user = get_user_model().objects.get(username=form.cleaned_data["email"]) + token = PWResetToken.objects.create(user=user) + + finally: + messages.success(self.request, "If a matching account was found, you should shortly receive an email containing password reset instructions. If you have not received this message after five minutes, please verify that you have entered the correct email address, or contact support.") + return redirect("login") \ No newline at end of file diff --git a/debian-packages.txt b/debian-packages.txt new file mode 100644 index 0000000..df29928 --- /dev/null +++ b/debian-packages.txt @@ -0,0 +1,2 @@ +rabbitmq-server +memcached \ No newline at end of file diff --git a/expephalon/__init__.py b/expephalon/__init__.py index e69de29..75daf47 100644 --- a/expephalon/__init__.py +++ b/expephalon/__init__.py @@ -0,0 +1,3 @@ +from expephalon.celery import app as celery_app + +__all__ = ('celery_app',) \ No newline at end of file diff --git a/expephalon/celery.py b/expephalon/celery.py new file mode 100644 index 0000000..489d160 --- /dev/null +++ b/expephalon/celery.py @@ -0,0 +1,22 @@ +import os + +from celery import Celery + +# set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'expephalon.settings') + +app = Celery('expephalon') + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Load task modules from all registered Django app configs. +app.autodiscover_tasks() + + +@app.task(bind=True) +def debug_task(self): + print('Request: {0!r}'.format(self.request)) \ No newline at end of file diff --git a/expephalon/custom_settings.dist.py b/expephalon/custom_settings.dist.py index 8161f86..d535e55 100644 --- a/expephalon/custom_settings.dist.py +++ b/expephalon/custom_settings.dist.py @@ -29,11 +29,24 @@ DEBUG = True ALLOWED_HOSTS = [] -# To add frontend or backend templates, move them to the /templates subdirectory, then insert their name (i.e. the directory name) in the appropriate field. Move any required statics to the /static subdirectory. +# To add frontend or backend templates, move them to the /templates subdirectory, then insert their name (i.e. the directory name) in the +# appropriate field. Move any required statics to the /static subdirectory EXPEPHALON_FRONTEND = "frontend" EXPEPHALON_BACKEND = "backend" # To add Expephalon modules, move them to the Expephalon root directory, then add them to this list -EXPEPHALON_MODULES = [] \ No newline at end of file +EXPEPHALON_MODULES = [] + +# To use memcached for caching, add IP:PORT or unix:PATH - default setting should be good for an unmodified local setup of memcached + +MEMCACHED_LOCATION = ["127.0.0.1:11211"] + +# RabbitMQ is required for queues to work - default settings should be good for an unmodified local setup of RabbitMQ, +# but you might still want to configure it to use a password + +RABBITMQ_LOCATION = "127.0.0.1:5672" +RABBITMQ_VHOST = "" +RABBITMQ_USER = "guest" +RABBITMQ_PASS = "guest" \ No newline at end of file diff --git a/expephalon/settings.py b/expephalon/settings.py index 73591c6..bd9ab28 100644 --- a/expephalon/settings.py +++ b/expephalon/settings.py @@ -18,6 +18,8 @@ INSTALLED_APPS = [ 'bootstrap4', 'core', 'dbsettings', + 'django_celery_results', + 'django_celery_beat', ] + EXPEPHALON_MODULES MIDDLEWARE = [ @@ -123,3 +125,22 @@ PASSWORD_HASHERS = [ DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' AWS_DEFAULT_ACL = None + +# Caching + +if MEMCACHED_LOCATION: + CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache', + 'LOCATION': MEMCACHED_LOCATION, + } + } + +# Queue + +CELERY_TASK_SERIALIZER = "pickle" +CELERY_RESULT_SERIALIZER = "pickle" +CELERY_ACCEPT_CONTENT = ['pickle'] +CELERY_CACHE_BACKEND = 'default' +CELERY_BROKER_URL = f"amqp://{RABBITMQ_USER}:{RABBITMQ_PASS}@{RABBITMQ_LOCATION}/{RABBITMQ_VHOST}" +CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler" \ No newline at end of file diff --git a/mail_templates/pwreset_mail.html b/mail_templates/pwreset_mail.html new file mode 100644 index 0000000..e69de29 diff --git a/mail_templates/pwreset_mail.txt b/mail_templates/pwreset_mail.txt new file mode 100644 index 0000000..45cb109 --- /dev/null +++ b/mail_templates/pwreset_mail.txt @@ -0,0 +1,11 @@ +Hi {{ first_name }}, + +Somebody (hopefully you) requested a new password for your {{ sitename }} account. If this was you, please click the following link to reset your password: + +{{ link }} + +If it was not you, you can ignore this message. The link will expire in 24 hours. + +Best regards + +Your {{ sitename }} Team \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cafa35f..3320023 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,6 @@ django-phonenumber-field[phonenumbers] django-bootstrap4 wheel git+https://kumig.it/kumisystems/django-dbsettings.git +celery +django-celery-results +django-celery-beat diff --git a/totp/__init__.py b/totp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/totp/apps.py b/totp/apps.py new file mode 100644 index 0000000..df50f88 --- /dev/null +++ b/totp/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TotpConfig(AppConfig): + name = 'totp' diff --git a/totp/models.py b/totp/models.py new file mode 100644 index 0000000..f135ed5 --- /dev/null +++ b/totp/models.py @@ -0,0 +1,12 @@ +from django.db.models import Model, CharField, ForeignKey, CASCADE, DateTimeField +from django.contrib.auth import get_user_model + +from dbsettings.functions import getValue + +import pyotp + +# Create your models here. + +class TOTPUser(Model): + secret = CharField(max_length=32, default=pyotp.random_base32()) + user = ForeignKey(get_user_model(), CASCADE) diff --git a/totp/otp.py b/totp/otp.py new file mode 100644 index 0000000..89168a3 --- /dev/null +++ b/totp/otp.py @@ -0,0 +1,29 @@ +from core.classes.otp import BaseOTPProvider +from totp.models import TOTPUser + +from dbsettings.functions import getValue + +from django.utils import timezone + +import pyotp + +class TOTP(BaseOTPProvider): + @property + def get_name(self): + return "Time-based OTP" + + @property + def is_active(self): + return True + + def start_authentication(self, user): + return "Please enter the token displayed in your app." + + def validate_token(self, user, token): + try: + otpuser = TOTPUser.objects.get(user=user) + return pyotp.TOTP(otpuser.secret).verify(token) + except OTPUser.DoesNotExist: + return False + +OTPPROVIDERS = {"totp": TOTP} \ No newline at end of file diff --git a/totp/requirements.txt b/totp/requirements.txt new file mode 100644 index 0000000..6c1907d --- /dev/null +++ b/totp/requirements.txt @@ -0,0 +1 @@ +pyotp