diff --git a/README.md b/README.md index 96adb11..fe6d3e4 100644 --- a/README.md +++ b/README.md @@ -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); ``` diff --git a/src/app/(dashboard)/api-keys/page.tsx b/src/app/(dashboard)/api-keys/page.tsx index 2fafa82..b65dfd7 100644 --- a/src/app/(dashboard)/api-keys/page.tsx +++ b/src/app/(dashboard)/api-keys/page.tsx @@ -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 ( <> @@ -10,10 +27,131 @@ export default function ApiKeys() { API keys Generate long lasting API keys that can be used to perform certain actions programatically. - - - + + !open && setKeyLabel("")}> + + + + + New API key + + What is this key for? + + + + + setKeyLabel(e.currentTarget.value.substring(0, 64))} + /> + + + + + + + + + + + + + + + + + + + + { + keys + ? keys.length + ? + + + Label + Key + Actions + + + + + { + keys.map((key) => ( + + {key.label} + {key.token} + + + {key.copyable && ( + + )} + { + 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" + > + + + + + + )) + } + + + : + } header="No API keys" message="You don't have any API keys right now" /> + + : } header="Loading" /> + } + ); } \ No newline at end of file diff --git a/src/lib/actions.ts b/src/lib/actions.ts index da5292f..3e51624 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -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 { @@ -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 { + 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 { + 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)); +} diff --git a/src/lib/audit.ts b/src/lib/audit.ts index 7eb1792..3f9e571 100644 --- a/src/lib/audit.ts +++ b/src/lib/audit.ts @@ -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 = { diff --git a/src/lib/db.ts b/src/lib/db.ts index bc51921..67aa973 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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(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(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(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(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(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(); + }); + }); +} diff --git a/src/lib/providers/ToastProvider.tsx b/src/lib/providers/ToastProvider.tsx index d9a9f6b..96180da 100644 --- a/src/lib/providers/ToastProvider.tsx +++ b/src/lib/providers/ToastProvider.tsx @@ -54,7 +54,7 @@ function Toast({ toast, onClosed }: { toast: ToastData, onClosed: () => void }) return ( !state && onClosed()} > diff --git a/src/lib/util.ts b/src/lib/util.ts index bcc32ac..8def298 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -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) + "********", + }; +}