feat: OIDC authentication

This commit is contained in:
Luca 2024-09-15 18:13:10 +02:00
parent 59a3bff173
commit 74423fde39
24 changed files with 390 additions and 1 deletions

5
.editorconfig Normal file
View File

@ -0,0 +1,5 @@
root = true
[*.html]
indent_size = 4
indent_style = space

View File

@ -4,6 +4,17 @@ DATABASE_URL=
DEBUG=False
OIDC_ADDITIONAL_SCOPES=
OIDC_OP_AUTHORIZATION_ENDPOINT=
#OIDC_OP_JWKS_ENDPOINT=
OIDC_OP_TOKEN_ENDPOINT=
OIDC_OP_USER_ENDPOINT=
OIDC_ROLES_CLAIM=
OIDC_RP_CLIENT_ID=
OIDC_RP_CLIENT_SECRET=
#OIDC_RP_SIGN_ALGO=
OIDC_SUPERUSER_ROLE=
SECRET_KEY=
TIME_ZONE=UTC

13
docker-compose.yml Normal file
View File

@ -0,0 +1,13 @@
---
services:
keycloak:
image: quay.io/keycloak/keycloak:25.0
restart: unless-stopped
command:
- start-dev
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- 127.0.0.1:8080:8080

0
lelcsc/core/__init__.py Normal file
View File

6
lelcsc/core/admin.py Normal file
View File

@ -0,0 +1,6 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User
admin.site.register(User, UserAdmin)

8
lelcsc/core/apps.py Normal file
View File

@ -0,0 +1,8 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
label = "lelcsc_core"
name = "lelcsc.core"
verbose_name = "core"

61
lelcsc/core/auth.py Normal file
View File

@ -0,0 +1,61 @@
from django.conf import settings
from django.db import transaction
from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as BaseOIDCAuthenticationBackend,
)
from .models import OIDCUser
def get_roles(claims):
roles = claims
roles_path = settings.OIDC_ROLES_CLAIM.split(".")
for key in roles_path:
roles = roles.get(key)
if roles is None:
return []
return roles
class OIDCAuthenticationBackend(BaseOIDCAuthenticationBackend):
@transaction.atomic
def create_user(self, claims):
is_superuser = settings.OIDC_SUPERUSER_ROLE in get_roles(claims)
user = self.UserModel.objects.create_user(
claims.get("preferred_username"),
claims.get("email"),
first_name=claims.get("given_name", ""),
last_name=claims.get("family_name", ""),
is_staff=is_superuser,
is_superuser=is_superuser,
)
OIDCUser.objects.create(uuid=claims.get("sub"), user=user)
return user
def update_user(self, user, claims):
user.username = claims.get("preferred_username")
user.email = claims.get("email")
user.first_name = claims.get("given_name", "")
user.last_name = claims.get("family_name", "")
user.is_superuser = user.is_staff = settings.OIDC_SUPERUSER_ROLE in get_roles(
claims
)
user.save()
return user
def filter_users_by_claims(self, claims):
uuid = claims.get("sub")
if not uuid:
return self.UserModel.objects.none()
try:
oidc_user = OIDCUser.objects.get(uuid=uuid)
return [oidc_user.user]
except OIDCUser.DoesNotExist:
return self.UserModel.objects.none()

View File

