Files
FEDEO/backend/src/plugins/auth.ts
flfeders 4ffafef3f1
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m5s
Build and Release FEDEO / build-and-release (push) Successful in 6m53s
feat(admin): archive users and their profiles permanently
2026-10-15 14:22:12 +02:00

254 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
tenants,
} from "../../db/schema"
import { eq, and, inArray, isNull } 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 isWriteMethod = (method: string) =>
["POST", "PUT", "PATCH", "DELETE"].includes(method)
const isTenantLockAllowedRoute = (urlPath: string) =>
urlPath === "/api/auth/me" ||
urlPath === "/auth/me" ||
urlPath === "/api/tenant/switch" ||
urlPath === "/tenant/switch" ||
urlPath.startsWith("/api/admin/") ||
urlPath.startsWith("/admin/") ||
urlPath.startsWith("/api/auth/") ||
urlPath.startsWith("/auth/")
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),
isNull(authUsers.archived_at)
))
.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) => {
if (req.method === "OPTIONS") {
return
}
const urlPath = req.url.split("?")[0]
const queryToken = (req.query as any)?.downloadToken
const isDownloadTokenRoute =
(urlPath.startsWith("/api/email/attachments/") && urlPath.endsWith("/download"))
|| urlPath.startsWith("/api/files/content/")
const downloadToken =
typeof queryToken === "string"
&& isDownloadTokenRoute
? queryToken
: null
// 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 || downloadToken || 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" })
}
// Check current account state even when a JWT carries cached admin claims.
const [currentUser] = await server.db
.select({ is_admin: authUsers.is_admin })
.from(authUsers)
.where(and(eq(authUsers.id, payload.user_id), isNull(authUsers.archived_at)))
.limit(1)
if (!currentUser) {
return reply.code(401).send({ error: "Invalid or expired token" })
}
req.user = { ...payload, 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
if (isWriteMethod(req.method) && !isTenantLockAllowedRoute(urlPath)) {
const [tenant] = await server.db
.select({
locked: tenants.locked,
lockedByExportJobId: tenants.lockedByExportJobId,
})
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1)
if (tenant?.locked === "maintenance_tenant") {
return reply.code(423).send({
error: "Tenant is locked for maintenance",
locked: tenant.locked,
lockedByExportJobId: tenant.lockedByExportJobId,
})
}
}
// --------------------------------------------------------
// 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
}
}