From 2ab01ec560584d71c6681616179aeb4e3170d757 Mon Sep 17 00:00:00 2001 From: BluemediaDev <oliver@traber-info.de> Date: Tue, 25 Mar 2025 17:30:03 +0000 Subject: [PATCH] Implement authentication logic --- backend/app/main.py | 14 +++ frontend/src/lib/axios.svelte.ts | 103 ++++++++++++++++++++ frontend/src/lib/persistent-store.ts | 13 ++- frontend/src/routes/(navbar)/+layout.svelte | 4 +- frontend/src/routes/login/+page.svelte | 64 +++++++++--- frontend/static/locales/de/login.json | 3 +- frontend/static/locales/en/login.json | 3 +- 7 files changed, 184 insertions(+), 20 deletions(-) create mode 100644 frontend/src/lib/axios.svelte.ts diff --git a/backend/app/main.py b/backend/app/main.py index b36e999..5a69345 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,6 @@ from dotenv import load_dotenv from fastapi import APIRouter, FastAPI +from fastapi.middleware.cors import CORSMiddleware from starlette.middleware.authentication import AuthenticationMiddleware load_dotenv() @@ -47,6 +48,19 @@ def create_app(): app.include_router(api_v1_router) app.mount(path="/v1/ocpp", app=create_ocpp_app()) + origins = [ + "http://localhost", + "http://localhost:5173", + ] + + app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + return app app = create_app() diff --git a/frontend/src/lib/axios.svelte.ts b/frontend/src/lib/axios.svelte.ts new file mode 100644 index 0000000..31ad377 --- /dev/null +++ b/frontend/src/lib/axios.svelte.ts @@ -0,0 +1,103 @@ +import axios from 'axios'; +import { dev } from '$app/environment'; +import { goto } from '$app/navigation'; +import { get } from 'svelte/store'; +import { persistentSettings, clearLoginState } from '$lib/persistent-store'; + +if (dev) { + axios.defaults.baseURL = "http://localhost:8000/api/v1" +} else { + axios.defaults.baseURL = "/api/v1" +} + +function createTokenRefreshInterceptor() { + const interceptor = axios.interceptors.response.use( + (response) => response, + (error) => { + // Reject promise if usual error + if (error.response.status !== 401) { + return Promise.reject(error); + } + + /* + * When response code is 401, try to refresh the token. + * Eject the interceptor so it doesn't loop in case + * token refresh causes the 401 response. + * + * Must be re-attached later on or the token refresh will only happen once + */ + axios.interceptors.response.eject(interceptor); + + return axios + .post("/auth/refresh", { + refresh_token: get(persistentSettings).refreshToken, + }) + .then((response) => { + // Save new refresh token + persistentSettings.update(settings => { + settings.refreshToken = response.data.refresh_token; + return settings; + }) + + // Update access token + const authHeader = "Bearer " + response.data.access_token; + axios.defaults.headers.common['Authorization'] = authHeader; + error.response.config.headers["Authorization"] = authHeader; + + // Retry initial request with new token + return axios(error.response.config); + }) + .catch((retryError) => { + // Retry failed, clean up and reject the promise + clearLoginState(); + axios.defaults.headers.common['Authorization'] = ""; + goto('/login?reauth') + return Promise.reject(retryError); + }) + .finally(createTokenRefreshInterceptor); // Re-attach interceptor for future requests + } + ); +} + +createTokenRefreshInterceptor(); + +export const login = async function(email: string, password: string) { + await axios + .post('/auth/login', { + email, + password, + }) + .then((response) => { + persistentSettings.update(settings => { + settings.loggedIn = true + settings.refreshToken = response.data.refresh_token + return settings; + }) + axios.defaults.headers.common['Authorization'] = 'Bearer ' + response.data.access_token + axios.get('/me').then((response) => { + persistentSettings.update(settings => { + settings.email = response.data.email + settings.friendlyName = response.data.friendly_name + settings.role = response.data.role + return settings; + }) + }) + goto('/') + return Promise.resolve(); + }) + .catch((error) => { + return Promise.reject(error); + }) +} + +export const logout = function() { + axios + .post('/auth/logout') + .then(() => { + clearLoginState(); + axios.defaults.headers.common['Authorization'] = ""; + goto('/login?logout') + }); +} + +export default axios; diff --git a/frontend/src/lib/persistent-store.ts b/frontend/src/lib/persistent-store.ts index 81b4fea..7e0c04c 100644 --- a/frontend/src/lib/persistent-store.ts +++ b/frontend/src/lib/persistent-store.ts @@ -20,4 +20,15 @@ const settingsDefault: PersistedSettings = { export const persistentSettings = writable<PersistedSettings>(JSON.parse(localStorage.getItem('persistentSettings') || JSON.stringify(settingsDefault))) -persistentSettings.subscribe((value) => localStorage.persistentSettings = JSON.stringify(value)) \ No newline at end of file +persistentSettings.subscribe((value) => localStorage.persistentSettings = JSON.stringify(value)) + +export const clearLoginState = function() { + persistentSettings.update(settings => { + settings.refreshToken = ""; + settings.loggedIn = false; + settings.friendlyName = ""; + settings.email = ""; + settings.role = "member"; + return settings; + }) +} \ No newline at end of file diff --git a/frontend/src/routes/(navbar)/+layout.svelte b/frontend/src/routes/(navbar)/+layout.svelte index 191f2f6..b58d427 100644 --- a/frontend/src/routes/(navbar)/+layout.svelte +++ b/frontend/src/routes/(navbar)/+layout.svelte @@ -2,6 +2,8 @@ import { goto } from '$app/navigation' import { persistentSettings } from '$lib/persistent-store' import i18n from '$lib/i18n' + import { logout } from '$lib/axios.svelte' + let { children } = $props() if (!$persistentSettings.loggedIn) { @@ -86,7 +88,7 @@ class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow" > <li><a href="/profile">{$i18n.t('common:navbar.link.profile')}</a></li> - <li><a href="/login?logout">{$i18n.t('common:navbar.link.logout')}</a></li> + <li><button onclick={logout}>{$i18n.t('common:navbar.link.logout')}</button></li> </ul> </div> </div> diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 038f95f..a9e3b16 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -1,18 +1,37 @@ <script lang="ts"> import { fly } from 'svelte/transition' import i18n from '$lib/i18n' + import { login as performLogin } from '$lib/axios.svelte' $i18n.loadNamespaces('login') - const urlParams = new URLSearchParams(window.location.search) - const isReauth = urlParams.has('reauth') - const isLogout = urlParams.has('logout') + type ToastType = 'reauth' | 'logout' | 'error' + let toastType: ToastType = $state('reauth') + let toastVisible = $state(false) - let showToast = $state(true) - if (isReauth || isLogout) { + function showToast(type: ToastType) { + toastType = type + toastVisible = true setTimeout(() => { - showToast = false + toastVisible = false }, 6000) } + + const urlParams = new URLSearchParams(window.location.search) + if (urlParams.has('reauth')) { + showToast('reauth') + } + if (urlParams.has('logout')) { + showToast('logout') + } + + let email: string = $state('') + let password: string = $state('') + + async function login() { + await performLogin(email, password).catch(() => { + showToast('error') + }) + } </script> <div class="w-full h-full flex flex-col justify-center items-center bg-base-200"> @@ -30,28 +49,41 @@ <fieldset class="fieldset"> <label class="fieldset-label input text-base-content"> <i class="bi bi-envelope-at"></i> - <input type="email" placeholder="me@example.com" required /> + <input type="email" bind:value={email} placeholder="me@example.com" required /> </label> <label class="fieldset-label input text-base-content"> <i class="bi bi-key"></i> - <input type="password" placeholder={$i18n.t('login:passwordPlaceholder')} required /> + <input + type="password" + bind:value={password} + placeholder={$i18n.t('login:passwordPlaceholder')} + required + /> </label> - <button class="btn btn-primary mt-4">{$i18n.t('login:button')}</button> + <button class="btn btn-primary mt-4" onclick={login}>{$i18n.t('login:button')}</button> </fieldset> </div> </div> </div> <span class="basis-0 grow"></span> - {#if (isReauth || isLogout) && showToast} + {#if toastVisible} <div class="toast toast-top" out:fly={{ x: 200 }}> - <div class="alert text-base {isReauth ? 'alert-warning' : 'alert-success'}"> - {#if isReauth} + {#if toastType === 'reauth'} + <div class="alert text-base alert-warning"> <i class="bi bi-exclamation-diamond text-xl"></i> - {:else} + <span>{$i18n.t('login:toast.reauth')}</span> + </div> + {:else if toastType === 'logout'} + <div class="alert text-base alert-success"> <i class="bi bi-check-circle text-xl"></i> - {/if} - <span>{$i18n.t(isReauth ? 'login:toast.reauth' : 'login:toast.logoutSuccess')}</span> - </div> + <span>{$i18n.t('login:toast.logoutSuccess')}</span> + </div> + {:else if toastType === 'error'} + <div class="alert text-base alert-error"> + <i class="bi bi-x-circle text-xl"></i> + <span>{$i18n.t('login:toast.loginFailed')}</span> + </div> + {/if} </div> {/if} </div> diff --git a/frontend/static/locales/de/login.json b/frontend/static/locales/de/login.json index 69f7392..f744f34 100644 --- a/frontend/static/locales/de/login.json +++ b/frontend/static/locales/de/login.json @@ -5,6 +5,7 @@ "button": "Anmelden", "toast": { "reauth": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.", - "logoutSuccess": "Abmeldung erfolgreich." + "logoutSuccess": "Abmeldung erfolgreich.", + "loginFailed": "Anmeldung fehlgeschlagen." } } diff --git a/frontend/static/locales/en/login.json b/frontend/static/locales/en/login.json index 6d600d6..10be868 100644 --- a/frontend/static/locales/en/login.json +++ b/frontend/static/locales/en/login.json @@ -5,6 +5,7 @@ "button": "Login", "toast": { "reauth": "Your session expired. Please log in again.", - "logoutSuccess": "Successfully logged out." + "logoutSuccess": "Successfully logged out.", + "loginFailed": "Login failed." } }