api key management
This commit is contained in:
parent
8fe74710e0
commit
aa7c2b12eb
|
@ -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);
|
||||
```
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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) + "********",
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue