Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e6c9795ec | ||
|
|
c6b667f8b3 | ||
|
|
ad4cddb4f3 | ||
|
|
ddba83b993 | ||
|
|
91c33b0431 | ||
|
|
d1df40633a | ||
|
|
7f9fc484e8 | ||
|
|
ecf564648e | ||
|
|
150e3190bc | ||
|
|
63947346e9 | ||
|
|
86816ce357 | ||
|
|
0d34831df4 | ||
|
|
c35da67401 | ||
|
|
fb47022380 | ||
|
|
46c5128418 | ||
|
|
4a5bfee616 | ||
|
|
f8314e0f8e | ||
|
|
9624af4e67 | ||
|
|
5bec4768e7 | ||
|
|
3851b0943a | ||
|
|
cc1f640a50 | ||
|
|
ec0a2dc053 | ||
|
|
a6166a1ad7 | ||
|
|
41e3d1f490 | ||
|
|
2cbecaa552 | ||
|
|
8d543dcc7d | ||
|
|
18b1afe34f | ||
|
|
0f86bbfad8 | ||
|
|
0d021a800a | ||
|
|
038304384a | ||
|
|
2c09ad6b91 | ||
|
|
0bd09d03c1 | ||
|
|
faa0e6c289 | ||
|
|
c28d800d7f | ||
|
|
4fd772ecd8 | ||
|
|
5520a84062 | ||
|
|
66c7123f7c | ||
|
|
bacf4154fd | ||
|
|
61790d2261 | ||
|
|
899111a310 | ||
|
|
3bfa35e1c7 | ||
|
|
ebefcb7fc1 | ||
|
|
ce11685371 | ||
|
|
9edb848947 | ||
|
|
f326096fad | ||
|
|
46f0b23f4f | ||
|
|
1c1d3bd619 | ||
|
|
d894f92d5e | ||
|
|
6c44191fe4 | ||
|
|
0deb78a9af | ||
|
|
9c15f4ba88 | ||
|
|
4ba27ec1d6 | ||
|
|
c8dd80530a | ||
|
|
eda5ea7d1a | ||
|
|
77a916e1a8 | ||
|
|
7ba2a4b27b | ||
|
|
d33f69720a |
@@ -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 <<EOF
|
||||
SECRET_KEY = '${DJANGO_SEKRET}'
|
||||
|
||||
DEBUG = True
|
||||
@@ -64,12 +67,17 @@ 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://${APP_HOST}']
|
||||
|
||||
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': {
|
||||
@@ -101,9 +109,9 @@ MESH_WS_URL = '${MESH_WS_URL}'
|
||||
ADMIN_ENABLED = True
|
||||
TRMM_INSECURE = True
|
||||
EOF
|
||||
)"
|
||||
)"
|
||||
|
||||
echo "${localvars}" > ${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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 @@ class Command(BaseCommand):
|
||||
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(
|
||||
|
||||
@@ -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 @@ class User(AbstractUser, BaseAuditModel):
|
||||
# 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
|
||||
|
||||
@@ -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 @@ class APIKeyPerms(permissions.BasePermission):
|
||||
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
|
||||
|
||||
@@ -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 TOTPSetupSerializer(ModelSerializer):
|
||||
|
||||
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])
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from tacticalrmm.test import TacticalTestCase
|
||||
|
||||
class TestAccounts(TacticalTestCase):
|
||||
def setUp(self):
|
||||
self.setup_coresettings()
|
||||
self.setup_client()
|
||||
self.bob = User(username="bob")
|
||||
self.bob.set_password("hunter2")
|
||||
|
||||
@@ -5,6 +5,10 @@ from . import views
|
||||
urlpatterns = [
|
||||
path("users/", views.GetAddUsers.as_view()),
|
||||
path("<int:pk>/users/", views.GetUpdateDeleteUser.as_view()),
|
||||
path("sessions/<str:pk>/", views.DeleteActiveLoginSession.as_view()),
|
||||
path(
|
||||
"users/<int:pk>/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()),
|
||||
|
||||
@@ -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 @@ class CheckCredsV2(KnoxLoginView):
|
||||
|
||||
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 @@ class LoginViewV2(KnoxLoginView):
|
||||
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 @@ class LoginViewV2(KnoxLoginView):
|
||||
)
|
||||
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 @@ class LoginViewV2(KnoxLoginView):
|
||||
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 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 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
|
||||
|
||||
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 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 @@ class GetAddUsers(APIView):
|
||||
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 @@ class GetUpdateDeleteUser(APIView):
|
||||
|
||||
|
||||
class UserActions(APIView):
|
||||
permission_classes = [IsAuthenticated, AccountsPerms]
|
||||
permission_classes = [IsAuthenticated, AccountsPerms, LocalUserPerms]
|
||||
|
||||
# reset password
|
||||
def post(self, request):
|
||||
@@ -381,7 +425,7 @@ class GetUpdateDeleteAPIKey(APIView):
|
||||
|
||||
|
||||
class ResetPass(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
permission_classes = [IsAuthenticated, SelfResetSSOPerms]
|
||||
|
||||
def put(self, request):
|
||||
user = request.user
|
||||
@@ -391,7 +435,7 @@ class ResetPass(APIView):
|
||||
|
||||
|
||||
class Reset2FA(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
permission_classes = [IsAuthenticated, SelfResetSSOPerms]
|
||||
|
||||
def put(self, request):
|
||||
user = request.user
|
||||
|
||||
@@ -177,8 +177,7 @@ class CheckRunnerGetSerializer(serializers.ModelSerializer):
|
||||
return Script.parse_script_env_vars(
|
||||
agent=agent,
|
||||
shell=obj.script.shell,
|
||||
env_vars=obj.env_vars
|
||||
or obj.script.env_vars, # check's env_vars override the script's env vars
|
||||
env_vars=obj.env_vars,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -3,7 +3,7 @@ from urllib.parse import urlparse
|
||||
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 @@ class Command(BaseCommand):
|
||||
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 @@ class Command(BaseCommand):
|
||||
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)
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -10,6 +10,9 @@ from django.contrib.postgres.fields import ArrayField
|
||||
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 @@ from tacticalrmm.constants import (
|
||||
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 @@ class CoreSettings(BaseAuditModel):
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -5,10 +5,8 @@ For details, see: https://license.tacticalrmm.com/ee
|
||||
"""
|
||||
|
||||
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 @@ class Command(BaseCommand):
|
||||
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)
|
||||
|
||||
5
api/tacticalrmm/ee/sso/__init__.py
Normal file
5
api/tacticalrmm/ee/sso/__init__.py
Normal file
@@ -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
|
||||
"""
|
||||
47
api/tacticalrmm/ee/sso/adapter.py
Normal file
47
api/tacticalrmm/ee/sso/adapter.py
Normal file
@@ -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)
|
||||
64
api/tacticalrmm/ee/sso/middleware.py
Normal file
64
api/tacticalrmm/ee/sso/middleware.py
Normal file
@@ -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
|
||||
13
api/tacticalrmm/ee/sso/permissions.py
Normal file
13
api/tacticalrmm/ee/sso/permissions.py
Normal file
@@ -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()
|
||||
19
api/tacticalrmm/ee/sso/sso_settings.py
Normal file
19
api/tacticalrmm/ee/sso/sso_settings.py
Normal file
@@ -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
|
||||
69
api/tacticalrmm/ee/sso/urls.py
Normal file
69
api/tacticalrmm/ee/sso/urls.py
Normal file
@@ -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<provider_id>[^/]+)/",
|
||||
include(
|
||||
[
|
||||
path(
|
||||
"login/callback/",
|
||||
callback,
|
||||
name="openid_connect_callback",
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
path("ssoproviders/", views.GetAddSSOProvider.as_view()),
|
||||
path("ssoproviders/<int:pk>/", 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",
|
||||
),
|
||||
)
|
||||
]
|
||||
251
api/tacticalrmm/ee/sso/views.py
Normal file
251
api/tacticalrmm/ee/sso/views.py
Normal file
@@ -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")
|
||||
@@ -213,6 +213,18 @@ class AuditLog(models.Model):
|
||||
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 @@ class PendingAction(models.Model):
|
||||
PAAction.RUN_PATCH_SCAN,
|
||||
PAAction.RUN_PATCH_INSTALL,
|
||||
):
|
||||
return f"{self.action_type}"
|
||||
return str(self.action_type)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -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
|
||||
ocxsect==0.1.5
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,7 +6,6 @@ import secrets
|
||||
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.
|
||||
|
||||
@@ -27,6 +27,9 @@ EXCLUDE_PATHS = (
|
||||
"/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 @@ class AuditMiddleware:
|
||||
# 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
|
||||
|
||||
@@ -4,6 +4,8 @@ from contextlib import suppress
|
||||
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"
|
||||
@@ -21,14 +23,14 @@ MAC_UNINSTALL = BASE_DIR / "core" / "mac_uninstall.sh"
|
||||
AUTH_USER_MODEL = "accounts.User"
|
||||
|
||||
# latest release
|
||||
TRMM_VERSION = "0.19.4"
|
||||
TRMM_VERSION = "0.20.0"
|
||||
|
||||
# https://github.com/amidaware/tacticalrmm-web
|
||||
WEB_VERSION = "0.101.49"
|
||||
WEB_VERSION = "0.101.50"
|
||||
|
||||
# bump this version everytime vue code is changed
|
||||
# to alert user they need to manually refresh their browser
|
||||
APP_VER = "0.0.195"
|
||||
APP_VER = "0.0.196"
|
||||
|
||||
# https://github.com/amidaware/rmmagent
|
||||
LATEST_AGENT_VER = "2.8.0"
|
||||
@@ -116,15 +118,63 @@ SWAGGER_ENABLED = False
|
||||
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 @@ INSTALLED_APPS = [
|
||||
"knox",
|
||||
"corsheaders",
|
||||
"accounts",
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"allauth.socialaccount",
|
||||
"allauth.socialaccount.providers.openid_connect",
|
||||
"allauth.headless",
|
||||
"apiv3",
|
||||
"clients",
|
||||
"agents",
|
||||
@@ -178,6 +233,7 @@ INSTALLED_APPS = [
|
||||
"scripts",
|
||||
"alerts",
|
||||
"ee.reporting",
|
||||
"ee.sso",
|
||||
]
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
@@ -189,6 +245,7 @@ CHANNEL_LAYERS = {
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# silence cache key length warnings
|
||||
import warnings # noqa
|
||||
|
||||
@@ -216,6 +273,8 @@ MIDDLEWARE = [
|
||||
"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 @@ LOGGING = {
|
||||
"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"
|
||||
|
||||
@@ -2,7 +2,8 @@ from django.conf import settings
|
||||
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 @@ urlpatterns = [
|
||||
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 @@ urlpatterns = [
|
||||
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")),)
|
||||
|
||||
|
||||
23
api/tacticalrmm/tacticalrmm/util_settings.py
Normal file
23
api/tacticalrmm/tacticalrmm/util_settings.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 <<EOF
|
||||
@@ -88,13 +90,17 @@ 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://${APP_HOST}']
|
||||
|
||||
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': {
|
||||
@@ -115,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
|
||||
)"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
SCRIPT_VERSION="60"
|
||||
SCRIPT_VERSION="61"
|
||||
SCRIPT_URL='https://raw.githubusercontent.com/amidaware/tacticalrmm/master/restore.sh'
|
||||
|
||||
sudo apt update
|
||||
@@ -15,6 +15,7 @@ NC='\033[0m'
|
||||
SCRIPTS_DIR='/opt/trmm-community-scripts'
|
||||
PYTHON_VER='3.11.8'
|
||||
SETTINGS_FILE='/rmm/api/tacticalrmm/tacticalrmm/settings.py'
|
||||
local_settings='/rmm/api/tacticalrmm/tacticalrmm/local_settings.py'
|
||||
|
||||
TMP_FILE=$(mktemp -p "" "rmmrestore_XXXXXXXXXX")
|
||||
curl -s -L "${SCRIPT_URL}" >${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}'"
|
||||
|
||||
Reference in New Issue
Block a user