added user session tracking, social accoutn tracking, and implemented local user logon blocking

This commit is contained in:
sadnub
2024-09-28 15:33:15 -04:00
committed by wh1te909
parent 09e39ef6da
commit c31ed666b5
15 changed files with 690 additions and 156 deletions

View File

@@ -31,6 +31,34 @@ export async function resetTwoFactor() {
}
}
// sessions api
export async function fetchUserSessions(id) {
try {
const { data } = await axios.get(`${baseUrl}/users/${id}/sessions/`);
return data;
} catch (e) {
console.error(e);
}
}
export async function deleteAllUserSessions(id) {
try {
const { data } = await axios.delete(`${baseUrl}/users/${id}/sessions/`);
return data;
} catch (e) {
console.error(e);
}
}
export async function deleteUserSession(id) {
try {
const { data } = await axios.delete(`${baseUrl}/sessions/${id}/`);
return data;
} catch (e) {
console.error(e);
}
}
// role api function
export async function fetchRoles(params = {}) {
try {

View File

@@ -8,8 +8,15 @@ import type {
TestRunURLActionResponse,
} from "@/types/core/urlactions";
import type { CoreSetting } from "@/types/core/settings";
const baseUrl = "/core";
export async function fetchCoreSettings(params = {}): Promise<CoreSetting> {
const { data } = await axios.get("/core/settings/", { params: params });
return data;
}
export async function fetchDashboardInfo(params = {}) {
const { data } = await axios.get(`${baseUrl}/dashinfo/`, { params: params });
return data;

View File

@@ -1,159 +1,191 @@
<template>
<div style="width: 900px; max-width: 90vw">
<q-card>
<q-bar>
<q-card style="width: 900px; max-width: 90vw; min-height: 50vh">
<q-bar>
<q-btn
ref="refresh"
@click="getUsers"
class="q-mr-sm"
dense
flat
push
icon="refresh"
/>User Administration
<q-space />
<q-btn dense flat icon="close" v-close-popup>
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<div class="q-pa-md">
<div class="q-gutter-sm">
<q-btn
ref="refresh"
@click="getUsers"
class="q-mr-sm"
ref="new"
label="New"
dense
flat
push
icon="refresh"
/>User Administration
<q-space />
<q-btn dense flat icon="close" v-close-popup>
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<div class="q-pa-md">
<div class="q-gutter-sm">
<q-btn
ref="new"
label="New"
dense
flat
push
unelevated
no-caps
icon="add"
@click="showAddUserModal"
/>
</div>
<q-table
dense
:rows="users"
:columns="columns"
v-model:pagination="pagination"
row-key="id"
binary-state-sort
hide-pagination
virtual-scroll
>
<!-- header slots -->
<template v-slot:header-cell-is_active="props">
<q-th :props="props" auto-width>
<q-icon name="power_settings_new" size="1.5em">
<q-tooltip>Enable User</q-tooltip>
</q-icon>
</q-th>
</template>
<!-- No data Slot -->
<template v-slot:no-data>
<div class="full-width row flex-center q-gutter-sm">
<span v-if="users.length === 0">No Users</span>
</div>
</template>
<!-- body slots -->
<template v-slot:body="props">
<q-tr
:props="props"
class="cursor-pointer"
@dblclick="showEditUserModal(props.row)"
>
<!-- context menu -->
<q-menu context-menu>
<q-list dense style="min-width: 200px">
<q-item
clickable
v-close-popup
@click="showEditUserModal(props.row)"
>
<q-item-section side>
<q-icon name="edit" />
</q-item-section>
<q-item-section>Edit</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="deleteUser(props.row)"
:disable="props.row.username === logged_in_user"
>
<q-item-section side>
<q-icon name="delete" />
</q-item-section>
<q-item-section>Delete</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item
clickable
v-close-popup
@click="ResetPassword(props.row)"
id="context-reset"
>
<q-item-section side>
<q-icon name="autorenew" />
</q-item-section>
<q-item-section>Reset Password</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="reset2FA(props.row)"
id="context-reset"
>
<q-item-section side>
<q-icon name="autorenew" />
</q-item-section>
<q-item-section>Reset Two-Factor Auth</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item clickable v-close-popup>
<q-item-section>Close</q-item-section>
</q-item>
</q-list>
</q-menu>
<!-- enabled checkbox -->
<q-td>
<q-checkbox
dense
@update:model-value="toggleEnabled(props.row)"
v-model="props.row.is_active"
:disable="props.row.username === logged_in_user"
/>
</q-td>
<q-td>{{ props.row.username }}</q-td>
<q-td>{{ props.row.first_name }} {{ props.row.last_name }}</q-td>
<q-td>{{ props.row.email }}</q-td>
<q-td v-if="props.row.last_login">{{
formatDate(props.row.last_login)
}}</q-td>
<q-td v-else>Never</q-td>
<q-td>{{ props.row.last_login_ip }}</q-td>
</q-tr>
</template>
</q-table>
unelevated
no-caps
icon="add"
@click="showAddUserModal"
/>
</div>
</q-card>
</div>
<q-table
dense
:rows="users"
:columns="columns"
v-model:pagination="pagination"
row-key="id"
binary-state-sort
hide-pagination
virtual-scroll
>
<!-- header slots -->
<template v-slot:header-cell-is_active="props">
<q-th :props="props" auto-width>
<q-icon name="power_settings_new" size="1.5em">
<q-tooltip>Enable User</q-tooltip>
</q-icon>
</q-th>
</template>
<!-- No data Slot -->
<template v-slot:no-data>
<div class="full-width row flex-center q-gutter-sm">
<span v-if="users.length === 0">No Users</span>
</div>
</template>
<!-- body slots -->
<template v-slot:body="props">
<q-tr
:props="props"
class="cursor-pointer"
@dblclick="showEditUserModal(props.row)"
>
<!-- context menu -->
<q-menu context-menu>
<q-list dense style="min-width: 200px">
<q-item
clickable
v-close-popup
@click="showEditUserModal(props.row)"
>
<q-item-section side>
<q-icon name="edit" />
</q-item-section>
<q-item-section>Edit</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="deleteUser(props.row)"
:disable="props.row.username === logged_in_user"
>
<q-item-section side>
<q-icon name="delete" />
</q-item-section>
<q-item-section>Delete</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item
clickable
v-close-popup
@click="ResetPassword(props.row)"
id="context-reset"
:disable="localLogonDisabled"
>
<q-item-section side>
<q-icon name="autorenew" />
</q-item-section>
<q-item-section>Reset Password</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="reset2FA(props.row)"
id="context-reset"
:disable="localLogonDisabled"
>
<q-item-section side>
<q-icon name="autorenew" />
</q-item-section>
<q-item-section>Reset Two-Factor Auth</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item
clickable
v-close-popup
@click="showSSOAccounts(props.row)"
id="context-reset"
:disable="props.row.social_accounts.length === 0"
>
<q-item-section side>
<q-icon name="groups" />
</q-item-section>
<q-item-section>Show Connected SSO Accounts</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="showSessions(props.row)"
id="context-reset"
>
<q-item-section side>
<q-icon name="groups" />
</q-item-section>
<q-item-section>Show Active Sessions</q-item-section>
</q-item>
<q-separator></q-separator>
<q-item clickable v-close-popup>
<q-item-section>Close</q-item-section>
</q-item>
</q-list>
</q-menu>
<!-- enabled checkbox -->
<q-td>
<q-checkbox
dense
@update:model-value="toggleEnabled(props.row)"
v-model="props.row.is_active"
:disable="props.row.username === logged_in_user"
/>
</q-td>
<q-td>{{ props.row.username }}</q-td>
<q-td>{{ props.row.first_name }} {{ props.row.last_name }}</q-td>
<q-td>{{ props.row.email }}</q-td>
<q-td v-if="props.row.last_login">{{
formatDate(props.row.last_login)
}}</q-td>
<q-td v-else>Never</q-td>
<q-td>{{ props.row.last_login_ip }}</q-td>
</q-tr>
</template>
</q-table>
</div>
</q-card>
</template>
<script>
import mixins from "@/mixins/mixins";
import { computed } from "vue";
import { computed, onMounted, ref } from "vue";
import { useStore } from "vuex";
import { useQuasar } from "quasar";
import { fetchCoreSettings } from "@/api/core";
import { mapState as piniaMapState } from "pinia";
import { useAuthStore } from "@/stores/auth";
import UserForm from "@/components/modals/admin/UserForm.vue";
import UserResetPasswordForm from "@/components/modals/admin/UserResetPasswordForm.vue";
import SSOAccountsTable from "@/ee/sso/components/SSOAccountsTable.vue";
import UserSessionsTable from "@/components/accounts/UserSessionsTable.vue";
export default {
name: "AdminManager",
@@ -163,8 +195,41 @@ export default {
const store = useStore();
const formatDate = computed(() => store.getters.formatDate);
const $q = useQuasar();
const localLogonDisabled = ref(false);
function showSSOAccounts(user) {
$q.dialog({
component: SSOAccountsTable,
componentProps: {
user,
},
});
}
async function getCoreSettings() {
const result = await fetchCoreSettings();
localLogonDisabled.value = result.block_local_user_logon;
}
async function showSessions(user) {
$q.dialog({
component: UserSessionsTable,
componentProps: {
user,
},
});
}
onMounted(getCoreSettings);
return {
localLogonDisabled,
formatDate,
showSSOAccounts,
showSessions,
};
},
data() {

View File

@@ -0,0 +1,150 @@
<template>
<q-dialog ref="dialogRef" @hide="onDialogHide">
<q-card style="width: 60vw; max-width: 90vw; min-height: 40vh">
<q-bar>
User Sessions for {{ user.username }}
<q-space />
<q-btn v-close-popup dense flat icon="close">
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<q-table
dense
:table-class="{
'table-bgcolor': !$q.dark.isActive,
'table-bgcolor-dark': $q.dark.isActive,
}"
:style="{ 'max-height': `${$q.screen.height - 24}px` }"
class="tbl-sticky"
:rows="sessions"
:columns="columns"
:loading="loading"
:pagination="{ rowsPerPage: 0, sortBy: 'display', descending: true }"
row-key="id"
binary-state-sort
virtual-scroll
:rows-per-page-options="[0]"
>
<template #top>
<q-space />
<q-btn
label="Remove All Sessions"
@click="removeAllSessions"
size="sm"
color="negative"
/>
</template>
<template #body="props">
<q-tr>
<!-- rows -->
<td>{{ props.row.created }}</td>
<td>{{ props.row.expiry }}</td>
<td>
<q-btn
size="sm"
@click="removeSession(props.row)"
label="Disconnect"
color="negative"
></q-btn>
</td>
</q-tr>
</template>
</q-table>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
// composition imports
import { onMounted, ref } from "vue";
import { useDialogPluginComponent, useQuasar, type QTableColumn } from "quasar";
import { notifySuccess } from "@/utils/notify";
import {
fetchUserSessions,
deleteAllUserSessions,
deleteUserSession,
} from "@/api/accounts";
//types
import type { SSOUser } from "@/ee/sso/types/sso";
import type { AuthToken } from "@/types/accounts";
const columns: QTableColumn[] = [
{
name: "created",
label: "Created",
field: "created",
align: "left",
sortable: true,
},
{
name: "expiry",
label: "Expires",
field: "expiry",
align: "left",
sortable: true,
},
{
name: "action",
label: "",
field: "action",
align: "left",
sortable: true,
},
];
// emits
defineEmits([...useDialogPluginComponent.emits]);
// props
const props = defineProps<{
user: SSOUser;
}>();
const { dialogRef, onDialogHide } = useDialogPluginComponent();
const $q = useQuasar();
const sessions = ref([] as AuthToken[]);
const loading = ref(false);
function removeSession(token: AuthToken) {
$q.dialog({
title: `Disconnect session for ${token.user}?`,
cancel: true,
ok: { label: "Delete", color: "negative" },
}).onOk(async () => {
loading.value = true;
try {
await deleteUserSession(token.digest);
notifySuccess("Login session deleted successfully");
} finally {
loading.value = false;
await getSessions();
}
});
}
function removeAllSessions() {
$q.dialog({
title: `Disconnect all sessions for ${props.user.username}?`,
cancel: true,
ok: { label: "Delete", color: "negative" },
}).onOk(async () => {
loading.value = true;
try {
await deleteAllUserSessions(props.user.id);
notifySuccess("Login sessions deleted successfully");
} finally {
loading.value = false;
onDialogHide();
}
});
}
async function getSessions() {
sessions.value = await fetchUserSessions(props.user.id);
}
onMounted(getSessions);
</script>

View File

@@ -739,7 +739,6 @@ export default {
URLActionsTable,
APIKeysTable,
SSOProvidersTable,
// ServerTasksTable,
},
mixins: [mixins],
data() {

View File

@@ -34,16 +34,7 @@ function postForm(url: string, data: FormData) {
f.submit();
}
export interface MetaIsAuthenticated {
is_authenticated: boolean;
}
// sso providers
export interface AllAuthResponse<T> {
data: T;
status: number;
meta?: MetaIsAuthenticated;
}
export async function fetchSSOProviders(): Promise<SSOProvider> {
const { data } = await axios.get(`${baseUrl}/ssoproviders/`);
@@ -65,6 +56,24 @@ export async function removeSSOProvider(id: number) {
return data;
}
export interface SSOSettings {
block_local_user_logon: boolean;
}
export async function fetchSSOSettings(): Promise<SSOSettings> {
const { data } = await axios.get(`${baseUrl}/ssoproviders/settings/`);
return data;
}
export async function updateSSOSettings(settings: SSOSettings) {
console.log(settings);
const { data } = await axios.post(
`${baseUrl}/ssoproviders/settings/`,
settings,
);
return data;
}
export async function getSSOProviderToken() {
const { data } = await axios.post(
`${baseUrl}/ssoproviders/token/`,
@@ -76,6 +85,17 @@ export async function getSSOProviderToken() {
return data;
}
// allauth
const allauthBase = "_allauth/browser/v1";
export interface AllAuthResponse<T> {
data: T;
status: number;
meta?: {
is_autheticated: boolean;
};
}
export interface SSOProviderConfig {
client_id: string;
flows: string[];
@@ -92,12 +112,29 @@ export interface SSOConfigResponse {
export async function getSSOConfig(): Promise<
AllAuthResponse<SSOConfigResponse>
> {
const { data } = await axios.get("_allauth/browser/v1/config");
const { data } = await axios.get(`${allauthBase}/config`);
return data;
}
export interface SSOAccountsResponse {
uid: string;
display: string;
provider: SSOProviderConfig;
}
export async function disconnectSSOAccount(
provider: string,
account: string,
): Promise<AllAuthResponse<SSOAccountsResponse[]>> {
const { data } = await axios.delete(`${allauthBase}/account/providers`, {
data: { provider, account },
headers: { "X-CSRFToken": getCSRFToken() },
});
return data;
}
export async function openSSOProviderRedirect(id: string) {
postForm(`${getBaseUrl()}/_allauth/browser/v1/auth/provider/redirect`, {
postForm(`${getBaseUrl()}/${allauthBase}/auth/provider/redirect`, {
provider: id,
process: "login",
callback_url: `${location.origin}/account/provider/callback`,

View File

@@ -0,0 +1,141 @@
<!--
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
-->
<template>
<q-dialog ref="dialogRef" @hide="onDialogHide">
<q-card style="width: 60vw; max-width: 90vw; min-height: 40vh">
<q-bar>
Connected Social Accounts for {{ user.username }}
<q-space />
<q-btn v-close-popup dense flat icon="close">
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
</q-btn>
</q-bar>
<q-table
dense
:table-class="{
'table-bgcolor': !$q.dark.isActive,
'table-bgcolor-dark': $q.dark.isActive,
}"
:style="{ 'max-height': `${$q.screen.height - 24}px` }"
class="tbl-sticky"
:rows="user.social_accounts"
:columns="columns"
:loading="loading"
:pagination="{ rowsPerPage: 0, sortBy: 'display', descending: true }"
row-key="id"
binary-state-sort
virtual-scroll
:rows-per-page-options="[0]"
>
<template #body="props">
<q-tr>
<!-- rows -->
<td>{{ props.row.display }}</td>
<td>{{ props.row.provider }}</td>
<td>{{ props.row.last_login }}</td>
<td>{{ props.row.date_joined }}</td>
<td>
<q-btn
size="sm"
@click="removeSSOAccount(props.row)"
label="Disconnect"
color="negative"
></q-btn>
</td>
</q-tr>
</template>
</q-table>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
// composition imports
import { ref } from "vue";
import { useDialogPluginComponent, useQuasar, type QTableColumn } from "quasar";
import { disconnectSSOAccount } from "@/ee/sso/api/sso";
import { notifySuccess } from "@/utils/notify";
import { useAuthStore } from "@/stores/auth";
//types
import type { SSOAccount, SSOUser } from "../types/sso";
const columns: QTableColumn[] = [
{
name: "display",
label: "Display Name",
field: "display",
align: "left",
sortable: true,
},
{
name: "provider",
label: "Provider",
field: "provider",
align: "left",
sortable: true,
},
{
name: "last_login",
label: "Last Login",
field: "last_login",
align: "left",
sortable: true,
},
{
name: "date_joined",
label: "Date Joined",
field: "date_joined",
align: "left",
sortable: true,
},
{
name: "action",
label: "",
field: "action",
align: "left",
sortable: true,
},
];
// emits
defineEmits([...useDialogPluginComponent.emits]);
// props
const props = defineProps<{
user: SSOUser;
}>();
const { dialogRef, onDialogHide } = useDialogPluginComponent();
const $q = useQuasar();
const auth = useAuthStore();
const loading = ref(false);
function removeSSOAccount(account: SSOAccount) {
$q.dialog({
title: `Disconnect social account: ${account.display}? If you are signed in with this account you will be logged off`,
cancel: true,
ok: { label: "Delete", color: "negative" },
}).onOk(async () => {
loading.value = true;
try {
await disconnectSSOAccount(account.provider, account.uid);
notifySuccess("Social account disconnected successfully");
if (
auth.username === props.user.username &&
auth.ssoLoginProvider === account.provider
) {
await auth.logout();
}
} finally {
loading.value = false;
onDialogHide();
}
});
}
</script>

View File

@@ -1,3 +1,9 @@
<!--
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
-->
<template>
<q-dialog ref="dialogRef" @hide="onDialogHide">
<q-card class="q-dialog-plugin" style="width: 50">

View File

@@ -1,3 +1,9 @@
<!--
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
-->
<template>
<div>
<div class="row">
@@ -26,6 +32,23 @@
no-data-label="No SSO Providers added yet"
:loading="loading"
>
<template v-slot:top>
<q-space />
<q-btn
@click="
changeSSOSettings({
block_local_user_logon: !ssoSettings.block_local_user_logon,
})
"
:label="
ssoSettings.block_local_user_logon
? 'Allow Local Logon'
: 'Block Local Logon'
"
no-caps
color="primary"
/>
</template>
<!-- body slots -->
<template v-slot:body="props">
<q-tr
@@ -86,7 +109,13 @@
// composition imports
import { ref, onMounted } from "vue";
import { QTableColumn, useQuasar } from "quasar";
import { fetchSSOProviders, removeSSOProvider } from "@/ee/sso/api/sso";
import {
fetchSSOProviders,
removeSSOProvider,
fetchSSOSettings,
updateSSOSettings,
type SSOSettings,
} from "@/ee/sso/api/sso";
import { notifySuccess } from "@/utils/notify";
// ui imports
@@ -101,7 +130,7 @@ const $q = useQuasar();
const loading = ref(false);
const providers = ref([] as SSOProvider[]);
const ssoSettings = ref({} as SSOSettings);
const columns: QTableColumn[] = [
{
name: "name",
@@ -136,6 +165,28 @@ async function getSSOProviders() {
loading.value = false;
}
async function getSSOSettings() {
loading.value = true;
try {
ssoSettings.value = await fetchSSOSettings();
} catch (e) {
console.error(e);
}
loading.value = false;
}
async function changeSSOSettings(data: SSOSettings) {
loading.value = true;
try {
ssoSettings.value = await updateSSOSettings(data);
await getSSOSettings();
notifySuccess("Settings updated successfully");
} catch (e) {
console.error(e);
}
loading.value = false;
}
function addSSOProvider() {
$q.dialog({
component: SSOProvidersForm,
@@ -168,5 +219,9 @@ function deleteSSOProvider(provider: SSOProvider) {
loading.value = false;
});
}
onMounted(getSSOProviders);
onMounted(async () => {
await getSSOProviders();
await getSSOSettings();
});
</script>

View File

@@ -1,3 +1,10 @@
/*
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
*/
import { User } from "@/types/accounts";
export interface SSOProvider {
id: number;
name: string;
@@ -6,3 +13,15 @@ export interface SSOProvider {
secret: string;
server_url: string;
}
export interface SSOAccount {
uid: string;
display: string;
provider: string;
last_login: string;
date_joined: string;
}
export interface SSOUser extends User {
social_accounts: SSOAccount[];
}

View File

@@ -1,3 +1,9 @@
/*
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
*/
export function getCookie(name: string) {
let cookieValue = null;
if (document.cookie && document.cookie !== "") {

View File

@@ -1,3 +1,9 @@
<!--
Copyright (c) 2023-present Amidaware Inc.
This file is subject to the EE License Agreement.
For details, see: https://license.tacticalrmm.com/ee
-->
<template>
<div class="fixed-center text-center" v-if="error">
<p class="text-faded">There was an error logging into your provider.</p>

View File

@@ -29,6 +29,7 @@ export const useAuthStore = defineStore("auth", {
state: () => ({
username: useStorage("user_name", null),
token: useStorage("access_token", null),
ssoLoginProvider: useStorage("sso_provider", null),
}),
getters: {
loggedIn: (state) => {
@@ -51,6 +52,7 @@ export const useAuthStore = defineStore("auth", {
const { data } = await axios.post("/v2/login/", credentials);
this.username = data.username;
this.token = data.token;
this.ssoLoginProvider = null;
return data;
},
@@ -62,6 +64,7 @@ export const useAuthStore = defineStore("auth", {
}
this.token = null;
this.username = null;
this.ssoLoginProvider = null;
},
async setupTotp(): Promise<TOTPSetupResponse | false> {
const { data } = await axios.post("/accounts/users/setup_totp/");

View File

@@ -1,4 +1,13 @@
export interface User {
id: number;
username: string;
name: string;
email: string;
}
export interface AuthToken {
digest: string;
created: string;
expiry: string;
user: string;
}

View File

@@ -0,0 +1,3 @@
export interface CoreSetting {
block_local_user_logon: boolean;
}