diff --git a/docs/dev/apps.md b/docs/dev/apps.md index 9489fbd..d50bdf5 100644 --- a/docs/dev/apps.md +++ b/docs/dev/apps.md @@ -2,3 +2,16 @@ The repapp is split into different modules, realized as Django apps, to improve the maintainability. +## one time login + +The one time login module provides a login for guests using a one time secret as part of an URL. + +### Settings + +- The templates use the `settings.ORGANIZATION` variable to fill the organization name. +- The Django mail module is used to send emails. See https://docs.djangoproject.com/en/4.2/topics/email/ for details how to configure the mail host. + +## mail interface + + +## repapp members diff --git a/docs/dev/configuration.md b/docs/dev/configuration.md new file mode 100644 index 0000000..a0affae --- /dev/null +++ b/docs/dev/configuration.md @@ -0,0 +1,23 @@ +# Repapp configuration + +## Debug mode + +The Django debug mode is activated if there is a environment variable `DJANGO_DEBUG` with the value "true", "1" or "t". By default, the debug mode is disabled. + +## Organization name + +Some templates use and organization name. This value is taken from `settings.ORGANIZATION` and defaults to "Repair-Café Hilpoltstein". + +## Email settings + +The following environment variables are used to configure the Django email feature: + +``` Python +EMAIL_HOST = os.getenv("DJANGO_EMAIL_HOST", "") +EMAIL_PORT = (int)(os.getenv("DJANGO_EMAIL_PORT", "25")) +EMAIL_HOST_USER = os.getenv("DJANGO_EMAIL_HOST_USER", None) +EMAIL_HOST_PASSWORD = os.getenv("DJANGO_EMAIL_HOST_PASSWORD", None) +EMAIL_USE_TLS = os.getenv("DJANGO_EMAIL_USE_TLS", "true") in ("true", "1", "t") +``` +See https://docs.djangoproject.com/en/4.2/topics/email/ for more details. + diff --git a/mkdocs.yml b/mkdocs.yml index 0952850..01fa306 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,6 +14,7 @@ nav: - 'Use cases': 'dev/use_cases.md' - Apps: 'dev/apps.md' - Objects: 'dev/objects.md' + - 'Repapp configuration': 'dev/configuration.md' - 'Repair-Café & Makespace': - Repair-Café: https://www.repaircafe-hilpoltstein.de - Makespace: https://makes-hacks-hip.de diff --git a/repapp/data/.gitkeep b/repapp/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/repapp/mail_inteface/__init__.py b/repapp/mail_inteface/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repapp/mail_inteface/admin.py b/repapp/mail_inteface/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/repapp/mail_inteface/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/repapp/mail_inteface/apps.py b/repapp/mail_inteface/apps.py new file mode 100644 index 0000000..9e9112a --- /dev/null +++ b/repapp/mail_inteface/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MailIntefaceConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'mail_inteface' diff --git a/repapp/mail_inteface/migrations/__init__.py b/repapp/mail_inteface/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repapp/mail_inteface/models.py b/repapp/mail_inteface/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/repapp/mail_inteface/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/repapp/mail_inteface/tests.py b/repapp/mail_inteface/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/repapp/mail_inteface/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/repapp/mail_inteface/views.py b/repapp/mail_inteface/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/repapp/mail_inteface/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/repapp/manage.py b/repapp/manage.py new file mode 100755 index 0000000..5e129a0 --- /dev/null +++ b/repapp/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'repapp.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/repapp/one_time_login/__init__.py b/repapp/one_time_login/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repapp/one_time_login/admin.py b/repapp/one_time_login/admin.py new file mode 100644 index 0000000..6dadf5d --- /dev/null +++ b/repapp/one_time_login/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from .models import OneTimeLogin + + +@admin.register(OneTimeLogin) +class OneTimeLoginAdmin(admin.ModelAdmin): + list_display = ['user', 'url', 'login_used', 'login_date', 'created'] + list_filter = ['user', 'url', 'login_used', 'login_date', 'created'] + search_fields = ['user', 'url'] + raw_id_fields = ['user'] + date_hierarchy = 'created' + ordering = ['created', 'user', 'url'] diff --git a/repapp/one_time_login/api.py b/repapp/one_time_login/api.py new file mode 100644 index 0000000..a8e737d --- /dev/null +++ b/repapp/one_time_login/api.py @@ -0,0 +1,64 @@ +import os +import datetime +import random +import logging +from hashlib import sha256 +from django.conf import settings +from django.contrib import messages +from django.core.mail import send_mail +from django.template.loader import render_to_string +from django.utils.translation import gettext_lazy as _ +from .models import OneTimeLogin + + +logger = logging.getLogger(__name__) + + +def create_one_time_login(user, url) -> str: + """ + create_one_time_login creates a one time login object for guest user logins. + """ + secret = sha256( + f'{user.email}{url}{datetime.datetime.now()}{random.randint(0,9999999)}'.encode( + 'utf-8') + ).hexdigest() + secret_hash = sha256(secret.encode('utf-8')).hexdigest() + otl = OneTimeLogin( + secret=secret_hash, + user=user, + url=url, + ) + otl.save() + return secret + + +def send_one_time_login_mail(secret, mail, request): + """ + Send a mail with a one time login link. + """ + url = request.build_absolute_uri( + f'/onetimelogin/{secret}/') + subject = render_to_string('one_time_login/mail_subject.txt', { + 'organization': settings.ORGANIZATION, + }).replace('\n', '') + html = render_to_string('one_time_login/mail_content.html', { + 'link': url, + 'organization': settings.ORGANIZATION, + }) + text = render_to_string('one_time_login/mail_content.html', { + 'link': url, + 'organization': settings.ORGANIZATION, + }) + + send_ok = send_mail( + subject=subject, + message=text, + from_email=os.getenv("DJANGO_SENDER_ADDRESS", ""), + recipient_list=[mail], + fail_silently=True, + html_message=html + ) + + if send_ok > 0: + messages.add_message(request, messages.INFO, _( + 'A new login link was sent by mail.')) diff --git a/repapp/one_time_login/apps.py b/repapp/one_time_login/apps.py new file mode 100644 index 0000000..16de268 --- /dev/null +++ b/repapp/one_time_login/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OneTimeLoginConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'one_time_login' diff --git a/repapp/one_time_login/migrations/0001_initial.py b/repapp/one_time_login/migrations/0001_initial.py new file mode 100644 index 0000000..8ca3e66 --- /dev/null +++ b/repapp/one_time_login/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2 on 2023-08-27 19:52 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='OneTimeLogin', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('secret', models.CharField(max_length=200, unique=True, verbose_name='Secret')), + ('url', models.CharField(max_length=200, verbose_name='URL')), + ('created', models.DateField(default=django.utils.timezone.now, verbose_name='Creation date')), + ('login_used', models.BooleanField(default=False, verbose_name='Was the login used?')), + ('login_date', models.DateField(null=True, verbose_name='Login date')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'One time login', + 'verbose_name_plural': 'One time logins', + }, + ), + ] diff --git a/repapp/one_time_login/migrations/__init__.py b/repapp/one_time_login/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repapp/one_time_login/models.py b/repapp/one_time_login/models.py new file mode 100644 index 0000000..7a0dded --- /dev/null +++ b/repapp/one_time_login/models.py @@ -0,0 +1,35 @@ +""" +Data models for the one time login feature. +""" + +import django.utils.timezone +from django.db import models +from django.conf import settings +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + + +class OneTimeLogin(models.Model): + """ + A one time login (otl) is a secret which can be used once to login a user. + """ + secret = models.CharField(max_length=200, verbose_name=_( + "Secret"), unique=True, null=False) + url = models.CharField(max_length=200, verbose_name=_("URL")) + created = models.DateField(verbose_name=_( + "Creation date"), default=django.utils.timezone.now) + login_used = models.BooleanField( + verbose_name=_("Was the login used?"), default=False) + login_date = models.DateField(verbose_name=_("Login date"), null=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=False, verbose_name=_("User")) + + class Meta: + verbose_name = _('One time login') + verbose_name_plural = _('One time logins') + + def __str__(self): + return f'One time login for {self.user}' + + def get_absolute_url(self): + return reverse('one_time_login:login', args=[self.secret]) diff --git a/repapp/one_time_login/templates/one_time_login/mail_content.html b/repapp/one_time_login/templates/one_time_login/mail_content.html new file mode 100644 index 0000000..3ea3845 --- /dev/null +++ b/repapp/one_time_login/templates/one_time_login/mail_content.html @@ -0,0 +1,19 @@ + + + + + + + One time login + + +

