Skip to content

Commit 4d6d34b

Browse files
committed
feat: add login rate limiting
fixate genesis version
1 parent 79b4a2b commit 4d6d34b

File tree

22 files changed

+279
-148
lines changed

22 files changed

+279
-148
lines changed

.env.genesis

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ GENESIS_DATA_MAX_SIZE=512
1212
GENESIS_KEYS_PER_USER=3
1313
GENESIS_GIN_MODE=test
1414
GENESIS_LOG_MODE=development
15+
GENESIS_LOGIN_MAX_ATTEMPTS=5
16+
GENESIS_LOGIN_LOCKOUT_DURATIONS=3s,15s,1m,5m,10m,15m

.github/workflows/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ jobs:
4848
- name: Start genesis and ocular
4949
run: |
5050
docker pull ghcr.io/simonwep/genesis:latest
51-
docker run -p 8080:8080 -v "./.data:/app/.data" --env-file .env.genesis ghcr.io/simonwep/genesis:latest start &
51+
docker run -p 8080:8080 -v "./.data:/app/.data" --env-file .env.genesis ghcr.io/simonwep/genesis:v1.5.0 start &
5252
pnpm vite preview &
5353
5454
- name: Wait for genesis to be healthy

docker/Dockerfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM ghcr.io/simonwep/genesis:v1.4.2 AS genesis
1+
FROM ghcr.io/simonwep/genesis:v1.5.0 AS genesis
22

33
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend
44

@@ -43,6 +43,8 @@ ENV GENESIS_USERNAME_PATTERN='^[\w]{0,32}$'
4343
ENV GENESIS_KEY_PATTERN='^[\w]{0,32}$'
4444
ENV GENESIS_DATA_MAX_SIZE='512'
4545
ENV GENESIS_KEYS_PER_USER='2'
46+
ENV GENESIS_LOGIN_MAX_ATTEMPTS='5'
47+
ENV GENESIS_LOGIN_LOCKOUT_DURATIONS='1m,5m,15m,30m,1h'
4648

4749
COPY --from=genesis /app/genesis /usr/local/bin/genesis
4850
COPY --from=frontend /app/dist /usr/share/caddy

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"scripts": {
1313
"dev": "pnpm run \"/^dev:.+/\"",
1414
"dev:frontend": "vite --host",
15-
"dev:backend": "docker run --pull always -p 8080:8080 -v \"./.data:/app/.data\" --env-file .env.genesis ghcr.io/simonwep/genesis:latest start",
15+
"dev:backend": "docker run --pull always -p 8080:8080 -v \"./.data:/app/.data\" --env-file .env.genesis ghcr.io/simonwep/genesis:v1.5.0 start",
1616
"build": "vue-tsc --noEmit && vite build",
1717
"preview": "vite preview",
1818
"lint": "eslint \"**/*.{js,mjs,ts,mts,vue}\" --cache",

src/app/pages/navigation/admin/manage-users/ManageUsersModal.vue

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,17 @@ const removeUser = async (user: GenesisUser) => {
6060
}
6161
};
6262
63-
const fetchUsers = async () => (users.value = await getAllUsers());
63+
const fetchUsers = async () => (users.value = (await getAllUsers()).data ?? []);
6464
65-
watch([user, toRef(props, 'open')], ([user]) => user?.admin && void fetchUsers(), { immediate: true });
65+
watch(
66+
[user, toRef(props, 'open')],
67+
([user]) => {
68+
if (user?.admin) {
69+
fetchUsers();
70+
}
71+
},
72+
{ immediate: true }
73+
);
6674
</script>
6775

6876
<style lang="scss" module>

src/app/pages/navigation/auth/LoginDialog.vue

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
@close="emit('close')"
99
>
1010
<Form
11+
:disabled="!!retryDuration"
1112
:maxWidth="INSECURE_CONNECTION ? 350 : undefined"
1213
:submitIcon="RiLoginCircleLine"
1314
:submitLabel="t('navigation.auth.signIn')"
14-
@submit="signIn"
15+
@submit="executeImmediate(username, password)"
1516
>
1617
<Alert v-if="INSECURE_CONNECTION" :text="t('navigation.auth.loginNotAvailableDueToHttp')" type="warning" />
1718

