Skip to content

Commit 736ea01

Browse files
committed
feat: add before logout hook
AdminForth/1900/image
1 parent 7e2b4b8 commit 736ea01

5 files changed

Lines changed: 166 additions & 1 deletion

File tree

‎adminforth/documentation/docs/tutorial/03-Customization/12-security.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,28 @@ non-existing users, so an attacker can't use it to find out whether credentials
328328
329329
If your check needs the user record itself (like two-factor authentication does), use [auth.beforeLoginConfirmation](/docs/api/Back/type-aliases/BeforeLoginConfirmationFunction) instead: it is called after credentials are verified.
330330
331+
## Doing cleanup on logout
332+
333+
`auth.beforeLogout` hooks are called when user logs out, before AdminForth removes auth cookie:
334+
335+
```ts title="./index.ts"
336+
export const admin = new AdminForth({
337+
338+
...
339+
340+
auth: {
341+
beforeLogout: [
342+
async ({ adminUser, adminforth, extra }) => {
343+
await revokeExternalSession(adminUser.pk);
344+
}
345+
]
346+
}
347+
348+
...
349+
350+
})
351+
```
352+
331353
## Custom user authorization hook
332354
333355
Default user authorization checks that cookie with JWT token is valid, signed and not expired.

‎adminforth/modules/configValidator.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1276,6 +1276,18 @@ export default class ConfigValidator implements IConfigValidator {
12761276
newConfig.auth.beforeLoginConfirmation = blc;
12771277
}
12781278

1279+
// normalize beforeLogout hooks
1280+
const blo = this.inputConfig.auth.beforeLogout;
1281+
if (!Array.isArray(blo)) {
1282+
if (blo) {
1283+
newConfig.auth.beforeLogout = [blo];
1284+
} else {
1285+
newConfig.auth.beforeLogout = [];
1286+
}
1287+
} else {
1288+
newConfig.auth.beforeLogout = blo;
1289+
}
1290+
12791291
// normalize adminUserAuthorize hooks
12801292
const aua = this.inputConfig.auth.adminUserAuthorize;
12811293
if (!Array.isArray(aua)) {

‎adminforth/modules/restApi.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type IHttpServer,
44
BeforeLoginAttemptFunction,
55
BeforeLoginConfirmationFunction,
6+
BeforeLogoutFunction,
67
AdminForthResource,
78
AllowedActionValue,
89
AllowedActions,
@@ -773,6 +774,17 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
773774
}
774775
}
775776