@ -0,0 +1,147 @@
# Generated by Django 5.1.1 on 2024-09-14 19:26
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="OIDCUser",
fields=[
("uuid", models.UUIDField(primary_key=True, serialize=False)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

11
lelcsc/core/models.py Normal file
View File

@ -0,0 +1,11 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
pass
class OIDCUser(models.Model):
uuid = models.UUIDField(primary_key=True)
user = models.OneToOneField(User, on_delete=models.CASCADE)

File diff suppressed because one or more lines are too long

6
lelcsc/core/static/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,5 @@
{% extends "base.html" %}
{% load i18n %}
{% block page_title %}{% translate "Client Error" %}{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends "base.html" %}
{% load i18n %}
{% block page_title %}{% translate "Forbidden" %}{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends "base.html" %}
{% load i18n %}
{% block page_title %}{% translate "Not Found" %}{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends "base.html" %}
{% load i18n %}
{% block page_title %}{% translate "Server Error" %}{% endblock %}

View File

@ -0,0 +1,23 @@
{% load django_bootstrap5 %}
{% load i18n %}
{% load static %}
{% get_current_language as LANGUAGE_CODE %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}{% block page_title %}{% endblock %} &ndash; lelcsc{% endblock %}</title>
<link rel="icon" href="data:,%89PNG%0D%0A%1A%0A">
<link rel="stylesheet" href="{% static 'bootstrap.min.css' %}">
</head>
<body>
{% block body %}
{% block messages %}
{% bootstrap_messages %}
{% endblock %}
{% block content %}
{% endblock %}
{% endblock %}
</body>
</html>

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% load django_bootstrap5 %}
{% load i18n %}
{% block page_title %}{% translate "Overview" %}{% endblock %}
{% block content %}
<form action="{% url 'oidc_logout' %}" method="post">
{% csrf_token %}
{% bootstrap_button button_type="submit" content="Log out" %}
</form>
{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends "base.html" %}
{% load i18n %}
{% block page_title %}{% translate "Unauthorized" %}{% endblock %}

3
lelcsc/core/tests.py Normal file
View File

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

8
lelcsc/core/urls.py Normal file
View File

@ -0,0 +1,8 @@
from django.urls import path
from . import views
urlpatterns = [
path("", views.index, name="index"),
path("unauthorized", views.unauthorized, name="unauthorized"),
]

11
lelcsc/core/views.py Normal file
View File

@ -0,0 +1,11 @@
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
@login_required
def index(request):
return render(request, "core/index.html", {})
def unauthorized(request):
return render(request, "core/unauthorized.html", {})

View File

@ -47,6 +47,7 @@ INSTALLED_APPS = [
"django_bootstrap5",
"mozilla_django_oidc",
# local
"lelcsc.core",
]
MIDDLEWARE = [
@ -132,3 +133,31 @@ STATIC_URL = "static/"
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTHENTICATION_BACKENDS = [
"lelcsc.core.auth.OIDCAuthenticationBackend",
]
AUTH_USER_MODEL = "lelcsc_core.User"
LOGIN_REDIRECT_URL = "/"
LOGIN_URL = "oidc_authentication_init"
LOGOUT_REDIRECT_URL = LOGIN_REDIRECT_URL_FAILURE = "/unauthorized"
OIDC_ADDITIONAL_SCOPES = env("OIDC_ADDITIONAL_SCOPES", default="")
OIDC_OP_AUTHORIZATION_ENDPOINT = env("OIDC_OP_AUTHORIZATION_ENDPOINT")
OIDC_OP_JWKS_ENDPOINT = env("OIDC_OP_JWKS_ENDPOINT", default=None)
OIDC_OP_TOKEN_ENDPOINT = env("OIDC_OP_TOKEN_ENDPOINT")
OIDC_OP_USER_ENDPOINT = env("OIDC_OP_USER_ENDPOINT")
OIDC_ROLES_CLAIM = env("OIDC_ROLES_CLAIM")
OIDC_RP_CLIENT_ID = env("OIDC_RP_CLIENT_ID")
OIDC_RP_CLIENT_SECRET = env("OIDC_RP_CLIENT_SECRET")
OIDC_RP_SCOPES = f"openid profile email{' ' if OIDC_ADDITIONAL_SCOPES else ''}{OIDC_ADDITIONAL_SCOPES}"
OIDC_RP_SIGN_ALGO = env("OIDC_RP_SIGN_ALGO", default="HS256")
OIDC_SUPERUSER_ROLE = env("OIDC_SUPERUSER_ROLE")

View File

@ -16,8 +16,10 @@ Including another URLconf
"""
from django.contrib import admin
from django.urls import path
from django.urls import include, path
urlpatterns = [
path("", include("lelcsc.core.urls")),
path("admin/", admin.site.urls),
path("oidc/", include("mozilla_django_oidc.urls")),
]