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 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 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";
|
"use client";
|
||||||
import { ExternalLinkIcon } from "lucide-react";
|
|
||||||
|
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 Link from "next/link";
|
||||||
|
import { useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function ApiKeys() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex justify="between" align="center" direction="row" gap="5">
|
<Flex justify="between" align="center" direction="row" gap="5">
|
||||||
|
@ -10,10 +27,131 @@ export default function ApiKeys() {
|
||||||
<Heading>API keys</Heading>
|
<Heading>API keys</Heading>
|
||||||
<Text size="3" weight="light">Generate long lasting API keys that can be used to perform certain actions programatically.</Text>
|
<Text size="3" weight="light">Generate long lasting API keys that can be used to perform certain actions programatically.</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<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">
|
<Link href="/api-doc" target="_blank">
|
||||||
<Button variant="outline">API docs <ExternalLinkIcon size="16" /></Button>
|
<Button variant="outline">API docs <ExternalLinkIcon size="16" /></Button>
|
||||||
</Link>
|
</Link>
|
||||||
</Flex>
|
</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 { getServerSession } from "next-auth";
|
||||||
import * as random_words from "random-words";
|
import * as random_words from "random-words";
|
||||||
import { AuditLog, auditLog } from "./audit";
|
import { AuditLog, auditLog } from "./audit";
|
||||||
import { AliasEntry, AliasRequestEntry, approveAliasEntry, createAliasEntry, createTempAliasRequestEntry, createUserEntry, database, deleteAliasEntry, deleteTempAliasRequestEntry, getAlias, getAllAliases, getTempAliasRequestEntry, getUserAliases, setUserPassword } from "./db";
|
import { AliasEntry, AliasRequestEntry, ApiKeyEntry, approveAliasEntry, createAliasEntry, createApiKeyEntry, createTempAliasRequestEntry, createUserEntry, database, deleteAliasEntry, deleteApiKey, deleteTempAliasRequestEntry, getAlias, getAllAliases, getApiKeyById, getTempAliasRequestEntry, getUserAliases, getUserApiKeys, setUserPassword } from "./db";
|
||||||
import { aliasesNeedApproval, isAdmin } from "./util";
|
import { aliasesNeedApproval, anonymizeApiKey, isAdmin } from "./util";
|
||||||
import { TEMP_EMAIL_DOMAIN } from "./constants";
|
import { TEMP_EMAIL_DOMAIN } from "./constants";
|
||||||
|
|
||||||
export async function fetchAllUsers(): Promise<string[]> {
|
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 })),
|
.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";
|
import fs from "fs/promises";
|
||||||
|
|
||||||
export type AuditLogAction = 'login' | 'changeOwnPassword' | 'requestAlias' | 'deleteAlias' | 'createTempAlias' // Unprivileged
|
export type AuditLogAction = 'login' | 'changeOwnPassword' | 'requestAlias' | 'deleteAlias' | 'createTempAlias' // Unprivileged
|
||||||
|
| 'createApiKey' | 'deleteApiKey'
|
||||||
| 'createUser' | 'createAlias' | 'approveAlias' | 'deleteAlias'; // Privileged
|
| 'createUser' | 'createAlias' | 'approveAlias' | 'deleteAlias'; // Privileged
|
||||||
|
|
||||||
export type AuditLog = {
|
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 AliasEntry = { id: number, address: string, alias: string, pending: boolean, temporary: boolean };
|
||||||
export type AliasRequestEntry = { key: string, address: string, alias: string, expires: number };
|
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') => {
|
export const database = (type: 'credentials' | 'aliases') => {
|
||||||
if (process.env.NEXT_PHASE != PHASE_PRODUCTION_BUILD) {
|
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 (
|
return (
|
||||||
<ToastPrimitives.Root
|
<ToastPrimitives.Root
|
||||||
className={`ToastRoot`}
|
className={`ToastRoot`}
|
||||||
duration={3000}
|
duration={5000}
|
||||||
onOpenChange={(state) => !state && onClosed()}
|
onOpenChange={(state) => !state && onClosed()}
|
||||||
>
|
>
|
||||||
<Flex justify="between" align="center" gap="4">
|
<Flex justify="between" align="center" gap="4">
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Session } from 'next-auth';
|
import { Session } from 'next-auth';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { ApiKeyEntry } from './db';
|
||||||
|
|
||||||
export function sha256sum(input: any) {
|
export function sha256sum(input: any) {
|
||||||
const hash = crypto.createHash('sha256');
|
const hash = crypto.createHash('sha256');
|
||||||
|
@ -15,3 +16,10 @@ export function isAdmin(session: Session | string | null) {
|
||||||
export function aliasesNeedApproval(session: Session | null) {
|
export function aliasesNeedApproval(session: Session | null) {
|
||||||
return !isAdmin(session); // also todo
|
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