@@ -31,7 +32,24 @@
3132
type="password"
3233
name="password"
3334
/>
34-
<Alert v-if="state === 'errored'" testId="login-failed" :text="t('navigation.auth.loginFailed')" type="error" />
35+
36+
<template v-if="state?.error?.status === 429">
37+
<Alert
38+
v-if="retryDuration"
39+
testId="login-too-many-attempts"
40+
:text="t('navigation.auth.tooManyFailedAttempts', { duration: retryDuration })"
41+
type="error"
42+
/>
43+
</template>
44+
45+
<Alert
46+
v-else-if="state?.error?.status === 401"
47+
testId="login-invalid-credentials"
48+
:text="t('navigation.auth.incorrectUsernameOrPassword')"
49+
type="error"
50+
/>
51+
52+
<Alert v-else-if="state?.error" :text="state?.error.message" type="error" />
3553
</Form>
3654
</Dialog>
3755
</template>
@@ -43,9 +61,11 @@ import Form from '@components/base/form/Form.vue';
4361
import TextField from '@components/base/text-field/TextField.vue';
4462
import { useStorage } from '@store/storage/useStorage.ts';
4563
import { RiLoginCircleLine } from '@remixicon/vue';
46-
import { ref } from 'vue';
64+
import { useAsyncState, useTimestamp } from '@vueuse/core';
65+
import { computed, ref, watch } from 'vue';
4766
import { useI18n } from 'vue-i18n';
4867
68+
const { OCULAR_TEST_USERNAME, OCULAR_TEST_PASSWORD } = import.meta.env;
4969
const INSECURE_CONNECTION = window.location.protocol === 'http:' && !import.meta.env.OCULAR_HYBRID_MODE;
5070
5171
const emit = defineEmits<{
@@ -57,25 +77,57 @@ defineProps<{
5777
lockDialog?: boolean;
5878
}>();
5979
60-
const { t } = useI18n();
80+
const time = useTimestamp();
81+
const { t, locale } = useI18n();
6182
const { login } = useStorage();
83+
const { state, executeImmediate } = useAsyncState(login, undefined, {
84+
immediate: false
85+
});
86+
87+
const username = ref(OCULAR_TEST_USERNAME);
88+
const password = ref(OCULAR_TEST_PASSWORD);
89+
90+
const relativeTimeFormatter = computed(() => new Intl.RelativeTimeFormat(locale.value, { numeric: 'auto' }));
91+
92+
const retryTimeLeft = computed(() => {
93+
const { status, retry_after, retry_timestamp } = state.value?.error ?? {
94+
retry_after: undefined,
95+
retry_timestamp: undefined
96+
};
97+
98+
if (status !== 429 || typeof retry_after !== 'number' || typeof retry_timestamp !== 'number') {
99+
return;
100+
}
101+
102+
return retry_after - Math.floor(time.value / 1000 - retry_timestamp);
103+
});
104+
105+
const retryDuration = computed(() => {
106+
if (!retryTimeLeft.value) return;
107+
108+
if (retryTimeLeft.value < 1) {
109+
return undefined;
110+
}
111+
112+
if (retryTimeLeft.value < 60) {
113+
return relativeTimeFormatter.value.format(Math.ceil(retryTimeLeft.value), 'second');
114+
}
115+
116+
const minutes = Math.ceil(retryTimeLeft.value / 60);
117+
return relativeTimeFormatter.value.format(minutes, 'minute');
118+
});
119+
120+
watch(retryTimeLeft, (duration) => {
121+
if (duration && duration < 1) {
122+
state.value = undefined;
123+
}
124+
});
62125
63-
const username = ref(import.meta.env.OCULAR_TEST_USERNAME ?? '');
64-
const password = ref(import.meta.env.OCULAR_TEST_PASSWORD ?? '');
65-
const state = ref<'idle' | 'loading' | 'errored'>('idle');
66-
67-
const signIn = async () => {
68-
if (state.value !== 'loading') {
69-
state.value = 'loading';
70-
71-
if (await login(username.value, password.value)) {
72-
username.value = '';
73-
password.value = '';
74-
state.value = 'idle';
75-
emit('close');
76-
} else {
77-
state.value = 'errored';
78-
}
126+
watch(state, (response) => {
127+
if (response?.data) {
128+
username.value = '';
129+
password.value = '';
130+
emit('close');
79131
}
80-
};
132+
});
81133
</script>

src/composables/time/useTime.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const useTime = createGlobalState(() => {
88

99
return {
1010
year: computed(() => date.value.getFullYear()),
11-
month: computed(() => date.value.getMonth())
11+
month: computed(() => date.value.getMonth()),
12+
timestamp
1213
};
1314
});

src/i18n/locales/cze.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@
7272
"signIn": "Přihlašte se",
7373
"username": "Uživatelské jméno",
7474
"password": "Heslo",
75-
"loginFailed": "Přihlášení se nezdařilo. Uživatelské jméno a/nebo heslo je neplatné.",
7675
"loginNotAvailable": "Přihlášení není dostupné, backend není nakonfigurován.",
77-
"loginNotAvailableDueToHttp": "Přihlášení nemusí fungovat, pokud je stránka načtena přes HTTP. Ujistěte se, že v souboru .env nastavíte GENESIS_JWT_COOKIE_ALLOW_HTTP na true, nebo raději používejte HTTPS pro přístup k aplikaci."
76+
"loginNotAvailableDueToHttp": "Přihlášení nemusí fungovat, pokud je stránka načtena přes HTTP. Ujistěte se, že v souboru .env nastavíte GENESIS_JWT_COOKIE_ALLOW_HTTP na true, nebo raději používejte HTTPS pro přístup k aplikaci.",
77+
"incorrectUsernameOrPassword": "Přihlášení se nezdařilo, uživatelské jméno a/nebo heslo jsou neplatné.",
78+
"tooManyFailedAttempts": "Příliš mnoho neúspěšných pokusů o přihlášení, zopakujte za {duration}.",
79+
"unknownError": "Něco se pokazilo, zkuste to prosím později. (Chyba: {error})"
7880
},
7981
"admin": {
8082
"settings": "Nastavení správce",

src/i18n/locales/de.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@
7272
"signIn": "Anmelden",
7373
"username": "Benutzername",
7474
"password": "Passwort",
75-
"loginFailed": "Anmeldung fehlgeschlagen, Benutzername und/oder Passwort ungültig.",
7675
"loginNotAvailable": "Login nicht verfügbar, Backend nicht konfiguriert.",
77-
"loginNotAvailableDueToHttp": "Die Anmeldung funktioniert möglicherweise nicht, wenn die Seite über HTTP aufgerufen wird. Stellen Sie sicher, dass Sie in Ihrer .env-Datei GENESIS_JWT_COOKIE_ALLOW_HTTP auf true setzen, oder noch besser – verwenden Sie HTTPS, um auf die Anwendung zuzugreifen."
76+
"loginNotAvailableDueToHttp": "Die Anmeldung funktioniert möglicherweise nicht, wenn die Seite über HTTP aufgerufen wird. Stellen Sie sicher, dass Sie in Ihrer .env-Datei GENESIS_JWT_COOKIE_ALLOW_HTTP auf true setzen, oder noch besser – verwenden Sie HTTPS, um auf die Anwendung zuzugreifen.",
77+
"incorrectUsernameOrPassword": "Anmeldung fehlgeschlagen, Benutzername und/oder Passwort ungültig.",
78+
"tooManyFailedAttempts": "Zu viele fehlgeschlagene Anmeldeversuche, bitte versuchen Sie es in {duration} erneut.",
79+
"unknownError": "Etwas ist schiefgelaufen, bitte versuchen Sie es später noch einmal. (Fehler: {error})"
7880
},
7981
"admin": {
8082
"settings": "Admin-Einstellungen",

src/i18n/locales/en.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@
7272
"signIn": "Sign in",
7373
"username": "Username",
7474
"password": "Password",
75-
"loginFailed": "Login failed, username and/or password invalid.",
7675
"loginNotAvailable": "Login not available, backend not configured.",
77-
"loginNotAvailableDueToHttp": "Login might not work if the page is accessed via HTTP. Make sure to set GENESIS_JWT_COOKIE_ALLOW_HTTP to true in your .env file or, better yet, use HTTPS to access the application."
76+
"loginNotAvailableDueToHttp": "Login might not work if the page is accessed via HTTP. Make sure to set GENESIS_JWT_COOKIE_ALLOW_HTTP to true in your .env file or, better yet, use HTTPS to access the application.",
77+
"incorrectUsernameOrPassword": "Login failed, username and/or password invalid.",
78+
"tooManyFailedAttempts": "Too many failed login attempts, retry {duration}.",
79+
"unknownError": "Something went wrong, please try again later. (Error: {error})"
7880
},
7981
"admin": {
8082
"settings": "Admin settings",

0 commit comments

Comments
 (0)