diff options
author | vnugent <public@vaughnnugent.com> | 2024-01-04 11:13:31 -0500 |
---|---|---|
committer | vnugent <public@vaughnnugent.com> | 2024-01-04 11:13:31 -0500 |
commit | e87c4b69036e32b4fcf3df89e8158fb52df6a4e0 (patch) | |
tree | 83ce96172100abb0949f60e3c733daf738cbcf2d /extension | |
parent | 8dec218a1aa259f83b8178265a7d0d0f08817cac (diff) |
package updates & partial account page added
Diffstat (limited to 'extension')
32 files changed, 1338 insertions, 135 deletions
diff --git a/extension/package-lock.json b/extension/package-lock.json index dec73f0..bf558a0 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -26,12 +26,14 @@ "jose": "^5.0.x", "lodash-es": "^4.17.21", "nanoid": "^5.0.4", + "otpauth": "^9.2.1", "pinia": "^2.1.7", "sass": "^1.56.1", "serialize-error": "^11.0.0", "universal-cookie": "^7.0.x", "vite-plugin-web-extension": "^4.1.1", "vue": "^3.2.47", + "vue3-otp-input": "^0.4.1", "webextension-polyfill": "^0.10.0" }, "devDependencies": { @@ -5186,6 +5188,14 @@ "node": ">=0.6.0" } }, + "node_modules/jssha": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz", + "integrity": "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==", + "engines": { + "node": "*" + } + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -5932,6 +5942,17 @@ "node": ">= 0.4.0" } }, + "node_modules/otpauth": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.2.1.tgz", + "integrity": "sha512-/MRvcm63pzK20NCsIOe8Btun42/yWNylPbUo/h5dMpSRJpoAJstWodEUjm4zUDeT1+Vbqif2E8IcP4trl1U4gQ==", + "dependencies": { + "jssha": "~3.3.1" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, "node_modules/p-cancelable": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", @@ -8382,6 +8403,18 @@ "typescript": "*" } }, + "node_modules/vue3-otp-input": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/vue3-otp-input/-/vue3-otp-input-0.4.1.tgz", + "integrity": "sha512-wVl9i3DcWlO0C7fBI9V+RIP3crm/1tY72fuhvb3YM2JfbLoYofB96aPl5AgFhA0Cse5bQEMYtIvOeiqW3rfbAw==", + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "peerDependencies": { + "vue": "^3.0.*" + } + }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", diff --git a/extension/package.json b/extension/package.json index 2ba409a..806d54f 100644 --- a/extension/package.json +++ b/extension/package.json @@ -52,13 +52,15 @@ "base32-encode": "^2.0.0", "jose": "^5.0.x", "lodash-es": "^4.17.21", + "nanoid": "^5.0.4", + "otpauth": "^9.2.1", "pinia": "^2.1.7", "sass": "^1.56.1", "serialize-error": "^11.0.0", - "nanoid": "^5.0.4", "universal-cookie": "^7.0.x", "vite-plugin-web-extension": "^4.1.1", "vue": "^3.2.47", + "vue3-otp-input": "^0.4.1", "webextension-polyfill": "^0.10.0" } } diff --git a/extension/src/assets/modals.scss b/extension/src/assets/modals.scss index 254b8e1..b201073 100644 --- a/extension/src/assets/modals.scss +++ b/extension/src/assets/modals.scss @@ -3,8 +3,8 @@ @apply fixed z-50 flex w-full px-6; .modal-content-container { - @apply w-full max-w-md p-5 m-auto rounded-md shadow-2xl mt-44; - @apply bg-white border border-transparent dark:bg-dark-600 dark:border-primary-500 dark:text-white; + @apply w-full max-w-md p-5 m-auto rounded shadow-2xl mt-44; + @apply bg-white border border-transparent dark:bg-dark-600 dark:border-dark-200 dark:text-white; .modal-title { @apply text-xl font-bold; diff --git a/extension/src/components/ConfirmPrompt.vue b/extension/src/components/ConfirmPrompt.vue new file mode 100644 index 0000000..fa8601b --- /dev/null +++ b/extension/src/components/ConfirmPrompt.vue @@ -0,0 +1,67 @@ + +<template> + <div id="confirm-prompt"> + <Dialog class="modal-entry" :style="style" :open="isRevealed" @close="cancel" > + <div class="modal-content-container"> + <DialogPanel> + <DialogTitle class="modal-title"> + {{ message.title ?? 'Confirm' }} + </DialogTitle> + + <DialogDescription class="modal-description"> + {{ message.text }} + </DialogDescription> + + <p class="modal-text-secondary"> + {{ message.subtext }} + </p> + + <div class="modal-button-container"> + <button class="rounded btn sm primary" @click="confirm"> + Confirm + </button> + <button class="rounded btn sm" @click="cancel"> + Close + </button> + </div> + </DialogPanel> + </div> + </Dialog> + </div> +</template> + +<script setup lang="ts"> +import { defaultTo } from 'lodash' +import { computed, ref } from 'vue' + +import { + Dialog, + DialogPanel, + DialogTitle, + DialogDescription, +} from '@headlessui/vue' + +import { onClickOutside } from '@vueuse/core' +import { useConfirm, useEnvSize } from '@vnuge/vnlib.browser' + +const { headerHeight } = useEnvSize() +//Use component side of confirm +const { isRevealed, confirm, cancel, onReveal } = useConfirm() + +const dialog = ref(null) +const message = ref({}) + +//Cancel prompt when user clicks outside of dialog, only when its open +onClickOutside(dialog, () => isRevealed.value ? cancel() : null) + +//Set message on reveal +onReveal(m => message.value = defaultTo(m, {})); + +const style = computed(() => { + return { + 'height': `calc(100vh - ${headerHeight.value}px)`, + 'top': `${headerHeight.value}px` + } +}) + +</script>
\ No newline at end of file diff --git a/extension/src/components/PasswordPrompt.vue b/extension/src/components/PasswordPrompt.vue new file mode 100644 index 0000000..ae29358 --- /dev/null +++ b/extension/src/components/PasswordPrompt.vue @@ -0,0 +1,110 @@ +<template> + <div id="password-prompt"> + <Dialog + class="modal-entry" + :style="style" + :open="isRevealed" + @close="close" + > + <div ref="dialog" class="modal-content-container" > + <DialogPanel> + <DialogTitle class="modal-title"> + Enter your password + </DialogTitle> + + <DialogDescription class="modal-description"> + Please re-enter your password to continue. + </DialogDescription> + + <form id="password-form" @submit.prevent="formSubmitted" :disabled="waiting"> + <fieldset> + <div class="input-container"> + <input v-model="v$.password.$model" type="password" class="rounded input primary" placeholder="Password" @input="onInput"> + </div> + </fieldset> + </form> + + <div class="modal-button-container"> + <button class="rounded btn sm primary" form="password-form"> + Submit + </button> + <button class="rounded btn sm" @click="close" > + Close + </button> + </div> + </DialogPanel> + </div> + </Dialog> + </div> +</template> + +<script setup lang="ts"> +import { onClickOutside } from '@vueuse/core' +import useVuelidate from '@vuelidate/core' +import { reactive, ref, computed } from 'vue' +import { helpers, required, maxLength } from '@vuelidate/validators' +import { useWait, useMessage, usePassConfirm, useEnvSize, useVuelidateWrapper } from '@vnuge/vnlib.browser' +import { Dialog, DialogPanel, DialogTitle, DialogDescription } from '@headlessui/vue' + +const { headerHeight } = useEnvSize() + +//Use component side of pw prompt +const { isRevealed, confirm, cancel } = usePassConfirm() + +const { waiting } = useWait() +const { onInput } = useMessage() + +//Dialog html ref +const dialog = ref(null) + +const pwState = reactive({ password: '' }) + +const rules = { + password: { + required: helpers.withMessage('Please enter your password', required), + maxLength: helpers.withMessage('Password must be less than 100 characters', maxLength(100)) + } +} + +const v$ = useVuelidate(rules, pwState, { $lazy: true }) + +//Wrap validator so we an display error message on validation, defaults to the form toaster +const { validate } = useVuelidateWrapper(v$); + +const style = computed(() => { + return { + 'height': `calc(100vh - ${headerHeight.value}px)`, + 'top': `${headerHeight.value}px` + } +}) + +const formSubmitted = async function () { + //Calls validate on the vuelidate instance + if (!await validate()) { + return + } + + //Store pw copy + const password = v$.value.password.$model; + + //Clear the password form + v$.value.password.$model = ''; + v$.value.$reset(); + + //Pass the password to the confirm function + confirm({ password }); +} + +const close = function () { + // Clear the password form + v$.value.password.$model = ''; + v$.value.$reset(); + + //Close prompt + cancel(null); +} + +//Cancel prompt when user clicks outside of dialog, only when its open +onClickOutside(dialog, () => isRevealed.value ? cancel() : null) + +</script>
\ No newline at end of file diff --git a/extension/src/entries/background/main.ts b/extension/src/entries/background/main.ts index 1d74f26..85e358a 100644 --- a/extension/src/entries/background/main.ts +++ b/extension/src/entries/background/main.ts @@ -23,7 +23,8 @@ import { useAppSettings, usePkiApi, useEventTagFilterApi, - useInjectAllowList + useInjectAllowList, + useMfaConfigApi, } from "../../features"; import { useBackgroundFeatures } from "../../features/framework"; @@ -40,5 +41,6 @@ register([ useLocalPki, usePkiApi, useEventTagFilterApi, - useInjectAllowList + useInjectAllowList, + useMfaConfigApi ])
\ No newline at end of file diff --git a/extension/src/entries/contentScript/renderContent.js b/extension/src/entries/contentScript/renderContent.js index 293bdd5..ca45c4f 100644 --- a/extension/src/entries/contentScript/renderContent.js +++ b/extension/src/entries/contentScript/renderContent.js @@ -20,7 +20,11 @@ export default async function renderContent( cssPaths, render = (_appRoot) => {} ) { + + //insert a div into the top of the body const appContainer = document.createElement("div"); + document.body.insertBefore(appContainer, document.body.firstChild); + const shadowRoot = appContainer.attachShadow({ mode: 'closed' }); const appRoot = document.createElement("div"); @@ -40,7 +44,5 @@ export default async function renderContent( } shadowRoot.appendChild(appRoot); - document.body.appendChild(appContainer); - render(appRoot, shadowRoot); }
\ No newline at end of file diff --git a/extension/src/entries/options/App.vue b/extension/src/entries/options/App.vue index dd71209..d1da48e 100644 --- a/extension/src/entries/options/App.vue +++ b/extension/src/entries/options/App.vue @@ -1,5 +1,9 @@ <template> <main id="injected-root"> + + <!-- Global password/confirm promps --> + <ConfirmPrompt /> + <PasswordPrompt /> <notifications class="toaster" group="form" position="top-right" /> @@ -17,6 +21,11 @@ </Tab> <Tab v-slot="{ selected }"> <button class="tab-title" :class="{ selected }"> + Account + </button> + </Tab> + <Tab v-slot="{ selected }"> + <button class="tab-title" :class="{ selected }"> Privacy </button> </Tab> @@ -56,6 +65,9 @@ <Identities :all-keys="allKeys" @edit-key="editKey"/> </TabPanel> <TabPanel> + <Account/> + </TabPanel> + <TabPanel> <Privacy/> </TabPanel> <TabPanel> @@ -115,6 +127,9 @@ import SiteSettings from './components/SiteSettings.vue'; import Identities from './components/Identities.vue'; import Privacy from "./components/Privacy.vue"; import { useStore } from "../store"; +import Account from "./components/Account.vue"; +import ConfirmPrompt from "../../components/ConfirmPrompt.vue"; +import PasswordPrompt from "../../components/PasswordPrompt.vue"; //Configure the notifier to use the notification library @@ -128,7 +143,7 @@ const keyBuffer = ref<NostrPubKey>({} as NostrPubKey) const editKey = (key: NostrPubKey) =>{ //Goto hidden tab - selectedTab.value = 3 + selectedTab.value = 4 //Set selected key keyBuffer.value = { ...key } } @@ -165,7 +180,7 @@ watchEffect(() => darkMode.value ? document.body.classList.add('dark') : documen </script> -<style lang="scss" scoped> +<style lang="scss"> main { font-family: Avenir, Helvetica, Arial, sans-serif; @@ -198,4 +213,8 @@ main { } } +.text-color-background{ + @apply text-gray-400 dark:text-gray-500; +} + </style>
\ No newline at end of file diff --git a/extension/src/entries/options/components/Account.vue b/extension/src/entries/options/components/Account.vue new file mode 100644 index 0000000..574dc51 --- /dev/null +++ b/extension/src/entries/options/components/Account.vue @@ -0,0 +1,34 @@ +<template> + <div id="account-settings-template" class="mt-4"> + <div class="flex flex-col w-full max-w-lg gap-4 mx-auto"> + <div class=""> + <h3 class="text-center">Account Settings</h3> + <div class=""> + + </div> + </div> + <div class=""> + <div class="w-full font-bold border-b border-gray-200 dark:border-dark-500"> + Multi Factor + </div> + <div class="mt-4"> + <Totp /> + </div> + <div class="mt-8 "> + <Pki /> + </div> + </div> + <div class="mt-6"> + <h4>Password</h4> + <div class=""> + </div> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import Pki from './Pki.vue'; +import Totp from './Totp.vue'; + +</script>
\ No newline at end of file diff --git a/extension/src/entries/options/components/Identities.vue b/extension/src/entries/options/components/Identities.vue index 0e3e79d..b7765be 100644 --- a/extension/src/entries/options/components/Identities.vue +++ b/extension/src/entries/options/components/Identities.vue @@ -38,6 +38,13 @@ </PopoverPanel> </Popover> </div> + <div class=""> + <div class=""> + <button class="rounded btn sm" @click="store.refreshIdentities()"> + <fa-icon icon="refresh" class="" /> + </button> + </div> + </div> </div> <div v-for="key in allKeys" :key="key.Id" class="mt-2 mb-3"> <div class="" :class="{'selected': isSelected(key)}" @click.self="selectKey(key)"> @@ -90,7 +97,7 @@ import { PopoverPanel, PopoverOverlay } from '@headlessui/vue' -import { apiCall, configureNotifier } from '@vnuge/vnlib.browser'; +import { apiCall, configureNotifier, useConfirm } from '@vnuge/vnlib.browser'; import { NostrPubKey } from '../../../features'; import { notify } from "@kyvg/vue3-notification"; import { get, useClipboard } from '@vueuse/core'; @@ -106,7 +113,7 @@ const downloadAnchor = ref<HTMLAnchorElement>() const store = useStore() const { selectedKey, allKeys } = storeToRefs(store) const { copy } = useClipboard() - +const { reveal } = useConfirm() const isSelected = (me : NostrPubKey) => isEqual(me, selectedKey.value) const editKey = (key : NostrPubKey) => emit('edit-key', key); @@ -134,7 +141,17 @@ const prettyPrintDate = (key : NostrPubKey) => { const onDeleteKey = async (key : NostrPubKey) => { - if(!confirm(`Are you sure you want to delete ${key.UserName}?`)){ + const { isCanceled } = await reveal({ + title: 'Are you sure?', + text: `You are about to perminantly delete your identity ${key.UserName}.`, + subtext: 'This is a perminant action and cannot be undone.', + }) + + if(isCanceled){ + return; + } + + if(!confirm(`Are you REALLY sure you want to delete ${key.UserName}?`)){ return; } diff --git a/extension/src/entries/options/components/Pki.vue b/extension/src/entries/options/components/Pki.vue new file mode 100644 index 0000000..ff64840 --- /dev/null +++ b/extension/src/entries/options/components/Pki.vue @@ -0,0 +1,237 @@ +<template> + <div id="pki-settings" class="container"> + <div class="panel-content"> + + <div class="flex flex-row flex-wrap justify-between"> + <div class="text-sm font-bold">OTP Auth Keys</div> + <div class=""> + <div v-if="pkiEnabled" class="button-group"> + <button class="btn xs" @click.prevent="setIsOpen(true)"> + <fa-icon icon="plus" /> + <span class="pl-2">Add Key</span> + </button> + <button class="btn red xs" @click.prevent="onDisable"> + <fa-icon icon="minus-circle" /> + <span class="pl-2">Disable</span> + </button> + </div> + <div v-else class=""> + <button class="btn xs" @click.prevent="setIsOpen(true)"> + <fa-icon icon="plus" /> + <span class="pl-2">Add Key</span> + </button> + </div> + </div> + + <div v-if="store.pkiServerKeys.length > 0" class="w-full mt-2"> + <table class="min-w-full text-sm divide-y-2 divide-gray-200 dark:divide-dark-500"> + <thead class="text-left"> + <tr> + <th class="p-2 font-medium whitespace-nowrap dark:text-white"> + KeyID + </th> + <th class="p-2 font-medium whitespace-nowrap dark:text-white"> + Algorithm + </th> + <th class="p-2 font-medium whitespace-nowrap dark:text-white"> + Curve + </th> + <th class="p-2"></th> + </tr> + </thead> + + <tbody class="divide-y divide-gray-200 dark:divide-dark-500"> + <tr v-for="key in store.pkiServerKeys"> + <td class="p-2 t font-medium truncate max-w-[8rem] whitespace-nowrap dark:text-white"> + {{ key.kid }} + </td> + <td class="p-2 text-gray-700 whitespace-nowrap dark:text-gray-200"> + {{ key.alg }} + </td> + <td class="p-2 text-gray-700 whitespace-nowrap dark:text-gray-200"> + {{ key.crv }} + </td> + <td class="p-2 text-right whitespace-nowrap"> + <button class="rounded btn red xs borderless" @click="onRemoveKey(key)"> + <span class="hidden sm:inline">Remove</span> + <fa-icon icon="trash-can" class="inline sm:hidden" /> + </button> + </td> + </tr> + </tbody> + </table> + </div> + + <p v-else class="p-1 pt-3 text-sm text-color-background"> + PKI authentication is a method of authenticating your user account with signed messages and a shared + public key. This method implementation + uses client signed Json Web Tokens to authenticate user generated outside this website as a One Time + Password (OTP). This allows for you to + use your favorite hardware or software tools, to generate said OTPs to authenticate your user. + </p> + </div> + </div> + </div> + <Dialog :open="isOpen" @close="setIsOpen" class="relative z-30"> + <div class="fixed inset-0 bg-black/30" aria-hidden="true" /> + + <div class="fixed inset-0 flex justify-center"> + <DialogPanel class="w-full max-w-lg p-4 m-auto mt-24 bg-white rounded dark:bg-dark-700 dark:text-gray-300"> + <h4>Configure your authentication key</h4> + <p class="mt-2 text-sm"> + Please paste your authenticator's public key as a Json Web Key (JWK) object. Your JWK must include a kid + (key id) and a kty (key type) field. + </p> + <div class="p-2 mt-3"> + <textarea class="w-full p-1 text-sm border dark:bg-dark-700 ring-0 dark:border-dark-400" rows="10" + v-model="keyData" /> + </div> + <div class="flex justify-end gap-2 mt-4"> + <button class="rounded btn sm primary" @click.prevent="onSubmitKeys">Submit</button> + <button class="rounded btn sm" @click.prevent="setIsOpen(false)">Cancel</button> + </div> + </DialogPanel> + </div> + </Dialog> +</template> + +<script setup lang="ts"> +import { includes, isEmpty } from 'lodash' +import { apiCall, useConfirm, useSession, debugLog, useFormToaster, MfaMethod } from '@vnuge/vnlib.browser' +import { computed, ref, watch } from 'vue' +import { Dialog, DialogPanel } from '@headlessui/vue' +import { } from 'pinia' +import { useStore } from '../../store' +import { PkiPubKey } from '../../../features' + +const store = useStore() +const { reveal } = useConfirm() +const { isLocalAccount } = useSession() +const { error, success } = useFormToaster() + +const pkiEnabled = computed(() => isLocalAccount.value && includes(store.mfaEnabledMethods, "pki" as MfaMethod)) + +const isOpen = ref(false) +const keyData = ref('') +const pemFormat = ref(false) +const explicitCurve = ref("") + +watch(isOpen, () => { + keyData.value = '' + pemFormat.value = false + explicitCurve.value = "" + //Reload status + store.mfaRefresh() +}) + +const setIsOpen = (value: boolean) => isOpen.value = value + +const onRemoveKey = async (single: PkiPubKey) => { + const { isCanceled } = await reveal({ + title: 'Are you sure?', + text: `This will remove key ${single.kid} from your account.` + }) + if (isCanceled) { + return; + } + + //Delete pki + await apiCall(async () => { + + //TODO: require password or some upgrade to disable + await store.pkiRemoveKey(single); + + success({ + title: 'Key was removed successfully.', + }) + + //Refresh the status + store.mfaRefresh() + }); +} + +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 ({ toaster }) => { + + //Disable pki + //TODO: require password or some upgrade to disable + const { success } = await store.disable(); + + if (success) { + toaster.general.success({ + title: 'Success', + text: 'PKI authentication has been disabled.' + }) + } + else { + toaster.general.error({ + title: 'Error', + text: 'PKI authentication could not be disabled.' + }) + } + + //Refresh the status + store.mfaRefresh() + }); +} + +const onSubmitKeys = async () => { + + if (window.crypto.subtle == null) { + error({ title: "Your browser does not support PKI authentication." }) + return; + } + + //Validate key data + if (isEmpty(keyData.value)) { + error({ title: "Please enter key data" }) + return; + } + + let jwk: PkiPubKey; + try { + //Try to parse as jwk + jwk = JSON.parse(keyData.value) + 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) + error({ title: "The key is not a valid Json Web Key (JWK)" }) + return; + } + + //Send to server + await apiCall(async () => { + + //init/update the key + //TODO: require password or some upgrade to disable + await store.pkiAddKey(jwk); + + success({ + title: 'Successfully update your PKI keys.', + }) + + setIsOpen(false) + }) +} + +</script> + +<style></style> diff --git a/extension/src/entries/options/components/Privacy.vue b/extension/src/entries/options/components/Privacy.vue index d54f679..46261b3 100644 --- a/extension/src/entries/options/components/Privacy.vue +++ b/extension/src/entries/options/components/Privacy.vue @@ -1,18 +1,19 @@ <template> - <div class="flex flex-col w-full mt-4 sm:px-2"> - <div class="flex flex-row gap-1"> - <div class="text-2xl"> - Tracking protection - </div> - <div class="mt-auto" :class="[isOriginProtectionOn ? 'text-primary-600' : 'text-red-500']"> - {{ isOriginProtectionOn ? 'active' : 'inactive' }} + <div class="flex flex-col w-full max-w-md mx-auto mt-4 sm:px-2"> + <div class="flex flex-row gap-1 mx-auto"> + <div class="mb-auto mr-1" > + <div class="w-2 h-2 rounded-full" :class="[isOriginProtectionOn ? 'bg-primary-600' : 'bg-red-500']"> + </div> </div> + <h3 class="text-2xl"> + Tracking protection + </h3> </div> <div class=""> <div class="p-2"> <div class="my-1"> - <form @submit.prevent="allowOrigin()"> - <input class="w-full max-w-xs input primary" type="text" v-model="newOrigin" placeholder="Add new origin"/> + <form class="flex flex-row w-full" @submit.prevent="allowOrigin()"> + <input class="flex-1 input primary" type="text" v-model="newOrigin" placeholder="Add new origin"/> <button type="submit" class="ml-1 btn xs" > <fa-icon icon="plus" /> </button> diff --git a/extension/src/entries/options/components/SiteSettings.vue b/extension/src/entries/options/components/SiteSettings.vue index b31bb9c..17f41c4 100644 --- a/extension/src/entries/options/components/SiteSettings.vue +++ b/extension/src/entries/options/components/SiteSettings.vue @@ -128,9 +128,9 @@ import { useStore } from '../../store'; import { storeToRefs } from 'pinia'; import useVuelidate from '@vuelidate/core' -const { waiting } = useWait(); const store = useStore() -const { settings, isOriginProtectionOn } = storeToRefs(store) +const { settings } = storeToRefs(store) +const { waiting } = useWait(); const { apply, data, buffer, modified, update } = useDataBuffer(settings.value, async sb =>{ const newConfig = await store.saveSiteConfig(sb.buffer) @@ -142,7 +142,7 @@ const { apply, data, buffer, modified, update } = useDataBuffer(settings.value, watch(settings, v => apply(v)) const originProtection = computed({ - get: () => isOriginProtectionOn.value, + get: () => store.isOriginProtectionOn, set: v => store.setOriginProtection(v) }) diff --git a/extension/src/entries/options/components/Totp.vue b/extension/src/entries/options/components/Totp.vue new file mode 100644 index 0000000..ed836b7 --- /dev/null +++ b/extension/src/entries/options/components/Totp.vue @@ -0,0 +1,234 @@ +<template> + <div id="totp-settings"> + + <div v-if="showTotpCode" class="w-full py-2 text-center"> + <h5 class="text-center" /> + <p class="py-2"> + Scan the QR code with your TOTP authenticator app. + </p> + + <div class="flex"> + <VueQrcode class="m-auto" :value="qrCode" /> + </div> + + <p class="py-2"> + Your secret, if your application requires it. + </p> + + <p + class="flex flex-row flex-wrap justify-center p-2 bg-gray-200 border border-gray-300 dark:bg-dark-800 dark:border-dark-500"> + <span v-for="code in secretSegments" :key="code" class="px-2 font-mono tracking-wider"> + {{ code }} + </span> + </p> + + <p class="py-2 text-color-background"> + Please enter your code from your authenticator app to continue. + </p> + + <div class="m-auto w-min"> + <VOtpInput class="otp-input" input-type="letter-numeric" separator="" value="" :is-disabled="showSubmitButton" + input-classes="primary input rounded" :num-inputs="6" @on-change="onInput" @on-complete="VerifyTotp" /> + </div> + + <div v-if="showSubmitButton" class="flex flex-row justify-end my-2"> + <button class="btn primary" @click.prevent="CloseQrWindow"> + Complete + </button> + </div> + </div> + + <div v-else class="flex flex-row flex-wrap justify-between"> + <div class="text-sm font-bold">TOTP Authenticator App</div> + + <div v-if="totpEnabled" class="button-group"> + <button class="btn xs" @click.prevent="regenTotp"> + <fa-icon icon="sync" /> + <span class="pl-2">Regenerate</span> + </button> + <button class="btn red xs" @click.prevent="disable"> + <fa-icon icon="minus-circle" /> + <span class="pl-2">Disable</span> + </button> + </div> + + <div v-else> + <button class="btn xs" @click.prevent="configTotp"> + <fa-icon icon="plus" /> + <span class="pl-2">Setup</span> + </button> + </div> + <p v-if="!totpEnabled" class="p-1 pt-3 text-sm text-color-background"> + TOTP is a time based one time password. You can use it as a form of Multi Factor Authentication when + using another device such as a smart phone or TOTP hardware device. You can use TOTP with your smart + phone + using apps like Google Authenticator, Authy, or Duo. Read more on + <a class="link" href="https://en.wikipedia.org/wiki/Time-based_one-time_password" target="_blank"> + Wikipedia. + </a> + </p> + + <p v-else class="w-full p-1 pt-1 text-sm text-color-background"> + TOTP is enabled for your account. + </p> + </div> + + </div> +</template> + +<script setup lang="ts"> +import { isNil, chunk, defaultTo, includes, map, join } from 'lodash' +import { TOTP } from 'otpauth' +import base32Encode from 'base32-encode' +import VueQrcode from '@chenfengyuan/vue-qrcode' +import VOtpInput from "vue3-otp-input"; +import { computed, ref } from 'vue' +import { + useSession, + useMessage, + useConfirm, + usePassConfirm, + useFormToaster, + MfaMethod +} from '@vnuge/vnlib.browser' +import { storeToRefs } from 'pinia'; +import { useStore } from '../../store'; +import { Mutable } from '@vueuse/core'; +import { TotpUpdateMessage } from '../../../features/types'; + +type TotpConfig = Mutable<TotpUpdateMessage> + +const store = useStore() +const { userName } = storeToRefs(store); + +const { KeyStore } = useSession() +const { reveal } = useConfirm() +const { elevatedApiCall } = usePassConfirm() +const { onInput, setMessage } = useMessage() + +const totpEnabled = computed(() => includes(store.mfaEnabledMethods, MfaMethod.TOTP)) + +const totpMessage = ref<TotpConfig>() +const showSubmitButton = ref(false) +const toaster = useFormToaster() + +const showTotpCode = computed(() => !isNil(totpMessage.value?.secret)) + +const secretSegments = computed<string[]>(() => { + //Chunk the secret into 6 character segments + const chunks = chunk(totpMessage.value?.secret, 6) + //Join the chunks into their chunk arrays + return map(chunks, chunk => join(chunk, '')) +}) + +const qrCode = computed(() => { + if (isNil(totpMessage.value?.secret)) { + return '' + } + + const m = totpMessage.value!; + + // 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()) + const url = `otpauth://totp/${m.issuer}:${userName.value}?${params.toString()}` + return url +}) + +const ProcessAddOrUpdate = async () => { + await elevatedApiCall(async ({ password }) => { + + // Init or update the totp method and get the encrypted totp message + const totp = await store.mfaUpsertMethod(MfaMethod.TOTP, password) as TotpConfig + + // Decrypt the totp secret + const secretBuf = await KeyStore.decryptDataAsync(totp.secret) + + // Encode the secret to base32 + totp.secret = base32Encode(secretBuf, 'RFC3548', { padding: false }) + + totpMessage.value = totp + }) +} + +const configTotp = async () => { + const { isCanceled } = await reveal({ + title: 'Enable TOTP multi factor?', + text: 'Are you sure you understand TOTP multi factor and wish to enable it?', + }) + + if (!isCanceled) { + ProcessAddOrUpdate() + } +} + +const regenTotp = async () => { + // If totp is enabled, show a prompt to regenerate totp + if (!totpEnabled.value) { + return + } + + const { isCanceled } = await reveal({ + title: 'Are you sure?', + text: 'If you continue your previous TOTP authenticator and recovery codes will no longer be valid.' + }) + + if (!isCanceled) { + ProcessAddOrUpdate() + } +} + +const disable = async () => { + // Show a confrimation prompt + const { isCanceled } = await reveal({ + title: 'Disable TOTP', + text: 'Are you sure you want to disable TOTP? You may re-enable TOTP later.' + }) + + if (isCanceled) { + return + } + + await elevatedApiCall(async ({ password }) => { + // Disable the totp method + await store.mfaDisableMethod(MfaMethod.TOTP, password) + }) +} + +const VerifyTotp = async (code: string) => { + // Create a new TOTP instance from the current message + const totp = new TOTP(totpMessage.value) + + // validate the code + const valid = totp.validate({ token: code, window: 4 }) + + if (valid) { + showSubmitButton.value = true + toaster.success({ + title: 'Success', + text: 'Your TOTP code is valid and your account is now verified.' + }) + } else { + setMessage('Your TOTP code is not valid.') + } +} + +const CloseQrWindow = () => { + showSubmitButton.value = false + totpMessage.value = undefined + + //Fresh methods + store.mfaRefresh() +} + +</script> + +<style> +#totp-settings .otp-input input { + @apply w-12 text-center text-lg mx-1 focus:border-primary-500; +} +</style> diff --git a/extension/src/entries/options/main.js b/extension/src/entries/options/main.js index 827c426..7747735 100644 --- a/extension/src/entries/options/main.js +++ b/extension/src/entries/options/main.js @@ -22,12 +22,12 @@ import Notifications from "@kyvg/vue3-notification"; /* FONT AWESOME CONFIG */ import { library } from '@fortawesome/fontawesome-svg-core' -import { faChevronLeft, faChevronRight, faCopy, faDownload, faEdit, faExternalLinkAlt, faLock, faLockOpen, faMoon, faPlus, faSun, faTrash } from '@fortawesome/free-solid-svg-icons' +import { faChevronLeft, faChevronRight, faCopy, faDownload, faEdit, faExternalLinkAlt, faLock, faLockOpen, faMinusCircle, faMoon, faPlus, faRefresh, faSun, faTrash, faTrashCan } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { createPinia } from "pinia"; -import { identityPlugin, originPlugin, useBackgroundPiniaPlugin } from "../store"; +import { identityPlugin, mfaConfigPlugin, originPlugin, useBackgroundPiniaPlugin } from "../store"; -library.add(faCopy, faEdit, faChevronLeft, faMoon, faSun, faLock, faLockOpen, faExternalLinkAlt, faTrash, faDownload, faChevronRight, faPlus) +library.add(faCopy, faEdit, faChevronLeft, faMoon, faSun, faLock, faLockOpen, faExternalLinkAlt, faTrash, faDownload, faChevronRight, faPlus, faRefresh, faTrashCan, faMinusCircle) //Create the background feature wiring const bgPlugins = useBackgroundPiniaPlugin('options') @@ -36,6 +36,7 @@ const pinia = createPinia() .use(bgPlugins) //Add the background pinia plugin .use(identityPlugin) //Add the identity plugin .use(originPlugin) //Add the origin plugin + .use(mfaConfigPlugin) //Add the mfa config plugin createApp(App) .use(Notifications) diff --git a/extension/src/entries/popup/Components/IdentitySelection.vue b/extension/src/entries/popup/Components/IdentitySelection.vue index 99d8e34..eb08fb1 100644 --- a/extension/src/entries/popup/Components/IdentitySelection.vue +++ b/extension/src/entries/popup/Components/IdentitySelection.vue @@ -1,18 +1,22 @@ <template> <div class="text-left"> - <div class="w-full"> - <div class=""> + <div class="flex flex-row w-full gap-1"> + <div class="flex-1"> <select class="w-full input" - :disabled="waiting" - :value="selected?.Id" - @change.prevent="onSelected" + :disabled="waiting" + :value="selected?.Id" + @change.prevent="onSelected" > <option disabled value="">Select an identity</option> <option v-for="key in allKeys" :value="key.Id">{{ key.UserName }}</option> </select> </div> + <div class="my-auto"> + <button class="btn sm borderless" @click="store.refreshIdentities()"> + <fa-icon icon="refresh" class="" /> + </button> + </div> </div> - </div> </template> diff --git a/extension/src/entries/popup/Components/Login.vue b/extension/src/entries/popup/Components/Login.vue index 93c0178..8b7b807 100644 --- a/extension/src/entries/popup/Components/Login.vue +++ b/extension/src/entries/popup/Components/Login.vue @@ -1,44 +1,46 @@ <template> <div id="login-template" class="py-4"> - <form class="" @submit.prevent="onSubmit"> - <fieldset class="px-4 input-container"> - <label class="">Please enter your authentication token</label> - <textarea class="w-full input" v-model="token" rows="5"> - </textarea> - </fieldset> - <div class="flex justify-end mt-2"> - <div class="px-3"> - <button class="w-24 rounded btn sm primary"> - <fa-icon v-if="waiting" icon="spinner" class="animate-spin" /> - <span v-else>Submit</span> - </button> - </div> - </div> - </form> + <TabGroup @change="onChange"> + <TabList as="div" class="flex flex-row mx-auto mb-3 font-bold w-fit"> + <Tab + class="p-0.5 mx-1 border-b" + :class="[ isActive(0) ? 'border-gray-400' : 'border-transparent']" + > + OTP + </Tab> + <Tab + class="p-0.5 mx-1 border-b" + :class="[ isActive(1) ? 'border-gray-400' : 'border-transparent']" + > + User/Pass + </Tab> + </TabList> + <TabPanels> + <TabPanel> + <OtpLogin /> + </TabPanel> + <TabPanel> + <PassLogin /> + </TabPanel> + </TabPanels> + </TabGroup> </div> </template> <script setup lang="ts"> -import { apiCall, useWait } from "@vnuge/vnlib.browser"; -import { ref } from "vue"; -import { useStore } from "../../store"; +import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue' +import PassLogin from "./PassLogin.vue"; +import OtpLogin from "./OtpLogin.vue"; +import { shallowRef } from 'vue'; -const { login } = useStore() -const { waiting } = useWait() +const activeTab = shallowRef(0) -const token = ref('') - -const onSubmit = async () => { - await apiCall(async ({ toaster }) => { - await login(token.value) - toaster.form.success({ - 'title': 'Login successful', - 'text': 'Successfully logged into your profile' - }) - }) - +const onChange = (index: any) => { + activeTab.value = index } +const isActive = (index: any) => activeTab.value === index + </script> <style lang="scss"> diff --git a/extension/src/entries/popup/Components/OtpLogin.vue b/extension/src/entries/popup/Components/OtpLogin.vue new file mode 100644 index 0000000..a2b8ac7 --- /dev/null +++ b/extension/src/entries/popup/Components/OtpLogin.vue @@ -0,0 +1,51 @@ +<template> + <form class="" @submit.prevent="onSubmit"> + <fieldset class="px-4 input-container"> + <label class="">Please enter your authentication token</label> + <textarea class="w-full input" v-model="token" rows="5" /> + </fieldset> + <div class="flex justify-end mt-2"> + <div class="px-3"> + <button class="w-24 rounded btn sm primary"> + <fa-icon v-if="waiting" icon="spinner" class="animate-spin" /> + <span v-else>Submit</span> + </button> + </div> + </div> + </form> +</template> + +<script setup lang="ts"> +import { apiCall, useWait } from "@vnuge/vnlib.browser"; +import { ref } from "vue"; +import { useStore } from "../../store"; + +const { login } = useStore() +const { waiting } = useWait() + +const token = ref('') + +const onSubmit = async () => { + await apiCall(async ({ toaster }) => { + try{ + await login(token.value) + + toaster.form.success({ + 'title': 'Login successful', + 'text': 'Successfully logged into your profile' + }) + } + catch(e:any){ + if('response' in e){ + throw e; + } + + toaster.form.error({ + title: 'Failed to login', + text: e.message + }) + } + }) +} + +</script>
\ No newline at end of file diff --git a/extension/src/entries/popup/Components/PageContent.vue b/extension/src/entries/popup/Components/PageContent.vue index e4fcb49..8a48840 100644 --- a/extension/src/entries/popup/Components/PageContent.vue +++ b/extension/src/entries/popup/Components/PageContent.vue @@ -57,7 +57,7 @@ {{ pubKey ?? 'No key selected' }} </div> <div class="my-auto ml-auto cursor-pointer" :class="{'text-primary-500': copied }"> - <fa-icon class="mr-1" icon="copy" @click="copy(pubKey)"/> + <fa-icon class="mr-1" icon="copy" @click="copy(pubKey!)"/> </div> </div> </div> diff --git a/extension/src/entries/popup/Components/PassLogin.vue b/extension/src/entries/popup/Components/PassLogin.vue new file mode 100644 index 0000000..29aeeb6 --- /dev/null +++ b/extension/src/entries/popup/Components/PassLogin.vue @@ -0,0 +1,89 @@ +<template> + <div v-if="showTotp" class=""> + <form id="totp-login" class="" @submit.prevent=""> + <fieldset class="px-4 input-container"> + <div class="text-center"> + <label class="text-sm text-center">Enter your totp code</label> + <div class="m-auto mt-3 w-min"> + <VOtpInput + class="otp-input" + input-type="letter-numeric" + separator="" + value="" + input-classes="primary input rounded" + :num-inputs="6" + @on-complete="onSubmitTotp" + /> + </div> + </div> + </fieldset> + </form> + </div> + <div v-else> + <form class="" @submit.prevent="onSubmit()"> + <fieldset class="px-4 input-container"> + <div class=""> + <label class="">Username</label> + <input type="text" name="username" class="w-full input" v-model="username" /> + </div> + <div class="mt-1"> + <label class="">Password</label> + <input type="password" name="password" class="w-full input" v-model="password" /> + </div> + </fieldset> + <div class="flex justify-end mt-2"> + <div class="px-3"> + <button class="w-24 rounded btn sm primary"> + <fa-icon v-if="waiting" icon="spinner" class="animate-spin" /> + <span v-else>Submit</span> + </button> + </div> + </div> + </form> + </div> +</template> + +<script setup lang="ts"> +import { useStore } from '../../store'; +import { computed, shallowRef } from 'vue'; +import { apiCall, useWait } from "@vnuge/vnlib.browser"; +import { isEmpty, toNumber } from 'lodash'; +import VOtpInput from "vue3-otp-input"; + +const { waiting } = useWait() +const store = useStore(); + +const showTotp = computed(() => store.mfaStatus?.type === 'totp') + +const username = shallowRef(''); +const password = shallowRef(''); + +const onSubmit = () => { + + //Invoke user-pass login + apiCall(async ({ toaster }) => { + + //Validate + if(isEmpty(username.value) || isEmpty(password.value)) { + toaster.form.error({ + title:'Please enter your username and password' + }) + return + } + + await store.login(username.value, password.value) + }); +}; + +const onSubmitTotp = (code: string) => { + //Invoke totp login + apiCall(() => store.plugins.user.submitMfa({ code: toNumber(code) })); +}; + +</script> + +<style lang="scss"> + #totp-login .otp-input input { + @apply w-10 p-0.5 rounded text-center text-lg mx-1 focus:border-primary-500; +} +</style>
\ No newline at end of file diff --git a/extension/src/entries/popup/main.js b/extension/src/entries/popup/main.js index 8b8a3d9..c8e9ef8 100644 --- a/extension/src/entries/popup/main.js +++ b/extension/src/entries/popup/main.js @@ -24,10 +24,10 @@ import "./local.scss" /* FONT AWESOME CONFIG */ import { library } from '@fortawesome/fontawesome-svg-core' -import { faArrowRightFromBracket, faCopy, faEdit, faGear, faMinus, faMoon, faPlus, faSpinner, faSun } from '@fortawesome/free-solid-svg-icons' +import { faArrowRightFromBracket, faCopy, faEdit, faGear, faMinus, faMoon, faPlus, faRefresh, faSpinner, faSun } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' -library.add(faSpinner, faEdit, faGear, faCopy, faArrowRightFromBracket, faPlus, faMinus, faSun, faMoon) +library.add(faSpinner, faEdit, faGear, faCopy, faArrowRightFromBracket, faPlus, faMinus, faSun, faMoon, faRefresh) const bgPlugin = useBackgroundPiniaPlugin('popup') diff --git a/extension/src/entries/store/features.ts b/extension/src/entries/store/features.ts index c619b0e..9f9a4db 100644 --- a/extension/src/entries/store/features.ts +++ b/extension/src/entries/store/features.ts @@ -1,7 +1,22 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. import 'pinia' import { } from 'lodash' import { PiniaPluginContext } from 'pinia' +import type { IMfaFlowContinuiation } from '@vnuge/vnlib.browser' import { useAuthApi, @@ -14,7 +29,8 @@ import { useForegoundFeatures, useEventTagFilterApi, useInjectAllowList, - onWatchableChange + onWatchableChange, + useMfaConfigApi } from "../../features" import { ChannelContext } from '../../messaging' @@ -25,6 +41,7 @@ export type BgPluginState<T> = { plugins: BgPlugins } & T declare module 'pinia' { export interface PiniaCustomProperties { plugins: BgPlugins + mfaStatus: Partial<IMfaFlowContinuiation> | null } } @@ -41,7 +58,8 @@ const usePlugins = (context: ChannelContext) => { localPki: use(useLocalPki), pki: use(usePkiApi), tagFilter: use(useEventTagFilterApi), - allowedOrigins: use(useInjectAllowList) + allowedOrigins: use(useInjectAllowList), + mfaConfig: use(useMfaConfigApi) } } @@ -56,9 +74,10 @@ export const useBackgroundPiniaPlugin = (context: ChannelContext) => { //watch for status changes onWatchableChange(user, async () => { //Get status update and set the values - const { loggedIn, userName } = await user.getStatus(); + const { loggedIn, userName, mfaStatus } = await user.getStatus(); store.loggedIn = loggedIn; store.userName = userName; + store.mfaStatus = mfaStatus }, { immediate: true }) diff --git a/extension/src/entries/store/identity.ts b/extension/src/entries/store/identity.ts index ef1941e..5bbc67a 100644 --- a/extension/src/entries/store/identity.ts +++ b/extension/src/entries/store/identity.ts @@ -1,3 +1,17 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. import 'pinia' import { } from 'lodash' @@ -13,6 +27,7 @@ declare module 'pinia' { createIdentity(id: Partial<NostrPubKey>): Promise<NostrPubKey>; updateIdentity(id: NostrPubKey): Promise<NostrPubKey>; selectKey(key: NostrPubKey): Promise<void>; + refreshIdentities(): Promise<void>; } } @@ -40,6 +55,7 @@ export const identityPlugin = ({ store }: PiniaPluginContext) => { selectKey: identity.selectKey, deleteIdentity: identity.deleteIdentity, createIdentity: identity.createIdentity, - updateIdentity: identity.updateIdentity + updateIdentity: identity.updateIdentity, + refreshIdentities: identity.refreshKeys } }
\ No newline at end of file diff --git a/extension/src/entries/store/index.ts b/extension/src/entries/store/index.ts index 07fce6d..e3eef2f 100644 --- a/extension/src/entries/store/index.ts +++ b/extension/src/entries/store/index.ts @@ -23,6 +23,7 @@ export type * from './types' export * from './allowedOrigins' export * from './features' export * from './identity' +export * from './mfaconfig' export const useStore = defineStore({ id: 'main', @@ -34,8 +35,8 @@ export const useStore = defineStore({ }), actions: { - async login (token: string) { - await this.plugins.user.login(token); + async login (usernameOrToken: string, password?: string) { + await this.plugins.user.login(usernameOrToken, password); }, async logout () { @@ -49,10 +50,6 @@ export const useStore = defineStore({ async toggleDarkMode(){ await this.plugins.settings.setDarkMode(this.darkMode === false) }, - - checkIsCurrentOriginAllowed() { - - } }, getters:{ diff --git a/extension/src/entries/store/mfaconfig.ts b/extension/src/entries/store/mfaconfig.ts new file mode 100644 index 0000000..6a5116d --- /dev/null +++ b/extension/src/entries/store/mfaconfig.ts @@ -0,0 +1,67 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +import 'pinia' +import { } from 'lodash' +import { PiniaPluginContext } from 'pinia' +import { shallowRef } from 'vue'; +import { MfaUpdateResult, PkiPubKey, onWatchableChange } from '../../features'; +import { MfaMethod } from '@vnuge/vnlib.browser'; + +declare module 'pinia' { + export interface PiniaCustomProperties { + readonly mfaEnabledMethods: Array<MfaMethod>; + readonly pkiServerKeys: Array<PkiPubKey>; + mfaUpsertMethod(method: MfaMethod, password: string): Promise<MfaUpdateResult>; + mfaDisableMethod(method: MfaMethod, password: string): Promise<void>; + mfaRefresh(): void; + pkiAddKey(key: PkiPubKey): Promise<void>; + pkiRemoveKey(key: PkiPubKey): Promise<void>; + } +} + +export const mfaConfigPlugin = ({ store }: PiniaPluginContext) => { + + const mfaEnabledMethods = shallowRef<MfaMethod[]>() + const pkiServerKeys = shallowRef<PkiPubKey[]>() + const { mfaConfig, pki } = store.plugins + + onWatchableChange(mfaConfig, async () => { + //store enabled methods + mfaEnabledMethods.value = await mfaConfig.getMfaMethods() + }, { immediate: true }) + + onWatchableChange(pki, async () => { + //store pki keys + pkiServerKeys.value = await pki.getAllKeys() + }, { immediate: true }) + + return { + mfaEnabledMethods, + pkiServerKeys, + mfaUpsertMethod: (method: MfaMethod, password: string) => { + return mfaConfig.enableOrUpdate(method, password) + }, + mfaDisableMethod: async (method: MfaMethod, password: string) => { + await mfaConfig.disableMethod(method, password) + }, + mfaRefresh: () => { + mfaConfig.refresh() + pki.refresh() + }, + pkiAddKey: pki.addOrUpdate, + pkiRemoveKey: pki.removeKey + } +} diff --git a/extension/src/features/auth-api.ts b/extension/src/features/auth-api.ts index f47d505..fbc9420 100644 --- a/extension/src/features/auth-api.ts +++ b/extension/src/features/auth-api.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2023 Vaughn Nugent +// Copyright (C) 2024 Vaughn Nugent // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as @@ -14,15 +14,17 @@ // along with this program. If not, see <https://www.gnu.org/licenses/>. import { AxiosInstance } from "axios"; -import { get } from "@vueuse/core"; -import { computed } from "vue"; -import { delay } from "lodash"; -import { usePkiAuth, useSession, useUser } from "@vnuge/vnlib.browser"; +import { get, useTimeoutFn, set } from "@vueuse/core"; +import { computed, shallowRef } from "vue"; +import { clone, defer, delay } from "lodash"; +import { IMfaFlowContinuiation, totpMfaProcessor, useMfaLogin, usePkiAuth, useSession, useUser, + type IMfaSubmission, type IMfaMessage, type WebMessage +} from "@vnuge/vnlib.browser"; +import { type FeatureApi, type BgRuntime, type IFeatureExport, exportForegroundApi, popupAndOptionsOnly, popupOnly } from "./framework"; +import { waitForChangeFn } from "./util"; import type { ClientStatus } from "./types"; import type { AppSettings } from "./settings"; import type { JsonObject } from "type-fest"; -import { type FeatureApi, type BgRuntime, type IFeatureExport, exportForegroundApi, popupAndOptionsOnly, popupOnly } from "./framework"; -import { waitForChangeFn } from "./util"; export interface ProectedHandler<T extends JsonObject> { @@ -38,11 +40,12 @@ export interface ApiMessageHandler<T extends JsonObject> { } export interface UserApi extends FeatureApi { - login: (token: string) => Promise<boolean> + login(username: string, password?: string): Promise<boolean> logout: () => Promise<void> getProfile: () => Promise<any> getStatus: () => Promise<ClientStatus> waitForChange: () => Promise<void> + submitMfa: (submission: IMfaSubmission) => Promise<boolean> } export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => { @@ -55,7 +58,8 @@ export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => { const currentPkiPath = computed(() => `${currentConfig.value.accountBasePath}/pki`) //Use pki login controls - const { login } = usePkiAuth(currentPkiPath as any) + const pkiAuth = usePkiAuth(currentPkiPath as any) + const { login } = useMfaLogin([ totpMfaProcessor() ]) //We can send post messages to the server heartbeat endpoint to get status const runHeartbeat = async () => { @@ -76,15 +80,73 @@ export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => { } } + const mfaUpgrade = (() => { + + const store = shallowRef<IMfaFlowContinuiation | null>(null) + const message = computed<IMfaMessage | null>(() =>{ + if(!store.value){ + return null + } + //clone the continuation to send to the popup + const cl = clone<Partial<IMfaFlowContinuiation>>(store.value) + //Remove the submit method from the continuation + delete cl.submit; + return cl as IMfaMessage + }) + + const { start, stop } = useTimeoutFn(() => set(store, null), 360 * 1000) + + return{ + setContiuation(cont: IMfaFlowContinuiation){ + //Store continuation for later + set(store, cont) + //Restart cleanup timer + start() + }, + continuation: message, + async submit(submission: IMfaSubmission){ + const cont = get(store) + if(!cont){ + throw new Error('MFA login expired') + } + const response = await cont.submit(submission) + response.getResultOrThrow() + + //Stop timer + stop() + //clear the continuation + defer(() => set(store, null)) + } + } + })() + //Configure interval to run every 5 minutes to update the status setInterval(runHeartbeat, 60 * 1000); delay(runHeartbeat, 1000) //Delay 1 second to allow the extension to load return { - waitForChange: waitForChangeFn([currentConfig, loggedIn, userName]), - login: popupOnly(async (token: string): Promise<boolean> => { - //Perform login - await login(token) + waitForChange: waitForChangeFn([currentConfig, loggedIn, userName, mfaUpgrade.continuation]), + login: popupOnly(async (usernameOrToken: string, password?: string): Promise<boolean> => { + + if(password){ + const result = await login(usernameOrToken, password) + if ('getResultOrThrow' in result){ + (result as WebMessage).getResultOrThrow() + } + + if((result as IMfaFlowContinuiation).submit){ + //Capture continuation, store for submission for later, and set the continuation + mfaUpgrade.setContiuation(result as IMfaFlowContinuiation); + return true; + } + + //Otherwise normal login + } + else{ + //Perform login + await pkiAuth.login(usernameOrToken) + } + //load profile getProfile() return true; @@ -95,6 +157,19 @@ export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => { //Cleanup after logout clearLoginState() }), + submitMfa: popupOnly(async (submission: IMfaSubmission): Promise<boolean> => { + const cont = get(mfaUpgrade.continuation) + if(!cont || cont.expired){ + return false; + } + + //Submit the continuation + await mfaUpgrade.submit(submission); + + //load profile + getProfile() + return true; + }), getProfile: popupAndOptionsOnly(getProfile), async getStatus (){ return { @@ -102,6 +177,8 @@ export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => { loggedIn: get(loggedIn), //username userName: get(userName), + //mfa status + mfaStatus: get(mfaUpgrade.continuation) } as ClientStatus }, } @@ -111,7 +188,8 @@ export const useAuthApi = (): IFeatureExport<AppSettings, UserApi> => { 'logout', 'getProfile', 'getStatus', - 'waitForChange' + 'waitForChange', + 'submitMfa', ]), } }
\ No newline at end of file diff --git a/extension/src/features/framework/index.ts b/extension/src/features/framework/index.ts index 44ae031..2d9cad5 100644 --- a/extension/src/features/framework/index.ts +++ b/extension/src/features/framework/index.ts @@ -34,7 +34,11 @@ export type FeatureApi = { export type SendMessageHandler = <T extends JsonObject | JsonObject[]>(action: string, data: any) => Promise<T> export type VarArgsFunction<T> = (...args: any[]) => T export type FeatureConstructor<TState, T extends FeatureApi> = () => IFeatureExport<TState, T> -export type DummyApiExport<T extends FeatureApi> = Array<keyof T> + +export type DummyApiExport<T extends FeatureApi> = { + [K in keyof T]: T[K] extends Function ? K : never +}[keyof T][] + export interface IFeatureExport<TState, TFeature extends FeatureApi> { /** diff --git a/extension/src/features/identity-api.ts b/extension/src/features/identity-api.ts index a8ac4e6..d909162 100644 --- a/extension/src/features/identity-api.ts +++ b/extension/src/features/identity-api.ts @@ -24,10 +24,10 @@ import { exportForegroundApi } from "./framework"; import { AppSettings } from "./settings"; -import { shallowRef, watch } from "vue"; +import { shallowRef } from "vue"; import { useSession } from "@vnuge/vnlib.browser"; -import { set, useToggle } from "@vueuse/core"; -import { defer, isArray } from "lodash"; +import { set, useToggle, watchDebounced } from "@vueuse/core"; +import { isArray } from "lodash"; import { waitForChange, waitForChangeFn } from "./util"; export interface IdentityApi extends FeatureApi, Watchable { @@ -37,6 +37,7 @@ export interface IdentityApi extends FeatureApi, Watchable { getAllKeys: () => Promise<NostrPubKey[]>; getPublicKey: () => Promise<NostrPubKey | undefined>; selectKey: (key: NostrPubKey) => Promise<void>; + refreshKeys: () => Promise<void>; } export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => { @@ -50,27 +51,23 @@ export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => { const allKeys = shallowRef<NostrPubKey[]>([]); const [ onKeyUpdateTriggered , triggerKeyUpdate ] = useToggle() - const keyLoadWatchLoop = async () => { - while(true){ - //Load keys from server if logged in - if(loggedIn.value){ - const [...keys] = await execRequest(Endpoints.GetKeys); - allKeys.value = isArray(keys) ? keys : []; - } - else{ - //Clear all keys when logged out - allKeys.value = []; - } - - //Wait for changes to trigger a new key-load - await waitForChange([loggedIn, onKeyUpdateTriggered]) + watchDebounced([onKeyUpdateTriggered, loggedIn], async () => { + //Load keys from server if logged in + if (loggedIn.value) { + const [...keys] = await execRequest(Endpoints.GetKeys); + allKeys.value = isArray(keys) ? keys : []; } - } + else { + //Clear all keys when logged out + allKeys.value = []; - defer(keyLoadWatchLoop) + //Clear the selected key if the user becomes logged out + selectedKey.value = undefined; + } - //Clear the selected key if the user logs out - watch(loggedIn, (li) => li ? null : selectedKey.value = undefined) + //Wait for changes to trigger a new key-load + await waitForChange([ loggedIn, onKeyUpdateTriggered ]) + }, { debounce: 100 }) return { //Identity is only available in options context @@ -98,6 +95,10 @@ export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => { getPublicKey: (): Promise<NostrPubKey | undefined> => { return Promise.resolve(selectedKey.value); }, + refreshKeys: () => { + triggerKeyUpdate() + return Promise.resolve() + }, waitForChange: waitForChangeFn([selectedKey, loggedIn, allKeys]) } }, @@ -108,7 +109,8 @@ export const useIdentityApi = (): IFeatureExport<AppSettings, IdentityApi> => { 'getAllKeys', 'getPublicKey', 'selectKey', - 'waitForChange' + 'waitForChange', + 'refreshKeys' ]) } }
\ No newline at end of file diff --git a/extension/src/features/index.ts b/extension/src/features/index.ts index c146cef..f05de9b 100644 --- a/extension/src/features/index.ts +++ b/extension/src/features/index.ts @@ -17,13 +17,13 @@ export type { NostrPubKey, LoginMessage } from './types' export type * from './framework' export type { PluginConfig } from './settings' -export type { PkiPubKey, EcKeyParams, LocalPkiApi as PkiApi } from './account-api' +export type { PkiPubKey, EcKeyParams, LocalPkiApi as PkiApi } from './pki-api' export type { NostrApi } from './nostr-api' export type { UserApi } from './auth-api' export type { IdentityApi } from './identity-api' export { useBackgroundFeatures, useForegoundFeatures } from './framework' -export { useLocalPki, usePkiApi } from './account-api' +export { useLocalPki, usePkiApi } from './pki-api' export { useAuthApi } from './auth-api' export { useIdentityApi } from './identity-api' export { useNostrApi } from './nostr-api' @@ -31,4 +31,5 @@ export { useSettingsApi, useAppSettings } from './settings' export { useHistoryApi } from './history' export { useEventTagFilterApi } from './tagfilter-api' export { useInjectAllowList } from './nip07allow-api' -export { onWatchableChange } from './util'
\ No newline at end of file +export { onWatchableChange } from './util' +export { useMfaConfigApi, type MfaUpdateResult } from './mfa-api'
\ No newline at end of file diff --git a/extension/src/features/mfa-api.ts b/extension/src/features/mfa-api.ts new file mode 100644 index 0000000..fc6d51a --- /dev/null +++ b/extension/src/features/mfa-api.ts @@ -0,0 +1,87 @@ +// Copyright (C) 2023 Vaughn Nugent +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +import { get, set, useToggle, watchDebounced } from "@vueuse/core"; +import { computed, shallowRef } from "vue"; +import { } from "lodash"; +import { useSession, useMfaConfig, MfaMethod } from "@vnuge/vnlib.browser"; +import type { TotpUpdateMessage, Watchable } from "./types"; +import type { AppSettings } from "./settings"; +import { type FeatureApi, type BgRuntime, type IFeatureExport, exportForegroundApi, optionsOnly } from "./framework"; +import { waitForChangeFn } from "./util"; + +export type MfaUpdateResult = TotpUpdateMessage + +export interface MfaConfigApi extends FeatureApi, Watchable { + getMfaMethods: () => Promise<MfaMethod[]> + enableOrUpdate: (method: MfaMethod, password: string) => Promise<MfaUpdateResult> + disableMethod: (method: MfaMethod, password: string) => Promise<void> + refresh: () => Promise<void> +} + +export const useMfaConfigApi = (): IFeatureExport<AppSettings, MfaConfigApi> => { + + return { + background: ({ state }: BgRuntime<AppSettings>): MfaConfigApi => { + const { loggedIn } = useSession(); + const { currentConfig } = state + + const [onRefresh, refresh] = useToggle() + + const mfaPath = computed(() => `${currentConfig.value.accountBasePath}/mfa`) + const mfaConfig = useMfaConfig(mfaPath) + const mfaEnabledMethods = shallowRef<MfaMethod[]>([]) + + //Update enabled methods + watchDebounced([currentConfig, loggedIn, onRefresh], async () => { + if(!loggedIn.value){ + set(mfaEnabledMethods, []) + return + } + const methods = await mfaConfig.getMethods() + set(mfaEnabledMethods, methods) + }, { debounce: 100 }) + + return { + waitForChange: waitForChangeFn([currentConfig, loggedIn, mfaEnabledMethods]), + + getMfaMethods: optionsOnly(() => { + return Promise.resolve(get(mfaEnabledMethods)) + }), + enableOrUpdate: optionsOnly(async (method: MfaMethod, password: string) => { + //Exec request to update mfa method + const result = await mfaConfig.initOrUpdateMethod<MfaUpdateResult>(method, password) + refresh() + return result.getResultOrThrow() + }), + disableMethod: optionsOnly(async (method: MfaMethod, password: string) => { + await mfaConfig.disableMethod(method, password) + refresh() + }), + refresh() { + refresh() + return Promise.resolve() + } + } + }, + foreground: exportForegroundApi<MfaConfigApi>([ + 'waitForChange', + 'getMfaMethods', + 'enableOrUpdate', + 'disableMethod', + 'refresh' + ]), + } +}
\ No newline at end of file diff --git a/extension/src/features/account-api.ts b/extension/src/features/pki-api.ts index 96948c4..41fbd48 100644 --- a/extension/src/features/account-api.ts +++ b/extension/src/features/pki-api.ts @@ -13,15 +13,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see <https://www.gnu.org/licenses/>. -import { useMfaConfig, usePkiConfig, type PkiPublicKey } from "@vnuge/vnlib.browser"; +import { usePkiConfig, type PkiPublicKey, useSession } from "@vnuge/vnlib.browser"; import { ArrayToHexString, Base64ToUint8Array } from "@vnuge/vnlib.browser/dist/binhelpers"; import { JsonObject } from "type-fest"; -import { computed, watch } from "vue"; +import { computed, shallowRef } from "vue"; import { JWK, SignJWT, importJWK } from "jose"; import { clone } from "lodash"; -import { FeatureApi, BgRuntime, IFeatureExport, exportForegroundApi, optionsOnly, popupAndOptionsOnly } from "./framework"; +import { FeatureApi, BgRuntime, IFeatureExport, exportForegroundApi, optionsOnly } from "./framework"; import { AppSettings } from "./settings"; -import { set, toRefs } from "@vueuse/core"; +import { get, set, toRefs, useToggle, watchDebounced } from "@vueuse/core"; +import { Watchable } from "./types"; +import { waitForChangeFn } from "./util"; export interface EcKeyParams extends JsonObject { @@ -39,43 +41,59 @@ export interface PkiPubKey extends JsonObject, PkiPublicKey { readonly userName: string } -export interface PkiApi extends FeatureApi{ +export interface PkiApi extends FeatureApi, Watchable{ getAllKeys(): Promise<PkiPubKey[]> + addOrUpdate(key: PkiPubKey): Promise<void> removeKey(kid: PkiPubKey): Promise<void> - isEnabled(): Promise<boolean> + refresh(): Promise<void> } export const usePkiApi = (): IFeatureExport<AppSettings, PkiApi> => { return{ background: ({ state } : BgRuntime<AppSettings>):PkiApi =>{ + const { loggedIn } = useSession() const accountPath = computed(() => state.currentConfig.value.accountBasePath) - const mfaEndpoint = computed(() => `${accountPath.value}/mfa`) const pkiEndpoint = computed(() => `${accountPath.value}/pki`) - + const [ onRefresh, refresh ] = useToggle() //Compute config - const mfaConfig = useMfaConfig(mfaEndpoint); - const pkiConfig = usePkiConfig(pkiEndpoint, mfaConfig); + + const pkiConfig = usePkiConfig(pkiEndpoint); + const keys = shallowRef<PkiPubKey[]>([]) //Refresh the config when the endpoint changes - watch(mfaEndpoint, () => pkiConfig.refresh()); + watchDebounced([pkiEndpoint, loggedIn, onRefresh], async () => { + if(!loggedIn.value){ + set(keys, []) + return + } + + const res = await pkiConfig.getAllKeys() + set(keys, res as PkiPubKey[]) + }, {debounce: 100}); return{ - getAllKeys: optionsOnly(async () => { - const res = await pkiConfig.getAllKeys(); - return res as PkiPubKey[] + waitForChange: waitForChangeFn([loggedIn, keys]), + getAllKeys: optionsOnly(() => { + return Promise.resolve(get(keys)) }), removeKey: optionsOnly(async (key: PkiPubKey) => { await pkiConfig.removeKey(key.kid) }), - isEnabled: popupAndOptionsOnly(async () => { - return pkiConfig.enabled.value - }) + addOrUpdate: optionsOnly(async (key: PkiPubKey) => { + await pkiConfig.addOrUpdate(key) + }), + refresh() { + refresh() + return Promise.resolve() + } } }, foreground: exportForegroundApi<PkiApi>([ + 'waitForChange', 'getAllKeys', + 'addOrUpdate', 'removeKey', - 'isEnabled' + 'refresh' ]) } } diff --git a/extension/src/features/types.ts b/extension/src/features/types.ts index 856b95a..92cf6cf 100644 --- a/extension/src/features/types.ts +++ b/extension/src/features/types.ts @@ -63,6 +63,7 @@ export interface LoginMessage extends JsonObject { export interface ClientStatus extends JsonObject { readonly loggedIn: boolean; readonly userName: string | null; + readonly mfaStatus: object | null; } export enum NostrRelayFlags { @@ -96,4 +97,12 @@ export interface Watchable{ * must be called again to listen for the next change. */ waitForChange(): Promise<void>; +} + +export interface TotpUpdateMessage extends JsonObject { + readonly issuer: string + readonly digits: number + readonly period: number + readonly algorithm: string + readonly secret: string }
\ No newline at end of file |