feat: OIDC authentication
This commit is contained in:
parent
59a3bff173
commit
74423fde39
|
@ -0,0 +1,5 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*.html]
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
11
.env.example
11
.env.example
|
@ -4,6 +4,17 @@ DATABASE_URL=
|
||||||
|
|
||||||
DEBUG=False
|
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=
|
SECRET_KEY=
|
||||||
|
|
||||||
TIME_ZONE=UTC
|
TIME_ZONE=UTC
|
||||||
|
|
|
@ -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,0 +1,6 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
admin.site.register(User, UserAdmin)
|
|
@ -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"
|
|
@ -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()
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -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
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,5 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block page_title %}{% translate "Client Error" %}{% endblock %}
|
|
@ -0,0 +1,5 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block page_title %}{% translate "Forbidden" %}{% endblock %}
|
|
@ -0,0 +1,5 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block page_title %}{% translate "Not Found" %}{% endblock %}
|
|
@ -0,0 +1,5 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block page_title %}{% translate "Server Error" %}{% endblock %}
|
|
@ -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 %} – 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>
|
|
@ -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 %}
|
|
@ -0,0 +1,5 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block page_title %}{% translate "Unauthorized" %}{% endblock %}
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
|
@ -0,0 +1,8 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.index, name="index"),
|
||||||
|
path("unauthorized", views.unauthorized, name="unauthorized"),
|
||||||
|
]
|
|
@ -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", {})
|
|
@ -47,6 +47,7 @@ INSTALLED_APPS = [
|
||||||
"django_bootstrap5",
|
"django_bootstrap5",
|
||||||
"mozilla_django_oidc",
|
"mozilla_django_oidc",
|
||||||
# local
|
# local
|
||||||
|
"lelcsc.core",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
@ -132,3 +133,31 @@ STATIC_URL = "static/"
|
||||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
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")
|
||||||
|
|
|
@ -16,8 +16,10 @@ Including another URLconf
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path
|
from django.urls import include, path
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path("", include("lelcsc.core.urls")),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
|
path("oidc/", include("mozilla_django_oidc.urls")),
|
||||||
]
|
]
|
||||||
|
|
Loading…
Reference in New Issue