Skip to content

Commit a9545a3

Browse files
committed
2 parents bc6e07d + 298dcdd commit a9545a3

7 files changed

Lines changed: 353 additions & 23 deletions

File tree

README.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,31 @@
11
# AdminForth TwoFactorsAuth Plugin
22

3-
<img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT" /> <img src="https://woodpecker.devforth.io/api/badges/3848/status.svg" alt="Build Status" /> <a href="https://www.npmjs.com/package/@adminforth/two-factors-auth"> <img src="https://img.shields.io/npm/dt/@adminforth/two-factors-auth" alt="npm downloads" /></a> <a href="https://www.npmjs.com/package/@adminforth/two-factors-auth"><img src="https://img.shields.io/npm/v/@adminforth/two-factors-auth" alt="npm version" /></a> <a href="https://www.npmjs.com/package/@adminforth/two-factors-auth">
3+
<img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT" /> <img src="https://woodpecker.devforth.io/api/badges/3848/status.svg" alt="Build Status" /> <a href="https://www.npmjs.com/package/@adminforth/two-factors-auth"><img src="https://img.shields.io/npm/dm/@adminforth/two-factors-auth" alt="npm downloads" /></a> <a href="https://www.npmjs.com/package/@adminforth/two-factors-auth"><img src="https://img.shields.io/npm/v/@adminforth/two-factors-auth" alt="npm version" /></a>
4+
5+
[![Ask AI](https://tluma.ai/badge)](https://tluma.ai/ask-ai/devforth/adminforth)
46

57
Allows to enable Two-Factor Authentication to an adminforth application.
68

7-
## For usage, see [AdminForth TwoFactorsAuth Documentation](https://adminforth.dev/docs/tutorial/Plugins/TwoFactorsAuth/)
9+
## Features
10+
11+
- Add two-factor authentication to AdminForth login.
12+
- Improve account security for admin users.
13+
- Introduce a stronger verification step at sign-in.
14+
- Integrate 2FA into existing authentication workflows.
15+
16+
## Documentation
17+
18+
Full setup and configuration guide:
19+
20+
[AdminForth TwoFactorsAuth Documentation](https://adminforth.dev/docs/tutorial/Plugins/two-factors-auth/)
21+
22+
## About AdminForth
23+
24+
AdminForth is an open-source, agent-first admin framework for building robust admin panels and back-office applications faster.
25+
26+
## Related links
27+
28+
- [AdminForth website](https://adminforth.dev)
29+
- [npm package](https://www.npmjs.com/package/@adminforth/two-factors-auth)
30+
- [More AdminForth plugins](https://adminforth.dev/docs/tutorial/ListOfPlugins/)
31+
- [Built by DevForth](https://devforth.io)

custom/TwoFAModal.vue

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@
3737
@on-complete="handleOnComplete"
3838
/>
3939
</div>
40-
41-
<p class="af-2fa-totp-footer text-center text-xs text-gray-500 dark:text-gray-400">
40+
<p v-if="doesUserHavePasskeys" class="af-2fa-totp-footer text-center text-xs text-gray-500 dark:text-gray-400">
4241
{{$t('Having trouble?')}}
43-
<button v-if="doesUserHavePasskeys" type="button" class="af-2fa-switch-to-passkey text-lightPrimary dark:text-white hover:underline cursor-pointer" @click="modalMode = 'passkey'">{{$t('Use passkey instead')}}</button>
42+
<button type="button" class="af-2fa-switch-to-passkey text-lightPrimary dark:text-white hover:underline cursor-pointer" @click="modalMode = 'passkey'">{{$t('Use passkey instead')}}</button>
4443
</p>
44+
<p class="af-2fa-multiple-actions text-center text-red-500 text-xs" v-if="sessionsIdsToResolve.length > 1"> You are confirming {{ sessionsIdsToResolve.length }} actions</p>
4545
</div>
4646

4747
<div v-else-if="modalMode === 'passkey'" class="af-two-factor-modal-passkeys flex flex-col gap-4 relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
@@ -89,6 +89,7 @@
8989
{{$t('Having trouble?')}}
9090
<button type="button" class="af-2fa-switch-to-totp text-lightPrimary dark:text-white hover:underline cursor-pointer" @click="modalMode = 'totp'">{{$t('Use TOTP instead')}}</button>
9191
</p>
92+
<p class="af-2fa-multiple-actions text-center text-red-500 text-xs" v-if="sessionsIdsToResolve.length > 1"> You are confirming {{ sessionsIdsToResolve.length }} actions</p>
9293
</div>
9394
</div>
9495
</template>
@@ -103,8 +104,9 @@
103104
import { Link, Button } from '@/afcl';
104105
import { IconShieldOutline } from '@iconify-prerendered/vue-flowbite';
105106
import { getPasskey } from './utils.js'
106-
import adminforth from '@/adminforth';
107-
107+
import { useAdminforth } from '@/adminforth';
108+
import websocket from '@/websocket';
109+
import type { AdminUser } from '@/types/Common';
108110
109111
type TwoFaConfirmationResult = { mode: 'totp'; result: string } | { mode: 'passkey'; result: Record<string, any> };
110112
@@ -120,7 +122,76 @@
120122
}
121123
const props = defineProps<{
122124
autoFinishLogin?: boolean
125+
adminUser?: AdminUser
123126
}>();
127+
128+
const { alert } = useAdminforth();
129+
130+
const isAwaiting2FAResult = ref(false);
131+
let allowAddNewSessions = true;
132+
const ALLOW_NEW_SESSIONS_PERIOD = 1000;
133+
const sessionsIdsToResolve = ref<string[]>([]);
134+
135+
watch(isAwaiting2FAResult, (awaiting) => {
136+
if (awaiting) {
137+
allowAddNewSessions = true;
138+
setTimeout(() => {
139+
if (isAwaiting2FAResult.value) {
140+
allowAddNewSessions = false;
141+
}
142+
}, ALLOW_NEW_SESSIONS_PERIOD);
143+
}
144+
});
145+
146+
watch( props, () => {
147+
if (props.adminUser) {
148+
websocket.unsubscribeByPrefix(`/user2fa/`);
149+
websocket.subscribe(`/user2fa/${props.adminUser.pk}`, async (data: {sessionId: string}) => {
150+
if (!allowAddNewSessions) {
151+
alert({message: 'Some process or user tries to add new actions to confirm. Action was blocked', variant: 'warning'});
152+
return;
153+
}
154+
sessionsIdsToResolve.value.push(data.sessionId);
155+
let confirmationResult;
156+
if (isAwaiting2FAResult.value) {
157+
return;
158+
}
159+
try {
160+
isAwaiting2FAResult.value = true;
161+
confirmationResult = await window.adminforthTwoFaModal.get2FaConfirmationResult();
162+
} catch (error) {
163+
console.error('Error during 2FA confirmation:', error);
164+
}
165+
isAwaiting2FAResult.value = false;
166+
try {
167+
const response = await callAdminForthApi({
168+
method: "POST",
169+
path: "/plugin/passkeys/resolveVerifyAuto",
170+
body: { confirmationResult, sessionsIds: sessionsIdsToResolve.value }
171+
});
172+
if (!response.ok && response.error === 'No session ID or confirmation result'){
173+
alert({message: 'Verification session finished or cancelled.', variant: 'warning'});
174+
} else if (!response.ok) {
175+
alert({message: 'Verification failed', variant: 'danger'});
176+
} else if (response.ok) {
177+
alert({message: 'Verification successful', variant: 'success'});
178+
}
179+
sessionsIdsToResolve.value = [];
180+
} catch (error) {
181+
console.error('Error resolving automatic 2FA verification:', error);
182+
}
183+
allowAddNewSessions = true;
184+
});
185+
websocket.subscribe(`/user2fa/${props.adminUser.pk}-resolve`, async (data: {sessionId: string}) => {
186+
if (sessionsIdsToResolve.value.includes(data.sessionId) && rejectFn && modelShow.value) {
187+
onCancel();
188+
sessionsIdsToResolve.value = sessionsIdsToResolve.value.filter(id => id !== data.sessionId);
189+
}
190+
});
191+
}
192+
})
193+
194+
124195
const emit = defineEmits<{
125196
(e: 'resolved', payload: any): void
126197
(e: 'rejected', err?: any): void

custom/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"test": "echo \"Error: no test specified\" && exit 1"
77
},
88
"keywords": [],
9-
"author": "",
9+
"author": "DevForth (https://devforth.io)",
1010
"license": "ISC",
1111
"description": "",
1212
"dependencies": {

index.ts

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,15 +141,40 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
141141
return false;
142142
}
143143

144+
private pending = new Map<string, (value: any) => void>();
145+
146+
private waitForResponse(id: string): Promise<any> {
147+
return new Promise((resolve) => {
148+
this.pending.set(id, resolve);
149+
});
150+
}
151+
152+
private resolveResponse(id: string, data: any) {
153+
const resolve = this.pending.get(id);
154+
if (resolve) {
155+
resolve(data);
156+
this.pending.delete(id);
157+
}
158+
}
159+
160+
public async verifyAuto(adminUser: AdminUser) {
161+
const sessionId = crypto.randomUUID();
162+
const jwt = this.adminforth.auth.issueJWT({sessionId, adminUserPk: adminUser.pk}, 'auto2FA', '5m');
163+
this.adminforth.websocket.publish(`/user2fa/${adminUser.pk}`, { sessionId: jwt });
164+
const result = await this.waitForResponse(jwt);
165+
this.adminforth.websocket.publish(`/user2fa/${adminUser.pk}-resolve`, { sessionId: jwt });
166+
return result;
167+
}
168+
144169
public async verify(
145170
confirmationResult: Record<string, any>,
146171
opts?: { adminUser?: AdminUser; userPk?: string; cookies?: any, response?: IAdminForthHttpResponse, extra?: HttpExtra }
147172
): Promise<{ ok: true } | { error: string }> {
148173
if (!confirmationResult) return { error: "Confirmation result is required" };
149174
if (!opts.adminUser) return { error: "Admin user is required" };
150175
if (!opts.userPk) return { error: "User PK is required" };
151-
const cookies = opts.extra.cookies || opts.cookies;
152-
const response = opts.extra.response || opts.response;
176+
const cookies = opts.extra?.cookies || opts.cookies;
177+
const response = opts.extra?.response || opts.response;
153178
if (this.options.usersFilterToApply) {
154179
const res = this.options.usersFilterToApply(opts.adminUser);
155180
if ( res === false ) {
@@ -753,7 +778,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
753778
method: 'POST',
754779
path: `/plugin/passkeys/registerPasskeyRequest`,
755780
noAuth: false,
756-
handler: async ({ body, adminUser, response, cookies, extra }) => {
781+
handler: async ({ body, adminUser, response, cookies, headers }) => {
757782
const mode = body?.mode;
758783

759784
const confirmationResult = body?.confirmationResult;
@@ -762,7 +787,9 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
762787
userPk: adminUser.pk,
763788
cookies: cookies,
764789
response: response,
765-
extra: { ...extra }
790+
extra: {
791+
headers,
792+
} as HttpExtra
766793
});
767794
if (!verificationResult || !('ok' in verificationResult)) {
768795
return { ok: false, error: 'error' in verificationResult ? verificationResult.error : 'Verification failed' };
@@ -1049,5 +1076,55 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
10491076
}
10501077
}
10511078
});
1079+
server.endpoint({
1080+
method: 'POST',
1081+
path: `/plugin/passkeys/resolveVerifyAuto`,
1082+
noAuth: false,
1083+
handler: async ({ body, adminUser, response, cookies, headers }) => {
1084+
const sessionsIds = body?.sessionsIds;
1085+
const confirmationResult = body?.confirmationResult;
1086+
const idsToResolve = sessionsIds;
1087+
1088+
const resolveAllIdsAsFailed = (message) => {
1089+
for (const id of idsToResolve) {
1090+
this.resolveResponse(id, { ok: false, error: message });
1091+
}
1092+
return { ok: false, error: message };
1093+
}
1094+
1095+
if (!(sessionsIds) || !confirmationResult) {
1096+
return(resolveAllIdsAsFailed('Confirmation window was closed or did not return required data'));
1097+
}
1098+
1099+
for (const id of idsToResolve) {
1100+
const validationResult = await this.adminforth.auth.verify(id, 'auto2FA', false);
1101+
if (!validationResult) {
1102+
return(resolveAllIdsAsFailed('Invalid session ID or confirmation result'));
1103+
}
1104+
if (validationResult.adminUserPk !== adminUser.pk) {
1105+
return(resolveAllIdsAsFailed('Session does not belong to the authenticated user'));
1106+
}
1107+
}
1108+
1109+
const verificationResult = await this.verify(confirmationResult, {
1110+
adminUser: adminUser,
1111+
userPk: adminUser.pk,
1112+
cookies: cookies,
1113+
response: response,
1114+
extra: {
1115+
headers: headers,
1116+
} as HttpExtra
1117+
});
1118+
if ( !verificationResult || !('ok' in verificationResult) ) {
1119+
return(resolveAllIdsAsFailed('Verification failed'));
1120+
}
1121+
if ('ok' in verificationResult && verificationResult.ok){
1122+
for (const id of idsToResolve) {
1123+
this.resolveResponse(id, { ok: true, passkeyConfirmed: verificationResult });
1124+
}
1125+
return { ok: true };
1126+
}
1127+
}
1128+
});
10521129
}
10531130
}

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"publishConfig": {
88
"access": "public"
99
},
10-
"homepage": "https://adminforth.dev/docs/tutorial/Plugins/TwoFactorsAuth/",
10+
"homepage": "https://adminforth.dev/docs/tutorial/Plugins/two-factors-auth/",
1111
"repository": {
1212
"type": "git",
1313
"url": "https://github.com/devforth/adminforth-two-factors-auth.git"
@@ -21,14 +21,14 @@
2121
"two-factors-auth",
2222
"2fa"
2323
],
24-
"author": "devforth",
25-
"license": "ISC",
24+
"author": "DevForth (https://devforth.io)",
25+
"license": "MIT",
2626
"dependencies": {
2727
"@simplewebauthn/server": "^13.2.1",
2828
"node-2fa": "^2.0.3"
2929
},
3030
"peerDependencies": {
31-
"adminforth": "^2.24.0"
31+
"adminforth": "^2.42.0"
3232
},
3333
"devDependencies": {
3434
"@types/node": "^22.10.7",

0 commit comments

Comments
 (0)