Skip to content

Commit db3fa16

Browse files
committed
feat: implement client ID management and request context handling
1 parent cb193e9 commit db3fa16

9 files changed

Lines changed: 111 additions & 47 deletions

File tree

adminforth/documentation/docs/tutorial/09-Plugins/02-TwoFactorsAuth.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,8 @@ If you wan't to call 2FA verification modal from the backend for verification, y
237237
t2fa.verifyAuto(adminUser);
238238
```
239239
This method opens 2FA verification modal at the frontend and then returns verification result.
240+
The modal is shown only in the same browser tab/window which made the backend request.
241+
Because of this, `verifyAuto` must be called from a browser-initiated request handled by AdminForth request context, for example from an endpoint wrapped with `admin.express.authorize`.
240242
241243
Here is an example:
242244
@@ -932,4 +934,3 @@ After adding passkey you can use passkey, instead of TOTP:
932934
933935
934936
935-

adminforth/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export * from './types/Back.js';
4848
export * from './types/Common.js';
4949
export * from './types/adapters/index.js';
5050
export * from './modules/filtersTools.js';
51+
export * from './modules/requestContext.js';
5152
export { interpretResource };
5253
export { AdminForthPlugin };
5354
export { suggestIfTypo, RateLimiter, RAMLock, getClientIp, convertPeriodToSeconds };
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { AsyncLocalStorage } from 'async_hooks';
2+
3+
export const ADMINFORTH_CLIENT_ID_HEADER = 'x-adminforth-client-id';
4+
5+
export type AdminForthRequestContext = {
6+
websocketClientId?: string;
7+
};
8+
9+
const requestContextStorage = new AsyncLocalStorage<AdminForthRequestContext>();
10+
11+
export function runWithRequestContext<T>(context: AdminForthRequestContext, callback: () => T): T {
12+
return requestContextStorage.run(context, callback);
13+
}
14+
15+
export function getRequestContext(): AdminForthRequestContext | undefined {
16+
return requestContextStorage.getStore();
17+
}
18+
19+
export function getRequestWebsocketClientId(): string | undefined {
20+
return requestContextStorage.getStore()?.websocketClientId;
21+
}

adminforth/servers/common.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AdminUser } from "../types/Common.js";
55

66
export class WebSocketClient implements IWebSocketClient {
77
id: string;
8+
clientId?: string;
89
lastPing: number;
910
topics: Set<string>;
1011
adminUser: AdminUser;
@@ -14,8 +15,9 @@ export class WebSocketClient implements IWebSocketClient {
1415
onMessage: (handler: (message: string) => void) => void;
1516
onClose: (handler: () => void) => void;
1617

17-
constructor({ id, send, close, onMessage, onClose, adminUser }) {
18+
constructor({ id, clientId, send, close, onMessage, onClose, adminUser }) {
1819
this.id = id;
20+
this.clientId = clientId;
1921
this.send = send;
2022
this.close = close;
2123
this.onMessage = onMessage;
@@ -25,4 +27,4 @@ export class WebSocketClient implements IWebSocketClient {
2527
this.topics = new Set();
2628
this.adminUser = adminUser;
2729
}
28-
}
30+
}

adminforth/servers/express.ts

Lines changed: 62 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type { AddressInfo } from 'net';
2323
import { randomUUID } from 'crypto';
2424
import { listify } from '../modules/utils.js';
2525
import { afLogger } from '../modules/logger.js';
26+
import { ADMINFORTH_CLIENT_ID_HEADER, runWithRequestContext } from '../modules/requestContext.js';
2627
import * as z from 'zod';
2728
import multer from 'multer';
2829

@@ -52,6 +53,11 @@ function parseCookiesString(cookiesString: string): Array<{
5253
return result;
5354
}
5455

56+
function getHeaderString(headers: Record<string, any>, name: string): string | undefined {
57+
const value = headers[name];
58+
return typeof value === 'string' ? value : undefined;
59+
}
60+
5561
async function parseExpressCookie(req): Promise<
5662
Array<{
5763
key: string,
@@ -271,6 +277,7 @@ class ExpressServer implements IExpressHttpServer {
271277
this.adminforth.websocket.registerWsClient(
272278
new WebSocketClient({
273279
id: randomUUID(),
280+
clientId: typeof req.url === 'string' ? new URL(req.url, 'http://localhost').searchParams.get('clientId') || undefined : undefined,
274281
adminUser,
275282
send: (data) => ws.send(data),
276283
close: () => ws.close(),
@@ -287,6 +294,11 @@ class ExpressServer implements IExpressHttpServer {
287294

288295
serve(app) {
289296
this.expressApp = app;
297+
this.expressApp.use((req, res, next) => {
298+
runWithRequestContext({
299+
websocketClientId: getHeaderString(req.headers, ADMINFORTH_CLIENT_ID_HEADER),
300+
}, next);
301+
});
290302
this.patchSchemaAwareRouteRegistration();
291303
this.flushPendingEndpointRegistrations();
292304
const stack = (this.expressApp as any)?._router?.stack;
@@ -337,55 +349,63 @@ class ExpressServer implements IExpressHttpServer {
337349
}
338350
}
339351
}
352+
353+
runInRequestContext(req, callback) {
354+
return runWithRequestContext({
355+
websocketClientId: getHeaderString(req.headers, ADMINFORTH_CLIENT_ID_HEADER),
356+
}, callback);
357+
}
340358

341359

342360
authorize(handler) {
343361
return async (req, res, next) => {
344-
const cookies = await parseExpressCookie(req);
345-
const brandSlug = this.adminforth.config.customization.brandNameSlug;
346-
// check if multiple adminforth_jwt providerd and show warning
347-
const jwts = cookies.filter(({key}) => key === `adminforth_${brandSlug}_jwt`);
348-
if (jwts.length > 1) {
349-
afLogger.error('Multiple adminforth_jwt cookies provided');
350-
}
362+
return this.runInRequestContext(req, async () => {
363+
const cookies = await parseExpressCookie(req);
364+
const brandSlug = this.adminforth.config.customization.brandNameSlug;
365+
// check if multiple adminforth_jwt providerd and show warning
366+
const jwts = cookies.filter(({key}) => key === `adminforth_${brandSlug}_jwt`);
367+
if (jwts.length > 1) {
368+
afLogger.error('Multiple adminforth_jwt cookies provided');
369+
}
351370

352-
const jwt = jwts[0]?.value;
371+
const jwt = jwts[0]?.value;
353372

354-
if (!jwt) {
355-
res.status(401).send('Unauthorized by AdminForth');
356-
return
357-
}
358-
let adminforthUser;
359-
try {
360-
adminforthUser = await this.adminforth.auth.verify(jwt, 'auth');
361-
} catch (e) {
362-
// this might happen if e.g. database intialization in progress.
363-
// so we can't answer with 401 (would logout user)
364-
// reproduced during usage of listRowsAutoRefreshSeconds
365-
afLogger.error(e.stack);
366-
res.status(500).send('Failed to verify JWT token - something went wrong');
367-
return;
368-
}
369-
if (!adminforthUser) {
370-
res.status(401).send('Unauthorized by AdminForth');
371-
} else {
372-
req.adminUser = adminforthUser;
373-
const toReturn: { error?: string, allowed: boolean } = { allowed: true };
374-
await this.processAuthorizeCallbacks(adminforthUser, toReturn, res, {
375-
body: req.body,
376-
query: req.query,
377-
headers: req.headers,
378-
cookies: cookies as any,
379-
requestUrl: req.url,
380-
meta: {},
381-
response: res
382-
});
383-
if (!toReturn.allowed) {
373+
if (!jwt) {
374+
res.status(401).send('Unauthorized by AdminForth');
375+
return
376+
}
377+
let adminforthUser;
378+
try {
379+
adminforthUser = await this.adminforth.auth.verify(jwt, 'auth');
380+
} catch (e) {
381+
// this might happen if e.g. database intialization in progress.
382+
// so we can't answer with 401 (would logout user)
383+
// reproduced during usage of listRowsAutoRefreshSeconds
384+
afLogger.error(e.stack);
385+
res.status(500).send('Failed to verify JWT token - something went wrong');
386+
return;
387+
}
388+
if (!adminforthUser) {
384389
res.status(401).send('Unauthorized by AdminForth');
385390
} else {
386-
handler(req, res, next);
391+
req.adminUser = adminforthUser;
392+
const toReturn: { error?: string, allowed: boolean } = { allowed: true };
393+
await this.processAuthorizeCallbacks(adminforthUser, toReturn, res, {
394+
body: req.body,
395+
query: req.query,
396+
headers: req.headers,
397+
cookies: cookies as any,
398+
requestUrl: req.url,
399+
meta: {},
400+
response: res
401+
});
402+
if (!toReturn.allowed) {
403+
res.status(401).send('Unauthorized by AdminForth');
404+
} else {
405+
handler(req, res, next);
406+
}
387407
}
388-
}
408+
});
389409
};
390410
}
391411

@@ -590,7 +610,7 @@ class ExpressServer implements IExpressHttpServer {
590610
})
591611
: null;
592612

593-
const expressHandler = async (req, res) => {
613+
const expressHandler = async (req, res) => this.runInRequestContext(req, async () => {
594614
const abortController = new AbortController();
595615
res.on('close', () => {
596616
if(req.destroyed) {
@@ -718,7 +738,7 @@ class ExpressServer implements IExpressHttpServer {
718738
}
719739

720740
res.json(output);
721-
}
741+
});
722742

723743
const registerEndpoint = () => {
724744
afLogger.trace(`👂 Adding endpoint ${method} ${fullPath}`);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
const ADMINFORTH_CLIENT_ID_STORAGE_KEY = 'adminforthClientId';
2+
3+
export const ADMINFORTH_CLIENT_ID_HEADER = 'x-adminforth-client-id';
4+
5+
export function getAdminForthClientId(): string {
6+
const existingClientId = sessionStorage.getItem(ADMINFORTH_CLIENT_ID_STORAGE_KEY);
7+
if (existingClientId) {
8+
return existingClientId;
9+
}
10+
11+
const clientId = crypto.randomUUID();
12+
sessionStorage.setItem(ADMINFORTH_CLIENT_ID_STORAGE_KEY, clientId);
13+
return clientId;
14+
}

adminforth/spa/src/utils/utils.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { i18nInstance } from '../i18n'
1313
import { useI18n } from 'vue-i18n';
1414
import { onBeforeRouteLeave } from 'vue-router';
1515
import { reconnect } from '@/websocket';
16+
import { ADMINFORTH_CLIENT_ID_HEADER, getAdminForthClientId } from './clientId';
1617

1718

1819

@@ -133,6 +134,7 @@ export async function callApi({path, method, body, headers, silentError = false,
133134
headers: {
134135
'Content-Type': 'application/json',
135136
'accept-language': localStorage.getItem(LS_LANG_KEY) || 'en',
137+
[ADMINFORTH_CLIENT_ID_HEADER]: getAdminForthClientId(),
136138
...headers
137139
},
138140
body: JSON.stringify(body),
@@ -993,4 +995,4 @@ export async function executeCustomBulkAction({
993995
} finally {
994996
setLoadingState?.(false);
995997
}
996-
}
998+
}

adminforth/spa/src/websocket.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getAdminForthClientId } from '@/utils/clientId';
12

23
type WebsocketCallback = (data: any) => void;
34
type Unsubscribe = () => void;
@@ -46,9 +47,10 @@ async function connect () {
4647
base = base.slice(0, -1);
4748
}
4849

50+
const clientId = encodeURIComponent(getAdminForthClientId());
4951
state.ws = new WebSocket(`${
5052
window.location.protocol === 'http:' ? 'ws' : 'wss'
51-
}://${window.location.host}${base}/afws`);
53+
}://${window.location.host}${base}/afws?clientId=${clientId}`);
5254
state.status = 'connecting';
5355
state.ws.addEventListener('open', () => {
5456
console.log('🔌 AFWS connected');

adminforth/types/Back.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2385,6 +2385,7 @@ export interface AdminForthResourceColumn extends Omit<AdminForthResourceColumnC
23852385

23862386
export interface IWebSocketClient {
23872387
id: string;
2388+
clientId?: string;
23882389
lastPing: number;
23892390
topics: Set<string>;
23902391
adminUser: AdminUser;

0 commit comments

Comments
 (0)