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..924f708 --- /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: humitifier + 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/humitifier/${{ 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..3890966 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.11-alpine3.20 + +ENV PYTHONUNBUFFERED=1 +# Copy app +WORKDIR /app +COPY . /app + +# Install dependencies +RUN apk add git xmlsec gettext +RUN pip install -r requirements.txt +RUN pip install gunicorn + +# Cleanup +RUN apk del git + +# 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..e3a30a4 --- /dev/null +++ b/container.env @@ -0,0 +1,12 @@ +DJANGO_SECRET_KEY="django-insecure-lb=q@u4df-x0th(5u%$eye_ti#etst+5z+%2=lrh$$le3&v_y$" +DJANGO_DEBUG="True" +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" +# TODO: document all variables _somewhere_ 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..056cedb --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,28 @@ +services: + dev-idp: + build: . + ports: + - 7000:7000 + env_file: + - ./container.env + volumes: + - ./db:/run/db + networks: + - default + # Below allows for debugging with pdb etc. + 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/requirements.in b/requirements.in index c9d7858..bf4d2be 100644 --- a/requirements.in +++ b/requirements.in @@ -7,7 +7,5 @@ django-debug-toolbar django-csp django-extensions 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 diff --git a/requirements.txt b/requirements.txt index dae1dd2..9465d41 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # -# This file is autogenerated by pip-compile with Python 3.11 -# by the following command: +# This file is autogenerated by pip-compile with python 3.9 +# To update, run: # # pip-compile # @@ -10,9 +10,9 @@ asgiref==3.8.1 # via django build==1.2.1 # 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 @ git+https://github.com/DH-IT-Portal-Development/django-shared-core.git@v3.1.0-alpha-1 # via -r requirements.in -certifi==2024.2.2 +certifi==2024.7.4 # via requests cffi==1.16.0 # via cryptography @@ -20,9 +20,8 @@ charset-normalizer==3.3.2 # via requests click==8.1.7 # via pip-tools -cryptography==42.0.5 +cryptography==43.0.0 # via - # -r requirements.in # cdh-django-core # pyopenssl # pysaml2 @@ -30,7 +29,7 @@ defusedxml==0.7.1 # via pysaml2 deprecated==1.2.14 # via cdh-django-core -django==4.2.11 +django==4.2.14 # via # -r requirements.in # cdh-django-core @@ -48,39 +47,39 @@ 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.5 # 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 # via xmlschema -idna==3.6 +idna==3.7 # via requests -mysqlclient==2.2.4 - # via -r requirements.in -packaging==24.0 +importlib-metadata==8.2.0 + # via build +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.1.0 # via # build # pip-tools @@ -94,26 +93,34 @@ pytz==2024.1 # via # djangosaml2idp # pysaml2 -requests==2.31.0 +requests==2.32.3 # via pysaml2 six==1.16.0 # via python-dateutil -sqlparse==0.4.4 +sqlparse==0.5.1 # via # django # django-debug-toolbar +tomli==2.0.1 + # via + # build + # pip-tools types-python-dateutil==2.9.0.20240316 # via arrow -typing-extensions==4.10.0 - # via django-modeltranslation -urllib3==2.2.1 +typing-extensions==4.12.2 + # via + # asgiref + # django-modeltranslation +urllib3==2.2.2 # via requests -wheel==0.43.0 +wheel==0.44.0 # via pip-tools wrapt==1.16.0 # via deprecated xmlschema==2.5.1 # via pysaml2 +zipp==3.19.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/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..435f1f0 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,127 @@ # 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", # 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", + "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", ] 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 +152,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 +175,90 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.0/howto/static-files/ -STATIC_URL = '/static/' - +STATIC_URL = "/static/" +STATIC_ROOT = "public/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