Hello,

+

+ we prepared a one time login for you. Click here to use the link. +

+

+ Kind regards, + {{ organization }} +

+ + diff --git a/repapp/one_time_login/templates/one_time_login/mail_content.txt b/repapp/one_time_login/templates/one_time_login/mail_content.txt new file mode 100644 index 0000000..2cea407 --- /dev/null +++ b/repapp/one_time_login/templates/one_time_login/mail_content.txt @@ -0,0 +1,6 @@ +Hello, + +we prepared a one time login for you. Open {{ link }} in your browser to use the link. + +Kind regards, +{{ organization }} diff --git a/repapp/one_time_login/templates/one_time_login/mail_subject.txt b/repapp/one_time_login/templates/one_time_login/mail_subject.txt new file mode 100644 index 0000000..5f06fde --- /dev/null +++ b/repapp/one_time_login/templates/one_time_login/mail_subject.txt @@ -0,0 +1 @@ +{{ organization }} - Einmal-Ammeldelink \ No newline at end of file diff --git a/repapp/one_time_login/tests.py b/repapp/one_time_login/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/repapp/one_time_login/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/repapp/one_time_login/urls.py b/repapp/one_time_login/urls.py new file mode 100644 index 0000000..2df01bf --- /dev/null +++ b/repapp/one_time_login/urls.py @@ -0,0 +1,13 @@ +""" +Urls of RepApp. +""" +from django.urls import path +from . import views + + +app_name = 'one_time_login' + + +urlpatterns = [ + path("/", views.one_time_login, name="login"), +] diff --git a/repapp/one_time_login/views.py b/repapp/one_time_login/views.py new file mode 100644 index 0000000..4da11b8 --- /dev/null +++ b/repapp/one_time_login/views.py @@ -0,0 +1,43 @@ +import time +from hashlib import sha256 +from django.contrib import messages +from django.contrib.auth import authenticate, login +from django.shortcuts import get_object_or_404 +from django.http import HttpResponseRedirect +from django.urls import reverse_lazy +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from .models import OneTimeLogin +from .api import create_one_time_login, send_one_time_login_mail + + +def one_time_login(request, secret: str): + """ + View for one time login. + """ + # waste a little time as brute force protection + time.sleep(1) + + secret_hash = sha256(secret.encode('utf-8')).hexdigest() + otl = get_object_or_404(OneTimeLogin, secret=secret_hash) + + if otl.login_used: + messages.add_message(request, messages.ERROR, + _('Login was used already.')) + new_secret = create_one_time_login(otl.user, otl.url) + send_one_time_login_mail(new_secret, otl.user.email, request) + return HttpResponseRedirect(reverse_lazy('index')) + else: + otl.login_used = True + otl.login_date = now() + otl.save() + + user = authenticate(request, username=secret_hash, password=None) + + if user is not None: + login(request, user) + messages.add_message(request, messages.INFO, _('Login successful!')) + return HttpResponseRedirect(otl.url) + else: + messages.add_message(request, messages.ERROR, _('Login failed!')) + return HttpResponseRedirect(reverse_lazy('index')) diff --git a/repapp/repapp/__init__.py b/repapp/repapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repapp/repapp/asgi.py b/repapp/repapp/asgi.py new file mode 100644 index 0000000..91290eb --- /dev/null +++ b/repapp/repapp/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for repapp project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'repapp.settings') + +application = get_asgi_application() diff --git a/repapp/repapp/settings.py b/repapp/repapp/settings.py new file mode 100644 index 0000000..a974947 --- /dev/null +++ b/repapp/repapp/settings.py @@ -0,0 +1,162 @@ +""" +Django settings for repapp project. + +Generated by 'django-admin startproject' using Django 4.2. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +import os +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-py(c)@wu#^$!rd_eyxg7-1j&)ep$8+1w^#m4mw909@yd^+_cx9' + +# SECURITY WARNING: don't run with debug turned on in production! +# DEBUG = True + +ALLOWED_HOSTS = [] + +# Application definition + +INSTALLED_APPS = [ + "one_time_login.apps.OneTimeLoginConfig", + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'repapp.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'repapp.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + "NAME": BASE_DIR / "data" / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +###################### +# Repapp configuration +###################### + +# Set debug mode according to env variable +DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() in ("true", "1", "t") + +# basic Python logging configuration +LOG_LEVEL = "INFO" +if DEBUG: + LOG_LEVEL = "DEBUG" + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": LOG_LEVEL, + }, +} + +# Organization name +# used by one time login +ORGANIZATION = "Repair-Café Hilpoltstein" + +# Email settings +# used by one time login +EMAIL_HOST = os.getenv("DJANGO_EMAIL_HOST", "") +EMAIL_PORT = (int)(os.getenv("DJANGO_EMAIL_PORT", "25")) +EMAIL_HOST_USER = os.getenv("DJANGO_EMAIL_HOST_USER", None) +EMAIL_HOST_PASSWORD = os.getenv("DJANGO_EMAIL_HOST_PASSWORD", None) +EMAIL_USE_TLS = os.getenv("DJANGO_EMAIL_USE_TLS", "true") in ("true", "1", "t") diff --git a/repapp/repapp/urls.py b/repapp/repapp/urls.py new file mode 100644 index 0000000..4f501ab --- /dev/null +++ b/repapp/repapp/urls.py @@ -0,0 +1,23 @@ +""" +URL configuration for repapp project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include + +urlpatterns = [ + path('admin/', admin.site.urls), + path('one_time_login/', include('one_time_login.urls', namespace='one_time_login')), +] diff --git a/repapp/repapp/wsgi.py b/repapp/repapp/wsgi.py new file mode 100644 index 0000000..90d5992 --- /dev/null +++ b/repapp/repapp/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for repapp project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'repapp.settings') + +application = get_wsgi_application() diff --git a/repapp/repapp_members/__init__.py b/repapp/repapp_members/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repapp/repapp_members/admin.py b/repapp/repapp_members/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/repapp/repapp_members/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/repapp/repapp_members/apps.py b/repapp/repapp_members/apps.py new file mode 100644 index 0000000..6a5416b --- /dev/null +++ b/repapp/repapp_members/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RepappMembersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'repapp_members' diff --git a/repapp/repapp_members/migrations/__init__.py b/repapp/repapp_members/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repapp/repapp_members/models.py b/repapp/repapp_members/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/repapp/repapp_members/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/repapp/repapp_members/tests.py b/repapp/repapp_members/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/repapp/repapp_members/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/repapp/repapp_members/views.py b/repapp/repapp_members/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/repapp/repapp_members/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here.