diff options
author | vnugent <public@vaughnnugent.com> | 2024-01-20 23:49:29 -0500 |
---|---|---|
committer | vnugent <public@vaughnnugent.com> | 2024-01-20 23:49:29 -0500 |
commit | 6cb7da37824d02a1898d08d0f9495c77fde4dd1d (patch) | |
tree | 95e37ea3c20f416d6a205ee4ab050c307b18eafe /front-end/src/components/Settings |
inital commit
Diffstat (limited to 'front-end/src/components/Settings')
-rw-r--r-- | front-end/src/components/Settings/Oauth2Apps.vue | 85 | ||||
-rw-r--r-- | front-end/src/components/Settings/PasswordReset.vue | 156 | ||||
-rw-r--r-- | front-end/src/components/Settings/PkiSettings.vue | 216 | ||||
-rw-r--r-- | front-end/src/components/Settings/TotpSettings.vue | 169 |
4 files changed, 626 insertions, 0 deletions
diff --git a/front-end/src/components/Settings/Oauth2Apps.vue b/front-end/src/components/Settings/Oauth2Apps.vue new file mode 100644 index 0000000..11f5d1e --- /dev/null +++ b/front-end/src/components/Settings/Oauth2Apps.vue @@ -0,0 +1,85 @@ +<script setup lang="ts"> +import { defineAsyncComponent, shallowRef } from 'vue'; +import { useStore } from '../../store'; +import { formatTimeAgo, set } from '@vueuse/core'; +import { OAuth2Application } from '../../store/userProfile'; +import { ServerDataBuffer, useDataBuffer } from '@vnuge/vnlib.browser'; +const Dialog = defineAsyncComponent(() => import('../global/Dialog.vue')); + +const { oauth2 } = useStore(); + +const editBuffer = shallowRef<ServerDataBuffer<OAuth2Application, void> | undefined>(); +const editApp = (app: OAuth2Application) =>{ + //Init new server data buffer to push updates + const buffer = useDataBuffer(app, ({ buffer }) => oauth2.updateMeta(buffer)); + set(editBuffer, buffer); +} + +const cancelEdit = () => set(editBuffer, undefined); + +</script> + +<template> + <div class=""> + <div class="flex flex-row justify-between"> + <h3 class="text-xl font-bold">OAuth2 Apps</h3> + <div class=""> + + <button type="button" class="btn blue" @click.prevent=""> + <div class="flex flex-row items-center gap-1.5 "> + New + </div> + </button> + + </div> + </div> + + <div class="relative mt-2 overflow-x-auto shadow-md sm:rounded"> + <table class="w-full text-sm text-left text-gray-500 rtl:text-right dark:text-gray-400"> + <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"> + <tr> + <th scope="col" class="px-4 py-2"> + Client ID + </th> + <th scope="col" class="px-4 py-2"> + Name + </th> + <th scope="col" class="px-4 py-2"> + Created + </th> + <th scope="col" class="px-4 py-2"> + Action + </th> + </tr> + </thead> + <tbody> + <tr v-for="app in oauth2.apps" :key="app.Id" class="bg-white border-b dark:bg-gray-800 dark:border-gray-700"> + <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + {{ app.client_id }} + </th> + <td class="px-4 py-3"> + {{ app.name }} + </td> + <td class="px-4 py-3"> + {{ formatTimeAgo(app.Created) }} + </td> + <td class="px-4 py-3"> + <button + @click.prevent="editApp(app)" + class="font-medium text-blue-600 dark:text-blue-500 hover:underline"> + Edit + </button> + </td> + </tr> + </tbody> + </table> + </div> + <Dialog :open="editBuffer!= undefined" title="Edit App" @cancel="cancelEdit"> + <template #body> + Hello world + </template> + </Dialog> + </div> +</template> + +<style lang="scss"></style>
\ No newline at end of file diff --git a/front-end/src/components/Settings/PasswordReset.vue b/front-end/src/components/Settings/PasswordReset.vue new file mode 100644 index 0000000..41633b7 --- /dev/null +++ b/front-end/src/components/Settings/PasswordReset.vue @@ -0,0 +1,156 @@ +<script setup lang="ts"> +import { computed, defineAsyncComponent, shallowReactive } from 'vue'; +import { useStore } from '../../store'; +import { get, useToggle } from '@vueuse/core'; +import { MfaMethod, apiCall, useUser, useVuelidateWrapper } from '@vnuge/vnlib.browser'; +import { includes, isEmpty, toSafeInteger } from 'lodash-es'; +import { useVuelidate } from '@vuelidate/core' +import { required, maxLength, minLength, helpers } from '@vuelidate/validators' +const Dialog = defineAsyncComponent(() => import('../global/Dialog.vue')); + +const store = useStore(); +const { resetPassword } = useUser() +const totpEnabled = computed(() => includes(store.mfaEndabledMethods, MfaMethod.TOTP)) + +const [ isOpen, toggleOpen ] = useToggle(false) +const vState = shallowReactive({ + current: '', + newPassword: '', + repeatPassword: '', + totpCode: '' +}) + +const rules = computed(() => { + return { + current: { + required: helpers.withMessage('Current password cannot be empty', required), + minLength: helpers.withMessage('Current password must be at least 8 characters', minLength(8)), + maxLength: helpers.withMessage('Current password must have less than 128 characters', maxLength(128)) + }, + newPassword: { + notOld: helpers.withMessage('New password cannot be the same as your current password', (value: string) => value != vState.current), + required: helpers.withMessage('New password cannot be empty', required), + minLength: helpers.withMessage('New password must be at least 8 characters', minLength(8)), + maxLength: helpers.withMessage('New password must have less than 128 characters', maxLength(128)) + }, + repeatPassword: { + sameAs: helpers.withMessage('Your new passwords do not match', (value: string) => value == vState.newPassword), + required: helpers.withMessage('Repeast password cannot be empty', required), + minLength: helpers.withMessage('Repeast password must be at least 8 characters', minLength(8)), + maxLength: helpers.withMessage('Repeast password must have less than 128 characters', maxLength(128)) + }, + totpCode: { + required: helpers.withMessage('TOTP code cannot be empty', (value: string) => get(totpEnabled) ? !isEmpty(value) : true), + minLength: helpers.withMessage('TOTP code must be at least 6 characters', minLength(6)), + maxLength: helpers.withMessage('TOTP code must have less than 12 characters', maxLength(12)) + } + } +}) + +const v$ = useVuelidate(rules, vState) +const { validate } = useVuelidateWrapper(v$ as any) + +const onSubmit = async () => { + if (!await validate()) return + + apiCall(async ({toaster}) => { + //Rest password and pass totp code + const { getResultOrThrow } = await resetPassword(vState.current, vState.newPassword, { totp_code: toSafeInteger(vState.totpCode) }) + getResultOrThrow() + + toaster.general.success({ + title: 'Password Reset', + text: 'Your password has been reset' + }); + + onCancel() + }) +} + +const onCancel = () => { + vState.current = '' + vState.newPassword = '' + vState.repeatPassword = '' + vState.totpCode = '' + v$.value.$reset() + toggleOpen(false) +} + +</script> + +<template> + <div class=""> + + <div class="flex flex-row justify-between w-full"> + <h3 class="text-xl font-bold">Password</h3> + + <div class="flex flex-row justify-end"> + <button class="btn blue" @click="toggleOpen(true)">Reset Password</button> + </div> + </div> + + <Dialog :open="isOpen" title="Reset Password" @cancel="onCancel"> + <template #body> + <div class="p-4"> + + <form @submit.prevent="onSubmit"> + + <div class="mb-4"> + <label for="current_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Current</label> + <input + v-model="v$.current.$model" + type="password" + id="current_password" + class="input" + :class="{ 'error': v$.current.$error, 'dirty': v$.current.$dirty }" + placeholder="•••••••••" + required + > + </div> + <div class="mb-4"> + <label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">New Password</label> + <input + v-model="v$.newPassword.$model" + type="password" + id="password" + class="input" + :class="{ 'error': v$.newPassword.$error, 'dirty': v$.newPassword.$dirty }" + placeholder="•••••••••" + required + > + </div> + <div class="mb-4"> + <label for="confirm_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Confirm password</label> + <input + v-model="v$.repeatPassword.$model" + type="password" + id="confirm_password" + class="input" + :class="{ 'error': v$.repeatPassword.$error, 'dirty': v$.repeatPassword.$dirty }" + placeholder="•••••••••" + required + > + </div> + <div v-if="totpEnabled" class="mb-4"> + <label for="totp_code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">TOTP Code</label> + <input + v-model="v$.totpCode.$model" + type="text" + id="totp_code" + class="input" + :class="{ 'error': v$.totpCode.$error, 'dirty': v$.totpCode.$dirty }" + placeholder="6 Digit Code" + required + > + </div> + <div class="ml-auto w-fit"> + <button type="submit" class="btn blue"> + Submit + </button> + </div> + </form> + </div> + </template> + </Dialog> + </div> +</template>
\ No newline at end of file diff --git a/front-end/src/components/Settings/PkiSettings.vue b/front-end/src/components/Settings/PkiSettings.vue new file mode 100644 index 0000000..5fa5c93 --- /dev/null +++ b/front-end/src/components/Settings/PkiSettings.vue @@ -0,0 +1,216 @@ +<script setup lang="ts"> +import { defineAsyncComponent, shallowRef } from 'vue'; +import { useStore } from '../../store'; +import { get, set } from '@vueuse/core'; +import { PkiPublicKey, apiCall, debugLog, useConfirm, useGeneralToaster } from '@vnuge/vnlib.browser'; +import { storeToRefs } from 'pinia'; +import { isEmpty, toLower } from 'lodash-es'; +const Dialog = defineAsyncComponent(() => import('../global/Dialog.vue')); + +const store = useStore(); +const { pkiPublicKeys } = storeToRefs(store); +const { reveal } = useConfirm() +const toaster = useGeneralToaster() + +const keyData = shallowRef<string | undefined>() +const showAddKeyDialog = () => set(keyData, ''); +const hideAddKeyDialog = () => set(keyData, undefined); + +const removeKey = async (key: PkiPublicKey) => { + const { isCanceled } = await reveal({ + title: 'Remove Key', + text: `Are you sure you want to remove ${key.kid}?` + }); + + if (isCanceled) return + + apiCall(async ({ toaster }) => { + //const { getResultOrThrow } = await store.pkiConfig.removeKey(key.kid); + //await getResultOrThrow(); + + toaster.general.success({ + title: 'Key Removed', + text: `${key.kid} has been successfully removed` + }) + }) +} + +const onDisable = async () => { + const { isCanceled } = await reveal({ + title: 'Are you sure?', + text: 'This will disable PKI authentication for your account.' + }) + if (isCanceled) { + return; + } + + //Delete pki + await apiCall(async () => { + + //Disable pki + //TODO: require password or some upgrade to disable + const { success } = await store.pkiConfig.disable(); + + if (success) { + toaster.success({ + title: 'Success', + text: 'PKI authentication has been disabled.' + }) + } + else { + toaster.error({ + title: 'Error', + text: 'PKI authentication could not be disabled.' + }) + } + + //Refresh the status + store.mfaRefreshMethods() + }); +} + +const onAddKey = async () => { + + if (window.crypto.subtle == null) { + toaster.error({ title: "Your browser does not support PKI authentication." }) + return; + } + + const jwkKeyData = get(keyData) + + //Validate key data + if (!jwkKeyData || isEmpty(jwkKeyData)) { + toaster.error({ title: "Please enter key data" }) + return; + } + + let jwk: PkiPublicKey & JsonWebKey; + try { + //Try to parse as jwk + jwk = JSON.parse(jwkKeyData) + if (isEmpty(jwk.use) + || isEmpty(jwk.kty) + || isEmpty(jwk.alg) + || isEmpty(jwk.kid) + || isEmpty(jwk.x) + || isEmpty(jwk.y)) { + throw new Error("Invalid JWK"); + } + } + catch (e) { + //Write error to debug log + debugLog(e) + toaster.error({ title: "Invalid JWK", text:"The public key you entered is not a valid JWK" }) + return; + } + + //Send to server + await apiCall(async () => { + + //init/update the key + //TODO: require password or some upgrade to disable + const { getResultOrThrow } = await store.pkiConfig.addOrUpdate(jwk); + const result = getResultOrThrow(); + + toaster.success({ + title: 'Success', + text: result + }) + + hideAddKeyDialog() + }) +} + +</script> + +<template> + <div class=""> + <div class="flex flex-row justify-between"> + <h3 class="text-xl font-bold">Authentication Keys</h3> + <div class=""> + + <button type="button" class="btn blue" @click.prevent="showAddKeyDialog"> + <div class="flex flex-row items-center gap-1.5"> + Add + </div> + </button> + <button type="button" class="btn red" @click.prevent="onDisable"> + <div class="flex flex-row items-center gap-1.5"> + Disable + </div> + </button> + </div> + </div> + + <div class="relative mt-2 overflow-x-auto shadow-md sm:rounded"> + <table class="w-full text-sm text-left text-gray-500 rtl:text-right dark:text-gray-400"> + <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"> + <tr> + <th scope="col" class="px-4 py-2"> + KeyID + </th> + <th scope="col" class="px-4 py-2"> + Algorithm + </th> + <th scope="col" class="px-4 py-2"> + Curve + </th> + <th scope="col" class="px-4 py-2"> + Action + </th> + </tr> + </thead> + <tbody> + <tr v-for="key in pkiPublicKeys" :key="key.kid" + class="bg-white border-b dark:bg-gray-800 dark:border-gray-700"> + <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"> + {{ toLower(key.kid) }} + </th> + <td class="px-4 py-3"> + {{ key.alg }} + </td> + <td class="px-4 py-3"> + {{ key.crv }} + </td> + <td class="px-4 py-3"> + <button @click.prevent="removeKey(key)" + class="font-medium text-red-600 dark:text-red-500 hover:underline"> + Remove + </button> + </td> + </tr> + </tbody> + </table> + + </div> + <p class="p-3 text-sm text-gray-500 rtl:text-right dark:text-gray-400"> + Above are your account authentication keys. You can use these keys to sign in to your account using the PKI + authentication method. + </p> + <Dialog :open="keyData != undefined" title="Add Key" @cancel="hideAddKeyDialog"> + <template #body> + <div class="p-4"> + + <label for="message" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"> + Add a single Json Web Key (JWK) encoded public key to your account. + </label> + + <textarea + id="add-pki-key" + rows="6" + v-model="keyData" + placeholder="Paste your JWK..." + class="block w-full text-sm text-gray-900 border border-gray-300 rounded bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" + > + </textarea> + + <div class="flex justify-end mt-3 row"> + <button class="btn blue" @click="onAddKey()">Add Key</button> + </div> + </div> + </template> + </Dialog> + </div> +</template> + +<style lang="scss"></style>
\ No newline at end of file diff --git a/front-end/src/components/Settings/TotpSettings.vue b/front-end/src/components/Settings/TotpSettings.vue new file mode 100644 index 0000000..cc8642f --- /dev/null +++ b/front-end/src/components/Settings/TotpSettings.vue @@ -0,0 +1,169 @@ +<script setup lang="ts"> +import { computed, defineAsyncComponent, shallowRef } from 'vue'; +import { useStore } from '../../store'; +import { set, get } from '@vueuse/core'; +import { MfaMethod, useGeneralToaster, usePassConfirm, useSession } from '@vnuge/vnlib.browser'; +import { defaultTo, includes, isEmpty, isNil } from 'lodash-es'; +import { TOTP } from 'otpauth' +import base32Encode from 'base32-encode' +const QrCode = defineAsyncComponent(() => import('qrcode.vue')); +const Dialog = defineAsyncComponent(() => import('../global/Dialog.vue')); +const VOtpInput = defineAsyncComponent(() => import('vue3-otp-input')) + +interface TotpConfig { + secret: string; + readonly issuer: string; + readonly algorithm: string; + readonly digits?: number; + readonly period?: number; +} + +const { KeyStore } = useSession() +const { elevatedApiCall } = usePassConfirm() +const { success, error } = useGeneralToaster() +const store = useStore() +const newTotpConfig = shallowRef<TotpConfig | undefined>() +const totpEnabled = computed(() => includes(store.mfaEndabledMethods, MfaMethod.TOTP)) + +const qrCode = computed(() => { + + const m = get(newTotpConfig); + + if (isNil(m)) { + return '' + } + + // Build the totp qr codeurl + const params = new URLSearchParams() + params.append('secret', m.secret) + params.append('issuer', m.issuer) + params.append('algorithm', m.algorithm) + params.append('digits', defaultTo(m.digits, 6).toString()) + params.append('period', defaultTo(m.period, 30).toString()) + + return `otpauth://totp/${m.issuer}:${store.userName}?${params.toString()}` +}) + +const showUpdateDialog = computed(() => !isEmpty(get(qrCode))) + +const disableTotp = async () => { + + elevatedApiCall(async ({ password, toaster }) =>{ + const { getResultOrThrow } = await store.mfaConfig.disableMethod(MfaMethod.TOTP, password); + getResultOrThrow(); + + toaster.general.success({ + title: 'TOTP Disabled', + text: 'TOTP has been disabled for your account.' + }) + }) +} + +const addOrUpdate = async () => { + + elevatedApiCall(async ({ password }) =>{ + const { getResultOrThrow } = await store.mfaConfig.initOrUpdateMethod<TotpConfig>(MfaMethod.TOTP, password); + const newConfig = getResultOrThrow(); + + // Decrypt the server sent secret + const decSecret = await KeyStore.decryptDataAsync(newConfig.secret); + // Encode the secret to base32 + newConfig.secret = base32Encode(decSecret, 'RFC3548', { padding: false }) + + set(newTotpConfig, newConfig); + }) +} + +const onVerifyOtp = async (code: string) => { + // Create a new TOTP instance from the current message + const totp = new TOTP(get(newTotpConfig)) + + // validate the code + const valid = totp.validate({ token: code, window: 4 }) + + if (valid) { + success({ + title: 'Success', + text: 'Your code is valid and TOPT has been enabled.' + }) + + //Close the dialog + set(newTotpConfig, undefined) + + } else { + error({ title: 'The code you entered is invalid.'}) + } +} + +</script> + +<template> + + <div class="flex flex-row items-center justify-between"> + <div class="relative me-4"> + <h4 class="text-base font-bold"> + TOTP + </h4> + <span + :class="[totpEnabled ? 'visible' : 'invisible' ]" + class="absolute top-0 w-3 h-3 bg-green-500 border-2 border-white rounded-full -end-5 dark:border-gray-800" + > + </span> + </div> + <div v-if="totpEnabled" class="flex" @click="addOrUpdate()"> + <button class="btn light"> + Regenerate + </button> + <button class="btn red" @click="disableTotp()"> + Disable + </button> + </div> + <div v-else> + <button class="btn green" @click="addOrUpdate()"> + Enable + </button> + </div> + </div> + <p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> + Use Time based One Time Passcodes (TOTP) to secure your account as a second factor authentication. + </p> + + <Dialog :open="showUpdateDialog" title="TOTP Secret" @cancel=""> + <template #body> + <div class="p-4 pb-8"> + <div class="flex flex-col items-center justify-center"> + <div> + <p class="text-sm text-gray-500 dark:text-gray-400"> + Scan this QR code with your authenticator app to add this account. + </p> + </div> + <div class="mt-4"> + <QrCode :size="200" level="L" :value="qrCode" /> + </div> + <div class="w-full mt-4"> + <input + type="text" + id="disabled-input-2" + aria-label="disabled input 2" + class="bg-gray-100 border border-gray-300 text-gray-900 text-sm rounded focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 cursor-not-allowed dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-400 dark:focus:ring-blue-500 dark:focus:border-blue-500" + :value="qrCode" + disabled + readonly + > + </div> + + <div class="mx-auto mt-4"> + <VOtpInput class="otp-input" input-type="letter-numeric" separator="" + input-classes="rounded" :num-inputs="6" value="" @on-change="" + @on-complete="onVerifyOtp" /> + </div> + <p class="mt-2 text-sm text-gray-500 dark:text-gray-400"> + Enter the 6 digit code from your authenticator app to verify. + </p> + </div> + </div> + </template> + </Dialog> +</template> + +<style lang="scss"></style>
\ No newline at end of file |