Skip to content

Commit 577a8e2

Browse files
committed
feat: enhance login attempt handling with translation support and failed attempts tracking
AdminForth/1817/login-captcha-plugin-does-not-
1 parent c393782 commit 577a8e2

7 files changed

Lines changed: 44 additions & 13 deletions

File tree

‎adminforth/documentation/docs/tutorial/03-Customization/08-pageInjections.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,26 @@ Now create file `CustomLoginHeader.vue` in the `custom` folder of your project:
235235
236236
237237
238+
### Reacting to rejected login attempts
239+
240+
Components injected into `underInputs` and `underLoginButton` get a `failedLoginAttempts` prop, which is incremented every time
241+
the login endpoint answers with an error. Use it when your injection has to be refreshed for the next attempt, e.g. a captcha widget
242+
has to issue a new token. Such injections can also disable the login button until they are ready, by emitting `update:disableLoginButton`:
243+
244+
```html title="./custom/CustomLoginFooter.vue"
245+
<script setup lang="ts">
246+
import { watch } from 'vue';
247+
248+
const props = defineProps<{ failedLoginAttempts?: number }>();
249+
const emit = defineEmits(['update:disableLoginButton']);
250+
251+
watch(() => props.failedLoginAttempts, () => {
252+
emit('update:disableLoginButton', true);
253+
// prepare the injection for the next attempt, then enable the button back
254+
});
255+
</script>
256+
```
257+
238258
## List view page injections shrinking: thin enough to shrink?
239259
240260

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,9 +308,9 @@ export const admin = new AdminForth({
308308

309309
auth: {
310310
beforeLoginAttempt: [
311-
async ({ username, adminforth, extra }) => {
311+
async ({ username, adminforth, extra, tr }) => {
312312
if (!await captchaIsValid(extra)) {
313-
return { ok: false, error: "Captcha verification failed" };
313+
return { ok: false, error: await tr("Captcha verification failed", "errors") };
314314
}
315315
return { ok: true };
316316
}

‎adminforth/documentation/docs/tutorial/09-Plugins/19-login-captcha.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ plugins: [
4848

4949
## Preventing token reuse
5050

51-
A captcha token stays valid at the provider for a short period, which means the same token could be replayed for several logins. To prevent this, pass an optional [key-value adapter](/docs/tutorial/Adapters/key-value-adapters) via `keyValueAdapter`. Each token is recorded once it has been used for a successful login, and any later attempt with the same token is rejected.
51+
A captcha token stays valid at the provider for a short period, which means the same token could be replayed for several login attempts. To prevent this, pass an optional [key-value adapter](/docs/tutorial/Adapters/key-value-adapters) via `keyValueAdapter`. Each token is recorded once it has been used for a login attempt, and any later attempt with the same token is rejected.
5252

5353
```bash
5454
pnpm add @adminforth/key-value-adapter-ram
@@ -68,9 +68,9 @@ plugins: [
6868
siteKey: "YOUR_SITE_KEY", // Replace with your site key
6969
secretKey: "YOUR_SECRET_KEY", // Replace with your secret key
7070
}),
71-
// Store used tokens to prevent them from being replayed
71+
// Store spent tokens to prevent them from being replayed
7272
keyValueAdapter: new RamKeyValueAdapter(),
73-
// Optional: how long (in seconds) a used token is remembered. Should be at least as long
73+
// Optional: how long (in seconds) a spent token is remembered. Should be at least as long
7474
// as the captcha provider keeps the token valid. Defaults to 300 (5 minutes).
7575
tokenTimeToLiveSeconds: 300,
7676
}),

‎adminforth/modules/restApi.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
AfterDataSourceResponseFunction,
1010
BeforeDataSourceRequestFunction,
1111
IAdminForthEndpointHandlerInput,
12+
ITranslateFunction,
1213
IAdminForthRestAPI,
1314
IAdminForthSort,
1415
HttpExtra,
@@ -737,11 +738,11 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
737738
/**
738739
* Runs beforeLoginAttempt hooks. Returns error to answer with, or null if login attempt is allowed to proceed.
739740
*/
740-
async processBeforeLoginAttempt(username: string, extra: HttpExtra): Promise<{ error: string } | null> {
741+
async processBeforeLoginAttempt(username: string, extra: HttpExtra, tr: ITranslateFunction): Promise<{ error: string } | null> {
741742
const beforeLoginAttempt = this.adminforth.config.auth.beforeLoginAttempt as (BeforeLoginAttemptFunction[] | undefined);
742743

743744
for (const hook of listify(beforeLoginAttempt)) {
744-
const hookRespError = hookResponseError(await hook({ username, adminforth: this.adminforth, extra }), 'beforeLoginAttempt');
745+
const hookRespError = hookResponseError(await hook({ username, adminforth: this.adminforth, extra, tr }), 'beforeLoginAttempt');
745746
if (hookRespError) {
746747
return hookRespError;
747748
}
@@ -811,7 +812,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
811812
// for existing and non-existing users (e.g. captcha check should not leak valid credentials)
812813
const loginAttemptError = await this.processBeforeLoginAttempt(normalizedUsername, {
813814
body, headers, query, cookies, requestUrl, response
814-
});
815+
}, tr);
815816
if (loginAttemptError) {
816817
response.setStatus(401);
817818
return loginAttemptError;

‎adminforth/spa/src/views/LoginView.vue‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
:key="`under-inputs-${index}`"
9898
:is="getCustomComponent(formatComponent(c))"
9999
:meta="formatComponent(c).meta"
100+
:failedLoginAttempts="failedLoginAttempts"
100101
@update:disableLoginButton="setDisableLoginButton($event)"
101102
/>
102103

@@ -117,6 +118,7 @@
117118
:key="`under-login-button-${index}`"
118119
:is="getCustomComponent(formatComponent(c))"
119120
:meta="formatComponent(c).meta"
121+
:failedLoginAttempts="failedLoginAttempts"
120122
@update:disableLoginButton="setDisableLoginButton($event)"
121123
@update:oauthRedirecting="oauthRedirecting = $event"
122124
/>
@@ -161,6 +163,8 @@ const showPw = ref(false);
161163
162164
const error = ref(null);
163165
const disableLoginButton = ref(false);
166+
// login page injections (e.g. captcha widget) use it to know that attempt was rejected and they have to refresh themselves
167+
const failedLoginAttempts = ref(0);
164168
165169
const backgroundPosition = computed(() => {
166170
return coreStore.config?.loginBackgroundPosition || '1/2';
@@ -194,6 +198,7 @@ async function login() {
194198
});
195199
if (resp.error) {
196200
error.value = resp.error;
201+
failedLoginAttempts.value++;
197202
} else if (resp.redirectTo) {
198203
error.value = null;
199204
isSuccess.value = true;

‎adminforth/types/Back.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,6 +1285,10 @@ export type BeforeLoginAttemptFunction = (params: {
12851285
* Extra HTTP information of login request. Use extra.response to set custom status or headers.
12861286
*/
12871287
extra: HttpExtra,
1288+
/**
1289+
* Translate function, respects language of login request.
1290+
*/
1291+
tr: ITranslateFunction,
12881292
}) => Promise<{ ok: boolean, error?: string }>;
12891293

12901294
/**

‎tests/jest_tests/login_attempt_hook.test.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { jest } from '@jest/globals';
22
import request from 'supertest';
33
import { admin, app, closeApplication } from './authTestApp';
44

5-
type LoginAttemptHook = (params: { username: string, adminforth: any, extra: any }) => Promise<{ ok: boolean, error?: string }>;
5+
type LoginAttemptHook = (params: { username: string, adminforth: any, extra: any, tr: any }) => Promise<{ ok: boolean, error?: string }>;
66

77
const hooks = admin.config.auth.beforeLoginAttempt as LoginAttemptHook[];
88

@@ -45,10 +45,10 @@ describe('auth.beforeLoginAttempt', () => {
4545
getData.mockRestore();
4646
});
4747

48-
it('receives normalized username and request data', async () => {
49-
const calls: { username: string, headers: Record<string, string> }[] = [];
50-
hooks.push(async ({ username, extra }) => {
51-
calls.push({ username, headers: extra.headers });
48+
it('receives normalized username, request data and translate function', async () => {
49+
const calls: { username: string, headers: Record<string, string>, translated: string }[] = [];
50+
hooks.push(async ({ username, extra, tr }) => {
51+
calls.push({ username, headers: extra.headers, translated: await tr('Captcha verification failed', 'errors') });
5252
return { ok: true };
5353
});
5454

@@ -62,6 +62,7 @@ describe('auth.beforeLoginAttempt', () => {
6262
expect(calls).toHaveLength(1);
6363
expect(calls[0].username).toEqual('adminforth');
6464
expect(calls[0].headers['x-captcha-token']).toEqual('token');
65+
expect(calls[0].translated).toEqual('Captcha verification failed');
6566
});
6667

6768
it('stops on first rejecting hook and lets login pass when all hooks allow it', async () => {

0 commit comments

Comments
 (0)