added api-keys
This commit is contained in:
parent
ab5e38abc2
commit
d510e96541
|
|
@ -1 +1 @@
|
|||
1.1.26
|
||||
1.1.27
|
||||
|
|
@ -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 "{confirmDelete.label}" wirklich löschen? Alle Anwendungen, die diesen Key verwenden, verlieren den Zugriff."
|
||||
onconfirm={() => deleteApiKey(confirmDelete!)}
|
||||
oncancel={() => confirmDelete = null}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue