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/src/entries | |
parent | 8dec218a1aa259f83b8178265a7d0d0f08817cac (diff) |
package updates & partial account page added
Diffstat (limited to 'extension/src/entries')
20 files changed, 866 insertions, 74 deletions
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 + } +} |