From 4c39fed29b23fa2fcd3835584348d56245e201ce Mon Sep 17 00:00:00 2001 From: Kaladaran Date: Fri, 17 May 2024 16:14:02 +0200 Subject: [PATCH] add authentication --- .env.template | 5 +++- Dockerfile | 1 + authentication/__init__.py | 0 authentication/admin.py | 3 ++ authentication/apps.py | 6 ++++ authentication/backends.py | 57 ++++++++++++++++++++++++++++++++++++++ authentication/tests.py | 3 ++ authentication/urls.py | 9 ++++++ docker-compose.dev.yml | 3 +- marbas/settings.py | 25 ++++++++++++++++- marbas/urls.py | 2 +- requirements.txt | 1 + 12 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 authentication/__init__.py create mode 100644 authentication/admin.py create mode 100644 authentication/apps.py create mode 100644 authentication/backends.py create mode 100644 authentication/tests.py create mode 100644 authentication/urls.py diff --git a/.env.template b/.env.template index 83628c3..22dfb7b 100644 --- a/.env.template +++ b/.env.template @@ -19,4 +19,7 @@ POSTGRES_DB= DRF_SECRET_KEY= DRF_DEBUG= ALLOWED_HOSTS= -CSRF_TRUSTED_ORIGINS= \ No newline at end of file +CSRF_TRUSTED_ORIGINS= + +OIDC_RP_CLIENT_ID= +OIDC_WELLKNOWN= \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 17620d3..9d2aae6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,7 @@ RUN python -m pip install --no-cache-dir --upgrade -r requirements.txt COPY --chown=appuser:appuser marbas /app/marbas COPY --chown=appuser:appuser sde /app/sde COPY --chown=appuser:appuser api /app/api +COPY --chown=appuser:appuser authentication /app/authentication USER appuser CMD ["uvicorn", "marbas.asgi:application", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/authentication/__init__.py b/authentication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authentication/admin.py b/authentication/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/authentication/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/authentication/apps.py b/authentication/apps.py new file mode 100644 index 0000000..7b90ca1 --- /dev/null +++ b/authentication/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "authentication" diff --git a/authentication/backends.py b/authentication/backends.py new file mode 100644 index 0000000..6f9ca33 --- /dev/null +++ b/authentication/backends.py @@ -0,0 +1,57 @@ +from functools import wraps + +from django.core.cache import cache +from django.http import JsonResponse +from django.contrib.auth import authenticate +from django.contrib.auth.models import User +from django.contrib.auth.backends import ModelBackend + +from rest_framework.exceptions import AuthenticationFailed + +from mozilla_django_oidc.auth import OIDCAuthenticationBackend +from mozilla_django_oidc.contrib.drf import OIDCAuthentication + + +class CustomOIDCBackend(OIDCAuthenticationBackend): + def get_username(self, claims): + if 'preferred_username' in claims and not User.objects.filter(username=claims['preferred_username']).exists(): + print(claims['preferred_username']) + return claims['preferred_username'] + return super().get_username(claims) + + def authenticate(self, request, **kwargs): + """Hack to use the same auth as DRF""" + back = OIDCAuthentication() + try: + u, tok = back.authenticate(request) + except AuthenticationFailed: + u = None + return u + + def get_userinfo(self, access_token, id_token, payload): + userinfo = cache.get(f'userinfo-{access_token}') + if userinfo is None: + print("no cache found for userinfo-{access_token} yet.") + userinfo = super().get_userinfo(access_token, id_token, payload) + if userinfo: + cache.set(f'userinfo-{access_token}', userinfo, timeout=60*60*24) + return userinfo + + def update_user(self, user, claims): # TODO: update groups? + return super().update_user(user, claims) + + def create_user(self, claims): # TODO: add groups? + return super().create_user(claims) + + +def login_required(func): + @wraps(func) + def wrapper(request, *args, **kwargs): + if request.META.get("HTTP_AUTHORIZATION", "").startswith("Bearer"): + if not hasattr(request, "user") or request.user.is_anonymous: + user = authenticate(request=request) + if not user: + return JsonResponse({"error": "Unauthorized"}, status=401) + request.user = request._cached_user = user + return func(request, *args, **kwargs) + return wrapper diff --git a/authentication/tests.py b/authentication/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/authentication/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/authentication/urls.py b/authentication/urls.py new file mode 100644 index 0000000..50c3df5 --- /dev/null +++ b/authentication/urls.py @@ -0,0 +1,9 @@ +from . import views +from django.urls import include, path +from rest_framework import routers + +router = routers.DefaultRouter() + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1fa99ae..5849a62 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,4 +1,3 @@ -version: '3' services: migrations: image: marbas:local @@ -12,6 +11,7 @@ services: - ./marbas:/app/marbas - ./api:/app/api - ./sde:/app/sde + - ./authentication:/app/authentication - ./manage.py:/app/manage.py command: sh -c "python manage.py makemigrations && python manage.py migrate" depends_on: @@ -29,6 +29,7 @@ services: - ./marbas:/app/marbas - ./api:/app/api - ./sde:/app/sde + - ./authentication:/app/authentication - ./manage.py:/app/manage.py - ./static_eve:/app/static_eve command: ["uvicorn", "marbas.asgi:application", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/marbas/settings.py b/marbas/settings.py index 950e1bc..f4a6aba 100644 --- a/marbas/settings.py +++ b/marbas/settings.py @@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/4.2/ref/settings/ from pathlib import Path import os +import requests # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -42,21 +43,27 @@ REST_FRAMEWORK = { 'rest_framework.parsers.JSONParser', ], 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'mozilla_django_oidc.contrib.drf.OIDCAuthentication', + # 'rest_framework.authentication.SessionAuthentication', + ], } INSTALLED_APPS = [ + 'authentication', 'api', 'sde', 'esi', 'django.contrib.admin', 'django.contrib.auth', + 'mozilla_django_oidc', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'django_filters', 'health_check', - 'rest_framework' + 'rest_framework', ] MIDDLEWARE = [ @@ -133,6 +140,12 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] +AUTHENTICATION_BACKENDS = [ + 'authentication.backends.CustomOIDCBackend', + 'authentication.backends.EveAuthBackend', + 'django.contrib.auth.backends.ModelBackend', +] + # Internationalization # https://docs.djangoproject.com/en/4.2/topics/i18n/ @@ -161,3 +174,13 @@ ESI_SSO_CLIENT_SECRET = os.getenv("ESI_SECRET_KEY") ESI_SSO_CALLBACK_URL = os.getenv("ESI_CALLBACK_URL") ESI_USER_AGENT = os.getenv("ESI_USER_AGENT") ESI_USER_CONTACT_EMAIL = os.getenv("ESI_USER_AGENT") +ESI_SCOPES = ['publicData', 'esi-calendar.respond_calendar_events.v1', 'esi-calendar.read_calendar_events.v1', 'esi-location.read_location.v1', 'esi-location.read_ship_type.v1', 'esi-mail.organize_mail.v1', 'esi-mail.read_mail.v1', 'esi-mail.send_mail.v1', 'esi-skills.read_skills.v1', 'esi-skills.read_skillqueue.v1', 'esi-wallet.read_character_wallet.v1', 'esi-wallet.read_corporation_wallet.v1', 'esi-search.search_structures.v1', 'esi-clones.read_clones.v1', 'esi-characters.read_contacts.v1', 'esi-universe.read_structures.v1', 'esi-bookmarks.read_character_bookmarks.v1', 'esi-killmails.read_killmails.v1', 'esi-corporations.read_corporation_membership.v1', 'esi-assets.read_assets.v1', 'esi-planets.manage_planets.v1', 'esi-fleets.read_fleet.v1', 'esi-fleets.write_fleet.v1', 'esi-ui.open_window.v1', 'esi-ui.write_waypoint.v1', 'esi-characters.write_contacts.v1', 'esi-fittings.read_fittings.v1', 'esi-fittings.write_fittings.v1', 'esi-markets.structure_markets.v1', 'esi-corporations.read_structures.v1', 'esi-characters.read_loyalty.v1', 'esi-characters.read_opportunities.v1', 'esi-characters.read_chat_channels.v1', 'esi-characters.read_medals.v1', 'esi-characters.read_standings.v1', 'esi-characters.read_agents_research.v1', 'esi-industry.read_character_jobs.v1', 'esi-markets.read_character_orders.v1', 'esi-characters.read_blueprints.v1', 'esi-characters.read_corporation_roles.v1', 'esi-location.read_online.v1', 'esi-contracts.read_character_contracts.v1', 'esi-clones.read_implants.v1', 'esi-characters.read_fatigue.v1', 'esi-killmails.read_corporation_killmails.v1', 'esi-corporations.track_members.v1', 'esi-wallet.read_corporation_wallets.v1', 'esi-characters.read_notifications.v1', 'esi-corporations.read_divisions.v1', 'esi-corporations.read_contacts.v1', 'esi-assets.read_corporation_assets.v1', 'esi-corporations.read_titles.v1', 'esi-corporations.read_blueprints.v1', 'esi-bookmarks.read_corporation_bookmarks.v1', 'esi-contracts.read_corporation_contracts.v1', 'esi-corporations.read_standings.v1', 'esi-corporations.read_starbases.v1', 'esi-industry.read_corporation_jobs.v1', 'esi-markets.read_corporation_orders.v1', 'esi-corporations.read_container_logs.v1', 'esi-industry.read_character_mining.v1', 'esi-industry.read_corporation_mining.v1', 'esi-planets.read_customs_offices.v1', 'esi-corporations.read_facilities.v1', 'esi-corporations.read_medals.v1', 'esi-characters.read_titles.v1', 'esi-alliances.read_contacts.v1', 'esi-characters.read_fw_stats.v1', 'esi-corporations.read_fw_stats.v1', 'esi-characterstats.read.v1'] + + +OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID") +OIDC_RP_CLIENT_SECRET = "" +if WN := os.getenv("OIDC_WELLKNOWN"): + oauth_conf = requests.get(WN).json() + OIDC_OP_AUTHORIZATION_ENDPOINT = oauth_conf["authorization_endpoint"] + OIDC_OP_TOKEN_ENDPOINT = oauth_conf["token_endpoint"] + OIDC_OP_USER_ENDPOINT = oauth_conf["userinfo_endpoint"] diff --git a/marbas/urls.py b/marbas/urls.py index 0b13171..5214780 100644 --- a/marbas/urls.py +++ b/marbas/urls.py @@ -22,7 +22,7 @@ from django.views.generic import TemplateView urlpatterns = [ path('api/', include("api.urls")), path('sde/', include("sde.urls")), - path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), + path('auth/', include("authentication.urls")), path('sso/', include('esi.urls', namespace='esi')), path('openapi/', get_schema_view( title="marbas", diff --git a/requirements.txt b/requirements.txt index f0094e5..f846d7b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ uritemplate inflection django-esi django-health-check +mozilla-django-oidc \ No newline at end of file