From 0081474cc7231565c3a408fd7b4fc964bf4c509e Mon Sep 17 00:00:00 2001 From: sadnub Date: Sat, 14 Sep 2024 23:32:06 -0400 Subject: [PATCH] sso init --- .devcontainer/entrypoint.sh | 18 +++- api/tacticalrmm/accounts/urls.py | 4 + api/tacticalrmm/accounts/views.py | 101 ++++++++++++++++++++++- api/tacticalrmm/requirements.txt | 3 +- api/tacticalrmm/tacticalrmm/settings.py | 23 ++++++ api/tacticalrmm/tacticalrmm/urls.py | 4 + docker/containers/tactical/entrypoint.sh | 15 +++- 7 files changed, 158 insertions(+), 10 deletions(-) diff --git a/.devcontainer/entrypoint.sh b/.devcontainer/entrypoint.sh index e24a674c89..39b71868cd 100644 --- a/.devcontainer/entrypoint.sh +++ b/.devcontainer/entrypoint.sh @@ -50,6 +50,8 @@ function django_setup { DJANGO_SEKRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 80 | head -n 1) + BASE_DOMAIN=$(echo "$APP_HOST" | awk -F. '{print $(NF-1)"."$NF}') + localvars="$(cat << EOF SECRET_KEY = '${DJANGO_SEKRET}' @@ -64,12 +66,20 @@ KEY_FILE = '${CERT_PRIV_PATH}' SCRIPTS_DIR = '/community-scripts' -ALLOWED_HOSTS = ['${API_HOST}', '*'] - ADMIN_URL = 'admin/' -CORS_ORIGIN_ALLOW_ALL = True -CORS_ORIGIN_WHITELIST = ['https://${API_HOST}'] +ALLOWED_HOSTS = ['${API_HOST}', 'https://${APP_HOST}', '*'] + +CORS_ORIGIN_WHITELIST = ['https://${API_HOST}', 'https://${APP_HOST}'] +CORS_ALLOW_CREDENTIALS = True + +SESSION_COOKIE_DOMAIN = '${BASE_DOMAIN}' +CSRF_COOKIE_DOMAIN = '${BASE_DOMAIN}' +CSRF_TRUSTED_ORIGINS = ['https://${API_HOST}', 'https://${APP_HOST}'] + +HEADLESS_FRONTEND_URLS = { + 'socialaccount_login_error': 'https://${APP_HOST}/account/provider/callback' +} DATABASES = { 'default': { diff --git a/api/tacticalrmm/accounts/urls.py b/api/tacticalrmm/accounts/urls.py index 5aeb2178e9..4d3857a298 100644 --- a/api/tacticalrmm/accounts/urls.py +++ b/api/tacticalrmm/accounts/urls.py @@ -1,8 +1,10 @@ from django.urls import path +from django.urls import include from . import views urlpatterns = [ + path("", include("allauth.urls")), path("users/", views.GetAddUsers.as_view()), path("/users/", views.GetUpdateDeleteUser.as_view()), path("users/reset/", views.UserActions.as_view()), @@ -15,4 +17,6 @@ path("apikeys//", views.GetUpdateDeleteAPIKey.as_view()), path("resetpw/", views.ResetPass.as_view()), path("reset2fa/", views.Reset2FA.as_view()), + path("ssoproviders/", views.GetAddSSOProvider.as_view()), + path("ssoproviders//", views.GetUpdateDeleteSSOProvider.as_view()), ] diff --git a/api/tacticalrmm/accounts/views.py b/api/tacticalrmm/accounts/views.py index e329323b27..2472afda61 100644 --- a/api/tacticalrmm/accounts/views.py +++ b/api/tacticalrmm/accounts/views.py @@ -1,6 +1,7 @@ import datetime - +import re import pyotp +from allauth.socialaccount.models import SocialApp from django.conf import settings from django.contrib.auth import login from django.db import IntegrityError @@ -8,6 +9,7 @@ from knox.views import LoginView as KnoxLoginView from python_ipware import IpWare from rest_framework.authtoken.serializers import AuthTokenSerializer +from rest_framework.serializers import ModelSerializer, ReadOnlyField from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -398,3 +400,100 @@ def put(self, request): user.totp_key = "" user.save() return Response("2FA was reset. Log out and back in to setup.") + + +# sso views +class SocialAppSerializer(ModelSerializer): + server_url = ReadOnlyField(source="settings.server_url") + class Meta: + model = SocialApp + fields = [ + "id", + "name", + "provider", + "provider_id", + "client_id", + "secret", + "server_url", + "settings", + ] + + +class GetAddSSOProvider(APIView): + permission_classes = [IsAuthenticated, AccountsPerms] + + def get(self, request): + providers = SocialApp.objects.all() + return Response(SocialAppSerializer(providers, many=True).data) + + class InputSerializer(ModelSerializer): + server_url = ReadOnlyField() + class Meta: + model = SocialApp + fields = [ + "name", + "client_id", + "secret", + "server_url", + "provider", + "provider_id", + "settings" + ] + + # removed any special characters and replaces spaces with a hyphen + def generate_provider_id(self, string): + id = re.sub(r'[^A-Za-z0-9\s]', '', string) + id = id.replace(' ', '-') + return id + + def post(self, request): + data = request.data + + # need to move server_url into json settings + data["settings"] = {} + data["settings"]["server_url"] = data["server_url"] + + # set provider to 'openid_connect' + data["provider"] = "openid_connect" + + # generate a url friendly provider id from the name + data["provider_id"] = self.generate_provider_id(data["name"]) + + serializer = self.InputSerializer(data=data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response("ok") + + +class GetUpdateDeleteSSOProvider(APIView): + permission_classes = [IsAuthenticated, APIKeyPerms] + + class InputSerialzer(ModelSerializer): + server_url = ReadOnlyField() + class Meta: + model = SocialApp + fields = [ + "client_id", + "secret", + "server_url", + "settings" + ] + + def put(self, request, pk): + provider = get_object_or_404(SocialApp, pk=pk) + data = request.data + + # need to move server_url into json settings + data["settings"] = {} + data["settings"]["server_url"] = data["server_url"] + + serializer = self.InputSerialzer(instance=provider, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response("ok") + + def delete(self, request, pk): + provider = get_object_or_404(SocialApp, pk=pk) + provider.delete() + return Response("ok") + diff --git a/api/tacticalrmm/requirements.txt b/api/tacticalrmm/requirements.txt index ee4d3c20f4..97e46d92fc 100644 --- a/api/tacticalrmm/requirements.txt +++ b/api/tacticalrmm/requirements.txt @@ -7,6 +7,7 @@ channels==4.1.0 channels_redis==4.2.0 cryptography==42.0.8 Django==4.2.16 +django-allauth[socialaccount]==64.2.1 django-cors-headers==4.4.0 django-filter==24.3 django-rest-knox==4.2.0 @@ -44,4 +45,4 @@ jinja2==3.1.4 markdown==3.7 plotly==5.24.0 weasyprint==62.3 -ocxsect==0.1.5 \ No newline at end of file +ocxsect==0.1.5 diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index 7f2f62a07c..8e11cff529 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -135,6 +135,7 @@ "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_AUTHENTICATION_CLASSES": ( "knox.auth.TokenAuthentication", + "allauth.account.auth_backends.AuthenticationBackend", "tacticalrmm.auth.APIAuthentication", ), "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", @@ -163,6 +164,10 @@ "rest_framework.authtoken", "knox", "corsheaders", + "allauth.account", + "allauth.headless", + "allauth.socialaccount", + "allauth.socialaccount.providers.openid_connect", "accounts", "apiv3", "clients", @@ -189,6 +194,23 @@ }, } +# settings for django all auth +HEADLESS_ONLY = True +ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" +ACCOUNT_EMAIL_VERIFICATION = 'none' +SOCIALACCOUNT_ONLY = True +SOCIALACCOUNT_EMAIL_AUTHENTICATION = True +SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True +SOCIALACCOUNT_EMAIL_VERIFICATION = True + +SOCIALACCOUNT_PROVIDERS = { + "openid_connect": { + "OAUTH_PKCE_ENABLED": True + } +} + +SESSION_COOKIE_SECURE = True + # silence cache key length warnings import warnings # noqa @@ -216,6 +238,7 @@ "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "tacticalrmm.middleware.AuditMiddleware", + "allauth.account.middleware.AccountMiddleware", ] if SWAGGER_ENABLED: diff --git a/api/tacticalrmm/tacticalrmm/urls.py b/api/tacticalrmm/tacticalrmm/urls.py index 7fceedf24f..bb13822462 100644 --- a/api/tacticalrmm/tacticalrmm/urls.py +++ b/api/tacticalrmm/tacticalrmm/urls.py @@ -23,6 +23,10 @@ def to_url(self, value): urlpatterns = [ path("", home), + + # all auth urls + path("_allauth/", include("allauth.headless.urls")), + path("v2/checkcreds/", CheckCredsV2.as_view()), path("v2/login/", LoginViewV2.as_view()), path("checkcreds/", CheckCreds.as_view()), # DEPRECATED AS OF 0.19.0 diff --git a/docker/containers/tactical/entrypoint.sh b/docker/containers/tactical/entrypoint.sh index 4cc7a8cd09..7f73a46125 100644 --- a/docker/containers/tactical/entrypoint.sh +++ b/docker/containers/tactical/entrypoint.sh @@ -88,13 +88,20 @@ LOG_DIR = '/opt/tactical/api/tacticalrmm/private/log' SCRIPTS_DIR = '/opt/tactical/community-scripts' -ALLOWED_HOSTS = ['${API_HOST}', 'tactical-backend'] +ALLOWED_HOSTS = ['${API_HOST}', '${APP_HOST}', 'tactical-backend'] ADMIN_URL = '${ADMINURL}/' -CORS_ORIGIN_WHITELIST = [ - 'https://${APP_HOST}' -] +CORS_ORIGIN_WHITELIST = ['https://${API_HOST}', 'https://${APP_HOST}'] +CORS_ALLOW_CREDENTIALS = True + +SESSION_COOKIE_DOMAIN = '${BASE_DOMAIN}' +CSRF_COOKIE_DOMAIN = '${BASE_DOMAIN}' +CSRF_TRUSTED_ORIGINS = ['https://${API_HOST}', 'https://${APP_HOST}'] + +HEADLESS_FRONTEND_URLS = { + 'socialaccount_login_error': 'https://${APP_HOST}/account/provider/callback' +} DATABASES = { 'default': {