[go: nahoru, domu]

Skip to content

Commit

Permalink
feat(ui): added countdown for login blocked attempts
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
luannmoreira authored and gustavosbarreto committed Apr 26, 2024
1 parent 3311ce7 commit 5b9b8f3
Show file tree
Hide file tree
Showing 6 changed files with 130 additions and 18 deletions.
16 changes: 8 additions & 8 deletions ui/src/api/client/api.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 11 additions & 3 deletions ui/src/store/modules/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Module } from "vuex";
import { AxiosError } from "axios";
import * as apiAuth from "../api/auth";
import { IUserLogin, ApiKey } from "@/interfaces/IUserLogin";
import { State } from "..";
Expand Down Expand Up @@ -28,6 +29,7 @@ export interface AuthState {
keyList: Array<ApiKey>,
keyResponse: string,
numberApiKeys: number,
loginTimeout: number,
}
export const auth: Module<AuthState, State> = {
namespaced: true,
Expand Down Expand Up @@ -56,7 +58,7 @@ export const auth: Module<AuthState, State> = {
keyList: [],
keyResponse: "",
numberApiKeys: 0,

loginTimeout: 0,
},

getters: {
Expand All @@ -82,6 +84,7 @@ export const auth: Module<AuthState, State> = {
apiKey: (state) => state.keyResponse,
apiKeyList: (state) => state.keyList,
getNumberApiKeys: (state) => state.numberApiKeys,
getLoginTimeout: (state) => state.loginTimeout,
},

mutations: {
Expand Down Expand Up @@ -188,12 +191,15 @@ export const auth: Module<AuthState, State> = {
state.sortStatusString = data.sortStatusString;
state.sortStatusField = data.sortStatusField;
},

setLoginTimeout: (state, data) => {
state.loginTimeout = data;
},
},

actions: {
async login(context, user: IUserLogin) {
context.commit("authRequest");

try {
const resp = await apiAuth.login(user);

Expand All @@ -207,7 +213,9 @@ export const auth: Module<AuthState, State> = {
localStorage.setItem("role", resp.data.role || "");
localStorage.setItem("mfa", resp.data.mfa?.enable ? "true" : "false");
context.commit("authSuccess", resp.data);
} catch (error) {
} catch (error: unknown) {
const typedErr = error as AxiosError;
context.commit("setLoginTimeout", typedErr.response?.headers["x-account-lockout"]);
context.commit("authError");
throw error;
}
Expand Down
26 changes: 26 additions & 0 deletions ui/src/utils/countdownTimeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ref } from "vue";
import moment from "moment";

export default function useCountdown() {
const countdown = ref("");

let countdownInterval;

function startCountdown(loginTimeoutEpoch: number) {
clearInterval(countdownInterval);
const endTime = moment.unix(loginTimeoutEpoch); // Convert to seconds
countdownInterval = setInterval(() => {
const diff = moment.duration(endTime.diff(moment()));
if (diff.asSeconds() <= 0) {
clearInterval(countdownInterval);
countdown.value = "0 seconds";
} else if (diff.asMinutes() < 1) {
countdown.value = `${Math.floor(diff.asSeconds())} seconds`;
} else {
countdown.value = `${Math.floor(diff.asMinutes())} minutes`;
}
}, 1000);
}

return { startCountdown, countdown };
}
56 changes: 50 additions & 6 deletions ui/src/views/Login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,27 @@
<v-alert
v-model="invalidCredentials"
type="error"
:title="invalid.title + (invalid.timeout ? countdownTimer : '')"
:text="invalid.msg"
@click:close="!invalidCredentials"
closable
variant="tonal"
class="mb-4"
data-test="invalid-login-alert"
>
<strong>Invalid login credentials:</strong>
Your password is incorrect or this account doesn't exists.
</v-alert>
/>
</v-slide-y-reverse-transition>
<v-slide-y-reverse-transition>
<v-alert
v-model="isCountdownFinished"
type="success"
title="Your timeout has finished"
text="Please try to log back in."
closable
variant="tonal"
class="mb-4"
data-test="invalid-login-alert"
/>
</v-slide-y-reverse-transition>

<v-form
v-model="validForm"
@submit.prevent="login"
Expand Down Expand Up @@ -105,13 +116,14 @@
</v-container>
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from "vue";
import { onMounted, ref, computed, reactive, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import axios, { AxiosError } from "axios";
import { useStore } from "../store";
import isCloudEnvironment from "../utils/cloudUtils";
import handleError from "../utils/handleError";
import useSnackbar from "../helpers/snackbar";
import useCountdown from "@/utils/countdownTimeout";
const store = useStore();
const route = useRoute();
Expand All @@ -120,19 +132,35 @@ const snackbar = useSnackbar();
const showPassword = ref(false);
const loginToken = ref(false);
const invalid = reactive({ title: "", msg: "", timeout: false });
const username = ref("");
const password = ref("");
const rules = [(v: string) => v ? true : "This is a required field"];
const validForm = ref(false);
const cloudEnvironment = isCloudEnvironment();
const invalidCredentials = ref(false);
const isCountdownFinished = ref(false);
const isMfa = computed(() => store.getters["auth/isMfa"]);
const loginTimeout = computed(() => store.getters["auth/getLoginTimeout"]);
const { startCountdown, countdown } = useCountdown();
const countdownTimer = ref("");
watch(countdown, (newValue) => {
countdownTimer.value = newValue;
if (countdownTimer.value === "0 seconds") {
invalidCredentials.value = false;
isCountdownFinished.value = true;
}
});
onMounted(async () => {
if (!route.query.token) {
return;
}
loginToken.value = true;
await store.dispatch("stats/clear");
await store.dispatch("namespaces/clearNamespaceList");
await store.dispatch("auth/logout");
Expand All @@ -148,15 +176,31 @@ const login = async () => {
router.push(route.query.redirect ? route.query.redirect.toString() : "/");
}
} catch (error: unknown) {
isCountdownFinished.value = false;
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
switch (axiosError.response?.status) {
case 401:
invalidCredentials.value = true;
Object.assign(invalid, {
title: "Invalid login credentials",
msg: "Your password is incorrect or this account doesn't exist.",
timeout: false,
});
break;
case 403:
router.push({ name: "ConfirmAccount", query: { username: username.value } });
break;
case 429:
startCountdown(loginTimeout.value);
invalidCredentials.value = true;
Object.assign(invalid, {
title: "Your account is blocked for ",
msg: "There was too many failed login attempts. Please wait to try again.",
timeout: true,
});
break;
default:
snackbar.showError("Something went wrong in our server. Please try again later.");
handleError(error);
Expand Down
33 changes: 32 additions & 1 deletion ui/tests/views/Login.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createVuetify } from "vuetify";
import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import MockAdapter from "axios-mock-adapter";
import Login from "../../src/views/Login.vue";
import { usersApi } from "@/api/http";
Expand Down Expand Up @@ -178,4 +178,35 @@ describe("Login", () => {
query: { username: "testuser" },
});
});

it("locks account after 10 failed login attempts", async () => {
const username = "testuser";
const maxAttempts = 10;
const lockoutDuration = 7 * 24 * 60 * 60; // 7 days in seconds
let attempts = 0;

mock.onPost("http://localhost:3000/api/login").reply((config) => {
const { username: reqUsername, password } = JSON.parse(config.data);
if (reqUsername === username && password === "wrongpassword") {
attempts++;
if (attempts >= maxAttempts) {
return [429, {}, { "x-account-lockout": lockoutDuration.toString() }];
}
return [401];
}
return [200, { token: "fake-token" }];
});

// Simulate 10 failed login attempts
for (let i = 0; i < maxAttempts; i++) {
wrapper.findComponent('[data-test="username-text"]').setValue(username);
wrapper.findComponent('[data-test="password-text"]').setValue("wrongpassword");
wrapper.findComponent('[data-test="form"]').trigger("submit");
// eslint-disable-next-line no-await-in-loop
await flushPromises();
}

// Ensure the account is locked out
expect(wrapper.findComponent('[data-test="invalid-login-alert"]').exists()).toBeTruthy();
});
});
3 changes: 3 additions & 0 deletions ui/tests/views/__snapshots__/Login.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ exports[`Login > Renders the component 1`] = `
<transition-stub name=\\"slide-y-reverse-transition\\" appear=\\"false\\" persisted=\\"false\\" css=\\"true\\">
<!---->
</transition-stub>
<transition-stub name=\\"slide-y-reverse-transition\\" appear=\\"false\\" persisted=\\"false\\" css=\\"true\\">
<!---->
</transition-stub>
<form class=\\"v-form\\" novalidate=\\"\\" data-test=\\"form\\">
<div class=\\"v-col\\">
<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\\">
Expand Down

0 comments on commit 5b9b8f3

Please sign in to comment.