Skip to content

Commit 426d3ab

Browse files
committed
feat: add afterLoginFailed hooks
1 parent 573cffe commit 426d3ab

5 files changed

Lines changed: 211 additions & 14 deletions

File tree

‎adminforth/modules/configValidator.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,6 +1215,18 @@ export default class ConfigValidator implements IConfigValidator {
12151215
newConfig.auth.beforeLoginAttempt = bla;
12161216
}
12171217

1218+
// normalize afterLoginFailed hooks
1219+
const alf = this.inputConfig.auth.afterLoginFailed;
1220+
if (!Array.isArray(alf)) {
1221+
if (alf) {
1222+
newConfig.auth.afterLoginFailed = [alf];
1223+
} else {
1224+
newConfig.auth.afterLoginFailed = [];
1225+
}
1226+
} else {
1227+
newConfig.auth.afterLoginFailed = alf;
1228+
}
1229+
12181230
// normalize beforeLoginConfirmation hooks
12191231
const blc = this.inputConfig.auth.beforeLoginConfirmation;
12201232
if (!Array.isArray(blc)) {

‎adminforth/modules/restApi.ts‎

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import {
22
type IAdminForth,
33
type IHttpServer,
44
BeforeLoginAttemptFunction,
5+
AfterLoginFailedFunction,
6+
LoginFailureReason,
57
BeforeLoginConfirmationFunction,
68
BeforeLogoutFunction,
79
AdminForthResource,
@@ -598,6 +600,21 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
598600
return null;
599601
}
600602

603+
/**
604+
* Runs afterLoginFailed hooks. Hooks can't change the 401 response, they are called for logging only.
605+
*/
606+
async processAfterLoginFail ed(username: string, pk: string | null, reason: LoginFailureReason, extra: HttpExtra) {
607+
const afterLoginFailed =this.adminforth.config.auth.afterLoginFailed as (AfterLoginFailedFunction[] | undefined);
608+
609+
for (const hook of listify(afterLoginFailed)) {
610+
try {
611+
await hook({ username, pk, reason, adminforth: this.adminforth, extra });
612+
} catch (e) {
613+
afLogger.error(`Error in afterLoginFailed hook, continuing with failed login response: ${e}`);
614+
}
615+
}
616+
}
617+
601618
async processLoginCallbacks(adminUser: AdminUser, toReturn: { redirectTo?: string, allowedLogin:boolean, error?: string }, response: any, extra: HttpExtra, sessionDuration?: string) {
602619
const beforeLoginConfirmation = this.adminforth.config.auth.beforeLoginConfirmation as (BeforeLoginConfirmationFunction[] | undefined);
603620

@@ -672,14 +689,14 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
672689
const userResource = this.adminforth.config.resources.find((res) => res.resourceId === this.adminforth.config.auth.usersResourceId);
673690
const usernameColumn = userResource.columns.find((col) => col.name === this.adminforth.config.auth.usernameField);
674691
const normalizedUsername = normalizeColumnValue(usernameColumn, username);
692+
const extra: HttpExtra = { body, headers, query, cookies, requestUrl, response };
675693

676694
// hooks are called before any lookup in database, so rejected attempt looks the same
677695
// for existing and non-existing users (e.g. captcha check should not leak valid credentials)
678-
const loginAttemptError = await this.processBeforeLoginAttempt(normalizedUsername, {
679-
body, headers, query, cookies, requestUrl, response
680-
}, tr);
696+
const loginAttemptError = await this.processBeforeLoginAttempt(normalizedUsername, extra, tr);
681697
if (loginAttemptError) {
682698
response.setStatus(401);
699+
await this.processAfterLoginFailed(normalizedUsername, null, 'rejected', extra);
683700
return loginAttemptError;
684701
}
685702

@@ -709,41 +726,42 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
709726

710727
if (!userRecord) {
711728
response.setStatus(401);
729+
await this.processAfterLoginFailed(normalizedUsername, null, 'user_not_found', extra);
712730
return { error: INVALID_MESSAGE };
713731
}
714732

733+
const pk = userRecord[userResource.columns.find((col) => col.primaryKey).name];
715734
const passwordHash = userRecord[this.adminforth.config.auth.passwordHashField];
716735
const valid = await AdminForthAuth.verifyPassword(password, passwordHash);
717736
if (valid) {
718-
adminUser = {
737+
adminUser = {
719738
dbUser: userRecord,
720-
pk: userRecord[userResource.columns.find((col) => col.primaryKey).name],
739+
pk,
721740
username: normalizedUsername,
722741
};
723742

724-
const expireInDuration = rememberMe
743+
const expireInDuration = rememberMe
725744
? (this.adminforth.config.auth.rememberMeDuration || '30d')
726745
: '1d';
727746

728-
await this.processLoginCallbacks(adminUser, toReturn, response, {
729-
body, headers, query, cookies, requestUrl, response
730-
}, expireInDuration);
747+
await this.processLoginCallbacks(adminUser, toReturn, response, extra, expireInDuration);
731748

732749
if (toReturn.allowedLogin) {
733-
750+
734751
await this.adminforth.auth.setAuthCookie({
735752
expireInDuration,
736753
response,
737754
username: normalizedUsername,
738-
pk: userRecord[userResource.columns.find((col) => col.primaryKey).name],
739-
extra: { body, headers, query, cookies, requestUrl, response },
755+
pk,
756+
extra,
740757
});
741-
}
758+
}
742759
} else {
743760
response.setStatus(401);
761+
await this.processAfterLoginFailed(normalizedUsername, pk, 'invalid_password', extra);
744762
return { error: INVALID_MESSAGE };
745763
}
746-
764+
747765

748766
return toReturn;
749767
}

‎adminforth/types/Back.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1225,6 +1225,44 @@ export type BeforeLoginAttemptFunction = (params: {
12251225
tr: ITranslateFunction,
12261226
}) => Promise<{ ok: boolean, error?: string }>;
12271227

1228+
/**
1229+
* Why password login attempt failed:
1230+
* - `rejected`: one of `auth.beforeLoginAttempt` hooks rejected the attempt, credentials were not checked
1231+
* - `user_not_found`: there is no user with such username
1232+
* - `invalid_password`: user exists, but password is wrong
1233+
*/
1234+
export type LoginFailureReason = 'rejected' | 'user_not_found' | 'invalid_password';
1235+
1236+
/**
1237+
* Called after password login attempt failed, right before AdminForth answers with `401`.
1238+
* Not called for attempts blocked by `auth.rateLimit`.
1239+
* Useful to log failed attempts or to notify user about bruteforce of their account.
1240+
*
1241+
* Can't change response of the login request: thrown error is logged and login still answers with `401`.
1242+
*/
1243+
export type AfterLoginFailedFunction = (params: {
1244+
/**
1245+
* Username which user tried to login with, normalized in same way as it is stored in database.
1246+
*/
1247+
username: string,
1248+
/**
1249+
* Primary key of user with such username. Is `null` when reason is `rejected` or `user_not_found`.
1250+
*/
1251+
pk: string | null,
1252+
/**
1253+
* Why login attempt failed.
1254+
*/
1255+
reason: LoginFailureReason,
1256+
/**
1257+
* Adminforth instance.
1258+
*/
1259+
adminforth: IAdminForth,
1260+
/**
1261+
* Extra HTTP information of login request.
1262+
*/
1263+
extra: HttpExtra,
1264+
}) => Promise<void>;
1265+
12281266
/**
12291267
* Called when user logs out, before AdminForth removes auth cookie.
12301268
* Useful for cleanup of things issued for the session (e.g. revoking tokens on external identity provider,
@@ -1804,6 +1842,23 @@ export interface AdminForthInputConfig {
18041842
*/
18051843
beforeLoginAttempt?: BeforeLoginAttemptFunction | Array<BeforeLoginAttemptFunction>,
18061844

1845+
/**
1846+
* Function or functions which will be called after password login attempt failed, right before
1847+
* AdminForth answers with `401`. Not called for attempts blocked by `auth.rateLimit`.
1848+
* Use them to log failed attempts. Hooks can't change the response, thrown errors are logged.
1849+
*
1850+
* Example:
1851+
*
1852+
* ```ts
1853+
* afterLoginFailed: async ({ username, pk, reason, adminforth, extra }) => {
1854+
* if (reason === 'invalid_password') {
1855+
* await notifyUser(pk, adminforth.auth.getClientIp(extra.headers));
1856+
* }
1857+
* },
1858+
* ```
1859+
*/
1860+
afterLoginFailed?: AfterLoginFailedFunction | Array<AfterLoginFailedFunction>,
1861+
18071862
/**
18081863
* Function or functions which will be called before user try to login.
18091864
* Each function will resive User object as an argument

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,43 @@ non-existing users, so an attacker can't use it to find out whether credentials
334334
335335
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.
336336
337+
## Tracking failed login attempts
338+
339+
`auth.afterLoginFailed` hooks are called after password login attempt failed, right before AdminForth answers with `401`:
340+
341+
```ts title="./index.ts"
342+
export const admin = new AdminForth({
343+
344+
...
345+
346+
auth: {
347+
afterLoginFailed: [
348+
async ({ username, pk, reason, adminforth, extra }) => {
349+
if (reason === 'invalid_password') {
350+
await notifyUser(pk, adminforth.auth.getClientIp(extra.headers));
351+
}
352+
}
353+
]
354+
}
355+
356+
...
357+
358+
})
359+
```
360+
361+
`reason` tells why the attempt failed:
362+
363+
- `rejected`: one of `auth.beforeLoginAttempt` hooks rejected the attempt, credentials were not checked
364+
- `user_not_found`: there is no user with such username
365+
- `invalid_password`: user exists, but password is wrong
366+
367+
`pk` is the primary key of the user and is `null` when reason is `rejected` or `user_not_found`.
368+
369+
Attempts blocked by `auth.rateLimit` don't call the hook, so the number of hook calls per IP is never higher than the rate limit.
370+
Still, every call happens while the client waits for the response, so keep the hook cheap: for example, aggregate attempts in memory and write them to the database in batches.
371+
372+
Hooks can't change the response: error thrown from the hook is logged and login still answers with `401`.
373+
337374
## Tracking created sessions
338375
339376
`auth.afterSessionCreated` hooks are called right after auth cookie with a new session was issued. They are called for
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import request from 'supertest';
2+
import { admin, app, closeApplication } from './authTestApp';
3+
4+
type LoginFailedHook = (params: { username: string, pk: string | null, reason: string, adminforth: any, extra: any }) => Promise<void>;
5+
type LoginAttemptHook = (params: { username: string, adminforth: any, extra: any, tr: any }) => Promise<{ ok: boolean, error?: string }>;
6+
7+
const hooks = admin.config.auth.afterLoginFailed as LoginFailedHook[];
8+
const loginAttemptHooks = admin.config.auth.beforeLoginAttempt as LoginAttemptHook[];
9+
10+
const login = (username: string, password: string) =>
11+
request(app).post('/adminapi/v1/login').send({ username, password });
12+
13+
afterAll(async () => {
14+
await closeApplication();
15+
});
16+
17+
afterEach(() => {
18+
hooks.length = 0;
19+
loginAttemptHooks.length = 0;
20+
});
21+
22+
describe('auth.afterLoginFailed', () => {
23+
it('receives reason and pk of user for every kind of failed attempt', async () => {
24+
const calls: { username: string, pk: string | null, reason: string }[] = [];
25+
hooks.push(async ({ username, pk, reason }) => { calls.push({ username, pk, reason }); });
26+
27+
await login('adminforth', 'wrong password');
28+
await login('nobody@example.com', 'wrong password');
29+
loginAttemptHooks.push(async () => ({ ok: false, error: 'Captcha verification failed' }));
30+
await login('adminforth', 'adminforth');
31+
32+
expect(calls).toHaveLength(3);
33+
expect(calls[0]).toEqual({ username: 'adminforth', pk: expect.anything(), reason: 'invalid_password' });
34+
expect(calls[1]).toEqual({ username: 'nobody@example.com', pk: null, reason: 'user_not_found' });
35+
expect(calls[2]).toEqual({ username: 'adminforth', pk: null, reason: 'rejected' });
36+
});
37+
38+
it('receives normalized username and request data', async () => {
39+
const calls: { username: string, headers: Record<string, string> }[] = [];
40+
hooks.push(async ({ username, extra }) => { calls.push({ username, headers: extra.headers }); });
41+
42+
await request(app)
43+
.post('/adminapi/v1/login')
44+
.set('x-some-header', 'value')
45+
.send({ username: ' ADMINFORTH ', password: 'wrong password' });
46+
47+
expect(calls).toHaveLength(1);
48+
expect(calls[0].username).toEqual('adminforth');
49+
expect(calls[0].headers['x-some-header']).toEqual('value');
50+
});
51+
52+
it('is not called for successful login', async () => {
53+
let called = false;
54+
hooks.push(async () => { called = true; });
55+
56+
const res = await login('adminforth', 'adminforth');
57+
58+
expect(res.status).toEqual(200);
59+
expect(called).toBe(false);
60+
});
61+
62+
it('keeps 401 response when hook throws and calls the next hooks', async () => {
63+
let secondCalled = false;
64+
hooks.push(
65+
async () => { throw new Error('Log storage is down'); },
66+
async () => { secondCalled = true; },
67+
);
68+
69+
const res = await login('adminforth', 'wrong password');
70+
71+
expect(res.status).toEqual(401);
72+
expect(res.body).toEqual({ error: 'Invalid username or password' });
73+
expect(secondCalled).toBe(true);
74+
});
75+
});

0 commit comments

Comments
 (0)