added api-keys

This commit is contained in:
beo3000 2026-02-23 15:44:10 +01:00
parent ab5e38abc2
commit d510e96541
9 changed files with 1403 additions and 1 deletions

View File

@ -1 +1 @@
1.1.26
1.1.27

View File

@ -1,10 +1,86 @@
<script lang="ts">
import { scopeSettings, currentScope, type ScopeSettings } from '$lib/stores/scopeContext';
import { authFetch } from '$lib/auth/apiClient.js';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { onMount } from 'svelte';
currentScope.set(null);
const API_BASE = import.meta.env.VITE_API_URL ?? '';
let settings = $state<ScopeSettings>({ ...$scopeSettings });
// --- API Keys ---
interface ApiKey {
id: string;
label: string;
createdAt: string | number;
lastUsedAt: string | number | null;
}
let apiKeysList = $state<ApiKey[]>([]);
let apiKeysLoading = $state(false);
let apiKeysError = $state<string | null>(null);
let newKeyLabel = $state('');
let newKeyCreating = $state(false);
let newKeyValue = $state<string | null>(null);
let confirmDelete = $state<ApiKey | null>(null);
function fmtDate(val: string | number | null | undefined): string {
if (!val) return '';
return new Date(typeof val === 'number' ? val : val).toLocaleDateString('de-DE');
}
async function loadApiKeys() {
apiKeysLoading = true;
apiKeysError = null;
try {
const res = await authFetch(`${API_BASE}/api/api-keys`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
apiKeysList = await res.json() as ApiKey[];
} catch (e: unknown) {
apiKeysError = e instanceof Error ? e.message : 'Fehler';
} finally {
apiKeysLoading = false;
}
}
async function createApiKey() {
if (!newKeyLabel.trim()) return;
newKeyCreating = true;
apiKeysError = null;
try {
const res = await authFetch(`${API_BASE}/api/api-keys`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: newKeyLabel.trim() }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json() as ApiKey & { key: string };
newKeyValue = data.key;
newKeyLabel = '';
await loadApiKeys();
} catch (e: unknown) {
apiKeysError = e instanceof Error ? e.message : 'Fehler';
} finally {
newKeyCreating = false;
}
}
async function deleteApiKey(key: ApiKey) {
try {
const res = await authFetch(`${API_BASE}/api/api-keys/${key.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await loadApiKeys();
} catch (e: unknown) {
apiKeysError = e instanceof Error ? e.message : 'Fehler';
} finally {
confirmDelete = null;
}
}
onMount(loadApiKeys);
function save() {
scopeSettings.set({ ...settings });
}
@ -91,4 +167,71 @@
Auf Standard zurücksetzen
</button>
</section>
<!-- API Keys -->
<section class="space-y-4">
<h2 class="text-sm font-semibold uppercase text-muted">API Keys</h2>
<p class="text-xs text-muted">Für Automationen und Maschinen-Zugänge (z.B. iOS Kurzbefehle). Der Key wird nur einmal angezeigt.</p>
{#if apiKeysError}
<p class="text-xs text-red-400">{apiKeysError}</p>
{/if}
{#if newKeyValue}
<div class="rounded border border-green-600 bg-green-950/40 p-3 space-y-2">
<p class="text-xs text-green-400 font-semibold">Neuer Key — jetzt kopieren, wird nicht mehr angezeigt:</p>
<code class="block break-all text-xs font-mono text-green-300 select-all">{newKeyValue}</code>
<button class="text-xs text-muted hover:text-white underline" onclick={() => newKeyValue = null}>Schliessen</button>
</div>
{/if}
<!-- existing keys -->
{#if apiKeysLoading}
<p class="text-xs text-muted">Laden...</p>
{:else if apiKeysList.length === 0}
<p class="text-xs text-muted">Keine API Keys vorhanden.</p>
{:else}
<ul class="space-y-2">
{#each apiKeysList as key (key.id)}
<li class="flex items-center justify-between rounded border border-border bg-card-bg px-3 py-2">
<div>
<span class="text-sm font-medium">{key.label}</span>
<span class="ml-3 text-xs text-muted">erstellt {fmtDate(key.createdAt)}</span>
{#if key.lastUsedAt}
<span class="ml-2 text-xs text-muted">zuletzt {fmtDate(key.lastUsedAt)}</span>
{/if}
</div>
<button
class="text-xs text-red-400 hover:text-red-300 ml-4 shrink-0"
onclick={() => confirmDelete = key}
>Löschen</button>
</li>
{/each}
</ul>
{/if}
<!-- create new -->
<div class="flex gap-2">
<input
type="text"
placeholder="Bezeichnung (z.B. iOS Kurzbefehle)"
bind:value={newKeyLabel}
class="flex-1 rounded border border-border bg-card-bg px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-accent"
onkeydown={(e) => e.key === 'Enter' && createApiKey()}
/>
<button
class="rounded bg-accent px-3 py-1.5 text-sm font-medium disabled:opacity-50"
disabled={!newKeyLabel.trim() || newKeyCreating}
onclick={createApiKey}
>{newKeyCreating ? '...' : 'Erstellen'}</button>
</div>
</section>
</div>
{#if confirmDelete}
<ConfirmDialog
message="API Key &quot;{confirmDelete.label}&quot; wirklich löschen? Alle Anwendungen, die diesen Key verwenden, verlieren den Zugriff."
onconfirm={() => deleteApiKey(confirmDelete!)}
oncancel={() => confirmDelete = null}
/>
{/if}

View File

@ -0,0 +1,13 @@
CREATE TABLE `api_keys` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`label` text NOT NULL,
`key_hash` text NOT NULL,
`created_at` integer NOT NULL,
`last_used_at` integer,
`deleted_at` integer
);
--> statement-breakpoint
CREATE UNIQUE INDEX `api_keys_key_hash_unique` ON `api_keys` (`key_hash`);--> statement-breakpoint
CREATE INDEX `api_keys_user_id_idx` ON `api_keys` (`user_id`);--> statement-breakpoint
CREATE INDEX `api_keys_key_hash_idx` ON `api_keys` (`key_hash`);

File diff suppressed because it is too large Load Diff

View File

@ -71,6 +71,13 @@
"when": 1771848773587,
"tag": "0009_fixed_fabian_cortez",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1771857652883,
"tag": "0010_sharp_bishop",
"breakpoints": true
}
]
}

View File

@ -139,6 +139,19 @@ export const aiLocks = sqliteTable('ai_locks', {
expiresAt: text('expires_at').notNull(),
});
export const apiKeys = sqliteTable('api_keys', {
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
label: text('label').notNull(),
keyHash: text('key_hash').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
lastUsedAt: integer('last_used_at', { mode: 'timestamp' }),
deletedAt: integer('deleted_at', { mode: 'timestamp' }),
}, (table) => [
index('api_keys_user_id_idx').on(table.userId),
index('api_keys_key_hash_idx').on(table.keyHash),
]);
export const ratings = sqliteTable('ratings', {
id: text('id').notNull(),
userId: text('user_id').notNull(),

View File

@ -14,6 +14,7 @@ import trashRoutes from './routes/trash.js';
import adminRoutes from './routes/admin.js';
import pushRoutes from './routes/push.js';
import backupRoutes from './routes/backup.js';
import apiKeyRoutes from './routes/api-keys.js';
import { runScheduledBackup } from './lib/backup-service.js';
const app = new OpenAPIHono();
@ -63,6 +64,9 @@ app.route('/api/push', pushRoutes);
app.use('/api/backup/*', authMiddleware);
app.route('/api/backup', backupRoutes);
app.use('/api/api-keys/*', authMiddleware);
app.route('/api/api-keys', apiKeyRoutes);
// OpenAPI spec + Scalar UI
app.openAPIRegistry.registerComponent('securitySchemes', 'BearerAuth', {
type: 'http',

View File

@ -1,5 +1,9 @@
import { createMiddleware } from 'hono/factory';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { createHash } from 'node:crypto';
import { db } from '../db/connection.js';
import { apiKeys } from '../db/schema.js';
import { and, eq, isNull } from 'drizzle-orm';
export interface AuthInfo {
userId: string;
@ -35,6 +39,25 @@ export const authMiddleware = createMiddleware<AuthEnv>(async (c, next) => {
}
const token = authHeader.slice(7);
// API key path
if (token.startsWith('ka_')) {
const hash = createHash('sha256').update(token).digest('hex');
const [key] = await db
.select()
.from(apiKeys)
.where(and(eq(apiKeys.keyHash, hash), isNull(apiKeys.deletedAt)))
.limit(1);
if (!key) {
return c.json({ error: 'Invalid or revoked API key' }, 401);
}
c.set('auth', { userId: key.userId, name: key.label, email: '' });
// fire-and-forget lastUsedAt update
db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, key.id)).catch(() => {});
await next();
return;
}
try {
const { payload } = await jwtVerify(token, JWKS!, {
issuer: [issuerV2, issuerV1],

View File

@ -0,0 +1,62 @@
import { Hono } from 'hono';
import { randomBytes, createHash } from 'node:crypto';
import { db } from '../db/connection.js';
import { apiKeys } from '../db/schema.js';
import { and, eq, isNull } from 'drizzle-orm';
import type { AuthEnv } from '../middleware/auth.js';
const router = new Hono<AuthEnv>();
router.get('/', async (c) => {
const { userId } = c.get('auth');
const rows = await db
.select({
id: apiKeys.id,
label: apiKeys.label,
createdAt: apiKeys.createdAt,
lastUsedAt: apiKeys.lastUsedAt,
})
.from(apiKeys)
.where(and(eq(apiKeys.userId, userId), isNull(apiKeys.deletedAt)));
return c.json(rows);
});
router.post('/', async (c) => {
const { userId } = c.get('auth');
const body = await c.req.json<{ label: string }>();
if (!body.label?.trim()) {
return c.json({ error: 'label required' }, 400);
}
const rawKey = 'ka_' + randomBytes(32).toString('base64url');
const keyHash = createHash('sha256').update(rawKey).digest('hex');
const id = randomBytes(16).toString('hex');
const now = new Date();
await db.insert(apiKeys).values({
id,
userId,
label: body.label.trim(),
keyHash,
createdAt: now,
});
return c.json({ id, label: body.label.trim(), createdAt: now, lastUsedAt: null, key: rawKey }, 201);
});
router.delete('/:id', async (c) => {
const { userId } = c.get('auth');
const id = c.req.param('id');
const result = await db
.update(apiKeys)
.set({ deletedAt: new Date() })
.where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId), isNull(apiKeys.deletedAt)));
if (result.rowsAffected === 0) {
return c.json({ error: 'Not found' }, 404);
}
return c.body(null, 204);
});
export default router;