🏗️(common) replace Django app with SATOSA

We mostly import SATOSA and launch its WSGI app.
This commit is contained in:
Jonathan Perret
2024-04-17 14:35:51 +02:00
parent ce004c5e80
commit 3e71b2166c
44 changed files with 305 additions and 964 deletions
-1
View File
@@ -20,7 +20,6 @@ docs
*.log *.log
# Development/test cache & configurations # Development/test cache & configurations
data
.cache .cache
.circleci .circleci
.git .git
+5 -15
View File
@@ -62,7 +62,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
working-directory: src/backend working-directory: src/satosa
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v2 uses: actions/checkout@v2
@@ -73,7 +73,7 @@ jobs:
- name: Install development dependencies - name: Install development dependencies
run: | run: |
# Python's xmlsec requirement # Python's xmlsec requirement
sudo apt-get update -y -q && sudo apt-get install -y -q libxmlsec1-dev sudo apt-get update -y -q && sudo apt-get install -y -q xmlsec1 libxmlsec1-dev
pip install --user .[dev] pip install --user .[dev]
- name: Check code formatting with ruff - name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff run: ~/.local/bin/ruff format . --diff
@@ -86,21 +86,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
working-directory: src/backend working-directory: src/satosa
env:
DJANGO_CONFIGURATION: Test
DJANGO_SETTINGS_MODULE: oidc2fer.settings
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Create writable /data
run: |
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
- name: Install Python - name: Install Python
uses: actions/setup-python@v3 uses: actions/setup-python@v3
with: with:
@@ -108,7 +98,7 @@ jobs:
- name: Install development dependencies - name: Install development dependencies
run: | run: |
# Python's xmlsec requirement # Python's xmlsec requirement
sudo apt-get update -y -q && sudo apt-get install -y -q libxmlsec1-dev sudo apt-get update -y -q && sudo apt-get install -y -q xmlsec1 libxmlsec1-dev
pip install --user .[dev] pip install --user .[dev]
- name: Run tests - name: Run tests
run: ~/.local/bin/pytest -n 2 run: ~/.local/bin/pytest
-9
View File
@@ -47,12 +47,6 @@ env.d/terraform
# npm # npm
node_modules node_modules
# Mails
src/backend/core/templates/mail/
# Typescript client
src/frontend/tsclient
# Swagger # Swagger
**/swagger.json **/swagger.json
@@ -71,9 +65,6 @@ src/frontend/tsclient
db.sqlite3 db.sqlite3
.mypy_cache .mypy_cache
# Site media
/data/
# IDEs # IDEs
.idea/ .idea/
.vscode/ .vscode/
+18 -53
View File
@@ -1,5 +1,3 @@
# Django OIDC2FER
# ---- base image to inherit from ---- # ---- base image to inherit from ----
FROM python:3.11-slim-bookworm as base FROM python:3.11-slim-bookworm as base
@@ -10,6 +8,7 @@ RUN apt-get update && \
apt-get install -y \ apt-get install -y \
pkg-config \ pkg-config \
gcc \ gcc \
xmlsec1 \
libxml2-dev \ libxml2-dev \
libxmlsec1-dev \ libxmlsec1-dev \
libxmlsec1-openssl && \ libxmlsec1-openssl && \
@@ -24,37 +23,11 @@ FROM base as back-builder
WORKDIR /builder WORKDIR /builder
# Copy required python dependencies # Copy required python dependencies
COPY ./src/backend /builder COPY ./src/satosa /builder
RUN mkdir /install && \ RUN mkdir /install && \
pip install --prefix=/install . pip install --prefix=/install .
# ---- static link collector ----
FROM base as link-collector
ARG OIDC2FER_STATIC_ROOT=/data/static
# Install rdfind
RUN apt-get update && \
apt-get install -y \
rdfind && \
rm -rf /var/lib/apt/lists/*
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy oidc2fer application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app
# collectstatic
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the
# final image
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${OIDC2FER_STATIC_ROOT}
# ---- Core application image ---- # ---- Core application image ----
FROM base as core FROM base as core
@@ -78,7 +51,7 @@ RUN chmod g=u /etc/passwd
COPY --from=back-builder /install /usr/local COPY --from=back-builder /install /usr/local
# Copy oidc2fer application (see .dockerignore) # Copy oidc2fer application (see .dockerignore)
COPY ./src/backend /app/ COPY ./src/satosa /app/
WORKDIR /app WORKDIR /app
@@ -87,8 +60,22 @@ WORKDIR /app
# ID. # ID.
ENTRYPOINT [ "/usr/local/bin/entrypoint" ] ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
# ---- Production image ----
FROM core as production
# Gunicorn
RUN mkdir -p /usr/local/etc/gunicorn
COPY docker/files/usr/local/etc/gunicorn/satosa.py /usr/local/etc/gunicorn/satosa.py
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
# The default command runs gunicorn WSGI server in satosa's main module
CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/satosa.py", "satosa.wsgi:app"]
# ---- Development image ---- # ---- Development image ----
FROM core as development FROM production as development
# Switch back to the root user to install development dependencies # Switch back to the root user to install development dependencies
USER root:root USER root:root
@@ -101,25 +88,3 @@ RUN pip install -e .[dev]
# Restore the un-privileged user running the application # Restore the un-privileged user running the application
ARG DOCKER_USER ARG DOCKER_USER
USER ${DOCKER_USER} USER ${DOCKER_USER}
# Run django development server
CMD python manage.py runserver 0.0.0.0:8000
# ---- Production image ----
FROM core as production
ARG OIDC2FER_STATIC_ROOT=/data/static
# Gunicorn
RUN mkdir -p /usr/local/etc/gunicorn
COPY docker/files/usr/local/etc/gunicorn/oidc2fer.py /usr/local/etc/gunicorn/oidc2fer.py
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
# Copy statics
COPY --from=link-collector ${OIDC2FER_STATIC_ROOT} ${OIDC2FER_STATIC_ROOT}
# The default command runs gunicorn WSGI server in oidc2fer's main module
CMD gunicorn -c /usr/local/etc/gunicorn/oidc2fer.py oidc2fer.wsgi:application
+5 -41
View File
@@ -39,35 +39,24 @@ COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
# ============================================================================== # ==============================================================================
# RULES # RULES
default: help default: help
data/media:
@mkdir -p data/media
data/static:
@mkdir -p data/static
# -- Project # -- Project
create-env-files: ## Copy the dist env files to env files create-env-files: ## Copy the dist env files to env files
create-env-files: \ create-env-files: \
env.d/development/common env.d/development/common \
env.d/development/satosa
.PHONY: create-env-files .PHONY: create-env-files
bootstrap: ## Prepare Docker images for the project bootstrap: ## Prepare Docker images for the project
bootstrap: \ bootstrap: \
data/media \
data/static \
create-env-files \ create-env-files \
build \ build \
run \ run
migrate
.PHONY: bootstrap .PHONY: bootstrap
# -- Docker/compose # -- Docker/compose
@@ -122,7 +111,7 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
.PHONY: lint-pylint .PHONY: lint-pylint
test: ## run project tests test: ## run project tests
@$(MAKE) test-back-parallel @$(MAKE) test-back
.PHONY: test .PHONY: test
test-back: ## run back-end tests test-back: ## run back-end tests
@@ -130,34 +119,9 @@ test-back: ## run back-end tests
bin/pytest $${args:-${1}} bin/pytest $${args:-${1}}
.PHONY: test-back .PHONY: test-back
test-back-parallel: ## run all back-end tests in parallel
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel
superuser: ## Create an admin superuser with password "admin"
@echo "$(BOLD)Creating a Django superuser$(RESET)"
@$(MANAGE) createsuperuser --email admin@example.com --password admin
.PHONY: superuser
shell: ## connect to database shell
@$(MANAGE) shell #_plus
.PHONY: dbshell
# -- Database
dbshell: ## connect to database shell
docker compose exec app-dev python manage.py dbshell
.PHONY: dbshell
resetdb: ## flush database and create a superuser "admin"
@echo "$(BOLD)Flush database$(RESET)"
@$(MANAGE) flush
@${MAKE} superuser
.PHONY: resetdb
env.d/development/common: env.d/development/common:
cp -n env.d/development/common.dist env.d/development/common cp -n env.d/development/common.dist env.d/development/common
cp -n env.d/development/satosa.dist env.d/development/satosa
# -- Misc # -- Misc
clean: ## restore repository state as it was freshly cloned clean: ## restore repository state as it was freshly cloned
-11
View File
@@ -47,17 +47,6 @@ Finally, you can check all available Make rules using:
$ make help $ make help
``` ```
### Django admin
You can access the Django admin site at
[http://localhost:8071/admin](http://localhost:8071/admin).
You first need to create a superuser account:
```bash
$ make superuser
```
## Contributing ## Contributing
This project is intended to be community-driven, so please, do not hesitate to This project is intended to be community-driven, so please, do not hesitate to
-9
View File
@@ -84,15 +84,6 @@ function _dc_exec() {
_docker_compose exec $user_args "$@" _docker_compose exec $user_args "$@"
} }
# _django_manage: wrap django's manage.py command with docker compose
#
# usage : _django_manage [ARGS...]
#
# ARGS : django's manage.py command arguments
function _django_manage() {
_dc_run "app-dev" python manage.py "$@"
}
# _set_openstack_project: select an OpenStack project from the openrc files defined in the # _set_openstack_project: select an OpenStack project from the openrc files defined in the
# terraform directory. # terraform directory.
# #
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env bash
# shellcheck source=bin/_config.sh
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_django_manage "$@"
+4 -4
View File
@@ -27,12 +27,12 @@ do
done done
if [[ -n "${diff_from}" ]]; then if [[ -n "${diff_from}" ]]; then
# Run pylint only on modified files located in src/backend # Run pylint only on modified files located in src/satosa
# (excluding deleted files and migration files) # (excluding deleted files)
# shellcheck disable=SC2207 # shellcheck disable=SC2207
paths=($(git diff "${diff_from}" --name-only --diff-filter=d -- src/backend ':!**/migrations/*.py' | grep -E '^src/backend/.*\.py$')) paths=($(git diff "${diff_from}" --name-only --diff-filter=d -- src/satosa | grep -E '^src/satosa/.*\.py$'))
fi fi
# Fix docker vs local path when project sources are mounted as a volume # Fix docker vs local path when project sources are mounted as a volume
read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/backend/||g")" read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/satosa/||g")"
_dc_run app-dev pylint "${paths[@]}" "${args[@]}" _dc_run app-dev pylint "${paths[@]}" "${args[@]}"
-1
View File
@@ -3,6 +3,5 @@
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \ _dc_run \
-e DJANGO_CONFIGURATION=Test \
app-dev \ app-dev \
pytest "$@" pytest "$@"
+5 -15
View File
@@ -9,16 +9,12 @@ services:
image: oidc2fer:development image: oidc2fer:development
environment: environment:
- PYLINTHOME=/app/.pylint.d - PYLINTHOME=/app/.pylint.d
- DJANGO_CONFIGURATION=Development
env_file: env_file:
- env.d/development/common - env.d/development/common
ports: - env.d/development/satosa
- "8071:8000"
volumes: volumes:
- ./src/backend:/app - ./src/satosa:/app
- ./data/media:/data/media
- ./data/static:/data/static
app: app:
build: build:
context: . context: .
@@ -27,20 +23,14 @@ services:
DOCKER_USER: ${DOCKER_USER:-1000} DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000} user: ${DOCKER_USER:-1000}
image: oidc2fer:production image: oidc2fer:production
environment:
- DJANGO_CONFIGURATION=Demo
env_file: env_file:
- env.d/development/common - env.d/development/common
volumes: - env.d/development/satosa
- ./data/media:/data/media
nginx: nginx:
image: nginx:1.25 image: nginx:1.25
ports: ports:
- "8081:8081"
- "8082:8082" - "8082:8082"
- "8088:8088"
volumes: volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro - ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
- ./data/media:/data/media:ro
depends_on:
- app
+21 -9
View File
@@ -1,17 +1,29 @@
server { resolver 127.0.0.11;
server {
listen 8081;
server_name localhost;
charset utf-8;
location / {
set $backend "http://app:8000";
proxy_pass $backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
server {
listen 8082; listen 8082;
server_name localhost; server_name localhost;
charset utf-8; charset utf-8;
location /media {
alias /data/media;
}
location / { location / {
proxy_pass http://app:8000; set $backend "http://app-dev:8000";
proxy_set_header Host $host; proxy_pass $backend;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
} }
+8
View File
@@ -31,5 +31,13 @@ if ! whoami > /dev/null 2>&1; then
fi fi
fi fi
echo "🐳(entrypoint) creating config files from environment..."
mkdir -p /tmp
# These files are referenced by the Satosa config files
printenv SAML2_BACKEND_CERT > /tmp/backend.crt
printenv SAML2_BACKEND_KEY > /tmp/backend.key
printenv OIDC_FRONTEND_KEY > /tmp/frontend.key
printenv CLIENT_DB_JSON > /tmp/client_db.json
echo "🐳(entrypoint) running your command: ${*}" echo "🐳(entrypoint) running your command: ${*}"
exec "$@" exec "$@"
@@ -1,6 +1,5 @@
# Gunicorn-django settings
bind = ["0.0.0.0:8000"] bind = ["0.0.0.0:8000"]
name = "oidc2fer" name = "satosa"
python_path = "/app" python_path = "/app"
# Run # Run
-6
View File
@@ -1,8 +1,2 @@
# Django
DJANGO_ALLOWED_HOSTS=*
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
DJANGO_SETTINGS_MODULE=oidc2fer.settings
DJANGO_SUPERUSER_PASSWORD=admin
# Python # Python
PYTHONPATH=/app PYTHONPATH=/app
+27
View File
@@ -0,0 +1,27 @@
STATE_ENCRYPTION_KEY=
SAML2_BACKEND_CERT="-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
"
SAML2_BACKEND_KEY="-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
"
OIDC_FRONTEND_KEY="-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
"
CLIENT_DB_JSON="
{
\"oidc-test-client\": {
\"response_types\": [
\"id_token\",
\"code\"
],
\"redirect_uris\": [
\"https://oidc-test-client.traefik.me/auth/callback\"
],
\"client_secret\": \"oidc-test-secret\"
}
}
"
-3
View File
@@ -1,3 +0,0 @@
include LICENSE
include README.md
recursive-include src/backend/oidc2fer *.html *.png *.gif *.css *.ico *.jpg *.jpeg *.po *.mo *.eot *.svg *.ttf *.woff *.woff2
View File
-61
View File
@@ -1,61 +0,0 @@
"""Admin classes and registrations for core app."""
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from . import models
@admin.register(models.User)
class UserAdmin(auth_admin.UserAdmin):
"""Admin class for the User model"""
fieldsets = (
(
None,
{
"fields": (
"id",
"password",
)
},
),
(_("Personal info"), {"fields": ("email", "language", "timezone")}),
(
_("Permissions"),
{
"fields": (
"is_active",
"is_device",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
),
},
),
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("email", "password1", "password2"),
},
),
)
list_display = (
"id",
"email",
"is_active",
"is_staff",
"is_superuser",
"is_device",
"created_at",
"updated_at",
)
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
readonly_fields = ("id", "email", "created_at", "updated_at")
search_fields = ("id", "email")
-24
View File
@@ -1,24 +0,0 @@
# ruff: noqa: S311
"""
Core application factories
"""
from django.conf import settings
from django.contrib.auth.hashers import make_password
import factory.fuzzy
from faker import Faker
from core import models
fake = Faker()
class UserFactory(factory.django.DjangoModelFactory):
"""A factory to random users for testing purposes."""
class Meta:
model = models.User
email = factory.Faker("email")
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
password = make_password("password")
@@ -1,45 +0,0 @@
# Generated by Django 5.0.2 on 2024-03-09 22:04
import django.contrib.auth.models
import timezone_field.fields
import uuid
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=[
('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')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')),
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('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',
'db_table': 'oidc2fer_user',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]
-100
View File
@@ -1,100 +0,0 @@
"""
Declare and configure the models for the oidc2fer core application
"""
import uuid
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.db import models
from django.utils.functional import lazy
from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneField
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
before saving as Django doesn't do it by default.
Includes fields common to all models: a UUID primary key and creation/update timestamps.
"""
id = models.UUIDField(
verbose_name=_("id"),
help_text=_("primary key for the record as UUID"),
primary_key=True,
default=uuid.uuid4,
editable=False,
)
created_at = models.DateTimeField(
verbose_name=_("created on"),
help_text=_("date and time at which a record was created"),
auto_now_add=True,
editable=False,
)
updated_at = models.DateTimeField(
verbose_name=_("updated on"),
help_text=_("date and time at which a record was last updated"),
auto_now=True,
editable=False,
)
class Meta:
abstract = True
def save(self, *args, **kwargs):
"""Call `full_clean` before saving."""
self.full_clean()
super().save(*args, **kwargs)
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model."""
email = models.EmailField(_("email address"), unique=True)
language = models.CharField(
max_length=10,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
default=settings.LANGUAGE_CODE,
verbose_name=_("language"),
help_text=_("The language in which the user wants to see the interface."),
)
timezone = TimeZoneField(
choices_display="WITH_GMT_OFFSET",
use_pytz=False,
default=settings.TIME_ZONE,
help_text=_("The timezone in which the user wants to see times."),
)
is_device = models.BooleanField(
_("device"),
default=False,
help_text=_("Whether the user is a device or a real user."),
)
is_staff = models.BooleanField(
_("staff status"),
default=False,
help_text=_("Whether the user can log into this admin site."),
)
is_active = models.BooleanField(
_("active"),
default=True,
help_text=_(
"Whether this user should be treated as active. "
"Unselect this instead of deleting accounts."
),
)
objects = auth_models.UserManager()
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
class Meta:
db_table = "oidc2fer_user"
verbose_name = _("user")
verbose_name_plural = _("users")
def __str__(self):
return self.email
View File
View File
@@ -1,46 +0,0 @@
"""Management user to create a superuser."""
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
UserModel = get_user_model()
class Command(BaseCommand):
"""Management command to create a superuser from and email and password."""
help = "Create a superuser with an email and a password"
def add_arguments(self, parser):
"""Define required arguments "email" and "password"."""
parser.add_argument(
"--email",
help=("Email for the user."),
)
parser.add_argument(
"--password",
help="Password for the user.",
)
def handle(self, *args, **options):
"""
Given an email and a password, create a superuser or upgrade the existing
user to superuser status.
"""
email = options.get("email")
try:
user = UserModel.objects.get(email=email)
except UserModel.DoesNotExist:
user = UserModel(email=email)
message = "Superuser created successfully."
else:
if user.is_superuser and user.is_staff:
message = "Superuser already exists."
else:
message = "User already existed and was upgraded to superuser."
user.is_superuser = True
user.is_staff = True
user.set_password(options["password"])
user.save()
self.stdout.write(self.style.SUCCESS(message))
-14
View File
@@ -1,14 +0,0 @@
#!/usr/bin/env python
"""
OIDC2FER's sandbox management script.
"""
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "oidc2fer.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
from configurations.management import execute_from_command_line
execute_from_command_line(sys.argv)
View File
-422
View File
@@ -1,422 +0,0 @@
"""
Django's settings for OIDC2FER project.
Generated by 'django-admin startproject' using Django 5.0.2.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.0/ref/settings/
"""
import json
import os
from django.utils.translation import gettext_lazy as _
import sentry_sdk
from configurations import Configuration, values
from sentry_sdk.integrations.django import DjangoIntegration
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join("/", "data")
def get_release():
"""
Get the current release of the application
By release, we mean the release from the version.json file à la Mozilla [1]
(if any). If this file has not been found, it defaults to "NA".
[1]
https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md
"""
# Try to get the current release from the version.json file generated by the
# CI during the Docker image build
try:
with open(os.path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
return json.load(version)["version"]
except FileNotFoundError:
return "NA" # Default: not available
class Base(Configuration):
"""
This is the base configuration every configuration (aka environment) should inherit from. It
is recommended to configure third-party applications by creating a configuration mixins in
./configurations and compose the Base configuration with those mixins.
It depends on an environment variable that SHOULD be defined:
* DJANGO_SECRET_KEY
You may also want to override default configuration by setting the following environment
variables:
* DJANGO_SENTRY_DSN
* DB_NAME
* DB_HOST
* DB_PASSWORD
* DB_USER
"""
DEBUG = False
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = values.Value(None)
# Application definition
ROOT_URLCONF = "oidc2fer.urls"
WSGI_APPLICATION = "oidc2fer.wsgi.application"
# Database
DATABASES = {
"default": {
"ENGINE": values.Value(
"django.db.backends.postgresql_psycopg2",
environ_name="DB_ENGINE",
environ_prefix=None,
),
"NAME": values.Value(
"oidc2fer", environ_name="DB_NAME", environ_prefix=None
),
"USER": values.Value("dinum", environ_name="DB_USER", environ_prefix=None),
"PASSWORD": values.Value(
"pass", environ_name="DB_PASSWORD", environ_prefix=None
),
"HOST": values.Value(
"localhost", environ_name="DB_HOST", environ_prefix=None
),
"PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None),
}
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Static files (CSS, JavaScript, Images)
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(DATA_DIR, "static")
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(DATA_DIR, "media")
SITE_ID = 1
STORAGES = {
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
# Languages
LANGUAGE_CODE = values.Value("en-us")
# Careful! Languages should be ordered by priority, as this tuple is used to get
# fallback/default languages throughout the app.
LANGUAGES = values.SingleNestedTupleValue(
(
("en-us", _("English")),
("fr-fr", _("French")),
)
)
LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),)
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Templates
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")],
"OPTIONS": {
"context_processors": [
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.csrf",
"django.template.context_processors.debug",
"django.template.context_processors.i18n",
"django.template.context_processors.media",
"django.template.context_processors.request",
"django.template.context_processors.tz",
],
"loaders": [
"django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
],
},
},
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
]
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
]
# Django's applications from the highest priority to the lowest
INSTALLED_APPS = [
# People
"core",
"demo",
# Third party apps
"social_edu_federation.django.apps.PythonSocialEduFedAuthConfig",
"corsheaders",
"dockerflow.django",
# Django
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.postgres",
"django.contrib.sessions",
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.staticfiles",
]
# Cache
CACHES = {
"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
}
AUTH_USER_MODEL = "core.User"
# CORS
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(False)
CORS_ALLOWED_ORIGINS = values.ListValue([])
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
# Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
# Session
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_COOKIE_AGE = 60 * 60 * 12 # 12 hours to match Agent Connect
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
"""Environment in which the application is launched."""
return self.__class__.__name__.lower()
# pylint: disable=invalid-name
@property
def RELEASE(self):
"""
Return the release information.
Delegate to the module function to enable easier testing.
"""
return get_release()
@classmethod
def post_setup(cls):
"""Post setup configuration.
This is the place where you can configure settings that require other
settings to be loaded.
"""
super().post_setup()
# The SENTRY_DSN setting should be available to activate sentry for an environment
if cls.SENTRY_DSN is not None:
sentry_sdk.init(
dsn=cls.SENTRY_DSN,
environment=cls.__name__.lower(),
release=get_release(),
integrations=[DjangoIntegration()],
)
with sentry_sdk.configure_scope() as scope:
scope.set_extra("application", "backend")
class Build(Base):
"""Settings used when the application is built.
This environment should not be used to run the application. Just to build it with non-blocking
settings.
"""
SECRET_KEY = values.Value("DummyKey")
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": values.Value(
"whitenoise.storage.CompressedManifestStaticFilesStorage",
environ_name="STORAGES_STATICFILES_BACKEND",
),
},
}
class Development(Base):
"""
Development environment settings
We set DEBUG to True and configure the server to respond from all hosts.
"""
ALLOWED_HOSTS = ["*"]
CORS_ALLOW_ALL_ORIGINS = True
CSRF_TRUSTED_ORIGINS = ["http://localhost:8071"]
DEBUG = True
SESSION_COOKIE_NAME = "oidc2fer_sessionid"
USE_SWAGGER = True
def __init__(self):
# pylint: disable=invalid-name
self.INSTALLED_APPS += ["django_extensions"]
class Test(Base):
"""Test environment settings"""
LOGGING = values.DictValue(
{
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"loggers": {
"oidc2fer": {
"handlers": ["console"],
"level": "DEBUG",
},
},
}
)
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]
USE_SWAGGER = True
STORAGES = {
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
class ContinuousIntegration(Test):
"""
Continuous Integration environment settings
nota bene: it should inherit from the Test environment.
"""
class Production(Base):
"""
Production environment settings
You must define the ALLOWED_HOSTS environment variable in Production
configuration (and derived configurations):
ALLOWED_HOSTS=["foo.com", "foo.fr"]
"""
# Security
ALLOWED_HOSTS = values.ListValue(None)
CSRF_TRUSTED_ORIGINS = values.ListValue([])
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
# SECURE_PROXY_SSL_HEADER allows to fix the scheme in Django's HttpRequest
# object when your application is behind a reverse proxy.
#
# Keep this SECURE_PROXY_SSL_HEADER configuration only if :
# - your Django app is behind a proxy.
# - your proxy strips the X-Forwarded-Proto header from all incoming requests
# - Your proxy sets the X-Forwarded-Proto header and sends it to Django
#
# In other cases, you should comment the following line to avoid security issues.
# SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
# For static files in production, we want to use a backend that includes a hash in
# the filename, that is calculated from the file content, so that browsers always
# get the updated version of each file.
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
},
"staticfiles": {
# For static files in production, we want to use a backend that includes a hash in
# the filename, that is calculated from the file content, so that browsers always
# get the updated version of each file.
"BACKEND": values.Value(
"whitenoise.storage.CompressedManifestStaticFilesStorage",
environ_name="STORAGES_STATICFILES_BACKEND",
)
},
}
# Privacy
SECURE_REFERRER_POLICY = "same-origin"
class Feature(Production):
"""
Feature environment settings
nota bene: it should inherit from the Production environment.
"""
class Staging(Production):
"""
Staging environment settings
nota bene: it should inherit from the Production environment.
"""
class PreProduction(Production):
"""
Pre-production environment settings
nota bene: it should inherit from the Production environment.
"""
class Demo(Production):
"""
Demonstration environment settings
nota bene: it should inherit from the Production environment.
"""
CSRF_TRUSTED_ORIGINS = ["http://localhost:8082"]
STORAGES = {
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
-18
View File
@@ -1,18 +0,0 @@
"""People URL Configuration"""
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import path
urlpatterns = [
path("admin/", admin.site.urls),
]
if settings.DEBUG:
urlpatterns = (
urlpatterns
+ staticfiles_urlpatterns()
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
)
-17
View File
@@ -1,17 +0,0 @@
"""
WSGI config for the People project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/
"""
import os
from configurations.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "oidc2fer.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
application = get_wsgi_application()
@@ -23,7 +23,7 @@ jobs=0
# List of plugins (as comma separated values of python modules names) to load, # List of plugins (as comma separated values of python modules names) to load,
# usually to register additional checkers. # usually to register additional checkers.
load-plugins=pylint_django,pylint.extensions.no_self_use load-plugins=pylint.extensions.no_self_use
# Pickle collected data for later comparisons. # Pickle collected data for later comparisons.
persistent=yes persistent=yes
@@ -57,7 +57,6 @@ confidence=
# --disable=W" # --disable=W"
disable=bad-inline-option, disable=bad-inline-option,
deprecated-pragma, deprecated-pragma,
django-not-configured,
file-ignored, file-ignored,
locally-disabled, locally-disabled,
no-self-use, no-self-use,
+46
View File
@@ -0,0 +1,46 @@
attributes:
address:
openid:
- address.street_address
saml:
- postaladdress
displayname:
openid:
- nickname
saml:
- displayName
edupersontargetedid:
openid:
- sub
saml:
- eduPersonTargetedID
givenname:
openid:
- given_name
saml:
- firstName
- givenName
mail:
openid:
- email
saml:
- email
- emailAddress
- mail
name:
openid:
- name
saml:
- cn
surname:
openid:
- family_name
saml:
- sn
- surname
- lastName
organization:
saml:
- o
openid:
- organization
@@ -0,0 +1,53 @@
module: satosa.backends.saml2.SAMLBackend
name: Saml2
config:
disco_srv: https://discovery.renater.fr/test/
entityid_endpoint: true
mirror_force_authn: 'no'
memorize_idp: 'no'
use_memorized_idp_when_force_authn: 'no'
send_requester_id: 'no'
enable_metadata_reload: 'no'
acs_selection_strategy: prefer_matching_host
sp_config:
name: Passerelle OIDC vers FER
description: SP Description
key_file: /tmp/backend.key
cert_file: /tmp/backend.crt
organization:
name: DINUM
display_name: DINUM
url: https://beta.gouv.fr/incubateurs/dinum_produits_interministeriels.html
metadata:
# mdq:
# - url: https://mdq.federation.renater.fr
remote:
- url: https://pub.federation.renater.fr/metadata/test/preview/preview-idps-test-metadata.xml
entityid: <base_url>/<name>/proxy_saml2_backend.xml
accepted_time_diff: 60
allow_unknown_attributes: true
service:
sp:
ui_info:
display_name:
- lang: fr
text: Passerelle OIDC vers FER
logo:
text: https://beta.gouv.fr/img/incubators/logo_dinum.png
width: '200'
height: '200'
authn_requests_signed: true
want_response_signed: true
allow_unsolicited: true
name_id_format:
- urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
- urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified
endpoints:
assertion_consumer_service:
- - <base_url>/<name>/acs/post
- urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST
discovery_response:
- - <base_url>/<name>/disco
- 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol'
name_id_format_allow_create: true
@@ -0,0 +1,44 @@
module: satosa.frontends.openid_connect.OpenIDConnectFrontend
name: OIDC
config:
signing_key_path: /tmp/frontend.key
signing_key_id: frontend.key1
# Defines the database connection URI for the databases:
# - authz_code_db
# - access_token_db
# - refresh_token_db
# - sub_db
# - user_db
#
# supported storage backends:
# - In-memory dictionary
# - MongoDB (e.g. mongodb://db.example.com)
# - Redis (e.g. redis://example/0)
# - Stateless (eg. stateless://user:encryptionkey?alg=aes256)
#
# This configuration is optional.
# By default, the in-memory storage is used.
# db_uri: mongodb://db.example.com
# Where to store clients.
#
# If client_db_uri is set, the database connection is used.
# Otherwise, if client_db_path is set, the JSON file is used.
# By default, an in-memory dictionary is used.
# client_db_uri: mongodb://db.example.com
client_db_path: /tmp/client_db.json
# if not specified, it is randomly generated on every startup
sub_hash_salt: randomSALTvalue
sub_mirror_public: yes
provider:
client_registration_supported: no
response_types_supported: ["code", "id_token token"]
subject_types_supported: ["public"]
scopes_supported: ["openid", "email"]
id_token_lifetime: 3600
extra_id_token_claims:
oidc-test-client:
- organization
@@ -0,0 +1,3 @@
module: satosa.frontends.ping.PingFrontend
name: ping
config: null
@@ -0,0 +1,13 @@
module: satosa.micro_services.attribute_modifications.AddStaticAttributes
name: AddAttributes
config:
static_attributes:
organisation: Example Org.
schachomeorganization: example.com
schachomeorganizationtype:
- urn:schac:homeOrganizationType:eu:higherEducationInstitution
- urn:schac:homeOrganizationType:eu:educationInstitution
organizationname: Example Organization
noreduorgacronym: EO
countryname: SE
friendlycountryname: Sweden
+41
View File
@@ -0,0 +1,41 @@
BASE: !ENV BASE_URL
COOKIE_STATE_NAME: SATOSA_STATE
CONTEXT_STATE_DELETE: 'yes'
STATE_ENCRYPTION_KEY: !ENV STATE_ENCRYPTION_KEY
cookies_samesite_compat:
- - SATOSA_STATE
- SATOSA_STATE_LEGACY
INTERNAL_ATTRIBUTES: internal_attributes.yaml
BACKEND_MODULES:
- plugins/backends/saml2_backend.yaml
FRONTEND_MODULES:
- plugins/frontends/openid_connect_frontend.yaml
- plugins/frontends/ping_frontend.yaml
MICRO_SERVICES:
- plugins/microservices/static_attributes.yaml
LOGGING:
version: 1
formatters:
simple:
format: '[%(asctime)s] [%(levelname)s] [%(name)s.%(funcName)s] %(message)s'
handlers:
stdout:
class: logging.StreamHandler
stream: ext://sys.stdout
level: DEBUG
formatter: simple
loggers:
satosa:
level: DEBUG
saml2:
level: DEBUG
oidcendpoint:
level: DEBUG
pyop:
level: DEBUG
oic:
level: DEBUG
root:
level: DEBUG
handlers:
- stdout
@@ -11,8 +11,6 @@ version = "0.1.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
"Framework :: Django",
"Framework :: Django :: 5",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Natural Language :: English", "Natural Language :: English",
@@ -20,25 +18,13 @@ classifiers = [
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
] ]
description = "An application to handle contacts and teams." description = "An application to handle contacts and teams."
keywords = ["Django", "OIDC", "SAML", "Shibboleth", "FER", "RENATER"] keywords = ["OIDC", "SAML", "Shibboleth", "FER", "RENATER"]
license = { file = "LICENSE" } license = { file = "LICENSE" }
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"django-configurations==2.5", "SATOSA==8.4.0",
"django-cors-headers==4.3.1",
"django==5.0.2",
"django-timezone-field>=5.1",
"dockerflow==2024.2.0",
"factory_boy==3.3.0",
"gunicorn==21.2.0", "gunicorn==21.2.0",
"psycopg[binary]==3.1.18",
"requests==2.31.0",
"sentry-sdk==1.40.6",
"social-auth-app-django==5.4.0",
"social-auth-core[saml]==4.5.3",
"social-edu-federation==2.1.1",
"whitenoise==6.6.0",
] ]
[project.urls] [project.urls]
@@ -49,20 +35,10 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"django-extensions==3.2.3",
"ipdb==0.13.13",
"ipython==8.22.1",
"pyfakefs==5.3.5",
"pylint-django==2.5.5",
"pylint==3.1.0", "pylint==3.1.0",
"pytest-cov==4.1.0", "pytest-cov==4.1.0",
"pytest-django==4.8.0",
"pytest==8.0.2", "pytest==8.0.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.5.0",
"responses==0.25.0",
"ruff==0.2.2", "ruff==0.2.2",
"types-requests==2.31.0.20240218",
] ]
[tool.setuptools] [tool.setuptools]
@@ -79,7 +55,6 @@ exclude = [
"build", "build",
"venv", "venv",
"__pycache__", "__pycache__",
"*/migrations/*",
] ]
line-length = 88 line-length = 88
@@ -90,7 +65,6 @@ select = [
"B", # flake8-bugbear "B", # flake8-bugbear
"BLE", # flake8-blind-except "BLE", # flake8-blind-except
"C4", # flake8-comprehensions "C4", # flake8-comprehensions
"DJ", # flake8-django
"I", # isort "I", # isort
"PLC", # pylint-convention "PLC", # pylint-convention
"PLE", # pylint-error "PLE", # pylint-error
@@ -104,8 +78,8 @@ select = [
] ]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
section-order = ["future","standard-library","django","third-party","oidc2fer","first-party","local-folder"] section-order = ["future","standard-library","third-party","oidc2fer","first-party","local-folder"]
sections = { oidc2fer=["core"], django=["django"] } sections = { oidc2fer=["core"] }
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"**/tests/*" = ["S", "SLF"] "**/tests/*" = ["S", "SLF"]
+6
View File
@@ -0,0 +1,6 @@
import pytest
def test_dummy():
actual = "hello"
assert actual == "hello"