api key management

This commit is contained in:
Lea 2024-01-23 22:37:55 +01:00
parent 8fe74710e0
commit aa7c2b12eb
Signed by: Lea
GPG key ID: 1BAFFE8347019C42
7 changed files with 277 additions and 9 deletions

View file

@ -40,5 +40,5 @@ Check out our [Next.js deployment documentation](https://nextjs.org/docs/deploym
CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, address TEXT NOT NULL, alias TEXT NOT NULL, pending INTEGER DEFAULT 0, temporary INTEGER DEFAULT 0);
CREATE TABLE temp_alias_requests (key TEXT PRIMARY KEY, address TEXT NOT NULL, alias TEXT NOT NULL, expires INTEGER NOT NULL);
CREATE TABLE api_keys (id INTEGER PRIMARY KEY AUTOINCREMENT, address TEXT NOT NULL, token TEXT NOT NULL);
CREATE TABLE api_keys (id INTEGER PRIMARY KEY AUTOINCREMENT, address TEXT NOT NULL, label TEXT NOT NULL, token TEXT NOT NULL);
```

View file

@ -1,8 +1,25 @@
import { Button, Flex, Heading, Text } from "@radix-ui/themes";
import { ExternalLinkIcon } from "lucide-react";
"use client";
import { createApiKey, deleteOwnApikey, fetchOwnApiKeys } from "@/lib/actions";
import GenericConfirmationDialog from "@/lib/components/ui/GenericConfirmationDialog";
import GhostMessage from "@/lib/components/ui/GhostMessage";
import LoadingSpinner from "@/lib/components/ui/LoadingSpinner";
import { ApiKeyEntry } from "@/lib/db";
import { ToastContext } from "@/lib/providers/ToastProvider";
import { Box, Button, Card, Dialog, Flex, Heading, Table, Text, TextField } from "@radix-ui/themes";
import { ExternalLinkIcon, KeyIcon, PencilIcon, PlusIcon } from "lucide-react";
import Link from "next/link";
import { useContext, useEffect, useState } from "react";
export default function ApiKeys() {
const [keys, setKeys] = useState<(ApiKeyEntry & { copyable?: true })[] | null>(null);
const [keyLabel, setKeyLabel] = useState("");
const toast = useContext(ToastContext);
useEffect(() => {
fetchOwnApiKeys().then(setKeys);
}, []);
return (
<>
<Flex justify="between" align="center" direction="row" gap="5">
@ -10,10 +27,131 @@ export default function ApiKeys() {
<Heading>API keys</Heading>
<Text size="3" weight="light">Generate long lasting API keys that can be used to perform certain actions programatically.</Text>
</Flex>
<Link href="/api-doc" target="_blank">
<Button variant="outline">API docs <ExternalLinkIcon size="16" /></Button>
</Link>
<Flex direction="row" gap="3">
<Dialog.Root onOpenChange={(open) => !open && setKeyLabel("")}>
<Dialog.Trigger>
<Button variant="outline"><PlusIcon size="16" /> New key</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>New API key</Dialog.Title>
<Flex mt="2" direction="column" gap="2">
<Text size="1" color="gray">What is this key for?</Text>
<TextField.Root>
<TextField.Slot>
<PencilIcon size="16" />
</TextField.Slot>
<TextField.Input
placeholder="Alias generator shortcut on my phone"
value={keyLabel}
onChange={(e) => setKeyLabel(e.currentTarget.value.substring(0, 64))}
/>
</TextField.Root>
</Flex>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="outline">Cancel</Button>
</Dialog.Close>
<Dialog.Close>
<Button
variant="soft"
disabled={!keyLabel.length}
onClick={async () => {
try {
const key = await createApiKey(keyLabel);
setKeys([ { ...key, copyable: true }, ...(keys ?? []) ]);
toast({
title: "API key created",
description: "The token will only be displayed once",
variant: "success",
});
} catch (e) {
toast({
title: "Failed to create key",
description: `${e}`,
variant: "error",
});
}
}}
>Create</Button>
</Dialog.Close>
</Flex>
</Dialog.Content>
</Dialog.Root>
<Link href="/api-doc" target="_blank">
<Button variant="outline">API docs <ExternalLinkIcon size="16" /></Button>
</Link>
</Flex>
</Flex>
<Box mt="4">
{
keys
? keys.length
? <Table.Root variant="surface">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>Label</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell>Key</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell justify="end">Actions</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{
keys.map((key) => (
<Table.Row key={key.id}>
<Table.Cell>{key.label}</Table.Cell>
<Table.Cell>{key.token}</Table.Cell>
<Table.Cell justify="end">
<Flex gap="2" direction="row" justify="end">
{key.copyable && (
<Button
variant="outline"
onClick={() => navigator.clipboard.writeText(key.token)}
size="1"
>
Copy
</Button>
)}
<GenericConfirmationDialog
title="Delete key?"
description="It will be invalidated immediately. You cannot restore deleted keys."
action={async () => {
try {
await deleteOwnApikey(key.id);
setKeys(keys.filter((k) => k.id != key.id));
toast({
title: "Key deleted",
variant: "success",
});
} catch(e) {
toast({
title: "Failed to delete key",
description: `${e}`,
variant: "error",
});
}
}}
labelConfirm="Delete"
>
<Button variant="soft" size="1">
Delete
</Button>
</GenericConfirmationDialog>
</Flex>
</Table.Cell>
</Table.Row>
))
}
</Table.Body>
</Table.Root>
: <Card>
<GhostMessage icon={<KeyIcon />} header="No API keys" message="You don't have any API keys right now" />
</Card>
: <GhostMessage icon={<LoadingSpinner />} header="Loading" />
}
</Box>
</>
);
}

View file

@ -5,8 +5,8 @@ import fs from "fs/promises";
import { getServerSession } from "next-auth";
import * as random_words from "random-words";
import { AuditLog, auditLog } from "./audit";
import { AliasEntry, AliasRequestEntry, approveAliasEntry, createAliasEntry, createTempAliasRequestEntry, createUserEntry, database, deleteAliasEntry, deleteTempAliasRequestEntry, getAlias, getAllAliases, getTempAliasRequestEntry, getUserAliases, setUserPassword } from "./db";
import { aliasesNeedApproval, isAdmin } from "./util";
import { AliasEntry, AliasRequestEntry, ApiKeyEntry, approveAliasEntry, createAliasEntry, createApiKeyEntry, createTempAliasRequestEntry, createUserEntry, database, deleteAliasEntry, deleteApiKey, deleteTempAliasRequestEntry, getAlias, getAllAliases, getApiKeyById, getTempAliasRequestEntry, getUserAliases, getUserApiKeys, setUserPassword } from "./db";
import { aliasesNeedApproval, anonymizeApiKey, isAdmin } from "./util";
import { TEMP_EMAIL_DOMAIN } from "./constants";
export async function fetchAllUsers(): Promise<string[]> {
@ -279,3 +279,44 @@ export async function fetchAuditLog(page: number): Promise<{ page: number, perPa
.map((item, i) => ({ ...JSON.parse(item), index: page * itemsPerPage + i })),
};
}
export async function createApiKey(label: string): Promise<ApiKeyEntry> {
const session = await getServerSession();
if (!session?.user?.email) throw new Error("Unauthenticated");
if (!label.length || label.length > 64) throw new Error("Malformed label");
const token = crypto.randomBytes(48).toString("base64");
const id = await createApiKeyEntry(session.user.email, label, token);
const key: ApiKeyEntry = {
id: id,
address: session.user.email,
label: label,
token: token,
};
auditLog('createApiKey', anonymizeApiKey(key));
return key;
}
export async function fetchOwnApiKeys(): Promise<ApiKeyEntry[]> {
const session = await getServerSession();
if (!session?.user?.email) throw new Error("Unauthenticated");
const keys = await getUserApiKeys(session.user.email);
return keys.map(anonymizeApiKey);
}
export async function deleteOwnApikey(id: number) {
const session = await getServerSession();
if (!session?.user?.email) throw new Error("Unauthenticated");
const key = await getApiKeyById(id);
if (!key || key.address != session.user.email) throw new Error("Unauthorized");
await deleteApiKey(id);
auditLog('deleteApiKey', anonymizeApiKey(key));
}

View file

@ -2,6 +2,7 @@ import { getServerSession } from "next-auth";
import fs from "fs/promises";
export type AuditLogAction = 'login' | 'changeOwnPassword' | 'requestAlias' | 'deleteAlias' | 'createTempAlias' // Unprivileged
| 'createApiKey' | 'deleteApiKey'
| 'createUser' | 'createAlias' | 'approveAlias' | 'deleteAlias'; // Privileged
export type AuditLog = {

View file

@ -4,6 +4,7 @@ import { PHASE_PRODUCTION_BUILD } from "next/dist/shared/lib/constants";
export type AliasEntry = { id: number, address: string, alias: string, pending: boolean, temporary: boolean };
export type AliasRequestEntry = { key: string, address: string, alias: string, expires: number };
export type ApiKeyEntry = { id: number, address: string, label: string, token: string };
export const database = (type: 'credentials' | 'aliases') => {
if (process.env.NEXT_PHASE != PHASE_PRODUCTION_BUILD) {
@ -232,3 +233,82 @@ export function deleteAliasEntry(alias: string) {
});
});
}
export function createApiKeyEntry(address: string, label: string, token: string) {
return new Promise<number>(async (resolve, reject) => {
const db = database('credentials');
db.run(
"INSERT INTO api_keys (address, label, token) VALUES (?1, ?2, ?3)",
{
1: address,
2: label,
3: token,
},
function (err: any) {
db.close();
if (err) return reject(err);
resolve(this.lastID);
});
});
}
export function getUserApiKeys(address: string) {
return new Promise<ApiKeyEntry[]>(async (resolve, reject) => {
const db = database('credentials');
db.all(
"SELECT id, address, label, token FROM api_keys WHERE address = ?",
address,
function (err: any, res: any) {
db.close();
if (err) return reject(err);
resolve(res);
});
});
}
export function getApiKeyByToken(token: string) {
return new Promise<ApiKeyEntry | undefined>(async (resolve, reject) => {
const db = database('credentials');
db.get(
"SELECT id, address, label, token FROM api_keys WHERE token = ?",
token,
function (err: any, res: any) {
db.close();
if (err) return reject(err);
resolve(res);
});
});
}
export function getApiKeyById(id: number) {
return new Promise<ApiKeyEntry | undefined>(async (resolve, reject) => {
const db = database('credentials');
db.get(
"SELECT id, address, label, token FROM api_keys WHERE id = ?",
id,
function (err: any, res: any) {
db.close();
if (err) return reject(err);
resolve(res);
});
});
}
export function deleteApiKey(id: number) {
return new Promise<void>(async (resolve, reject) => {
const db = database('credentials');
db.run(
"DELETE FROM api_keys WHERE id = ?",
id,
function (err: any) {
db.close();
if (err) return reject(err);
resolve();
});
});
}

View file

@ -54,7 +54,7 @@ function Toast({ toast, onClosed }: { toast: ToastData, onClosed: () => void })
return (
<ToastPrimitives.Root
className={`ToastRoot`}
duration={3000}
duration={5000}
onOpenChange={(state) => !state && onClosed()}
>
<Flex justify="between" align="center" gap="4">

View file

@ -1,5 +1,6 @@
import { Session } from 'next-auth';
import crypto from 'crypto';
import { ApiKeyEntry } from './db';
export function sha256sum(input: any) {
const hash = crypto.createHash('sha256');
@ -15,3 +16,10 @@ export function isAdmin(session: Session | string | null) {
export function aliasesNeedApproval(session: Session | null) {
return !isAdmin(session); // also todo
}
export function anonymizeApiKey(key: ApiKeyEntry): ApiKeyEntry {
return {
...key,
token: key.token.substring(0, 6) + "********",
};
}