feat: add api keys (#991)

* feat: add api keys

* chore: address pull request feedback

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Manuel
2024-10-05 16:18:31 +02:00
committed by GitHub
parent ee8375756c
commit b14f82b4bb
22 changed files with 3374 additions and 60 deletions

View File

@@ -0,0 +1,78 @@
"use client";
import { useMemo } from "react";
import { Button, Group, Stack, Text, Title } from "@mantine/core";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { CopyApiKeyModal } from "~/app/[locale]/manage/tools/api/components/copy-api-key-modal";
interface ApiKeysManagementProps {
apiKeys: RouterOutputs["apiKeys"]["getAll"];
}
export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
const { openModal } = useModalAction(CopyApiKeyModal);
const { mutate, isPending } = clientApi.apiKeys.create.useMutation({
async onSuccess(data) {
openModal({
apiKey: data.randomToken,
});
await revalidatePathActionAsync("/manage/tools/api");
},
});
const t = useScopedI18n("management.page.tool.api.tab.apiKey");
const columns = useMemo<MRT_ColumnDef<RouterOutputs["apiKeys"]["getAll"][number]>[]>(
() => [
{
accessorKey: "id",
header: t("table.header.id"),
},
{
accessorKey: "user",
header: t("table.header.createdBy"),
Cell: ({ row }) => (
<Group gap={"xs"}>
<UserAvatar user={row.original.user} size={"sm"} />
<Text>{row.original.user.name}</Text>
</Group>
),
},
],
[],
);
const table = useMantineReactTable({
columns,
data: apiKeys,
renderTopToolbarCustomActions: () => (
<Button
onClick={() => {
mutate();
}}
loading={isPending}
>
{t("button.createApiToken")}
</Button>
),
enableDensityToggle: false,
state: {
density: "xs",
},
});
return (
<Stack>
<Title>{t("title")}</Title>
<MantineReactTable table={table} />
</Stack>
);
};

View File

