summaryrefslogtreecommitdiff
path: root/front-end/src/views/Login
diff options
context:
space:
mode:
authorLibravatar vnugent <public@vaughnnugent.com>2023-07-12 01:28:23 -0400
committerLibravatar vnugent <public@vaughnnugent.com>2023-07-12 01:28:23 -0400
commitf64955c69d91e578e580b409ba31ac4b3477da96 (patch)
tree16f01392ddf1abfea13d7d1ede3bfb0459fe8f0d /front-end/src/views/Login
Initial commit
Diffstat (limited to 'front-end/src/views/Login')
-rw-r--r--front-end/src/views/Login/components/Social.vue57
-rw-r--r--front-end/src/views/Login/components/Totp.vue65
-rw-r--r--front-end/src/views/Login/components/UserPass.vue92
-rw-r--r--front-end/src/views/Login/index.vue182
-rw-r--r--front-end/src/views/Login/pki/index.vue80
-rw-r--r--front-end/src/views/Login/social/[type].vue127
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>