211 lines
6.8 KiB
TypeScript
211 lines
6.8 KiB
TypeScript
import { FastifyInstance } from "fastify"
|
||
import fp from "fastify-plugin"
|
||
import jwt from "jsonwebtoken"
|
||
import { secrets } from "../utils/secrets"
|
||
import { createHash } from "node:crypto"
|
||
|
||
import {
|
||
authUserRoles,
|
||
authRolePermissions,
|
||
authUsers,
|
||
m2mApiKeys,
|
||
} from "../../db/schema"
|
||
|
||
import { eq, and, inArray } from "drizzle-orm"
|
||
|
||
export default fp(async (server: FastifyInstance) => {
|
||
const hashApiKey = (apiKey: string) =>
|
||
createHash("sha256").update(apiKey, "utf8").digest("hex")
|
||
|
||
const isMcpRoute = (url: string) =>
|
||
url === "/mcp" ||
|
||
url.startsWith("/mcp/") ||
|
||
url === "/api/mcp" ||
|
||
url.startsWith("/api/mcp/")
|
||
|
||
const authenticateMcpApiKey = async (apiKey: string) => {
|
||
if (!apiKey.startsWith("fedeo_mcp_")) return false
|
||
|
||
const keyHash = hashApiKey(apiKey)
|
||
|
||
const rows = await server.db
|
||
.select({
|
||
id: m2mApiKeys.id,
|
||
tenantId: m2mApiKeys.tenantId,
|
||
userId: m2mApiKeys.userId,
|
||
active: m2mApiKeys.active,
|
||
expiresAt: m2mApiKeys.expiresAt,
|
||
userEmail: authUsers.email,
|
||
isAdmin: authUsers.is_admin,
|
||
})
|
||
.from(m2mApiKeys)
|
||
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
|
||
.where(and(
|
||
eq(m2mApiKeys.keyHash, keyHash),
|
||
eq(m2mApiKeys.active, true)
|
||
))
|
||
.limit(1)
|
||
|
||
const key = rows[0]
|
||
if (!key) return false
|
||
if (key.expiresAt && new Date(key.expiresAt).getTime() < Date.now()) return false
|
||
|
||
await server.db
|
||
.update(m2mApiKeys)
|
||
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
|
||
.where(eq(m2mApiKeys.id, key.id))
|
||
|
||
return {
|
||
user_id: key.userId,
|
||
email: key.userEmail,
|
||
tenant_id: key.tenantId,
|
||
is_admin: Boolean(key.isAdmin),
|
||
}
|
||
}
|
||
|
||
server.addHook("preHandler", async (req, reply) => {
|
||
// 1️⃣ Token aus Header oder Cookie lesen
|
||
const cookieToken = req.cookies?.token
|
||
const authHeader = req.headers.authorization
|
||
|
||
const headerToken =
|
||
authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null
|
||
|
||
const token =
|
||
headerToken && headerToken.length > 10
|
||
? headerToken
|
||
: cookieToken || null
|
||
|
||
if (!token) {
|
||
return reply.code(401).send({ error: "Authentication required" })
|
||
}
|
||
|
||
try {
|
||
// 2️⃣ JWT verifizieren oder für MCP dauerhaften Token akzeptieren
|
||
let payload: {
|
||
user_id: string
|
||
email: string
|
||
tenant_id: number | null
|
||
is_admin?: boolean
|
||
}
|
||
|
||
try {
|
||
payload = jwt.verify(token, secrets.JWT_SECRET!) as {
|
||
user_id: string
|
||
email: string
|
||
tenant_id: number | null
|
||
}
|
||
} catch (jwtError) {
|
||
const mcpPayload = isMcpRoute(req.url)
|
||
? await authenticateMcpApiKey(token)
|
||
: false
|
||
|
||
if (!mcpPayload) {
|
||
throw jwtError
|
||
}
|
||
|
||
payload = mcpPayload
|
||
;(req as any).mcpTokenAuth = true
|
||
}
|
||
|
||
if (!payload?.user_id) {
|
||
return reply.code(401).send({ error: "Invalid token" })
|
||
}
|
||
|
||
// Payload an Request hängen
|
||
req.user = payload
|
||
|
||
if (typeof payload.is_admin === "boolean") {
|
||
req.user.is_admin = payload.is_admin
|
||
} else {
|
||
const [currentUser] = await server.db
|
||
.select({
|
||
is_admin: authUsers.is_admin,
|
||
})
|
||
.from(authUsers)
|
||
.where(eq(authUsers.id, payload.user_id))
|
||
.limit(1)
|
||
|
||
req.user.is_admin = Boolean(currentUser?.is_admin)
|
||
}
|
||
|
||
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
|
||
if (!req.user.tenant_id) {
|
||
return
|
||
}
|
||
|
||
const tenantId = req.user.tenant_id
|
||
const userId = req.user.user_id
|
||
|
||
// --------------------------------------------------------
|
||
// 3️⃣ Rollen des Nutzers im Tenant holen
|
||
// --------------------------------------------------------
|
||
const roleRows = await server.db
|
||
.select({
|
||
role_id: authUserRoles.role_id,
|
||
})
|
||
.from(authUserRoles)
|
||
.where(
|
||
and(
|
||
eq(authUserRoles.user_id, userId),
|
||
eq(authUserRoles.tenant_id, tenantId)
|
||
)
|
||
)
|
||
|
||
if (roleRows.length === 0) {
|
||
if (req.user.is_admin) {
|
||
req.role = ""
|
||
req.permissions = []
|
||
req.hasPermission = () => false
|
||
return
|
||
}
|
||
|
||
return reply
|
||
.code(403)
|
||
.send({ error: "No role assigned for this tenant" })
|
||
}
|
||
|
||
const roleIds = Array.from(new Set(roleRows.map((role) => role.role_id)))
|
||
|
||
// --------------------------------------------------------
|
||
// 4️⃣ Berechtigungen der Rollen laden
|
||
// --------------------------------------------------------
|
||
const permissionRows = await server.db
|
||
.select()
|
||
.from(authRolePermissions)
|
||
.where(inArray(authRolePermissions.role_id, roleIds))
|
||
|
||
const permissions = Array.from(new Set(permissionRows.map((p) => p.permission)))
|
||
|
||
// --------------------------------------------------------
|
||
// 5️⃣ An Request hängen für spätere Nutzung
|
||
// --------------------------------------------------------
|
||
req.role = roleIds[0]
|
||
req.permissions = permissions
|
||
req.hasPermission = (perm: string) => permissions.includes(perm)
|
||
|
||
} catch (err) {
|
||
console.error("JWT verification error:", err)
|
||
return reply.code(401).send({ error: "Invalid or expired token" })
|
||
}
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fastify TypeScript Erweiterungen
|
||
// ---------------------------------------------------------------------------
|
||
|
||
declare module "fastify" {
|
||
interface FastifyRequest {
|
||
user: {
|
||
user_id: string
|
||
email: string
|
||
tenant_id: number | null
|
||
is_admin?: boolean
|
||
}
|
||
role: string
|
||
permissions: string[]
|
||
hasPermission: (permission: string) => boolean
|
||
}
|
||
}
|