Fix #104
This commit is contained in:
@@ -1,11 +1,60 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import bcrypt from "bcrypt"
|
import bcrypt from "bcrypt"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
|
import jwt from "jsonwebtoken"
|
||||||
|
import { secrets } from "../../utils/secrets"
|
||||||
|
|
||||||
import { authUsers } from "../../../db/schema" // wichtig: Drizzle Schema importieren!
|
import { authUsers } from "../../../db/schema" // wichtig: Drizzle Schema importieren!
|
||||||
|
|
||||||
export default async function authRoutesAuthenticated(server: FastifyInstance) {
|
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", {
|
server.post("/auth/password/change", {
|
||||||
schema: {
|
schema: {
|
||||||
tags: ["Auth"],
|
tags: ["Auth"],
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export default async function authRoutes(server: FastifyInstance) {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
maxAge: 60 * 60 * 3,
|
maxAge: 60 * 60 * 6,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { token };
|
return { token };
|
||||||
|
|||||||
43
frontend/components/SessionRefreshModal.vue
Normal file
43
frontend/components/SessionRefreshModal.vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const remainingMinutes = computed(() => Math.floor(auth.sessionWarningRemainingSeconds / 60))
|
||||||
|
const remainingSeconds = computed(() => auth.sessionWarningRemainingSeconds % 60)
|
||||||
|
const remainingTimeLabel = computed(() => `${remainingMinutes.value}:${String(remainingSeconds.value).padStart(2, "0")}`)
|
||||||
|
|
||||||
|
const onRefresh = async () => {
|
||||||
|
await auth.refreshSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLogout = async () => {
|
||||||
|
auth.sessionWarningVisible = false
|
||||||
|
await auth.logout()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal v-model="auth.sessionWarningVisible" prevent-close>
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<h3 class="text-lg font-semibold">Sitzung läuft bald ab</h3>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Deine Sitzung endet in
|
||||||
|
<span class="font-semibold">{{ remainingTimeLabel }}</span>.
|
||||||
|
Bitte bestätige, um eingeloggt zu bleiben.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<UButton variant="outline" color="gray" @click="onLogout">
|
||||||
|
Abmelden
|
||||||
|
</UButton>
|
||||||
|
<UButton color="primary" @click="onRefresh">
|
||||||
|
Eingeloggt bleiben
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
@@ -5,6 +5,7 @@ import GlobalMessages from "~/components/GlobalMessages.vue";
|
|||||||
import TenantDropdown from "~/components/TenantDropdown.vue";
|
import TenantDropdown from "~/components/TenantDropdown.vue";
|
||||||
import LabelPrinterButton from "~/components/LabelPrinterButton.vue";
|
import LabelPrinterButton from "~/components/LabelPrinterButton.vue";
|
||||||
import {useCalculatorStore} from '~/stores/calculator'
|
import {useCalculatorStore} from '~/stores/calculator'
|
||||||
|
import SessionRefreshModal from "~/components/SessionRefreshModal.vue";
|
||||||
|
|
||||||
const dataStore = useDataStore()
|
const dataStore = useDataStore()
|
||||||
const colorMode = useColorMode()
|
const colorMode = useColorMode()
|
||||||
@@ -130,6 +131,7 @@ const footerLinks = computed(() => [
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="!auth.loading">
|
<div v-if="!auth.loading">
|
||||||
|
<SessionRefreshModal />
|
||||||
<div v-if="auth.activeTenantData?.locked === 'maintenance_tenant'">
|
<div v-if="auth.activeTenantData?.locked === 'maintenance_tenant'">
|
||||||
<UContainer class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
<UContainer class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||||
<UCard class="max-w-lg text-center p-10">
|
<UCard class="max-w-lg text-center p-10">
|
||||||
@@ -315,4 +317,4 @@ const footerLinks = computed(() => [
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -12,13 +12,129 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
activeTenantData: null as any,
|
activeTenantData: null as any,
|
||||||
loading: true as boolean,
|
loading: true as boolean,
|
||||||
notLoggedIn: true,
|
notLoggedIn: true,
|
||||||
|
sessionWarningVisible: false,
|
||||||
|
sessionWarningRemainingSeconds: 0,
|
||||||
|
sessionWarningLeadMs: 5 * 60 * 1000,
|
||||||
|
sessionWarningTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
|
sessionLogoutTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
|
sessionCountdownTimer: null as ReturnType<typeof setInterval> | null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
actions: {
|
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")
|
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)
|
console.log("Token: " + token)
|
||||||
|
|
||||||
// 1. WICHTIG: Token sofort ins Cookie schreiben, damit es persistiert wird
|
// 1. WICHTIG: Token sofort ins Cookie schreiben, damit es persistiert wird
|
||||||
const tokenCookie = useCookie("token")
|
this.setToken(token)
|
||||||
tokenCookie.value = token
|
|
||||||
|
|
||||||
// 2. User Daten laden
|
// 2. User Daten laden
|
||||||
await this.fetchMe(token)
|
await this.fetchMe(token)
|
||||||
@@ -112,19 +227,22 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
this.resetState()
|
this.resetState()
|
||||||
|
|
||||||
// Token löschen
|
// Token löschen
|
||||||
useCookie("token").value = null
|
this.setToken(null)
|
||||||
|
|
||||||
// Nur beim expliziten Logout navigieren wir
|
// Nur beim expliziten Logout navigieren wir
|
||||||
navigateTo("/login")
|
navigateTo("/login")
|
||||||
},
|
},
|
||||||
|
|
||||||
resetState() {
|
resetState() {
|
||||||
|
this.clearSessionTimers()
|
||||||
this.user = null
|
this.user = null
|
||||||
this.permissions = []
|
this.permissions = []
|
||||||
this.profile = null
|
this.profile = null
|
||||||
this.activeTenant = null
|
this.activeTenant = null
|
||||||
this.tenants = []
|
this.tenants = []
|
||||||
this.activeTenantData = null
|
this.activeTenantData = null
|
||||||
|
this.sessionWarningVisible = false
|
||||||
|
this.sessionWarningRemainingSeconds = 0
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchMe(jwt= null) {
|
async fetchMe(jwt= null) {
|
||||||
@@ -162,6 +280,8 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
this.activeTenantData = me.tenants.find(i => i.id === me.activeTenant)
|
this.activeTenantData = me.tenants.find(i => i.id === me.activeTenant)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.scheduleSessionTimers(tokenToUse)
|
||||||
|
|
||||||
console.log(this)
|
console.log(this)
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -170,12 +290,27 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
|
|
||||||
// Stattdessen nur den State sauber machen und Token löschen
|
// Stattdessen nur den State sauber machen und Token löschen
|
||||||
this.resetState()
|
this.resetState()
|
||||||
useCookie("token").value = null
|
this.setToken(null)
|
||||||
|
|
||||||
// Wir werfen den Fehler nicht weiter, damit initStore normal durchläuft
|
// 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) {
|
async switchTenant(tenant_id: string) {
|
||||||
console.log("Auth switchTenant")
|
console.log("Auth switchTenant")
|
||||||
this.loading = true
|
this.loading = true
|
||||||
@@ -188,7 +323,7 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
const {token} = res
|
const {token} = res
|
||||||
|
|
||||||
|
|
||||||
useCookie("token").value = token // persistieren
|
this.setToken(token)
|
||||||
|
|
||||||
|
|
||||||
await this.init(token)
|
await this.init(token)
|
||||||
@@ -198,4 +333,4 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
return this.permissions.includes(key)
|
return this.permissions.includes(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user