@adminforth/two-factors-auth 1.4.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/dist/custom/TwoFaModal.vue +144 -0
- package/dist/index.js +21 -0
- package/index.ts +22 -0
- 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
|
+
|
|
@@ -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
|
+
|
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) {
|
|
@@ -175,5 +177,24 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
175
177
|
}
|
|
176
178
|
}),
|
|
177
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
|
+
});
|
|
178
199
|
}
|
|
179
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
|
|
|
@@ -188,5 +190,25 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
|
|
|
188
190
|
}
|
|
189
191
|
},
|
|
190
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
|
+
});
|
|
191
213
|
}
|
|
192
214
|
}
|