diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6fc926f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,44 @@ +# App specific +certificates +*.sqlite3 +docker_initial_setup.sh + +# Common +README.md + +# Docker +docker-compose.yml +Dockerfile +.docker +.dockerignore +container.env + +# git +.git +.gitattributes +.gitignore +# GitHub +.github + +## Python +# Byte-compiled +**/__pycache__/ +**/*.py[cod] + +# Virtual environment +.env +.venv/ +venv/ + +## Editors +# Vim swap files +**/*.swp + +# VS Code +.vscode/ + +# JetBrains +.idea + +# Notepad++ +.editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..54c0eca --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] +end_of_line = lf +indent_style = space +indent_size = 4 +max_line_length = 80 +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +max_line_length = 88 diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..fd0b6d7 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,46 @@ +name: Build and Push Docker Images + +on: + push: + tags: + - '*' + workflow_dispatch: + +env: + IMAGE_NAME: development-idp + DOCKERFILE_PATH: ./Dockerfile + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push main image + uses: docker/build-push-action@v4 + with: + context: . + file: ${{ env.DOCKERFILE_PATH }} + push: true + tags: | + ghcr.io/centrefordigitalhumanities/development-idp/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + + - name: Grype Scan + id: scan + uses: anchore/scan-action@v3 + with: + image: ghcr.io/centrefordigitalhumanities/development-idp/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + fail-build: false + + - name: upload Grype SARIF report + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: ${{ steps.scan.outputs.sarif }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..674a18c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.11-alpine3.20 + +ENV PYTHONUNBUFFERED=1 +# Copy app +WORKDIR /app +COPY . /app + +# Install dependencies +## Container dependencies +RUN apk add postgresql-dev gcc musl-dev libffi-dev +RUN pip install gunicorn psycopg[c] +## App dependencies +RUN apk add git xmlsec gettext +RUN pip install -r requirements.txt + +# Cleanup build-only packages +RUN apk del git gcc musl-dev libffi-dev + +# Compile messages +RUN python manage.py compilemessages + +# Collect static files +## Create public dir to store them in +RUN mkdir -p /app/public/static +## Collect them +RUN python manage.py collectstatic --noinput + +EXPOSE 7000 +CMD ["sh", "docker/run.sh"] diff --git a/README.md b/README.md index 3e03d96..5e55fb3 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,20 @@ A quick and dirty IdP for local development with applications using Federated Authentication. -Internally it's follows SurfConext attribute/claim naming, but has built-in +Internally it's follows SurfConext attribute/claim naming, but has built-in profiles to act like the UU IdP. -NOTE: Currently only SAML is implemented, with OpenID possibly being added in +NOTE: Currently only SAML is implemented, with OpenID possibly being added in the future. ## Instructions +### Running with Docker + +TODO: write this. TL;DR: ``docker-compose up`` and `./docker_initial_setup.sh` the first time. + +### Running locally + 1. Setup a virtualenv and activate it 2. Install dependencies ``pip install -r requirements.txt`` 4. Run migrations ``python manage.py migrate`` @@ -18,8 +24,8 @@ the future. * This will create an admin user with username/pass: admin/admin. Will override any existing user with pk 1 * Otherwise, create an admin user using ``python manage.py createsuperuser`` 7. (Optional) Load test users ``python manage.py loaddata main/fixtures/surfconext-test-users.json`` - * These test users are identical to the test users in SurfConext's test environment. - * These accounts will override any existing user using a PK between 2 and 40. + * These test users are identical to the test users in SurfConext's test environment. + * These accounts will override any existing user using a PK between 2 and 40. 8. Verify that ``BASE_URL`` in ``testidp/saml_settings.py`` is set to the correct host for your usage * By default this is ``localhost:7000``, which is probably fine? 9. Run the IDP ``python manage.py runserver `` @@ -28,14 +34,14 @@ the future. ## Adding SAML Service Providers -1. Make sure your app has SAML already setup and is using +1. Make sure your app has SAML already setup and is using ``http(s)://localhost:7000/saml/idp/metadata`` as it's IdP * Replacing ``localhost:7000`` with the actual IP of the IdP 2. Click 'New' next to Service Provider in the app 3. Provide at least your SP's ``entity_id`` and ``metadata`` (preferably by URL import) -4. Choose your starting attribute map* +4. Choose your starting attribute map* 5. Done! -6. Optionally: review your new SP by editting. You might want to add missing +6. Optionally: review your new SP by editting. You might want to add missing attributes to the attribute map ### Note on attribute maps @@ -44,13 +50,13 @@ In SAML, (well, PySAML), the term attribute map is often used and often not even referring to the same thing. This can be confusing, so to clear up: In the context _of this app_ you'll only have to worry about SP attribute maps, -which both maps the attribute name (as stored in the dev-IdP's database) to the +which both maps the attribute name (as stored in the dev-IdP's database) to the name sent to the SP and restricts what attribute names are sent. (Any attribute not in the dict will not be sent back to the SP.) For example, internally the Solis-ID is named ``username``, but the UU IdP calls -this attribute ``uuShortId``. Thus, we need to _map_ ``username`` to -``uuShortId``. +this attribute ``uuShortId``. Thus, we need to _map_ ``username`` to +``uuShortId``. Thus, you'll get this attribute map: ```json @@ -59,7 +65,7 @@ Thus, you'll get this attribute map: } ``` -However, with this map the IdP will only supply the SP the solis-id of the +However, with this map the IdP will only supply the SP the solis-id of the logged-in user. Thus, a more common attribute map would be: ```json @@ -71,16 +77,16 @@ logged-in user. Thus, a more common attribute map would be: } ``` -The app provides a couple preset attribute maps, which can be chosen when +The app provides a couple preset attribute maps, which can be chosen when registering an SP in the app. These maps can also be consulted in the file `main/attribute_map_presets.py`. ## Known Issues ### xmlsec1 bug for Mac users -If you are working on an Apple device, you might run into problems with reading -the certificates for some obscure mac reasons. The currently functioning +If you are working on an Apple device, you might run into problems with reading +the certificates for some obscure mac reasons. The currently functioning workaround (as per 10-1-2024) is reverting your `xmlsec1` package to version -`1.2.37`. You can do that with the following code ([source](https://github.com/xmlsec/python-xmlsec/issues/254#issuecomment-1726249435>`)): +`1.2.37`. You can do that with the following code ([source](https://github.com/xmlsec/python-xmlsec/issues/254#issuecomment-1726249435>`)): ```shell brew uninstall libxmlsec1 diff --git a/container.env b/container.env new file mode 100644 index 0000000..2a4bbf7 --- /dev/null +++ b/container.env @@ -0,0 +1,11 @@ +DJANGO_SECRET_KEY="django-insecure-lb=q@u4df-x0th(5u%$eye_ti#etst+5z+%2=lrh$$le3&v_y$" +DJANGO_DEBUG="False" +DJANGO_DEBUG_TOOLBAR="True" +DJANGO_HOST="localhost:7000" +DJANGO_ALLOWED_HOSTS="localhost,127.0.0.1" +DJANGO_DB_TYPE="sqlite3" +DJANGO_SQLLITE_FILE="/run/db/db.sqlite3" +DJANGO_HTTPS="False" + +DJANGO_IDP_PRIVATE_KEY="/run/secrets/private_key" +DJANGO_IDP_PUBLIC_CERT="/run/secrets/public_cert" diff --git a/convertcsv.py b/convertcsv.py deleted file mode 100644 index da46671..0000000 --- a/convertcsv.py +++ /dev/null @@ -1,109 +0,0 @@ -"""File to transform a CSV of users into fixtures loadable by Django""" -import csv -import json -from typing import Dict - -ENTITLEMENT_MAPPINGS = { - "urn:mace:terena.org:tcs:personal-user-example": 1, - "urn:x-surfnet:surf.nl:surfdrive-example:quota:50": 2, - "urn:mace:surf.nl:value:edulicense": 3, - "urn:mace:dir:entitlement:common-lib-terms-example": 4, - "urn:mace:incommon.org:reg:education-example": 5, -} - -AFFILIATION_MAPPINGS = { - "student": 1, - "staff": 2, - "employee": 3, - "faculty": 4, - "member": 5, -} - -GROUP_MAPPINGS = { - 'urn:collab:org:co-example.org': 1, - 'urn:collab:org:exchange-university.org': 2, - 'urn:collab:org:home-university.org': 3, - 'urn:collab:org:sunet-example.se': 4, - 'urn:collab:org:surf.nl': 5, -} - - -def _split_line(string: str): - return string.split(', ') - - -def _mapper(data: str, mappings: Dict[str, int]): - new_data = [] - splitted = _split_line(data) - for item in splitted: - result = mappings.get(item.strip(), None) - if result: - new_data.append(result) - - return new_data - - -with open('convertcsv.csv', mode='r') as f: - csv_reader = csv.DictReader(f) - - user_id_num = 2 - email_id_num = 2 - - user_fixtures = [] - email_fixtures = [] - - datum: dict # Makes the linter happy - for datum in csv_reader: - datum['is_staff'] = False - datum['is_active'] = True - datum['is_superuser'] = False - datum['user_permissions'] = [] - datum['last_login'] = None - datum['date_joined'] = "2023-05-02T08:27:53.470Z" - - datum['password'] = f"plain${datum['password']}" - - datum['_eduPersonEntitlement'] = _mapper( - datum['eduPersonEntitlement'], - ENTITLEMENT_MAPPINGS - ) - del datum['eduPersonEntitlement'] - datum['_eduPersonAffiliation'] = _mapper( - datum['eduPersonAffiliation'], - AFFILIATION_MAPPINGS - ) - del datum['eduPersonAffiliation'] - datum['_isMemberOf'] = _mapper( - datum['isMemberOf'], - GROUP_MAPPINGS - ) - del datum['isMemberOf'] - - # Not used in dev IdP - del datum['eduPersonScopedAffiliation'] - - for email in _split_line(datum['mail']): - email_fixture_data = { - 'pk': email_id_num, - 'model': 'main.usermail', - 'fields': { - 'email': email, - 'user': user_id_num - } - } - email_id_num += 1 - email_fixtures.append(email_fixture_data) - - del datum['mail'] - - user_fixture_data = { - 'pk': user_id_num, - 'model': 'main.user', - 'fields': datum - } - user_fixtures.append(user_fixture_data) - - user_id_num += 1 - - with open('surfconext-test-users.json', mode='w') as output_file: - json.dump(user_fixtures + email_fixtures, output_file, indent=2) diff --git a/db/.empty b/db/.empty new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..6e1ec99 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,27 @@ +services: + dev-idp: + build: . + ports: + - "7000:7000" + env_file: + - ./container.env + volumes: + - ./db:/run/db + networks: + - default + stdin_open: true + tty: true + secrets: + - private_key + - public_cert + + +networks: + default: + +secrets: + private_key: + file: ./certificates/private.key + public_cert: + file: ./certificates/public.cert + diff --git a/docker/run.sh b/docker/run.sh new file mode 100644 index 0000000..92e46a7 --- /dev/null +++ b/docker/run.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +# Migrate the database +python manage.py migrate + +# Run da server +exec gunicorn testidp.wsgi:application -c gunicorn.conf.py "$@" \ No newline at end of file diff --git a/docker_initial_setup.sh b/docker_initial_setup.sh new file mode 100755 index 0000000..0bd88e1 --- /dev/null +++ b/docker_initial_setup.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +docker compose exec dev-idp python manage.py loaddata main/fixtures/initial.json +docker compose exec dev-idp python manage.py loaddata main/fixtures/admin-user.json +docker compose exec dev-idp python manage.py loaddata main/fixtures/surfconext-test-users.json diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..984f730 --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,8 @@ +import os + +chdir = "/app/public" +bind = "0.0.0.0:7000" +workers = 3 +capture_output = True +# How verbose the Gunicorn error logs should be +loglevel = os.getenv("LOG_LEVEL", "WARNING") diff --git a/idp/views.py b/idp/views.py index b73ddbc..f2ff349 100644 --- a/idp/views.py +++ b/idp/views.py @@ -1,6 +1,9 @@ import logging +from urllib.parse import urlparse + from django.contrib.auth import get_user_model, logout -from django.http import HttpRequest, HttpResponseRedirect +from django.http import HttpRequest, HttpResponseRedirect, JsonResponse +from django.urls import reverse from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.cache import never_cache @@ -10,6 +13,9 @@ from djangosaml2idp.idp import IDP from djangosaml2idp.utils import repr_saml, verify_request_signature from djangosaml2idp.views import IdPHandlerViewMixin, store_params_in_session +from oauth2_provider.models import AbstractGrant, Application +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.views.mixins import OIDCOnlyMixin logger = logging.getLogger(__name__) @@ -105,3 +111,75 @@ def get(self, request: HttpRequest, *args, **kwargs): destination=destination, relay_state=relay_state) return self.render_response(request, html_response, None) + + +class ConnectDiscoveryInfoView(OIDCOnlyMixin, View): + """ + View used to show oidc provider configuration information per + `OpenID Provider Metadata `_ + + Modified to support UU weirdness + """ + + def get(self, request, *args, **kwargs): + issuer_url = oauth2_settings.OIDC_ISS_ENDPOINT + + if not issuer_url: + issuer_url = oauth2_settings.oidc_issuer(request) + authorization_endpoint = request.build_absolute_uri(reverse("oauth2_provider:authorize")) + token_endpoint = request.build_absolute_uri(reverse("oauth2_provider:token")) + userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or request.build_absolute_uri( + reverse("oauth2_provider:user-info") + ) + introspection_endpoint = request.build_absolute_uri(reverse('oauth2_provider:introspect')) + jwks_uri = request.build_absolute_uri(reverse("oauth2_provider:jwks-info")) + if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED: + end_session_endpoint = request.build_absolute_uri( + reverse("oauth2_provider:rp-initiated-logout") + ) + else: + parsed_url = urlparse(oauth2_settings.OIDC_ISS_ENDPOINT) + host = parsed_url.scheme + "://" + parsed_url.netloc + authorization_endpoint = "{}{}".format(host, reverse("oauth2_provider:authorize")) + token_endpoint = "{}{}".format(host, reverse("oauth2_provider:token")) + userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or "{}{}".format( + host, reverse("oauth2_provider:user-info") + ) + introspection_endpoint = "{}{}".format(host, reverse('oauth2_provider:introspect')) + + jwks_uri = "{}{}".format(host, reverse("oauth2_provider:jwks-info")) + if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED: + end_session_endpoint = "{}{}".format(host, reverse("oauth2_provider:rp-initiated-logout")) + + signing_algorithms = [Application.HS256_ALGORITHM] + if oauth2_settings.OIDC_RSA_PRIVATE_KEY: + signing_algorithms = [Application.RS256_ALGORITHM, Application.HS256_ALGORITHM] + + # Weird UU behavior, only openid scope is advertised but other scopes are supported + scopes_supported = ["openid"] + # More weird UU behavior, no claims are advertised but claims are supported + oidc_claims = [] + + + data = { + "issuer": issuer_url, + "authorization_endpoint": authorization_endpoint, + "token_endpoint": token_endpoint, + "userinfo_endpoint": userinfo_endpoint, + "introspection_endpoint": introspection_endpoint, + "jwks_uri": jwks_uri, + "scopes_supported": scopes_supported, + "response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED, + "subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED, + "id_token_signing_alg_values_supported": signing_algorithms, + "token_endpoint_auth_methods_supported": ( + oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED + ), + "code_challenge_methods_supported": [key for key, _ in AbstractGrant.CODE_CHALLENGE_METHODS], + "claims_supported": oidc_claims, + } + if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED: + data["end_session_endpoint"] = end_session_endpoint + response = JsonResponse(data) + response["Access-Control-Allow-Origin"] = "*" + return response diff --git a/main/forms.py b/main/forms.py index de9ba87..14711de 100644 --- a/main/forms.py +++ b/main/forms.py @@ -4,6 +4,7 @@ from cdh.core.forms import TemplatedForm, TemplatedModelForm, \ BootstrapCheckboxInput, BootstrapSelect from djangosaml2idp.models import ServiceProvider +from oauth2_provider.models import Application from main.models import User @@ -46,6 +47,31 @@ def clean_password(self): return f"plain${self.cleaned_data['password']}" +class ApplicationForm(TemplatedModelForm): + class Meta: + model = Application + fields = [ + 'name', + 'redirect_uris', + 'skip_authorization', + ] + widgets = { + 'redirect_uris': forms.Textarea, + 'post_logout_redirect_uris': forms.Textarea, + 'skip_authorization': BootstrapCheckboxInput, + } + + def __init__(self, *args, **kwargs): + super(ApplicationForm, self).__init__(*args, **kwargs) + + if 'client_secret' in self.initial and self.initial['client_secret'].startswith( + 'plain'): + self.initial['client_secret'] = self.initial['client_secret'].split('$', 2)[1] + + def clean_client_secret(self): + return f"plain${self.cleaned_data['client_secret']}" + + class SPCreateForm(TemplatedForm): name = forms.CharField( diff --git a/main/templates/main/index.html b/main/templates/main/index.html index 4c9edfb..2e615c2 100644 --- a/main/templates/main/index.html +++ b/main/templates/main/index.html @@ -87,6 +87,52 @@

