Implement authentication logic

This commit is contained in:
Oliver Traber 2025-03-25 17:30:03 +00:00
parent 34b12f6a44
commit be736638bd
Signed by: Bluemedia
GPG key ID: C0674B105057136C
11 changed files with 196 additions and 24 deletions

View file

@ -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()

View file

@ -0,0 +1,108 @@
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"
}
// Get access token from local storage
axios.defaults.headers.common['Authorization'] = "Bearer " + get(persistentSettings).accessToken;
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 tokens
persistentSettings.update(settings => {
settings.accessToken = response.data.access_token
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.accessToken = response.data.access_token
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;

View file

@ -3,7 +3,7 @@ import Backend from 'i18next-chained-backend'
import Fetch from 'i18next-fetch-backend'
import LocalStorageBackend from 'i18next-localstorage-backend'
import I18nextBrowserLanguageDetector from 'i18next-browser-languagedetector'
import { createI18nStore } from './i18n-store'
import { createI18nStore } from './i18n_store'
i18next
.use(Backend)

View file

@ -6,6 +6,7 @@ interface PersistedSettings {
friendlyName: string
email: string
role: string
accessToken: string,
refreshToken: string
}
@ -15,9 +16,22 @@ const settingsDefault: PersistedSettings = {
friendlyName: "",
email: "",
role: "member",
accessToken: "",
refreshToken: ""
}
export const persistentSettings = writable<PersistedSettings>(JSON.parse(localStorage.getItem('persistentSettings') || JSON.stringify(settingsDefault)))
persistentSettings.subscribe((value) => localStorage.persistentSettings = JSON.stringify(value))
export const clearLoginState = function() {
persistentSettings.update(settings => {
settings.accessToken = "";
settings.refreshToken = "";
settings.loggedIn = false;
settings.friendlyName = "";
settings.email = "";
settings.role = "member";
return settings;
})
}

View file

@ -1,7 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { persistentSettings } from '$lib/persistent-store'
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>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { persistentSettings } from '$lib/persistent-store'
import { persistentSettings } from '$lib/persistent_store'
import { currentDaytime } from '$lib/util'
import i18n from '$lib/i18n'
import DashboardCard from '$lib/component/DashboardCard.svelte'

View file

@ -1,5 +1,5 @@
<script>
import { persistentSettings } from '$lib/persistent-store'
import { persistentSettings } from '$lib/persistent_store'
let { children } = $props()
import '../app.css'
</script>

View file

@ -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>

View file

@ -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."
}
}

View file

@ -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."
}
}