| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent c393782 commit 577a8e2
7 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -235,6 +235,26 @@ Now create file `CustomLoginHeader.vue` in the `custom` folder of your project: | |||
| 235 | 235 | ||
| 236 | 236 | ||
| 237 | 237 | ||
| 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 | + | ||
| 238 | 258 | ## List view page injections shrinking: thin enough to shrink? | |
| 239 | 259 | ||
| 240 | 260 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -308,9 +308,9 @@ export const admin = new AdminForth({ | |||
| 308 | 308 | ||
| 309 | 309 | auth: { | |
| 310 | 310 | beforeLoginAttempt: [ | |
| 311 | - async ({ username, adminforth, extra }) => { | ||
| 311 | + async ({ username, adminforth, extra, tr }) => { | ||
| 312 | 312 | if (!await captchaIsValid(extra)) { | |
| 313 | - return { ok: false, error: "Captcha verification failed" }; | ||
| 313 | + return { ok: false, error: await tr("Captcha verification failed", "errors") }; | ||
| 314 | 314 | } | |
| 315 | 315 | return { ok: true }; | |
| 316 | 316 | } | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -48,7 +48,7 @@ plugins: [ | |||
| 48 | 48 | ||
| 49 | 49 | ## Preventing token reuse | |
| 50 | 50 | ||
| 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. | ||
| 52 | 52 | ||
| 53 | 53 | ```bash | |
| 54 | 54 | pnpm add @adminforth/key-value-adapter-ram | |
@@ -68,9 +68,9 @@ plugins: [ | |||
| 68 | 68 | siteKey: "YOUR_SITE_KEY", // Replace with your site key | |
| 69 | 69 | secretKey: "YOUR_SECRET_KEY", // Replace with your secret key | |
| 70 | 70 | }), | |
| 71 | - // Store used tokens to prevent them from being replayed | ||
| 71 | + // Store spent tokens to prevent them from being replayed | ||
| 72 | 72 | 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 | ||
| 74 | 74 | // as the captcha provider keeps the token valid. Defaults to 300 (5 minutes). | |
| 75 | 75 | tokenTimeToLiveSeconds: 300, | |
| 76 | 76 | }), | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -9,6 +9,7 @@ import { | |||
| 9 | 9 | AfterDataSourceResponseFunction, | |
| 10 | 10 | BeforeDataSourceRequestFunction, | |
| 11 | 11 | IAdminForthEndpointHandlerInput, | |
| 12 | + ITranslateFunction, | ||
| 12 | 13 | IAdminForthRestAPI, | |
| 13 | 14 | IAdminForthSort, | |
| 14 | 15 | HttpExtra, | |
@@ -737,11 +738,11 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { | |||
| 737 | 738 | /** | |
| 738 | 739 | * Runs beforeLoginAttempt hooks. Returns error to answer with, or null if login attempt is allowed to proceed. | |
| 739 | 740 | */ | |
| 740 | - async processBeforeLoginAttempt(username: string, extra: HttpExtra): Promise<{ error: string } | null> { | ||
| 741 | + async processBeforeLoginAttempt(username: string, extra: HttpExtra, tr: ITranslateFunction): Promise<{ error: string } | null> { | ||
| 741 | 742 | const beforeLoginAttempt = this.adminforth.config.auth.beforeLoginAttempt as (BeforeLoginAttemptFunction[] | undefined); | |
| 742 | 743 | ||
| 743 | 744 | 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'); | ||
| 745 | 746 | if (hookRespError) { | |
| 746 | 747 | return hookRespError; | |
| 747 | 748 | } | |
@@ -811,7 +812,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { | |||
| 811 | 812 | // for existing and non-existing users (e.g. captcha check should not leak valid credentials) | |
| 812 | 813 | const loginAttemptError = await this.processBeforeLoginAttempt(normalizedUsername, { | |
| 813 | 814 | body, headers, query, cookies, requestUrl, response | |
| 814 | - }); | ||
| 815 | + }, tr); | ||
| 815 | 816 | if (loginAttemptError) { | |
| 816 | 817 | response.setStatus(401); | |
| 817 | 818 | return loginAttemptError; | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -97,6 +97,7 @@ | |||
| 97 | 97 | :key="`under-inputs-${index}`" | |
| 98 | 98 | :is="getCustomComponent(formatComponent(c))" | |
| 99 | 99 | :meta="formatComponent(c).meta" | |
| 100 | + :failedLoginAttempts="failedLoginAttempts" | ||
| 100 | 101 | @update:disableLoginButton="setDisableLoginButton($event)" | |
| 101 | 102 | /> | |
| 102 | 103 | ||
@@ -117,6 +118,7 @@ | |||
| 117 | 118 | :key="`under-login-button-${index}`" | |
| 118 | 119 | :is="getCustomComponent(formatComponent(c))" | |
| 119 | 120 | :meta="formatComponent(c).meta" | |
| 121 | + :failedLoginAttempts="failedLoginAttempts" | ||
| 120 | 122 | @update:disableLoginButton="setDisableLoginButton($event)" | |
| 121 | 123 | @update:oauthRedirecting="oauthRedirecting = $event" | |
| 122 | 124 | /> | |
@@ -161,6 +163,8 @@ const showPw = ref(false); | |||
| 161 | 163 | ||
| 162 | 164 | const error = ref(null); | |
| 163 | 165 | 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); | ||
| 164 | 168 | ||
| 165 | 169 | const backgroundPosition = computed(() => { | |
| 166 | 170 | return coreStore.config?.loginBackgroundPosition || '1/2'; | |
@@ -194,6 +198,7 @@ async function login() { | |||
| 194 | 198 | }); | |
| 195 | 199 | if (resp.error) { | |
| 196 | 200 | error.value = resp.error; | |
| 201 | + failedLoginAttempts.value++; | ||
| 197 | 202 | } else if (resp.redirectTo) { | |
| 198 | 203 | error.value = null; | |
| 199 | 204 | isSuccess.value = true; | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1285,6 +1285,10 @@ export type BeforeLoginAttemptFunction = (params: { | |||
| 1285 | 1285 | * Extra HTTP information of login request. Use extra.response to set custom status or headers. | |
| 1286 | 1286 | */ | |
| 1287 | 1287 | extra: HttpExtra, | |
| 1288 | + /** | ||
| 1289 | + * Translate function, respects language of login request. | ||
| 1290 | + */ | ||
| 1291 | + tr: ITranslateFunction, | ||
| 1288 | 1292 | }) => Promise<{ ok: boolean, error?: string }>; | |
| 1289 | 1293 | ||
| 1290 | 1294 | /** | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -2,7 +2,7 @@ import { jest } from '@jest/globals'; | |||
| 2 | 2 | import request from 'supertest'; | |
| 3 | 3 | import { admin, app, closeApplication } from './authTestApp'; | |
| 4 | 4 | ||
| 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 }>; | ||
| 6 | 6 | ||
| 7 | 7 | const hooks = admin.config.auth.beforeLoginAttempt as LoginAttemptHook[]; | |
| 8 | 8 | ||
@@ -45,10 +45,10 @@ describe('auth.beforeLoginAttempt', () => { | |||
| 45 | 45 | getData.mockRestore(); | |
| 46 | 46 | }); | |
| 47 | 47 | ||
| 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') }); | ||
| 52 | 52 | return { ok: true }; | |
| 53 | 53 | }); | |
| 54 | 54 | ||
@@ -62,6 +62,7 @@ describe('auth.beforeLoginAttempt', () => { | |||
| 62 | 62 | expect(calls).toHaveLength(1); | |
| 63 | 63 | expect(calls[0].username).toEqual('adminforth'); | |
| 64 | 64 | expect(calls[0].headers['x-captcha-token']).toEqual('token'); | |
| 65 | + expect(calls[0].translated).toEqual('Captcha verification failed'); | ||
| 65 | 66 | }); | |
| 66 | 67 | ||
| 67 | 68 | it('stops on first rejecting hook and lets login pass when all hooks allow it', async () => { | |
| Back | FazBrowse Home | New Git URL |
0 commit comments