@@ -0,0 +1,34 @@
import { Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { createModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
export const CopyApiKeyModal = createModal<{ apiKey: string }>(({ actions, innerProps }) => {
const t = useScopedI18n("management.page.tool.api.modal.createApiToken");
const [visible, { toggle }] = useDisclosure(false);
return (
<Stack>
<Text>{t("description")}</Text>
<PasswordInput value={innerProps.apiKey} visible={visible} onVisibilityChange={toggle} readOnly />
<CopyButton value={innerProps.apiKey}>
{({ copy }) => (
<Button
onClick={() => {
copy();
actions.closeModal();
}}
variant="default"
fullWidth
>
{t("button")}
</Button>
)}
</CopyButton>
</Stack>
);
}).withOptions({
defaultTitle(t) {
return t("management.page.tool.api.modal.createApiToken.title");
},
});

View File

@@ -0,0 +1,23 @@
"use client";
import type { OpenAPIV3 } from "openapi-types";
import SwaggerUI from "swagger-ui-react";
// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045
import "../swagger-ui-dark.css";
import "../swagger-ui-overrides.css";
import "../swagger-ui.css";
interface SwaggerUIClientProps {
document: OpenAPIV3.Document;
}
export const SwaggerUIClient = ({ document }: SwaggerUIClientProps) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const requestInterceptor = (req: Record<string, any>) => {
req.credentials = "omit";
return req;
};
return <SwaggerUI requestInterceptor={requestInterceptor} spec={document} />;
};

View File

@@ -1,17 +1,14 @@
import { getScopedI18n } from "@homarr/translation/server";
// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045
import "./swagger-ui-dark.css";
import "./swagger-ui-overrides.css";
import "./swagger-ui.css";
import { headers } from "next/headers";
import SwaggerUI from "swagger-ui-react";
import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
import { openApiDocument } from "@homarr/api";
import { api } from "@homarr/api/server";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
import { SwaggerUIClient } from "~/app/[locale]/manage/tools/api/components/swagger-ui";
import { createMetaTitle } from "~/metadata";
import { ApiKeysManagement } from "./components/api-keys";
export async function generateMetadata() {
const t = await getScopedI18n("management");
@@ -21,8 +18,25 @@ export async function generateMetadata() {
};
}
export default function ApiPage() {
export default async function ApiPage() {
const document = openApiDocument(extractBaseUrlFromHeaders(headers()));
const apiKeys = await api.apiKeys.getAll();
const t = await getScopedI18n("management.page.tool.api.tab");
return <SwaggerUI spec={document} />;
return (
<Stack>
<Tabs defaultValue={"documentation"}>
<TabsList>
<TabsTab value={"documentation"}>{t("documentation.label")}</TabsTab>
<TabsTab value={"authentication"}>{t("apiKey.label")}</TabsTab>
</TabsList>
<TabsPanel value={"authentication"}>
<ApiKeysManagement apiKeys={apiKeys} />
</TabsPanel>
<TabsPanel value={"documentation"}>
<SwaggerUIClient document={document} />
</TabsPanel>
</Tabs>
</Stack>
);
}

View File

@@ -7146,9 +7146,6 @@
}
.swagger-ui .wrapper {
box-sizing: border-box;
margin: 0 auto;
max-width: 1460px;
padding: 0 20px;
width: 100%;
}
.swagger-ui .opblock-tag-section {
@@ -7734,7 +7731,7 @@
background: #fff;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.15);
margin: 0 0 20px;
padding: 30px 0;
padding: 30px 20px;
}
.swagger-ui .scheme-container .schemes {
align-items: flex-end;

View File

@@ -1,14 +1,59 @@
import { createOpenApiFetchHandler } from "trpc-swagger/build/index.mjs";
import { appRouter, createTRPCContext } from "@homarr/api";
import type { Session } from "@homarr/auth";
import { createSessionAsync } from "@homarr/auth/server";
import { db, eq } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema/sqlite";
import { logger } from "@homarr/log";
const handlerAsync = async (req: Request) => {
const apiKeyHeaderValue = req.headers.get("ApiKey");
const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue);
const handler = (req: Request) => {
return createOpenApiFetchHandler({
req,
endpoint: "/",
router: appRouter,
createContext: () => createTRPCContext({ session: null, headers: req.headers }),
createContext: () => createTRPCContext({ session, headers: req.headers }),
});
};
export { handler as GET, handler as POST };
const getSessionOrDefaultFromHeadersAsync = async (apiKeyHeaderValue: string | null): Promise<Session | null> => {
logger.info(
`Creating OpenAPI fetch handler for user ${apiKeyHeaderValue ? "with an api key" : "without an api key"}`,
);
if (apiKeyHeaderValue === null) {
return null;
}
const apiKeyFromDb = await db.query.apiKeys.findFirst({
where: eq(apiKeys.apiKey, apiKeyHeaderValue),
columns: {
id: true,
apiKey: false,
salt: false,
},
with: {
user: {
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
},
},
},
});
if (apiKeyFromDb === undefined) {
logger.warn("An attempt to authenticate over API has failed");
return null;
}
logger.info(`Read session from API request and found user ${apiKeyFromDb.user.name} (${apiKeyFromDb.user.id})`);
return await createSessionAsync(db, apiKeyFromDb.user);
};
export { handlerAsync as GET, handlerAsync as POST };

View File

@@ -8,4 +8,12 @@ export const openApiDocument = (base: string) =>
version: "1.0.0",
baseUrl: base,
docsUrl: "https://homarr.dev",
securitySchemes: {
apikey: {
type: "apiKey",
name: "ApiKey",
description: "API key which can be obtained in the Homarr administration dashboard",
in: "header",
},
},
});

View File

@@ -1,3 +1,4 @@
import { apiKeysRouter } from "./router/apiKeys";
import { appRouter as innerAppRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { cronJobsRouter } from "./router/cron-jobs";
@@ -31,6 +32,7 @@ export const appRouter = createTRPCRouter({
docker: dockerRouter,
serverSettings: serverSettingsRouter,
cronJobs: cronJobsRouter,
apiKeys: apiKeysRouter,
});
// export type definition of API

View File

@@ -0,0 +1,41 @@
import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
import { generateSecureRandomToken } from "@homarr/common/server";
import { createId, db } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema/sqlite";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
export const apiKeysRouter = createTRPCRouter({
getAll: permissionRequiredProcedure.requiresPermission("admin").query(() => {
return db.query.apiKeys.findMany({
columns: {
id: true,
apiKey: false,
salt: false,
},
with: {
user: {
columns: {
id: true,
name: true,
image: true,
},
},
},
});
}),
create: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => {
const salt = await createSaltAsync();
const randomToken = generateSecureRandomToken(64);
const hashedRandomToken = await hashPasswordAsync(randomToken, salt);
await db.insert(apiKeys).values({
id: createId(),
apiKey: hashedRandomToken,
salt,
userId: ctx.session.user.id,
});
return {
randomToken,
};
}),
});

