diff options
Diffstat (limited to 'front-end/src/views/Login')
-rw-r--r-- | front-end/src/views/Login/components/Social.vue | 57 | ||||
-rw-r--r-- | front-end/src/views/Login/components/Totp.vue | 65 | ||||
-rw-r--r-- | front-end/src/views/Login/components/UserPass.vue | 92 | ||||
-rw-r--r-- | front-end/src/views/Login/index.vue | 182 | ||||
-rw-r--r-- | front-end/src/views/Login/pki/index.vue | 80 | ||||
-rw-r--r-- | front-end/src/views/Login/social/[type].vue | 127 |
6 files changed, 603 insertions, 0 deletions
diff --git a/front-end/src/views/Login/components/Social.vue b/front-end/src/views/Login/components/Social.vue new file mode 100644 index 0000000..34c5a1e --- /dev/null +++ b/front-end/src/views/Login/components/Social.vue @@ -0,0 +1,57 @@ +<template> + + <form class="w-full" @submit.prevent="SocalLogin('/login/social/github')"> + <button type="submit" class="btn social-button" :disabled="waiting"> + <fa-icon :icon="['fab','github']" size="xl" /> + Login with Github + </button> + </form> + + <form class="mt-4" @submit.prevent="SocalLogin('/login/social/discord')"> + <button type="submit" class="btn social-button" :disabled="waiting"> + <fa-icon :icon="['fab','discord']" size="xl" /> + Login with Discord + </button> + </form> + + <form v-if="auth0Enabled" class="mt-4" @submit.prevent="SocalLogin('/login/social/auth0')"> + <button type="submit" class="btn social-button" :disabled="waiting"> + <fa-icon :icon="['fa','key']" size="xl" /> + Login with Auth0 + </button> + </form> + +</template> + +<script setup lang="ts"> +import { apiCall, useWait, useSession, useSessionUtils, WebMessage } from '@vnuge/vnlib.browser' + +//auth0 enabled flag from env +const auth0Enabled = import.meta.env.VITE_ENABLE_AUTH0 == 'true'; + +const { waiting } = useWait() +const { browserId, publicKey } = useSession() +const { KeyStore } = useSessionUtils() + +const SocalLogin = async (url:string) => { + await apiCall(async ({ axios }) => { + const { data } = await axios.put<WebMessage<string>>(url, { + browser_id: browserId.value, + public_key: publicKey.value + }) + + const encDat = data.getResultOrThrow() + // Decrypt the result which should be a redirect url + const result = await KeyStore.decryptDataAsync(encDat) + // get utf8 text + const text = new TextDecoder('utf-8').decode(result) + // Recover url + const redirect = new URL(text) + // Force https + redirect.protocol = 'https:' + // redirect to the url + window.location.href = redirect.href + }) +} + +</script>
\ No newline at end of file diff --git a/front-end/src/views/Login/components/Totp.vue b/front-end/src/views/Login/components/Totp.vue new file mode 100644 index 0000000..50a5be3 --- /dev/null +++ b/front-end/src/views/Login/components/Totp.vue @@ -0,0 +1,65 @@ +<template> + <div id="totp-login-form"> + <h5>Enter your TOTP code</h5> + <div class="flex flex-col h-32"> + <div class="h-8 mx-auto"> + <fa-icon v-if="waiting" class="animate-spin" size="xl" icon="spinner"/> + </div> + <div class="mx-auto mt-4"> + <VOtpInput + class="otp-input" + input-type="letter-numeric" + :is-disabled="waiting" + separator="" + input-classes="primary input rounded" + :num-inputs="6" + value="" + @on-change="onInput" + @on-complete="SubimitTotp" + /> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { useMessage, useWait } from '@vnuge/vnlib.browser'; +import { toSafeInteger } from 'lodash'; +import VOtpInput from "vue3-otp-input"; + +const emit = defineEmits(['submit']) + +const { waiting } = useWait(); +const { onInput } = useMessage(); + +const SubimitTotp = async (code : string) => { + + //If a request is still pending, do nothing + if (waiting.value) { + return + } + + //Submit a mfa upgrade result + emit('submit', { + code: toSafeInteger(code) + }) +} + + +</script> + +<style lang="scss"> + +#totp-login-form { + .otp-input { + @apply rounded-sm gap-2; + + input { + @apply w-12 h-12 p-3 text-center text-2xl; + appearance: none; + -webkit-appearance: none; + } + } +} + +</style>
\ No newline at end of file diff --git a/front-end/src/views/Login/components/UserPass.vue b/front-end/src/views/Login/components/UserPass.vue new file mode 100644 index 0000000..e218cb8 --- /dev/null +++ b/front-end/src/views/Login/components/UserPass.vue @@ -0,0 +1,92 @@ +<template> + <div class=""> + <h3>Login</h3> + <form id="user-pass-submit-form" method="post" action="/login" @submit.prevent="SubmitLogin"> + <fieldset class="" :disabled="waiting" > + <div> + <div class="float-label"> + <input + id="username" + v-model="v$.username.$model" + type="email" + class="w-full primary input" + placeholder="Email" + :class="{ 'data-invalid': v$.username.$invalid }" + @input="onInput" + > + <label for="username">Email</label> + </div> + </div> + <div class="py-3"> + <div class="mb-2 float-label"> + <input + id="password" + v-model="v$.password.$model" + type="password" + class="w-full primary input" + placeholder="Password" + :class="{ 'data-invalid': v$.password.$invalid }" + @input="onInput" + > + <label for="password">Password</label> + </div> + </div> + </fieldset> + <button type="submit" form="user-pass-submit-form" class="btn primary" :disabled="waiting"> + <!-- Display spinner if waiting, otherwise the sign-in icon --> + <fa-icon :class="{'animate-spin':waiting}" :icon="waiting ? 'spinner' : 'sign-in-alt'"/> + Log-in + </button> + </form> + <div class="flex flex-row justify-between gap-3 pt-3 pb-2 form-links"> + <router-link to="/pwreset"> + Forgot password + </router-link> + <router-link to="/register"> + Register a new account + </router-link> + </div> + </div> +</template> + +<script setup lang="ts"> +import { reactive } from 'vue' +import useVuelidate from '@vuelidate/core' +import { required, maxLength, minLength, email, helpers } from '@vuelidate/validators' +import { useMessage, useVuelidateWrapper, useWait } from '@vnuge/vnlib.browser' + +const emit = defineEmits(['login']) + +const { onInput } = useMessage(); +const { waiting } = useWait(); + +const vState = reactive({ username: '', password: '' }) + +const rules = { + username: { + required: helpers.withMessage('Email cannot be empty', required), + email: helpers.withMessage('Your email address is not valid', email), + maxLength: helpers.withMessage('Email address must be less than 50 characters', maxLength(50)) + }, + password: { + required: helpers.withMessage('Password cannot be empty', required), + minLength: helpers.withMessage('Password must be at least 8 characters', minLength(8)), + maxLength: helpers.withMessage('Password must have less than 128 characters', maxLength(128)) + } +} + +const v$ = useVuelidate(rules, vState) +const { validate } = useVuelidateWrapper(v$); + +const SubmitLogin = async () => { + + // If the form is not valid set the error message + if (!await validate()) { + return + } + + //Emit login and pass the username and password + emit('login', { username: v$.value.username.$model, password: v$.value.password.$model }); +} + +</script>
\ No newline at end of file diff --git a/front-end/src/views/Login/index.vue b/front-end/src/views/Login/index.vue new file mode 100644 index 0000000..f3a3f59 --- /dev/null +++ b/front-end/src/views/Login/index.vue @@ -0,0 +1,182 @@ +<template> + <div id="login-template" class="app-component-entry"> + <div class="login-container"> + + <div v-if="showTotp"> + <Totp @submit="totpSubmit" /> + </div> + + <div v-else-if="!loggedIn"> + <UserPass @login="submitLogin" /> + </div> + + <div v-else> + <h3>Logout</h3> + <p class="mt-3 mb-5 text-lg"> + You are currently logged-in. + </p> + <div class=""> + <button form="user-pass-submit-form" class="btn primary" @click="submitLogout" :disabled="waiting"> + <!-- Display spinner if waiting, otherwise the sign-in icon --> + <fa-icon :class="{'animate-spin':waiting}" :icon="waiting ? 'spinner' : 'sign-in-alt'"/> + Log-out + </button> + </div> + </div> + + <div v-if="!(loggedIn || showTotp)" class="w-full mt-6"> + + <Social /> + + <!-- pki button, forward to the pki route --> + <div v-if="pkiEnabled" class="mt-4"> + <router-link to="/login/pki"> + <button type="submit" class="btn red social-button" :disabled="waiting"> + <fa-icon :icon="['fa','certificate']" size="xl" /> + Login with PKI Credential + </button> + </router-link> + </div> + </div> + + </div> + </div> +</template> + +<script setup lang="ts"> +import { computed, ref } from 'vue' +import Totp from './components/Totp.vue' +import UserPass from './components/UserPass.vue' +import Social from './components/Social.vue' +import { apiCall, useMessage, useWait, useUser, useSession, useLastPage, useTitle, debugLog } from '@vnuge/vnlib.browser' +import { useMfaLogin, totpMfaProcessor, IMfaFlowContinuiation, MfaMethod } from '@vnuge/vnlib.browser/dist/mfa' +import { useTimeoutFn } from '@vueuse/shared' +import { isNil } from 'lodash' + +useTitle('Login') + +//pki enabled flag from env +const pkiEnabled = !isNil(import.meta.env.VITE_PKI_ENDPOINT); + +const { waiting } = useWait() +const { setMessage } = useMessage() +const { logout } = useUser(); +const { loggedIn } = useSession() + +//Setup mfa login +const { login } = useMfaLogin([ + totpMfaProcessor() +]) + +//If logged in re-route to the last page the user +//was on but delayed to the session has time to be set +const { gotoLastPage } = useLastPage() +useTimeoutFn(() => loggedIn.value ? gotoLastPage() : null, 500) + +const mfaUpgrade = ref<IMfaFlowContinuiation>(); +const mfaTimer = ref<{stop:() => void}>(); +const showTotp = computed(() => mfaUpgrade.value?.type === MfaMethod.TOTP) + +const submitLogout = async () => { + //Submit logout request + await apiCall(async ({ toaster }) => { + // Attempt to login + await logout() + // Push a new toast message + toaster.general.success({ + id: 'logout-success', + title: 'Success', + text: 'You have been logged out', + duration: 5000 + }) + }) +} + +const submitLogin = async ({username, password} : { username: string, password:string }) => { + // Run login in an apicall wrapper + await apiCall(async ({ toaster }) => { + // Attempt to login + const response = await login(username, password) + + debugLog('Mfa-login',response) + + //Try to get response as a flow continuation + const mfa = response as IMfaFlowContinuiation + + // Response is a totp upgrade request + if (mfa.type === MfaMethod.TOTP) { + + //Store the upgrade message + mfaUpgrade.value = mfa; + + // Set timeout to reset the form when totp expires + mfaTimer.value = useTimeoutFn(() => { + + //Clear upgrade message + mfaUpgrade.value = undefined; + + setMessage('Your TOTP request has expired') + + }, mfa.expires! * 1000) + } + //If login without mfa was successful + else if (response.success) { + // Push a new toast message + toaster.general.success({ + title: 'Success', + text: 'You have been logged in', + }) + + return; + } + }) +} + +const totpSubmit = ({ code } : {code:number}) =>{ + apiCall(async ({ toaster }) =>{ + + if (!mfaUpgrade.value) + return; + + //Submit totp code + const res = await mfaUpgrade.value.submit({ code }) + res.getResultOrThrow() + + //Clear timer + mfaTimer.value?.stop() + + //Clear upgrade message + mfaUpgrade.value = undefined; + + // Push a new toast message + toaster.general.success({ + title: 'Success', + text: 'You have been logged in', + }) + }) +} + +</script> + +<style lang="scss"> +#login-template { + .login-container{ + @apply container max-w-sm w-full sm:mt-2 mt-8 mb-16 mx-auto lg:mt-16 px-6 py-4 flex flex-col; + @apply ease-linear duration-150 text-center; + @apply rounded-sm sm:bg-white sm:border shadow-sm border-gray-200 sm:dark:bg-dark-800 dark:border-dark-500; + } + + .login-container button{ + @apply w-full border py-2.5; + } + + button.social-button { + @apply flex flex-row justify-center gap-3 items-center; + } + + a { + @apply ease-in-out duration-100; + @apply hover:text-primary-600 dark:hover:text-primary-500; + } +} +</style> diff --git a/front-end/src/views/Login/pki/index.vue b/front-end/src/views/Login/pki/index.vue new file mode 100644 index 0000000..ae1a4a8 --- /dev/null +++ b/front-end/src/views/Login/pki/index.vue @@ -0,0 +1,80 @@ +<template> + <div id="pki-login-template" class="app-component-entry"> + <div class="container max-w-lg mx-auto mt-6 lg:mt-20"> + <div class="p-2 text-center bg-white border rounded shadow-md dark:border-dark-500 dark:bg-dark-800"> + + <h4>Enter your PKI-OTP</h4> + + <div class="p-3"> + <div class=""> + <textarea v-model="otp" class="w-full p-1 border rounded-sm input primary" rows="5"></textarea> + </div> + + <div class="flex justify-between mt-4"> + <div class="text-sm"> + <a class="link" target="_blank" href="https://github.com/VnUgE/Plugins.Essentials/tree/master/plugins/VNLib.Plugins.Essentials.Accounts"> + Goto OTP spec + <fa-icon icon="arrow-right" class="ml-1" /> + </a> + </div> + <div class="button-group"> + <RouterLink to="/login"> + <button class="btn">Back</button> + </RouterLink> + <button class="btn primary" @click.prevent="submit">Login</button> + </div> + </div> + + </div> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { isEmpty } from 'lodash'; +import { apiCall, debugLog, useUser, useMessage } from '@vnuge/vnlib.browser'; +import { ITokenResponse } from '@vnuge/vnlib.browser/dist/session'; +import { ref } from 'vue' +import { decodeJwt } from 'jose' +import { useRouter } from 'vue-router'; + +const otp = ref('') + +const pkiEndpoint = import.meta.env.VITE_PKI_ENDPOINT + +const { prepareLogin } = useUser() +const { setMessage } = useMessage() +const { push } = useRouter() + +const submit = () =>{ + + apiCall(async ({ axios }) =>{ + if(isEmpty(otp.value)){ + setMessage('Please enter your OTP') + return + } + + //try to decode the jwt to confirm its form is valid + const jwt = decodeJwt(otp.value) + debugLog(jwt) + + //Prepare a login message + const loginMessage = prepareLogin() + + //Set the 'login' field to the otp + loginMessage.login = otp.value + + const { data } = await axios.post<ITokenResponse>(pkiEndpoint, loginMessage) + + data.getResultOrThrow() + + //Finalize the login + await loginMessage.finalize(data); + + //Go back to login page + push({ name: 'Login' }) + }) +} + +</script>
\ No newline at end of file diff --git a/front-end/src/views/Login/social/[type].vue b/front-end/src/views/Login/social/[type].vue new file mode 100644 index 0000000..5a803bd --- /dev/null +++ b/front-end/src/views/Login/social/[type].vue @@ -0,0 +1,127 @@ +<template> + <div id="social-login-template" class="app-component-entry"> + <div class="container flex flex-col m-auto my-16"> + <div id="social-final-template" class="flex justify-center"> + <div class="entry-container"> + <h3>Finalizing login</h3> + <div class="mt-6 mb-4"> + <div v-if="message?.length > 0" class="text-lg text-red-500 dark:text-rose-500"> + <p>{{ message }}</p> + <div class="flex justify-center mt-5"> + <router-link to="/login"> + <button type="submit" class="btn primary" :disabled="waiting"> + <fa-icon icon="sign-in-alt" /> + Try again + </button> + </router-link> + </div> + </div> + <div v-else> + <div class="flex justify-center"> + <div class="m-auto"> + <fa-icon class="animate-spin" icon="spinner" size="2x"/> + </div> + </div> + <p>Please wait while we log you in.</p> + </div> + </div> + </div> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { isEqual } from 'lodash' +import { useRouteParams, useRouteQuery } from '@vueuse/router' +import { useSession, useWait, useUser, useTitle, configureApiCall } from '@vnuge/vnlib.browser' +import { useRouter } from 'vue-router'; +import { ref } from 'vue' + +useTitle('Social Login') + +const { loggedIn } = useSession() +const { prepareLogin } = useUser() +const { waiting } = useWait() + +const type = useRouteParams('type') +const result = useRouteQuery('result', ''); +const nonce = useRouteQuery('nonce', ''); +const router = useRouter() + +const message = ref('') + +//Override the message handler to capture the error message and display it +const { apiCall } = configureApiCall(m => message.value = m) + +//If logged-in redirect to login page +if (loggedIn.value) { + router.push({ name: 'Login' }) +} + + +const run = async () => { + if (isEqual(result.value, 'authorized')) { + + let loginUrl : string = '' + + switch (type.value) { + case 'github': + loginUrl = '/login/social/github'; + break; + case 'discord': + loginUrl = '/login/social/discord'; + break; + case 'auth0': + loginUrl = '/login/social/auth0'; + break; + default: + router.push('/login') + break; + } + + // If nonce is set, then we can proceed with finalization + await apiCall(async ({ axios }) => { + const preppedLogin = prepareLogin() + // Send the login request + const response = await axios.post(loginUrl, { nonce: nonce.value }) + if (response.data.success === true) { + // Finalize the login + await preppedLogin.finalize(response) + // If the login was successful, then we can redirect to the login page + router.push({ name: 'Login' }) + return + } + // Otherwise, we can show an error + throw { response } + }) + } else { + switch (result.value) { + case 'invalid': + message.value = 'The request was invalid, and you could not be logged in. Please try again.' + break + case 'expired': + message.value = 'The request has expired. Please try again.' + break + default: + message.value = 'There was an error processing the request. Please try again.' + break + } + } +} + +//Run without awaiting +run() + +</script> + +<style lang="scss"> + +#social-login-template{ + .entry-container{ + @apply w-full max-w-[28rem] p-6 text-center sm:border rounded-sm sm:shadow-sm; + @apply sm:bg-white bg-transparent sm:dark:bg-dark-700 dark:border-dark-400; + } +} + +</style> |