From 564a00d46d1a38717612087d719c645bab465983 Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Fri, 20 Sep 2024 15:59:51 +0200 Subject: [PATCH] Finish login and registration flow - add template for login page - add logout URL - display user information --- cats/authentication.py | 122 ++++++++++++++---- cats/templates/base.html | 17 ++- cats/templates/home.html | 11 +- cats/templates/login.html | 34 +++++ cats/templates/regular_view.html | 11 ++ .../snippets/certificate_information.html | 41 ++++++ cats/urls.py | 7 +- cats/views.py | 99 ++++++++++++-- django_cats/settings.py | 34 +++-- django_cats/urls.py | 8 +- 10 files changed, 328 insertions(+), 56 deletions(-) create mode 100644 cats/templates/login.html create mode 100644 cats/templates/regular_view.html create mode 100644 cats/templates/snippets/certificate_information.html diff --git a/cats/authentication.py b/cats/authentication.py index 5370f6f..d89de40 100644 --- a/cats/authentication.py +++ b/cats/authentication.py @@ -1,42 +1,110 @@ +from dataclasses import dataclass +from datetime import datetime from urllib.parse import unquote_to_bytes from cryptography import x509 -from cryptography.x509 import load_pem_x509_certificate, SubjectAlternativeName +from cryptography.x509 import SubjectAlternativeName, load_pem_x509_certificate +from django.conf import settings +from django.contrib.auth import get_user_model from django.contrib.auth.backends import BaseBackend +from cats.models import UserCertificate -class ClientCertificateUser: - def __init__(self, serial_number, issuer_name, subject_name, emails): - self.serial_number = serial_number - self.issuer_name = issuer_name - self.subject_name = subject_name - self.emails = emails +UserModel = get_user_model() - def __str__(self): - return (f"") + +@dataclass(frozen=True) +class CertificateInformation: + serial_number: str + issuer_common_name: str + issuer_organization: str + issuer_organizational_unit: str + subject_common_name: str + emails: list[str] + not_before: datetime + not_after: datetime + + +def get_certificate_information(encoded_certificate=None): + if encoded_certificate is None: + return None + + pem_data = unquote_to_bytes(encoded_certificate) + certificate = load_pem_x509_certificate(pem_data) + + serial_number = f"{certificate.serial_number:X}" + if len(serial_number) % 2 == 1: + serial_number = "0" + serial_number + + return CertificateInformation( + serial_number=serial_number, + issuer_common_name=certificate.issuer.get_attributes_for_oid( + x509.OID_COMMON_NAME + )[0].value, + issuer_organization=certificate.issuer.get_attributes_for_oid( + x509.OID_ORGANIZATION_NAME + )[0].value, + issuer_organizational_unit=certificate.issuer.get_attributes_for_oid( + x509.OID_ORGANIZATIONAL_UNIT_NAME + )[0].value, + subject_common_name=certificate.subject.get_attributes_for_oid( + x509.OID_COMMON_NAME + )[0].value, + emails=certificate.extensions.get_extension_for_class( + SubjectAlternativeName + ).value.get_values_for_type(x509.RFC822Name), + not_before=certificate.not_valid_before_utc, + not_after=certificate.not_valid_after_utc, + ) + + +def get_user_for_certificate(certificate): + user = None + for email in certificate.emails: + try: + user = UserModel.objects.get(email=UserModel.objects.normalize_email(email)) + break + except UserModel.DoesNotExist: + continue + return user class ClientCertificateBackend(BaseBackend): - def authenticate(self, request, encoded_certificate=None): + def authenticate(self, request, certificate=None): """ encoded_certificate is expected to be a URL encoded PEM certificate as sent by nginx when using the $ssl_client_escaped_cert """ - if encoded_certificate is None: + + user = get_user_for_certificate(certificate) + + if not user: + user = UserModel.objects.create_user( + certificate.emails[0], + email=certificate.emails[0], + is_staff=certificate.emails[0] in settings.CATS_ADMIN_EMAILS, + ) + + try: + user_certificate = UserCertificate.objects.get( + serial_number=certificate.serial_number, + issuer_name=certificate.issuer_common_name, + ) + if not user_certificate.user: + user_certificate.user = user + user_certificate.save() + except UserCertificate.DoesNotExist: + UserCertificate.objects.create( + serial_number=certificate.serial_number, + issuer_name=certificate.issuer_common_name, + user=user, + common_name=certificate.subject_common_name, + ) + + return user + + def get_user(self, user_id): + try: + return UserModel.objects.get(pk=user_id) + except UserModel.DoesNotExist: return None - - pem_data = unquote_to_bytes(encoded_certificate) - certificate_data = load_pem_x509_certificate(pem_data) - - subject_alternative_name = certificate_data.extensions.get_extension_for_class(SubjectAlternativeName) - emails = subject_alternative_name.value.get_values_for_type(x509.RFC822Name) - - serial_number = f"{certificate_data.serial_number:X}" - if len(serial_number) % 2 == 1: - serial_number = "0" + serial_number - - issuer_name = certificate_data.issuer.get_attributes_for_oid(x509.OID_COMMON_NAME)[0].value - subject_name = certificate_data.subject.get_attributes_for_oid(x509.OID_COMMON_NAME)[0].value - - return ClientCertificateUser(serial_number, issuer_name, subject_name, emails) diff --git a/cats/templates/base.html b/cats/templates/base.html index eb0e44a..7465678 100644 --- a/cats/templates/base.html +++ b/cats/templates/base.html @@ -15,7 +15,22 @@
{% block content %}{% endblock content %}
diff --git a/cats/templates/home.html b/cats/templates/home.html index 308541a..b04f69f 100644 --- a/cats/templates/home.html +++ b/cats/templates/home.html @@ -1,7 +1,10 @@ -{% extends "base.html" %} +{% extends "regular_view.html" %} {% load i18n %} {% block content %} -{% blocktranslate trimmed %} - Welcome to the CAcert Assurer Training System - CATS. -{% endblocktranslate %} +

