Skip to content

Commit

Permalink
Release 0.8.3
Browse files Browse the repository at this point in the history
  • Loading branch information
wh1te909 committed Sep 6, 2021
2 parents 4942f26 + f319c95 commit 8dddd2d
Show file tree
Hide file tree
Showing 59 changed files with 2,503 additions and 185 deletions.
1 change: 1 addition & 0 deletions .devcontainer/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ POSTGRES_PASS=postgrespass
# DEV SETTINGS
APP_PORT=80
API_PORT=80
API_PROTOCOL=https://
HTTP_PROTOCOL=https
DOCKER_NETWORK=172.21.0.0/24
DOCKER_NGINX_IP=172.21.0.20
Expand Down
1 change: 1 addition & 0 deletions .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ services:
CERT_PRIV_KEY: ${CERT_PRIV_KEY}
APP_PORT: ${APP_PORT}
API_PORT: ${API_PORT}
API_PROTOCOL: ${API_PROTOCOL}
networks:
dev:
ipv4_address: ${DOCKER_NGINX_IP}
Expand Down
18 changes: 0 additions & 18 deletions .devcontainer/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -78,24 +78,6 @@ DATABASES = {
}
}
REST_FRAMEWORK = {
'DATETIME_FORMAT': '%b-%d-%Y - %H:%M',
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
'DEFAULT_AUTHENTICATION_CLASSES': (
'knox.auth.TokenAuthentication',
),
}
if not DEBUG:
REST_FRAMEWORK.update({
'DEFAULT_RENDERER_CLASSES': (
'rest_framework.renderers.JSONRenderer',
)
})
MESH_USERNAME = '${MESH_USER}'
MESH_SITE = 'https://${MESH_HOST}'
MESH_TOKEN_KEY = '${MESH_TOKEN}'
Expand Down
34 changes: 34 additions & 0 deletions api/tacticalrmm/accounts/migrations/0026_auto_20210901_1247.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Generated by Django 3.2.6 on 2021-09-01 12:47

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('accounts', '0025_auto_20210721_0424'),
]

operations = [
migrations.CreateModel(
name='APIKey',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_by', models.CharField(blank=True, max_length=100, null=True)),
('created_time', models.DateTimeField(auto_now_add=True, null=True)),
('modified_by', models.CharField(blank=True, max_length=100, null=True)),
('modified_time', models.DateTimeField(auto_now=True, null=True)),
('name', models.CharField(max_length=25, unique=True)),
('key', models.CharField(blank=True, max_length=48, unique=True)),
('expiration', models.DateTimeField(blank=True, default=None, null=True)),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='role',
name='can_manage_api_keys',
field=models.BooleanField(default=False),
),
]
25 changes: 25 additions & 0 deletions api/tacticalrmm/accounts/migrations/0027_auto_20210903_0054.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 3.2.6 on 2021-09-03 00:54

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('accounts', '0026_auto_20210901_1247'),
]

operations = [
migrations.AddField(
model_name='apikey',
name='user',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='api_key', to='accounts.user'),
preserve_default=False,
),
migrations.AddField(
model_name='user',
name='block_dashboard_login',
field=models.BooleanField(default=False),
),
]
23 changes: 23 additions & 0 deletions api/tacticalrmm/accounts/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models.fields import CharField, DateTimeField

from logs.models import BaseAuditModel

Expand All @@ -24,6 +25,7 @@

class User(AbstractUser, BaseAuditModel):
is_active = models.BooleanField(default=True)
block_dashboard_login = models.BooleanField(default=False)
totp_key = models.CharField(max_length=50, null=True, blank=True)
dark_mode = models.BooleanField(default=True)
show_community_scripts = models.BooleanField(default=True)
Expand Down Expand Up @@ -138,6 +140,9 @@ class Role(BaseAuditModel):
can_manage_accounts = models.BooleanField(default=False)
can_manage_roles = models.BooleanField(default=False)

# authentication
can_manage_api_keys = models.BooleanField(default=False)

def __str__(self):
return self.name

Expand Down Expand Up @@ -186,4 +191,22 @@ def perms():
"can_manage_winupdates",
"can_manage_accounts",
"can_manage_roles",
"can_manage_api_keys",
]


class APIKey(BaseAuditModel):
name = CharField(unique=True, max_length=25)
key = CharField(unique=True, blank=True, max_length=48)
expiration = DateTimeField(blank=True, null=True, default=None)
user = models.ForeignKey(
"accounts.User",
related_name="api_key",
on_delete=models.CASCADE,
)

@staticmethod
def serialize(apikey):
from .serializers import APIKeyAuditSerializer

return APIKeyAuditSerializer(apikey).data
21 changes: 21 additions & 0 deletions api/tacticalrmm/accounts/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ def has_permission(self, r, view):
if r.method == "GET":
return True

# allow users to reset their own password/2fa see issue #686
base_path = "/accounts/users/"
paths = ["reset/", "reset_totp/"]

if r.path in [base_path + i for i in paths]:
from accounts.models import User

try:
user = User.objects.get(pk=r.data["id"])
except User.DoesNotExist:
pass
else:
if user == r.user:
return True

return _has_perm(r, "can_manage_accounts")


Expand All @@ -17,3 +32,9 @@ def has_permission(self, r, view):
return True

return _has_perm(r, "can_manage_roles")


class APIKeyPerms(permissions.BasePermission):
def has_permission(self, r, view):

return _has_perm(r, "can_manage_api_keys")
31 changes: 29 additions & 2 deletions api/tacticalrmm/accounts/serializers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import pyotp
from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.serializers import (
ModelSerializer,
SerializerMethodField,
ReadOnlyField,
)