View File

@@ -7,53 +7,114 @@ import { validation, z } from "@homarr/validation";
import { createTRPCRouter, publicProcedure } from "../trpc";
export const appRouter = createTRPCRouter({
all: publicProcedure.query(async ({ ctx }) => {
return await ctx.db.query.apps.findMany({
orderBy: asc(apps.name),
});
}),
selectable: publicProcedure.query(async ({ ctx }) => {
return await ctx.db.query.apps.findMany({
columns: {
id: true,
name: true,
iconUrl: true,
},
orderBy: asc(apps.name),
});
}),
all: publicProcedure
.input(z.void())
.output(
z.array(
z.object({
name: z.string(),
id: z.string(),
description: z.string().nullable(),
iconUrl: z.string(),
href: z.string().nullable(),
}),
),
)
.meta({ openapi: { method: "GET", path: "/api/apps", tags: ["apps"], protect: true } })
.query(({ ctx }) => {
return ctx.db.query.apps.findMany({
orderBy: asc(apps.name),
});
}),
search: publicProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.apps.findMany({
.output(
z.array(
z.object({
name: z.string(),
id: z.string(),
description: z.string().nullable(),
iconUrl: z.string(),
href: z.string().nullable(),
}),
),
)
.meta({ openapi: { method: "GET", path: "/api/apps/search", tags: ["apps"], protect: true } })
.query(({ ctx, input }) => {
return ctx.db.query.apps.findMany({
where: like(apps.name, `%${input.query}%`),
orderBy: asc(apps.name),
limit: input.limit,
});
}),
byId: publicProcedure.input(validation.common.byId).query(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
});
if (!app) {
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
selectable: publicProcedure
.input(z.void())
.output(
z.array(
z.object({
name: z.string(),
id: z.string(),
iconUrl: z.string(),
}),
),
)
.meta({
openapi: {
method: "GET",
path: "/api/apps/selectable",
tags: ["apps"],
protect: true,
},
})
.query(({ ctx }) => {
return ctx.db.query.apps.findMany({
columns: {
id: true,
name: true,
iconUrl: true,
},
orderBy: asc(apps.name),
});
}),
byId: publicProcedure
.input(validation.common.byId)
.output(
z.object({
name: z.string(),
id: z.string(),
description: z.string().nullable(),
iconUrl: z.string(),
href: z.string().nullable(),
}),
)
.meta({ openapi: { method: "GET", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
.query(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
});
}
return app;
}),
create: publicProcedure.input(validation.app.manage).mutation(async ({ ctx, input }) => {
await ctx.db.insert(apps).values({
id: createId(),
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
href: input.href,
});
}),
if (!app) {
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
});
}
return app;
}),
create: publicProcedure
.input(validation.app.manage)
.output(z.void())
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(apps).values({
id: createId(),
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
href: input.href,
});
}),
update: publicProcedure.input(validation.app.edit).mutation(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
@@ -76,7 +137,11 @@ export const appRouter = createTRPCRouter({
})
.where(eq(apps.id, input.id));
}),
delete: publicProcedure.input(validation.common.byId).mutation(async ({ ctx, input }) => {
await ctx.db.delete(apps).where(eq(apps.id, input.id));
}),
delete: publicProcedure
.output(z.void())
.meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
.input(validation.common.byId)
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(apps).where(eq(apps.id, input.id));
}),
});

View File

