From 1dc74947f444446afae69546678441ba0ecdebad Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sun, 15 Feb 2026 12:52:34 +0100 Subject: [PATCH] Fix #104 --- backend/src/routes/auth/auth-authenticated.ts | 49 ++++++ backend/src/routes/auth/auth.ts | 2 +- frontend/components/SessionRefreshModal.vue | 43 +++++ frontend/layouts/default.vue | 4 +- frontend/stores/auth.ts | 151 +++++++++++++++++- 5 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 frontend/components/SessionRefreshModal.vue diff --git a/backend/src/routes/auth/auth-authenticated.ts b/backend/src/routes/auth/auth-authenticated.ts index b7626c0..e54f01f 100644 --- a/backend/src/routes/auth/auth-authenticated.ts +++ b/backend/src/routes/auth/auth-authenticated.ts @@ -1,11 +1,60 @@ import { FastifyInstance } from "fastify" import bcrypt from "bcrypt" import { eq } from "drizzle-orm" +import jwt from "jsonwebtoken" +import { secrets } from "../../utils/secrets" import { authUsers } from "../../../db/schema" // wichtig: Drizzle Schema importieren! export default async function authRoutesAuthenticated(server: FastifyInstance) { + server.post("/auth/refresh", { + schema: { + tags: ["Auth"], + summary: "Refresh JWT for current authenticated user", + response: { + 200: { + type: "object", + properties: { + token: { type: "string" }, + }, + required: ["token"], + }, + 401: { + type: "object", + properties: { + error: { type: "string" }, + }, + required: ["error"], + }, + }, + }, + }, async (req, reply) => { + if (!req.user?.user_id) { + return reply.code(401).send({ error: "Unauthorized" }) + } + + const token = jwt.sign( + { + user_id: req.user.user_id, + email: req.user.email, + tenant_id: req.user.tenant_id, + }, + secrets.JWT_SECRET!, + { expiresIn: "6h" } + ) + + reply.setCookie("token", token, { + path: "/", + httpOnly: true, + sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 6, + }) + + return { token } + }) + server.post("/auth/password/change", { schema: { tags: ["Auth"], diff --git a/backend/src/routes/auth/auth.ts b/backend/src/routes/auth/auth.ts index dd4eb14..7d7881e 100644 --- a/backend/src/routes/auth/auth.ts +++ b/backend/src/routes/auth/auth.ts @@ -137,7 +137,7 @@ export default async function authRoutes(server: FastifyInstance) { httpOnly: true, sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", secure: process.env.NODE_ENV === "production", - maxAge: 60 * 60 * 3, + maxAge: 60 * 60 * 6, }); return { token }; diff --git a/frontend/components/SessionRefreshModal.vue b/frontend/components/SessionRefreshModal.vue new file mode 100644 index 0000000..b5b9182 --- /dev/null +++ b/frontend/components/SessionRefreshModal.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index 0d8247d..028353c 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -5,6 +5,7 @@ import GlobalMessages from "~/components/GlobalMessages.vue"; import TenantDropdown from "~/components/TenantDropdown.vue"; import LabelPrinterButton from "~/components/LabelPrinterButton.vue"; import {useCalculatorStore} from '~/stores/calculator' +import SessionRefreshModal from "~/components/SessionRefreshModal.vue"; const dataStore = useDataStore() const colorMode = useColorMode() @@ -130,6 +131,7 @@ const footerLinks = computed(() => [ \ No newline at end of file + diff --git a/frontend/stores/auth.ts b/frontend/stores/auth.ts index 308831e..05610e8 100644 --- a/frontend/stores/auth.ts +++ b/frontend/stores/auth.ts @@ -12,13 +12,129 @@ export const useAuthStore = defineStore("auth", { activeTenantData: null as any, loading: true as boolean, notLoggedIn: true, + sessionWarningVisible: false, + sessionWarningRemainingSeconds: 0, + sessionWarningLeadMs: 5 * 60 * 1000, + sessionWarningTimer: null as ReturnType | null, + sessionLogoutTimer: null as ReturnType | null, + sessionCountdownTimer: null as ReturnType | null, }), actions: { - async persist(token) { + decodeTokenExpiryMs(token: string) { + try { + const parts = token.split(".") + if (parts.length < 2) return null + + const payload = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"))) + if (!payload?.exp) return null + + return Number(payload.exp) * 1000 + } catch (err) { + console.error("Could not decode token expiry", err) + return null + } + }, + + clearSessionTimers() { + if (!process.client) return + + if (this.sessionWarningTimer) { + clearTimeout(this.sessionWarningTimer) + this.sessionWarningTimer = null + } + + if (this.sessionLogoutTimer) { + clearTimeout(this.sessionLogoutTimer) + this.sessionLogoutTimer = null + } + + if (this.sessionCountdownTimer) { + clearInterval(this.sessionCountdownTimer) + this.sessionCountdownTimer = null + } + }, + + startSessionCountdown(expiresAtMs: number) { + if (!process.client) return + + if (this.sessionCountdownTimer) { + clearInterval(this.sessionCountdownTimer) + } + + this.sessionCountdownTimer = setInterval(() => { + const remaining = Math.max(0, Math.ceil((expiresAtMs - Date.now()) / 1000)) + this.sessionWarningRemainingSeconds = remaining + + if (remaining <= 0 && this.sessionCountdownTimer) { + clearInterval(this.sessionCountdownTimer) + this.sessionCountdownTimer = null + } + }, 1000) + }, + + openSessionWarning(expiresAtMs: number) { + this.sessionWarningVisible = true + this.sessionWarningRemainingSeconds = Math.max(0, Math.ceil((expiresAtMs - Date.now()) / 1000)) + this.startSessionCountdown(expiresAtMs) + }, + + scheduleSessionTimers(token?: string | null) { + if (!process.client) return + + const tokenToUse = token || useCookie("token").value + + this.clearSessionTimers() + this.sessionWarningVisible = false + this.sessionWarningRemainingSeconds = 0 + + if (!tokenToUse) return + + const expiresAtMs = this.decodeTokenExpiryMs(tokenToUse) + if (!expiresAtMs) return + + const now = Date.now() + const msUntilLogout = expiresAtMs - now + + if (msUntilLogout <= 0) { + void this.logout() + return + } + + this.sessionLogoutTimer = setTimeout(() => { + this.sessionWarningVisible = false + void this.logout() + }, msUntilLogout) + + const msUntilWarning = msUntilLogout - this.sessionWarningLeadMs + + if (msUntilWarning <= 0) { + this.openSessionWarning(expiresAtMs) + return + } + + this.sessionWarningTimer = setTimeout(() => { + this.openSessionWarning(expiresAtMs) + }, msUntilWarning) + }, + + setToken(token: string | null) { + useCookie("token").value = token + + if (!token) { + this.clearSessionTimers() + this.sessionWarningVisible = false + this.sessionWarningRemainingSeconds = 0 + return + } + + this.scheduleSessionTimers(token) + }, + + async persist(token: string | null) { console.log("On Web") - useCookie("token").value = token // persistieren + this.setToken(token) }, @@ -74,8 +190,7 @@ export const useAuthStore = defineStore("auth", { console.log("Token: " + token) // 1. WICHTIG: Token sofort ins Cookie schreiben, damit es persistiert wird - const tokenCookie = useCookie("token") - tokenCookie.value = token + this.setToken(token) // 2. User Daten laden await this.fetchMe(token) @@ -112,19 +227,22 @@ export const useAuthStore = defineStore("auth", { this.resetState() // Token löschen - useCookie("token").value = null + this.setToken(null) // Nur beim expliziten Logout navigieren wir navigateTo("/login") }, resetState() { + this.clearSessionTimers() this.user = null this.permissions = [] this.profile = null this.activeTenant = null this.tenants = [] this.activeTenantData = null + this.sessionWarningVisible = false + this.sessionWarningRemainingSeconds = 0 }, async fetchMe(jwt= null) { @@ -162,6 +280,8 @@ export const useAuthStore = defineStore("auth", { this.activeTenantData = me.tenants.find(i => i.id === me.activeTenant) } + this.scheduleSessionTimers(tokenToUse) + console.log(this) } catch (err: any) { @@ -170,12 +290,27 @@ export const useAuthStore = defineStore("auth", { // Stattdessen nur den State sauber machen und Token löschen this.resetState() - useCookie("token").value = null + this.setToken(null) // Wir werfen den Fehler nicht weiter, damit initStore normal durchläuft } }, + async refreshSession() { + try { + const { token } = await useNuxtApp().$api("/api/auth/refresh", { + method: "POST", + }) + + this.setToken(token) + await this.fetchMe(token) + this.sessionWarningVisible = false + } catch (err) { + console.error("JWT refresh failed", err) + await this.logout() + } + }, + async switchTenant(tenant_id: string) { console.log("Auth switchTenant") this.loading = true @@ -188,7 +323,7 @@ export const useAuthStore = defineStore("auth", { const {token} = res - useCookie("token").value = token // persistieren + this.setToken(token) await this.init(token) @@ -198,4 +333,4 @@ export const useAuthStore = defineStore("auth", { return this.permissions.includes(key) } } -}) \ No newline at end of file +})