Finish login and registration flow

- add template for login page
- add logout URL
- display user information
This commit is contained in:
Jan Dittberner 2024-09-20 15:59:51 +02:00
parent 6b3fce5684
commit 564a00d46d
10 changed files with 328 additions and 56 deletions

View file

@ -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"<ClientCertificateUser[serial_number={self.serial_number}, issuer_name={self.issuer_name},"
f" subject_name={self.subject_name}, emails={self.emails}]>")
@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)

View file

@ -15,7 +15,22 @@
</header>
<main>{% block content %}{% endblock content %}</main>
<nav class="sidebar">
{% block sidebar_content %}{% endblock sidebar_content %}
{% block sidebar_content %}
<form method="post" action="{% url "switch_language" %}?next={{ request.path }}">
{% csrf_token %}
<label for="choose_language">
{% translate "Choose language" %}
</label>
<select id="choose_language" name="choose_language">
{% get_available_languages as LANGUAGES %}
{% for lang in LANGUAGES %}
<option value="{{ lang.0 }}"{% if lang.0 == LANGUAGE_CODE %}
selected{% endif %}>{{ lang.1 }}</option>
{% endfor %}
</select>
<button type="submit">{% translate "Change" %}</button>
</form>
{% endblock sidebar_content %}
</nav>
<footer></footer>
</body>

View file

@ -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 %}
<h1>Welcome to the CAcert Assurer Training System - CATS.</h1>
<p>{% blocktranslate trimmed %}
For background information about the purpose of this application read the
<a href="{{ challenge_wiki_url }}">CAcert Assurer Challenge Wiki page</a>.
{% endblocktranslate %}</p>
{% endblock content %}

34
cats/templates/login.html Normal file
View file

@ -0,0 +1,34 @@
{% extends 'base.html' %}
{% load i18n %}
{% block content %}
{% if certificate %}
{% if certificate_user %}
<h1>{% translate "Welcome back" %}</h1>
{% include "snippets/certificate_information.html" %}
<form method="post">
<p>
{% csrf_token %}
<button type="submit">{% translate "Continue" %}</button>
</p>
</form>
{% else %}
<h1>{% translate "Registration" %}</h1>
<p>{% blocktranslate trimmed %}
You are not registered. Please check your certificate information and confirm the registration.
{% endblocktranslate %}</p>
{% include "snippets/certificate_information.html" %}
<p>{% blocktranslate trimmed %}
Register with this certificate?
{% endblocktranslate %}</p>
<form method="post">
<p>
{% csrf_token %}
<button type="submit" class="btn btn-primary">{% translate "Register" %}</button>
<a href="{% url "home" %}" role="button" class="btn btn-primary">{% translate "Cancel" %}</a>
</p>
</form>
{% endif %}
{% else %}
<p>{% translate "You must present a client certificate to use CATS." %}</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% load i18n %}
{% block sidebar_content %}
{{ super }}
{% if request.user.is_anonymous %}
<a href="{% url 'login' %}?next={{ request.path }}">{% translate "Login" %}</a>
{% else %}
{% blocktrans trimmed %}You are logged in as {{ user }}{% endblocktrans %}
<a href="{% url 'logout' %}?next={{ request.path }}">Logout</a>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,41 @@
{% load i18n %}
<div class="certificate-information">
<h3>{% translate "Certificate information" %}</h3>
<h4>{% translate "Issued to:" %}</h4>
<dl>
<dt>{% translate "Common Name (CN):" %}</dt>
<dd>{{ certificate.subject_common_name }}</dd>
<dt>{% translate "Serial Number:" %}</dt>
<dd>{{ certificate.serial_number }}</dd>
<dt>
{% blocktranslate trimmed count counter=certificate.emails|length %}
Email address:
{% plural %}
Email addresses:
{% endblocktranslate %}
</dt>
<dd>{{ certificate.emails|join:", " }}</dd>
</dl>
<h4>{% translate "Issued By:" %}</h4>
<dl>
<dt>{% translate "Common Name (CN):" %}</dt>
<dd>{{ certificate.issuer_common_name }}</dd>
<dt>{% translate "Organization (O):" %}</dt>
<dd>{{ certificate.organization }}</dd>
<dt>{% translate "Organizational Unit (OU):" %}</dt>
<dd>{{ certificate.issuer_organizational_unit }}</dd>
</dl>
<h4>{% translate "Validity:" %}</h4>
<dl>
<dt>{% translate "Not valid before:" %}</dt>
<dd>{{ certificate.not_before|date:"r" }}</dd>
<dt>{% translate "Not valid after:" %}</dt>
<dd>{{ certificate.not_after|date:"r" }}</dd>
</dl>
</div>

View file

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

View file

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

View file

@ -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 = "/"

View file

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