777+
/**
778+
* Runs beforeLogout hooks. Hooks can't block logout, they are called for cleanup and logging only.
779+
*/
780+
async processBeforeLogout(adminUser: AdminUser | null, extra: HttpExtra, tr: ITranslateFunction) {
781+
const beforeLogout = this.adminforth.config.auth.beforeLogout as (BeforeLogoutFunction[] | undefined);
782+
783+
for (const hook of listify(beforeLogout)) {
784+
await hook({ adminUser, adminforth: this.adminforth, extra, tr });
785+
}
786+
}
787+
776788
checkAbortSignal(abortSignal: AbortSignal): boolean {
777789
if (abortSignal.aborted) {
778790
return true;
@@ -896,7 +908,15 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
896908
noAuth: true,
897909
method: 'POST',
898910
path: '/logout',
899-
handler: async ({ response }) => {
911+
handler: async ({ body, headers, query, cookies, requestUrl, response, tr }) => {
912+
// endpoint is noAuth (expired session should be able to log out as well), so user is resolved here
913+
const jwt = this.adminforth.auth.getAuthCookie(cookies);
914+
const adminUser = jwt ? await this.adminforth.auth.verify(jwt, 'auth') as AdminUser | null : null;
915+
916+
await this.processBeforeLogout(adminUser, {
917+
body, headers, query, cookies, requestUrl, response
918+
}, tr);
919+
900920
this.adminforth.auth.removeAuthCookie( response );
901921
return { ok: true };
902922
},

‎adminforth/types/Back.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1328,6 +1328,30 @@ export type BeforeLoginAttemptFunction = (params: {
13281328
tr: ITranslateFunction,
13291329
}) => Promise<{ ok: boolean, error?: string }>;
13301330

1331+
/**
1332+
* Called when user logs out, before AdminForth removes auth cookie.
1333+
* Useful for cleanup of things issued for the session (e.g. revoking tokens on external identity provider,
1334+
* removing custom cookies set by plugins) or for logging logout event.
1335+
*/
1336+
export type BeforeLogoutFunction = (params: {
1337+
/**
1338+
* User which is logging out. Is `null` when session was already expired or invalid at the moment of logout.
1339+
*/
1340+
adminUser: AdminUser | null,
1341+
/**
1342+
* Adminforth instance.
1343+
*/
1344+
adminforth: IAdminForth,
1345+
/**
1346+
* Extra HTTP information of logout request. Use extra.response to set custom status, headers or cookies.
1347+
*/
1348+
extra: HttpExtra,
1349+
/**
1350+
* Translate function, respects language of logout request.
1351+
*/
1352+
tr: ITranslateFunction,
1353+
}) => Promise<void>;
1354+
13311355
/**
13321356
* Allow to make extra authorization
13331357
*/
@@ -1835,6 +1859,20 @@ export interface AdminForthInputConfig {
18351859
*/
18361860
beforeLoginConfirmation?: BeforeLoginConfirmationFunction | Array<BeforeLoginConfirmationFunction>,
18371861

1862+
/**
1863+
* Function or functions which will be called when user logs out, before AdminForth removes auth cookie.
1864+
* Logout is never blocked by these hooks, so use them for cleanup or logging.
1865+
*
1866+
* Example:
1867+
*
1868+
* ```ts
1869+
* beforeLogout: async ({ adminUser, adminforth }) => {
1870+
* await revokeExternalSession(adminUser.pk);
1871+
* },
1872+
* ```
1873+
*/
1874+
beforeLogout?: BeforeLogoutFunction | Array<BeforeLogoutFunction>,
1875+
18381876
/**
18391877
* Array of functions which will be called before any request to AdminForth API.
18401878
*/
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import request from 'supertest';
2+
import { admin, app, closeApplication } from './authTestApp';
3+
4+
type LogoutHook = (params: { adminUser: any, adminforth: any, extra: any, tr: any }) => Promise<void>;
5+
6+
const hooks = admin.config.auth.beforeLogout as LogoutHook[];
7+
8+
const logout = (cookie?: string) => {
9+
const req = request(app).post('/adminapi/v1/logout');
10+
return (cookie ? req.set("Cookie", cookie) : req).send({});
11+
};
12+
13+
const loginCookie = async (): Promise<string> => {
14+
const res = await request(app).post('/adminapi/v1/login').send({ username: 'adminforth', password: 'adminforth' });
15+
return res.headers['set-cookie'][0].split(';')[0];
16+
};
17+
18+
afterAll(async () => {
19+
await closeApplication();
20+
});
21+
22+
afterEach(() => {
23+
hooks.length = 0;
24+
});
25+
26+
describe('auth.beforeLogout', () => {
27+
it('receives logged in user, request data and translate function', async () => {
28+
const calls: { username: string, headers: Record<string, string>, translated: string }[] = [];
29+
hooks.push(async ({ adminUser, extra, tr }) => {
30+
calls.push({
31+
username: adminUser.username,
32+
headers: extra.headers,
33+
translated: await tr('Invalid username or password', 'errors'),
34+
});
35+
});
36+
37+
const res = await logout(await loginCookie()).set('x-some-header', 'value');
38+
39+
expect(res.status).toEqual(200);
40+
expect(res.body).toEqual({ ok: true });
41+
expect(calls).toHaveLength(1);
42+
expect(calls[0].username).toEqual('adminforth');
43+
expect(calls[0].headers['x-some-header']).toEqual('value');
44+
expect(calls[0].translated).toEqual('Invalid username or password');
45+
});
46+
47+
it('is called before auth cookie is removed and does not block logout', async () => {
48+
const called: string[] = [];
49+
hooks.push(
50+
async () => { called.push('first'); },
51+
async () => { called.push('second'); },
52+
);
53+
54+
const res = await logout(await loginCookie());
55+
56+
expect(res.status).toEqual(200);
57+
expect(called).toEqual(['first', 'second']);
58+
expect(res.headers['set-cookie'][0]).toContain('adminforth_');
59+
expect(res.headers['set-cookie'][0]).toContain('Expires=Thu, 01 Jan 1970 00:00:00 GMT');
60+
});
61+
62+
it('passes null user when session is missing or invalid', async () => {
63+
const users: (any | null)[] = [];
64+
hooks.push(async ({ adminUser }) => { users.push(adminUser); });
65+
66+
const withoutCookie = await logout();
67+
const withBrokenCookie = await logout(`adminforth_${admin.config.customization.brandNameSlug}_jwt=not-a-jwt`);
68+
69+
expect(withoutCookie.status).toEqual(200);
70+
expect(withBrokenCookie.status).toEqual(200);
71+
expect(users).toEqual([null, null]);
72+
});
73+
});

0 commit comments

Comments
 (0)