From 0c71dc29b343e7630a19c35be7044ff9810809fd Mon Sep 17 00:00:00 2001 From: Josh Krawczyk Date: Wed, 19 Aug 2020 18:22:44 -0400 Subject: [PATCH] user management addition --- api/tacticalrmm/accounts/serializers.py | 38 +++ api/tacticalrmm/accounts/urls.py | 10 + api/tacticalrmm/accounts/views.py | 91 +++++ api/tacticalrmm/alerts/serializers.py | 2 + api/tacticalrmm/tacticalrmm/urls.py | 1 + web/package-lock.json | 5 + web/package.json | 1 + web/src/components/AdminManager.vue | 310 ++++++++++++++++++ web/src/components/FileBar.vue | 15 +- web/src/components/modals/admin/UserForm.vue | 137 ++++++++ .../modals/admin/UserResetPasswordForm.vue | 57 ++++ .../modals/alerts/AlertsOverview.vue | 72 ++-- web/src/mixins/mixins.js | 7 +- web/src/router/routes.js | 5 + web/src/store/admin.js | 51 +++ web/src/store/index.js | 2 + web/src/views/Login.vue | 7 +- web/src/views/TOTPSetup.vue | 76 +++++ 18 files changed, 861 insertions(+), 26 deletions(-) create mode 100644 api/tacticalrmm/accounts/serializers.py create mode 100644 api/tacticalrmm/accounts/urls.py create mode 100644 web/src/components/AdminManager.vue create mode 100644 web/src/components/modals/admin/UserForm.vue create mode 100644 web/src/components/modals/admin/UserResetPasswordForm.vue create mode 100644 web/src/store/admin.js create mode 100644 web/src/views/TOTPSetup.vue diff --git a/api/tacticalrmm/accounts/serializers.py b/api/tacticalrmm/accounts/serializers.py new file mode 100644 index 00000000..2eeb9dce --- /dev/null +++ b/api/tacticalrmm/accounts/serializers.py @@ -0,0 +1,38 @@ +import pyotp + +from rest_framework.serializers import ( + ModelSerializer, + SerializerMethodField, +) + +from .models import User + + +class UserSerializer(ModelSerializer): + + class Meta: + model = User + fields = ( + "id", + "username", + "first_name", + "last_name", + "email", + "is_active", + "last_login", + ) + +class TOTPSetupSerializer(ModelSerializer): + + qr_url = SerializerMethodField() + + class Meta: + model = User + fields = ( + "username", + "totp_key", + "qr_url", + ) + + def get_qr_url(self, obj): + return pyotp.totp.TOTP(obj.totp_key).provisioning_uri(obj.username, issuer_name="Tactical RMM") diff --git a/api/tacticalrmm/accounts/urls.py b/api/tacticalrmm/accounts/urls.py new file mode 100644 index 00000000..703e4a2f --- /dev/null +++ b/api/tacticalrmm/accounts/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path("users/", views.GetAddUsers.as_view()), + path("users//", views.GetUpdateDeleteUser.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 1e640360..fc27dd85 100644 --- a/api/tacticalrmm/accounts/views.py +++ b/api/tacticalrmm/accounts/views.py @@ -2,7 +2,9 @@ import pyotp from django.contrib.auth import login from django.conf import settings +from django.shortcuts import get_object_or_404 +from rest_framework.views import APIView from rest_framework.authtoken.serializers import AuthTokenSerializer from knox.views import LoginView as KnoxLoginView from rest_framework.permissions import AllowAny @@ -11,6 +13,8 @@ from rest_framework import status from .models import User +from .serializers import UserSerializer, TOTPSetupSerializer + class CheckCreds(KnoxLoginView): @@ -19,6 +23,12 @@ class CheckCreds(KnoxLoginView): def post(self, request, format=None): serializer = AuthTokenSerializer(data=request.data) serializer.is_valid(raise_exception=True) + + user = User.objects.get(username=request.data["username"]) + + if not user.totp_key: + return Response("totp not set") + return Response("ok") @@ -46,3 +56,84 @@ class LoginView(KnoxLoginView): return super(LoginView, self).post(request, format=None) else: return Response("bad credentials", status=status.HTTP_400_BAD_REQUEST) + +class GetAddUsers(APIView): + def get(self, request): + users = User.objects.all() + + return Response(UserSerializer(users, many=True).data) + + def post(self, request): + + # Remove password from serializer + password = request.data.pop("password") + + serializer = UserSerializer(data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + user = serializer.save() + + # Can be changed once permissions and groups are introduced + user.is_superuser = True + user.set_password = password + user.save() + + return Response("ok") + +class GetUpdateDeleteUser(APIView): + def get(self, request, pk): + user = get_object_or_404(User, pk=pk) + + return Response(UserSerializer(user).data) + + def put(self, request, pk): + user = get_object_or_404(User, pk=pk) + + serializer = UserSerializer(instance=user, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response("ok") + + def delete(self, request, pk): + User.objects.get(pk=pk).delete() + + return Response("ok") + +class UserActions(APIView): + + # reset password + def post(self, request): + + user = User.objects.get(pk=request.data["id"]) + user.set_password(request.data["password"]) + user.save() + + return Response("ok") + + # reset two factor token + def put(self, request): + + user = User.objects.get(pk=request.data["id"]) + user.totp_key = "" + user.save() + + return Response("ok") + + +class TOTPSetup(APIView): + + permission_classes = (AllowAny,) + + # totp setup + def post(self, request): + + user = get_object_or_404(User, username=request.data["username"]) + + if not user.totp_key: + code = pyotp.random_base32() + user.totp_key = code + user.save(update_fields=["totp_key"]) + return Response(TOTPSetupSerializer(user).data) + + return Response("TOTP token already set") + diff --git a/api/tacticalrmm/alerts/serializers.py b/api/tacticalrmm/alerts/serializers.py index 09c20d9b..ab2801bb 100644 --- a/api/tacticalrmm/alerts/serializers.py +++ b/api/tacticalrmm/alerts/serializers.py @@ -1,6 +1,7 @@ from rest_framework.serializers import ( ModelSerializer, ReadOnlyField, + DateTimeField, ) from .models import Alert @@ -11,6 +12,7 @@ class AlertSerializer(ModelSerializer): hostname = ReadOnlyField(source="agent.hostname") client = ReadOnlyField(source="agent.client") site = ReadOnlyField(source="agent.site") + alert_time = DateTimeField(format="iso-8601") class Meta: model = Alert diff --git a/api/tacticalrmm/tacticalrmm/urls.py b/api/tacticalrmm/tacticalrmm/urls.py index 3b888ae4..763ef6ff 100644 --- a/api/tacticalrmm/tacticalrmm/urls.py +++ b/api/tacticalrmm/tacticalrmm/urls.py @@ -23,4 +23,5 @@ urlpatterns = [ path("logs/", include("logs.urls")), path("scripts/", include("scripts.urls")), path("alerts/", include("alerts.urls")), + path("accounts/", include("accounts.urls")) ] diff --git a/web/package-lock.json b/web/package-lock.json index 9aa4ca82..7b1911d9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15528,6 +15528,11 @@ "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=", "dev": true }, + "qrcode.vue": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-1.7.0.tgz", + "integrity": "sha512-R7t6Y3fDDtcU7L4rtqwGUDP9xD64gJhIwpfjhRCTKmBoYF6SS49PIJHRJ048cse6OI7iwTwgyy2C46N9Ygoc6g==" + }, "qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", diff --git a/web/package.json b/web/package.json index 695efb99..5dd59ada 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,7 @@ "@quasar/extras": "^1.9.0", "axios": "^0.19.2", "dotenv": "^8.2.0", + "qrcode.vue": "^1.7.0", "quasar": "^1.12.13" }, "devDependencies": { diff --git a/web/src/components/AdminManager.vue b/web/src/components/AdminManager.vue new file mode 100644 index 00000000..bbb9a4ed --- /dev/null +++ b/web/src/components/AdminManager.vue @@ -0,0 +1,310 @@ + + + \ No newline at end of file diff --git a/web/src/components/FileBar.vue b/web/src/components/FileBar.vue index 4d6bd68a..fec15587 100644 --- a/web/src/components/FileBar.vue +++ b/web/src/components/FileBar.vue @@ -69,6 +69,10 @@ Automation Manager + + + Administration + Global Settings @@ -124,6 +128,12 @@ + +
+ + + +
@@ -142,6 +152,7 @@ import UpdateAgents from "@/components/modals/agents/UpdateAgents"; import ScriptManager from "@/components/ScriptManager"; import EditCoreSettings from "@/components/modals/coresettings/EditCoreSettings"; import AutomationManager from "@/components/automation/AutomationManager"; +import AdminManager from "@/components/AdminManager"; import InstallAgent from "@/components/modals/agents/InstallAgent"; import UploadMesh from "@/components/modals/core/UploadMesh"; @@ -158,7 +169,8 @@ export default { EditCoreSettings, AutomationManager, InstallAgent, - UploadMesh + UploadMesh, + AdminManager }, props: ["clients"], data() { @@ -170,6 +182,7 @@ export default { showUpdateAgentsModal: false, showEditCoreSettingsModal: false, showAutomationManager: false, + showAdminManager: false, showInstallAgent: false, showUploadMesh: false }; diff --git a/web/src/components/modals/admin/UserForm.vue b/web/src/components/modals/admin/UserForm.vue new file mode 100644 index 00000000..27bba518 --- /dev/null +++ b/web/src/components/modals/admin/UserForm.vue @@ -0,0 +1,137 @@ + + + \ No newline at end of file diff --git a/web/src/components/modals/admin/UserResetPasswordForm.vue b/web/src/components/modals/admin/UserResetPasswordForm.vue new file mode 100644 index 00000000..0c5c6113 --- /dev/null +++ b/web/src/components/modals/admin/UserResetPasswordForm.vue @@ -0,0 +1,57 @@ + + + \ No newline at end of file diff --git a/web/src/components/modals/alerts/AlertsOverview.vue b/web/src/components/modals/alerts/AlertsOverview.vue index 554ce03c..6fe517fd 100644 --- a/web/src/components/modals/alerts/AlertsOverview.vue +++ b/web/src/components/modals/alerts/AlertsOverview.vue @@ -7,25 +7,58 @@ Close - - All Alerts - - - - - {{ alert.client }} - {{ alert.hostname }} - - - {{ alert.message }} - - - - {{ alert.timestamp }} - - - - + + + +
+ + + + + +
+ +
+ +
+
+ + + + + No Alerts! + + + {{ alert.client }} - {{ alert.site }} - {{ alert.hostname }} + + + {{ alert.message }} + + + + + {{ alertTime(alert.alert_time) }} + + + + Snooze the alert for 24 hours + + + + + Dismiss alert + + + + + + @@ -38,7 +71,8 @@ export default { mixins: [mixins], data() { return { - + search: "", + includeDismissed: false }; }, methods: { diff --git a/web/src/mixins/mixins.js b/web/src/mixins/mixins.js index d181260c..d30c5428 100644 --- a/web/src/mixins/mixins.js +++ b/web/src/mixins/mixins.js @@ -59,13 +59,10 @@ function getTimeLapse(unixtime) { export default { methods: { bootTime(unixtime) { - return getTimeLapse(unixtime) + return getTimeLapse(unixtime); }, alertTime(datetime) { - console.log(datetime) - var unixtime = new Date(datetime).getTime()/1000 - console.log(unixtime) - return getTimeLapse(parseInt(unixtime)) + return getTimeLapse(Date.parse(datetime)/1000); }, notifySuccess(msg, timeout = 2000) { diff --git a/web/src/router/routes.js b/web/src/router/routes.js index 9dcb4ba8..2ea2b51a 100644 --- a/web/src/router/routes.js +++ b/web/src/router/routes.js @@ -15,6 +15,11 @@ const routes = [ requireAuth: true } }, + { + path: "/totp_setup/:username", + name: "TOTPSetup", + component: () => import("@/views/TOTPSetup") + }, { path: "/takecontrol/:pk", name: "TakeControl", diff --git a/web/src/store/admin.js b/web/src/store/admin.js new file mode 100644 index 00000000..a431be5b --- /dev/null +++ b/web/src/store/admin.js @@ -0,0 +1,51 @@ +import axios from "axios"; + +export default { + namespaced: true, + state: { + users: [] + }, + + getters: { + users(state) { + return state.checks; + } + }, + + mutations: { + setUsers(state, users) { + state.users = users; + } + }, + + actions: { + loadUsers(context) { + return axios.get("/accounts/users/").then(r => { + context.commit("setUsers", r.data); + }) + }, + loadUser(context, pk) { + return axios.get(`/accounts/users/${pk}/`); + }, + addUser(context, data) { + return axios.post("/accounts/users/", data); + }, + editUser(context, data) { + return axios.put(`/accounts/users/${data.id}/`, data); + }, + deleteUser(context, pk) { + return axios.delete(`/accounts/users/${pk}/`).then(r => { + context.dispatch("loadUsers"); + }); + }, + resetUserPassword(context, data) { + return axios.post("/accounts/users/reset/", data); + }, + resetUserTOTP(context, data) { + return axios.put("/accounts/users/reset_totp/", data); + }, + setupTOTP(context, data) { + return axios.post("/accounts/users/setup_totp/", data); + } + } +} diff --git a/web/src/store/index.js b/web/src/store/index.js index d27fb233..702dcd75 100644 --- a/web/src/store/index.js +++ b/web/src/store/index.js @@ -5,6 +5,7 @@ import { Notify } from "quasar"; import logModule from "./logs"; import alertsModule from "./alerts"; import automationModule from "./automation"; +import adminModule from "./admin.js" Vue.use(Vuex); @@ -14,6 +15,7 @@ export default function () { logs: logModule, automation: automationModule, alerts: alertsModule, + admin: adminModule }, state: { username: localStorage.getItem("user_name") || null, diff --git a/web/src/views/Login.vue b/web/src/views/Login.vue index 2938b3eb..784cb99b 100644 --- a/web/src/views/Login.vue +++ b/web/src/views/Login.vue @@ -79,7 +79,12 @@ export default { axios .post("/checkcreds/", this.credentials) .then(r => { - this.prompt = true; + if (r.data === "totp not set") { + this.$router.push({name: "TOTPSetup", params: { username: this.credentials.username }}) + } else { + + this.prompt = true; + } }) .catch(() => { this.notifyError("Bad credentials"); diff --git a/web/src/views/TOTPSetup.vue b/web/src/views/TOTPSetup.vue new file mode 100644 index 00000000..f7ed7d40 --- /dev/null +++ b/web/src/views/TOTPSetup.vue @@ -0,0 +1,76 @@ + + +