Finish login and registration flow
- add template for login page - add logout URL - display user information
This commit is contained in:
parent
6b3fce5684
commit
564a00d46d
10 changed files with 328 additions and 56 deletions
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
34
cats/templates/login.html
Normal 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 %}
|
11
cats/templates/regular_view.html
Normal file
11
cats/templates/regular_view.html
Normal 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 %}
|
41
cats/templates/snippets/certificate_information.html
Normal file
41
cats/templates/snippets/certificate_information.html
Normal 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>
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = "/"
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in a new issue