Skip to content

Commit 5b9b8f3

Browse files
luannmoreiragustavosbarreto
authored andcommitted
feat(ui): added countdown for login blocked attempts
This commit adds an countdown utilitary for the new changes on the login process that can block the user from logging in after failed attempts.
1 parent 3311ce7 commit 5b9b8f3

File tree

6 files changed

+130
-18
lines changed

6 files changed

+130
-18
lines changed

ui/src/api/client/api.ts

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/src/store/modules/auth.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Module } from "vuex";
2+
import { AxiosError } from "axios";
23
import * as apiAuth from "../api/auth";
34
import { IUserLogin, ApiKey } from "@/interfaces/IUserLogin";
45
import { State } from "..";
@@ -28,6 +29,7 @@ export interface AuthState {
2829
keyList: Array<ApiKey>,
2930
keyResponse: string,
3031
numberApiKeys: number,
32+
loginTimeout: number,
3133
}
3234
export const auth: Module<AuthState, State> = {
3335
namespaced: true,
@@ -56,7 +58,7 @@ export const auth: Module<AuthState, State> = {
5658
keyList: [],
5759
keyResponse: "",
5860
numberApiKeys: 0,
59-
61+
loginTimeout: 0,
6062
},
6163

6264
getters: {
@@ -82,6 +84,7 @@ export const auth: Module<AuthState, State> = {
8284
apiKey: (state) => state.keyResponse,
8385
apiKeyList: (state) => state.keyList,
8486
getNumberApiKeys: (state) => state.numberApiKeys,
87+
getLoginTimeout: (state) => state.loginTimeout,
8588
},
8689

8790
mutations: {
@@ -188,12 +191,15 @@ export const auth: Module<AuthState, State> = {
188191
state.sortStatusString = data.sortStatusString;
189192
state.sortStatusField = data.sortStatusField;
190193
},
194+
195+
setLoginTimeout: (state, data) => {
196+
state.loginTimeout = data;
197+
},
191198
},
192199

193200
actions: {
194201
async login(context, user: IUserLogin) {
195202
context.commit("authRequest");
196-
197203
try {
198204
const resp = await apiAuth.login(user);
199205

@@ -207,7 +213,9 @@ export const auth: Module<AuthState, State> = {
207213
localStorage.setItem("role", resp.data.role || "");
208214
localStorage.setItem("mfa", resp.data.mfa?.enable ? "true" : "false");
209215
context.commit("authSuccess", resp.data);
210-
} catch (error) {
216+
} catch (error: unknown) {
217+
const typedErr = error as AxiosError;
218+
context.commit("setLoginTimeout", typedErr.response?.headers["x-account-lockout"]);
211219
context.commit("authError");
212220
throw error;
213221
}

ui/src/utils/countdownTimeout.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { ref } from "vue";
2+
import moment from "moment";
3+
4+
export default function useCountdown() {
5+
const countdown = ref("");
6+
7+
let countdownInterval;
8+
9+
function startCountdown(loginTimeoutEpoch: number) {
10+
clearInterval(countdownInterval);
11+
const endTime = moment.unix(loginTimeoutEpoch); // Convert to seconds
12+
countdownInterval = setInterval(() => {
13+
const diff = moment.duration(endTime.diff(moment()));
14+
if (diff.asSeconds() <= 0) {
15+
clearInterval(countdownInterval);
16+
countdown.value = "0 seconds";
17+
} else if (diff.asMinutes() < 1) {
18+
countdown.value = `${Math.floor(diff.asSeconds())} seconds`;
19+
} else {
20+
countdown.value = `${Math.floor(diff.asMinutes())} minutes`;
21+
}
22+
}, 1000);
23+
}
24+
25+
return { startCountdown, countdown };
26+
}

ui/src/views/Login.vue

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,27 @@
1919
<v-alert
2020
v-model="invalidCredentials"
2121
type="error"
22+
:title="invalid.title + (invalid.timeout ? countdownTimer : '')"
23+
:text="invalid.msg"
24+
@click:close="!invalidCredentials"
2225
closable
2326
variant="tonal"
2427
class="mb-4"
2528
data-test="invalid-login-alert"
26-
>
27-
<strong>Invalid login credentials:</strong>
28-
Your password is incorrect or this account doesn't exists.
29-
</v-alert>
29+
/>
30+
</v-slide-y-reverse-transition>
31+
<v-slide-y-reverse-transition>
32+
<v-alert
33+
v-model="isCountdownFinished"
34+
type="success"
35+
title="Your timeout has finished"
36+
text="Please try to log back in."
37+
closable
38+
variant="tonal"
39+
class="mb-4"
40+
data-test="invalid-login-alert"
41+
/>
3042
</v-slide-y-reverse-transition>
31-
3243
<v-form
3344
v-model="validForm"
3445
@submit.prevent="login"
@@ -105,13 +116,14 @@
105116
</v-container>
106117
</template>
107118
<script setup lang="ts">
108-
import { onMounted, ref, computed } from "vue";
119+
import { onMounted, ref, computed, reactive, watch } from "vue";
109120
import { useRoute, useRouter } from "vue-router";
110121
import axios, { AxiosError } from "axios";
111122
import { useStore } from "../store";
112123
import isCloudEnvironment from "../utils/cloudUtils";
113124
import handleError from "../utils/handleError";
114125
import useSnackbar from "../helpers/snackbar";
126+
import useCountdown from "@/utils/countdownTimeout";
115127
116128
const store = useStore();
117129
const route = useRoute();
@@ -120,19 +132,35 @@ const snackbar = useSnackbar();
120132
121133
const showPassword = ref(false);
122134
const loginToken = ref(false);
135+
const invalid = reactive({ title: "", msg: "", timeout: false });
123136
const username = ref("");
124137
const password = ref("");
125138
const rules = [(v: string) => v ? true : "This is a required field"];
126139
const validForm = ref(false);
127140
const cloudEnvironment = isCloudEnvironment();
128141
const invalidCredentials = ref(false);
142+
const isCountdownFinished = ref(false);
129143
const isMfa = computed(() => store.getters["auth/isMfa"]);
144+
const loginTimeout = computed(() => store.getters["auth/getLoginTimeout"]);
145+
146+
const { startCountdown, countdown } = useCountdown();
147+
148+
const countdownTimer = ref("");
149+
150+
watch(countdown, (newValue) => {
151+
countdownTimer.value = newValue;
152+
if (countdownTimer.value === "0 seconds") {
153+
invalidCredentials.value = false;
154+
isCountdownFinished.value = true;
155+
}
156+
});
130157
131158
onMounted(async () => {
132159
if (!route.query.token) {
133160
return;
134161
}
135162
loginToken.value = true;
163+
136164
await store.dispatch("stats/clear");
137165
await store.dispatch("namespaces/clearNamespaceList");
138166
await store.dispatch("auth/logout");
@@ -148,15 +176,31 @@ const login = async () => {
148176
router.push(route.query.redirect ? route.query.redirect.toString() : "/");
149177
}
150178
} catch (error: unknown) {
179+
isCountdownFinished.value = false;
151180
if (axios.isAxiosError(error)) {
152181
const axiosError = error as AxiosError;
153182
switch (axiosError.response?.status) {
154183
case 401:
155184
invalidCredentials.value = true;
185+
Object.assign(invalid, {
186+
title: "Invalid login credentials",
187+
msg: "Your password is incorrect or this account doesn't exist.",
188+
timeout: false,
189+
});
156190
break;
157191
case 403:
158192
router.push({ name: "ConfirmAccount", query: { username: username.value } });
159193
break;
194+
case 429:
195+
startCountdown(loginTimeout.value);
196+
invalidCredentials.value = true;
197+
Object.assign(invalid, {
198+
title: "Your account is blocked for ",
199+
msg: "There was too many failed login attempts. Please wait to try again.",
200+
timeout: true,
201+
});
202+
break;
203+
160204
default:
161205
snackbar.showError("Something went wrong in our server. Please try again later.");
162206
handleError(error);

ui/tests/views/Login.spec.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createVuetify } from "vuetify";
22
import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
3-
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import MockAdapter from "axios-mock-adapter";
55
import Login from "../../src/views/Login.vue";
66
import { usersApi } from "@/api/http";
@@ -178,4 +178,35 @@ describe("Login", () => {
178178
query: { username: "testuser" },
179179
});
180180
});
181+
182+
it("locks account after 10 failed login attempts", async () => {
183+
const username = "testuser";
184+
const maxAttempts = 10;
185+
const lockoutDuration = 7 * 24 * 60 * 60; // 7 days in seconds
186+
let attempts = 0;
187+
188+
mock.onPost("http://localhost:3000/api/login").reply((config) => {
189+
const { username: reqUsername, password } = JSON.parse(config.data);
190+
if (reqUsername === username && password === "wrongpassword") {
191+
attempts++;
192+
if (attempts >= maxAttempts) {
193+
return [429, {}, { "x-account-lockout": lockoutDuration.toString() }];
194+
}
195+
return [401];
196+
}
197+
return [200, { token: "fake-token" }];
198+
});
199+
200+
// Simulate 10 failed login attempts
201+
for (let i = 0; i < maxAttempts; i++) {
202+
wrapper.findComponent('[data-test="username-text"]').setValue(username);
203+
wrapper.findComponent('[data-test="password-text"]').setValue("wrongpassword");
204+
wrapper.findComponent('[data-test="form"]').trigger("submit");
205+
// eslint-disable-next-line no-await-in-loop
206+
await flushPromises();
207+
}
208+
209+
// Ensure the account is locked out
210+
expect(wrapper.findComponent('[data-test="invalid-login-alert"]').exists()).toBeTruthy();
211+
});
181212
});

ui/tests/views/__snapshots__/Login.spec.ts.snap

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ exports[`Login > Renders the component 1`] = `
66
<transition-stub name=\\"slide-y-reverse-transition\\" appear=\\"false\\" persisted=\\"false\\" css=\\"true\\">
77
<!---->
88
</transition-stub>
9+
<transition-stub name=\\"slide-y-reverse-transition\\" appear=\\"false\\" persisted=\\"false\\" css=\\"true\\">
10+
<!---->
11+
</transition-stub>
912
<form class=\\"v-form\\" novalidate=\\"\\" data-test=\\"form\\">
1013
<div class=\\"v-col\\">
1114
<div class=\\"v-input v-input--horizontal v-input--density-default v-locale--is-ltr v-text-field v-input--plain-underlined\\" data-test=\\"username-text\\">

0 commit comments

Comments
 (0)