SAML Service Providers

+ +
+

OpenID Connect Applications

+ + New + +
+ +
+
+ + + + + + + + + + + + {% for app in openid_applications %} + + + + + + + + {% endfor %} + +
NameClient IDClient SecretRedirect uris
+ {{ app.name }} + + {{ app.client_id }} + + {{ app.client_secret }} + + {{ app.redirect_uris }} + + Edit + Delete +
+
+
+

Users

diff --git a/main/templates/main/oidc_form.html b/main/templates/main/oidc_form.html new file mode 100644 index 0000000..38bcd76 --- /dev/null +++ b/main/templates/main/oidc_form.html @@ -0,0 +1,22 @@ +{% extends 'base/app_base.html' %} + +{% block content %} +
+

Edit OIDC application

+
+
+
+

+ This is a subset of the available settings, with advanced options removed. Please edit the app in the + Django Admin for all options. +

+
+
+ {% csrf_token %} + {{ form }} + +
+
+{% endblock %} diff --git a/main/templates/main/oidc_form_create.html b/main/templates/main/oidc_form_create.html new file mode 100644 index 0000000..73a138a --- /dev/null +++ b/main/templates/main/oidc_form_create.html @@ -0,0 +1,16 @@ +{% extends 'base/app_base.html' %} + +{% block content %} +
+

