diff --git a/.devcontainer/entrypoint.sh b/.devcontainer/entrypoint.sh index e24a674c89..a3235aa94a 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,8 +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) - - localvars="$(cat << EOF + + 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 < ${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 @@ -118,9 +126,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 } 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/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 e9e809ecb3..3bbb1f5cd2 100644 --- a/api/tacticalrmm/accounts/permissions.py +++ b/api/tacticalrmm/accounts/permissions.py @@ -1,6 +1,7 @@ from rest_framework import permissions from tacticalrmm.permissions import _has_perm +from tacticalrmm.utils import get_core_settings class AccountsPerms(permissions.BasePermission): @@ -40,3 +41,14 @@ 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 + + +class SelfResetSSOPerms(permissions.BasePermission): + def has_permission(self, r, view) -> bool: + return not r.user.is_sso_user 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/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") diff --git a/api/tacticalrmm/accounts/urls.py b/api/tacticalrmm/accounts/urls.py index 5aeb2178e9..5a09620de0 100644 --- a/api/tacticalrmm/accounts/urls.py +++ b/api/tacticalrmm/accounts/urls.py @@ -5,6 +5,10 @@ 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 e329323b27..5be38b9fc9 100644 --- a/api/tacticalrmm/accounts/views.py +++ b/api/tacticalrmm/accounts/views.py @@ -1,24 +1,39 @@ 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.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.serializers import ( + ModelSerializer, + ReadOnlyField, + SerializerMethodField, +) from rest_framework.views import APIView 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 +from .permissions import ( + AccountsPerms, + APIKeyPerms, + LocalUserPerms, + RolesPerms, + SelfResetSSOPerms, +) from .serializers import ( APIKeySerializer, RoleSerializer, @@ -46,7 +61,12 @@ 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 + 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 @@ -72,6 +92,14 @@ def post(self, request, format=None): if user.block_dashboard_login: 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 user.is_sso_user: + return notify_error("Bad credentials") + token = request.data["twofactor"] totp = pyotp.TOTP(user.totp_key) @@ -97,6 +125,8 @@ def post(self, request, format=None): ) response = super().post(request, format=None) response.data["username"] = request.user.username + response.data["name"] = None + return Response(response.data) else: AuditLog.audit_user_failed_twofactor( @@ -105,86 +135,100 @@ 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,) +class GetDeleteActiveLoginSessionsPerUser(APIView): + permission_classes = [IsAuthenticated, AccountsPerms] - 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} + class TokenSerializer(ModelSerializer): + user = ReadOnlyField(source="user.username") + + class Meta: + model = AuthToken + fields = ( + "digest", + "user", + "created", + "expiry", ) - return notify_error("Bad credentials") - user = serializer.validated_data["user"] + def get(self, request, pk): + tokens = get_object_or_404(User, pk=pk).auth_token_set.filter( + expiry__gt=djangotime.now() + ) - if user.block_dashboard_login: - return notify_error("Bad credentials") + return Response(self.TokenSerializer(tokens, many=True).data) - # 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 + 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 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: - 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 +class DeleteActiveLoginSession(APIView): + permission_classes = [IsAuthenticated, AccountsPerms] - if valid: - login(request, user) + def delete(self, request, pk): + token = get_object_or_404(AuthToken, digest=pk) - # 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() + token.delete() - 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") + return Response("ok") class GetAddUsers(APIView): permission_classes = [IsAuthenticated, AccountsPerms] + class UserSerializerSSO(ModelSerializer): + social_accounts = SerializerMethodField() + + def get_social_accounts(self, obj): + accounts = SocialAccount.objects.filter(user_id=obj.pk) + + 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 + 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 +239,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 @@ -255,7 +299,7 @@ def delete(self, request, pk): class UserActions(APIView): - permission_classes = [IsAuthenticated, AccountsPerms] + permission_classes = [IsAuthenticated, AccountsPerms, LocalUserPerms] # reset password def post(self, request): @@ -381,7 +425,7 @@ def delete(self, request, pk): class ResetPass(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, SelfResetSSOPerms] def put(self, request): user = request.user @@ -391,7 +435,7 @@ def put(self, request): class Reset2FA(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, SelfResetSSOPerms] def put(self, request): user = request.user diff --git a/api/tacticalrmm/core/management/commands/get_config.py b/api/tacticalrmm/core/management/commands/get_config.py index f9a4fa20c5..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_webdomain +from tacticalrmm.util_settings import get_backend_url, get_root_domain, get_webdomain from tacticalrmm.utils import get_certs @@ -17,6 +17,8 @@ def handle(self, *args, **kwargs): match kwargs["name"]: case "api": self.stdout.write(settings.ALLOWED_HOSTS[0]) + case "rootdomain": + self.stdout.write(get_root_domain(settings.ALLOWED_HOSTS[0])) case "version": self.stdout.write(settings.TRMM_VERSION) case "webversion": @@ -27,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/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/models.py b/api/tacticalrmm/core/models.py index 54d22d7846..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 @@ -111,6 +112,9 @@ 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=False) + sso_enabled = models.BooleanField(default=False) + def save(self, *args, **kwargs) -> None: from alerts.tasks import cache_agents_alert_template @@ -127,9 +131,23 @@ 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 + + if old_settings: + # fail safe to not lock out user logons + 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/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, } ) 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/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/__init__.py b/api/tacticalrmm/ee/sso/__init__.py new file mode 100644 index 0000000000..31d29b122c --- /dev/null +++ b/api/tacticalrmm/ee/sso/__init__.py @@ -0,0 +1,5 @@ +""" +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 new file mode 100644 index 0000000000..c0dcc66142 --- /dev/null +++ b/api/tacticalrmm/ee/sso/adapter.py @@ -0,0 +1,47 @@ +""" +Copyright (c) 2024-present Amidaware Inc. +This file is subject to the EE License Agreement. +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 + +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 + + +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"]) + except Exception: + logger.debug( + "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): + _, 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: + return [] + + return super().list_providers(request) diff --git a/api/tacticalrmm/ee/sso/middleware.py b/api/tacticalrmm/ee/sso/middleware.py new file mode 100644 index 0000000000..ee0872db68 --- /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", None) + 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/ee/sso/permissions.py b/api/tacticalrmm/ee/sso/permissions.py new file mode 100644 index 0000000000..c98f773348 --- /dev/null +++ b/api/tacticalrmm/ee/sso/permissions.py @@ -0,0 +1,13 @@ +""" +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 + + +class SSOLoginPerms(permissions.BasePermission): + def has_permission(self, r, view): + return SocialAccount.objects.filter(user=r.user).exists() diff --git a/api/tacticalrmm/ee/sso/sso_settings.py b/api/tacticalrmm/ee/sso/sso_settings.py new file mode 100644 index 0000000000..e0111b933e --- /dev/null +++ b/api/tacticalrmm/ee/sso/sso_settings.py @@ -0,0 +1,19 @@ +""" +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 +CORS_ALLOW_CREDENTIALS = True diff --git a/api/tacticalrmm/ee/sso/urls.py b/api/tacticalrmm/ee/sso/urls.py new file mode 100644 index 0000000000..8662c4c0f9 --- /dev/null +++ b/api/tacticalrmm/ee/sso/urls.py @@ -0,0 +1,69 @@ +""" +Copyright (c) 2024-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, include, re_path +from allauth.socialaccount.providers.openid_connect.views import callback +from allauth.headless.socialaccount.views import RedirectToProviderView +from allauth.headless.base.views import ConfigView + +from . import views + +urlpatterns = [ + 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()), + path("ssoproviders/account/", views.DisconnectSSOAccount.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", + ) + ], + "headless", + ), + namespace="socialaccount", + ), + ), + ], + "headless", + ), + namespace="browser", + ), + ) +] diff --git a/api/tacticalrmm/ee/sso/views.py b/api/tacticalrmm/ee/sso/views.py new file mode 100644 index 0000000000..d7d690999c --- /dev/null +++ b/api/tacticalrmm/ee/sso/views.py @@ -0,0 +1,251 @@ +""" +Copyright (c) 2024-present Amidaware Inc. +This file is subject to the EE License Agreement. +For details, see: https://license.tacticalrmm.com/ee +""" + +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 +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, + 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 + + +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 + fields = [ + "id", + "name", + "provider", + "provider_id", + "client_id", + "secret", + "server_url", + "settings", + "role", + "callback_url", + "javascript_origin_url", + ] + + +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, context=ctx).data) + + class InputSerializer(ModelSerializer): + server_url = ReadOnlyField() + role = ReadOnlyField() + + class Meta: + model = SocialApp + fields = [ + "name", + "client_id", + "secret", + "server_url", + "provider", + "provider_id", + "settings", + "role", + ] + + # removed any special characters and replaces spaces with a hyphen + def generate_provider_id(self, string): + return re.sub(r"[^A-Za-z0-9\s]", "", string).replace(" ", "-") + + def post(self, request): + data = request.data + + # need to move server_url into json settings + data["settings"] = {} + data["settings"]["server_url"] = data["server_url"] + data["settings"]["role"] = data["role"] or None + + # 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() + role = ReadOnlyField() + + class Meta: + model = SocialApp + fields = ["client_id", "secret", "server_url", "settings", "role"] + + 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"] + data["settings"]["role"] = data["role"] or None + + serializer = self.InputSerialzer(instance=provider, data=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") + + +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] + + def post(self, request, format=None): + + core = get_core_settings() + + # check for auth method before signing in + if ( + 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] + + # get token + response = super().post(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}" + ) + 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() + 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) + + return Response(response.data) + else: + logout(request) + return Response("No pending login session found", status.HTTP_403_FORBIDDEN) + + +class GetUpdateSSOSettings(APIView): + permission_classes = [IsAuthenticated, AccountsPerms] + + def get(self, request): + + core_settings = get_core_settings() + + return Response( + { + "block_local_user_logon": core_settings.block_local_user_logon, + "sso_enabled": core_settings.sso_enabled, + } + ) + + def post(self, request): + + data = request.data + + core_settings = get_core_settings() + + 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") diff --git a/api/tacticalrmm/logs/models.py b/api/tacticalrmm/logs/models.py index 14ecc6bb4b..13448c656d 100644 --- a/api/tacticalrmm/logs/models.py +++ b/api/tacticalrmm/logs/models.py @@ -213,6 +213,18 @@ 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_url_action( username: str, @@ -462,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 diff --git a/api/tacticalrmm/requirements.txt b/api/tacticalrmm/requirements.txt index 4114ae4680..ccc582ba2a 100644 --- a/api/tacticalrmm/requirements.txt +++ b/api/tacticalrmm/requirements.txt @@ -6,7 +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-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 @@ -16,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 @@ -29,10 +30,11 @@ redis==5.0.8 requests==2.32.3 six==1.16.0 sqlparse==0.5.1 +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 @@ -43,4 +45,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/helpers.py b/api/tacticalrmm/tacticalrmm/helpers.py index fedb96b12a..00ef28f37c 100644 --- a/api/tacticalrmm/tacticalrmm/helpers.py +++ b/api/tacticalrmm/tacticalrmm/helpers.py @@ -6,7 +6,6 @@ import string from pathlib import Path from typing import TYPE_CHECKING, Any, Literal -from urllib.parse import urlparse from zoneinfo import ZoneInfo from cryptography import x509 @@ -103,10 +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 rand_range(min: int, max: int) -> float: """ Input is milliseconds. diff --git a/api/tacticalrmm/tacticalrmm/middleware.py b/api/tacticalrmm/tacticalrmm/middleware.py index 8f8ca3bfe1..de24cc1d77 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 = ( @@ -72,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__ + 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 91e318b1e9..99bfc3de63 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -4,6 +4,8 @@ from datetime import timedelta from pathlib import Path +from tacticalrmm.util_settings import get_backend_url, get_root_domain, get_webdomain + BASE_DIR = Path(__file__).resolve().parent.parent SCRIPTS_DIR = "/opt/trmm-community-scripts" @@ -116,15 +118,63 @@ 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 = [] + +with suppress(ImportError): + from ee.sso.sso_settings import * # noqa 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]) + frontend_domain = get_webdomain(CORS_ORIGIN_WHITELIST[0]).split(":")[0] + + ALLOWED_HOSTS.append(frontend_domain) + + if DEBUG: + ALLOWED_HOSTS.append("*") + + 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 + 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" -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 @@ -164,6 +214,11 @@ "knox", "corsheaders", "accounts", + "allauth", + "allauth.account", + "allauth.socialaccount", + "allauth.socialaccount.providers.openid_connect", + "allauth.headless", "apiv3", "clients", "agents", @@ -178,6 +233,7 @@ "scripts", "alerts", "ee.reporting", + "ee.sso", ] CHANNEL_LAYERS = { @@ -189,6 +245,7 @@ }, } + # silence cache key length warnings import warnings # noqa @@ -216,6 +273,8 @@ "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "tacticalrmm.middleware.AuditMiddleware", + "allauth.account.middleware.AccountMiddleware", + "ee.sso.middleware.SSOIconMiddleware", ] if SWAGGER_ENABLED: @@ -326,25 +385,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" diff --git a/api/tacticalrmm/tacticalrmm/urls.py b/api/tacticalrmm/tacticalrmm/urls.py index 7fceedf24f..8a248619f0 100644 --- a/api/tacticalrmm/tacticalrmm/urls.py +++ b/api/tacticalrmm/tacticalrmm/urls.py @@ -2,7 +2,8 @@ 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 from core.consumers import DashInfo, TerminalConsumer @@ -25,8 +26,6 @@ def to_url(self, value): path("", home), 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")), @@ -46,6 +45,12 @@ def to_url(self, value): 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/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 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 4cc7a8cd09..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}" @@ -71,6 +72,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 <${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}'"