from .models import User, Role
from .models import APIKey, User, Role


class UserUISerializer(ModelSerializer):
Expand All @@ -17,6 +21,7 @@ class Meta:
"client_tree_splitter",
"loading_bar_color",
"clear_search_when_switching",
"block_dashboard_login",
]


Expand All @@ -33,6 +38,7 @@ class Meta:
"last_login",
"last_login_ip",
"role",
"block_dashboard_login",
]


Expand Down Expand Up @@ -64,3 +70,24 @@ class RoleAuditSerializer(ModelSerializer):
class Meta:
model = Role
fields = "__all__"


class APIKeySerializer(ModelSerializer):

username = ReadOnlyField(source="user.username")

class Meta:
model = APIKey
fields = "__all__"


class APIKeyAuditSerializer(ModelSerializer):
username = ReadOnlyField(source="user.username")

class Meta:
model = APIKey
fields = [
"name",
"username",
"expiration",
]
100 changes: 98 additions & 2 deletions api/tacticalrmm/accounts/tests.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from unittest.mock import patch

from django.test import override_settings

from accounts.models import User
from model_bakery import baker, seq
from accounts.models import User, APIKey
from tacticalrmm.test import TacticalTestCase

from accounts.serializers import APIKeySerializer


class TestAccounts(TacticalTestCase):
def setUp(self):
Expand Down Expand Up @@ -39,6 +41,12 @@ def test_check_creds(self):
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data, "ok")

# test user set to block dashboard logins
self.bob.block_dashboard_login = True
self.bob.save()
r = self.client.post(url, data, format="json")
self.assertEqual(r.status_code, 400)

@patch("pyotp.TOTP.verify")
def test_login_view(self, mock_verify):
url = "/login/"
Expand Down Expand Up @@ -288,6 +296,68 @@ def test_user_ui(self):
self.check_not_authenticated("patch", url)


class TestAPIKeyViews(TacticalTestCase):
def setUp(self):
self.setup_coresettings()
self.authenticate()

def test_get_api_keys(self):
url = "/accounts/apikeys/"
apikeys = baker.make("accounts.APIKey", key=seq("APIKEY"), _quantity=3)

serializer = APIKeySerializer(apikeys, many=True)
resp = self.client.get(url, format="json")
self.assertEqual(resp.status_code, 200)
self.assertEqual(serializer.data, resp.data) # type: ignore

self.check_not_authenticated("get", url)

def test_add_api_keys(self):
url = "/accounts/apikeys/"

user = baker.make("accounts.User")
data = {"name": "Name", "user": user.id, "expiration": None}

resp = self.client.post(url, data, format="json")
self.assertEqual(resp.status_code, 200)
self.assertTrue(APIKey.objects.filter(name="Name").exists())
self.assertTrue(APIKey.objects.get(name="Name").key)

self.check_not_authenticated("post", url)

def test_modify_api_key(self):
# test a call where api key doesn't exist
resp = self.client.put("/accounts/apikeys/500/", format="json")
self.assertEqual(resp.status_code, 404)

apikey = baker.make("accounts.APIKey", name="Test")
url = f"/accounts/apikeys/{apikey.pk}/" # type: ignore

data = {"name": "New Name"} # type: ignore

resp = self.client.put(url, data, format="json")
self.assertEqual(resp.status_code, 200)
apikey = APIKey.objects.get(pk=apikey.pk) # type: ignore
self.assertEquals(apikey.name, "New Name")

self.check_not_authenticated("put", url)

def test_delete_api_key(self):
# test a call where api key doesn't exist
resp = self.client.delete("/accounts/apikeys/500/", format="json")
self.assertEqual(resp.status_code, 404)

# test delete api key
apikey = baker.make("accounts.APIKey")
url = f"/accounts/apikeys/{apikey.pk}/" # type: ignore
resp = self.client.delete(url, format="json")
self.assertEqual(resp.status_code, 200)

self.assertFalse(APIKey.objects.filter(pk=apikey.pk).exists()) # type: ignore

self.check_not_authenticated("delete", url)


class TestTOTPSetup(TacticalTestCase):
def setUp(self):
self.authenticate()
Expand All @@ -313,3 +383,29 @@ def test_post_totp_set(self):
r = self.client.post(url)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data, "totp token already set")


class TestAPIAuthentication(TacticalTestCase):
def setUp(self):
# create User and associate to API Key
self.user = User.objects.create(username="api_user", is_superuser=True)
self.api_key = APIKey.objects.create(
name="Test Token", key="123456", user=self.user
)

self.client_setup()

def test_api_auth(self):
url = "/clients/clients/"
# auth should fail if no header set
self.check_not_authenticated("get", url)

# invalid api key in header should return code 400
self.client.credentials(HTTP_X_API_KEY="000000")
r = self.client.get(url, format="json")
self.assertEqual(r.status_code, 401)

# valid api key in header should return code 200
self.client.credentials(HTTP_X_API_KEY="123456")
r = self.client.get(url, format="json")
self.assertEqual(r.status_code, 200)
2 changes: 2 additions & 0 deletions api/tacticalrmm/accounts/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@
path("permslist/", views.PermsList.as_view()),
path("roles/", views.GetAddRoles.as_view()),
path("<int:pk>/role/", views.GetUpdateDeleteRole.as_view()),
path("apikeys/", views.GetAddAPIKeys.as_view()),
path("apikeys/<int:pk>/", views.GetUpdateDeleteAPIKey.as_view()),
]
Loading

0 comments on commit 8dddd2d

Please sign in to comment.