Create OIDC application

+
+
+
+ {% csrf_token %} + {{ form }} + +
+
+{% endblock %} diff --git a/main/templates/main/oidc_form_delete.html b/main/templates/main/oidc_form_delete.html new file mode 100644 index 0000000..5b08196 --- /dev/null +++ b/main/templates/main/oidc_form_delete.html @@ -0,0 +1,23 @@ +{% extends 'base/app_base.html' %} + +{% block content %} +
+

Delete OIDC App

+
+
+

+ Are you sure you want to delete application '{{ object }}'? +

+
+ {% csrf_token %} +
+ + + Cancel + +
+
+
+{% endblock %} diff --git a/main/urls.py b/main/urls.py index 6f938d8..51b7949 100644 --- a/main/urls.py +++ b/main/urls.py @@ -2,7 +2,10 @@ from django.contrib.auth import views as auth_views from django.urls import path -from .views import HomeView, SamlMetadataView, SamlSPCreateView, \ +from .views import HomeView, OpenIDApplicationCreateView, OpenIDApplicationDeleteView, \ + OpenIDApplicationEditView, \ + SamlMetadataView, \ + SamlSPCreateView, \ SamlSPDeleteView, SamlSPEditView, UserCreateView, UserEditView app_name = 'main' @@ -17,6 +20,12 @@ name='sp-delete'), path('saml-sp//', SamlSPEditView.as_view(), name='sp-edit'), + path('oidc-app/new/', OpenIDApplicationCreateView.as_view(), + name='oidc-app-create'), + path('oidc-app//delete/', OpenIDApplicationDeleteView.as_view(), + name='oidc-app-delete'), + path('oidc-app//', OpenIDApplicationEditView.as_view(), + name='oidc-app-edit'), path('user/new/', UserCreateView.as_view(), name='user-create'), path('user//', UserEditView.as_view(), diff --git a/main/views.py b/main/views.py index 5dcad83..eb0f015 100644 --- a/main/views.py +++ b/main/views.py @@ -8,9 +8,10 @@ from django.urls import reverse_lazy from django.views import generic from djangosaml2idp.models import ServiceProvider +from oauth2_provider.models import Application from main.attribute_map_presets import SC, SC_ALL, UU -from main.forms import SPCreateForm, SPForm, UserForm +from main.forms import ApplicationForm, SPCreateForm, SPForm, UserForm from main.models import User, UserMail, UserOU @@ -21,6 +22,7 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['service_providers'] = ServiceProvider.objects.all() + context['openid_applications'] = Application.objects.all() context['users'] = User.objects.all() return context @@ -162,3 +164,40 @@ def dispatch(self, request, sp, *args, **kwargs): ) raise Http404 + + +class OpenIDApplicationEditView(braces.LoginRequiredMixin, generic.UpdateView): + model = Application + template_name = 'main/oidc_form.html' + form_class = ApplicationForm + success_url = reverse_lazy('main:home') + + +class OpenIDApplicationCreateView(braces.LoginRequiredMixin, generic.FormView): + template_name = 'main/oidc_form_create.html' + form_class = ApplicationForm + success_url = reverse_lazy('main:home') + + def form_valid(self, form): + resp = super().form_valid(form) + + app = Application() + app.name = form.cleaned_data['name'] + app.redirect_uris = form.cleaned_data['redirect_uris'] + app.skip_authorization = form.cleaned_data['skip_authorization'] + + # Hardcoded values + app.user = self.request.user + app.client_type = "public" + app.authorization_grant_type = "authorization-code" + app.hash_client_secret = False + app.algorithm = "RS256" + + app.save() + + return resp + +class OpenIDApplicationDeleteView(braces.LoginRequiredMixin, generic.DeleteView): + model = Application + success_url = reverse_lazy('main:home') + template_name = 'main/oidc_form_delete.html' diff --git a/requirements.in b/requirements.in index c9d7858..fb87899 100644 --- a/requirements.in +++ b/requirements.in @@ -6,8 +6,9 @@ django-simple-menu django-debug-toolbar django-csp django-extensions +django-oauth-toolkit +django-cors-headers +whitenoise djangosaml2idp -cryptography # Not needed if not using encrypted DB fields pip-tools -mysqlclient -cdh-django-core[core] @ git+https://github.com/DH-IT-Portal-Development/django-shared-core.git@v3.1.0-alpha-1 \ No newline at end of file +cdh-django-core[core] @ git+https://github.com/CentreForDigitalHumanities/django-shared-core.git@v3.2.0 diff --git a/requirements.txt b/requirements.txt index dae1dd2..e66a584 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.9 # by the following command: # # pip-compile @@ -7,80 +7,92 @@ arrow==1.3.0 # via djangosaml2idp asgiref==3.8.1 - # via django -build==1.2.1 + # via + # django + # django-cors-headers +build==1.2.2.post1 # via pip-tools -cdh-django-core[core] @ git+https://github.com/DH-IT-Portal-Development/django-shared-core.git@v3.1.0-alpha-1 +cdh-django-core[core] @ git+https://github.com/CentreForDigitalHumanities/django-shared-core.git@v3.2.0 # via -r requirements.in -certifi==2024.2.2 +certifi==2024.8.30 # via requests -cffi==1.16.0 +cffi==1.17.1 # via cryptography charset-normalizer==3.3.2 # via requests click==8.1.7 # via pip-tools -cryptography==42.0.5 +cryptography==43.0.1 # via - # -r requirements.in # cdh-django-core + # jwcrypto # pyopenssl # pysaml2 defusedxml==0.7.1 # via pysaml2 deprecated==1.2.14 # via cdh-django-core -django==4.2.11 +django==4.2.16 # via # -r requirements.in # cdh-django-core # django-braces + # django-cors-headers # django-csp # django-debug-toolbar # django-extensions # django-modeltranslation + # django-oauth-toolkit # django-simple-menu # djangorestframework # djangosaml2idp django-braces==1.15.0 # via -r requirements.in +django-cors-headers==4.4.0 + # via -r requirements.in django-csp==3.8 # via # -r requirements.in # cdh-django-core -django-debug-toolbar==4.3.0 +django-debug-toolbar==4.4.6 # via -r requirements.in django-extensions==3.2.3 # via -r requirements.in -django-impersonate==1.9.2 +django-impersonate==1.9.4 # via # -r requirements.in # cdh-django-core -django-modeltranslation==0.18.11 +django-modeltranslation==0.19.9 + # via -r requirements.in +django-oauth-toolkit==3.0.1 # via -r requirements.in django-simple-menu==2.1.3 # via # -r requirements.in # cdh-django-core -djangorestframework==3.15.1 +djangorestframework==3.15.2 # via cdh-django-core djangosaml2idp==0.7.2 # via -r requirements.in -elementpath==4.4.0 +elementpath==4.5.0 # via xmlschema -idna==3.6 +idna==3.10 # via requests -mysqlclient==2.2.4 - # via -r requirements.in -packaging==24.0 +importlib-metadata==8.5.0 + # via build +jwcrypto==1.5.6 + # via django-oauth-toolkit +oauthlib==3.2.2 + # via django-oauth-toolkit +packaging==24.1 # via build pip-tools==7.4.1 # via -r requirements.in pycparser==2.22 # via cffi -pyopenssl==24.1.0 +pyopenssl==24.2.1 # via pysaml2 -pyproject-hooks==1.0.0 +pyproject-hooks==1.2.0 # via # build # pip-tools @@ -90,30 +102,43 @@ python-dateutil==2.9.0.post0 # via # arrow # pysaml2 -pytz==2024.1 +pytz==2024.2 # via # djangosaml2idp # pysaml2 -requests==2.31.0 - # via pysaml2 +requests==2.32.3 + # via + # django-oauth-toolkit + # pysaml2 six==1.16.0 # via python-dateutil -sqlparse==0.4.4 +sqlparse==0.5.1 # via # django # django-debug-toolbar -types-python-dateutil==2.9.0.20240316 +tomli==2.0.2 + # via + # build + # pip-tools +types-python-dateutil==2.9.0.20241003 # via arrow -typing-extensions==4.10.0 - # via django-modeltranslation -urllib3==2.2.1 +typing-extensions==4.12.2 + # via + # asgiref + # django-modeltranslation + # jwcrypto +urllib3==2.2.3 # via requests -wheel==0.43.0 +wheel==0.44.0 # via pip-tools +whitenoise==6.7.0 + # via -r requirements.in wrapt==1.16.0 # via deprecated xmlschema==2.5.1 # via pysaml2 +zipp==3.20.2 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/testidp/env.py b/testidp/env.py new file mode 100644 index 0000000..1459929 --- /dev/null +++ b/testidp/env.py @@ -0,0 +1,7 @@ +from os import environ + +get = environ.get + +def get_boolean(key: str, default) -> bool: + return get(key, default=str(default)).lower() in ("true", "1", "yes", 't') + diff --git a/testidp/openid_settings.py b/testidp/openid_settings.py new file mode 100644 index 0000000..f5f1ca7 --- /dev/null +++ b/testidp/openid_settings.py @@ -0,0 +1,30 @@ +from .settings import BASE_DIR + +PRIVATE_KEY = str(BASE_DIR) + '/certificates/private.key' + +with open(PRIVATE_KEY) as f: + OIDC_RSA_PRIVATE_KEY = f.read() + + +OAUTH2_PROVIDER = { + "OAUTH2_VALIDATOR_CLASS": "testidp.openid_validator.OpenIDValidator", + "OIDC_ENABLED": True, + "OIDC_RSA_PRIVATE_KEY": OIDC_RSA_PRIVATE_KEY, + "OIDC_RP_INITIATED_LOGOUT_ENABLED": True, + "OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT": True, + "PKCE_REQUIRED": False, + "SCOPES": { + "openid": "OpenID Connect scope", + "profile": "Profile scope", + "email": "email", + }, + "OIDC_RESPONSE_TYPES_SUPPORTED": [ + "token", + "id_token", + "code", + "token id_token", + "code token", + "code id_token token", + "code id_token", + ] +} diff --git a/testidp/openid_validator.py b/testidp/openid_validator.py new file mode 100644 index 0000000..0ad7cc3 --- /dev/null +++ b/testidp/openid_validator.py @@ -0,0 +1,57 @@ +from oauth2_provider.oauth2_validators import OAuth2Validator + + +class OpenIDValidator(OAuth2Validator): + oidc_claim_scope = { + "sub": "openid", + "name": "profile", + "family_name": "profile", + "given_name": "profile", + "nickname": "profile", + "preferred_username": "profile", + "website": "profile", + "locale": "profile", + "email": "email", + "email_verified": "email", + } + + def get_discovery_claims(self, request): + claims = ["sub"] + if self._get_additional_claims_is_request_agnostic(): + claims += list(self.get_claim_dict(request).keys()) + return claims + + def get_userinfo_claims(self, request): + # TODO: make this configurable? + + claims = super().get_userinfo_claims(request) # Type: dict + + data = { + "name": request.user.displayName, + "family_name": request.user.sn, + "given_name": request.user.givenName, + "nickname": request.user.username, + "preferred_username": request.user.username + } + + email = request.user.mail() + if email: + data["website"] = email[0] # Don't ask + data["email"] = email[0] + data["email_verified"] = True + else: + data["website"] = "" + data["email"] = "" + data["email_verified"] = False + + data["locale"] = "NL" + + # Remove claims that are not requested + for key in list(data.keys()): + if self.oidc_claim_scope[key] not in request.scopes: + del data[key] + + claims.update(data) + + return claims + diff --git a/testidp/saml_settings.py b/testidp/saml_settings.py index 2f74344..2e7868d 100644 --- a/testidp/saml_settings.py +++ b/testidp/saml_settings.py @@ -1,11 +1,28 @@ import saml2 +from django.core.exceptions import ImproperlyConfigured from saml2.saml import NAMEID_FORMAT_TRANSIENT, NAMEID_FORMAT_PERSISTENT from saml2.sigver import get_xmlsec_binary +from . import env from .settings import DEBUG, BASE_DIR -LOGIN_URL = '/login/' -BASE_URL = 'http://localhost:7000/saml/idp' +_https_enabled = env.get_boolean("DJANGO_HTTPS", default=False) +_host = env.get("DJANGO_HOST", default="localhost:7000") + +LOGIN_URL = "/login/" +BASE_URL = f"http{'s' if _https_enabled else ''}://{_host}/saml/idp" + +PRIVATE_KEY = str(BASE_DIR) + '/certificates/private.key' +PUBLIC_CERT = str(BASE_DIR) + '/certificates/public.cert' + +if private_key := env.get("DJANGO_IDP_PRIVATE_KEY", default=None): + PRIVATE_KEY = private_key + if public_cert := env.get("DJANGO_IDP_PUBLIC_CERT", default=None): + PUBLIC_CERT = public_cert + else: + raise ImproperlyConfigured( + "DJANGO_IDP_PUBLIC_CERT is required if DJANGO_IDP_PRIVATE_KEY is set" + ) SAML_IDP_SP_FIELD_DEFAULT_ATTRIBUTE_MAPPING = { "username": "uuShortID", @@ -18,7 +35,7 @@ 'debug': DEBUG, 'xmlsec_binary': get_xmlsec_binary(['/opt/local/bin', '/usr/bin']), 'entityid': '%s/metadata' % BASE_URL, - 'description': 'Second test IdP', + 'description': env.get("DJANGO_IDP_DESCRIPTION", default="Django localhost IdP"), 'processor': 'testidp.saml_processor.SamlProcessor', @@ -52,7 +69,7 @@ 'service': { 'idp': { - 'name': 'Django localhost IdP 2', + 'name': env.get("DJANGO_IDP_DESCRIPTION", default="Django localhost IdP"), 'endpoints': { 'single_sign_on_service': [ (f'{BASE_URL}/sso/post/', @@ -77,12 +94,12 @@ }, # Signing - 'key_file': str(BASE_DIR) + '/certificates/private.key', - 'cert_file': str(BASE_DIR) + '/certificates/public.cert', + 'key_file': PRIVATE_KEY, + 'cert_file': PUBLIC_CERT, # Encryption 'encryption_keypairs': [{ - 'key_file': str(BASE_DIR) + '/certificates/private.key', - 'cert_file': str(BASE_DIR) + '/certificates/public.cert', + 'key_file': PRIVATE_KEY, + 'cert_file': PUBLIC_CERT, }], 'valid_for': 365 * 24, "organization": { diff --git a/testidp/settings.py b/testidp/settings.py index 8914461..35ef7d7 100644 --- a/testidp/settings.py +++ b/testidp/settings.py @@ -14,6 +14,8 @@ from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ +from . import env + BASE_DIR = Path(__file__).resolve().parent.parent @@ -21,114 +23,132 @@ # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-lb=q@u4df-x0th(5u%$eye_ti#etst+5z+%2=lrh$$le3&v_y$' +SECRET_KEY = env.get( + "DJANGO_SECRET_KEY", + default="django-insecure-lb=q@u4df-x0th(5u%$eye_ti#etst+5z+%2=lrh$$le3&v_y$", +) # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True -ENABLE_DEBUG_TOOLBAR = True -HOSTED = True # Only set to true on idp.dev.im.hum.uu.nl +DEBUG = env.get_boolean("DJANGO_DEBUG", default=True) +ENABLE_DEBUG_TOOLBAR = env.get_boolean("DJANGO_DEBUG_TOOLBAR", default=DEBUG) +HOSTED = env.get_boolean("DJANGO_HOSTED", default=False) # Only set to true on +# idp.dev.im.hum.uu.nl ALLOWED_HOSTS = [] +_env_hosts = env.get("DJANGO_ALLOWED_HOSTS", default=None) +if _env_hosts: + ALLOWED_HOSTS += _env_hosts.split(",") -# Application definition +# Application definition INSTALLED_APPS = [ # CDH Core - 'cdh.core', - + "cdh.core", # Django supplied apps - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'djangosaml2idp', - + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "djangosaml2idp", + "oauth2_provider", # Django extensions - 'django_extensions', - + "django_extensions", # django-simple-menu - 'menu', - + "menu", # Impersonate - 'impersonate', - + "impersonate", # Django model translation - 'modeltranslation', - + "modeltranslation", # Local apps - 'main', - 'idp', + "main", + "idp", ] 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.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'csp.middleware.CSPMiddleware', + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "csp.middleware.CSPMiddleware", + "corsheaders.middleware.CorsMiddleware", ] +CORS_ORIGIN_ALLOW_ALL = True + if DEBUG and ENABLE_DEBUG_TOOLBAR: - INSTALLED_APPS.append('debug_toolbar') - MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware', ) + INSTALLED_APPS.append("debug_toolbar") + MIDDLEWARE.append( + "debug_toolbar.middleware.DebugToolbarMiddleware", + ) -ROOT_URLCONF = 'testidp.urls' +ROOT_URLCONF = "testidp.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR / 'templates'] - , - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - 'main.context_processors.hosted', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "main.context_processors.hosted", ], }, }, ] -WSGI_APPLICATION = 'testidp.wsgi.application' +WSGI_APPLICATION = "testidp.wsgi.application" # Email -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_HOST = 'localhost' -EMAIL_PORT = 2525 -EMAIL_FROM = 'T.D.Mees@uu.nl' - +# TODO: Decide if to set up email backend +EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend" # Database # https://docs.djangoproject.com/en/4.0/ref/settings/#databases -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', +_db_type = env.get("DJANGO_DB_TYPE", default="sqlite3") + +if _db_type == "sqlite3": + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": env.get("DJANGO_SQLLITE_FILE", default=BASE_DIR / "db.sqlite3"), + } + } +elif _db_type == "postgres": + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": env.get("POSTGRES_DB", default="idp"), + "USER": env.get("POSTGRES_USER", default="idp"), + "PASSWORD": env.get("POSTGRES_PASSWORD", default="idp"), + "HOST": env.get("POSTGRES_HOST", default="localhost"), + "PORT": env.get("POSTGRES_PORT", default="5432"), + } } -} # Default primary key field type # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Auth info -AUTH_USER_MODEL = 'main.User' +AUTH_USER_MODEL = "main.User" -LOGIN_URL = reverse_lazy('main:login') +LOGIN_URL = reverse_lazy("main:login") -LOGIN_REDIRECT_URL = reverse_lazy('main:home') +LOGIN_REDIRECT_URL = reverse_lazy("main:home") # Password validation @@ -137,20 +157,18 @@ AUTH_PASSWORD_VALIDATORS = [] PASSWORD_HASHERS = [ - 'main.password_hashers.PlainPasswordHasher', - 'django.contrib.auth.hashers.PBKDF2PasswordHasher', + "main.password_hashers.PlainPasswordHasher", + "django.contrib.auth.hashers.PBKDF2PasswordHasher", ] # Internationalization # https://docs.djangoproject.com/en/4.0/topics/i18n/ -LANGUAGE_CODE = 'en' -LANGUAGES = ( - ('en', _('lang:en')), -) +LANGUAGE_CODE = "en" +LANGUAGES = (("en", _("lang:en")),) -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -162,34 +180,90 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.0/howto/static-files/ -STATIC_URL = '/static/' - +STATIC_URL = "/static/" +STATIC_ROOT = "/static/" # Security # https://docs.djangoproject.com/en/2.0/topics/security/ -X_FRAME_OPTIONS = 'DENY' -# Local development server doesn't support https -SESSION_COOKIE_SECURE = not DEBUG -CSRF_COOKIE_SECURE = not DEBUG -SECURE_SSL_REDIRECT = not DEBUG -SESSION_COOKIE_NAME = "devidp_sessionid" +_https_enabled = env.get_boolean("DJANGO_HTTPS", default=False) + +X_FRAME_OPTIONS = "DENY" +SECURE_SSL_REDIRECT = _https_enabled + +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +SESSION_COOKIE_SECURE = _https_enabled +CSRF_COOKIE_SECURE = _https_enabled +# Needed to work in kubernetes, as the app may be behind a proxy/may not know it's +# own domain +SESSION_COOKIE_DOMAIN = env.get("SESSION_COOKIE_DOMAIN", default=None) +CSRF_COOKIE_DOMAIN = env.get("CSRF_COOKIE_DOMAIN", default=None) +SESSION_COOKIE_NAME = env.get("SESSION_COOKIE_NAME", default="devidp_sessionid") SESSION_EXPIRE_AT_BROWSER_CLOSE = True SESSION_COOKIE_AGE = 60 * 60 * 12 # 12 hours +CSRF_TRUSTED_ORIGINS = [ + f"http{'s' if _https_enabled else ''}://{host}" for host in ALLOWED_HOSTS +] + +# Logging + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": env.get("LOG_LEVEL", default="WARNING"), + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": env.get("DJANGO_LOG_LEVEL", default="INFO"), + "propagate": False, + }, + "saml2": { + "handlers": ["console"], + "level": env.get("SAML_LOG_LEVEL", default="INFO"), + "propagate": False, + }, + "djangosaml2": { + "handlers": ["console"], + "level": env.get("SAML_LOG_LEVEL", default="INFO"), + "propagate": False, + }, + "djangosaml2idp": { + "handlers": ["console"], + "level": env.get("SAML_LOG_LEVEL", default="INFO"), + "propagate": False, + } + }, +} # Django CSP # http://django-csp.readthedocs.io/en/latest/index.html CSP_REPORT_ONLY = True -CSP_UPGRADE_INSECURE_REQUESTS = not DEBUG -CSP_INCLUDE_NONCE_IN = ['script-src'] -CSP_EXCLUDE_URL_PREFIXES = ('/idp', ) +CSP_UPGRADE_INSECURE_REQUESTS = _https_enabled +CSP_INCLUDE_NONCE_IN = ["script-src"] +CSP_EXCLUDE_URL_PREFIXES = ("/idp",) -CSP_DEFAULT_SRC = ["'self'", ] -CSP_SCRIPT_SRC = ["'self'", ] -CSP_FONT_SRC = ["'self'", 'data:', ] +CSP_DEFAULT_SRC = [ + "'self'", +] +CSP_SCRIPT_SRC = [ + "'self'", +] +CSP_FONT_SRC = [ + "'self'", + "data:", +] CSP_STYLE_SRC = ["'self'", "'unsafe-inline'"] -CSP_IMG_SRC = ["'self'", 'data:', "*"] # Remove the last one if you +CSP_IMG_SRC = ["'self'", "data:", "*"] # Remove the last one if you # want to be really secure # Django Simple Menu @@ -203,3 +277,9 @@ except Exception as e: print("Could not load SAML settings somehow?") raise e + +try: + from .openid_settings import * +except Exception as e: + print("Could not load OpenID settings somehow?") + raise e diff --git a/testidp/urls.py b/testidp/urls.py index 983e4f4..ce242d6 100644 --- a/testidp/urls.py +++ b/testidp/urls.py @@ -16,7 +16,9 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin -from django.urls import include, path +from django.urls import include, path, re_path + +from idp.views import ConnectDiscoveryInfoView handler404 = 'main.error_views.error_404' handler500 = 'main.error_views.error_500' @@ -27,6 +29,12 @@ path('admin/', admin.site.urls), path('', include('main.urls')), path('saml/idp/', include('idp.urls')), + re_path( + r"^o/\.well-known/openid-configuration/?$", + ConnectDiscoveryInfoView.as_view(), + name="oidc-connect-discovery-info", + ), + path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")), path('impersonate/', include('impersonate.urls')), path('cdhcore/', include('cdh.core.urls')), path('i18n/', include('django.conf.urls.i18n')),