From eda5ea7d1a094dd38e603fa9017efb563846fe39 Mon Sep 17 00:00:00 2001 From: sadnub Date: Sun, 15 Sep 2024 22:22:45 -0400 Subject: [PATCH 01/50] sso init --- .devcontainer/entrypoint.sh | 18 +++- api/tacticalrmm/accounts/views.py | 2 +- api/tacticalrmm/ee/sso/__init__.py | 0 api/tacticalrmm/ee/sso/urls.py | 11 +++ api/tacticalrmm/ee/sso/views.py | 104 +++++++++++++++++++++++ api/tacticalrmm/requirements.txt | 3 +- api/tacticalrmm/tacticalrmm/settings.py | 29 ++++++- api/tacticalrmm/tacticalrmm/urls.py | 5 ++ docker/containers/tactical/entrypoint.sh | 15 +++- 9 files changed, 175 insertions(+), 12 deletions(-) create mode 100644 api/tacticalrmm/ee/sso/__init__.py create mode 100644 api/tacticalrmm/ee/sso/urls.py create mode 100644 api/tacticalrmm/ee/sso/views.py diff --git a/.devcontainer/entrypoint.sh b/.devcontainer/entrypoint.sh index e24a674c89..e4a506b873 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}', '${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/views.py b/api/tacticalrmm/accounts/views.py index e329323b27..bd3c4e509c 100644 --- a/api/tacticalrmm/accounts/views.py +++ b/api/tacticalrmm/accounts/views.py @@ -1,5 +1,4 @@ import datetime - import pyotp from django.conf import settings from django.contrib.auth import login @@ -8,6 +7,7 @@ from knox.views import LoginView as KnoxLoginView from python_ipware import IpWare from rest_framework.authtoken.serializers import AuthTokenSerializer + from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView diff --git a/api/tacticalrmm/ee/sso/__init__.py b/api/tacticalrmm/ee/sso/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tacticalrmm/ee/sso/urls.py b/api/tacticalrmm/ee/sso/urls.py new file mode 100644 index 0000000000..6858fa78d0 --- /dev/null +++ b/api/tacticalrmm/ee/sso/urls.py @@ -0,0 +1,11 @@ + +from django.urls import path +from django.urls import include + +from . import views + +urlpatterns = [ + path("", include("allauth.urls")), + path("ssoproviders/", views.GetAddSSOProvider.as_view()), + path("ssoproviders//", views.GetUpdateDeleteSSOProvider.as_view()), +] \ No newline at end of file diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py new file mode 100644 index 0000000000..5e528d186d --- /dev/null +++ b/api/tacticalrmm/ee/sso/views.py @@ -0,0 +1,104 @@ +import re +from django.shortcuts import get_object_or_404 + +from allauth.socialaccount.models import SocialApp +from rest_framework.serializers import ModelSerializer, ReadOnlyField +from rest_framework.response import Response +from rest_framework.views import APIView +from accounts.permissions import AccountsPerms +from rest_framework.permissions import IsAuthenticated + +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, AccountsPerms] + + 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 4114ae4680..565cb6dc38 100644 --- a/api/tacticalrmm/requirements.txt +++ b/api/tacticalrmm/requirements.txt @@ -7,6 +7,7 @@ channels_redis==4.2.0 cryptography==43.0.3 Django==4.2.16 django-cors-headers==4.5.0 +django-allauth[socialaccount]==64.2.1 django-filter==24.3 django-rest-knox==4.2.0 djangorestframework==3.15.2 @@ -43,4 +44,4 @@ jinja2==3.1.4 markdown==3.7 plotly==5.24.1 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 91e318b1e9..399327a511 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", @@ -158,12 +159,18 @@ "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.staticfiles", + "django.contrib.messages", "channels", "rest_framework", "rest_framework.authtoken", "knox", "corsheaders", "accounts", + "allauth", + "allauth.account", + "allauth.socialaccount", + "allauth.socialaccount.providers.openid_connect", + "allauth.headless", "apiv3", "clients", "agents", @@ -178,6 +185,7 @@ "scripts", "alerts", "ee.reporting", + "ee.sso" ] CHANNEL_LAYERS = { @@ -189,6 +197,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 @@ -215,7 +240,9 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", "tacticalrmm.middleware.AuditMiddleware", + "allauth.account.middleware.AccountMiddleware", ] if SWAGGER_ENABLED: @@ -231,10 +258,8 @@ MIDDLEWARE.insert(0, "silk.middleware.SilkyMiddleware") if ADMIN_ENABLED: - MIDDLEWARE += ("django.contrib.messages.middleware.MessageMiddleware",) INSTALLED_APPS += ( "django.contrib.admin", - "django.contrib.messages", ) if DEMO: diff --git a/api/tacticalrmm/tacticalrmm/urls.py b/api/tacticalrmm/tacticalrmm/urls.py index 7fceedf24f..3f3bbd02e1 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 @@ -43,6 +47,7 @@ def to_url(self, value): path("scripts/", include("scripts.urls")), path("alerts/", include("alerts.urls")), path("accounts/", include("accounts.urls")), + path("accounts/", include("ee.sso.urls")), path("reporting/", include("ee.reporting.urls")), ] 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': { From c8dd80530a6075cc3d7b39e5bb92d9f075b1e114 Mon Sep 17 00:00:00 2001 From: sadnub Date: Tue, 17 Sep 2024 23:23:35 -0400 Subject: [PATCH 02/50] fix session auth and restrict it only to access_token view --- api/tacticalrmm/ee/sso/urls.py | 1 + api/tacticalrmm/ee/sso/views.py | 16 ++++++++++++++++ api/tacticalrmm/tacticalrmm/middleware.py | 2 +- api/tacticalrmm/tacticalrmm/settings.py | 4 ++-- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/api/tacticalrmm/ee/sso/urls.py b/api/tacticalrmm/ee/sso/urls.py index 6858fa78d0..c0e9c07b9e 100644 --- a/api/tacticalrmm/ee/sso/urls.py +++ b/api/tacticalrmm/ee/sso/urls.py @@ -8,4 +8,5 @@ path("", include("allauth.urls")), path("ssoproviders/", views.GetAddSSOProvider.as_view()), path("ssoproviders//", views.GetUpdateDeleteSSOProvider.as_view()), + path("ssoproviders/token/", views.GetAccessToken.as_view()), ] \ No newline at end of file diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index 5e528d186d..b82dd8c095 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -7,6 +7,9 @@ from rest_framework.views import APIView from accounts.permissions import AccountsPerms from rest_framework.permissions import IsAuthenticated +from rest_framework.authentication import SessionAuthentication +from knox.views import LoginView as KnoxLoginView +from django.contrib.auth import logout class SocialAppSerializer(ModelSerializer): server_url = ReadOnlyField(source="settings.server_url") @@ -102,3 +105,16 @@ def delete(self, request, pk): provider.delete() return Response("ok") + +class GetAccessToken(KnoxLoginView): + permission_classes = [IsAuthenticated] + authentication_classes = [SessionAuthentication] + + def post(self, request, format=None): + response = super().post(request, format=None) + response.data["username"] = request.user.username + + #invalid user session since we have an access token now + logout(request) + return Response(response.data) + diff --git a/api/tacticalrmm/tacticalrmm/middleware.py b/api/tacticalrmm/tacticalrmm/middleware.py index 8f8ca3bfe1..eaecfe7ac1 100644 --- a/api/tacticalrmm/tacticalrmm/middleware.py +++ b/api/tacticalrmm/tacticalrmm/middleware.py @@ -72,7 +72,7 @@ def process_view(self, request, view_func, view_args, view_kwargs): # gather and save debug info debug_info["url"] = request.path debug_info["method"] = request.method - debug_info["view_class"] = view_func.cls.__name__ + debug_info["view_class"] = view_func.cls.__name__ if hasattr(view_func, "cls") else None debug_info["view_func"] = view_Name debug_info["view_args"] = view_args debug_info["view_kwargs"] = view_kwargs diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index 399327a511..4b9f4c3f36 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -135,7 +135,6 @@ "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", @@ -199,9 +198,9 @@ # settings for django all auth HEADLESS_ONLY = True +SOCIALACCOUNT_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 @@ -212,6 +211,7 @@ } } +AUTHENTICATION_BACKENDS = ("allauth.account.auth_backends.AuthenticationBackend",) SESSION_COOKIE_SECURE = True # silence cache key length warnings From 4ba27ec1d6a34b7c62a08808b57afed144025409 Mon Sep 17 00:00:00 2001 From: sadnub Date: Wed, 18 Sep 2024 11:05:50 -0400 Subject: [PATCH 03/50] add auditing and session key checking to the sso auth token view --- api/tacticalrmm/ee/sso/views.py | 31 ++++++++++++++++++++++--------- api/tacticalrmm/logs/models.py | 24 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index b82dd8c095..264e55edd5 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -4,13 +4,14 @@ from allauth.socialaccount.models import SocialApp from rest_framework.serializers import ModelSerializer, ReadOnlyField from rest_framework.response import Response +from rest_framework import status from rest_framework.views import APIView from accounts.permissions import AccountsPerms from rest_framework.permissions import IsAuthenticated from rest_framework.authentication import SessionAuthentication from knox.views import LoginView as KnoxLoginView from django.contrib.auth import logout - +from logs.models import AuditLog class SocialAppSerializer(ModelSerializer): server_url = ReadOnlyField(source="settings.server_url") class Meta: @@ -107,14 +108,26 @@ def delete(self, request, pk): class GetAccessToken(KnoxLoginView): - permission_classes = [IsAuthenticated] - authentication_classes = [SessionAuthentication] + permission_classes = [IsAuthenticated] + authentication_classes = [SessionAuthentication] + + def post(self, request, format=None): + # check for auth method before signing in + if "account_authentication_methods" in request.session and len(request.session["account_authentication_methods"]) > 0: + login_method = request.session["account_authentication_methods"][0] - def post(self, request, format=None): - response = super().post(request, format=None) - response.data["username"] = request.user.username + # get token + response = super().post(request, format=None) + response.data["username"] = request.user.username - #invalid user session since we have an access token now - logout(request) - return Response(response.data) + AuditLog.audit_user_login_successful_sso(request.user.username, login_method["provider"], login_method) + + #invalid user session since we have an access token now + logout(request) + + return Response(response.data) + else: + AuditLog.audit_user_login_failed_sso(request.user.username) + logout(request) + return Response("The credentials supplied were invalid", status.HTTP_403_FORBIDDEN) diff --git a/api/tacticalrmm/logs/models.py b/api/tacticalrmm/logs/models.py index 14ecc6bb4b..2a69315f6f 100644 --- a/api/tacticalrmm/logs/models.py +++ b/api/tacticalrmm/logs/models.py @@ -213,6 +213,30 @@ def audit_user_login_successful( debug_info=debug_info, ) + @staticmethod + def audit_user_login_successful_sso( + username: str, provider: str, debug_info: Dict[Any, Any] = {} + ) -> None: + AuditLog.objects.create( + username=username, + object_type=AuditObjType.USER, + action=AuditActionType.LOGIN, + message=f"{username} logged in successfully through SSO Provider {provider}", + debug_info=debug_info, + ) + + @staticmethod + def audit_user_login_failed_sso( + username: str, debug_info: Dict[Any, Any] = {} + ) -> None: + AuditLog.objects.create( + username=username, + object_type=AuditObjType.USER, + action=AuditActionType.LOGIN, + message=f"{username} failed to login through unknown sso provider", + debug_info=debug_info, + ) + @staticmethod def audit_url_action( username: str, From 9c15f4ba88e77e550fa7a7ffa42c83f01af2caac Mon Sep 17 00:00:00 2001 From: sadnub Date: Sat, 28 Sep 2024 15:34:08 -0400 Subject: [PATCH 04/50] implemented user session tracking, social account tracking, and blocking local user logon --- api/tacticalrmm/accounts/urls.py | 2 + api/tacticalrmm/accounts/views.py | 107 +++++++++++++++++- ...048_coresettings_block_local_user_logon.py | 18 +++ api/tacticalrmm/core/models.py | 2 + api/tacticalrmm/ee/sso/__init__.py | 5 + api/tacticalrmm/ee/sso/urls.py | 9 +- api/tacticalrmm/ee/sso/views.py | 30 +++++ api/tacticalrmm/tacticalrmm/middleware.py | 3 + 8 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 api/tacticalrmm/core/migrations/0048_coresettings_block_local_user_logon.py diff --git a/api/tacticalrmm/accounts/urls.py b/api/tacticalrmm/accounts/urls.py index 5aeb2178e9..69e439453d 100644 --- a/api/tacticalrmm/accounts/urls.py +++ b/api/tacticalrmm/accounts/urls.py @@ -5,6 +5,8 @@ urlpatterns = [ path("users/", views.GetAddUsers.as_view()), path("/users/", views.GetUpdateDeleteUser.as_view()), + path("sessions//", views.DeleteActiveLoginSession.as_view()), + path("users//sessions/", views.GetDeleteActiveLoginSessionsPerUser.as_view()), path("users/reset/", views.UserActions.as_view()), path("users/reset_totp/", views.UserActions.as_view()), path("users/setup_totp/", views.TOTPSetup.as_view()), diff --git a/api/tacticalrmm/accounts/views.py b/api/tacticalrmm/accounts/views.py index bd3c4e509c..e18a7add57 100644 --- a/api/tacticalrmm/accounts/views.py +++ b/api/tacticalrmm/accounts/views.py @@ -1,21 +1,27 @@ import datetime import pyotp -from django.conf import settings from django.contrib.auth import login from django.db import IntegrityError from django.shortcuts import get_object_or_404 +from django.utils import timezone as djangotime from knox.views import LoginView as KnoxLoginView +from knox.models import AuthToken from python_ipware import IpWare from rest_framework.authtoken.serializers import AuthTokenSerializer - from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from rest_framework.serializers import ( + ModelSerializer, + SerializerMethodField, + ReadOnlyField +) from accounts.utils import is_root_user from core.tasks import sync_mesh_perms_task from logs.models import AuditLog from tacticalrmm.helpers import notify_error +from tacticalrmm.utils import get_core_settings from .models import APIKey, Role, User from .permissions import AccountsPerms, APIKeyPerms, RolesPerms @@ -48,6 +54,11 @@ def post(self, request, format=None): if user.block_dashboard_login: return notify_error("Bad credentials") + + # block local logon if configured + settings = get_core_settings() + if not user.is_superuser and settings.block_local_user_logon: + return notify_error("Bad credentials") # if totp token not set modify response to notify frontend if not user.totp_key: @@ -72,6 +83,11 @@ def post(self, request, format=None): if user.block_dashboard_login: return notify_error("Bad credentials") + # block local logon if configured + settings = get_core_settings() + if not user.is_superuser and settings.block_local_user_logon: + return notify_error("Bad credentials") + token = request.data["twofactor"] totp = pyotp.TOTP(user.totp_key) @@ -124,6 +140,11 @@ def post(self, request, format=None): if user.block_dashboard_login: return notify_error("Bad credentials") + + # block local logon if configured + settings = get_core_settings() + if not user.is_superuser and settings.block_local_user_logon: + return notify_error("Bad credentials") # if totp token not set modify response to notify frontend if not user.totp_key: @@ -150,6 +171,11 @@ def post(self, request, format=None): if user.block_dashboard_login: return notify_error("Bad credentials") + + # block local logon if configured + settings = get_core_settings() + if not user.is_superuser and settings.block_local_user_logon: + return notify_error("Bad credentials") token = request.data["twofactor"] totp = pyotp.TOTP(user.totp_key) @@ -182,9 +208,84 @@ def post(self, request, format=None): return notify_error("Bad credentials") +class GetDeleteActiveLoginSessionsPerUser(APIView): + permission_classes = [IsAuthenticated, AccountsPerms] + + class TokenSerializer(ModelSerializer): + user = ReadOnlyField(source="user.username") + class Meta: + model = AuthToken + fields = ( + "digest", + "user", + "created", + "expiry", + ) + + + def get(self, request, pk): + tokens = get_object_or_404(User, pk=pk).auth_token_set.filter(expiry__gt=djangotime.now()) + + return Response(self.TokenSerializer(tokens, many=True).data) + + + def delete(self, request, pk): + tokens = get_object_or_404(User, pk=pk).auth_token_set.filter(expiry__gt=djangotime.now()) + + tokens.delete() + return Response("ok") + + +class DeleteActiveLoginSession(APIView): + permission_classes = [IsAuthenticated, AccountsPerms] + + def delete(self, request, pk): + token = get_object_or_404(AuthToken, digest=pk) + + token.delete() + + return Response("ok") + class GetAddUsers(APIView): permission_classes = [IsAuthenticated, AccountsPerms] + class UserSerializerSSO(ModelSerializer): + social_accounts = SerializerMethodField() + + def get_social_accounts(self, obj): + from allauth.socialaccount.models import SocialAccount + + accounts = SocialAccount.objects.filter(user_id=obj.pk) + + return [ + { + "uid": account.uid, + "provider": account.provider, + "display": account.get_provider_account().to_str(), + "last_login": account.last_login, + "date_joined": account.date_joined, + "extra_data": account.extra_data + } + for account in accounts + ] + + class Meta: + model = User + fields = [ + "id", + "username", + "first_name", + "last_name", + "email", + "is_active", + "last_login", + "last_login_ip", + "role", + "block_dashboard_login", + "date_format", + "social_accounts" + ] + def get(self, request): search = request.GET.get("search", None) @@ -195,7 +296,7 @@ def get(self, request): else: users = User.objects.filter(agent=None, is_installer_user=False) - return Response(UserSerializer(users, many=True).data) + return Response(self.UserSerializerSSO(users, many=True).data) def post(self, request): # add new user diff --git a/api/tacticalrmm/core/migrations/0048_coresettings_block_local_user_logon.py b/api/tacticalrmm/core/migrations/0048_coresettings_block_local_user_logon.py new file mode 100644 index 0000000000..dd2eaf4f23 --- /dev/null +++ b/api/tacticalrmm/core/migrations/0048_coresettings_block_local_user_logon.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.14 on 2024-09-22 04:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0047_alter_coresettings_notify_on_warning_alerts'), + ] + + operations = [ + migrations.AddField( + model_name='coresettings', + name='block_local_user_logon', + field=models.BooleanField(default=True), + ), + ] diff --git a/api/tacticalrmm/core/models.py b/api/tacticalrmm/core/models.py index 54d22d7846..38eb958405 100644 --- a/api/tacticalrmm/core/models.py +++ b/api/tacticalrmm/core/models.py @@ -111,6 +111,8 @@ class CoreSettings(BaseAuditModel): notify_on_info_alerts = models.BooleanField(default=False) notify_on_warning_alerts = models.BooleanField(default=True) + block_local_user_logon = models.BooleanField(default=True) + def save(self, *args, **kwargs) -> None: from alerts.tasks import cache_agents_alert_template diff --git a/api/tacticalrmm/ee/sso/__init__.py b/api/tacticalrmm/ee/sso/__init__.py index e69de29bb2..38bd5902d7 100644 --- a/api/tacticalrmm/ee/sso/__init__.py +++ b/api/tacticalrmm/ee/sso/__init__.py @@ -0,0 +1,5 @@ +""" +Copyright (c) 2023-present Amidaware Inc. +This file is subject to the EE License Agreement. +For details, see: https://license.tacticalrmm.com/ee +""" diff --git a/api/tacticalrmm/ee/sso/urls.py b/api/tacticalrmm/ee/sso/urls.py index c0e9c07b9e..df23729a3d 100644 --- a/api/tacticalrmm/ee/sso/urls.py +++ b/api/tacticalrmm/ee/sso/urls.py @@ -1,4 +1,10 @@ - +""" +Copyright (c) 2023-present Amidaware Inc. +This file is subject to the EE License Agreement. +For details, see: https://license.tacticalrmm.com/ee +""" + + from django.urls import path from django.urls import include @@ -9,4 +15,5 @@ path("ssoproviders/", views.GetAddSSOProvider.as_view()), path("ssoproviders//", views.GetUpdateDeleteSSOProvider.as_view()), path("ssoproviders/token/", views.GetAccessToken.as_view()), + path("ssoproviders/settings/", views.GetUpdateSSOSettings.as_view()), ] \ No newline at end of file diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index 264e55edd5..7c7f5699ea 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -1,3 +1,10 @@ +""" +Copyright (c) 2023-present Amidaware Inc. +This file is subject to the EE License Agreement. +For details, see: https://license.tacticalrmm.com/ee +""" + + import re from django.shortcuts import get_object_or_404 @@ -12,6 +19,8 @@ from knox.views import LoginView as KnoxLoginView from django.contrib.auth import logout from logs.models import AuditLog +from tacticalrmm.utils import get_core_settings + class SocialAppSerializer(ModelSerializer): server_url = ReadOnlyField(source="settings.server_url") class Meta: @@ -119,6 +128,7 @@ def post(self, request, format=None): # get token response = super().post(request, format=None) response.data["username"] = request.user.username + response.data["provider"] = login_method["provider"] AuditLog.audit_user_login_successful_sso(request.user.username, login_method["provider"], login_method) @@ -131,3 +141,23 @@ def post(self, request, format=None): logout(request) return Response("The credentials supplied were invalid", status.HTTP_403_FORBIDDEN) + +class GetUpdateSSOSettings(APIView): + permission_classes = [IsAuthenticated, AccountsPerms] + + def get(self, request): + + settings = get_core_settings() + + return Response({"block_local_user_logon": settings.block_local_user_logon}) + + def post(self, request): + + data = request.data + + settings = get_core_settings() + + settings.block_local_user_logon = data["block_local_user_logon"] + settings.save(update_fields=["block_local_user_logon"]) + + return Response("ok") \ No newline at end of file diff --git a/api/tacticalrmm/tacticalrmm/middleware.py b/api/tacticalrmm/tacticalrmm/middleware.py index eaecfe7ac1..35e073a3c8 100644 --- a/api/tacticalrmm/tacticalrmm/middleware.py +++ b/api/tacticalrmm/tacticalrmm/middleware.py @@ -27,6 +27,9 @@ def get_debug_info() -> Dict[str, Any]: "/logout", "/agents/installer", "/api/schema", + "/accounts/ssoproviders/token", + "/_allauth/browser/v1/config", + "/_allauth/browser/v1/auth/provider/redirect" ) DEMO_EXCLUDE_PATHS = ( From 0deb78a9af531b05eaf22d74f7ebacd1d7e65d88 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Mon, 30 Sep 2024 19:37:38 +0000 Subject: [PATCH 05/50] fix settings --- api/tacticalrmm/accounts/views.py | 47 +++++++++++++++++-------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/api/tacticalrmm/accounts/views.py b/api/tacticalrmm/accounts/views.py index e18a7add57..7dd94de467 100644 --- a/api/tacticalrmm/accounts/views.py +++ b/api/tacticalrmm/accounts/views.py @@ -1,5 +1,6 @@ import datetime import pyotp +from django.conf import settings from django.contrib.auth import login from django.db import IntegrityError from django.shortcuts import get_object_or_404 @@ -14,7 +15,7 @@ from rest_framework.serializers import ( ModelSerializer, SerializerMethodField, - ReadOnlyField + ReadOnlyField, ) from accounts.utils import is_root_user @@ -54,10 +55,10 @@ def post(self, request, format=None): if user.block_dashboard_login: return notify_error("Bad credentials") - + # block local logon if configured - settings = get_core_settings() - if not user.is_superuser and settings.block_local_user_logon: + core_settings = get_core_settings() + if not user.is_superuser and core_settings.block_local_user_logon: return notify_error("Bad credentials") # if totp token not set modify response to notify frontend @@ -84,10 +85,10 @@ def post(self, request, format=None): return notify_error("Bad credentials") # block local logon if configured - settings = get_core_settings() - if not user.is_superuser and settings.block_local_user_logon: + core_settings = get_core_settings() + if not user.is_superuser and core_settings.block_local_user_logon: return notify_error("Bad credentials") - + token = request.data["twofactor"] totp = pyotp.TOTP(user.totp_key) @@ -140,10 +141,10 @@ def post(self, request, format=None): if user.block_dashboard_login: return notify_error("Bad credentials") - + # block local logon if configured - settings = get_core_settings() - if not user.is_superuser and settings.block_local_user_logon: + core_settings = get_core_settings() + if not user.is_superuser and core_settings.block_local_user_logon: return notify_error("Bad credentials") # if totp token not set modify response to notify frontend @@ -171,10 +172,10 @@ def post(self, request, format=None): if user.block_dashboard_login: return notify_error("Bad credentials") - + # block local logon if configured - settings = get_core_settings() - if not user.is_superuser and settings.block_local_user_logon: + core_settings = get_core_settings() + if not user.is_superuser and core_settings.block_local_user_logon: return notify_error("Bad credentials") token = request.data["twofactor"] @@ -213,6 +214,7 @@ class GetDeleteActiveLoginSessionsPerUser(APIView): class TokenSerializer(ModelSerializer): user = ReadOnlyField(source="user.username") + class Meta: model = AuthToken fields = ( @@ -222,15 +224,17 @@ class Meta: "expiry", ) - def get(self, request, pk): - tokens = get_object_or_404(User, pk=pk).auth_token_set.filter(expiry__gt=djangotime.now()) + tokens = get_object_or_404(User, pk=pk).auth_token_set.filter( + expiry__gt=djangotime.now() + ) return Response(self.TokenSerializer(tokens, many=True).data) - def delete(self, request, pk): - tokens = get_object_or_404(User, pk=pk).auth_token_set.filter(expiry__gt=djangotime.now()) + tokens = get_object_or_404(User, pk=pk).auth_token_set.filter( + expiry__gt=djangotime.now() + ) tokens.delete() return Response("ok") @@ -246,6 +250,7 @@ def delete(self, request, pk): return Response("ok") + class GetAddUsers(APIView): permission_classes = [IsAuthenticated, AccountsPerms] @@ -264,10 +269,10 @@ def get_social_accounts(self, obj): "display": account.get_provider_account().to_str(), "last_login": account.last_login, "date_joined": account.date_joined, - "extra_data": account.extra_data - } + "extra_data": account.extra_data, + } for account in accounts - ] + ] class Meta: model = User @@ -283,7 +288,7 @@ class Meta: "role", "block_dashboard_login", "date_format", - "social_accounts" + "social_accounts", ] def get(self, request): From 6c44191fe466ed0a0d14744410bd4315496c01fb Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Mon, 30 Sep 2024 19:39:03 +0000 Subject: [PATCH 06/50] blacked --- api/tacticalrmm/accounts/urls.py | 4 +- api/tacticalrmm/ee/sso/urls.py | 3 +- api/tacticalrmm/ee/sso/views.py | 49 +++++++++++++---------- api/tacticalrmm/tacticalrmm/middleware.py | 6 ++- api/tacticalrmm/tacticalrmm/settings.py | 14 ++----- api/tacticalrmm/tacticalrmm/urls.py | 2 - 6 files changed, 40 insertions(+), 38 deletions(-) diff --git a/api/tacticalrmm/accounts/urls.py b/api/tacticalrmm/accounts/urls.py index 69e439453d..5a09620de0 100644 --- a/api/tacticalrmm/accounts/urls.py +++ b/api/tacticalrmm/accounts/urls.py @@ -6,7 +6,9 @@ path("users/", views.GetAddUsers.as_view()), path("/users/", views.GetUpdateDeleteUser.as_view()), path("sessions//", views.DeleteActiveLoginSession.as_view()), - path("users//sessions/", views.GetDeleteActiveLoginSessionsPerUser.as_view()), + path( + "users//sessions/", views.GetDeleteActiveLoginSessionsPerUser.as_view() + ), path("users/reset/", views.UserActions.as_view()), path("users/reset_totp/", views.UserActions.as_view()), path("users/setup_totp/", views.TOTPSetup.as_view()), diff --git a/api/tacticalrmm/ee/sso/urls.py b/api/tacticalrmm/ee/sso/urls.py index df23729a3d..c07a376f47 100644 --- a/api/tacticalrmm/ee/sso/urls.py +++ b/api/tacticalrmm/ee/sso/urls.py @@ -4,7 +4,6 @@ For details, see: https://license.tacticalrmm.com/ee """ - from django.urls import path from django.urls import include @@ -16,4 +15,4 @@ path("ssoproviders//", views.GetUpdateDeleteSSOProvider.as_view()), path("ssoproviders/token/", views.GetAccessToken.as_view()), path("ssoproviders/settings/", views.GetUpdateSSOSettings.as_view()), -] \ No newline at end of file +] diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index 7c7f5699ea..8a7c0e8c39 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -4,7 +4,6 @@ For details, see: https://license.tacticalrmm.com/ee """ - import re from django.shortcuts import get_object_or_404 @@ -21,8 +20,10 @@ from logs.models import AuditLog from tacticalrmm.utils import get_core_settings + class SocialAppSerializer(ModelSerializer): server_url = ReadOnlyField(source="settings.server_url") + class Meta: model = SocialApp fields = [ @@ -35,7 +36,7 @@ class Meta: "server_url", "settings", ] - + class GetAddSSOProvider(APIView): permission_classes = [IsAuthenticated, AccountsPerms] @@ -46,6 +47,7 @@ def get(self, request): class InputSerializer(ModelSerializer): server_url = ReadOnlyField() + class Meta: model = SocialApp fields = [ @@ -55,13 +57,13 @@ class Meta: "server_url", "provider", "provider_id", - "settings" + "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(' ', '-') + id = re.sub(r"[^A-Za-z0-9\s]", "", string) + id = id.replace(" ", "-") return id def post(self, request): @@ -88,14 +90,10 @@ class GetUpdateDeleteSSOProvider(APIView): class InputSerialzer(ModelSerializer): server_url = ReadOnlyField() + class Meta: model = SocialApp - fields = [ - "client_id", - "secret", - "server_url", - "settings" - ] + fields = ["client_id", "secret", "server_url", "settings"] def put(self, request, pk): provider = get_object_or_404(SocialApp, pk=pk) @@ -105,7 +103,9 @@ def put(self, request, pk): data["settings"] = {} data["settings"]["server_url"] = data["server_url"] - serializer = self.InputSerialzer(instance=provider, data=request.data, partial=True) + serializer = self.InputSerialzer( + instance=provider, data=request.data, partial=True + ) serializer.is_valid(raise_exception=True) serializer.save() return Response("ok") @@ -122,7 +122,10 @@ class GetAccessToken(KnoxLoginView): def post(self, request, format=None): # check for auth method before signing in - if "account_authentication_methods" in request.session and len(request.session["account_authentication_methods"]) > 0: + if ( + "account_authentication_methods" in request.session + and len(request.session["account_authentication_methods"]) > 0 + ): login_method = request.session["account_authentication_methods"][0] # get token @@ -130,17 +133,21 @@ def post(self, request, format=None): response.data["username"] = request.user.username response.data["provider"] = login_method["provider"] - AuditLog.audit_user_login_successful_sso(request.user.username, login_method["provider"], login_method) + AuditLog.audit_user_login_successful_sso( + request.user.username, login_method["provider"], login_method + ) - #invalid user session since we have an access token now + # invalid user session since we have an access token now logout(request) - + return Response(response.data) else: AuditLog.audit_user_login_failed_sso(request.user.username) logout(request) - return Response("The credentials supplied were invalid", status.HTTP_403_FORBIDDEN) - + return Response( + "The credentials supplied were invalid", status.HTTP_403_FORBIDDEN + ) + class GetUpdateSSOSettings(APIView): permission_classes = [IsAuthenticated, AccountsPerms] @@ -150,14 +157,14 @@ def get(self, request): settings = get_core_settings() return Response({"block_local_user_logon": settings.block_local_user_logon}) - + def post(self, request): data = request.data - + settings = get_core_settings() settings.block_local_user_logon = data["block_local_user_logon"] settings.save(update_fields=["block_local_user_logon"]) - return Response("ok") \ No newline at end of file + return Response("ok") diff --git a/api/tacticalrmm/tacticalrmm/middleware.py b/api/tacticalrmm/tacticalrmm/middleware.py index 35e073a3c8..de24cc1d77 100644 --- a/api/tacticalrmm/tacticalrmm/middleware.py +++ b/api/tacticalrmm/tacticalrmm/middleware.py @@ -29,7 +29,7 @@ def get_debug_info() -> Dict[str, Any]: "/api/schema", "/accounts/ssoproviders/token", "/_allauth/browser/v1/config", - "/_allauth/browser/v1/auth/provider/redirect" + "/_allauth/browser/v1/auth/provider/redirect", ) DEMO_EXCLUDE_PATHS = ( @@ -75,7 +75,9 @@ def process_view(self, request, view_func, view_args, view_kwargs): # gather and save debug info debug_info["url"] = request.path debug_info["method"] = request.method - debug_info["view_class"] = view_func.cls.__name__ if hasattr(view_func, "cls") else None + debug_info["view_class"] = ( + view_func.cls.__name__ if hasattr(view_func, "cls") else None + ) debug_info["view_func"] = view_Name debug_info["view_args"] = view_args debug_info["view_kwargs"] = view_kwargs diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index 4b9f4c3f36..470778ac5e 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -184,7 +184,7 @@ "scripts", "alerts", "ee.reporting", - "ee.sso" + "ee.sso", ] CHANNEL_LAYERS = { @@ -200,16 +200,12 @@ HEADLESS_ONLY = True SOCIALACCOUNT_ONLY = True ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" -ACCOUNT_EMAIL_VERIFICATION = 'none' +ACCOUNT_EMAIL_VERIFICATION = "none" SOCIALACCOUNT_EMAIL_AUTHENTICATION = True SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True SOCIALACCOUNT_EMAIL_VERIFICATION = True -SOCIALACCOUNT_PROVIDERS = { - "openid_connect": { - "OAUTH_PKCE_ENABLED": True - } -} +SOCIALACCOUNT_PROVIDERS = {"openid_connect": {"OAUTH_PKCE_ENABLED": True}} AUTHENTICATION_BACKENDS = ("allauth.account.auth_backends.AuthenticationBackend",) SESSION_COOKIE_SECURE = True @@ -258,9 +254,7 @@ MIDDLEWARE.insert(0, "silk.middleware.SilkyMiddleware") if ADMIN_ENABLED: - INSTALLED_APPS += ( - "django.contrib.admin", - ) + INSTALLED_APPS += ("django.contrib.admin",) if DEMO: MIDDLEWARE += ("tacticalrmm.middleware.DemoMiddleware",) diff --git a/api/tacticalrmm/tacticalrmm/urls.py b/api/tacticalrmm/tacticalrmm/urls.py index 3f3bbd02e1..42e05a4a17 100644 --- a/api/tacticalrmm/tacticalrmm/urls.py +++ b/api/tacticalrmm/tacticalrmm/urls.py @@ -23,10 +23,8 @@ 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 From d894f92d5e21807716aa8718f2943cc8d6acde7f Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Sun, 6 Oct 2024 06:17:15 +0000 Subject: [PATCH 07/50] format --- .devcontainer/entrypoint.sh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.devcontainer/entrypoint.sh b/.devcontainer/entrypoint.sh index e4a506b873..6c600af299 100644 --- a/.devcontainer/entrypoint.sh +++ b/.devcontainer/entrypoint.sh @@ -33,12 +33,12 @@ function check_tactical_ready { } function django_setup { - until (echo > /dev/tcp/"${POSTGRES_HOST}"/"${POSTGRES_PORT}") &> /dev/null; do + until (echo >/dev/tcp/"${POSTGRES_HOST}"/"${POSTGRES_PORT}") &>/dev/null; do echo "waiting for postgresql container to be ready..." sleep 5 done - until (echo > /dev/tcp/"${MESH_SERVICE}"/4443) &> /dev/null; do + until (echo >/dev/tcp/"${MESH_SERVICE}"/4443) &>/dev/null; do echo "waiting for meshcentral container to be ready..." sleep 5 done @@ -49,10 +49,11 @@ function django_setup { MESH_TOKEN="$(cat ${TACTICAL_DIR}/tmp/mesh_token)" 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 + localvars="$( + cat < ${WORKSPACE_DIR}/api/tacticalrmm/tacticalrmm/local_settings.py + echo "${localvars}" >${WORKSPACE_DIR}/api/tacticalrmm/tacticalrmm/local_settings.py # run migrations and init scripts "${VIRTUAL_ENV}"/bin/python manage.py pre_update_tasks @@ -128,9 +129,8 @@ EOF "${VIRTUAL_ENV}"/bin/python manage.py create_natsapi_conf "${VIRTUAL_ENV}"/bin/python manage.py create_installer_user "${VIRTUAL_ENV}"/bin/python manage.py post_update_tasks - - # create super user + # create super user echo "from accounts.models import User; User.objects.create_superuser('${TRMM_USER}', 'admin@example.com', '${TRMM_PASS}') if not User.objects.filter(username='${TRMM_USER}').exists() else 0;" | python manage.py shell } From 1c1d3bd61990cd013720168dac750b1760287541 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Sun, 6 Oct 2024 06:22:25 +0000 Subject: [PATCH 08/50] frontend needs to come first --- .devcontainer/entrypoint.sh | 2 +- docker/containers/tactical/entrypoint.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/entrypoint.sh b/.devcontainer/entrypoint.sh index 6c600af299..d64a562369 100644 --- a/.devcontainer/entrypoint.sh +++ b/.devcontainer/entrypoint.sh @@ -71,7 +71,7 @@ ADMIN_URL = 'admin/' ALLOWED_HOSTS = ['${API_HOST}', '${APP_HOST}', '*'] -CORS_ORIGIN_WHITELIST = ['https://${API_HOST}', 'https://${APP_HOST}'] +CORS_ORIGIN_WHITELIST = ['https://${APP_HOST}', 'https://${API_HOST}'] CORS_ALLOW_CREDENTIALS = True SESSION_COOKIE_DOMAIN = '${BASE_DOMAIN}' diff --git a/docker/containers/tactical/entrypoint.sh b/docker/containers/tactical/entrypoint.sh index 7f73a46125..09927c6d20 100644 --- a/docker/containers/tactical/entrypoint.sh +++ b/docker/containers/tactical/entrypoint.sh @@ -92,7 +92,7 @@ ALLOWED_HOSTS = ['${API_HOST}', '${APP_HOST}', 'tactical-backend'] ADMIN_URL = '${ADMINURL}/' -CORS_ORIGIN_WHITELIST = ['https://${API_HOST}', 'https://${APP_HOST}'] +CORS_ORIGIN_WHITELIST = ['https://${APP_HOST}', 'https://${API_HOST}'] CORS_ALLOW_CREDENTIALS = True SESSION_COOKIE_DOMAIN = '${BASE_DOMAIN}' From 46f0b23f4faca957b5a71fe3749d14a1add2950b Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Sun, 6 Oct 2024 06:25:50 +0000 Subject: [PATCH 09/50] rename to avoid conflict with django settings --- api/tacticalrmm/ee/sso/views.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index 8a7c0e8c39..981f5bb3b1 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -154,17 +154,19 @@ class GetUpdateSSOSettings(APIView): def get(self, request): - settings = get_core_settings() + core_settings = get_core_settings() - return Response({"block_local_user_logon": settings.block_local_user_logon}) + return Response( + {"block_local_user_logon": core_settings.block_local_user_logon} + ) def post(self, request): data = request.data - settings = get_core_settings() + core_settings = get_core_settings() - settings.block_local_user_logon = data["block_local_user_logon"] - settings.save(update_fields=["block_local_user_logon"]) + core_settings.block_local_user_logon = data["block_local_user_logon"] + core_settings.save(update_fields=["block_local_user_logon"]) return Response("ok") From f326096fadf2387fd243db012379519c7b5d07fc Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Sun, 6 Oct 2024 06:26:10 +0000 Subject: [PATCH 10/50] isort --- api/tacticalrmm/ee/sso/views.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index 981f5bb3b1..ea3b4ba45e 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -5,18 +5,19 @@ """ import re -from django.shortcuts import get_object_or_404 from allauth.socialaccount.models import SocialApp -from rest_framework.serializers import ModelSerializer, ReadOnlyField -from rest_framework.response import Response +from django.contrib.auth import logout +from django.shortcuts import get_object_or_404 +from knox.views import LoginView as KnoxLoginView from rest_framework import status +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.serializers import ModelSerializer, ReadOnlyField from rest_framework.views import APIView + from accounts.permissions import AccountsPerms -from rest_framework.permissions import IsAuthenticated -from rest_framework.authentication import SessionAuthentication -from knox.views import LoginView as KnoxLoginView -from django.contrib.auth import logout from logs.models import AuditLog from tacticalrmm.utils import get_core_settings From 9edb848947693926c4c481a184a661e85a22bba7 Mon Sep 17 00:00:00 2001 From: sadnub Date: Tue, 8 Oct 2024 00:25:16 -0400 Subject: [PATCH 11/50] implement default role for sso signups and log ip for sso logins --- api/tacticalrmm/ee/sso/adapter.py | 43 +++++++++++++++++++++++++ api/tacticalrmm/ee/sso/views.py | 19 +++++++++-- api/tacticalrmm/tacticalrmm/settings.py | 1 + 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 api/tacticalrmm/ee/sso/adapter.py diff --git a/api/tacticalrmm/ee/sso/adapter.py b/api/tacticalrmm/ee/sso/adapter.py new file mode 100644 index 0000000000..e8ecf83962 --- /dev/null +++ b/api/tacticalrmm/ee/sso/adapter.py @@ -0,0 +1,43 @@ +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from allauth.socialaccount.models import SocialApp +from allauth.account.utils import user_email, user_field, user_username +from allauth.utils import valid_email_or_none + +from accounts.models import Role +class TacticalSocialAdapter(DefaultSocialAccountAdapter): + + def populate_user(self, request, sociallogin, data): + """ + Hook that can be used to further populate the user instance. + + For convenience, we populate several common fields. + + Note that the user instance being populated represents a + suggested User instance that represents the social user that is + in the process of being logged in. + + The User instance need not be completely valid and conflict + free. For example, verifying whether or not the username + already exists, is not a responsibility. + """ + username = data.get("username") + first_name = data.get("first_name") + last_name = data.get("last_name") + email = data.get("email") + name = data.get("name") + user = sociallogin.user + user_username(user, username or "") + user_email(user, valid_email_or_none(email) or "") + name_parts = (name or "").partition(" ") + user_field(user, "first_name", first_name or name_parts[0]) + user_field(user, "last_name", last_name or name_parts[2]) + + + try: + provider = sociallogin.account.get_provider() + provider_settings = SocialApp.objects.get(provider_id=provider).settings + user.role = Role.objects.get(pk=provider_settings["role"]) + print(provider, provider_settings) + except: + print("Provider settings or Role not found. Continuing with blank permissions.") + return user \ No newline at end of file diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index ea3b4ba45e..698e8d48b6 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -16,6 +16,7 @@ from rest_framework.response import Response from rest_framework.serializers import ModelSerializer, ReadOnlyField from rest_framework.views import APIView +from python_ipware import IpWare from accounts.permissions import AccountsPerms from logs.models import AuditLog @@ -24,6 +25,7 @@ class SocialAppSerializer(ModelSerializer): server_url = ReadOnlyField(source="settings.server_url") + role = ReadOnlyField(source="settings.role") class Meta: model = SocialApp @@ -36,6 +38,7 @@ class Meta: "secret", "server_url", "settings", + "role", ] @@ -48,6 +51,7 @@ def get(self, request): class InputSerializer(ModelSerializer): server_url = ReadOnlyField() + role = ReadOnlyField() class Meta: model = SocialApp @@ -59,6 +63,7 @@ class Meta: "provider", "provider_id", "settings", + "role" ] # removed any special characters and replaces spaces with a hyphen @@ -73,6 +78,7 @@ def post(self, request): # need to move server_url into json settings data["settings"] = {} data["settings"]["server_url"] = data["server_url"] + data["settings"]["role"] = data["role"] if data["role"] else None # set provider to 'openid_connect' data["provider"] = "openid_connect" @@ -91,10 +97,11 @@ class GetUpdateDeleteSSOProvider(APIView): class InputSerialzer(ModelSerializer): server_url = ReadOnlyField() + role = ReadOnlyField() class Meta: model = SocialApp - fields = ["client_id", "secret", "server_url", "settings"] + fields = ["client_id", "secret", "server_url", "settings", "role"] def put(self, request, pk): provider = get_object_or_404(SocialApp, pk=pk) @@ -103,9 +110,10 @@ def put(self, request, pk): # need to move server_url into json settings data["settings"] = {} data["settings"]["server_url"] = data["server_url"] + data["settings"]["role"] = data["role"] if data["role"] else None serializer = self.InputSerialzer( - instance=provider, data=request.data, partial=True + instance=provider, data=data, partial=True ) serializer.is_valid(raise_exception=True) serializer.save() @@ -138,6 +146,13 @@ def post(self, request, format=None): request.user.username, login_method["provider"], login_method ) + # log ip + ipw = IpWare() + client_ip, _ = ipw.get_client_ip(request.META) + if client_ip: + request.user.last_login_ip = str(client_ip) + request.user.save(update_fields=["last_login_ip"]) + # invalid user session since we have an access token now logout(request) diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index 470778ac5e..49823d8f1d 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -201,6 +201,7 @@ SOCIALACCOUNT_ONLY = True ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" ACCOUNT_EMAIL_VERIFICATION = "none" +SOCIALACCOUNT_ADAPTER = "ee.sso.adapter.TacticalSocialAdapter" SOCIALACCOUNT_EMAIL_AUTHENTICATION = True SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True SOCIALACCOUNT_EMAIL_VERIFICATION = True From ce11685371df5d526c327d90856de7a25d701f4e Mon Sep 17 00:00:00 2001 From: sadnub Date: Fri, 18 Oct 2024 11:06:55 -0400 Subject: [PATCH 12/50] secure sso token a little more and allow for disabling sso feature. --- .../0049_coresettings_disable_sso.py | 18 +++++ ...emove_coresettings_disable_sso_and_more.py | 22 +++++++ api/tacticalrmm/core/models.py | 1 + api/tacticalrmm/ee/sso/adapter.py | 35 ++-------- api/tacticalrmm/ee/sso/permissions.py | 8 +++ api/tacticalrmm/ee/sso/urls.py | 66 ++++++++++++++++++- api/tacticalrmm/ee/sso/views.py | 21 ++++-- api/tacticalrmm/logs/models.py | 12 ---- api/tacticalrmm/tacticalrmm/settings.py | 1 - api/tacticalrmm/tacticalrmm/urls.py | 4 +- 10 files changed, 135 insertions(+), 53 deletions(-) create mode 100644 api/tacticalrmm/core/migrations/0049_coresettings_disable_sso.py create mode 100644 api/tacticalrmm/core/migrations/0050_remove_coresettings_disable_sso_and_more.py create mode 100644 api/tacticalrmm/ee/sso/permissions.py diff --git a/api/tacticalrmm/core/migrations/0049_coresettings_disable_sso.py b/api/tacticalrmm/core/migrations/0049_coresettings_disable_sso.py new file mode 100644 index 0000000000..abd05da5cc --- /dev/null +++ b/api/tacticalrmm/core/migrations/0049_coresettings_disable_sso.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.14 on 2024-10-15 15:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0048_coresettings_block_local_user_logon'), + ] + + operations = [ + migrations.AddField( + model_name='coresettings', + name='disable_sso', + field=models.BooleanField(default=True), + ), + ] diff --git a/api/tacticalrmm/core/migrations/0050_remove_coresettings_disable_sso_and_more.py b/api/tacticalrmm/core/migrations/0050_remove_coresettings_disable_sso_and_more.py new file mode 100644 index 0000000000..64fdc6dfb6 --- /dev/null +++ b/api/tacticalrmm/core/migrations/0050_remove_coresettings_disable_sso_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.14 on 2024-10-15 20:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0049_coresettings_disable_sso'), + ] + + operations = [ + migrations.RemoveField( + model_name='coresettings', + name='disable_sso', + ), + migrations.AddField( + model_name='coresettings', + name='sso_enabled', + field=models.BooleanField(default=False), + ), + ] diff --git a/api/tacticalrmm/core/models.py b/api/tacticalrmm/core/models.py index 38eb958405..4a33ca4bc7 100644 --- a/api/tacticalrmm/core/models.py +++ b/api/tacticalrmm/core/models.py @@ -112,6 +112,7 @@ class CoreSettings(BaseAuditModel): notify_on_warning_alerts = models.BooleanField(default=True) block_local_user_logon = models.BooleanField(default=True) + sso_enabled = models.BooleanField(default=False) def save(self, *args, **kwargs) -> None: from alerts.tasks import cache_agents_alert_template diff --git a/api/tacticalrmm/ee/sso/adapter.py b/api/tacticalrmm/ee/sso/adapter.py index e8ecf83962..1da42dead4 100644 --- a/api/tacticalrmm/ee/sso/adapter.py +++ b/api/tacticalrmm/ee/sso/adapter.py @@ -4,40 +4,19 @@ from allauth.utils import valid_email_or_none from accounts.models import Role -class TacticalSocialAdapter(DefaultSocialAccountAdapter): - - def populate_user(self, request, sociallogin, data): - """ - Hook that can be used to further populate the user instance. - - For convenience, we populate several common fields. - Note that the user instance being populated represents a - suggested User instance that represents the social user that is - in the process of being logged in. - The User instance need not be completely valid and conflict - free. For example, verifying whether or not the username - already exists, is not a responsibility. - """ - username = data.get("username") - first_name = data.get("first_name") - last_name = data.get("last_name") - email = data.get("email") - name = data.get("name") - user = sociallogin.user - user_username(user, username or "") - user_email(user, valid_email_or_none(email) or "") - name_parts = (name or "").partition(" ") - user_field(user, "first_name", first_name or name_parts[0]) - user_field(user, "last_name", last_name or name_parts[2]) +class TacticalSocialAdapter(DefaultSocialAccountAdapter): + def populate_user(self, request, sociallogin, data): + user = super().populate_user(request, sociallogin, data) try: provider = sociallogin.account.get_provider() provider_settings = SocialApp.objects.get(provider_id=provider).settings user.role = Role.objects.get(pk=provider_settings["role"]) - print(provider, provider_settings) except: - print("Provider settings or Role not found. Continuing with blank permissions.") - return user \ No newline at end of file + print( + "Provider settings or Role not found. Continuing with blank permissions." + ) + return user diff --git a/api/tacticalrmm/ee/sso/permissions.py b/api/tacticalrmm/ee/sso/permissions.py new file mode 100644 index 0000000000..164ea2845f --- /dev/null +++ b/api/tacticalrmm/ee/sso/permissions.py @@ -0,0 +1,8 @@ +from rest_framework import permissions +from allauth.socialaccount.models import SocialAccount + +class SSOLoginPerms(permissions.BasePermission): + def has_permission(self, r, view): + connected_apps = SocialAccount.objects.filter(user=r.user) + + return len(connected_apps) > 0 \ No newline at end of file diff --git a/api/tacticalrmm/ee/sso/urls.py b/api/tacticalrmm/ee/sso/urls.py index c07a376f47..60f59a7769 100644 --- a/api/tacticalrmm/ee/sso/urls.py +++ b/api/tacticalrmm/ee/sso/urls.py @@ -4,15 +4,75 @@ For details, see: https://license.tacticalrmm.com/ee """ -from django.urls import path -from django.urls import include +from django.urls import path, include, re_path +from allauth.socialaccount.providers.openid_connect.views import callback +from allauth.headless.socialaccount.views import ( + RedirectToProviderView, + ManageProvidersView, +) +from allauth.headless.base.views import ConfigView from . import views urlpatterns = [ - path("", include("allauth.urls")), + re_path( + r"^oidc/(?P[^/]+)/", + include( + [ + path( + "login/callback/", + callback, + name="openid_connect_callback", + ), + ] + ), + ), path("ssoproviders/", views.GetAddSSOProvider.as_view()), path("ssoproviders//", views.GetUpdateDeleteSSOProvider.as_view()), path("ssoproviders/token/", views.GetAccessToken.as_view()), path("ssoproviders/settings/", views.GetUpdateSSOSettings.as_view()), ] + +allauth_urls = [ + path( + "browser/v1/", + include( + ( + [ + path( + "config", + ConfigView.as_api_view(client="browser"), + name="config", + ), + path( + "", + include( + ( + [ + path( + "auth/provider/redirect", + RedirectToProviderView.as_api_view( + client="browser" + ), + name="redirect_to_provider", + ), + path( + "providers", + ManageProvidersView.as_api_view( + client="browser" + ), + name="manage_providers", + ), + ], + "headless", + ), + namespace="socialaccount", + ), + ), + ], + "headless", + ), + namespace="browser", + ), + ) +] diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index 698e8d48b6..52e1a32889 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -21,7 +21,7 @@ from accounts.permissions import AccountsPerms from logs.models import AuditLog from tacticalrmm.utils import get_core_settings - +from .permissions import SSOLoginPerms class SocialAppSerializer(ModelSerializer): server_url = ReadOnlyField(source="settings.server_url") @@ -126,13 +126,17 @@ def delete(self, request, pk): class GetAccessToken(KnoxLoginView): - permission_classes = [IsAuthenticated] + permission_classes = [SSOLoginPerms] authentication_classes = [SessionAuthentication] def post(self, request, format=None): + + core = get_core_settings() + # check for auth method before signing in if ( - "account_authentication_methods" in request.session + core.sso_enabled + and "account_authentication_methods" in request.session and len(request.session["account_authentication_methods"]) > 0 ): login_method = request.session["account_authentication_methods"][0] @@ -158,10 +162,9 @@ def post(self, request, format=None): return Response(response.data) else: - AuditLog.audit_user_login_failed_sso(request.user.username) logout(request) return Response( - "The credentials supplied were invalid", status.HTTP_403_FORBIDDEN + "No pending login session found", status.HTTP_403_FORBIDDEN ) @@ -173,7 +176,10 @@ def get(self, request): core_settings = get_core_settings() return Response( - {"block_local_user_logon": core_settings.block_local_user_logon} + { + "block_local_user_logon": core_settings.block_local_user_logon, + "sso_enabled": core_settings.sso_enabled + } ) def post(self, request): @@ -183,6 +189,7 @@ def post(self, request): core_settings = get_core_settings() core_settings.block_local_user_logon = data["block_local_user_logon"] - core_settings.save(update_fields=["block_local_user_logon"]) + core_settings.sso_enabled = data["sso_enabled"] + core_settings.save(update_fields=["block_local_user_logon", "sso_enabled"]) return Response("ok") diff --git a/api/tacticalrmm/logs/models.py b/api/tacticalrmm/logs/models.py index 2a69315f6f..ebb15b29b5 100644 --- a/api/tacticalrmm/logs/models.py +++ b/api/tacticalrmm/logs/models.py @@ -225,18 +225,6 @@ def audit_user_login_successful_sso( debug_info=debug_info, ) - @staticmethod - def audit_user_login_failed_sso( - username: str, debug_info: Dict[Any, Any] = {} - ) -> None: - AuditLog.objects.create( - username=username, - object_type=AuditObjType.USER, - action=AuditActionType.LOGIN, - message=f"{username} failed to login through unknown sso provider", - debug_info=debug_info, - ) - @staticmethod def audit_url_action( username: str, diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index 49823d8f1d..1a36aa1536 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -208,7 +208,6 @@ SOCIALACCOUNT_PROVIDERS = {"openid_connect": {"OAUTH_PKCE_ENABLED": True}} -AUTHENTICATION_BACKENDS = ("allauth.account.auth_backends.AuthenticationBackend",) SESSION_COOKIE_SECURE = True # silence cache key length warnings diff --git a/api/tacticalrmm/tacticalrmm/urls.py b/api/tacticalrmm/tacticalrmm/urls.py index 42e05a4a17..e1f2cb5668 100644 --- a/api/tacticalrmm/tacticalrmm/urls.py +++ b/api/tacticalrmm/tacticalrmm/urls.py @@ -3,12 +3,12 @@ from knox import views as knox_views from accounts.views import CheckCreds, CheckCredsV2, LoginView, LoginViewV2 +from ee.sso.urls import allauth_urls # from agents.consumers import SendCMD from core.consumers import DashInfo, TerminalConsumer from core.views import home - class AgentIDConverter: regex = "[^/]{20}[^/]+" @@ -24,7 +24,7 @@ def to_url(self, value): urlpatterns = [ path("", home), # all auth urls - path("_allauth/", include("allauth.headless.urls")), + path("_allauth/", include(allauth_urls)), path("v2/checkcreds/", CheckCredsV2.as_view()), path("v2/login/", LoginViewV2.as_view()), path("checkcreds/", CheckCreds.as_view()), # DEPRECATED AS OF 0.19.0 From ebefcb7fc1f75a9a2321ff8fcc91ca1275ec804d Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Fri, 18 Oct 2024 19:01:22 +0000 Subject: [PATCH 13/50] block local should be disabled by default --- ...lter_coresettings_block_local_user_logon.py | 18 ++++++++++++++++++ api/tacticalrmm/core/models.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 api/tacticalrmm/core/migrations/0051_alter_coresettings_block_local_user_logon.py diff --git a/api/tacticalrmm/core/migrations/0051_alter_coresettings_block_local_user_logon.py b/api/tacticalrmm/core/migrations/0051_alter_coresettings_block_local_user_logon.py new file mode 100644 index 0000000000..04ede6b253 --- /dev/null +++ b/api/tacticalrmm/core/migrations/0051_alter_coresettings_block_local_user_logon.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-10-18 19:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0050_remove_coresettings_disable_sso_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="coresettings", + name="block_local_user_logon", + field=models.BooleanField(default=False), + ), + ] diff --git a/api/tacticalrmm/core/models.py b/api/tacticalrmm/core/models.py index 4a33ca4bc7..0e3ef1a2bc 100644 --- a/api/tacticalrmm/core/models.py +++ b/api/tacticalrmm/core/models.py @@ -111,7 +111,7 @@ class CoreSettings(BaseAuditModel): notify_on_info_alerts = models.BooleanField(default=False) notify_on_warning_alerts = models.BooleanField(default=True) - block_local_user_logon = models.BooleanField(default=True) + block_local_user_logon = models.BooleanField(default=False) sso_enabled = models.BooleanField(default=False) def save(self, *args, **kwargs) -> None: From 3bfa35e1c7cd2f7b96ad5c49a8379c9d48827996 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Fri, 18 Oct 2024 20:25:32 +0000 Subject: [PATCH 14/50] move settings before local import --- api/tacticalrmm/tacticalrmm/settings.py | 27 +++++++++++++------------ 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index 1a36aa1536..8abc2d0407 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -117,6 +117,20 @@ TRMM_LOG_LEVEL = "ERROR" TRMM_LOG_TO = "file" +# settings for django all auth +HEADLESS_ONLY = True +SOCIALACCOUNT_ONLY = True +ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" +ACCOUNT_EMAIL_VERIFICATION = "none" +SOCIALACCOUNT_ADAPTER = "ee.sso.adapter.TacticalSocialAdapter" +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 + with suppress(ImportError): from .local_settings import * # noqa @@ -196,19 +210,6 @@ }, } -# settings for django all auth -HEADLESS_ONLY = True -SOCIALACCOUNT_ONLY = True -ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" -ACCOUNT_EMAIL_VERIFICATION = "none" -SOCIALACCOUNT_ADAPTER = "ee.sso.adapter.TacticalSocialAdapter" -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 From 899111a3105fd385759899609e1ef5a69de30ad1 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Fri, 18 Oct 2024 20:26:14 +0000 Subject: [PATCH 15/50] remove unused imports --- api/tacticalrmm/ee/sso/adapter.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/tacticalrmm/ee/sso/adapter.py b/api/tacticalrmm/ee/sso/adapter.py index 1da42dead4..009170512d 100644 --- a/api/tacticalrmm/ee/sso/adapter.py +++ b/api/tacticalrmm/ee/sso/adapter.py @@ -1,7 +1,5 @@ from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.models import SocialApp -from allauth.account.utils import user_email, user_field, user_username -from allauth.utils import valid_email_or_none from accounts.models import Role From 61790d2261b5087c510d5ce3ea3c0c2d768dd7bd Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Fri, 18 Oct 2024 20:26:53 +0000 Subject: [PATCH 16/50] blacked --- api/tacticalrmm/ee/sso/permissions.py | 3 ++- api/tacticalrmm/ee/sso/views.py | 17 +++++++---------- api/tacticalrmm/tacticalrmm/urls.py | 1 + 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/api/tacticalrmm/ee/sso/permissions.py b/api/tacticalrmm/ee/sso/permissions.py index 164ea2845f..fb6af4da3a 100644 --- a/api/tacticalrmm/ee/sso/permissions.py +++ b/api/tacticalrmm/ee/sso/permissions.py @@ -1,8 +1,9 @@ from rest_framework import permissions from allauth.socialaccount.models import SocialAccount + class SSOLoginPerms(permissions.BasePermission): def has_permission(self, r, view): connected_apps = SocialAccount.objects.filter(user=r.user) - return len(connected_apps) > 0 \ No newline at end of file + return len(connected_apps) > 0 diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index 52e1a32889..0144377223 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -23,6 +23,7 @@ from tacticalrmm.utils import get_core_settings from .permissions import SSOLoginPerms + class SocialAppSerializer(ModelSerializer): server_url = ReadOnlyField(source="settings.server_url") role = ReadOnlyField(source="settings.role") @@ -63,7 +64,7 @@ class Meta: "provider", "provider_id", "settings", - "role" + "role", ] # removed any special characters and replaces spaces with a hyphen @@ -112,9 +113,7 @@ def put(self, request, pk): data["settings"]["server_url"] = data["server_url"] data["settings"]["role"] = data["role"] if data["role"] else None - serializer = self.InputSerialzer( - instance=provider, data=data, partial=True - ) + serializer = self.InputSerialzer(instance=provider, data=data, partial=True) serializer.is_valid(raise_exception=True) serializer.save() return Response("ok") @@ -130,9 +129,9 @@ class GetAccessToken(KnoxLoginView): authentication_classes = [SessionAuthentication] def post(self, request, format=None): - + core = get_core_settings() - + # check for auth method before signing in if ( core.sso_enabled @@ -163,9 +162,7 @@ def post(self, request, format=None): return Response(response.data) else: logout(request) - return Response( - "No pending login session found", status.HTTP_403_FORBIDDEN - ) + return Response("No pending login session found", status.HTTP_403_FORBIDDEN) class GetUpdateSSOSettings(APIView): @@ -178,7 +175,7 @@ def get(self, request): return Response( { "block_local_user_logon": core_settings.block_local_user_logon, - "sso_enabled": core_settings.sso_enabled + "sso_enabled": core_settings.sso_enabled, } ) diff --git a/api/tacticalrmm/tacticalrmm/urls.py b/api/tacticalrmm/tacticalrmm/urls.py index e1f2cb5668..3bc77d66b7 100644 --- a/api/tacticalrmm/tacticalrmm/urls.py +++ b/api/tacticalrmm/tacticalrmm/urls.py @@ -9,6 +9,7 @@ from core.consumers import DashInfo, TerminalConsumer from core.views import home + class AgentIDConverter: regex = "[^/]{20}[^/]+" From bacf4154fd4c4bd0d820ba343ef7b006d18ca0ff Mon Sep 17 00:00:00 2001 From: sadnub Date: Fri, 18 Oct 2024 17:50:21 -0400 Subject: [PATCH 17/50] fix some 500 errors --- .devcontainer/entrypoint.sh | 2 +- api/tacticalrmm/accounts/views.py | 25 +++++++++++++----------- api/tacticalrmm/ee/sso/permissions.py | 1 - api/tacticalrmm/ee/sso/views.py | 2 +- docker/containers/tactical/entrypoint.sh | 2 +- 5 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.devcontainer/entrypoint.sh b/.devcontainer/entrypoint.sh index d64a562369..193a01859d 100644 --- a/.devcontainer/entrypoint.sh +++ b/.devcontainer/entrypoint.sh @@ -71,7 +71,7 @@ ADMIN_URL = 'admin/' ALLOWED_HOSTS = ['${API_HOST}', '${APP_HOST}', '*'] -CORS_ORIGIN_WHITELIST = ['https://${APP_HOST}', 'https://${API_HOST}'] +CORS_ORIGIN_WHITELIST = ['https://${APP_HOST}'] CORS_ALLOW_CREDENTIALS = True SESSION_COOKIE_DOMAIN = '${BASE_DOMAIN}' diff --git a/api/tacticalrmm/accounts/views.py b/api/tacticalrmm/accounts/views.py index 7dd94de467..4a7ecb95a4 100644 --- a/api/tacticalrmm/accounts/views.py +++ b/api/tacticalrmm/accounts/views.py @@ -262,17 +262,20 @@ def get_social_accounts(self, obj): accounts = SocialAccount.objects.filter(user_id=obj.pk) - return [ - { - "uid": account.uid, - "provider": account.provider, - "display": account.get_provider_account().to_str(), - "last_login": account.last_login, - "date_joined": account.date_joined, - "extra_data": account.extra_data, - } - for account in accounts - ] + if len(accounts) > 0: + return [ + { + "uid": account.uid, + "provider": account.provider, + "display": account.get_provider_account().to_str(), + "last_login": account.last_login, + "date_joined": account.date_joined, + "extra_data": account.extra_data, + } + for account in accounts + ] + else: + return [] class Meta: model = User diff --git a/api/tacticalrmm/ee/sso/permissions.py b/api/tacticalrmm/ee/sso/permissions.py index fb6af4da3a..4352289f16 100644 --- a/api/tacticalrmm/ee/sso/permissions.py +++ b/api/tacticalrmm/ee/sso/permissions.py @@ -5,5 +5,4 @@ class SSOLoginPerms(permissions.BasePermission): def has_permission(self, r, view): connected_apps = SocialAccount.objects.filter(user=r.user) - return len(connected_apps) > 0 diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index 0144377223..382c2940ab 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -125,7 +125,7 @@ def delete(self, request, pk): class GetAccessToken(KnoxLoginView): - permission_classes = [SSOLoginPerms] + permission_classes = [IsAuthenticated, SSOLoginPerms] authentication_classes = [SessionAuthentication] def post(self, request, format=None): diff --git a/docker/containers/tactical/entrypoint.sh b/docker/containers/tactical/entrypoint.sh index 09927c6d20..8a914d7e55 100644 --- a/docker/containers/tactical/entrypoint.sh +++ b/docker/containers/tactical/entrypoint.sh @@ -92,7 +92,7 @@ ALLOWED_HOSTS = ['${API_HOST}', '${APP_HOST}', 'tactical-backend'] ADMIN_URL = '${ADMINURL}/' -CORS_ORIGIN_WHITELIST = ['https://${APP_HOST}', 'https://${API_HOST}'] +CORS_ORIGIN_WHITELIST = ['https://${APP_HOST}'] CORS_ALLOW_CREDENTIALS = True SESSION_COOKIE_DOMAIN = '${BASE_DOMAIN}' From 66c7123f7ce1c3c4e68de1ddca4ed129d70db980 Mon Sep 17 00:00:00 2001 From: sadnub Date: Mon, 21 Oct 2024 11:30:08 -0400 Subject: [PATCH 18/50] allow displaying full name in UI if present --- api/tacticalrmm/accounts/views.py | 4 ++++ api/tacticalrmm/ee/sso/views.py | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/api/tacticalrmm/accounts/views.py b/api/tacticalrmm/accounts/views.py index 4a7ecb95a4..a59d6cf15f 100644 --- a/api/tacticalrmm/accounts/views.py +++ b/api/tacticalrmm/accounts/views.py @@ -114,6 +114,10 @@ def post(self, request, format=None): ) response = super().post(request, format=None) response.data["username"] = request.user.username + if request.user.first_name and request.user.last_name: + response.data["name"] = f"{request.user.first_name} {request.user.last_name}" + else: + response.data["name"] = None return Response(response.data) else: AuditLog.audit_user_failed_twofactor( diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index 382c2940ab..24d1d849f5 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -142,9 +142,15 @@ def post(self, request, format=None): # get token response = super().post(request, format=None) + response.data["username"] = request.user.username response.data["provider"] = login_method["provider"] + if request.user.first_name and request.user.last_name: + response.data["name"] = f"{request.user.first_name} {request.user.last_name}" + else: + response.data["name"] = None + AuditLog.audit_user_login_successful_sso( request.user.username, login_method["provider"], login_method ) From 5520a84062c7c709c233ca8cfdf502efac3ab8fe Mon Sep 17 00:00:00 2001 From: sadnub Date: Tue, 22 Oct 2024 11:54:34 -0400 Subject: [PATCH 19/50] fix client ip not showing in audit log for sso logon and disable some unused urls and settings --- api/tacticalrmm/ee/sso/urls.py | 19 +++++-------------- api/tacticalrmm/ee/sso/views.py | 22 +++++++++++++++++----- api/tacticalrmm/tacticalrmm/settings.py | 10 ++++++---- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/api/tacticalrmm/ee/sso/urls.py b/api/tacticalrmm/ee/sso/urls.py index 60f59a7769..6519a16006 100644 --- a/api/tacticalrmm/ee/sso/urls.py +++ b/api/tacticalrmm/ee/sso/urls.py @@ -6,10 +6,7 @@ from django.urls import path, include, re_path from allauth.socialaccount.providers.openid_connect.views import callback -from allauth.headless.socialaccount.views import ( - RedirectToProviderView, - ManageProvidersView, -) +from allauth.headless.socialaccount.views import RedirectToProviderView from allauth.headless.base.views import ConfigView from . import views @@ -31,6 +28,7 @@ path("ssoproviders//", views.GetUpdateDeleteSSOProvider.as_view()), path("ssoproviders/token/", views.GetAccessToken.as_view()), path("ssoproviders/settings/", views.GetUpdateSSOSettings.as_view()), + path("ssoproviders/account/", views.DisconnectSSOAccount.as_view()) ] allauth_urls = [ @@ -40,7 +38,7 @@ ( [ path( - "config", + "config/", ConfigView.as_api_view(client="browser"), name="config", ), @@ -50,19 +48,12 @@ ( [ path( - "auth/provider/redirect", + "auth/provider/redirect/", RedirectToProviderView.as_api_view( client="browser" ), name="redirect_to_provider", - ), - path( - "providers", - ManageProvidersView.as_api_view( - client="browser" - ), - name="manage_providers", - ), + ) ], "headless", ), diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index 24d1d849f5..7a5ddffebc 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -6,7 +6,7 @@ import re -from allauth.socialaccount.models import SocialApp +from allauth.socialaccount.models import SocialApp, SocialAccount from django.contrib.auth import logout from django.shortcuts import get_object_or_404 from knox.views import LoginView as KnoxLoginView @@ -124,6 +124,17 @@ def delete(self, request, pk): return Response("ok") +class DisconnectSSOAccount(APIView): + permission_classes = [IsAuthenticated, AccountsPerms] + + def delete(self, request): + account = get_object_or_404(SocialAccount, uid=request.data["account"], provider=request.data["provider"]) + + account.delete() + + return Response("ok") + + class GetAccessToken(KnoxLoginView): permission_classes = [IsAuthenticated, SSOLoginPerms] authentication_classes = [SessionAuthentication] @@ -151,16 +162,17 @@ def post(self, request, format=None): else: response.data["name"] = None - AuditLog.audit_user_login_successful_sso( - request.user.username, login_method["provider"], login_method - ) - # log ip ipw = IpWare() client_ip, _ = ipw.get_client_ip(request.META) if client_ip: request.user.last_login_ip = str(client_ip) request.user.save(update_fields=["last_login_ip"]) + login_method["ip"] = str(client_ip) + + AuditLog.audit_user_login_successful_sso( + request.user.username, login_method["provider"], login_method + ) # invalid user session since we have an access token now logout(request) diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index 8abc2d0407..2f4576e0d3 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -172,7 +172,6 @@ "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.staticfiles", - "django.contrib.messages", "channels", "rest_framework", "rest_framework.authtoken", @@ -237,7 +236,6 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", "tacticalrmm.middleware.AuditMiddleware", "allauth.account.middleware.AccountMiddleware", ] @@ -255,8 +253,12 @@ MIDDLEWARE.insert(0, "silk.middleware.SilkyMiddleware") if ADMIN_ENABLED: - INSTALLED_APPS += ("django.contrib.admin",) - + MIDDLEWARE += ("django.contrib.messages.middleware.MessageMiddleware",) + INSTALLED_APPS += ( + "django.contrib.admin", + "django.contrib.messages", + ) + if DEMO: MIDDLEWARE += ("tacticalrmm.middleware.DemoMiddleware",) From 4fd772ecd84335a66f85e7dc41de0297e529cb52 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Thu, 24 Oct 2024 04:49:09 +0000 Subject: [PATCH 20/50] update reqs --- api/tacticalrmm/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tacticalrmm/requirements.txt b/api/tacticalrmm/requirements.txt index 565cb6dc38..f90ab46302 100644 --- a/api/tacticalrmm/requirements.txt +++ b/api/tacticalrmm/requirements.txt @@ -7,7 +7,7 @@ channels_redis==4.2.0 cryptography==43.0.3 Django==4.2.16 django-cors-headers==4.5.0 -django-allauth[socialaccount]==64.2.1 +django-allauth[socialaccount]==65.1.0 django-filter==24.3 django-rest-knox==4.2.0 djangorestframework==3.15.2 From c28d800d7f2f34949f5d50117073100ae8c3a1e1 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Thu, 24 Oct 2024 04:49:17 +0000 Subject: [PATCH 21/50] blacked --- api/tacticalrmm/accounts/views.py | 4 +++- api/tacticalrmm/ee/sso/urls.py | 2 +- api/tacticalrmm/ee/sso/views.py | 12 +++++++++--- api/tacticalrmm/tacticalrmm/settings.py | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/api/tacticalrmm/accounts/views.py b/api/tacticalrmm/accounts/views.py index a59d6cf15f..b13a7aa4d4 100644 --- a/api/tacticalrmm/accounts/views.py +++ b/api/tacticalrmm/accounts/views.py @@ -115,7 +115,9 @@ def post(self, request, format=None): response = super().post(request, format=None) response.data["username"] = request.user.username if request.user.first_name and request.user.last_name: - response.data["name"] = f"{request.user.first_name} {request.user.last_name}" + response.data["name"] = ( + f"{request.user.first_name} {request.user.last_name}" + ) else: response.data["name"] = None return Response(response.data) diff --git a/api/tacticalrmm/ee/sso/urls.py b/api/tacticalrmm/ee/sso/urls.py index 6519a16006..175dc48834 100644 --- a/api/tacticalrmm/ee/sso/urls.py +++ b/api/tacticalrmm/ee/sso/urls.py @@ -28,7 +28,7 @@ path("ssoproviders//", views.GetUpdateDeleteSSOProvider.as_view()), path("ssoproviders/token/", views.GetAccessToken.as_view()), path("ssoproviders/settings/", views.GetUpdateSSOSettings.as_view()), - path("ssoproviders/account/", views.DisconnectSSOAccount.as_view()) + path("ssoproviders/account/", views.DisconnectSSOAccount.as_view()), ] allauth_urls = [ diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index 7a5ddffebc..4bddd72986 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -128,7 +128,11 @@ class DisconnectSSOAccount(APIView): permission_classes = [IsAuthenticated, AccountsPerms] def delete(self, request): - account = get_object_or_404(SocialAccount, uid=request.data["account"], provider=request.data["provider"]) + account = get_object_or_404( + SocialAccount, + uid=request.data["account"], + provider=request.data["provider"], + ) account.delete() @@ -153,12 +157,14 @@ def post(self, request, format=None): # get token response = super().post(request, format=None) - + response.data["username"] = request.user.username response.data["provider"] = login_method["provider"] if request.user.first_name and request.user.last_name: - response.data["name"] = f"{request.user.first_name} {request.user.last_name}" + response.data["name"] = ( + f"{request.user.first_name} {request.user.last_name}" + ) else: response.data["name"] = None diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index 2f4576e0d3..4f211ceb18 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -258,7 +258,7 @@ "django.contrib.admin", "django.contrib.messages", ) - + if DEMO: MIDDLEWARE += ("tacticalrmm.middleware.DemoMiddleware",) From faa0e6c2894a74e0c8c616fd5485b455a5119917 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Fri, 25 Oct 2024 00:22:11 +0000 Subject: [PATCH 22/50] handle orphaned sso providers --- api/tacticalrmm/accounts/views.py | 41 ++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/api/tacticalrmm/accounts/views.py b/api/tacticalrmm/accounts/views.py index b13a7aa4d4..0818d72376 100644 --- a/api/tacticalrmm/accounts/views.py +++ b/api/tacticalrmm/accounts/views.py @@ -264,24 +264,35 @@ class UserSerializerSSO(ModelSerializer): social_accounts = SerializerMethodField() def get_social_accounts(self, obj): - from allauth.socialaccount.models import SocialAccount + from allauth.socialaccount.models import SocialAccount, SocialApp accounts = SocialAccount.objects.filter(user_id=obj.pk) - if len(accounts) > 0: - return [ - { - "uid": account.uid, - "provider": account.provider, - "display": account.get_provider_account().to_str(), - "last_login": account.last_login, - "date_joined": account.date_joined, - "extra_data": account.extra_data, - } - for account in accounts - ] - else: - return [] + if accounts: + social_accounts = [] + for account in accounts: + try: + provider_account = account.get_provider_account() + display = provider_account.to_str() + except SocialApp.DoesNotExist: + display = "Orphaned Provider" + except Exception: + display = "Unknown" + + social_accounts.append( + { + "uid": account.uid, + "provider": account.provider, + "display": display, + "last_login": account.last_login, + "date_joined": account.date_joined, + "extra_data": account.extra_data, + } + ) + + return social_accounts + + return [] class Meta: model = User From 0bd09d03c18d8316e458f3e6854ca760b28e0036 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Fri, 25 Oct 2024 01:01:22 +0000 Subject: [PATCH 23/50] fix tests --- api/tacticalrmm/accounts/tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/tacticalrmm/accounts/tests.py b/api/tacticalrmm/accounts/tests.py index 133fd9ca12..01be9f1b25 100644 --- a/api/tacticalrmm/accounts/tests.py +++ b/api/tacticalrmm/accounts/tests.py @@ -11,6 +11,7 @@ class TestAccounts(TacticalTestCase): def setUp(self): + self.setup_coresettings() self.setup_client() self.bob = User(username="bob") self.bob.set_password("hunter2") From 2c09ad6b91f54997f7fd23906905522a93290096 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Fri, 25 Oct 2024 03:32:14 +0000 Subject: [PATCH 24/50] update headers --- api/tacticalrmm/ee/LICENSE.md | 2 +- api/tacticalrmm/ee/sso/__init__.py | 2 +- api/tacticalrmm/ee/sso/adapter.py | 6 ++++++ api/tacticalrmm/ee/sso/permissions.py | 6 ++++++ api/tacticalrmm/ee/sso/urls.py | 2 +- api/tacticalrmm/ee/sso/views.py | 2 +- 6 files changed, 16 insertions(+), 4 deletions(-) diff --git a/api/tacticalrmm/ee/LICENSE.md b/api/tacticalrmm/ee/LICENSE.md index fb1889a320..20510d55e6 100644 --- a/api/tacticalrmm/ee/LICENSE.md +++ b/api/tacticalrmm/ee/LICENSE.md @@ -4,7 +4,7 @@ Copyright (c) 2023 Amidaware Inc. All rights reserved. This Agreement is entered into between the licensee ("You" or the "Licensee") and Amidaware Inc. ("Amidaware") and governs the use of the enterprise features of the Tactical RMM Software (hereinafter referred to as the "Software"). -The EE features of the Software, including but not limited to Reporting and White-labeling, are exclusively contained within directories named "ee," "enterprise," or "premium" in Amidaware's repositories, or in any files bearing the EE License header. The use of the Software is also governed by the terms and conditions set forth in the Tactical RMM License, available at https://license.tacticalrmm.com, which terms are incorporated herein by reference. +The EE features of the Software, including but not limited to SSO (Single Sign-On), Reporting and White-labeling, are exclusively contained within directories named "ee," "enterprise," or "premium" in Amidaware's repositories, or in any files bearing the EE License header. The use of the Software is also governed by the terms and conditions set forth in the Tactical RMM License, available at https://license.tacticalrmm.com, which terms are incorporated herein by reference. ## License Grant diff --git a/api/tacticalrmm/ee/sso/__init__.py b/api/tacticalrmm/ee/sso/__init__.py index 38bd5902d7..31d29b122c 100644 --- a/api/tacticalrmm/ee/sso/__init__.py +++ b/api/tacticalrmm/ee/sso/__init__.py @@ -1,5 +1,5 @@ """ -Copyright (c) 2023-present Amidaware Inc. +Copyright (c) 2024-present Amidaware Inc. This file is subject to the EE License Agreement. For details, see: https://license.tacticalrmm.com/ee """ diff --git a/api/tacticalrmm/ee/sso/adapter.py b/api/tacticalrmm/ee/sso/adapter.py index 009170512d..d649ab3464 100644 --- a/api/tacticalrmm/ee/sso/adapter.py +++ b/api/tacticalrmm/ee/sso/adapter.py @@ -1,3 +1,9 @@ +""" +Copyright (c) 2024-present Amidaware Inc. +This file is subject to the EE License Agreement. +For details, see: https://license.tacticalrmm.com/ee +""" + from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.models import SocialApp diff --git a/api/tacticalrmm/ee/sso/permissions.py b/api/tacticalrmm/ee/sso/permissions.py index 4352289f16..3b31318a53 100644 --- a/api/tacticalrmm/ee/sso/permissions.py +++ b/api/tacticalrmm/ee/sso/permissions.py @@ -1,3 +1,9 @@ +""" +Copyright (c) 2024-present Amidaware Inc. +This file is subject to the EE License Agreement. +For details, see: https://license.tacticalrmm.com/ee +""" + from rest_framework import permissions from allauth.socialaccount.models import SocialAccount diff --git a/api/tacticalrmm/ee/sso/urls.py b/api/tacticalrmm/ee/sso/urls.py index 175dc48834..8662c4c0f9 100644 --- a/api/tacticalrmm/ee/sso/urls.py +++ b/api/tacticalrmm/ee/sso/urls.py @@ -1,5 +1,5 @@ """ -Copyright (c) 2023-present Amidaware Inc. +Copyright (c) 2024-present Amidaware Inc. This file is subject to the EE License Agreement. For details, see: https://license.tacticalrmm.com/ee """ diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index 4bddd72986..c4bbb329b8 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -1,5 +1,5 @@ """ -Copyright (c) 2023-present Amidaware Inc. +Copyright (c) 2024-present Amidaware Inc. This file is subject to the EE License Agreement. For details, see: https://license.tacticalrmm.com/ee """ From 038304384af8c3e7ff6f994c395e455039fb3f91 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Fri, 25 Oct 2024 03:33:40 +0000 Subject: [PATCH 25/50] move sso settings --- api/tacticalrmm/ee/sso/sso_settings.py | 18 ++++++++++++++++++ api/tacticalrmm/tacticalrmm/settings.py | 15 ++------------- 2 files changed, 20 insertions(+), 13 deletions(-) create mode 100644 api/tacticalrmm/ee/sso/sso_settings.py diff --git a/api/tacticalrmm/ee/sso/sso_settings.py b/api/tacticalrmm/ee/sso/sso_settings.py new file mode 100644 index 0000000000..82a49b9627 --- /dev/null +++ b/api/tacticalrmm/ee/sso/sso_settings.py @@ -0,0 +1,18 @@ +""" +Copyright (c) 2024-present Amidaware Inc. +This file is subject to the EE License Agreement. +For details, see: https://license.tacticalrmm.com/ee +""" + +HEADLESS_ONLY = True +SOCIALACCOUNT_ONLY = True +ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" +ACCOUNT_EMAIL_VERIFICATION = "none" +SOCIALACCOUNT_ADAPTER = "ee.sso.adapter.TacticalSocialAdapter" +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 diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index 4f211ceb18..9b9cd95588 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -117,19 +117,8 @@ TRMM_LOG_LEVEL = "ERROR" TRMM_LOG_TO = "file" -# settings for django all auth -HEADLESS_ONLY = True -SOCIALACCOUNT_ONLY = True -ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" -ACCOUNT_EMAIL_VERIFICATION = "none" -SOCIALACCOUNT_ADAPTER = "ee.sso.adapter.TacticalSocialAdapter" -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 +with suppress(ImportError): + from ee.sso.sso_settings import * # noqa with suppress(ImportError): from .local_settings import * # noqa From 0d021a800af139e1733e48db304ad084de0b311c Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Fri, 25 Oct 2024 03:34:07 +0000 Subject: [PATCH 26/50] use exists --- api/tacticalrmm/ee/sso/permissions.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/tacticalrmm/ee/sso/permissions.py b/api/tacticalrmm/ee/sso/permissions.py index 3b31318a53..c98f773348 100644 --- a/api/tacticalrmm/ee/sso/permissions.py +++ b/api/tacticalrmm/ee/sso/permissions.py @@ -10,5 +10,4 @@ class SSOLoginPerms(permissions.BasePermission): def has_permission(self, r, view): - connected_apps = SocialAccount.objects.filter(user=r.user) - return len(connected_apps) > 0 + return SocialAccount.objects.filter(user=r.user).exists() From 0f86bbfad846ebc44a47505454d8bd681f1f33df Mon Sep 17 00:00:00 2001 From: sadnub Date: Tue, 29 Oct 2024 11:17:42 -0400 Subject: [PATCH 27/50] disable password/mfa reset views if block_local_logon is enabled --- api/tacticalrmm/accounts/permissions.py | 8 +++++++- api/tacticalrmm/accounts/views.py | 8 ++++---- api/tacticalrmm/core/models.py | 7 +++++++ api/tacticalrmm/core/views.py | 2 ++ 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/api/tacticalrmm/accounts/permissions.py b/api/tacticalrmm/accounts/permissions.py index e9e809ecb3..dca10baeee 100644 --- a/api/tacticalrmm/accounts/permissions.py +++ b/api/tacticalrmm/accounts/permissions.py @@ -1,7 +1,7 @@ from rest_framework import permissions from tacticalrmm.permissions import _has_perm - +from tacticalrmm.utils import get_core_settings class AccountsPerms(permissions.BasePermission): def has_permission(self, r, view) -> bool: @@ -40,3 +40,9 @@ def has_permission(self, r, view) -> bool: return _has_perm(r, "can_list_api_keys") return _has_perm(r, "can_manage_api_keys") + + +class LocalUserPerms(permissions.BasePermission): + def has_permission(self, r, view) -> bool: + settings = get_core_settings() + return not settings.block_local_user_logon \ No newline at end of file diff --git a/api/tacticalrmm/accounts/views.py b/api/tacticalrmm/accounts/views.py index 0818d72376..1d7532f446 100644 --- a/api/tacticalrmm/accounts/views.py +++ b/api/tacticalrmm/accounts/views.py @@ -25,7 +25,7 @@ from tacticalrmm.utils import get_core_settings from .models import APIKey, Role, User -from .permissions import AccountsPerms, APIKeyPerms, RolesPerms +from .permissions import AccountsPerms, APIKeyPerms, RolesPerms, LocalUserPerms from .serializers import ( APIKeySerializer, RoleSerializer, @@ -381,7 +381,7 @@ def delete(self, request, pk): class UserActions(APIView): - permission_classes = [IsAuthenticated, AccountsPerms] + permission_classes = [IsAuthenticated, AccountsPerms, LocalUserPerms] # reset password def post(self, request): @@ -507,7 +507,7 @@ def delete(self, request, pk): class ResetPass(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, LocalUserPerms] def put(self, request): user = request.user @@ -517,7 +517,7 @@ def put(self, request): class Reset2FA(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, LocalUserPerms] def put(self, request): user = request.user diff --git a/api/tacticalrmm/core/models.py b/api/tacticalrmm/core/models.py index 0e3ef1a2bc..9f5499119f 100644 --- a/api/tacticalrmm/core/models.py +++ b/api/tacticalrmm/core/models.py @@ -130,6 +130,13 @@ def save(self, *args, **kwargs) -> None: self.mesh_token = settings.MESH_TOKEN_KEY old_settings = type(self).objects.get(pk=self.pk) if self.pk else None + + print(old_settings.__dict__) + # fail safe to not lock out user logons + if not old_settings.sso_enabled and old_settings.block_local_user_logon: + self.block_local_user_logon = False + print("I'm Here!") + super().save(*args, **kwargs) if old_settings: diff --git a/api/tacticalrmm/core/views.py b/api/tacticalrmm/core/views.py index d2915fa9bd..eff8147a51 100644 --- a/api/tacticalrmm/core/views.py +++ b/api/tacticalrmm/core/views.py @@ -137,6 +137,8 @@ def dashboard_info(request): "run_cmd_placeholder_text": runcmd_placeholder_text(), "server_scripts_enabled": core_settings.server_scripts_enabled, "web_terminal_enabled": core_settings.web_terminal_enabled, + "block_local_user_logon": core_settings.block_local_user_logon, + "sso_enabled": core_settings.sso_enabled, } ) From 18b1afe34fa327eff6d523b08c19c9252210c8b1 Mon Sep 17 00:00:00 2001 From: sadnub Date: Tue, 29 Oct 2024 11:40:05 -0400 Subject: [PATCH 28/50] formatting --- api/tacticalrmm/accounts/permissions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/tacticalrmm/accounts/permissions.py b/api/tacticalrmm/accounts/permissions.py index dca10baeee..19206b7c6c 100644 --- a/api/tacticalrmm/accounts/permissions.py +++ b/api/tacticalrmm/accounts/permissions.py @@ -3,6 +3,7 @@ from tacticalrmm.permissions import _has_perm from tacticalrmm.utils import get_core_settings + class AccountsPerms(permissions.BasePermission): def has_permission(self, r, view) -> bool: if r.method == "GET": @@ -45,4 +46,4 @@ def has_permission(self, r, view) -> bool: class LocalUserPerms(permissions.BasePermission): def has_permission(self, r, view) -> bool: settings = get_core_settings() - return not settings.block_local_user_logon \ No newline at end of file + return not settings.block_local_user_logon From 8d543dcc7db11f63e857ac9d893ac5b97420e2aa Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Tue, 29 Oct 2024 20:03:10 +0000 Subject: [PATCH 29/50] move inside if block --- api/tacticalrmm/core/models.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/api/tacticalrmm/core/models.py b/api/tacticalrmm/core/models.py index 9f5499119f..363803a5c5 100644 --- a/api/tacticalrmm/core/models.py +++ b/api/tacticalrmm/core/models.py @@ -131,15 +131,13 @@ def save(self, *args, **kwargs) -> None: old_settings = type(self).objects.get(pk=self.pk) if self.pk else None - print(old_settings.__dict__) - # fail safe to not lock out user logons - if not old_settings.sso_enabled and old_settings.block_local_user_logon: - self.block_local_user_logon = False - print("I'm Here!") - super().save(*args, **kwargs) if old_settings: + # fail safe to not lock out user logons + if not old_settings.sso_enabled and old_settings.block_local_user_logon: + self.block_local_user_logon = False + if ( old_settings.alert_template != self.alert_template or old_settings.server_policy != self.server_policy From 2cbecaa55287b4e9d95d8c6dca217a8a77920bb0 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Wed, 30 Oct 2024 05:13:35 +0000 Subject: [PATCH 30/50] don't show providers list on login screen if sso is disabled globally --- api/tacticalrmm/ee/sso/adapter.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/api/tacticalrmm/ee/sso/adapter.py b/api/tacticalrmm/ee/sso/adapter.py index d649ab3464..8937f0ebe4 100644 --- a/api/tacticalrmm/ee/sso/adapter.py +++ b/api/tacticalrmm/ee/sso/adapter.py @@ -6,21 +6,35 @@ from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.models import SocialApp +from django.core.exceptions import PermissionDenied from accounts.models import Role +from core.utils import token_is_valid +from tacticalrmm.logger import logger +from tacticalrmm.utils import get_core_settings class TacticalSocialAdapter(DefaultSocialAccountAdapter): def populate_user(self, request, sociallogin, data): + _, valid = token_is_valid() + if not valid: + raise PermissionDenied() user = super().populate_user(request, sociallogin, data) try: provider = sociallogin.account.get_provider() provider_settings = SocialApp.objects.get(provider_id=provider).settings user.role = Role.objects.get(pk=provider_settings["role"]) - except: - print( + except Exception: + logger.debug( "Provider settings or Role not found. Continuing with blank permissions." ) return user + + def list_providers(self, request): + core_settings = get_core_settings() + if not core_settings.sso_enabled: + return [] + + return super().list_providers(request) From 41e3d1f4906bef304b9936686b5592c4ef70119f Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Wed, 30 Oct 2024 23:07:14 +0000 Subject: [PATCH 31/50] move check to signup --- api/tacticalrmm/ee/sso/adapter.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/api/tacticalrmm/ee/sso/adapter.py b/api/tacticalrmm/ee/sso/adapter.py index 8937f0ebe4..dbd178fadb 100644 --- a/api/tacticalrmm/ee/sso/adapter.py +++ b/api/tacticalrmm/ee/sso/adapter.py @@ -17,10 +17,6 @@ class TacticalSocialAdapter(DefaultSocialAccountAdapter): def populate_user(self, request, sociallogin, data): - _, valid = token_is_valid() - if not valid: - raise PermissionDenied() - user = super().populate_user(request, sociallogin, data) try: provider = sociallogin.account.get_provider() @@ -32,6 +28,13 @@ def populate_user(self, request, sociallogin, data): ) return user + def is_open_for_signup(self, request, sociallogin): + _, valid = token_is_valid() + if not valid: + raise PermissionDenied() + + return super().is_open_for_signup(request, sociallogin) + def list_providers(self, request): core_settings = get_core_settings() if not core_settings.sso_enabled: From a6166a1ad7bfce8f7e8e7b650dee525ca3c6bb5d Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Thu, 31 Oct 2024 01:25:20 +0000 Subject: [PATCH 32/50] add random otp to social accounts --- api/tacticalrmm/ee/sso/adapter.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/tacticalrmm/ee/sso/adapter.py b/api/tacticalrmm/ee/sso/adapter.py index dbd178fadb..262325ebf5 100644 --- a/api/tacticalrmm/ee/sso/adapter.py +++ b/api/tacticalrmm/ee/sso/adapter.py @@ -4,6 +4,7 @@ For details, see: https://license.tacticalrmm.com/ee """ +import pyotp from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.models import SocialApp from django.core.exceptions import PermissionDenied @@ -26,6 +27,7 @@ def populate_user(self, request, sociallogin, data): logger.debug( "Provider settings or Role not found. Continuing with blank permissions." ) + user.totp_key = pyotp.random_base32() # not actually used return user def is_open_for_signup(self, request, sociallogin): From ec0a2dc0530e521e5d2c0622ceae2c397d337a85 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Thu, 31 Oct 2024 19:06:39 +0000 Subject: [PATCH 33/50] handle deployment config updates --- .devcontainer/entrypoint.sh | 7 ++-- .../core/management/commands/get_config.py | 6 ++++ .../management/commands/post_update_tasks.py | 36 +++++++++++++++++++ .../management/commands/get_webtar_url.py | 33 ++--------------- api/tacticalrmm/ee/sso/sso_settings.py | 1 + api/tacticalrmm/requirements.txt | 1 + api/tacticalrmm/tacticalrmm/settings.py | 3 +- docker/containers/tactical/entrypoint.sh | 6 ++-- install.sh | 13 ++++++- restore.sh | 24 +++++++++++-- update.sh | 19 +++++++++- 11 files changed, 102 insertions(+), 47 deletions(-) diff --git a/.devcontainer/entrypoint.sh b/.devcontainer/entrypoint.sh index 193a01859d..a3235aa94a 100644 --- a/.devcontainer/entrypoint.sh +++ b/.devcontainer/entrypoint.sh @@ -50,7 +50,7 @@ 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}') + BASE_DOMAIN=$(echo "import tldextract; no_fetch_extract = tldextract.TLDExtract(suffix_list_urls=()); extracted = no_fetch_extract('${API_HOST}'); print(f'{extracted.domain}.{extracted.suffix}')" | python) localvars="$( cat < None: self.stdout.write("Running post update tasks") + # for 0.20.0 release + if not settings.DOCKER_BUILD: + needs_frontend = False + frontend_domain = get_webdomain().split(":")[0] + + local_settings = os.path.join( + settings.BASE_DIR, "tacticalrmm", "local_settings.py" + ) + + with open(local_settings) as f: + lines = f.readlines() + + modified_lines = [] + for line in lines: + if line.strip().startswith("ALLOWED_HOSTS"): + exec(line, globals()) + + if frontend_domain not in settings.ALLOWED_HOSTS: + needs_frontend = True + settings.ALLOWED_HOSTS.append(frontend_domain) + + line = f"ALLOWED_HOSTS = {settings.ALLOWED_HOSTS}\n" + + modified_lines.append(line) + + if needs_frontend: + backup = Path.home() / (Path("local_settings_0.20.0.bak")) + shutil.copy2(local_settings, backup) + with open(local_settings, "w") as f: + f.writelines(modified_lines) + # load community scripts into the db Script.load_community_scripts() diff --git a/api/tacticalrmm/ee/reporting/management/commands/get_webtar_url.py b/api/tacticalrmm/ee/reporting/management/commands/get_webtar_url.py index 626115a528..5931f9d281 100644 --- a/api/tacticalrmm/ee/reporting/management/commands/get_webtar_url.py +++ b/api/tacticalrmm/ee/reporting/management/commands/get_webtar_url.py @@ -5,10 +5,8 @@ """ import urllib.parse -from time import sleep from typing import Any, Optional -import requests from core.models import CodeSignToken from django.conf import settings from django.core.management.base import BaseCommand @@ -26,39 +24,12 @@ def handle(self, *args: tuple[Any, Any], **kwargs: dict[str, Any]) -> None: self.stdout.write(url) return - attempts = 0 - while 1: - try: - r = requests.post( - settings.REPORTING_CHECK_URL, - json={"token": t.token, "api": settings.ALLOWED_HOSTS[0]}, - headers={"Content-type": "application/json"}, - timeout=15, - ) - except Exception as e: - self.stderr.write(str(e)) - attempts += 1 - sleep(3) - else: - if r.status_code // 100 in (3, 5): - self.stderr.write(f"Error getting web tarball: {r.status_code}") - attempts += 1 - sleep(3) - else: - attempts = 0 - - if attempts == 0: - break - elif attempts > 5: - self.stdout.write(url) - return - - if r.status_code == 200: # type: ignore + if t.is_valid: params = { "token": t.token, "webver": settings.WEB_VERSION, "api": settings.ALLOWED_HOSTS[0], } - url = settings.REPORTING_DL_URL + urllib.parse.urlencode(params) + url = settings.WEBTAR_DL_URL + urllib.parse.urlencode(params) self.stdout.write(url) diff --git a/api/tacticalrmm/ee/sso/sso_settings.py b/api/tacticalrmm/ee/sso/sso_settings.py index 82a49b9627..e0111b933e 100644 --- a/api/tacticalrmm/ee/sso/sso_settings.py +++ b/api/tacticalrmm/ee/sso/sso_settings.py @@ -16,3 +16,4 @@ SOCIALACCOUNT_PROVIDERS = {"openid_connect": {"OAUTH_PKCE_ENABLED": True}} SESSION_COOKIE_SECURE = True +CORS_ALLOW_CREDENTIALS = True diff --git a/api/tacticalrmm/requirements.txt b/api/tacticalrmm/requirements.txt index f90ab46302..0a6d3e6485 100644 --- a/api/tacticalrmm/requirements.txt +++ b/api/tacticalrmm/requirements.txt @@ -30,6 +30,7 @@ redis==5.0.8 requests==2.32.3 six==1.16.0 sqlparse==0.5.1 +tldextract==5.1.2 twilio==8.13.0 urllib3==2.2.3 uvicorn[standard]==0.31.1 diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index 9b9cd95588..fa11bbe305 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -126,8 +126,7 @@ CHECK_TOKEN_URL = f"{AGENT_BASE_URL}/api/v2/checktoken" AGENTS_URL = f"{AGENT_BASE_URL}/api/v2/agents/?" EXE_GEN_URL = f"{AGENT_BASE_URL}/api/v2/exe" -REPORTING_CHECK_URL = f"{AGENT_BASE_URL}/api/v2/reporting/check" -REPORTING_DL_URL = f"{AGENT_BASE_URL}/api/v2/reporting/download/?" +WEBTAR_DL_URL = f"{AGENT_BASE_URL}/api/v2/webtar/?" if "GHACTIONS" in os.environ: DEBUG = False diff --git a/docker/containers/tactical/entrypoint.sh b/docker/containers/tactical/entrypoint.sh index 8a914d7e55..eb35a71293 100644 --- a/docker/containers/tactical/entrypoint.sh +++ b/docker/containers/tactical/entrypoint.sh @@ -71,6 +71,7 @@ if [ "$1" = 'tactical-init' ]; then MESH_TOKEN=$(cat ${TACTICAL_DIR}/tmp/mesh_token) ADMINURL=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 70 | head -n 1) DJANGO_SEKRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 80 | head -n 1) + BASE_DOMAIN=$(echo "import tldextract; no_fetch_extract = tldextract.TLDExtract(suffix_list_urls=()); extracted = no_fetch_extract('${API_HOST}'); print(f'{extracted.domain}.{extracted.suffix}')" | python) localvars="$( cat <&2 "${YELLOW}%0.s*${NC}" {1..80} printf >&2 "\n" printf >&2 "${YELLOW}Please create your login for the RMM website${NC}\n" @@ -585,6 +586,16 @@ python manage.py generate_barcode ${RANDBASE} ${djangousername} ${frontenddomain deactivate read -n 1 -s -r -p "Press any key to continue..." +allauth="$( + cat </dev/null + rmmservice="$( cat <${TMP_FILE} @@ -445,8 +446,8 @@ sudo chmod +x /usr/local/bin/nats-api print_green 'Restoring the trmm database' -pgusername=$(grep -w USER /rmm/api/tacticalrmm/tacticalrmm/local_settings.py | sed 's/^.*: //' | sed 's/.//' | sed -r 's/.{2}$//') -pgpw=$(grep -w PASSWORD /rmm/api/tacticalrmm/tacticalrmm/local_settings.py | sed 's/^.*: //' | sed 's/.//' | sed -r 's/.{2}$//') +pgusername=$(grep -w USER $local_settings | sed 's/^.*: //' | sed 's/.//' | sed -r 's/.{2}$//') +pgpw=$(grep -w PASSWORD $local_settings | sed 's/^.*: //' | sed 's/.//' | sed -r 's/.{2}$//') sudo -iu postgres psql -c "CREATE DATABASE tacticalrmm" sudo -iu postgres psql -c "CREATE USER ${pgusername} WITH PASSWORD '${pgpw}'" @@ -500,6 +501,23 @@ CERT_PUB_KEY=$(python manage.py get_config certfile) CERT_PRIV_KEY=$(python manage.py get_config keyfile) deactivate +HAS_ALLAUTH=$(grep HEADLESS_FRONTEND_URLS $local_settings) +if ! [[ $HAS_ALLAUTH ]]; then + source /rmm/api/env/bin/activate + cd /rmm/api/tacticalrmm + ROOT_DOMAIN=$(python manage.py get_config rootdomain) + deactivate + allauth="$( + cat </dev/null +fi + print_green 'Restoring hosts file' if grep -q manage_etc_hosts /etc/hosts; then diff --git a/update.sh b/update.sh index 31f84f16cf..8f129e7743 100644 --- a/update.sh +++ b/update.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -SCRIPT_VERSION="154" +SCRIPT_VERSION="155" SCRIPT_URL='https://raw.githubusercontent.com/amidaware/tacticalrmm/master/update.sh' LATEST_SETTINGS_URL='https://raw.githubusercontent.com/amidaware/tacticalrmm/master/api/tacticalrmm/tacticalrmm/settings.py' YELLOW='\033[1;33m' @@ -452,6 +452,23 @@ CERT_PUB_KEY=$(python manage.py get_config certfile) CERT_PRIV_KEY=$(python manage.py get_config keyfile) deactivate +HAS_ALLAUTH=$(grep HEADLESS_FRONTEND_URLS $local_settings) +if ! [[ $HAS_ALLAUTH ]]; then + source /rmm/api/env/bin/activate + cd /rmm/api/tacticalrmm + ROOT_DOMAIN=$(python manage.py get_config rootdomain) + deactivate + allauth="$( + cat </dev/null +fi + if grep -q manage_etc_hosts /etc/hosts; then sudo sed -i '/manage_etc_hosts: true/d' /etc/cloud/cloud.cfg >/dev/null if ! grep -q "manage_etc_hosts: false" /etc/cloud/cloud.cfg; then From cc1f640a502231683ebb1328f0b6c90ceda03c41 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Fri, 1 Nov 2024 17:42:51 +0000 Subject: [PATCH 34/50] set icon based on provider --- api/tacticalrmm/ee/sso/middleware.py | 64 +++++++++++++++++++++++++ api/tacticalrmm/tacticalrmm/settings.py | 1 + 2 files changed, 65 insertions(+) create mode 100644 api/tacticalrmm/ee/sso/middleware.py diff --git a/api/tacticalrmm/ee/sso/middleware.py b/api/tacticalrmm/ee/sso/middleware.py new file mode 100644 index 0000000000..63a205ba1a --- /dev/null +++ b/api/tacticalrmm/ee/sso/middleware.py @@ -0,0 +1,64 @@ +""" +Copyright (c) 2024-present Amidaware Inc. +This file is subject to the EE License Agreement. +For details, see: https://license.tacticalrmm.com/ee +""" + +import json +from contextlib import suppress + +from allauth.headless.base.response import ConfigResponse +from allauth.socialaccount.models import SocialApp + + +def set_provider_icon(provider, url): + icon_map = { + "google.com": "mdi-google", + "microsoft": "mdi-microsoft", + "discord.com": "fa-brands fa-discord", + "github.com": "fa-brands fa-github", + "slack.com": "fa-brands fa-slack", + "facebook.com": "fa-brands fa-facebook", + "linkedin.com": "fa-brands fa-linkedin", + "apple.com": "fa-brands fa-apple", + "amazon.com": "fa-brands fa-amazon", + "auth0.com": "mdi-lock", + "gitlab.com": "fa-brands fa-gitlab", + "twitter.com": "fa-brands fa-twitter", + "paypal.com": "fa-brands fa-paypal", + "yahoo.com": "fa-brands fa-yahoo", + } + + provider["icon"] = "mdi-key" + + for key, icon in icon_map.items(): + if key in url.lower(): + provider["icon"] = icon + break + + +class SSOIconMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + + if request.path == "/_allauth/browser/v1/config/" and isinstance( + response, ConfigResponse + ): + with suppress(Exception): + data = json.loads(response.content.decode("utf-8", "ignore")) + + data["data"].pop("account") + for provider in data["data"]["socialaccount"].get("providers", []): + provider.pop("client_id", None) + provider.pop("flows", None) + app = SocialApp.objects.get(provider_id=provider["id"]) + set_provider_icon(provider, app.settings["server_url"]) + + new_content = json.dumps(data) + response.content = new_content.encode("utf-8", "ignore") + response["Content-Length"] = str(len(response.content)) + + return response diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index fa11bbe305..fd9a2fdb70 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -226,6 +226,7 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "tacticalrmm.middleware.AuditMiddleware", "allauth.account.middleware.AccountMiddleware", + "ee.sso.middleware.SSOIconMiddleware", ] if SWAGGER_ENABLED: From 3851b0943a890eab8713747d5d9afb8b90892b16 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Sun, 3 Nov 2024 06:17:04 +0000 Subject: [PATCH 35/50] modify settings instead of local_settings --- .../core/management/commands/get_config.py | 8 ++--- .../management/commands/post_update_tasks.py | 36 ------------------- api/tacticalrmm/tacticalrmm/helpers.py | 7 ++++ api/tacticalrmm/tacticalrmm/settings.py | 27 ++++++++++++++ install.sh | 11 ------ restore.sh | 17 --------- update.sh | 17 --------- 7 files changed, 36 insertions(+), 87 deletions(-) diff --git a/api/tacticalrmm/core/management/commands/get_config.py b/api/tacticalrmm/core/management/commands/get_config.py index b83f894e69..0a76344fc3 100644 --- a/api/tacticalrmm/core/management/commands/get_config.py +++ b/api/tacticalrmm/core/management/commands/get_config.py @@ -3,7 +3,7 @@ from django.conf import settings from django.core.management.base import BaseCommand -from tacticalrmm.helpers import get_webdomain +from tacticalrmm.helpers import get_root_domain, get_webdomain from tacticalrmm.utils import get_certs @@ -18,11 +18,7 @@ def handle(self, *args, **kwargs): case "api": self.stdout.write(settings.ALLOWED_HOSTS[0]) case "rootdomain": - import tldextract - - no_fetch_extract = tldextract.TLDExtract(suffix_list_urls=()) - extracted = no_fetch_extract(settings.ALLOWED_HOSTS[0]) - self.stdout.write(f"{extracted.domain}.{extracted.suffix}") + self.stdout.write(get_root_domain(settings.ALLOWED_HOSTS[0])) case "version": self.stdout.write(settings.TRMM_VERSION) case "webversion": diff --git a/api/tacticalrmm/core/management/commands/post_update_tasks.py b/api/tacticalrmm/core/management/commands/post_update_tasks.py index 03b0be627f..ea8f0409b7 100644 --- a/api/tacticalrmm/core/management/commands/post_update_tasks.py +++ b/api/tacticalrmm/core/management/commands/post_update_tasks.py @@ -1,9 +1,5 @@ import base64 -import os -import shutil -from pathlib import Path -from django.conf import settings from django.core.management.base import BaseCommand from accounts.models import User @@ -14,7 +10,6 @@ from core.tasks import remove_orphaned_history_results, sync_mesh_perms_task from scripts.models import Script from tacticalrmm.constants import AGENT_DEFER, ScriptType -from tacticalrmm.helpers import get_webdomain class Command(BaseCommand): @@ -23,37 +18,6 @@ class Command(BaseCommand): def handle(self, *args, **kwargs) -> None: self.stdout.write("Running post update tasks") - # for 0.20.0 release - if not settings.DOCKER_BUILD: - needs_frontend = False - frontend_domain = get_webdomain().split(":")[0] - - local_settings = os.path.join( - settings.BASE_DIR, "tacticalrmm", "local_settings.py" - ) - - with open(local_settings) as f: - lines = f.readlines() - - modified_lines = [] - for line in lines: - if line.strip().startswith("ALLOWED_HOSTS"): - exec(line, globals()) - - if frontend_domain not in settings.ALLOWED_HOSTS: - needs_frontend = True - settings.ALLOWED_HOSTS.append(frontend_domain) - - line = f"ALLOWED_HOSTS = {settings.ALLOWED_HOSTS}\n" - - modified_lines.append(line) - - if needs_frontend: - backup = Path.home() / (Path("local_settings_0.20.0.bak")) - shutil.copy2(local_settings, backup) - with open(local_settings, "w") as f: - f.writelines(modified_lines) - # load community scripts into the db Script.load_community_scripts() diff --git a/api/tacticalrmm/tacticalrmm/helpers.py b/api/tacticalrmm/tacticalrmm/helpers.py index fedb96b12a..fbd9f796f6 100644 --- a/api/tacticalrmm/tacticalrmm/helpers.py +++ b/api/tacticalrmm/tacticalrmm/helpers.py @@ -9,6 +9,7 @@ from urllib.parse import urlparse from zoneinfo import ZoneInfo +import tldextract from cryptography import x509 from django.conf import settings from django.utils import timezone as djangotime @@ -107,6 +108,12 @@ def get_webdomain() -> str: return urlparse(settings.CORS_ORIGIN_WHITELIST[0]).netloc +def get_root_domain(subdomain) -> str: + no_fetch_extract = tldextract.TLDExtract(suffix_list_urls=()) + extracted = no_fetch_extract(subdomain) + return f"{extracted.domain}.{extracted.suffix}" + + def rand_range(min: int, max: int) -> float: """ Input is milliseconds. diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index fd9a2fdb70..c85a68638e 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -3,6 +3,7 @@ from contextlib import suppress from datetime import timedelta from pathlib import Path +from tacticalrmm.helpers import get_root_domain BASE_DIR = Path(__file__).resolve().parent.parent @@ -117,12 +118,38 @@ TRMM_LOG_LEVEL = "ERROR" TRMM_LOG_TO = "file" +if not DOCKER_BUILD: + ALLOWED_HOSTS = [] + CORS_ORIGIN_WHITELIST = [] + TRMM_PROTO = "https" + TRMM_BACKEND_PORT = None + with suppress(ImportError): from ee.sso.sso_settings import * # noqa with suppress(ImportError): from .local_settings import * # noqa +if not DOCKER_BUILD: + + TRMM_ROOT_DOMAIN = get_root_domain(ALLOWED_HOSTS[0]) + + ALLOWED_HOSTS.append(TRMM_ROOT_DOMAIN) + + if DEBUG: + ALLOWED_HOSTS.append("*") + + backend_url = f"{TRMM_PROTO}://{ALLOWED_HOSTS[0]}" + if TRMM_BACKEND_PORT: + backend_url = f"{backend_url}:{TRMM_BACKEND_PORT}" + + SESSION_COOKIE_DOMAIN = TRMM_ROOT_DOMAIN + CSRF_COOKIE_DOMAIN = TRMM_ROOT_DOMAIN + CSRF_TRUSTED_ORIGINS = [CORS_ORIGIN_WHITELIST[0], backend_url] + HEADLESS_FRONTEND_URLS = { + "socialaccount_login_error": f"{CORS_ORIGIN_WHITELIST[0]}/account/provider/callback" + } + CHECK_TOKEN_URL = f"{AGENT_BASE_URL}/api/v2/checktoken" AGENTS_URL = f"{AGENT_BASE_URL}/api/v2/agents/?" EXE_GEN_URL = f"{AGENT_BASE_URL}/api/v2/exe" diff --git a/install.sh b/install.sh index d096a69ea4..a311a1fd4b 100644 --- a/install.sh +++ b/install.sh @@ -570,7 +570,6 @@ python manage.py load_chocos python manage.py load_community_scripts WEB_VERSION=$(python manage.py get_config webversion) WEBTAR_URL=$(python manage.py get_webtar_url) -ROOT_DOMAIN=$(python manage.py get_config rootdomain) printf >&2 "${YELLOW}%0.s*${NC}" {1..80} printf >&2 "\n" printf >&2 "${YELLOW}Please create your login for the RMM website${NC}\n" @@ -586,16 +585,6 @@ python manage.py generate_barcode ${RANDBASE} ${djangousername} ${frontenddomain deactivate read -n 1 -s -r -p "Press any key to continue..." -allauth="$( - cat </dev/null - rmmservice="$( cat </dev/null -fi - print_green 'Restoring hosts file' if grep -q manage_etc_hosts /etc/hosts; then diff --git a/update.sh b/update.sh index 8f129e7743..8156648431 100644 --- a/update.sh +++ b/update.sh @@ -452,23 +452,6 @@ CERT_PUB_KEY=$(python manage.py get_config certfile) CERT_PRIV_KEY=$(python manage.py get_config keyfile) deactivate -HAS_ALLAUTH=$(grep HEADLESS_FRONTEND_URLS $local_settings) -if ! [[ $HAS_ALLAUTH ]]; then - source /rmm/api/env/bin/activate - cd /rmm/api/tacticalrmm - ROOT_DOMAIN=$(python manage.py get_config rootdomain) - deactivate - allauth="$( - cat </dev/null -fi - if grep -q manage_etc_hosts /etc/hosts; then sudo sed -i '/manage_etc_hosts: true/d' /etc/cloud/cloud.cfg >/dev/null if ! grep -q "manage_etc_hosts: false" /etc/cloud/cloud.cfg; then From 5bec4768e7410017c6b157ee56495566ca1070ba Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Sun, 3 Nov 2024 06:22:33 +0000 Subject: [PATCH 36/50] forgot frontend --- api/tacticalrmm/tacticalrmm/settings.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index c85a68638e..d4b83e36db 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -3,7 +3,7 @@ from contextlib import suppress from datetime import timedelta from pathlib import Path -from tacticalrmm.helpers import get_root_domain +from tacticalrmm.helpers import get_root_domain, get_webdomain BASE_DIR = Path(__file__).resolve().parent.parent @@ -133,8 +133,9 @@ if not DOCKER_BUILD: TRMM_ROOT_DOMAIN = get_root_domain(ALLOWED_HOSTS[0]) + frontend_domain = get_webdomain().split(":")[0] - ALLOWED_HOSTS.append(TRMM_ROOT_DOMAIN) + ALLOWED_HOSTS.append(frontend_domain) if DEBUG: ALLOWED_HOSTS.append("*") From 9624af4e670e3830cc6f3c0132426cb59a25e99a Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Sun, 3 Nov 2024 08:47:40 +0000 Subject: [PATCH 37/50] fix tests --- api/tacticalrmm/tacticalrmm/settings.py | 43 ++++++++++++------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index d4b83e36db..3f9a5e1cf3 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -130,6 +130,27 @@ with suppress(ImportError): from .local_settings import * # noqa +if "GHACTIONS" in os.environ: + print("-----------------------GHACTIONS----------------------------") + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "pipeline", + "USER": "pipeline", + "PASSWORD": "pipeline123456", + "HOST": "127.0.0.1", + "PORT": "", + } + } + SECRET_KEY = "abcdefghijklmnoptravis123456789" + ALLOWED_HOSTS = ["api.example.com"] + ADMIN_URL = "abc123456/" + CORS_ORIGIN_WHITELIST = ["https://rmm.example.com"] + MESH_USERNAME = "pipeline" + MESH_SITE = "https://example.com" + MESH_TOKEN_KEY = "bd65e957a1e70c622d32523f61508400d6cd0937001a7ac12042227eba0b9ed625233851a316d4f489f02994145f74537a331415d00047dbbf13d940f556806dffe7a8ce1de216dc49edbad0c1a7399c" + REDIS_HOST = "localhost" + if not DOCKER_BUILD: TRMM_ROOT_DOMAIN = get_root_domain(ALLOWED_HOSTS[0]) @@ -365,25 +386,3 @@ def configure_logging_handler(): "trmm": {"handlers": ["trmm"], "level": get_log_level(), "propagate": False}, }, } - - -if "GHACTIONS" in os.environ: - print("-----------------------GHACTIONS----------------------------") - DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": "pipeline", - "USER": "pipeline", - "PASSWORD": "pipeline123456", - "HOST": "127.0.0.1", - "PORT": "", - } - } - SECRET_KEY = "abcdefghijklmnoptravis123456789" - ALLOWED_HOSTS = ["api.example.com"] - ADMIN_URL = "abc123456/" - CORS_ORIGIN_WHITELIST = ["https://rmm.example.com"] - MESH_USERNAME = "pipeline" - MESH_SITE = "https://example.com" - MESH_TOKEN_KEY = "bd65e957a1e70c622d32523f61508400d6cd0937001a7ac12042227eba0b9ed625233851a316d4f489f02994145f74537a331415d00047dbbf13d940f556806dffe7a8ce1de216dc49edbad0c1a7399c" - REDIS_HOST = "localhost" From f8314e0f8e3f3e7d33f9408678cf4a5d5b1128a7 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Mon, 4 Nov 2024 18:57:09 +0000 Subject: [PATCH 38/50] fix pop --- api/tacticalrmm/ee/sso/middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tacticalrmm/ee/sso/middleware.py b/api/tacticalrmm/ee/sso/middleware.py index 63a205ba1a..ee0872db68 100644 --- a/api/tacticalrmm/ee/sso/middleware.py +++ b/api/tacticalrmm/ee/sso/middleware.py @@ -50,7 +50,7 @@ def __call__(self, request): with suppress(Exception): data = json.loads(response.content.decode("utf-8", "ignore")) - data["data"].pop("account") + data["data"].pop("account", None) for provider in data["data"]["socialaccount"].get("providers", []): provider.pop("client_id", None) provider.pop("flows", None) From 4a5bfee616d95ebf533915120e4d413d9807e6e8 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Mon, 4 Nov 2024 20:28:01 +0000 Subject: [PATCH 39/50] fix failsafe to ensure no lockouts and add self-reset sso perms --- api/tacticalrmm/accounts/models.py | 5 ++++ api/tacticalrmm/accounts/permissions.py | 5 ++++ api/tacticalrmm/accounts/views.py | 31 ++++++++++++++++--------- api/tacticalrmm/core/models.py | 20 ++++++++++++---- api/tacticalrmm/ee/sso/views.py | 18 ++++++++++---- 5 files changed, 58 insertions(+), 21 deletions(-) diff --git a/api/tacticalrmm/accounts/models.py b/api/tacticalrmm/accounts/models.py index ee66f8a640..1d42f1043b 100644 --- a/api/tacticalrmm/accounts/models.py +++ b/api/tacticalrmm/accounts/models.py @@ -1,5 +1,6 @@ from typing import Optional +from allauth.socialaccount.models import SocialAccount from django.contrib.auth.models import AbstractUser from django.core.cache import cache from django.db import models @@ -73,6 +74,10 @@ def mesh_username(self): # lower() needed for mesh api return f"{self.username.replace(' ', '').lower()}___{self.pk}" + @property + def is_sso_user(self): + return SocialAccount.objects.filter(user_id=self.pk).exists() + @staticmethod def serialize(user): # serializes the task and returns json diff --git a/api/tacticalrmm/accounts/permissions.py b/api/tacticalrmm/accounts/permissions.py index 19206b7c6c..3bbb1f5cd2 100644 --- a/api/tacticalrmm/accounts/permissions.py +++ b/api/tacticalrmm/accounts/permissions.py @@ -47,3 +47,8 @@ class LocalUserPerms(permissions.BasePermission): def has_permission(self, r, view) -> bool: settings = get_core_settings() return not settings.block_local_user_logon + + +class SelfResetSSOPerms(permissions.BasePermission): + def has_permission(self, r, view) -> bool: + return not r.user.is_sso_user diff --git a/api/tacticalrmm/accounts/views.py b/api/tacticalrmm/accounts/views.py index 1d7532f446..3f60cb17bd 100644 --- a/api/tacticalrmm/accounts/views.py +++ b/api/tacticalrmm/accounts/views.py @@ -1,22 +1,24 @@ import datetime + import pyotp +from allauth.socialaccount.models import SocialAccount, SocialApp from django.conf import settings from django.contrib.auth import login from django.db import IntegrityError from django.shortcuts import get_object_or_404 from django.utils import timezone as djangotime -from knox.views import LoginView as KnoxLoginView from knox.models import AuthToken +from knox.views import LoginView as KnoxLoginView from python_ipware import IpWare from rest_framework.authtoken.serializers import AuthTokenSerializer from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response -from rest_framework.views import APIView from rest_framework.serializers import ( ModelSerializer, - SerializerMethodField, ReadOnlyField, + SerializerMethodField, ) +from rest_framework.views import APIView from accounts.utils import is_root_user from core.tasks import sync_mesh_perms_task @@ -25,7 +27,13 @@ from tacticalrmm.utils import get_core_settings from .models import APIKey, Role, User -from .permissions import AccountsPerms, APIKeyPerms, RolesPerms, LocalUserPerms +from .permissions import ( + AccountsPerms, + APIKeyPerms, + LocalUserPerms, + RolesPerms, + SelfResetSSOPerms, +) from .serializers import ( APIKeySerializer, RoleSerializer, @@ -53,7 +61,7 @@ def post(self, request, format=None): user = serializer.validated_data["user"] - if user.block_dashboard_login: + if user.block_dashboard_login or user.is_sso_user: return notify_error("Bad credentials") # block local logon if configured @@ -89,6 +97,9 @@ def post(self, request, format=None): if not user.is_superuser and core_settings.block_local_user_logon: return notify_error("Bad credentials") + if user.is_sso_user: + return notify_error("Bad credentials") + token = request.data["twofactor"] totp = pyotp.TOTP(user.totp_key) @@ -145,7 +156,7 @@ def post(self, request, format=None): user = serializer.validated_data["user"] - if user.block_dashboard_login: + if user.block_dashboard_login or user.is_sso_user: return notify_error("Bad credentials") # block local logon if configured @@ -176,7 +187,7 @@ def post(self, request, format=None): serializer.is_valid(raise_exception=True) user = serializer.validated_data["user"] - if user.block_dashboard_login: + if user.block_dashboard_login or user.is_sso_user: return notify_error("Bad credentials") # block local logon if configured @@ -264,8 +275,6 @@ class UserSerializerSSO(ModelSerializer): social_accounts = SerializerMethodField() def get_social_accounts(self, obj): - from allauth.socialaccount.models import SocialAccount, SocialApp - accounts = SocialAccount.objects.filter(user_id=obj.pk) if accounts: @@ -507,7 +516,7 @@ def delete(self, request, pk): class ResetPass(APIView): - permission_classes = [IsAuthenticated, LocalUserPerms] + permission_classes = [IsAuthenticated, SelfResetSSOPerms] def put(self, request): user = request.user @@ -517,7 +526,7 @@ def put(self, request): class Reset2FA(APIView): - permission_classes = [IsAuthenticated, LocalUserPerms] + permission_classes = [IsAuthenticated, SelfResetSSOPerms] def put(self, request): user = request.user diff --git a/api/tacticalrmm/core/models.py b/api/tacticalrmm/core/models.py index 363803a5c5..596f4fab14 100644 --- a/api/tacticalrmm/core/models.py +++ b/api/tacticalrmm/core/models.py @@ -10,6 +10,9 @@ from django.core.cache import cache from django.core.exceptions import ValidationError from django.db import models +from twilio.base.exceptions import TwilioRestException +from twilio.rest import Client as TwClient + from logs.models import BaseAuditModel, DebugLog from tacticalrmm.constants import ( ALL_TIMEZONES, @@ -20,8 +23,6 @@ URLActionRestMethod, URLActionType, ) -from twilio.base.exceptions import TwilioRestException -from twilio.rest import Client as TwClient if TYPE_CHECKING: from alerts.models import AlertTemplate @@ -131,13 +132,22 @@ def save(self, *args, **kwargs) -> None: old_settings = type(self).objects.get(pk=self.pk) if self.pk else None - super().save(*args, **kwargs) - if old_settings: # fail safe to not lock out user logons - if not old_settings.sso_enabled and old_settings.block_local_user_logon: + if not self.sso_enabled and self.block_local_user_logon: self.block_local_user_logon = False + if old_settings.sso_enabled != self.sso_enabled and self.sso_enabled: + from core.utils import token_is_valid + + _, valid = token_is_valid() + if not valid: + raise ValidationError("") + + super().save(*args, **kwargs) + + if old_settings: + if ( old_settings.alert_template != self.alert_template or old_settings.server_policy != self.server_policy diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index c4bbb329b8..465b80d487 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -6,21 +6,23 @@ import re -from allauth.socialaccount.models import SocialApp, SocialAccount +from allauth.socialaccount.models import SocialAccount, SocialApp from django.contrib.auth import logout +from django.core.exceptions import ValidationError from django.shortcuts import get_object_or_404 from knox.views import LoginView as KnoxLoginView +from python_ipware import IpWare from rest_framework import status from rest_framework.authentication import SessionAuthentication from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.serializers import ModelSerializer, ReadOnlyField from rest_framework.views import APIView -from python_ipware import IpWare from accounts.permissions import AccountsPerms from logs.models import AuditLog from tacticalrmm.utils import get_core_settings + from .permissions import SSOLoginPerms @@ -209,8 +211,14 @@ def post(self, request): core_settings = get_core_settings() - core_settings.block_local_user_logon = data["block_local_user_logon"] - core_settings.sso_enabled = data["sso_enabled"] - core_settings.save(update_fields=["block_local_user_logon", "sso_enabled"]) + try: + core_settings.block_local_user_logon = data["block_local_user_logon"] + core_settings.sso_enabled = data["sso_enabled"] + core_settings.save(update_fields=["block_local_user_logon", "sso_enabled"]) + except ValidationError: + return Response( + "This feature requires a Tier 1 or higher sponsorship: https://docs.tacticalrmm.com/sponsor", + status=status.HTTP_423_LOCKED, + ) return Response("ok") From 46c51284185902efdc063738f50eb95473913161 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Mon, 4 Nov 2024 21:58:37 +0000 Subject: [PATCH 40/50] move callback url info to the backend --- .../accounts/management/commands/reset_2fa.py | 5 ++-- api/tacticalrmm/accounts/serializers.py | 5 ++-- .../core/management/commands/get_config.py | 12 ++++++-- api/tacticalrmm/ee/sso/views.py | 29 +++++++++++++++++-- api/tacticalrmm/tacticalrmm/helpers.py | 12 -------- api/tacticalrmm/tacticalrmm/settings.py | 13 ++++----- api/tacticalrmm/tacticalrmm/util_settings.py | 23 +++++++++++++++ 7 files changed, 72 insertions(+), 27 deletions(-) create mode 100644 api/tacticalrmm/tacticalrmm/util_settings.py diff --git a/api/tacticalrmm/accounts/management/commands/reset_2fa.py b/api/tacticalrmm/accounts/management/commands/reset_2fa.py index 4a71fb3dcc..8e659722e6 100644 --- a/api/tacticalrmm/accounts/management/commands/reset_2fa.py +++ b/api/tacticalrmm/accounts/management/commands/reset_2fa.py @@ -1,10 +1,11 @@ import subprocess import pyotp +from django.conf import settings from django.core.management.base import BaseCommand from accounts.models import User -from tacticalrmm.helpers import get_webdomain +from tacticalrmm.util_settings import get_webdomain class Command(BaseCommand): @@ -26,7 +27,7 @@ def handle(self, *args, **kwargs): user.save(update_fields=["totp_key"]) url = pyotp.totp.TOTP(code).provisioning_uri( - username, issuer_name=get_webdomain() + username, issuer_name=get_webdomain(settings.CORS_ORIGIN_WHITELIST[0]) ) subprocess.run(f'qr "{url}"', shell=True) self.stdout.write( diff --git a/api/tacticalrmm/accounts/serializers.py b/api/tacticalrmm/accounts/serializers.py index 78186aef5b..c671733398 100644 --- a/api/tacticalrmm/accounts/serializers.py +++ b/api/tacticalrmm/accounts/serializers.py @@ -1,11 +1,12 @@ import pyotp +from django.conf import settings from rest_framework.serializers import ( ModelSerializer, ReadOnlyField, SerializerMethodField, ) -from tacticalrmm.helpers import get_webdomain +from tacticalrmm.util_settings import get_webdomain from .models import APIKey, Role, User @@ -63,7 +64,7 @@ class Meta: def get_qr_url(self, obj): return pyotp.totp.TOTP(obj.totp_key).provisioning_uri( - obj.username, issuer_name=get_webdomain() + obj.username, issuer_name=get_webdomain(settings.CORS_ORIGIN_WHITELIST[0]) ) diff --git a/api/tacticalrmm/core/management/commands/get_config.py b/api/tacticalrmm/core/management/commands/get_config.py index 0a76344fc3..ba9f26cb36 100644 --- a/api/tacticalrmm/core/management/commands/get_config.py +++ b/api/tacticalrmm/core/management/commands/get_config.py @@ -3,7 +3,7 @@ from django.conf import settings from django.core.management.base import BaseCommand -from tacticalrmm.helpers import get_root_domain, get_webdomain +from tacticalrmm.util_settings import get_backend_url, get_root_domain, get_webdomain from tacticalrmm.utils import get_certs @@ -29,8 +29,16 @@ def handle(self, *args, **kwargs): self.stdout.write(settings.NATS_SERVER_VER) case "frontend": self.stdout.write(settings.CORS_ORIGIN_WHITELIST[0]) + case "backend_url": + self.stdout.write( + get_backend_url( + settings.ALLOWED_HOSTS[0], + settings.TRMM_PROTO, + settings.TRMM_BACKEND_PORT, + ) + ) case "webdomain": - self.stdout.write(get_webdomain()) + self.stdout.write(get_webdomain(settings.CORS_ORIGIN_WHITELIST[0])) case "djangoadmin": url = f"https://{settings.ALLOWED_HOSTS[0]}/{settings.ADMIN_URL}" self.stdout.write(url) diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index 465b80d487..1a8dfe203f 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -7,6 +7,7 @@ import re from allauth.socialaccount.models import SocialAccount, SocialApp +from django.conf import settings from django.contrib.auth import logout from django.core.exceptions import ValidationError from django.shortcuts import get_object_or_404 @@ -16,11 +17,16 @@ from rest_framework.authentication import SessionAuthentication from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.serializers import ModelSerializer, ReadOnlyField +from rest_framework.serializers import ( + ModelSerializer, + ReadOnlyField, + SerializerMethodField, +) from rest_framework.views import APIView from accounts.permissions import AccountsPerms from logs.models import AuditLog +from tacticalrmm.util_settings import get_backend_url from tacticalrmm.utils import get_core_settings from .permissions import SSOLoginPerms @@ -29,6 +35,15 @@ class SocialAppSerializer(ModelSerializer): server_url = ReadOnlyField(source="settings.server_url") role = ReadOnlyField(source="settings.role") + callback_url = SerializerMethodField() + javascript_origin_url = SerializerMethodField() + + def get_callback_url(self, obj): + backend_url = self.context["backend_url"] + return f"{backend_url}/accounts/oidc/{obj.provider_id}/login/callback/" + + def get_javascript_origin_url(self, obj): + return self.context["frontend_url"] class Meta: model = SocialApp @@ -42,6 +57,8 @@ class Meta: "server_url", "settings", "role", + "callback_url", + "javascript_origin_url", ] @@ -49,8 +66,16 @@ class GetAddSSOProvider(APIView): permission_classes = [IsAuthenticated, AccountsPerms] def get(self, request): + ctx = { + "backend_url": get_backend_url( + settings.ALLOWED_HOSTS[0], + settings.TRMM_PROTO, + settings.TRMM_BACKEND_PORT, + ), + "frontend_url": settings.CORS_ORIGIN_WHITELIST[0], + } providers = SocialApp.objects.all() - return Response(SocialAppSerializer(providers, many=True).data) + return Response(SocialAppSerializer(providers, many=True, context=ctx).data) class InputSerializer(ModelSerializer): server_url = ReadOnlyField() diff --git a/api/tacticalrmm/tacticalrmm/helpers.py b/api/tacticalrmm/tacticalrmm/helpers.py index fbd9f796f6..00ef28f37c 100644 --- a/api/tacticalrmm/tacticalrmm/helpers.py +++ b/api/tacticalrmm/tacticalrmm/helpers.py @@ -6,10 +6,8 @@ import string from pathlib import Path from typing import TYPE_CHECKING, Any, Literal -from urllib.parse import urlparse from zoneinfo import ZoneInfo -import tldextract from cryptography import x509 from django.conf import settings from django.utils import timezone as djangotime @@ -104,16 +102,6 @@ def date_is_in_past(*, datetime_obj: "datetime", agent_tz: str) -> bool: return djangotime.now() > utc_time -def get_webdomain() -> str: - return urlparse(settings.CORS_ORIGIN_WHITELIST[0]).netloc - - -def get_root_domain(subdomain) -> str: - no_fetch_extract = tldextract.TLDExtract(suffix_list_urls=()) - extracted = no_fetch_extract(subdomain) - return f"{extracted.domain}.{extracted.suffix}" - - def rand_range(min: int, max: int) -> float: """ Input is milliseconds. diff --git a/api/tacticalrmm/tacticalrmm/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index 3f9a5e1cf3..99bfc3de63 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -3,7 +3,8 @@ from contextlib import suppress from datetime import timedelta from pathlib import Path -from tacticalrmm.helpers import get_root_domain, get_webdomain + +from tacticalrmm.util_settings import get_backend_url, get_root_domain, get_webdomain BASE_DIR = Path(__file__).resolve().parent.parent @@ -117,12 +118,12 @@ REDIS_HOST = "127.0.0.1" TRMM_LOG_LEVEL = "ERROR" TRMM_LOG_TO = "file" +TRMM_PROTO = "https" +TRMM_BACKEND_PORT = None if not DOCKER_BUILD: ALLOWED_HOSTS = [] CORS_ORIGIN_WHITELIST = [] - TRMM_PROTO = "https" - TRMM_BACKEND_PORT = None with suppress(ImportError): from ee.sso.sso_settings import * # noqa @@ -154,16 +155,14 @@ if not DOCKER_BUILD: TRMM_ROOT_DOMAIN = get_root_domain(ALLOWED_HOSTS[0]) - frontend_domain = get_webdomain().split(":")[0] + frontend_domain = get_webdomain(CORS_ORIGIN_WHITELIST[0]).split(":")[0] ALLOWED_HOSTS.append(frontend_domain) if DEBUG: ALLOWED_HOSTS.append("*") - backend_url = f"{TRMM_PROTO}://{ALLOWED_HOSTS[0]}" - if TRMM_BACKEND_PORT: - backend_url = f"{backend_url}:{TRMM_BACKEND_PORT}" + backend_url = get_backend_url(ALLOWED_HOSTS[0], TRMM_PROTO, TRMM_BACKEND_PORT) SESSION_COOKIE_DOMAIN = TRMM_ROOT_DOMAIN CSRF_COOKIE_DOMAIN = TRMM_ROOT_DOMAIN diff --git a/api/tacticalrmm/tacticalrmm/util_settings.py b/api/tacticalrmm/tacticalrmm/util_settings.py new file mode 100644 index 0000000000..ccd977e67f --- /dev/null +++ b/api/tacticalrmm/tacticalrmm/util_settings.py @@ -0,0 +1,23 @@ +# this file must not import anything from django settings to avoid circular import issues + +from urllib.parse import urlparse + +import tldextract + + +def get_webdomain(url: str) -> str: + return urlparse(url).netloc + + +def get_root_domain(subdomain) -> str: + no_fetch_extract = tldextract.TLDExtract(suffix_list_urls=()) + extracted = no_fetch_extract(subdomain) + return f"{extracted.domain}.{extracted.suffix}" + + +def get_backend_url(subdomain, proto, port) -> str: + url = f"{proto}://{subdomain}" + if port: + url = f"{url}:{port}" + + return url From fb470223808410acac524568c5ac1a02f7f14973 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Mon, 4 Nov 2024 23:40:59 +0000 Subject: [PATCH 41/50] redo migrations --- ...048_coresettings_block_local_user_logon.py | 18 --------------- ...ettings_block_local_user_logon_and_more.py | 23 +++++++++++++++++++ .../0049_coresettings_disable_sso.py | 18 --------------- ...emove_coresettings_disable_sso_and_more.py | 22 ------------------ ...ter_coresettings_block_local_user_logon.py | 18 --------------- 5 files changed, 23 insertions(+), 76 deletions(-) delete mode 100644 api/tacticalrmm/core/migrations/0048_coresettings_block_local_user_logon.py create mode 100644 api/tacticalrmm/core/migrations/0048_coresettings_block_local_user_logon_and_more.py delete mode 100644 api/tacticalrmm/core/migrations/0049_coresettings_disable_sso.py delete mode 100644 api/tacticalrmm/core/migrations/0050_remove_coresettings_disable_sso_and_more.py delete mode 100644 api/tacticalrmm/core/migrations/0051_alter_coresettings_block_local_user_logon.py diff --git a/api/tacticalrmm/core/migrations/0048_coresettings_block_local_user_logon.py b/api/tacticalrmm/core/migrations/0048_coresettings_block_local_user_logon.py deleted file mode 100644 index dd2eaf4f23..0000000000 --- a/api/tacticalrmm/core/migrations/0048_coresettings_block_local_user_logon.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.14 on 2024-09-22 04:55 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0047_alter_coresettings_notify_on_warning_alerts'), - ] - - operations = [ - migrations.AddField( - model_name='coresettings', - name='block_local_user_logon', - field=models.BooleanField(default=True), - ), - ] diff --git a/api/tacticalrmm/core/migrations/0048_coresettings_block_local_user_logon_and_more.py b/api/tacticalrmm/core/migrations/0048_coresettings_block_local_user_logon_and_more.py new file mode 100644 index 0000000000..510a2355ea --- /dev/null +++ b/api/tacticalrmm/core/migrations/0048_coresettings_block_local_user_logon_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.16 on 2024-11-04 23:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0047_alter_coresettings_notify_on_warning_alerts"), + ] + + operations = [ + migrations.AddField( + model_name="coresettings", + name="block_local_user_logon", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="coresettings", + name="sso_enabled", + field=models.BooleanField(default=False), + ), + ] diff --git a/api/tacticalrmm/core/migrations/0049_coresettings_disable_sso.py b/api/tacticalrmm/core/migrations/0049_coresettings_disable_sso.py deleted file mode 100644 index abd05da5cc..0000000000 --- a/api/tacticalrmm/core/migrations/0049_coresettings_disable_sso.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.14 on 2024-10-15 15:20 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0048_coresettings_block_local_user_logon'), - ] - - operations = [ - migrations.AddField( - model_name='coresettings', - name='disable_sso', - field=models.BooleanField(default=True), - ), - ] diff --git a/api/tacticalrmm/core/migrations/0050_remove_coresettings_disable_sso_and_more.py b/api/tacticalrmm/core/migrations/0050_remove_coresettings_disable_sso_and_more.py deleted file mode 100644 index 64fdc6dfb6..0000000000 --- a/api/tacticalrmm/core/migrations/0050_remove_coresettings_disable_sso_and_more.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.14 on 2024-10-15 20:05 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0049_coresettings_disable_sso'), - ] - - operations = [ - migrations.RemoveField( - model_name='coresettings', - name='disable_sso', - ), - migrations.AddField( - model_name='coresettings', - name='sso_enabled', - field=models.BooleanField(default=False), - ), - ] diff --git a/api/tacticalrmm/core/migrations/0051_alter_coresettings_block_local_user_logon.py b/api/tacticalrmm/core/migrations/0051_alter_coresettings_block_local_user_logon.py deleted file mode 100644 index 04ede6b253..0000000000 --- a/api/tacticalrmm/core/migrations/0051_alter_coresettings_block_local_user_logon.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.16 on 2024-10-18 19:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0050_remove_coresettings_disable_sso_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="coresettings", - name="block_local_user_logon", - field=models.BooleanField(default=False), - ), - ] From c35da67401dde4a9508e41a936e52fab09214be0 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Tue, 5 Nov 2024 20:26:50 +0000 Subject: [PATCH 42/50] update reqs --- api/tacticalrmm/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tacticalrmm/requirements.txt b/api/tacticalrmm/requirements.txt index 0a6d3e6485..d6165e6fb8 100644 --- a/api/tacticalrmm/requirements.txt +++ b/api/tacticalrmm/requirements.txt @@ -30,7 +30,7 @@ redis==5.0.8 requests==2.32.3 six==1.16.0 sqlparse==0.5.1 -tldextract==5.1.2 +tldextract==5.1.3 twilio==8.13.0 urllib3==2.2.3 uvicorn[standard]==0.31.1 From 0d34831df4411c3948e5b0a58dbe691ec875d296 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Wed, 6 Nov 2024 20:32:28 +0000 Subject: [PATCH 43/50] also check if first name only and display --- api/tacticalrmm/accounts/views.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/api/tacticalrmm/accounts/views.py b/api/tacticalrmm/accounts/views.py index 3f60cb17bd..8f2a601b8e 100644 --- a/api/tacticalrmm/accounts/views.py +++ b/api/tacticalrmm/accounts/views.py @@ -125,12 +125,17 @@ def post(self, request, format=None): ) response = super().post(request, format=None) response.data["username"] = request.user.username - if request.user.first_name and request.user.last_name: - response.data["name"] = ( - f"{request.user.first_name} {request.user.last_name}" - ) - else: - response.data["name"] = None + + response.data["name"] = None + + if request.user.is_sso_user: + if request.user.first_name and request.user.last_name: + response.data["name"] = ( + f"{request.user.first_name} {request.user.last_name}" + ) + elif request.user.first_name: + response.data["name"] = request.user.first_name + return Response(response.data) else: AuditLog.audit_user_failed_twofactor( From 86816ce357cdb7dd8b48c2351522ad9822e018c8 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Sun, 10 Nov 2024 20:59:27 +0000 Subject: [PATCH 44/50] move name stuff to the correct view and add email fallback --- api/tacticalrmm/accounts/views.py | 9 --------- api/tacticalrmm/ee/sso/views.py | 8 ++++++-- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/api/tacticalrmm/accounts/views.py b/api/tacticalrmm/accounts/views.py index 8f2a601b8e..f0f6cf2bd7 100644 --- a/api/tacticalrmm/accounts/views.py +++ b/api/tacticalrmm/accounts/views.py @@ -125,17 +125,8 @@ def post(self, request, format=None): ) response = super().post(request, format=None) response.data["username"] = request.user.username - response.data["name"] = None - if request.user.is_sso_user: - if request.user.first_name and request.user.last_name: - response.data["name"] = ( - f"{request.user.first_name} {request.user.last_name}" - ) - elif request.user.first_name: - response.data["name"] = request.user.first_name - return Response(response.data) else: AuditLog.audit_user_failed_twofactor( diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index 1a8dfe203f..5dc85c559d 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -188,12 +188,16 @@ def post(self, request, format=None): response.data["username"] = request.user.username response.data["provider"] = login_method["provider"] + response.data["name"] = None + if request.user.first_name and request.user.last_name: response.data["name"] = ( f"{request.user.first_name} {request.user.last_name}" ) - else: - response.data["name"] = None + elif request.user.first_name: + response.data["name"] = request.user.first_name + elif request.user.email: + response.data["name"] = request.user.email # log ip ipw = IpWare() From 63947346e94c27848a325117530e93557c8aadd9 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Fri, 15 Nov 2024 20:18:41 +0000 Subject: [PATCH 45/50] remove deprecated login endpoints --- api/tacticalrmm/accounts/views.py | 87 ----------------------------- api/tacticalrmm/tacticalrmm/urls.py | 4 +- 2 files changed, 1 insertion(+), 90 deletions(-) diff --git a/api/tacticalrmm/accounts/views.py b/api/tacticalrmm/accounts/views.py index f0f6cf2bd7..5be38b9fc9 100644 --- a/api/tacticalrmm/accounts/views.py +++ b/api/tacticalrmm/accounts/views.py @@ -135,93 +135,6 @@ def post(self, request, format=None): return notify_error("Bad credentials") -class CheckCreds(KnoxLoginView): - # TODO - # This view is deprecated as of 0.19.0 - # Needed for the initial update to 0.19.0 so frontend code doesn't break on login - permission_classes = (AllowAny,) - - def post(self, request, format=None): - # check credentials - serializer = AuthTokenSerializer(data=request.data) - if not serializer.is_valid(): - AuditLog.audit_user_failed_login( - request.data["username"], debug_info={"ip": request._client_ip} - ) - return notify_error("Bad credentials") - - user = serializer.validated_data["user"] - - if user.block_dashboard_login or user.is_sso_user: - return notify_error("Bad credentials") - - # block local logon if configured - core_settings = get_core_settings() - if not user.is_superuser and core_settings.block_local_user_logon: - return notify_error("Bad credentials") - - # if totp token not set modify response to notify frontend - if not user.totp_key: - login(request, user) - response = super(CheckCreds, self).post(request, format=None) - response.data["totp"] = "totp not set" - return response - - return Response("ok") - - -class LoginView(KnoxLoginView): - # TODO - # This view is deprecated as of 0.19.0 - # Needed for the initial update to 0.19.0 so frontend code doesn't break on login - permission_classes = (AllowAny,) - - def post(self, request, format=None): - valid = False - - serializer = AuthTokenSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - user = serializer.validated_data["user"] - - if user.block_dashboard_login or user.is_sso_user: - return notify_error("Bad credentials") - - # block local logon if configured - core_settings = get_core_settings() - if not user.is_superuser and core_settings.block_local_user_logon: - return notify_error("Bad credentials") - - token = request.data["twofactor"] - totp = pyotp.TOTP(user.totp_key) - - if settings.DEBUG and token == "sekret": - valid = True - elif getattr(settings, "DEMO", False): - valid = True - elif totp.verify(token, valid_window=10): - valid = True - - if valid: - login(request, user) - - # save ip information - ipw = IpWare() - client_ip, _ = ipw.get_client_ip(request.META) - if client_ip: - user.last_login_ip = str(client_ip) - user.save() - - AuditLog.audit_user_login_successful( - request.data["username"], debug_info={"ip": request._client_ip} - ) - return super(LoginView, self).post(request, format=None) - else: - AuditLog.audit_user_failed_twofactor( - request.data["username"], debug_info={"ip": request._client_ip} - ) - return notify_error("Bad credentials") - - class GetDeleteActiveLoginSessionsPerUser(APIView): permission_classes = [IsAuthenticated, AccountsPerms] diff --git a/api/tacticalrmm/tacticalrmm/urls.py b/api/tacticalrmm/tacticalrmm/urls.py index 3bc77d66b7..b5b190944d 100644 --- a/api/tacticalrmm/tacticalrmm/urls.py +++ b/api/tacticalrmm/tacticalrmm/urls.py @@ -2,7 +2,7 @@ from django.urls import include, path, register_converter from knox import views as knox_views -from accounts.views import CheckCreds, CheckCredsV2, LoginView, LoginViewV2 +from accounts.views import CheckCredsV2, LoginViewV2 from ee.sso.urls import allauth_urls # from agents.consumers import SendCMD @@ -28,8 +28,6 @@ def to_url(self, value): path("_allauth/", include(allauth_urls)), path("v2/checkcreds/", CheckCredsV2.as_view()), path("v2/login/", LoginViewV2.as_view()), - path("checkcreds/", CheckCreds.as_view()), # DEPRECATED AS OF 0.19.0 - path("login/", LoginView.as_view()), # DEPRECATED AS OF 0.19.0 path("logout/", knox_views.LogoutView.as_view()), path("logoutall/", knox_views.LogoutAllView.as_view()), path("api/v3/", include("apiv3.urls")), From 150e3190bcedebf4de0d3785defdd5ff283a1864 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Fri, 15 Nov 2024 20:19:00 +0000 Subject: [PATCH 46/50] refurb --- api/tacticalrmm/ee/sso/views.py | 8 +++----- api/tacticalrmm/logs/models.py | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py index 5dc85c559d..d7d690999c 100644 --- a/api/tacticalrmm/ee/sso/views.py +++ b/api/tacticalrmm/ee/sso/views.py @@ -96,9 +96,7 @@ class Meta: # 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 + return re.sub(r"[^A-Za-z0-9\s]", "", string).replace(" ", "-") def post(self, request): data = request.data @@ -106,7 +104,7 @@ def post(self, request): # need to move server_url into json settings data["settings"] = {} data["settings"]["server_url"] = data["server_url"] - data["settings"]["role"] = data["role"] if data["role"] else None + data["settings"]["role"] = data["role"] or None # set provider to 'openid_connect' data["provider"] = "openid_connect" @@ -138,7 +136,7 @@ def put(self, request, pk): # need to move server_url into json settings data["settings"] = {} data["settings"]["server_url"] = data["server_url"] - data["settings"]["role"] = data["role"] if data["role"] else None + data["settings"]["role"] = data["role"] or None serializer = self.InputSerialzer(instance=provider, data=data, partial=True) serializer.is_valid(raise_exception=True) diff --git a/api/tacticalrmm/logs/models.py b/api/tacticalrmm/logs/models.py index ebb15b29b5..13448c656d 100644 --- a/api/tacticalrmm/logs/models.py +++ b/api/tacticalrmm/logs/models.py @@ -474,7 +474,7 @@ def description(self) -> Optional[str]: PAAction.RUN_PATCH_SCAN, PAAction.RUN_PATCH_INSTALL, ): - return f"{self.action_type}" + return str(self.action_type) return None From ecf564648e8d6bd238223615807ed968d8558703 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Fri, 15 Nov 2024 20:25:54 +0000 Subject: [PATCH 47/50] update reqs --- api/tacticalrmm/requirements.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/tacticalrmm/requirements.txt b/api/tacticalrmm/requirements.txt index d6165e6fb8..ccc582ba2a 100644 --- a/api/tacticalrmm/requirements.txt +++ b/api/tacticalrmm/requirements.txt @@ -6,8 +6,8 @@ channels==4.1.0 channels_redis==4.2.0 cryptography==43.0.3 Django==4.2.16 -django-cors-headers==4.5.0 -django-allauth[socialaccount]==65.1.0 +django-cors-headers==4.6.0 +django-allauth[socialaccount]==65.2.0 django-filter==24.3 django-rest-knox==4.2.0 djangorestframework==3.15.2 @@ -17,7 +17,7 @@ kombu==5.3.7 meshctrl==0.1.15 msgpack==1.1.0 nats-py==2.9.0 -packaging==24.1 +packaging==24.2 psutil==6.0.0 psycopg[binary]==3.2.3 pycparser==2.22 @@ -34,7 +34,7 @@ tldextract==5.1.3 twilio==8.13.0 urllib3==2.2.3 uvicorn[standard]==0.31.1 -uWSGI==2.0.27 +uWSGI==2.0.28 validators==0.24.0 vine==5.1.0 websockets==13.1 From 7f9fc484e86d8303f2bf33d360a04c6a9526fa45 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Fri, 15 Nov 2024 20:40:16 +0000 Subject: [PATCH 48/50] revert as these haven't changed [skip ci] --- install.sh | 2 +- update.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index a311a1fd4b..469f449f29 100644 --- a/install.sh +++ b/install.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -SCRIPT_VERSION="87" +SCRIPT_VERSION="86" SCRIPT_URL="https://raw.githubusercontent.com/amidaware/tacticalrmm/master/install.sh" sudo apt install -y curl wget dirmngr gnupg lsb-release ca-certificates diff --git a/update.sh b/update.sh index 8156648431..31f84f16cf 100644 --- a/update.sh +++ b/update.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -SCRIPT_VERSION="155" +SCRIPT_VERSION="154" SCRIPT_URL='https://raw.githubusercontent.com/amidaware/tacticalrmm/master/update.sh' LATEST_SETTINGS_URL='https://raw.githubusercontent.com/amidaware/tacticalrmm/master/api/tacticalrmm/tacticalrmm/settings.py' YELLOW='\033[1;33m' From d1df40633af43a858e2bfeec7838033c075410ba Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Sat, 16 Nov 2024 19:10:17 +0000 Subject: [PATCH 49/50] call sync mesh after sso user created --- api/tacticalrmm/ee/sso/adapter.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/tacticalrmm/ee/sso/adapter.py b/api/tacticalrmm/ee/sso/adapter.py index 262325ebf5..c0dcc66142 100644 --- a/api/tacticalrmm/ee/sso/adapter.py +++ b/api/tacticalrmm/ee/sso/adapter.py @@ -10,6 +10,7 @@ from django.core.exceptions import PermissionDenied from accounts.models import Role +from core.tasks import sync_mesh_perms_task from core.utils import token_is_valid from tacticalrmm.logger import logger from tacticalrmm.utils import get_core_settings @@ -28,6 +29,7 @@ def populate_user(self, request, sociallogin, data): "Provider settings or Role not found. Continuing with blank permissions." ) user.totp_key = pyotp.random_base32() # not actually used + sync_mesh_perms_task.delay() return user def is_open_for_signup(self, request, sociallogin): From 91c33b04316ad07a402aa1af28c7366763c47fe9 Mon Sep 17 00:00:00 2001 From: wh1te909 <7434746+wh1te909@users.noreply.github.com> Date: Sat, 16 Nov 2024 19:28:28 +0000 Subject: [PATCH 50/50] add setting override to disable sso --- api/tacticalrmm/tacticalrmm/urls.py | 9 ++++++--- docker/.env.example | 5 ++++- docker/containers/tactical/entrypoint.sh | 2 ++ docker/docker-compose.yml | 1 + 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/api/tacticalrmm/tacticalrmm/urls.py b/api/tacticalrmm/tacticalrmm/urls.py index b5b190944d..8a248619f0 100644 --- a/api/tacticalrmm/tacticalrmm/urls.py +++ b/api/tacticalrmm/tacticalrmm/urls.py @@ -24,8 +24,6 @@ def to_url(self, value): urlpatterns = [ path("", home), - # all auth urls - path("_allauth/", include(allauth_urls)), path("v2/checkcreds/", CheckCredsV2.as_view()), path("v2/login/", LoginViewV2.as_view()), path("logout/", knox_views.LogoutView.as_view()), @@ -44,10 +42,15 @@ def to_url(self, value): path("scripts/", include("scripts.urls")), path("alerts/", include("alerts.urls")), path("accounts/", include("accounts.urls")), - path("accounts/", include("ee.sso.urls")), path("reporting/", include("ee.reporting.urls")), ] +if not getattr(settings, "TRMM_DISABLE_SSO", False): + urlpatterns += ( + path("_allauth/", include(allauth_urls)), + path("accounts/", include("ee.sso.urls")), + ) + if getattr(settings, "BETA_API_ENABLED", False): urlpatterns += (path("beta/v1/", include("beta.v1.urls")),) diff --git a/docker/.env.example b/docker/.env.example index 51c20b98ea..0321927b66 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -5,7 +5,7 @@ VERSION=latest TRMM_USER=tactical TRMM_PASS=tactical -# optional web port override settings +# optional web port override settings TRMM_HTTP_PORT=80 TRMM_HTTPS_PORT=443 @@ -30,3 +30,6 @@ TRMM_DISABLE_WEB_TERMINAL=False # disable server side scripts TRMM_DISABLE_SERVER_SCRIPTS=False + +# disable sso +TRMM_DISABLE_SSO=False diff --git a/docker/containers/tactical/entrypoint.sh b/docker/containers/tactical/entrypoint.sh index eb35a71293..0eda08394e 100644 --- a/docker/containers/tactical/entrypoint.sh +++ b/docker/containers/tactical/entrypoint.sh @@ -20,6 +20,7 @@ set -e : "${SKIP_UWSGI_CONFIG:=0}" : "${TRMM_DISABLE_WEB_TERMINAL:=False}" : "${TRMM_DISABLE_SERVER_SCRIPTS:=False}" +: "${TRMM_DISABLE_SSO:=False}" : "${CERT_PRIV_PATH:=${TACTICAL_DIR}/certs/privkey.pem}" : "${CERT_PUB_PATH:=${TACTICAL_DIR}/certs/fullchain.pem}" @@ -120,6 +121,7 @@ MESH_WS_URL = '${MESH_WS_URL}' ADMIN_ENABLED = False TRMM_DISABLE_WEB_TERMINAL = ${TRMM_DISABLE_WEB_TERMINAL} TRMM_DISABLE_SERVER_SCRIPTS = ${TRMM_DISABLE_SERVER_SCRIPTS} +TRMM_DISABLE_SSO = ${TRMM_DISABLE_SSO} EOF )" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1ebefee565..f71cfab7ac 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -64,6 +64,7 @@ services: TRMM_PASS: ${TRMM_PASS} TRMM_DISABLE_WEB_TERMINAL: ${TRMM_DISABLE_WEB_TERMINAL} TRMM_DISABLE_SERVER_SCRIPTS: ${TRMM_DISABLE_SERVER_SCRIPTS} + TRMM_DISABLE_SSO: ${TRMM_DISABLE_SSO} depends_on: - tactical-postgres - tactical-meshcentral