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 dotenv import load_dotenv
from fastapi import APIRouter, FastAPI from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.authentication import AuthenticationMiddleware from starlette.middleware.authentication import AuthenticationMiddleware
load_dotenv() load_dotenv()
@ -47,6 +48,19 @@ def create_app():
app.include_router(api_v1_router) app.include_router(api_v1_router)
app.mount(path="/v1/ocpp", app=create_ocpp_app()) 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 return app
app = create_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 Fetch from 'i18next-fetch-backend'
import LocalStorageBackend from 'i18next-localstorage-backend' import LocalStorageBackend from 'i18next-localstorage-backend'
import I18nextBrowserLanguageDetector from 'i18next-browser-languagedetector' import I18nextBrowserLanguageDetector from 'i18next-browser-languagedetector'
import { createI18nStore } from './i18n-store' import { createI18nStore } from './i18n_store'
i18next i18next
.use(Backend) .use(Backend)

View file

@ -6,6 +6,7 @@ interface PersistedSettings {
friendlyName: string friendlyName: string
email: string email: string
role: string role: string
accessToken: string,
refreshToken: string refreshToken: string
} }
@ -15,9 +16,22 @@ const settingsDefault: PersistedSettings = {
friendlyName: "", friendlyName: "",
email: "", email: "",
role: "member", role: "member",
accessToken: "",
refreshToken: "" refreshToken: ""
} }
export const persistentSettings = writable<PersistedSettings>(JSON.parse(localStorage.getItem('persistentSettings') || JSON.stringify(settingsDefault))) export const persistentSettings = writable<PersistedSettings>(JSON.parse(localStorage.getItem('persistentSettings') || JSON.stringify(settingsDefault)))
persistentSettings.subscribe((value) => localStorage.persistentSettings = JSON.stringify(value)) 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"> <script lang="ts">
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { persistentSettings } from '$lib/persistent-store' import { persistentSettings } from '$lib/persistent_store'
import i18n from '$lib/i18n' import i18n from '$lib/i18n'
import { logout } from '$lib/axios.svelte'
let { children } = $props() let { children } = $props()
if (!$persistentSettings.loggedIn) { 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" 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="/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> </ul>
</div> </div>
</div> </div>

View file

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

View file

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

View file

@ -1,18 +1,37 @@
<script lang="ts"> <script lang="ts">
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition'
import i18n from '$lib/i18n' import i18n from '$lib/i18n'
import { login as performLogin } from '$lib/axios.svelte'
$i18n.loadNamespaces('login') $i18n.loadNamespaces('login')
const urlParams = new URLSearchParams(window.location.search) type ToastType = 'reauth' | 'logout' | 'error'
const isReauth = urlParams.has('reauth') let toastType: ToastType = $state('reauth')
const isLogout = urlParams.has('logout') let toastVisible = $state(false)
let showToast = $state(true) function showToast(type: ToastType) {
if (isReauth || isLogout) { toastType = type
toastVisible = true
setTimeout(() => { setTimeout(() => {
showToast = false toastVisible = false
}, 6000) }, 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> </script>
<div class="w-full h-full flex flex-col justify-center items-center bg-base-200"> <div class="w-full h-full flex flex-col justify-center items-center bg-base-200">
@ -30,28 +49,41 @@
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="fieldset-label input text-base-content"> <label class="fieldset-label input text-base-content">
<i class="bi bi-envelope-at"></i> <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>
<label class="fieldset-label input text-base-content"> <label class="fieldset-label input text-base-content">
<i class="bi bi-key"></i> <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> </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> </fieldset>
</div> </div>
</div> </div>
</div> </div>
<span class="basis-0 grow"></span> <span class="basis-0 grow"></span>
{#if (isReauth || isLogout) && showToast} {#if toastVisible}
<div class="toast toast-top" out:fly={{ x: 200 }}> <div class="toast toast-top" out:fly={{ x: 200 }}>
<div class="alert text-base {isReauth ? 'alert-warning' : 'alert-success'}"> {#if toastType === 'reauth'}
{#if isReauth} <div class="alert text-base alert-warning">
<i class="bi bi-exclamation-diamond text-xl"></i> <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> <i class="bi bi-check-circle text-xl"></i>
{/if} <span>{$i18n.t('login:toast.logoutSuccess')}</span>
<span>{$i18n.t(isReauth ? 'login:toast.reauth' : 'login:toast.logoutSuccess')}</span> </div>
</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> </div>
{/if} {/if}
</div> </div>

View file

@ -5,6 +5,7 @@
"button": "Anmelden", "button": "Anmelden",
"toast": { "toast": {
"reauth": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.", "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", "button": "Login",
"toast": { "toast": {
"reauth": "Your session expired. Please log in again.", "reauth": "Your session expired. Please log in again.",
"logoutSuccess": "Successfully logged out." "logoutSuccess": "Successfully logged out.",
"loginFailed": "Login failed."
} }
} }