Welcome to the CAcert Assurer Training System - CATS.

+ +

{% blocktranslate trimmed %} + For background information about the purpose of this application read the + CAcert Assurer Challenge Wiki page. + {% endblocktranslate %}

{% endblock content %} \ No newline at end of file diff --git a/cats/templates/login.html b/cats/templates/login.html new file mode 100644 index 0000000..0d9a114 --- /dev/null +++ b/cats/templates/login.html @@ -0,0 +1,34 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block content %} + {% if certificate %} + {% if certificate_user %} +

{% translate "Welcome back" %}

+ {% include "snippets/certificate_information.html" %} +
+

+ {% csrf_token %} + +

+
+ {% else %} +

{% translate "Registration" %}

+

{% blocktranslate trimmed %} + You are not registered. Please check your certificate information and confirm the registration. + {% endblocktranslate %}

+ {% include "snippets/certificate_information.html" %} +

{% blocktranslate trimmed %} + Register with this certificate? + {% endblocktranslate %}

+
+

+ {% csrf_token %} + + {% translate "Cancel" %} +

+
+ {% endif %} + {% else %} +

{% translate "You must present a client certificate to use CATS." %}

+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/cats/templates/regular_view.html b/cats/templates/regular_view.html new file mode 100644 index 0000000..2f39641 --- /dev/null +++ b/cats/templates/regular_view.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% load i18n %} +{% block sidebar_content %} + {{ super }} + {% if request.user.is_anonymous %} + {% translate "Login" %} + {% else %} + {% blocktrans trimmed %}You are logged in as {{ user }}{% endblocktrans %} + Logout + {% endif %} +{% endblock %} \ No newline at end of file diff --git a/cats/templates/snippets/certificate_information.html b/cats/templates/snippets/certificate_information.html new file mode 100644 index 0000000..72548ca --- /dev/null +++ b/cats/templates/snippets/certificate_information.html @@ -0,0 +1,41 @@ +{% load i18n %} +
+

