diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index 5478cf5..c029ad2 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -43,6 +43,7 @@ export * from "./inventoryitemgroups" export * from "./inventoryitems" export * from "./letterheads" export * from "./movements" +export * from "./m2m_api_keys" export * from "./notifications_event_types" export * from "./notifications_items" export * from "./notifications_preferences" @@ -72,4 +73,4 @@ export * from "./staff_time_events" export * from "./serialtypes" export * from "./serialexecutions" export * from "./public_links" -export * from "./wikipages" \ No newline at end of file +export * from "./wikipages" diff --git a/backend/db/schema/m2m_api_keys.ts b/backend/db/schema/m2m_api_keys.ts new file mode 100644 index 0000000..f277865 --- /dev/null +++ b/backend/db/schema/m2m_api_keys.ts @@ -0,0 +1,48 @@ +import { + pgTable, + uuid, + bigint, + text, + timestamp, + boolean, +} from "drizzle-orm/pg-core" + +import { tenants } from "./tenants" +import { authUsers } from "./auth_users" + +export const m2mApiKeys = pgTable("m2m_api_keys", { + id: uuid("id").primaryKey().defaultRandom(), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + + tenantId: bigint("tenant_id", { mode: "number" }) + .notNull() + .references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }), + + userId: uuid("user_id") + .notNull() + .references(() => authUsers.id, { onDelete: "cascade", onUpdate: "cascade" }), + + createdBy: uuid("created_by").references(() => authUsers.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + + name: text("name").notNull(), + keyPrefix: text("key_prefix").notNull(), + keyHash: text("key_hash").notNull().unique(), + + active: boolean("active").notNull().default(true), + + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + expiresAt: timestamp("expires_at", { withTimezone: true }), +}) + +export type M2mApiKey = typeof m2mApiKeys.$inferSelect +export type NewM2mApiKey = typeof m2mApiKeys.$inferInsert diff --git a/backend/src/index.ts b/backend/src/index.ts index f9edff1..d7bb1cf 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -42,6 +42,7 @@ import helpdeskInboundEmailRoutes from "./routes/helpdesk.inbound.email"; import deviceRoutes from "./routes/internal/devices"; import tenantRoutesInternal from "./routes/internal/tenant"; import staffTimeRoutesInternal from "./routes/internal/time"; +import authM2mInternalRoutes from "./routes/internal/auth.m2m"; //Devices import devicesRFIDRoutes from "./routes/devices/rfid"; @@ -107,6 +108,7 @@ async function main() { await app.register(async (m2mApp) => { await m2mApp.register(authM2m) + await m2mApp.register(authM2mInternalRoutes) await m2mApp.register(helpdeskInboundEmailRoutes) await m2mApp.register(deviceRoutes) await m2mApp.register(tenantRoutesInternal) @@ -167,4 +169,4 @@ async function main() { } } -main(); \ No newline at end of file +main(); diff --git a/backend/src/plugins/auth.m2m.ts b/backend/src/plugins/auth.m2m.ts index 58e6c3c..70ebb70 100644 --- a/backend/src/plugins/auth.m2m.ts +++ b/backend/src/plugins/auth.m2m.ts @@ -1,6 +1,9 @@ import { FastifyInstance } from "fastify"; import fp from "fastify-plugin"; import { secrets } from "../utils/secrets"; +import { and, eq } from "drizzle-orm"; +import { authUsers, m2mApiKeys } from "../../db/schema"; +import { createHash } from "node:crypto"; /** * Fastify Plugin für Machine-to-Machine Authentifizierung. @@ -12,26 +15,99 @@ import { secrets } from "../utils/secrets"; * server.register(m2mAuthPlugin, { allowedPrefix: '/internal' }) */ export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string } = {}) => { - //const allowedPrefix = opts.allowedPrefix || "/internal"; + const hashApiKey = (apiKey: string) => + createHash("sha256").update(apiKey, "utf8").digest("hex") server.addHook("preHandler", async (req, reply) => { try { - // Nur prüfen, wenn Route unterhalb des Prefix liegt - //if (!req.url.startsWith(allowedPrefix)) return; + const apiKeyHeader = req.headers["x-api-key"]; + const apiKey = Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader; - const apiKey = req.headers["x-api-key"]; - - if (!apiKey || apiKey !== secrets.M2M_API_KEY) { + if (!apiKey) { server.log.warn(`[M2M Auth] Ungültiger oder fehlender API-Key bei ${req.url}`); return reply.status(401).send({ error: "Unauthorized" }); } - // Zusatzinformationen im Request (z. B. interne Kennung) + const keyHash = hashApiKey(apiKey); + + const keyRows = await server.db + .select({ + id: m2mApiKeys.id, + tenantId: m2mApiKeys.tenantId, + userId: m2mApiKeys.userId, + active: m2mApiKeys.active, + expiresAt: m2mApiKeys.expiresAt, + name: m2mApiKeys.name, + userEmail: authUsers.email, + }) + .from(m2mApiKeys) + .innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId)) + .where(and( + eq(m2mApiKeys.keyHash, keyHash), + eq(m2mApiKeys.active, true) + )) + .limit(1) + + let key = keyRows[0] + if (!key) { + const fallbackValid = apiKey === secrets.M2M_API_KEY + if (!fallbackValid) { + server.log.warn(`[M2M Auth] Ungültiger API-Key bei ${req.url}`) + return reply.status(401).send({ error: "Unauthorized" }) + } + + // Backward compatibility mode for one global key. + // The caller must provide user/tenant identifiers in headers. + const tenantIdHeader = req.headers["x-tenant-id"] + const userIdHeader = req.headers["x-user-id"] + const tenantId = Number(Array.isArray(tenantIdHeader) ? tenantIdHeader[0] : tenantIdHeader) + const userId = Array.isArray(userIdHeader) ? userIdHeader[0] : userIdHeader + + if (!tenantId || !userId) { + return reply.status(401).send({ error: "Missing x-tenant-id or x-user-id for legacy M2M key" }) + } + + const users = await server.db + .select({ email: authUsers.email }) + .from(authUsers) + .where(eq(authUsers.id, userId)) + .limit(1) + + if (!users[0]) { + return reply.status(401).send({ error: "Unknown user for legacy M2M key" }) + } + + req.user = { + user_id: userId, + email: users[0].email, + tenant_id: tenantId + } + } else { + if (key.expiresAt && new Date(key.expiresAt).getTime() < Date.now()) { + return reply.status(401).send({ error: "Expired API key" }) + } + + req.user = { + user_id: key.userId, + email: key.userEmail, + tenant_id: key.tenantId + } + + await server.db + .update(m2mApiKeys) + .set({ lastUsedAt: new Date(), updatedAt: new Date() }) + .where(eq(m2mApiKeys.id, key.id)) + } + (req as any).m2m = { verified: true, type: "internal", key: apiKey, }; + + req.role = "m2m" + req.permissions = [] + req.hasPermission = () => false } catch (err) { // @ts-ignore server.log.error("[M2M Auth] Fehler beim Prüfen des API-Keys:", err); diff --git a/backend/src/routes/internal/auth.m2m.ts b/backend/src/routes/internal/auth.m2m.ts new file mode 100644 index 0000000..f7a1d35 --- /dev/null +++ b/backend/src/routes/internal/auth.m2m.ts @@ -0,0 +1,63 @@ +import { FastifyInstance } from "fastify" +import jwt from "jsonwebtoken" +import { and, eq } from "drizzle-orm" +import { authTenantUsers } from "../../../db/schema" +import { secrets } from "../../utils/secrets" + +export default async function authM2mInternalRoutes(server: FastifyInstance) { + server.post("/auth/m2m/token", { + schema: { + tags: ["Auth"], + summary: "Exchange M2M API key for a short-lived JWT", + body: { + type: "object", + properties: { + expires_in_seconds: { type: "number" } + } + } + } + }, async (req, reply) => { + try { + if (!req.user?.user_id || !req.user?.tenant_id || !req.user?.email) { + return reply.code(401).send({ error: "Unauthorized" }) + } + + const membership = await server.db + .select() + .from(authTenantUsers) + .where(and( + eq(authTenantUsers.user_id, req.user.user_id), + eq(authTenantUsers.tenant_id, Number(req.user.tenant_id)) + )) + .limit(1) + + if (!membership[0]) { + return reply.code(403).send({ error: "User is not assigned to tenant" }) + } + + const requestedTtl = Number((req.body as any)?.expires_in_seconds ?? 900) + const ttlSeconds = Math.min(3600, Math.max(60, requestedTtl)) + + const token = jwt.sign( + { + user_id: req.user.user_id, + email: req.user.email, + tenant_id: req.user.tenant_id, + }, + secrets.JWT_SECRET!, + { expiresIn: ttlSeconds } + ) + + return { + token_type: "Bearer", + access_token: token, + expires_in_seconds: ttlSeconds, + user_id: req.user.user_id, + tenant_id: req.user.tenant_id + } + } catch (err) { + console.error("POST /internal/auth/m2m/token ERROR:", err) + return reply.code(500).send({ error: "Internal Server Error" }) + } + }) +} diff --git a/backend/src/routes/tenant.ts b/backend/src/routes/tenant.ts index 93fa7c6..73c39c3 100644 --- a/backend/src/routes/tenant.ts +++ b/backend/src/routes/tenant.ts @@ -1,18 +1,26 @@ import { FastifyInstance } from "fastify" import jwt from "jsonwebtoken" import { secrets } from "../utils/secrets" +import { createHash, randomBytes } from "node:crypto" import { authTenantUsers, authUsers, authProfiles, - tenants + tenants, + m2mApiKeys } from "../../db/schema" -import {and, eq, inArray} from "drizzle-orm" +import {and, desc, eq, inArray} from "drizzle-orm" export default async function tenantRoutes(server: FastifyInstance) { + const generateApiKey = () => { + const raw = randomBytes(32).toString("base64url") + return `fedeo_m2m_${raw}` + } + const hashApiKey = (apiKey: string) => + createHash("sha256").update(apiKey, "utf8").digest("hex") // ------------------------------------------------------------- @@ -73,7 +81,7 @@ export default async function tenantRoutes(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 } @@ -241,4 +249,172 @@ export default async function tenantRoutes(server: FastifyInstance) { } }) + // ------------------------------------------------------------- + // M2M API KEYS + // ------------------------------------------------------------- + server.get("/tenant/api-keys", async (req, reply) => { + try { + const tenantId = req.user?.tenant_id + if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) + + const keys = await server.db + .select({ + id: m2mApiKeys.id, + name: m2mApiKeys.name, + tenant_id: m2mApiKeys.tenantId, + user_id: m2mApiKeys.userId, + active: m2mApiKeys.active, + key_prefix: m2mApiKeys.keyPrefix, + created_at: m2mApiKeys.createdAt, + updated_at: m2mApiKeys.updatedAt, + expires_at: m2mApiKeys.expiresAt, + last_used_at: m2mApiKeys.lastUsedAt, + }) + .from(m2mApiKeys) + .where(eq(m2mApiKeys.tenantId, tenantId)) + .orderBy(desc(m2mApiKeys.createdAt)) + + return keys + } catch (err) { + console.error("/tenant/api-keys GET ERROR:", err) + return reply.code(500).send({ error: "Internal Server Error" }) + } + }) + + server.post("/tenant/api-keys", async (req, reply) => { + try { + const tenantId = req.user?.tenant_id + const creatorUserId = req.user?.user_id + if (!tenantId || !creatorUserId) { + return reply.code(401).send({ error: "Unauthorized" }) + } + + const { name, user_id, expires_at } = req.body as { + name: string + user_id: string + expires_at?: string | null + } + + if (!name || !user_id) { + return reply.code(400).send({ error: "name and user_id are required" }) + } + + const userMembership = await server.db + .select() + .from(authTenantUsers) + .where(and( + eq(authTenantUsers.tenant_id, tenantId), + eq(authTenantUsers.user_id, user_id) + )) + .limit(1) + + if (!userMembership[0]) { + return reply.code(400).send({ error: "user_id is not assigned to this tenant" }) + } + + const plainApiKey = generateApiKey() + const keyPrefix = plainApiKey.slice(0, 16) + const keyHash = hashApiKey(plainApiKey) + + const inserted = await server.db + .insert(m2mApiKeys) + .values({ + tenantId, + userId: user_id, + createdBy: creatorUserId, + name, + keyPrefix, + keyHash, + expiresAt: expires_at ? new Date(expires_at) : null, + }) + .returning({ + id: m2mApiKeys.id, + name: m2mApiKeys.name, + tenant_id: m2mApiKeys.tenantId, + user_id: m2mApiKeys.userId, + key_prefix: m2mApiKeys.keyPrefix, + created_at: m2mApiKeys.createdAt, + expires_at: m2mApiKeys.expiresAt, + active: m2mApiKeys.active, + }) + + return reply.code(201).send({ + ...inserted[0], + api_key: plainApiKey, // only returned once + }) + } catch (err) { + console.error("/tenant/api-keys POST ERROR:", err) + return reply.code(500).send({ error: "Internal Server Error" }) + } + }) + + server.patch("/tenant/api-keys/:id", async (req, reply) => { + try { + const tenantId = req.user?.tenant_id + if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) + + const { id } = req.params as { id: string } + const { name, active, expires_at } = req.body as { + name?: string + active?: boolean + expires_at?: string | null + } + + const updateData: any = { + updatedAt: new Date() + } + if (name !== undefined) updateData.name = name + if (active !== undefined) updateData.active = active + if (expires_at !== undefined) updateData.expiresAt = expires_at ? new Date(expires_at) : null + + const updated = await server.db + .update(m2mApiKeys) + .set(updateData) + .where(and( + eq(m2mApiKeys.id, id), + eq(m2mApiKeys.tenantId, tenantId) + )) + .returning({ + id: m2mApiKeys.id, + name: m2mApiKeys.name, + tenant_id: m2mApiKeys.tenantId, + user_id: m2mApiKeys.userId, + active: m2mApiKeys.active, + key_prefix: m2mApiKeys.keyPrefix, + updated_at: m2mApiKeys.updatedAt, + expires_at: m2mApiKeys.expiresAt, + last_used_at: m2mApiKeys.lastUsedAt, + }) + + if (!updated[0]) { + return reply.code(404).send({ error: "API key not found" }) + } + + return updated[0] + } catch (err) { + console.error("/tenant/api-keys PATCH ERROR:", err) + return reply.code(500).send({ error: "Internal Server Error" }) + } + }) + + server.delete("/tenant/api-keys/:id", async (req, reply) => { + try { + const tenantId = req.user?.tenant_id + if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) + + const { id } = req.params as { id: string } + await server.db + .delete(m2mApiKeys) + .where(and( + eq(m2mApiKeys.id, id), + eq(m2mApiKeys.tenantId, tenantId) + )) + + return { success: true } + } catch (err) { + console.error("/tenant/api-keys DELETE ERROR:", err) + return reply.code(500).send({ error: "Internal Server Error" }) + } + }) + }