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..4ef64db2 --- /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..8b9d8d35 100644 --- a/api/tacticalrmm/accounts/views.py +++ b/api/tacticalrmm/accounts/views.py @@ -2,7 +2,10 @@ import pyotp from django.contrib.auth import login from django.conf import settings +from django.shortcuts import get_object_or_404 +from django.db import IntegrityError +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 @@ -10,6 +13,10 @@ from rest_framework.response import Response from rest_framework import status from .models import User +from agents.models import Agent +from tacticalrmm.utils import notify_error + +from .serializers import UserSerializer, TOTPSetupSerializer class CheckCreds(KnoxLoginView): @@ -19,6 +26,11 @@ class CheckCreds(KnoxLoginView): def post(self, request, format=None): serializer = AuthTokenSerializer(data=request.data) serializer.is_valid(raise_exception=True) + user = serializer.validated_data["user"] + + if not user.totp_key: + return Response("totp not set") + return Response("ok") @@ -46,3 +58,94 @@ 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): + agents = Agent.objects.values_list("agent_id", flat=True) + users = User.objects.exclude(username__in=agents) + + return Response(UserSerializer(users, many=True).data) + + def post(self, request): + # add new user + try: + user = User.objects.create_user( + request.data["username"], + request.data["email"], + request.data["password"], + ) + except IntegrityError: + return notify_error( + f"ERROR: User {request.data['username']} already exists!" + ) + + user.first_name = request.data["first_name"] + user.last_name = request.data["last_name"] + # Can be changed once permissions and groups are introduced + user.is_superuser = True + user.save() + return Response(user.username) + + +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): + get_object_or_404(User, pk=pk).delete() + + return Response("ok") + + +class UserActions(APIView): + + # reset password + def post(self, request): + + user = get_object_or_404(User, pk=request.data["id"]) + user.set_password(request.data["password"]) + user.save() + + return Response("ok") + + # reset two factor token + def put(self, request): + + user = get_object_or_404(User, pk=request.data["id"]) + user.totp_key = "" + user.save() + + return Response( + f"{user.username}'s Two-Factor key was reset. Have them sign in again to setup" + ) + + +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/admin.py b/api/tacticalrmm/alerts/admin.py index 8c38f3f3..072d7049 100644 --- a/api/tacticalrmm/alerts/admin.py +++ b/api/tacticalrmm/alerts/admin.py @@ -1,3 +1,6 @@ from django.contrib import admin -# Register your models here. +from .models import Alert + + +admin.site.register(Alert) 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/settings.py b/api/tacticalrmm/tacticalrmm/settings.py index 6558f03e..7239f4f0 100644 --- a/api/tacticalrmm/tacticalrmm/settings.py +++ b/api/tacticalrmm/tacticalrmm/settings.py @@ -11,7 +11,7 @@ AUTH_USER_MODEL = "accounts.User" # bump this version everytime vue code is changed # to alert user they need to manually refresh their browser -APP_VER = "0.0.23" +APP_VER = "0.0.24" # https://github.com/wh1te909/salt LATEST_SALT_VER = "1.0.3" 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..9b526e4f --- /dev/null +++ b/web/src/components/AdminManager.vue @@ -0,0 +1,322 @@ + + + \ No newline at end of file diff --git a/web/src/components/AlertsIcon.vue b/web/src/components/AlertsIcon.vue index b0e2602e..3256585e 100644 --- a/web/src/components/AlertsIcon.vue +++ b/web/src/components/AlertsIcon.vue @@ -3,21 +3,33 @@ {{ alertsLengthText() }} - No Alerts + No New Alerts - {{ alert.client }} - {{ alert.hostname }} - - + {{ alert.client }} - {{ alert.site }} - {{ alert.hostname }} + + {{ alert.message }} - {{ alert.alert_time }} + {{ alertTime(alert.alert_time) }} + + + + Snooze the alert for 24 hours + + + + + Dismiss alert + + + - View All Alerts + View All Alerts ({{ alerts.length }}) @@ -72,7 +84,8 @@ export default { }, computed: { ...mapGetters({ - alerts: "alerts/getNewAlerts" + newAlerts: "alerts/getNewAlerts", + alerts: "alerts/getAlerts" }) }, mounted() { diff --git a/web/src/components/FileBar.vue b/web/src/components/FileBar.vue index 4d6bd68a..778589f7 100644 --- a/web/src/components/FileBar.vue +++ b/web/src/components/FileBar.vue @@ -69,6 +69,10 @@ Automation Manager + + + User 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,8 +182,9 @@ export default { showUpdateAgentsModal: false, showEditCoreSettingsModal: false, showAutomationManager: false, + showAdminManager: false, showInstallAgent: false, - showUploadMesh: false + showUploadMesh: false, }; }, methods: { @@ -187,7 +200,7 @@ export default { }, edited() { this.$emit("edited"); - } - } + }, + }, }; diff --git a/web/src/components/modals/admin/UserForm.vue b/web/src/components/modals/admin/UserForm.vue new file mode 100644 index 00000000..e5848e52 --- /dev/null +++ b/web/src/components/modals/admin/UserForm.vue @@ -0,0 +1,163 @@ + + + \ 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..71f2d2c9 --- /dev/null +++ b/web/src/components/modals/admin/UserResetPasswordForm.vue @@ -0,0 +1,71 @@ + + + \ 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/components/modals/coresettings/EditCoreSettings.vue b/web/src/components/modals/coresettings/EditCoreSettings.vue index e7bc258d..5c4e4fde 100644 --- a/web/src/components/modals/coresettings/EditCoreSettings.vue +++ b/web/src/components/modals/coresettings/EditCoreSettings.vue @@ -240,10 +240,6 @@ export default { const removed = this.settings.email_alert_recipients.filter(k => k !== email); this.settings.email_alert_recipients = removed; }, - isValidEmail(val) { - const email = /^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/; - return email.test(val); - }, editSettings() { this.$q.loading.show(); axios diff --git a/web/src/mixins/mixins.js b/web/src/mixins/mixins.js index 50344c0e..e8f6ea6c 100644 --- a/web/src/mixins/mixins.js +++ b/web/src/mixins/mixins.js @@ -32,30 +32,38 @@ export function notifyInfoConfig(msg, timeout = 2000) { } }; +function getTimeLapse(unixtime) { + var previous = unixtime * 1000; + var current = new Date(); + var msPerMinute = 60 * 1000; + var msPerHour = msPerMinute * 60; + var msPerDay = msPerHour * 24; + var msPerMonth = msPerDay * 30; + var msPerYear = msPerDay * 365; + var elapsed = current - previous; + if (elapsed < msPerMinute) { + return Math.round(elapsed / 1000) + " seconds ago"; + } else if (elapsed < msPerHour) { + return Math.round(elapsed / msPerMinute) + " minutes ago"; + } else if (elapsed < msPerDay) { + return Math.round(elapsed / msPerHour) + " hours ago"; + } else if (elapsed < msPerMonth) { + return Math.round(elapsed / msPerDay) + " days ago"; + } else if (elapsed < msPerYear) { + return Math.round(elapsed / msPerMonth) + " months ago"; + } else { + return Math.round(elapsed / msPerYear) + " years ago"; + } +} + export default { methods: { bootTime(unixtime) { - var previous = unixtime * 1000; - var current = new Date(); - var msPerMinute = 60 * 1000; - var msPerHour = msPerMinute * 60; - var msPerDay = msPerHour * 24; - var msPerMonth = msPerDay * 30; - var msPerYear = msPerDay * 365; - var elapsed = current - previous; - if (elapsed < msPerMinute) { - return Math.round(elapsed / 1000) + " seconds ago"; - } else if (elapsed < msPerHour) { - return Math.round(elapsed / msPerMinute) + " minutes ago"; - } else if (elapsed < msPerDay) { - return Math.round(elapsed / msPerHour) + " hours ago"; - } else if (elapsed < msPerMonth) { - return Math.round(elapsed / msPerDay) + " days ago"; - } else if (elapsed < msPerYear) { - return Math.round(elapsed / msPerMonth) + " months ago"; - } else { - return Math.round(elapsed / msPerYear) + " years ago"; - } + return getTimeLapse(unixtime); + }, + alertTime(datetime) { + return getTimeLapse(Date.parse(datetime) / 1000); + }, notifySuccess(msg, timeout = 2000) { Notify.create(notifySuccessConfig(msg, timeout)); @@ -68,6 +76,10 @@ export default { }, notifyInfo(msg, timeout = 2000) { Notify.create(notifyInfoConfig(msg, timeout)); - } + }, + isValidEmail(val) { + const email = /^(?=[a-zA-Z0-9@._%+-]{6,254}$)[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,8}[a-zA-Z]{2,63}$/; + return email.test(val); + }, } }; 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..6b319295 --- /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/${pk}/users/`); + }, + addUser(context, data) { + return axios.post("/accounts/users/", data); + }, + editUser(context, data) { + return axios.put(`/accounts/${data.id}/users/`, data); + }, + deleteUser(context, pk) { + return axios.delete(`/accounts/${pk}/users/`).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..9ce9cded 100644 --- a/web/src/views/Login.vue +++ b/web/src/views/Login.vue @@ -70,7 +70,7 @@ export default { data() { return { credentials: {}, - prompt: false + prompt: false, }; }, @@ -79,7 +79,11 @@ 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"); @@ -97,11 +101,11 @@ export default { this.credentials = {}; this.prompt = false; }); - } + }, }, created() { this.$q.dark.set(true); - } + }, }; 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 @@ + + +