@@ -70,7 +70,7 @@ export const userRouter = createTRPCRouter({
await ctx.db.delete(invites).where(inviteWhere);
}),
create: publicProcedure
.meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"] } })
.meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"], protect: true } })
.input(validation.user.create)
.output(z.void())
.mutation(async ({ ctx, input }) => {
@@ -143,7 +143,7 @@ export const userRouter = createTRPCRouter({
}),
),
)
.meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"] } })
.meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"], protect: true } })
.query(({ ctx }) => {
return ctx.db.query.users.findMany({
columns: {

View File

@@ -3,6 +3,7 @@ import type { Adapter } from "@auth/core/adapters";
import dayjs from "dayjs";
import type { NextAuthConfig } from "next-auth";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { groupMembers, groupPermissions, users } from "@homarr/db/schema/sqlite";
@@ -30,6 +31,21 @@ export const getCurrentUserPermissionsAsync = async (db: Database, userId: strin
return getPermissionsWithChildren(permissionKeys);
};
export const createSessionAsync = async (
db: Database,
user: { id: string; email: string | null },
): Promise<Session> => {
return {
expires: dayjs().add(1, "day").toISOString(),
user: {
...user,
email: user.email ?? "",
permissions: await getCurrentUserPermissionsAsync(db, user.id),
colorScheme: "auto",
},
} as Session;
};
export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session"> => {
return async ({ session, user }) => {
const additionalProperties = await db.query.users.findFirst({

View File

@@ -1,3 +1,4 @@
export { hasQueryAccessToIntegrationsAsync } from "./permissions/integration-query-permissions";
export { getIntegrationsWithPermissionsAsync } from "./permissions/integrations-with-permissions";
export { isProviderEnabled } from "./providers/check-provider";
export { createSessionCallback, createSessionAsync } from "./callbacks";

View File

@@ -0,0 +1,9 @@
CREATE TABLE `apiKey` (
`id` varchar(64) NOT NULL,
`apiKey` text NOT NULL,
`salt` text NOT NULL,
`userId` varchar(64) NOT NULL,
CONSTRAINT `apiKey_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `apiKey` ADD CONSTRAINT `apiKey_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,13 @@
"when": 1727532165317,
"tag": "0008_far_lifeguard",
"breakpoints": true
},
{
"idx": 9,
"version": "5",
"when": 1728074730696,
"tag": "0009_wakeful_tenebrous",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,7 @@
CREATE TABLE `apiKey` (
`id` text PRIMARY KEY NOT NULL,
`apiKey` text NOT NULL,
`salt` text NOT NULL,
`userId` text NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,13 @@
"when": 1727526190343,
"tag": "0008_third_thor",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1728074724956,
"tag": "0009_stale_roulette",
"breakpoints": true
}
]
}

View File

@@ -19,6 +19,17 @@ import type {
} from "@homarr/definitions";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
export const apiKeys = mysqlTable("apiKey", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
apiKey: text("apiKey").notNull(),
salt: text("salt").notNull(),
userId: varchar("userId", { length: 64 })
.notNull()
.references((): AnyMySqlColumn => users.id, {
onDelete: "cascade",
}),
});
export const users = mysqlTable("user", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
name: text("name"),
@@ -341,6 +352,13 @@ export const serverSettings = mysqlTable("serverSetting", {
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
});
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
user: one(users, {
fields: [apiKeys.userId],
references: [users.id],
}),
}));
export const searchEngines = mysqlTable("search_engine", {
id: varchar("id", { length: 64 }).notNull().primaryKey(),
iconUrl: text("icon_url").notNull(),

View File

@@ -20,6 +20,17 @@ import type {
WidgetKind,
} from "@homarr/definitions";
export const apiKeys = sqliteTable("apiKey", {
id: text("id").notNull().primaryKey(),
apiKey: text("apiKey").notNull(),
salt: text("salt").notNull(),
userId: text("userId")
.notNull()
.references((): AnySQLiteColumn => users.id, {
onDelete: "cascade",
}),
});
export const users = sqliteTable("user", {
id: text("id").notNull().primaryKey(),
name: text("name"),
@@ -343,6 +354,13 @@ export const serverSettings = sqliteTable("serverSetting", {
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
});
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
user: one(users, {
fields: [apiKeys.userId],
references: [users.id],
}),
}));
export const searchEngines = sqliteTable("search_engine", {
id: text("id").notNull().primaryKey(),
iconUrl: text("icon_url").notNull(),

View File

@@ -1887,6 +1887,35 @@ export default {
},
},
},
api: {
title: "API",
modal: {
createApiToken: {
title: "API token created",
description:
"API token was created. Be careful, this token is encrypted in the database and will never be transferred again to you. If you loose this token, you'll no longer be able to retrieve this specific token.",
button: "Copy and close",
},
},
tab: {
documentation: {
label: "Documentation",
},
apiKey: {
label: "Authentication",
title: "API Keys",
button: {
createApiToken: "Create API token",
},
table: {
header: {
id: "ID",
createdBy: "Created by",
},
},
},
},
},
},
about: {
version: "Version {version}",