Implement authentication logic
This commit is contained in:
parent
34b12f6a44
commit
be736638bd
11 changed files with 196 additions and 24 deletions
|
@ -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()
|
||||||
|
|
108
frontend/src/lib/axios.svelte.ts
Normal file
108
frontend/src/lib/axios.svelte.ts
Normal 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;
|
|
@ -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)
|
||||||
|
|
|
@ -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;
|
||||||
|
})
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue