Skip to content

Commit

Permalink
feat(controller): added 2fa auth (#426)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tbaile authored Nov 7, 2024
1 parent 4c04c67 commit 0037e05
Show file tree
Hide file tree
Showing 13 changed files with 290 additions and 179 deletions.
201 changes: 122 additions & 79 deletions src/components/controller/ControllerAppLogin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,36 @@

<script setup lang="ts">
import {
NeLink,
NeHeading,
NeInlineNotification,
NeButton,
NeTextInput,
getAxiosErrorMessage,
focusElement,
deleteFromStorage,
getStringFromStorage,
saveToStorage
NeButton,
NeHeading,
NeInlineNotification,
NeLink,
NeTextInput
} from '@nethesis/vue-components'
import { useLoginStore } from '@/stores/controller/controllerLogin'
import { onMounted, ref } from 'vue'
import { onMounted, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { getProductName, getCompanyName, getPrivacyPolicyUrl } from '@/lib/config'
import { validateRequired } from '@/lib/validation'
import { getCompanyName, getPrivacyPolicyUrl, getProductName } from '@/lib/config'
import { MessageBag, validateRequired, validateSixDigitCode } from '@/lib/validation'
import { getControllerRoutePrefix } from '@/lib/router'
import router from '@/router'
import { ValidationError } from '@/lib/standalone/ubus'

let username = ref('')
let usernameRef = ref()
let password = ref('')
let passwordRef = ref()
let rememberMe = ref(false)
const username = ref('')
const password = ref('')
const twoFaOtp = ref('')
const formRefs = {
username: useTemplateRef<HTMLInputElement>('username-ref'),
password: useTemplateRef<HTMLInputElement>('password-ref'),
twoFaOtp: useTemplateRef<HTMLInputElement>('two-fa-otp-ref')
}
const rememberMe = ref(false)
const validationErrors = ref(new MessageBag())
const error = ref<Error>()

let error = ref({
username: '',
password: '',
login: ''
})
const loading = ref(false)

const loginStore = useLoginStore()
const { t } = useI18n()
Expand All @@ -44,80 +46,89 @@ onMounted(() => {
if (usernameFromStorage) {
rememberMe.value = true
username.value = usernameFromStorage
focusElement(passwordRef)
formRefs.password.value?.focus()
} else {
focusElement(usernameRef)
formRefs.username.value?.focus()
}
})

async function login() {
error.value.username = ''
error.value.password = ''
error.value.login = ''

const isValidationOk = validate()

if (!isValidationOk) {
loading.value = true
error.value = undefined
if (isFormInvalid()) {
loading.value = false
return
}

try {
await loginStore.login(username.value, password.value)

// set or remove username to/from local storage
if (rememberMe.value) {
saveToStorage('controllerUsername', username.value)
} else {
deleteFromStorage('controllerUsername')
await loginStore.login(username.value, password.value, rememberMe.value)
if (!loginStore.twoFaActive) {
await router.push(`${getControllerRoutePrefix()}/`)
}
} catch (err: any) {
console.error('login error', err)

if (err?.response?.status == 401) {
error.value.login = 'login.incorrect_username_or_password'
focusElement(passwordRef)
error.value = new Error('login.incorrect_username_or_password')
formRefs.password.value?.focus()
} else {
error.value.login = getAxiosErrorMessage(err)
error.value = new Error(getAxiosErrorMessage(err))
}
} finally {
loading.value = false
}
}

function validate() {
error.value.username = ''
error.value.password = ''
error.value.login = ''
let isValidationOk = true

// username

{
// check required
let { valid, errMessage } = validateRequired(username.value)
if (!valid) {
error.value.username = errMessage as string

if (isValidationOk) {
isValidationOk = false
focusElement(usernameRef)
}
async function verifyTwoFa() {
loading.value = true
error.value = undefined
if (isFormInvalid()) {
loading.value = false
return
}
try {
await loginStore.verifyTwoFaToken(username.value, twoFaOtp.value)
await router.push(`${getControllerRoutePrefix()}/`)
} catch (err: any) {
if (err instanceof ValidationError) {
validationErrors.value = err.errorBag
formRefs.twoFaOtp.value?.focus()
} else {
error.value = new Error(getAxiosErrorMessage(err))
}
} finally {
loading.value = false
}
}

// password

{
// check required
let { valid, errMessage } = validateRequired(password.value)
if (!valid) {
error.value.password = errMessage as string

if (isValidationOk) {
isValidationOk = false
focusElement(passwordRef)
function isFormInvalid() {
validationErrors.value.clear()
if (loginStore.twoFaActive) {
{
// otp
let { valid, errMessage } = validateSixDigitCode(twoFaOtp.value)
if (!valid) {
validationErrors.value.set('otp', errMessage as string)
formRefs.twoFaOtp.value?.focus()
}
}
} else {
{
// username
let { valid, errMessage } = validateRequired(username.value)
if (!valid) {
validationErrors.value.set('username', errMessage as string)
formRefs.username.value?.focus()
}
}
{
// password
let { valid, errMessage } = validateRequired(password.value)
if (!valid) {
validationErrors.value.set('password', errMessage as string)
formRefs.password.value?.focus()
}
}
}
return isValidationOk
return validationErrors.value.size > 0
}
</script>

Expand Down Expand Up @@ -148,29 +159,59 @@ function validate() {
{{ t('login.privacy_policy') }}
</NeLink>
</div>
<form class="space-y-6" @submit.prevent>
<form v-if="loginStore.twoFaActive" class="space-y-6">
<NeInlineNotification
v-if="error"
:description="t(error.message)"
:title="t('login.cannot_login')"
kind="error"
/>
<NeTextInput
ref="two-fa-otp-ref"
v-model.trim="twoFaOtp"
:disabled="loading"
:invalidMessage="t(validationErrors.getFirstI18nKeyFor('otp'))"
:label="t('standalone.two_fa.otp')"
:loading="loading"
/>
<NeButton
:disabled="loading"
:loading="loading"
class="w-full"
kind="primary"
size="lg"
type="submit"
@click.prevent="verifyTwoFa"
>{{ t('standalone.two_fa.verify_code') }}
</NeButton>
</form>
<form v-else class="space-y-6" @submit.prevent>
<NeInlineNotification
v-if="error.login"
v-if="error"
kind="error"
:title="t('login.cannot_login')"
:description="t(error.login)"
:description="t(error.message)"
/>
<NeTextInput
:label="t('login.username')"
v-model.trim="username"
:invalidMessage="t(error.username)"
ref="username-ref"
:disabled="loading"
:invalidMessage="t(validationErrors.getFirstI18nKeyFor('username'))"
autocomplete="username"
ref="usernameRef"
:loading="loading"
/>
<NeTextInput
:label="t('login.password')"
v-model="password"
isPassword
ref="password-ref"
:disabled="loading"
:showPasswordLabel="t('ne_text_input.show_password')"
:hidePasswordLabel="t('ne_text_input.hide_password')"
:invalidMessage="t(error.password)"
:invalidMessage="t(validationErrors.getFirstI18nKeyFor('password'))"
autocomplete="current-password"
ref="passwordRef"
:loading="loading"
/>
<div class="flex items-center justify-between">
<div class="flex items-center">
Expand Down Expand Up @@ -200,6 +241,8 @@ function validate() {
kind="primary"
size="lg"
@click.prevent="login"
:disabled="loading"
:loading="loading"
type="submit"
class="w-full"
>{{ t('login.sign_in') }}</NeButton
Expand Down
17 changes: 17 additions & 0 deletions src/components/controller/users/UsersTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { NeButton } from '@nethesis/vue-components'
import { type ControllerAccount } from '@/stores/controller/accounts'
import { useLoginStore } from '@/stores/controller/controllerLogin'
import { ref } from 'vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faCheck, faCircleCheck, faCircleXmark } from '@fortawesome/free-solid-svg-icons'

const props = defineProps<{
users: ControllerAccount[]
Expand Down Expand Up @@ -63,6 +65,9 @@ function getDropdownItems(item: ControllerAccount) {
<NeTableHeadCell>
{{ t('controller.users.display_name') }}
</NeTableHeadCell>
<NeTableHeadCell>
{{ t('controller.users.two_fa_status') }}
</NeTableHeadCell>
<NeTableHeadCell>
<!-- no header for actions -->
</NeTableHeadCell>
Expand All @@ -75,6 +80,18 @@ function getDropdownItems(item: ControllerAccount) {
<NeTableCell :data-label="t('controller.users.display_name')">
{{ item.display_name }}
</NeTableCell>
<NeTableCell :data-label="t('controller.users.two_fa_status')">
<span class="flex items-center gap-2">
<template v-if="item.two_fa">
<FontAwesomeIcon :icon="faCircleCheck" class="text-green-700 dark:text-green-500" />
<span>{{ t('controller.users.two_fa_enabled') }}</span>
</template>
<template v-else>
<FontAwesomeIcon :icon="faCircleXmark" />
<span>{{ t('controller.users.two_fa_disabled') }}</span>
</template>
</span>
</NeTableCell>
<NeTableCell :data-label="t('common.actions')">
<div class="align-center -ml-2.5 flex gap-2 md:ml-0 md:justify-end">
<NeButton kind="tertiary" @click="emit('edit', item)">
Expand Down
2 changes: 1 addition & 1 deletion src/components/standalone/StandaloneAppLogin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { MessageBag, validateRequired, validateSixDigitCode } from '@/lib/valida
import { useI18n } from 'vue-i18n'
import { getProductName, getCompanyName, getPrivacyPolicyUrl } from '@/lib/config'
import { jwtDecode } from 'jwt-decode'
import { verifyTwoFaOtp } from '@/lib/standalone/twoFa'
import { verifyTwoFaOtp } from '@/lib/twoFa'
import { ValidationError } from '@/lib/standalone/ubus'

let username = ref('')
Expand Down
6 changes: 3 additions & 3 deletions src/components/standalone/account/two_fa/TwoFactorAuth.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { computed, onMounted, ref } from 'vue'
import { getTwoFaStatus } from '@/lib/standalone/twoFa'
import { getTwoFaStatus } from '@/lib/twoFa'
import {
NeInlineNotification,
NeSkeleton,
Expand All @@ -18,8 +18,8 @@ import {
getAxiosErrorMessage,
NeTextArea
} from '@nethesis/vue-components'
import ConfigureTwoFaDrawer from './ConfigureTwoFaDrawer.vue'
import RevokeTwoFaModal from './RevokeTwoFaModal.vue'
import ConfigureTwoFaDrawer from '@/components/two_fa/ConfigureTwoFaDrawer.vue'
import RevokeTwoFaModal from '@/components/two_fa/RevokeTwoFaModal.vue'
import { useLoginStore } from '@/stores/standalone/standaloneLogin'

const { t } = useI18n()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ import {
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { MessageBag, validateSixDigitCode } from '@/lib/validation'
import { getTwoFaQrCode, verifyTwoFaOtp } from '@/lib/standalone/twoFa'
import { getTwoFaQrCode, verifyTwoFaOtp } from '@/lib/twoFa'
import QRCodeVue3 from 'qrcode-vue3'
import { ValidationError } from '@/lib/standalone/ubus'
import { useLoginStore } from '@/stores/standalone/standaloneLogin'
import { useLoginStore as useStandaloneLoginStore } from '@/stores/standalone/standaloneLogin'
import { useLoginStore as useControllerLoginStore } from '@/stores/controller/controllerLogin'
import { useNotificationsStore } from '@/stores/notifications'
import { isStandaloneMode } from '@/lib/config'

const props = defineProps({
isShown: { type: Boolean, default: false }
Expand Down Expand Up @@ -116,7 +118,7 @@ async function verifyOtp() {
return
}
loading.value.verifyOtp = true
const loginStore = useLoginStore()
const loginStore = isStandaloneMode() ? useStandaloneLoginStore() : useControllerLoginStore()

try {
await verifyTwoFaOtp(loginStore.username, loginStore.token, otp.value)
Expand Down
Loading

0 comments on commit 0037e05

Please sign in to comment.