@adminforth/two-factors-auth 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build.log +3 -2
- package/custom/TwoFaModal.vue +144 -0
- package/custom/TwoFactorsConfirmation.vue +39 -52
- package/custom/TwoFactorsSetup.vue +27 -10
- package/dist/custom/TwoFaModal.vue +144 -0
- package/dist/custom/TwoFactorsConfirmation.vue +39 -52
- package/dist/custom/TwoFactorsSetup.vue +27 -10
- package/dist/index.js +29 -5
- package/index.ts +29 -6
- package/package.json +1 -1
package/build.log
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
sending incremental file list
|
|
6
6
|
custom/
|
|
7
|
+
custom/TwoFaModal.vue
|
|
7
8
|
custom/TwoFactorsConfirmation.vue
|
|
8
9
|
custom/TwoFactorsSetup.vue
|
|
9
10
|
custom/package-lock.json
|
|
10
11
|
custom/package.json
|
|
11
12
|
custom/tsconfig.json
|
|
12
13
|
|
|
13
|
-
sent
|
|
14
|
-
total size is
|
|
14
|
+
sent 30,935 bytes received 134 bytes 62,138.00 bytes/sec
|
|
15
|
+
total size is 30,449 speedup is 0.98
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 top-0 bottom-0 left-0 right-0"
|
|
3
|
+
v-if ="modelShow">
|
|
4
|
+
<div class="relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
|
|
5
|
+
<div id="mfaCode-label" class="mb-4 text-gray-700 dark:text-gray-100">
|
|
6
|
+
{{ $t('Please enter your authenticator code') }}
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="my-4 w-full flex justify-center" ref="otpRoot">
|
|
10
|
+
<v-otp-input
|
|
11
|
+
ref="code"
|
|
12
|
+
container-class="grid grid-cols-6 gap-3 w-full"
|
|
13
|
+
input-classes="bg-gray-50 text-center flex justify-center otp-input border leading-none border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-10 h-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
|
14
|
+
:num-inputs="6"
|
|
15
|
+
inputType="number"
|
|
16
|
+
inputmode="numeric"
|
|
17
|
+
:should-auto-focus="true"
|
|
18
|
+
:should-focus-order="true"
|
|
19
|
+
v-model:value="bindValue"
|
|
20
|
+
@on-complete="handleOnComplete"
|
|
21
|
+
/>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="mt-6 flex justify-center gap-3">
|
|
25
|
+
<button
|
|
26
|
+
class="px-4 py-2 rounded border bg-gray-100 dark:bg-gray-600"
|
|
27
|
+
@click="onCancel"
|
|
28
|
+
:disabled="inProgress"
|
|
29
|
+
>{{ $t('Cancel') }}</button>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script setup lang="ts">
|
|
36
|
+
import VOtpInput from 'vue3-otp-input';
|
|
37
|
+
import { ref, nextTick, onMounted, onBeforeUnmount } from 'vue';
|
|
38
|
+
import { useUserStore } from '@/stores/user';
|
|
39
|
+
import { useI18n } from 'vue-i18n';
|
|
40
|
+
declare global {
|
|
41
|
+
interface Window {
|
|
42
|
+
adminforthTwoFaModal: {
|
|
43
|
+
getCode: () => Promise<any>;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const props = defineProps<{
|
|
48
|
+
autoFinishLogin?: boolean
|
|
49
|
+
}>();
|
|
50
|
+
const emit = defineEmits<{
|
|
51
|
+
(e: 'resolved', payload: any): void
|
|
52
|
+
(e: 'rejected', err?: any): void
|
|
53
|
+
(e: 'closed'): void
|
|
54
|
+
}>();
|
|
55
|
+
|
|
56
|
+
const modelShow = ref(false);
|
|
57
|
+
let resolveFn: ((code: string) => void) | null = null;
|
|
58
|
+
let verifyingCallback: ((code: string) => boolean) | null = null;
|
|
59
|
+
let verifyFn: null | ((code: string) => Promise<boolean> | boolean) = null;
|
|
60
|
+
|
|
61
|
+
window.adminforthTwoFaModal = {
|
|
62
|
+
getCode: (verifyingCallback?: () => Promise<boolean>) => new Promise((resolve) => {
|
|
63
|
+
if (modelShow.value) {
|
|
64
|
+
throw new Error('Modal is already open');
|
|
65
|
+
}
|
|
66
|
+
modelShow.value = true;
|
|
67
|
+
resolveFn = resolve;
|
|
68
|
+
verifyFn = verifyingCallback ?? null;
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const { t } = useI18n();
|
|
73
|
+
const user = useUserStore();
|
|
74
|
+
|
|
75
|
+
const code = ref<any>(null);
|
|
76
|
+
const otpRoot = ref<HTMLElement | null>(null);
|
|
77
|
+
const bindValue = ref('');
|
|
78
|
+
|
|
79
|
+
function tagOtpInputs() {
|
|
80
|
+
const root = otpRoot.value;
|
|
81
|
+
if (!root) return;
|
|
82
|
+
root.querySelectorAll('input.otp-input').forEach((el, idx) => {
|
|
83
|
+
el.setAttribute('name', 'mfaCode');
|
|
84
|
+
el.setAttribute('id', `mfaCode-${idx + 1}`);
|
|
85
|
+
el.setAttribute('autocomplete', 'one-time-code');
|
|
86
|
+
el.setAttribute('inputmode', 'numeric');
|
|
87
|
+
el.setAttribute('aria-labelledby', 'mfaCode-label');
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function handlePaste(event: ClipboardEvent) {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
const pastedText = event.clipboardData?.getData('text') || '';
|
|
94
|
+
if (pastedText.length === 6) {
|
|
95
|
+
code.value?.fillInput(pastedText);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function submit() {
|
|
100
|
+
if (bindValue.value.length !== 6) return;
|
|
101
|
+
await sendCode(bindValue.value);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function handleOnComplete(value: string) {
|
|
105
|
+
await sendCode(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function sendCode(value: string) {
|
|
109
|
+
if (!resolveFn) {
|
|
110
|
+
throw new Error('Modal is not initialized properly');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (verifyFn) {
|
|
114
|
+
try {
|
|
115
|
+
const ok = await verifyFn(value);
|
|
116
|
+
if (!ok) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
modelShow.value = false;
|
|
125
|
+
resolveFn(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
function onCancel() {
|
|
130
|
+
modelShow.value = false;
|
|
131
|
+
emit('rejected', new Error('cancelled'));
|
|
132
|
+
emit('closed');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
onMounted(async () => {
|
|
136
|
+
await nextTick();
|
|
137
|
+
tagOtpInputs();
|
|
138
|
+
window.addEventListener('paste', handlePaste as any);
|
|
139
|
+
});
|
|
140
|
+
onBeforeUnmount(() => {
|
|
141
|
+
window.removeEventListener('paste', handlePaste as any);
|
|
142
|
+
});
|
|
143
|
+
</script>
|
|
144
|
+
|
|
@@ -12,24 +12,30 @@
|
|
|
12
12
|
<div class="relative p-4 w-full max-w-md max-h-full">
|
|
13
13
|
<!-- Modal content -->
|
|
14
14
|
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700 dark:shadow-black text-gray-500" >
|
|
15
|
-
<div class="p-8 w-full max-w-md max-h-full" >
|
|
16
|
-
<div class="m-
|
|
17
|
-
<div class="my-4 flex justify-center
|
|
15
|
+
<div class="p-8 w-full max-w-md max-h-full custom-auth-wrapper" >
|
|
16
|
+
<div id="mfaCode-label" class="m-4">{{$t('Please enter your authenticator code')}} </div>
|
|
17
|
+
<div class="my-4 w-full flex justify-center" ref="otpRoot">
|
|
18
18
|
<v-otp-input
|
|
19
19
|
ref="code"
|
|
20
|
+
container-class="grid grid-cols-6 gap-3 w-full"
|
|
20
21
|
input-classes="bg-gray-50 text-center flex justify-center otp-input border leading-none border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-10 h-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
|
21
|
-
:
|
|
22
|
+
:num-inputs="6"
|
|
22
23
|
inputType="number"
|
|
23
24
|
inputmode="numeric"
|
|
24
|
-
:num-inputs="6"
|
|
25
|
-
v-model:value="bindValue"
|
|
26
25
|
:should-auto-focus="true"
|
|
27
26
|
:should-focus-order="true"
|
|
27
|
+
v-model:value="bindValue"
|
|
28
28
|
@on-complete="handleOnComplete"
|
|
29
29
|
/>
|
|
30
30
|
</div>
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
<div class="mt-6 flex justify-center">
|
|
32
|
+
<LinkButton
|
|
33
|
+
to="/login"
|
|
34
|
+
class="w-[290px] mx-4"
|
|
35
|
+
>
|
|
36
|
+
{{$t('Back to login')}}
|
|
37
|
+
</LinkButton>
|
|
38
|
+
</div>
|
|
33
39
|
</div>
|
|
34
40
|
</div>
|
|
35
41
|
</div>
|
|
@@ -39,68 +45,50 @@
|
|
|
39
45
|
|
|
40
46
|
|
|
41
47
|
</template>
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
|
|
49
|
+
|
|
44
50
|
<script setup>
|
|
45
|
-
|
|
46
|
-
import { onMounted, onBeforeUnmount, ref, watchEffect,computed,watch } from 'vue';
|
|
51
|
+
|
|
52
|
+
import { onMounted, nextTick, onBeforeUnmount, ref, watchEffect,computed,watch } from 'vue';
|
|
47
53
|
import { useCoreStore } from '@/stores/core';
|
|
48
54
|
import { useUserStore } from '@/stores/user';
|
|
49
|
-
import { IconEyeSolid, IconEyeSlashSolid } from '@iconify-prerendered/vue-flowbite';
|
|
50
55
|
import { callAdminForthApi, loadFile } from '@/utils';
|
|
51
|
-
import { useRouter } from 'vue-router';
|
|
52
56
|
import { showErrorTost } from '@/composables/useFrontendApi';
|
|
53
57
|
import { LinkButton } from '@/afcl';
|
|
54
|
-
import Vue2FACodeInput from '@loltech/vue3-2fa-code-input';
|
|
55
58
|
import VOtpInput from "vue3-otp-input";
|
|
56
59
|
import { useI18n } from 'vue-i18n';
|
|
57
60
|
|
|
58
61
|
const { t } = useI18n();
|
|
59
62
|
const code = ref(null);
|
|
63
|
+
const otpRoot = ref(null);
|
|
64
|
+
const bindValue = ref('');
|
|
60
65
|
|
|
61
66
|
const handleOnComplete = (value) => {
|
|
62
67
|
sendCode(value);
|
|
63
68
|
};
|
|
64
69
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
function tagOtpInputs() {
|
|
71
|
+
const root = otpRoot.value;
|
|
72
|
+
if (!root) return;
|
|
73
|
+
const inputs = root.querySelectorAll('input.otp-input');
|
|
74
|
+
inputs.forEach((el, idx) => {
|
|
75
|
+
el.setAttribute('name', 'mfaCode');
|
|
76
|
+
el.setAttribute('id', `mfaCode-${idx + 1}`);
|
|
77
|
+
el.setAttribute('autocomplete', 'one-time-code');
|
|
78
|
+
el.setAttribute('inputmode', 'numeric');
|
|
79
|
+
el.setAttribute('aria-labelledby', 'mfaCode-label');
|
|
80
|
+
});
|
|
81
|
+
}
|
|
68
82
|
|
|
69
|
-
const router = useRouter();
|
|
70
83
|
const inProgress = ref(false);
|
|
71
|
-
|
|
84
|
+
|
|
72
85
|
const coreStore = useCoreStore();
|
|
73
86
|
const user = useUserStore();
|
|
74
87
|
|
|
75
|
-
|
|
76
|
-
// use this simple function to automatically focus on the next input
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const showPw = ref(false);
|
|
81
|
-
|
|
82
|
-
const error = ref(null);
|
|
83
|
-
const totp = ref({});
|
|
84
|
-
const totpJWT = ref(null);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
function parseJwt(token) {
|
|
88
|
-
// Split the token into its parts
|
|
89
|
-
const base64Url = token.split('.')[1];
|
|
90
|
-
|
|
91
|
-
// Base64-decode the payload
|
|
92
|
-
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
|
93
|
-
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
|
|
94
|
-
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
|
95
|
-
}).join(''));
|
|
96
|
-
|
|
97
|
-
// Parse the JSON payload
|
|
98
|
-
return JSON.parse(jsonPayload);
|
|
99
|
-
}
|
|
100
|
-
|
|
88
|
+
|
|
101
89
|
onMounted(async () => {
|
|
102
|
-
|
|
103
|
-
|
|
90
|
+
await nextTick();
|
|
91
|
+
tagOtpInputs();
|
|
104
92
|
});
|
|
105
93
|
|
|
106
94
|
onBeforeUnmount(() => {
|
|
@@ -161,9 +149,9 @@
|
|
|
161
149
|
}
|
|
162
150
|
|
|
163
151
|
/**
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
152
|
+
* This particular piece of code makes the last input have a gap in the middle.
|
|
153
|
+
*/
|
|
154
|
+
.spaced-code-input {
|
|
167
155
|
& .vue3-2fa-code-input-box {
|
|
168
156
|
&:nth-child(3) {
|
|
169
157
|
@apply mr-4;
|
|
@@ -175,4 +163,3 @@
|
|
|
175
163
|
}
|
|
176
164
|
}
|
|
177
165
|
</style>
|
|
178
|
-
|
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
<div class="relative p-4 w-full max-w-md max-h-full">
|
|
13
13
|
<!-- Modal content -->
|
|
14
14
|
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700 dark:shadow-black text-gray-500" >
|
|
15
|
-
<div class="p-10 w-full max-w-md max-h-full" >
|
|
15
|
+
<div class="p-10 w-full max-w-md max-h-full custom-auth-wrapper" >
|
|
16
16
|
<div class="m-3" >{{$t('Scan this QR code with your authenticator app or open by')}} <a class="text-blue-600" :href="totpUri">{{$t('click')}}</a></div>
|
|
17
17
|
<div class="flex justify-center m-3" >
|
|
18
18
|
<img :src="totpQrCode" class="min-w-[200px], min-h-[200px]" alt="QR code" />
|
|
19
19
|
</div>
|
|
20
20
|
<div class="m-3 ">
|
|
21
21
|
<div class="m-1">{{$t('Or copy this code to app manually:')}}</div>
|
|
22
|
-
<div class="w-full
|
|
22
|
+
<div class="w-full">
|
|
23
23
|
<div class="relative">
|
|
24
24
|
<label for="npm-install-copy-text" class="sr-only">{{$t('Label')}}</label>
|
|
25
25
|
<input id="npm-install-copy-text" type="text" class="col-span-10 bg-gray-50 border border-gray-300 text-gray-500 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full px-2.5 py-4 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 pr-12" :value="totp.newSecret" readonly>
|
|
@@ -33,21 +33,22 @@
|
|
|
33
33
|
</div>
|
|
34
34
|
</div>
|
|
35
35
|
</div>
|
|
36
|
-
<div class="my-4 flex justify-center
|
|
36
|
+
<div class="my-4 w-full flex justify-center p-2" ref="otpRoot">
|
|
37
37
|
<v-otp-input
|
|
38
38
|
ref="code"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
inputType="number"
|
|
42
|
-
inputmode="numeric"
|
|
39
|
+
container-class="grid grid-cols-6 gap-3 w-full"
|
|
40
|
+
input-classes="otp-input bg-gray-50 text-center border leading-none border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block h-[43.33px] w-full dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
|
43
41
|
:num-inputs="6"
|
|
42
|
+
inputType="number"
|
|
43
|
+
inputmode="numeric"
|
|
44
44
|
:should-auto-focus="true"
|
|
45
45
|
:should-focus-order="true"
|
|
46
|
+
v-model:value="bindValue"
|
|
46
47
|
@on-complete="handleOnComplete"
|
|
47
48
|
/>
|
|
48
49
|
</div>
|
|
49
50
|
<!-- <Vue2FACodeInput v-model="code" autofocus /> -->
|
|
50
|
-
<div class="flex flex-row gap-2.5
|
|
51
|
+
<div class="flex flex-row gap-2.5 px-3 h-12">
|
|
51
52
|
<LinkButton to="/login" class="w-full">
|
|
52
53
|
{{$t('Back to login')}}
|
|
53
54
|
</LinkButton>
|
|
@@ -67,7 +68,7 @@
|
|
|
67
68
|
|
|
68
69
|
<script setup lang="ts">
|
|
69
70
|
|
|
70
|
-
import { onMounted, onBeforeUnmount,
|
|
71
|
+
import { onMounted, onBeforeUnmount, nextTick, ref, watchEffect,computed,watch } from 'vue';
|
|
71
72
|
import { useCoreStore } from '@/stores/core';
|
|
72
73
|
import { useUserStore } from '@/stores/user';
|
|
73
74
|
import { IconEyeSolid, IconEyeSlashSolid } from '@iconify-prerendered/vue-flowbite';
|
|
@@ -89,6 +90,7 @@ const handleOnComplete = (value) => {
|
|
|
89
90
|
|
|
90
91
|
const router = useRouter();
|
|
91
92
|
const inProgress = ref(false);
|
|
93
|
+
const otpRoot = ref(null);
|
|
92
94
|
|
|
93
95
|
const coreStore = useCoreStore();
|
|
94
96
|
const user = useUserStore();
|
|
@@ -151,12 +153,27 @@ onMounted(async () => {
|
|
|
151
153
|
}
|
|
152
154
|
|
|
153
155
|
window.addEventListener('paste', handlePaste);
|
|
156
|
+
await nextTick();
|
|
157
|
+
tagOtpInputs();
|
|
154
158
|
});
|
|
155
159
|
|
|
156
160
|
onBeforeUnmount(() => {
|
|
157
161
|
window.removeEventListener('paste', handlePaste);
|
|
158
162
|
});
|
|
159
163
|
|
|
164
|
+
function tagOtpInputs() {
|
|
165
|
+
const root = otpRoot.value;
|
|
166
|
+
if (!root) return;
|
|
167
|
+
const inputs = root.querySelectorAll('input.otp-input');
|
|
168
|
+
inputs.forEach((el, idx) => {
|
|
169
|
+
el.setAttribute('name', 'mfaCode');
|
|
170
|
+
el.setAttribute('id', `mfaCode-${idx + 1}`);
|
|
171
|
+
el.setAttribute('autocomplete', 'one-time-code');
|
|
172
|
+
el.setAttribute('inputmode', 'numeric');
|
|
173
|
+
el.setAttribute('aria-labelledby', 'mfaCode-label');
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
160
177
|
async function sendCode (value) {
|
|
161
178
|
inProgress.value = true;
|
|
162
179
|
|
|
@@ -206,5 +223,5 @@ const handleSkip = async () => {
|
|
|
206
223
|
<style>
|
|
207
224
|
.otp-input {
|
|
208
225
|
margin: 0 5px;
|
|
209
|
-
}
|
|
226
|
+
}
|
|
210
227
|
</style>
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 top-0 bottom-0 left-0 right-0"
|
|
3
|
+
v-if ="modelShow">
|
|
4
|
+
<div class="relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
|
|
5
|
+
<div id="mfaCode-label" class="mb-4 text-gray-700 dark:text-gray-100">
|
|
6
|
+
{{ $t('Please enter your authenticator code') }}
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="my-4 w-full flex justify-center" ref="otpRoot">
|
|
10
|
+
<v-otp-input
|
|
11
|
+
ref="code"
|
|
12
|
+
container-class="grid grid-cols-6 gap-3 w-full"
|
|
13
|
+
input-classes="bg-gray-50 text-center flex justify-center otp-input border leading-none border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-10 h-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
|
14
|
+
:num-inputs="6"
|
|
15
|
+
inputType="number"
|
|
16
|
+
inputmode="numeric"
|
|
17
|
+
:should-auto-focus="true"
|
|
18
|
+
:should-focus-order="true"
|
|
19
|
+
v-model:value="bindValue"
|
|
20
|
+
@on-complete="handleOnComplete"
|
|
21
|
+
/>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="mt-6 flex justify-center gap-3">
|
|
25
|
+
<button
|
|
26
|
+
class="px-4 py-2 rounded border bg-gray-100 dark:bg-gray-600"
|
|
27
|
+
@click="onCancel"
|
|
28
|
+
:disabled="inProgress"
|
|
29
|
+
>{{ $t('Cancel') }}</button>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script setup lang="ts">
|
|
36
|
+
import VOtpInput from 'vue3-otp-input';
|
|
37
|
+
import { ref, nextTick, onMounted, onBeforeUnmount } from 'vue';
|
|
38
|
+
import { useUserStore } from '@/stores/user';
|
|
39
|
+
import { useI18n } from 'vue-i18n';
|
|
40
|
+
declare global {
|
|
41
|
+
interface Window {
|
|
42
|
+
adminforthTwoFaModal: {
|
|
43
|
+
getCode: () => Promise<any>;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const props = defineProps<{
|
|
48
|
+
autoFinishLogin?: boolean
|
|
49
|
+
}>();
|
|
50
|
+
const emit = defineEmits<{
|
|
51
|
+
(e: 'resolved', payload: any): void
|
|
52
|
+
(e: 'rejected', err?: any): void
|
|
53
|
+
(e: 'closed'): void
|
|
54
|
+
}>();
|
|
55
|
+
|
|
56
|
+
const modelShow = ref(false);
|
|
57
|
+
let resolveFn: ((code: string) => void) | null = null;
|
|
58
|
+
let verifyingCallback: ((code: string) => boolean) | null = null;
|
|
59
|
+
let verifyFn: null | ((code: string) => Promise<boolean> | boolean) = null;
|
|
60
|
+
|
|
61
|
+
window.adminforthTwoFaModal = {
|
|
62
|
+
getCode: (verifyingCallback?: () => Promise<boolean>) => new Promise((resolve) => {
|
|
63
|
+
if (modelShow.value) {
|
|
64
|
+
throw new Error('Modal is already open');
|
|
65
|
+
}
|
|
66
|
+
modelShow.value = true;
|
|
67
|
+
resolveFn = resolve;
|
|
68
|
+
verifyFn = verifyingCallback ?? null;
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const { t } = useI18n();
|
|
73
|
+
const user = useUserStore();
|
|
74
|
+
|
|
75
|
+
const code = ref<any>(null);
|
|
76
|
+
const otpRoot = ref<HTMLElement | null>(null);
|
|
77
|
+
const bindValue = ref('');
|
|
78
|
+
|
|
79
|
+
function tagOtpInputs() {
|
|
80
|
+
const root = otpRoot.value;
|
|
81
|
+
if (!root) return;
|
|
82
|
+
root.querySelectorAll('input.otp-input').forEach((el, idx) => {
|
|
83
|
+
el.setAttribute('name', 'mfaCode');
|
|
84
|
+
el.setAttribute('id', `mfaCode-${idx + 1}`);
|
|
85
|
+
el.setAttribute('autocomplete', 'one-time-code');
|
|
86
|
+
el.setAttribute('inputmode', 'numeric');
|
|
87
|
+
el.setAttribute('aria-labelledby', 'mfaCode-label');
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function handlePaste(event: ClipboardEvent) {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
const pastedText = event.clipboardData?.getData('text') || '';
|
|
94
|
+
if (pastedText.length === 6) {
|
|
95
|
+
code.value?.fillInput(pastedText);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function submit() {
|
|
100
|
+
if (bindValue.value.length !== 6) return;
|
|
101
|
+
await sendCode(bindValue.value);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function handleOnComplete(value: string) {
|
|
105
|
+
await sendCode(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function sendCode(value: string) {
|
|
109
|
+
if (!resolveFn) {
|
|
110
|
+
throw new Error('Modal is not initialized properly');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (verifyFn) {
|
|
114
|
+
try {
|
|
115
|
+
const ok = await verifyFn(value);
|
|
116
|
+
if (!ok) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
modelShow.value = false;
|
|
125
|
+
resolveFn(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
function onCancel() {
|
|
130
|
+
modelShow.value = false;
|
|
131
|
+
emit('rejected', new Error('cancelled'));
|
|
132
|
+
emit('closed');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
onMounted(async () => {
|
|
136
|
+
await nextTick();
|
|
137
|
+
tagOtpInputs();
|
|
138
|
+
window.addEventListener('paste', handlePaste as any);
|
|
139
|
+
});
|
|
140
|
+
onBeforeUnmount(() => {
|
|
141
|
+
window.removeEventListener('paste', handlePaste as any);
|
|
142
|
+
});
|
|
143
|
+
</script>
|
|
144
|
+
|
|
@@ -12,24 +12,30 @@
|
|
|
12
12
|
<div class="relative p-4 w-full max-w-md max-h-full">
|
|
13
13
|
<!-- Modal content -->
|
|
14
14
|
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700 dark:shadow-black text-gray-500" >
|
|
15
|
-
<div class="p-8 w-full max-w-md max-h-full" >
|
|
16
|
-
<div class="m-
|
|
17
|
-
<div class="my-4 flex justify-center
|
|
15
|
+
<div class="p-8 w-full max-w-md max-h-full custom-auth-wrapper" >
|
|
16
|
+
<div id="mfaCode-label" class="m-4">{{$t('Please enter your authenticator code')}} </div>
|
|
17
|
+
<div class="my-4 w-full flex justify-center" ref="otpRoot">
|
|
18
18
|
<v-otp-input
|
|
19
19
|
ref="code"
|
|
20
|
+
container-class="grid grid-cols-6 gap-3 w-full"
|
|
20
21
|
input-classes="bg-gray-50 text-center flex justify-center otp-input border leading-none border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-10 h-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
|
21
|
-
:
|
|
22
|
+
:num-inputs="6"
|
|
22
23
|
inputType="number"
|
|
23
24
|
inputmode="numeric"
|
|
24
|
-
:num-inputs="6"
|
|
25
|
-
v-model:value="bindValue"
|
|
26
25
|
:should-auto-focus="true"
|
|
27
26
|
:should-focus-order="true"
|
|
27
|
+
v-model:value="bindValue"
|
|
28
28
|
@on-complete="handleOnComplete"
|
|
29
29
|
/>
|
|
30
30
|
</div>
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
<div class="mt-6 flex justify-center">
|
|
32
|
+
<LinkButton
|
|
33
|
+
to="/login"
|
|
34
|
+
class="w-[290px] mx-4"
|
|
35
|
+
>
|
|
36
|
+
{{$t('Back to login')}}
|
|
37
|
+
</LinkButton>
|
|
38
|
+
</div>
|
|
33
39
|
</div>
|
|
34
40
|
</div>
|
|
35
41
|
</div>
|
|
@@ -39,68 +45,50 @@
|
|
|
39
45
|
|
|
40
46
|
|
|
41
47
|
</template>
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
|
|
49
|
+
|
|
44
50
|
<script setup>
|
|
45
|
-
|
|
46
|
-
import { onMounted, onBeforeUnmount, ref, watchEffect,computed,watch } from 'vue';
|
|
51
|
+
|
|
52
|
+
import { onMounted, nextTick, onBeforeUnmount, ref, watchEffect,computed,watch } from 'vue';
|
|
47
53
|
import { useCoreStore } from '@/stores/core';
|
|
48
54
|
import { useUserStore } from '@/stores/user';
|
|
49
|
-
import { IconEyeSolid, IconEyeSlashSolid } from '@iconify-prerendered/vue-flowbite';
|
|
50
55
|
import { callAdminForthApi, loadFile } from '@/utils';
|
|
51
|
-
import { useRouter } from 'vue-router';
|
|
52
56
|
import { showErrorTost } from '@/composables/useFrontendApi';
|
|
53
57
|
import { LinkButton } from '@/afcl';
|
|
54
|
-
import Vue2FACodeInput from '@loltech/vue3-2fa-code-input';
|
|
55
58
|
import VOtpInput from "vue3-otp-input";
|
|
56
59
|
import { useI18n } from 'vue-i18n';
|
|
57
60
|
|
|
58
61
|
const { t } = useI18n();
|
|
59
62
|
const code = ref(null);
|
|
63
|
+
const otpRoot = ref(null);
|
|
64
|
+
const bindValue = ref('');
|
|
60
65
|
|
|
61
66
|
const handleOnComplete = (value) => {
|
|
62
67
|
sendCode(value);
|
|
63
68
|
};
|
|
64
69
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
function tagOtpInputs() {
|
|
71
|
+
const root = otpRoot.value;
|
|
72
|
+
if (!root) return;
|
|
73
|
+
const inputs = root.querySelectorAll('input.otp-input');
|
|
74
|
+
inputs.forEach((el, idx) => {
|
|
75
|
+
el.setAttribute('name', 'mfaCode');
|
|
76
|
+
el.setAttribute('id', `mfaCode-${idx + 1}`);
|
|
77
|
+
el.setAttribute('autocomplete', 'one-time-code');
|
|
78
|
+
el.setAttribute('inputmode', 'numeric');
|
|
79
|
+
el.setAttribute('aria-labelledby', 'mfaCode-label');
|
|
80
|
+
});
|
|
81
|
+
}
|
|
68
82
|
|
|
69
|
-
const router = useRouter();
|
|
70
83
|
const inProgress = ref(false);
|
|
71
|
-
|
|
84
|
+
|
|
72
85
|
const coreStore = useCoreStore();
|
|
73
86
|
const user = useUserStore();
|
|
74
87
|
|
|
75
|
-
|
|
76
|
-
// use this simple function to automatically focus on the next input
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const showPw = ref(false);
|
|
81
|
-
|
|
82
|
-
const error = ref(null);
|
|
83
|
-
const totp = ref({});
|
|
84
|
-
const totpJWT = ref(null);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
function parseJwt(token) {
|
|
88
|
-
// Split the token into its parts
|
|
89
|
-
const base64Url = token.split('.')[1];
|
|
90
|
-
|
|
91
|
-
// Base64-decode the payload
|
|
92
|
-
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
|
93
|
-
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
|
|
94
|
-
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
|
95
|
-
}).join(''));
|
|
96
|
-
|
|
97
|
-
// Parse the JSON payload
|
|
98
|
-
return JSON.parse(jsonPayload);
|
|
99
|
-
}
|
|
100
|
-
|
|
88
|
+
|
|
101
89
|
onMounted(async () => {
|
|
102
|
-
|
|
103
|
-
|
|
90
|
+
await nextTick();
|
|
91
|
+
tagOtpInputs();
|
|
104
92
|
});
|
|
105
93
|
|
|
106
94
|
onBeforeUnmount(() => {
|
|
@@ -161,9 +149,9 @@
|
|
|
161
149
|
}
|
|
162
150
|
|
|
163
151
|
/**
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
152
|
+
* This particular piece of code makes the last input have a gap in the middle.
|
|
153
|
+
*/
|
|
154
|
+
.spaced-code-input {
|
|
167
155
|
& .vue3-2fa-code-input-box {
|
|
168
156
|
&:nth-child(3) {
|
|
169
157
|
@apply mr-4;
|
|
@@ -175,4 +163,3 @@
|
|
|
175
163
|
}
|
|
176
164
|
}
|
|
177
165
|
</style>
|
|
178
|
-
|
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
<div class="relative p-4 w-full max-w-md max-h-full">
|
|
13
13
|
<!-- Modal content -->
|
|
14
14
|
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700 dark:shadow-black text-gray-500" >
|
|
15
|
-
<div class="p-10 w-full max-w-md max-h-full" >
|
|
15
|
+
<div class="p-10 w-full max-w-md max-h-full custom-auth-wrapper" >
|
|
16
16
|
<div class="m-3" >{{$t('Scan this QR code with your authenticator app or open by')}} <a class="text-blue-600" :href="totpUri">{{$t('click')}}</a></div>
|
|
17
17
|
<div class="flex justify-center m-3" >
|
|
18
18
|
<img :src="totpQrCode" class="min-w-[200px], min-h-[200px]" alt="QR code" />
|
|
19
19
|
</div>
|
|
20
20
|
<div class="m-3 ">
|
|
21
21
|
<div class="m-1">{{$t('Or copy this code to app manually:')}}</div>
|
|
22
|
-
<div class="w-full
|
|
22
|
+
<div class="w-full">
|
|
23
23
|
<div class="relative">
|
|
24
24
|
<label for="npm-install-copy-text" class="sr-only">{{$t('Label')}}</label>
|
|
25
25
|
<input id="npm-install-copy-text" type="text" class="col-span-10 bg-gray-50 border border-gray-300 text-gray-500 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full px-2.5 py-4 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 pr-12" :value="totp.newSecret" readonly>
|
|
@@ -33,21 +33,22 @@
|
|
|
33
33
|
</div>
|
|
34
34
|
</div>
|
|
35
35
|
</div>
|
|
36
|
-
<div class="my-4 flex justify-center
|
|
36
|
+
<div class="my-4 w-full flex justify-center p-2" ref="otpRoot">
|
|
37
37
|
<v-otp-input
|
|
38
38
|
ref="code"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
inputType="number"
|
|
42
|
-
inputmode="numeric"
|
|
39
|
+
container-class="grid grid-cols-6 gap-3 w-full"
|
|
40
|
+
input-classes="otp-input bg-gray-50 text-center border leading-none border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block h-[43.33px] w-full dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
|
43
41
|
:num-inputs="6"
|
|
42
|
+
inputType="number"
|
|
43
|
+
inputmode="numeric"
|
|
44
44
|
:should-auto-focus="true"
|
|
45
45
|
:should-focus-order="true"
|
|
46
|
+
v-model:value="bindValue"
|
|
46
47
|
@on-complete="handleOnComplete"
|
|
47
48
|
/>
|
|
48
49
|
</div>
|
|
49
50
|
<!-- <Vue2FACodeInput v-model="code" autofocus /> -->
|
|
50
|
-
<div class="flex flex-row gap-2.5
|
|
51
|
+
<div class="flex flex-row gap-2.5 px-3 h-12">
|
|
51
52
|
<LinkButton to="/login" class="w-full">
|
|
52
53
|
{{$t('Back to login')}}
|
|
53
54
|
</LinkButton>
|
|
@@ -67,7 +68,7 @@
|
|
|
67
68
|
|
|
68
69
|
<script setup lang="ts">
|
|
69
70
|
|
|
70
|
-
import { onMounted, onBeforeUnmount,
|
|
71
|
+
import { onMounted, onBeforeUnmount, nextTick, ref, watchEffect,computed,watch } from 'vue';
|
|
71
72
|
import { useCoreStore } from '@/stores/core';
|
|
72
73
|
import { useUserStore } from '@/stores/user';
|
|
73
74
|
import { IconEyeSolid, IconEyeSlashSolid } from '@iconify-prerendered/vue-flowbite';
|
|
@@ -89,6 +90,7 @@ const handleOnComplete = (value) => {
|
|
|
89
90
|
|
|
90
91
|
const router = useRouter();
|
|
91
92
|
const inProgress = ref(false);
|
|
93
|
+
const otpRoot = ref(null);
|
|
92
94
|
|
|
93
95
|
const coreStore = useCoreStore();
|
|
94
96
|
const user = useUserStore();
|
|
@@ -151,12 +153,27 @@ onMounted(async () => {
|
|
|
151
153
|
}
|
|
152
154
|
|
|
153
155
|
window.addEventListener('paste', handlePaste);
|
|
156
|
+
await nextTick();
|
|
157
|
+
tagOtpInputs();
|
|
154
158
|
});
|
|
155
159
|
|
|
156
160
|
onBeforeUnmount(() => {
|
|
157
161
|
window.removeEventListener('paste', handlePaste);
|
|
158
162
|
});
|
|
159
163
|
|
|
164
|
+
function tagOtpInputs() {
|
|
165
|
+
const root = otpRoot.value;
|
|
166
|
+
if (!root) return;
|
|
167
|
+
const inputs = root.querySelectorAll('input.otp-input');
|
|
168
|
+
inputs.forEach((el, idx) => {
|
|
169
|
+
el.setAttribute('name', 'mfaCode');
|
|
170
|
+
el.setAttribute('id', `mfaCode-${idx + 1}`);
|
|
171
|
+
el.setAttribute('autocomplete', 'one-time-code');
|
|
172
|
+
el.setAttribute('inputmode', 'numeric');
|
|
173
|
+
el.setAttribute('aria-labelledby', 'mfaCode-label');
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
160
177
|
async function sendCode (value) {
|
|
161
178
|
inProgress.value = true;
|
|
162
179
|
|
|
@@ -206,5 +223,5 @@ const handleSkip = async () => {
|
|
|
206
223
|
<style>
|
|
207
224
|
.otp-input {
|
|
208
225
|
margin: 0 5px;
|
|
209
|
-
}
|
|
226
|
+
}
|
|
210
227
|
</style>
|
package/dist/index.js
CHANGED
|
@@ -30,6 +30,8 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
30
30
|
path: '/setup2fa',
|
|
31
31
|
component: { file: this.componentPath('TwoFactorsSetup.vue'), meta: { title: 'Setup 2FA', customLayout: true } }
|
|
32
32
|
});
|
|
33
|
+
const everyPageBottomInjections = this.adminforth.config.customization.globalInjections.everyPageBottom || [];
|
|
34
|
+
everyPageBottomInjections.push({ file: this.componentPath('TwoFaModal.vue'), meta: {} });
|
|
33
35
|
this.activate(resourceConfig, adminforth);
|
|
34
36
|
}
|
|
35
37
|
activate(resourceConfig, adminforth) {
|
|
@@ -49,7 +51,8 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
49
51
|
}
|
|
50
52
|
const beforeLoginConfirmation = this.adminforth.config.auth.beforeLoginConfirmation;
|
|
51
53
|
const beforeLoginConfirmationArray = Array.isArray(beforeLoginConfirmation) ? beforeLoginConfirmation : [beforeLoginConfirmation];
|
|
52
|
-
beforeLoginConfirmationArray.push((_a) => __awaiter(this, [_a], void 0, function* ({ adminUser, response }) {
|
|
54
|
+
beforeLoginConfirmationArray.push((_a) => __awaiter(this, [_a], void 0, function* ({ adminUser, response, extra }) {
|
|
55
|
+
var _b;
|
|
53
56
|
const secret = adminUser.dbUser[this.options.twoFaSecretFieldName];
|
|
54
57
|
const userName = adminUser.dbUser[adminforth.config.auth.usernameField];
|
|
55
58
|
const brandName = adminforth.config.customization.brandName;
|
|
@@ -57,6 +60,8 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
57
60
|
const authResource = adminforth.config.resources.find((res) => res.resourceId === adminforth.config.auth.usersResourceId);
|
|
58
61
|
const authPk = authResource.columns.find((col) => col.primaryKey).name;
|
|
59
62
|
const userPk = adminUser.dbUser[authPk];
|
|
63
|
+
const rememberMe = ((_b = extra === null || extra === void 0 ? void 0 : extra.body) === null || _b === void 0 ? void 0 : _b.rememberMe) || false;
|
|
64
|
+
const rememberMeDays = rememberMe ? adminforth.config.auth.rememberMeDays || 30 : 1;
|
|
60
65
|
let newSecret = null;
|
|
61
66
|
const userNeeds2FA = this.options.usersFilterToApply ? this.options.usersFilterToApply(adminUser) : true;
|
|
62
67
|
if (!userNeeds2FA) {
|
|
@@ -68,7 +73,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
68
73
|
newSecret = tempSecret.secret;
|
|
69
74
|
}
|
|
70
75
|
else {
|
|
71
|
-
const value = this.adminforth.auth.issueJWT({ userName, issuer: brandName, pk: userPk, userCanSkipSetup }, 'tempTotp', '2h');
|
|
76
|
+
const value = this.adminforth.auth.issueJWT({ userName, issuer: brandName, pk: userPk, userCanSkipSetup, rememberMeDays }, 'tempTotp', '2h');
|
|
72
77
|
response.setHeader('Set-Cookie', `adminforth_${brandNameSlug}_totpTemporaryJWT=${value}; Path=${this.adminforth.config.baseUrl || '/'}; HttpOnly; SameSite=Strict; max-age=3600; `);
|
|
73
78
|
return {
|
|
74
79
|
body: {
|
|
@@ -78,7 +83,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
78
83
|
ok: true
|
|
79
84
|
};
|
|
80
85
|
}
|
|
81
|
-
const totpTemporaryJWT = this.adminforth.auth.issueJWT({ userName, newSecret, issuer: brandName, pk: userPk, userCanSkipSetup }, 'tempTotp', '2h');
|
|
86
|
+
const totpTemporaryJWT = this.adminforth.auth.issueJWT({ userName, newSecret, issuer: brandName, pk: userPk, userCanSkipSetup, rememberMeDays }, 'tempTotp', '2h');
|
|
82
87
|
response.setHeader('Set-Cookie', `adminforth_${brandNameSlug}_totpTemporaryJWT=${totpTemporaryJWT}; Path=${this.adminforth.config.baseUrl || '/'}; HttpOnly; SameSite=Strict; Expires=${new Date(Date.now() + '1h').toUTCString()} `);
|
|
83
88
|
return {
|
|
84
89
|
body: {
|
|
@@ -125,7 +130,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
125
130
|
yield connector.updateRecord({ resource: this.authResource, recordId: decoded.pk, newValues: { [this.options.twoFaSecretFieldName]: decoded.newSecret } });
|
|
126
131
|
}
|
|
127
132
|
this.adminforth.auth.removeCustomCookie({ response, name: 'totpTemporaryJWT' });
|
|
128
|
-
this.adminforth.auth.setAuthCookie({ response, username: decoded.userName, pk: decoded.pk });
|
|
133
|
+
this.adminforth.auth.setAuthCookie({ expireInDays: decoded.rememberMeDays, response, username: decoded.userName, pk: decoded.pk });
|
|
129
134
|
return { status: 'ok', allowedLogin: true };
|
|
130
135
|
}
|
|
131
136
|
else {
|
|
@@ -140,7 +145,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
140
145
|
const verified = twofactor.verifyToken(user[this.options.twoFaSecretFieldName], body.code, this.options.timeStepWindow);
|
|
141
146
|
if (verified) {
|
|
142
147
|
this.adminforth.auth.removeCustomCookie({ response, name: 'totpTemporaryJWT' });
|
|
143
|
-
this.adminforth.auth.setAuthCookie({ response, username: decoded.userName, pk: decoded.pk });
|
|
148
|
+
this.adminforth.auth.setAuthCookie({ expireInDays: decoded.rememberMeDays, response, username: decoded.userName, pk: decoded.pk });
|
|
144
149
|
return { status: 'ok', allowedLogin: true };
|
|
145
150
|
}
|
|
146
151
|
else {
|
|
@@ -172,5 +177,24 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
172
177
|
}
|
|
173
178
|
}),
|
|
174
179
|
});
|
|
180
|
+
server.endpoint({
|
|
181
|
+
method: 'POST',
|
|
182
|
+
path: `/plugin/twofa/verify`,
|
|
183
|
+
noAuth: false,
|
|
184
|
+
handler: (_a) => __awaiter(this, [_a], void 0, function* ({ adminUser, body }) {
|
|
185
|
+
if (!(body === null || body === void 0 ? void 0 : body.code))
|
|
186
|
+
return { error: 'Code is required' };
|
|
187
|
+
const authRes = this.adminforth.config.resources
|
|
188
|
+
.find(r => r.resourceId === this.adminforth.config.auth.usersResourceId);
|
|
189
|
+
const connector = this.adminforth.connectors[authRes.dataSource];
|
|
190
|
+
const pkName = authRes.columns.find(c => c.primaryKey).name;
|
|
191
|
+
const user = yield connector.getRecordByPrimaryKey(authRes, adminUser.dbUser[pkName]);
|
|
192
|
+
const secret = user[this.options.twoFaSecretFieldName];
|
|
193
|
+
if (!secret)
|
|
194
|
+
return { error: '2FA is not set up for this user' };
|
|
195
|
+
const verified = twofactor.verifyToken(secret, body.code, this.options.timeStepWindow);
|
|
196
|
+
return verified ? { ok: true } : { error: 'Wrong or expired OTP code' };
|
|
197
|
+
})
|
|
198
|
+
});
|
|
175
199
|
}
|
|
176
200
|
}
|
package/index.ts
CHANGED
|
@@ -33,6 +33,8 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
33
33
|
path:'/setup2fa',
|
|
34
34
|
component: { file: this.componentPath('TwoFactorsSetup.vue'), meta: { title: 'Setup 2FA', customLayout: true }}
|
|
35
35
|
})
|
|
36
|
+
const everyPageBottomInjections = this.adminforth.config.customization.globalInjections.everyPageBottom || []
|
|
37
|
+
everyPageBottomInjections.push({ file: this.componentPath('TwoFaModal.vue'), meta: {} })
|
|
36
38
|
this.activate( resourceConfig, adminforth )
|
|
37
39
|
}
|
|
38
40
|
|
|
@@ -58,7 +60,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
58
60
|
const beforeLoginConfirmation = this.adminforth.config.auth.beforeLoginConfirmation;
|
|
59
61
|
const beforeLoginConfirmationArray = Array.isArray(beforeLoginConfirmation) ? beforeLoginConfirmation : [beforeLoginConfirmation];
|
|
60
62
|
beforeLoginConfirmationArray.push(
|
|
61
|
-
async({ adminUser, response }: { adminUser: AdminUser, response: IAdminForthHttpResponse} )=> {
|
|
63
|
+
async({ adminUser, response, extra }: { adminUser: AdminUser, response: IAdminForthHttpResponse, extra?: any} )=> {
|
|
62
64
|
const secret = adminUser.dbUser[this.options.twoFaSecretFieldName]
|
|
63
65
|
const userName = adminUser.dbUser[adminforth.config.auth.usernameField]
|
|
64
66
|
const brandName = adminforth.config.customization.brandName;
|
|
@@ -66,6 +68,8 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
66
68
|
const authResource = adminforth.config.resources.find((res)=>res.resourceId === adminforth.config.auth.usersResourceId )
|
|
67
69
|
const authPk = authResource.columns.find((col)=>col.primaryKey).name
|
|
68
70
|
const userPk = adminUser.dbUser[authPk]
|
|
71
|
+
const rememberMe = extra?.body?.rememberMe || false;
|
|
72
|
+
const rememberMeDays = rememberMe ? adminforth.config.auth.rememberMeDays || 30 : 1;
|
|
69
73
|
let newSecret = null;
|
|
70
74
|
|
|
71
75
|
const userNeeds2FA = this.options.usersFilterToApply ? this.options.usersFilterToApply(adminUser) : true;
|
|
@@ -79,7 +83,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
79
83
|
const tempSecret = twofactor.generateSecret({name: brandName,account: userName})
|
|
80
84
|
newSecret = tempSecret.secret
|
|
81
85
|
} else {
|
|
82
|
-
const value = this.adminforth.auth.issueJWT({userName,
|
|
86
|
+
const value = this.adminforth.auth.issueJWT({userName, issuer:brandName, pk:userPk, userCanSkipSetup, rememberMeDays }, 'tempTotp', '2h');
|
|
83
87
|
response.setHeader('Set-Cookie', `adminforth_${brandNameSlug}_totpTemporaryJWT=${value}; Path=${this.adminforth.config.baseUrl || '/'}; HttpOnly; SameSite=Strict; max-age=3600; `);
|
|
84
88
|
|
|
85
89
|
return {
|
|
@@ -90,7 +94,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
90
94
|
ok: true
|
|
91
95
|
}
|
|
92
96
|
}
|
|
93
|
-
const totpTemporaryJWT = this.adminforth.auth.issueJWT({userName, newSecret, issuer:brandName, pk:userPk, userCanSkipSetup }, 'tempTotp', '2h');
|
|
97
|
+
const totpTemporaryJWT = this.adminforth.auth.issueJWT({userName, newSecret, issuer:brandName, pk:userPk, userCanSkipSetup, rememberMeDays }, 'tempTotp', '2h');
|
|
94
98
|
response.setHeader('Set-Cookie', `adminforth_${brandNameSlug}_totpTemporaryJWT=${totpTemporaryJWT}; Path=${this.adminforth.config.baseUrl || '/'}; HttpOnly; SameSite=Strict; Expires=${new Date(Date.now() + '1h').toUTCString() } `);
|
|
95
99
|
|
|
96
100
|
return {
|
|
@@ -127,7 +131,6 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
127
131
|
const brandNameSlug = this.adminforth.config.customization.brandNameSlug;
|
|
128
132
|
const totpTemporaryJWT = cookies.find((cookie)=>cookie.key === `adminforth_${brandNameSlug}_totpTemporaryJWT`)?.value;
|
|
129
133
|
const decoded = await this.adminforth.auth.verify(totpTemporaryJWT, 'tempTotp');
|
|
130
|
-
|
|
131
134
|
if (!decoded)
|
|
132
135
|
return {status:'error',message:'Invalid token'}
|
|
133
136
|
|
|
@@ -140,7 +143,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
140
143
|
await connector.updateRecord({resource:this.authResource, recordId:decoded.pk, newValues:{[this.options.twoFaSecretFieldName]: decoded.newSecret}})
|
|
141
144
|
}
|
|
142
145
|
this.adminforth.auth.removeCustomCookie({response, name:'totpTemporaryJWT'})
|
|
143
|
-
this.adminforth.auth.setAuthCookie({response, username:decoded.userName, pk:decoded.pk})
|
|
146
|
+
this.adminforth.auth.setAuthCookie({expireInDays: decoded.rememberMeDays, response, username:decoded.userName, pk:decoded.pk})
|
|
144
147
|
return { status: 'ok', allowedLogin: true }
|
|
145
148
|
} else {
|
|
146
149
|
return {error: 'Wrong or expired OTP code'}
|
|
@@ -153,7 +156,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
153
156
|
const verified = twofactor.verifyToken(user[this.options.twoFaSecretFieldName], body.code, this.options.timeStepWindow);
|
|
154
157
|
if (verified) {
|
|
155
158
|
this.adminforth.auth.removeCustomCookie({response, name:'totpTemporaryJWT'})
|
|
156
|
-
this.adminforth.auth.setAuthCookie({response, username:decoded.userName, pk:decoded.pk})
|
|
159
|
+
this.adminforth.auth.setAuthCookie({expireInDays: decoded.rememberMeDays, response, username:decoded.userName, pk:decoded.pk})
|
|
157
160
|
return { status: 'ok', allowedLogin: true }
|
|
158
161
|
} else {
|
|
159
162
|
return {error: 'Wrong or expired OTP code'}
|
|
@@ -187,5 +190,25 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
187
190
|
}
|
|
188
191
|
},
|
|
189
192
|
});
|
|
193
|
+
server.endpoint({
|
|
194
|
+
method: 'POST',
|
|
195
|
+
path: `/plugin/twofa/verify`,
|
|
196
|
+
noAuth: false,
|
|
197
|
+
handler: async ({ adminUser, body }) => {
|
|
198
|
+
if (!body?.code) return { error: 'Code is required' };
|
|
199
|
+
|
|
200
|
+
const authRes = this.adminforth.config.resources
|
|
201
|
+
.find(r => r.resourceId === this.adminforth.config.auth.usersResourceId);
|
|
202
|
+
const connector = this.adminforth.connectors[authRes.dataSource];
|
|
203
|
+
const pkName = authRes.columns.find(c => c.primaryKey).name;
|
|
204
|
+
const user = await connector.getRecordByPrimaryKey(authRes, adminUser.dbUser[pkName]);
|
|
205
|
+
|
|
206
|
+
const secret = user[this.options.twoFaSecretFieldName];
|
|
207
|
+
if (!secret) return { error: '2FA is not set up for this user' };
|
|
208
|
+
|
|
209
|
+
const verified = twofactor.verifyToken(secret, body.code, this.options.timeStepWindow);
|
|
210
|
+
return verified ? { ok: true } : { error: 'Wrong or expired OTP code' };
|
|
211
|
+
}
|
|
212
|
+
});
|
|
190
213
|
}
|
|
191
214
|
}
|