one time login started

This commit is contained in:
Tom Irgang 2023-08-27 22:47:19 +02:00
parent f83b264e69
commit 1437a796c8
37 changed files with 548 additions and 0 deletions

View file

@ -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

23
docs/dev/configuration.md Normal file
View file

@ -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.

View file

@ -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

0
repapp/data/.gitkeep Normal file
View file

View file

View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class MailIntefaceConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'mail_inteface'

View file

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View file

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

22
repapp/manage.py Executable file
View file

@ -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()

View file

View file

@ -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']

View file

@ -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.'))

View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class OneTimeLoginConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'one_time_login'

View file

@ -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',
},
),
]

View file

@ -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])

View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="description" content="one time login link" />
<meta name="keywords" content="login" />
<title>One time login</title>
</head>
<body>
<p>Hello,</p>
<p>
we prepared a one time login for you. Click <a href="{{ link }}">here</a> to use the link.
</p>
<p>
Kind regards,
{{ organization }}
</p>
</body>
</html>

View file

@ -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 }}

View file

@ -0,0 +1 @@
{{ organization }} - Einmal-Ammeldelink

View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View file

@ -0,0 +1,13 @@
"""
Urls of RepApp.
"""
from django.urls import path
from . import views
app_name = 'one_time_login'
urlpatterns = [
path("<str:secret>/", views.one_time_login, name="login"),
]

View file

@ -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'))

View file

16
repapp/repapp/asgi.py Normal file
View file

@ -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()

162
repapp/repapp/settings.py Normal file
View file

@ -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")

23
repapp/repapp/urls.py Normal file
View file

@ -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')),
]

16
repapp/repapp/wsgi.py Normal file
View file

@ -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()

View file

View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class RepappMembersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'repapp_members'

View file

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View file

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.