{% translate "Certificate information" %}

+

{% translate "Issued to:" %}

+ +
+
{% translate "Common Name (CN):" %}
+
{{ certificate.subject_common_name }}
+
{% translate "Serial Number:" %}
+
{{ certificate.serial_number }}
+
+ {% blocktranslate trimmed count counter=certificate.emails|length %} + Email address: + {% plural %} + Email addresses: + {% endblocktranslate %} +
+
{{ certificate.emails|join:", " }}
+
+ +

{% translate "Issued By:" %}

+ +
+
{% translate "Common Name (CN):" %}
+
{{ certificate.issuer_common_name }}
+
{% translate "Organization (O):" %}
+
{{ certificate.organization }}
+
{% translate "Organizational Unit (OU):" %}
+
{{ certificate.issuer_organizational_unit }}
+
+ +

{% translate "Validity:" %}

+ +
+
{% translate "Not valid before:" %}
+
{{ certificate.not_before|date:"r" }}
+
{% translate "Not valid after:" %}
+
{{ certificate.not_after|date:"r" }}
+
+ +
\ No newline at end of file diff --git a/cats/urls.py b/cats/urls.py index d357770..ffc55a7 100644 --- a/cats/urls.py +++ b/cats/urls.py @@ -1,9 +1,12 @@ # URL configuration for cats +from django.contrib.auth.views import LogoutView from django.urls import path -from cats.views import certificate_login, home_page +from cats.views import CertificateLoginView, home_page, switch_language urlpatterns = [ path("", home_page, name="home"), - path("auth/login", certificate_login, name="login"), + path("switch-language", switch_language, name="switch_language"), + path("auth/login", CertificateLoginView.as_view(), name="login"), + path("auth/logout", LogoutView.as_view(), name="logout"), ] diff --git a/cats/views.py b/cats/views.py index 459973e..d3bd44b 100644 --- a/cats/views.py +++ b/cats/views.py @@ -1,23 +1,98 @@ -from django.contrib.auth import authenticate -from django.http import HttpResponseForbidden, HttpResponseRedirect -from django.shortcuts import redirect, render +from http import HTTPStatus + +from django.conf import settings +from django.contrib.auth import authenticate, login +from django.contrib.auth.views import RedirectURLMixin +from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect +from django.shortcuts import render, resolve_url +from django.urls import reverse +from django.utils.translation import get_language_from_request from django.utils.translation import gettext as _ +from django.views.generic import TemplateView + +from .authentication import get_certificate_information, get_user_for_certificate # Create your views here. -def certificate_login(request): - certificate = request.META.get('HTTP_X_SSL_CERT', None) +class CertificateLoginView(RedirectURLMixin, TemplateView): + template_name = "login.html" - user = authenticate(request, encoded_certificate=certificate) + def get_default_redirect_url(self): + """Return the default redirect URL.""" + if self.next_page: + return resolve_url(self.next_page) + else: + return resolve_url(settings.LOGIN_REDIRECT_URL) - if not user: - return HttpResponseForbidden(_("you have not sent a valid client certificate")) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + certificate = self.request.META.get("HTTP_X_SSL_CERT", None) - if "next" in request.GET: - return HttpResponseRedirect(request.GET["next"]) + certificate_information = get_certificate_information( + encoded_certificate=certificate + ) - return redirect("home") + context.update( + { + "certificate": certificate_information, + "certificate_user": ( + get_user_for_certificate(certificate_information) + if certificate + else None + ), + } + ) + + return context + + def post(self, *args, **kwargs): + certificate = self.request.META.get("HTTP_X_SSL_CERT", None) + + certificate_information = get_certificate_information( + encoded_certificate=certificate + ) + + user = authenticate(self.request, certificate=certificate_information) + + if not user: + return HttpResponseForbidden( + _("you have not sent a valid client certificate") + ) + + login(self.request, user) + + return HttpResponseRedirect(self.get_success_url()) + + +def get_challenge_wiki_url(request): + language = get_language_from_request(request) + + url_map = { + "cz": "https://wiki.cacert.org/AssurerChallenge/CZ", + "de": "https://wiki.cacert.org/AssurerChallenge/DE", + "fr": "https://wiki.cacert.org/AssurerChallenge/fr", + "nl": "https://wiki.cacert.org/AssurerChallenge/NL", + } + + return url_map.get(language, "https://wiki.cacert.org/AssurerChallenge") def home_page(request): - return render(request, "home.html") + return render( + request, + "home.html", + context={"challenge_wiki_url": get_challenge_wiki_url(request)}, + ) + + +def switch_language(request): + if request.method != "POST": + return HttpResponse(status=HTTPStatus.METHOD_NOT_ALLOWED) + + language = request.POST.get("choose_language", get_language_from_request(request)) + next_page = request.GET.get("next", reverse("home")) + + response = HttpResponseRedirect(next_page) + response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language) + + return response diff --git a/django_cats/settings.py b/django_cats/settings.py index cd2c245..3966c66 100644 --- a/django_cats/settings.py +++ b/django_cats/settings.py @@ -11,6 +11,8 @@ https://docs.djangoproject.com/en/4.2/ref/settings/ """ from pathlib import Path + +from django.utils.translation import gettext_lazy as _ from environs import Env # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -24,16 +26,19 @@ DEBUG = env.bool("DEBUG", default=False) # SECURITY WARNING: keep the secret key used in production secret! if DEBUG: - SECRET_KEY = env.str("SECRET_KEY", - default="django-insecure-m8s=a8rg)_cqo%q$g8qzzho&160+jei4uocg%q-ce3v_kfumr7") + SECRET_KEY = env.str( + "SECRET_KEY", + default="django-insecure-m8s=a8rg)_cqo%q$g8qzzho&160+jei4uocg%q-ce3v_kfumr7", + ) else: SECRET_KEY = env.str("SECRET_KEY") - # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[]) + +CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[]) # Application definition @@ -44,20 +49,26 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "debug_toolbar", "cats", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.auth.middleware.PersistentRemoteUserMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] +if DEBUG: + MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] + + INTERNAL_IPS = ["127.0.0.1", "::1"] + ROOT_URLCONF = "django_cats.urls" CATS_ADMIN_EMAILS = env.list("CATS_ADMIN_EMAILS", default=[]) @@ -80,30 +91,33 @@ TEMPLATES = [ WSGI_APPLICATION = "django_cats.wsgi.application" - # Database # https://docs.djangoproject.com/en/4.2/ref/settings/#databases DATABASES = {"default": env.dj_db_url("DATABASE_URL")} - AUTHENTICATION_BACKENDS = [ "cats.authentication.ClientCertificateBackend", ] - # Internationalization # https://docs.djangoproject.com/en/4.2/topics/i18n/ LANGUAGE_CODE = "en-us" +LANGUAGES = [ + ("cz", _("Czech")), + ("de", _("German")), + ("en", _("English")), + ("fr", _("French")), +] + TIME_ZONE = "UTC" USE_I18N = True USE_TZ = True - # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ @@ -113,3 +127,5 @@ STATIC_URL = "static/" # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +LOGIN_REDIRECT_URL = "/" diff --git a/django_cats/urls.py b/django_cats/urls.py index 5200082..7190cfe 100644 --- a/django_cats/urls.py +++ b/django_cats/urls.py @@ -15,10 +15,16 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ +from django.conf import settings from django.contrib import admin -from django.urls import path, include +from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), path("", include("cats.urls")), ] + +if settings.DEBUG: + from debug_toolbar.toolbar import debug_toolbar_urls + + urlpatterns += debug_toolbar_urls()