Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(controller): added 2fa #426

Merged
merged 2 commits into from
Nov 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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