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
|
||||
|
||||
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
|
||||
|
|
|
@ -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",
|
||||
"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")
|
||||
|
|
|
@ -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")),
|
||||
]
|
||||
|
|
Loading…
Reference in New Issue