- added api UserController.ts for 2FA
Some checks failed
CI Pipeline / japa-tests (push) Failing after 56s

- added PersonalTotpSettings.vue vor enablin/disabling 2FA
- changed User.ts: added attributes: state, twoFactorSecret and twoFactorRecoveryCodes
- added resources/js/utils/toast.ts for notifications
- modified start/routes/api.ts
- npm updates
This commit is contained in:
Kaimbacher 2024-01-19 15:33:46 +01:00
parent 18635f77b3
commit ebc62d9117
18 changed files with 1151 additions and 315 deletions

View file

@ -0,0 +1,246 @@
<template>
<!-- <div id="twofactor-totp-settings">
<template v-if="loading">
<span class="icon-loading-small totp-loading" />
<span> {{ t('twofactor_totp', 'Enable TOTP') }} </span>
</template>
<div v-else>
<input id="totp-enabled" v-model="enabled" type="checkbox" class="checkbox" :disabled="loading"
@change="toggleEnabled">
<label for="totp-enabled">{{
t('twofactor_totp', 'Enable TOTP')
}}</label>
</div>
<SetupConfirmation v-if="secret" :secret="secret" :qr-url="qrUrl" :loading="loadingConfirmation"
:confirmation.sync="confirmation" @confirm="enableTOTP" />
</div> -->
<CardBox :icon="mdiTwoFactorAuthentication" id="twofactor-totp-settings" title="Two-Factor Authentication" form>
<template v-if="loading">
<!-- <span class="icon-loading-small totp-loading" /> -->
<div class="relative inline-flex">
<div class="w-6 h-6 bg-blue-500 rounded-full"></div>
<div class="w-6 h-6 bg-blue-500 rounded-full absolute top-0 left-0 animate-ping"></div>
<div class="w-6 h-6 bg-blue-500 rounded-full absolute top-0 left-0 animate-pulse"></div>
<span class="ml-4 max-w-xl text-sm text-gray-600">Enabling TOTP...</span>
</div>
</template>
<div v-else>
<!-- <div class="text-lg font-medium text-gray-900">
You have not enabled two factor authentication.
</div>
<div class="text-sm text-gray-600">
When two factor authentication is enabled, you will be prompted for a secure,
random token during authentication. You may retrieve this token from your phone's
Google Authenticator application.
</div> -->
<input id="totp-enabled" v-model="enabled" type="checkbox" class="checkbox" :disabled="loading"
@change="toggleEnabled" />
<!-- <label for="totp-enabled"> Enable TOTP </label> -->
<label for="totp-enabled">{{ checkboxLabel }}</label>
</div>
<!-- <SetupConfirmation v-if="secret" :secret="secret" :qr-url="qrUrl" :loading="loadingConfirmation"
:confirmation.sync="confirmation" @confirm="enableTOTP" /> -->
<div v-if="qrSecret != ''">
<div class="mt-4 max-w-xl text-sm text-gray-600">
<!-- <p class="font-semibold">
Two factor authentication is now enabled.
</p> -->
<p>Your new TOTP secret is: {{ qrSecret }}</p>
<p>For quick setup, scan this QR code with your phone's authenticator application (TOTP):</p>
<div class="mt-4">
<img :src="qrSvg" />
</div>
</div>
<!-- <div class="mt-4 max-w-xl text-sm text-gray-600">
<p>
After you configured your app, enter a test code below to ensure everything works correctly:
</p>
</div> -->
<div class="mt-4 max-w-xl text-sm text-gray-600">
<p>After you configured your app, enter a test code below to ensure everything works correctly:</p>
<!-- :disabled="loading" -->
<input id="totp-confirmation" :disabled="loadingConfirmation" v-model="confirmationCode" type="tel"
minlength="6" maxlength="10" autocomplete="off" autocapitalize="off"
:placeholder="'Authentication code'" @keydown="onConfirmKeyDown" />
<!-- <input id="totp-confirmation-submit" type="button" :disabled="loading" :value="'Verify'"
@click="enableTOTP"> -->
<!-- <BaseButtons>
<BaseButton :icon="mdiContentSaveCheck" type="button" :disabled="loadingConfirmation" color="info"
label="Verify" @click="enableTOTP" />
</BaseButtons> -->
</div>
</div>
<template #footer>
<BaseButtons v-if="qrSecret != ''">
<BaseButton :icon="mdiContentSaveCheck" type="button" :disabled="loadingConfirmation" color="info"
label="Verify" @click="enableTOTP" />
</BaseButtons>
</template>
</CardBox>
</template>
<script setup lang="ts">
import CardBox from '@/Components/CardBox.vue';
import { computed, ref } from 'vue';
import { MainService, State } from '@/Stores/main';
import BaseButton from '@/Components/BaseButton.vue';
import BaseButtons from '@/Components/BaseButtons.vue';
import Notification from '@/utils/toast';
import { mdiContentSaveCheck, mdiTwoFactorAuthentication } from '@mdi/js';
const mainService = MainService();
const emit = defineEmits(['confirm', 'update:confirmation']);
const props = defineProps({
// user will be returned from controller action
// user: {
// type: Object,
// default: () => ({}),
// },
twoFactorEnabled: {
type: Boolean,
default: false,
},
// // code: {
// // type: Object,
// // },
// // recoveryCodes: {
// // type: Array<string>,
// // default: () => [],
// // },
// // errors: {
// // type: Object,
// // default: () => ({}),
// // },
});
let loading = ref(false);
let loadingConfirmation = ref(false);
let test;
if (props.twoFactorEnabled) {
test = State.STATE_ENABLED;
} else {
test = State.STATE_DISABLED;
}
mainService.setState(test);
const enabled = ref(mainService.totpState == State.STATE_ENABLED);
let qrSecret = ref('');
let qrUrl = ref('');
let qrSvg = ref('');
const confirmationCode = ref('');
const confirm = () => {
emit('update:confirmation', confirmationCode.value);
emit('confirm');
};
const onConfirmKeyDown = (e) => {
if (e.which === 13) {
confirm();
}
};
const state = computed(() => mainService.totpState);
const checkboxLabel = computed(() => {
if (enabled.value == true) {
return ' Disable TOTP';
} else {
return ' Enable TOTP';
}
});
const toggleEnabled = async () => {
if (loading.value == true) {
// Ignore event
// Logger.debug('still loading -> ignoring event')
return;
}
if (enabled.value) {
return await createTOTP();
} else {
return await disableTOTP();
}
};
const createTOTP = async () => {
// Show loading spinner
loading.value = true;
// Logger.debug('starting setup')
try {
const { url, secret, svg } = await mainService.create();
qrSecret.value = secret;
qrUrl.value = url;
qrSvg.value = svg;
// If the stat could be changed, keep showing the loading
// spinner until the user has finished the registration
// if state isCretaed, show loading:
loading.value = state.value === State.STATE_CREATED;
} catch (e) {
Notification.showWarning('Could not enable TOTP');
// Logger.error('Could not enable TOTP', e)
console.log('Could not create TOTP', e.message);
// Restore on error
loading.value = false;
enabled.value = false;
}
};
const disableTOTP = async () => {
loading.value = false;
// Logger.debug('starting disable');
await mainService.disable();
enabled.value = false;
loading.value = false;
Notification.showSuccess('TOTP disabled!');
};
const enableTOTP = async () => {
loading.value = true;
loadingConfirmation.value = true;
try {
await mainService.confirm(confirmationCode.value);
if (mainService.totpState === State.STATE_ENABLED) {
// Success
loading.value = false;
enabled.value = true;
qrUrl.value = '';
qrSecret.value = '';
Notification.showSuccess('two factor authentication enabled');
} else {
Notification.showWarning('Could not verify your key. Please try again');
console.log('Could not verify your key. Please try again');
}
confirmationCode.value = '';
loadingConfirmation.value = false;
} catch (e) {
console.log('Could not enable TOTP', e.message);
Notification.showWarning('Could not enable TOTP ' + e.message);
confirmationCode.value = '';
loadingConfirmation.value = false;
}
};
</script>
<style scoped>
.totp-loading {
display: inline-block;
vertical-align: sub;
margin-left: -2px;
margin-right: 4px;
}
</style>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
// import { Head, Link, useForm } from '@inertiajs/inertia-vue3';
import { useForm, router } from '@inertiajs/vue3';
import { useForm } from '@inertiajs/vue3';
// import { ref } from 'vue';
// import { reactive } from 'vue';
import {
mdiAccount,
@ -10,8 +11,7 @@ import {
mdiAsterisk,
mdiFormTextboxPassword,
mdiArrowLeftBoldOutline,
mdiAlertBoxOutline,
mdiInformation
mdiAlertBoxOutline,
} from '@mdi/js';
import SectionMain from '@/Components/SectionMain.vue';
import CardBox from '@/Components/CardBox.vue';
@ -28,10 +28,13 @@ import { stardust } from '@eidellev/adonis-stardust/client';
import { computed, Ref } from 'vue';
import { usePage } from '@inertiajs/vue3';
import FormValidationErrors from '@/Components/FormValidationErrors.vue';
// import { Inertia } from '@inertiajs/inertia';
import PersonalTotpSettings from '@/Components/PersonalTotpSettings.vue';
// import { MainService } from '@/Stores/main';
// const mainService = MainService();
const emit = defineEmits(['confirm', 'update:confirmation'])
const props = defineProps({
defineProps({
// user will be returned from controller action
user: {
type: Object,
@ -58,12 +61,12 @@ const props = defineProps({
// login: props.user.login,
// email: props.user.email,
// });
const enableTwoFactorAuthentication = async () => {
await router.post(stardust.route('account.password.enable2fa'));
};
const disableTwoFactorAuthentication = async () => {
await router.post(stardust.route('account.password.disable2fa'));
};
// const enableTwoFactorAuthentication = async () => {
// await router.post(stardust.route('account.password.enable2fa'));
// };
// const disableTwoFactorAuthentication = async () => {
// await router.post(stardust.route('account.password.disable2fa'));
// };
const passwordForm = useForm({
@ -84,6 +87,28 @@ const passwordSubmit = async () => {
const flash: Ref<any> = computed(() => {
return usePage().props.flash;
});
// const confirmationCode = ref('');
// const confirm = () => {
// emit('update:confirmation', confirmationCode);
// emit('confirm');
// };
// const onConfirmKeyDown = (e) => {
// if (e.which === 13) {
// confirm()
// }
// };
// const generateSecretCode = (user) => {
// const secret = generateSecret({
// name: 'TethysCloud',
// account: user.email,
// });
// return secret.secret;
// }
</script>
<template>
@ -101,8 +126,8 @@ const flash: Ref<any> = computed(() => {
{{ $page.props.flash.message }}
</NotificationBar> -->
<!-- <div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> -->
<div class="grid grid-cols-1 lg:grid-cols-1 gap-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- <div class="grid grid-cols-1 lg:grid-cols-1 gap-6"> -->
<!-- password form -->
<!-- <CardBox title="Edit Profile" :icon="mdiAccountCircle" form @submit.prevent="profileForm.post(route('admin.account.info.store'))"> -->
@ -186,24 +211,9 @@ const flash: Ref<any> = computed(() => {
<!-- <CardBox title="Edit Profile" :icon="mdiAccountCircle" form @submit.prevent="profileForm.post(route('admin.account.info.store'))"> -->
<CardBox v-if="!props.twoFactorEnabled" title="Two-Factor Authentication" :icon="mdiInformation" form
@submit.prevent="enableTwoFactorAuthentication()">
<!-- <FormField label="Login" help="Required. Your login name" :class="{ 'text-red-400': errors.login }">
<FormControl v-model="factorForm.login" v-bind:icon="mdiAccount" name="login" required :error="errors.login">
<div class="text-red-400 text-sm" v-if="errors.login">
{{ errors.login }}
</div>
</FormControl>
</FormField>
<FormField label="Email" help="Required. Your e-mail" :class="{ 'text-red-400': errors.email }">
<FormControl v-model="factorForm.email" :icon="mdiMail" type="email" name="email" required :error="errors.email">
<div class="text-red-400 text-sm" v-if="errors.email">
{{ errors.email }}
</div>
</FormControl>
</FormField> -->
<PersonalTotpSettings :twoFactorEnabled="twoFactorEnabled"/>
<!-- <CardBox v-if="!props.twoFactorEnabled" title="Two-Factor Authentication" :icon="mdiInformation" form
@submit.prevent="enableTwoFactorAuthentication()">
<div class="text-lg font-medium text-gray-900">
You have not enabled two factor authentication.
</div>
@ -218,70 +228,9 @@ const flash: Ref<any> = computed(() => {
<BaseButton color="info" type="submit" label="Enable" />
</BaseButtons>
</template>
</CardBox>
</CardBox> -->
<CardBox v-else-if="props.twoFactorEnabled" title="Two-Factor Authentication" :icon="mdiInformation" form @submit.prevent="disableTwoFactorAuthentication()">
<!-- <div class="w-1/2 space-y-4 bg-gray-100 p-8"> -->
<h3 class="text-lg font-medium text-gray-900">
You have enabled two factor authentication.
</h3>
<div class="mt-3 max-w-xl text-sm text-gray-600">
<p>
When two factor authentication is enabled, you will be prompted for a secure, random
token during authentication. You may retrieve this token from your phone's Google
Authenticator application.
</p>
</div>
<div v-if="code">
<div class="mt-4 max-w-xl text-sm text-gray-600">
<p class="font-semibold">
Two factor authentication is now enabled. Scan the following QR code using your
phone's authenticator application.
</p>
</div>
<div class="mt-4">
<img :src="code?.svg" />
</div>
</div>
<!-- @if(recoveryCodes) -->
<div v-if="recoveryCodes" class="mt-4 max-w-xl text-sm text-gray-600">
<p class="font-semibold">
Store these recovery codes in a secure password manager. They can be used to recover
access to your account if your two factor authentication device is lost.
</p>
</div>
<!-- <div class="mt-4 grid max-w-xl gap-1 rounded-lg bg-gray-100 px-4 py-4 font-mono text-sm">
@each(code in recoveryCodes)
<div>
{{ code }}
</div>
@endeach
</div> -->
<!-- @endif -->
<div class="flex justify-between">
<!-- <form action="{{ route('UserController.fetchRecoveryCodes') }}" method="GET">
<button type="submit" class="px-auto items-center rounded border border-gray-300 bg-white px-2.5 py-1.5 text-xs
font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none
">
Show Recovery Codes
</button>
</form>
<form action="{{ route('UserController.disableTwoFactorAuthentication') }}" method="POST">
<button type="submit" class="px-auto items-center rounded border border-gray-300 bg-white px-2.5 py-1.5 text-xs
font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none
">
Disable
</button>
</form> -->
<BaseButton color="info" type="submit" label="Disable" />
</div>
<!-- </div> -->
</CardBox>
</div>
</SectionMain>

View file

@ -22,6 +22,26 @@ interface TransactionItem {
business: string;
}
export enum State {
STATE_DISABLED = 0,
STATE_CREATED = 1,
STATE_ENABLED = 2,
}
export const saveState = async (data) => {
const url = '/api/twofactor_totp/settings/enable';
const resp = await axios.post(url, data);
return resp.data;
};
// Anfrage staet : 1
// ANtwort json:
// state: 1
// secret:"OX7IQ4OI3GXGFPHY"
// qUrl:"https://odysseus.geologie.ac.at/apps/twofactor_totp/settings/enable"
export const MainService = defineStore('main', {
state: () => ({
/* User */
@ -40,11 +60,13 @@ export const MainService = defineStore('main', {
authors: [] as Array<Person>,
// persons: [] as Array<Person>,
datasets: [],
files:[],
files: [],
dataset: {} as Dataset,
menu: menu,
totpState: 0,
}),
actions: {
// payload = authenticated user
@ -113,6 +135,36 @@ export const MainService = defineStore('main', {
});
},
setState(state) {
this.totpState = state;
},
async create(): Promise<{ url: any; secret: any; svg: any }> {
const { state, secret, url, svg } = await saveState({ state: State.STATE_CREATED });
this.totpState = state;
return { url, secret, svg };
// .then(({ state, secret, qrUrl }) => {
// this.totpState = state;
// return { qrUrl, secret };
// })
// .catch((error) => {
// alert(error.message);
// });
},
async disable() {
const { state } = await saveState({ state: State.STATE_DISABLED });
this.totpState = state;
},
async confirm(code: string) {
const { state } = await saveState({
state: State.STATE_ENABLED,
code,
});
this.totpState = state;
},
// fetchfiles(id) {
// // sampleDataKey= authors or datasets
// axios

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16">
<path d="M14 12.3L12.3 14 8 9.7 3.7 14 2 12.3 6.3 8 2 3.7 3.7 2 8 6.3 12.3 2 14 3.7 9.7 8z"/>
</svg>

After

Width:  |  Height:  |  Size: 170 B

View file

@ -0,0 +1,103 @@
/* remember to import this scss file into your app */
.toastify.dialogs {
min-width: 200px;
background: none;
background-color: var(--color-main-background);
color: var(--color-main-text);
box-shadow: 0 0 6px 0 var(--color-box-shadow);
padding: 0 12px;
margin-top: 45px;
position: fixed;
z-index: 10100;
border-radius: var(--border-radius);
display: flex;
align-items: center;
.toast-undo-container {
display: flex;
align-items: center;
}
.toast-undo-button,
.toast-close {
position: static;
overflow: hidden;
box-sizing: border-box;
min-width: 44px;
height: 100%;
padding: 12px;
white-space: nowrap;
background-repeat: no-repeat;
background-position: center;
background-color: transparent;
min-height: 0;
/* icon styling */
&.toast-close {
text-indent: 0;
opacity: 0.4;
border: none;
min-height: 44px;
margin-left: 10px;
font-size: 0;
/* dark theme overrides for Nextcloud 25 and later */
&::before {
background-image: url('./Close.svg');
content: ' ';
filter: var(--background-invert-if-dark);
display: inline-block;
width: 16px;
height: 16px;
}
}
&.toast-undo-button {
/* $margin: 3px; */
/* margin: $margin; */
/* height: calc(100% - 2 * #{$margin}); */
margin-left: 12px;
}
&:hover,
&:focus,
&:active {
cursor: pointer;
opacity: 1;
}
}
&.toastify-top {
right: 10px;
}
/* Toast with onClick callback */
&.toast-with-click {
cursor: pointer;
}
/* Various toasts types */
&.toast-error {
border-left: 3px solid var(--color-error);
}
&.toast-info {
border-left: 3px solid var(--color-primary);
}
&.toast-warning {
border-left: 3px solid var(--color-warning);
}
&.toast-success {
border-left: 3px solid var(--color-success);
}
&.toast-undo {
border-left: 3px solid var(--color-success);
}
}

216
resources/js/utils/toast.ts Normal file
View file

@ -0,0 +1,216 @@
import Toastify from 'toastify-js';
// import { t } from './utils/l10n.js';
import './toast.css';
/**
* Enum of available Toast types
*/
export enum ToastType {
ERROR = 'toast-error',
WARNING = 'toast-warning',
INFO = 'toast-info',
SUCCESS = 'toast-success',
PERMANENT = 'toast-error',
UNDO = 'toast-undo',
}
/** @deprecated Use ToastAriaLive.OFF */
export const TOAST_ARIA_LIVE_OFF = 'off';
/** @deprecated Use ToastAriaLive.POLITE */
export const TOAST_ARIA_LIVE_POLITE = 'polite';
/** @deprecated Use ToastAriaLive.ASSERTIVE */
export const TOAST_ARIA_LIVE_ASSERTIVE = 'assertive';
export enum ToastAriaLive {
OFF = TOAST_ARIA_LIVE_OFF,
POLITE = TOAST_ARIA_LIVE_POLITE,
ASSERTIVE = TOAST_ARIA_LIVE_ASSERTIVE,
}
/** Timeout in ms of a undo toast */
export const TOAST_UNDO_TIMEOUT = 10000;
/** Default timeout in ms of toasts */
export const TOAST_DEFAULT_TIMEOUT = 4000;
/** Timeout value to show a toast permanently */
export const TOAST_PERMANENT_TIMEOUT = -1;
/**
* Type of a toast
* @see https://apvarun.github.io/toastify-js/
* @notExported
*/
type Toast = ReturnType<typeof Toastify>;
export interface ToastOptions {
/**
* Defines the timeout in milliseconds after which the toast is closed. Set to -1 to have a persistent toast.
*/
timeout?: number;
/**
* Set to true to allow HTML content inside of the toast text
* @default false
*/
isHTML?: boolean;
/**
* Set a type of {ToastType} to style the modal
*/
type?: ToastType;
/**
* Provide a function that is called after the toast is removed
*/
onRemove?: () => void;
/**
* Provide a function that is called when the toast is clicked
*/
onClick?: () => void;
/**
* Make the toast closable
*/
close?: boolean;
/**
* Specify the element to attach the toast element to (for testing)
*/
selector?: string;
/**
* Whether the messages should be announced to screen readers.
* See the following docs for an explanation when to use which:
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions
*
* By default, errors are announced assertive and other messages "polite".
*/
ariaLive?: ToastAriaLive;
}
/**
* Show a toast message
*
* @param data Message to be shown in the toast, any HTML is removed by default
* @param options
*/
export function showMessage(data: string | Node, options?: ToastOptions): Toast {
options = Object.assign(
{
timeout: TOAST_DEFAULT_TIMEOUT,
isHTML: false,
type: undefined,
// An undefined selector defaults to the body element
selector: undefined,
onRemove: () => {},
onClick: undefined,
close: true,
},
options,
);
if (typeof data === 'string' && !options.isHTML) {
// fime mae sure that text is extracted
const element = document.createElement('div');
element.innerHTML = data;
data = element.innerText;
}
let classes = options.type ?? '';
if (typeof options.onClick === 'function') {
classes += ' toast-with-click ';
}
const isNode = data instanceof Node;
let ariaLive: ToastAriaLive = ToastAriaLive.POLITE;
if (options.ariaLive) {
ariaLive = options.ariaLive;
} else if (options.type === ToastType.ERROR || options.type === ToastType.UNDO) {
ariaLive = ToastAriaLive.ASSERTIVE;
}
const toast = Toastify({
[!isNode ? 'text' : 'node']: data,
duration: options.timeout,
callback: options.onRemove,
onClick: options.onClick,
close: options.close,
gravity: 'top',
selector: options.selector,
position: 'right',
backgroundColor: '',
className: 'dialogs ' + classes,
escapeMarkup: !options.isHTML,
ariaLive,
});
toast.showToast();
return toast;
}
export default {
updatableNotification: null,
getDefaultNotificationFunction: null,
/**
* Shows a notification that disappears after x seconds, default is
* 7 seconds
*
* @param {string} text Message to show
* @param {Array} [options] options array
* @param {number} [options.timeout=7] timeout in seconds, if this is 0 it will show the message permanently
* @param {boolean} [options.isHTML=false] an indicator for HTML notifications (true) or text (false)
* @param {string} [options.type] notification type
* @return {JQuery} the toast element
*/
showTemporary(text, options = { timeout: 3000 }) {
options = options || {};
options.timeout = options.timeout || TOAST_DEFAULT_TIMEOUT;
const toast = showMessage(text, options);
toast.toastElement.toastify = toast;
// return $(toast.toastElement)
},
/**
* Show a toast message with error styling
*
* @param text Message to be shown in the toast, any HTML is removed by default
* @param options
*/
showError(text: string, options?: ToastOptions): Toast {
return showMessage(text, { ...options, type: ToastType.ERROR });
},
/**
* Show a toast message with warning styling
*
* @param text Message to be shown in the toast, any HTML is removed by default
* @param options
*/
showWarning(text: string, options?: ToastOptions): Toast {
return showMessage(text, { ...options, type: ToastType.WARNING });
},
/**
* Show a toast message with info styling
*
* @param text Message to be shown in the toast, any HTML is removed by default
* @param options
*/
showInfo(text: string, options?: ToastOptions): Toast {
return showMessage(text, { ...options, type: ToastType.INFO });
},
/**
* Show a toast message with success styling
*
* @param text Message to be shown in the toast, any HTML is removed by default
* @param options
*/
showSuccess(text: string, options?: ToastOptions): Toast {
return showMessage(text, { ...options, type: ToastType.SUCCESS });
},
};