Backend-side admin-auth. Admin-App (apps/admin/) braucht das damit
useAdminAuth.verifyAdminRole() nach Login server-side prüfen kann ob User
in admin_users-tabelle steht.
New schema:
- model AdminUser → table rebreak.admin_users (user_id UUID PK FK Profile.id,
created_at, added_by). Migration 20260508_admin_users/migration.sql.
- ⚠️ SCHEMA-MIGRATION — NICHT autopushen. User entscheidet wann pipeline
triggert.
New backend code:
- backend/server/db/admin.ts: isAdminUser(userId) → boolean
- backend/server/utils/auth.ts: requireAdmin(event) wraps requireUser +
isAdminUser-check. Throws 403 wenn nicht admin.
- backend/server/api/admin/verify-admin.get.ts: GET endpoint. Returns
{ isAdmin: true, userId, email } bei success, 403 sonst, 401 if not auth'd.
Tests (5 cases in tests/admin/verify-admin.test.ts):
- isAdminUser DB-layer: row exists/null
- requireAdmin: admin → user, non-admin → 403, no token → 401
- Endpoint: admin → success, non-admin → 403
Pending User-Actions nach Push+Deploy:
1. Migration deploy auf staging:
ssh rebreak-server && cd /srv/rebreak && pnpm exec prisma migrate deploy
2. Seed-Admin eintragen:
INSERT INTO "rebreak"."admin_users" ("user_id", "created_at")
VALUES ('128df360-2008-4d6f-8aa1-bdb41ec1362f', NOW())
ON CONFLICT DO NOTHING;
3. Admin-App composables/useAdminAuth.ts kann dann verifyAdminRole()
gegen GET /api/admin/verify-admin aufrufen
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
114 lines
3.4 KiB
TypeScript
114 lines
3.4 KiB
TypeScript
import { createClient } from '@supabase/supabase-js';
|
||
import type { H3Event } from 'h3';
|
||
import { isAdminUser } from '../db/admin';
|
||
import { findUserDevice, registerDevice, touchDevice } from '../db/devices';
|
||
import { getProfile } from '../db/profile';
|
||
import { getPlanLimits } from './plan-features';
|
||
|
||
const TOUCH_THROTTLE_MS = 60_000; // touch lastSeenAt höchstens 1×/min pro Device
|
||
|
||
export interface RequireUserOptions {
|
||
/** Bootstrap-Endpoints (z.B. /api/devices/register) brauchen kein Device-Binding */
|
||
skipDeviceCheck?: boolean;
|
||
}
|
||
|
||
export async function requireUser(
|
||
event: H3Event,
|
||
opts: RequireUserOptions = {},
|
||
) {
|
||
const authHeader = getHeader(event, 'authorization');
|
||
let token = authHeader?.replace('Bearer ', '');
|
||
|
||
if (!token) {
|
||
const query = getQuery(event);
|
||
token = query.token as string;
|
||
}
|
||
|
||
if (!token) {
|
||
throw createError({ statusCode: 401, message: 'Nicht eingeloggt' });
|
||
}
|
||
|
||
const config = useRuntimeConfig(event);
|
||
const supabaseCfg =
|
||
(config as any).public?.supabase ?? (config as any).supabase;
|
||
const client = createClient(
|
||
supabaseCfg.url as string,
|
||
supabaseCfg.key as string,
|
||
{ global: { headers: { Authorization: `Bearer ${token}` } } },
|
||
);
|
||
|
||
const {
|
||
data: { user },
|
||
error,
|
||
} = await client.auth.getUser();
|
||
|
||
if (error || !user) {
|
||
throw createError({ statusCode: 401, message: 'Nicht eingeloggt' });
|
||
}
|
||
|
||
if (opts.skipDeviceCheck) return user;
|
||
|
||
// Device-Binding: nur enforced wenn Client einen x-device-id Header schickt.
|
||
// Web-Clients ohne Header laufen weiter wie bisher.
|
||
const deviceId = getHeader(event, 'x-device-id');
|
||
if (!deviceId) return user;
|
||
|
||
const existing = await findUserDevice(user.id, deviceId);
|
||
if (existing) {
|
||
// Touch lastSeenAt, throttled auf 1×/min — fire-and-forget
|
||
if (Date.now() - existing.lastSeenAt.getTime() > TOUCH_THROTTLE_MS) {
|
||
touchDevice(user.id, deviceId).catch(() => {});
|
||
}
|
||
return user;
|
||
}
|
||
|
||
// Device unbekannt — Auto-Register (ohne Frontend-explicit-call)
|
||
// Plan-Limit holen
|
||
const profile = await getProfile(user.id);
|
||
const limits = getPlanLimits(profile?.plan ?? 'free');
|
||
const platform = getHeader(event, 'x-platform') ?? 'unknown';
|
||
|
||
try {
|
||
await registerDevice({
|
||
userId: user.id,
|
||
deviceId,
|
||
platform,
|
||
maxDevices: limits.maxDevices,
|
||
});
|
||
return user;
|
||
} catch (err: any) {
|
||
if (err.code === 'DEVICE_LIMIT_REACHED') {
|
||
// Devices-Liste mitschicken damit das Frontend-Modal die Geräte
|
||
// anzeigen + Freigeben-Buttons rendern kann (auch wenn der 403
|
||
// nicht vom register-Endpoint sondern vom auth-Middleware kommt).
|
||
const { listUserDevices } = await import('../db/devices');
|
||
const devices = await listUserDevices(user.id);
|
||
throw createError({
|
||
statusCode: 403,
|
||
statusMessage: 'device_limit_reached',
|
||
data: {
|
||
error: 'device_limit_reached',
|
||
max: limits.maxDevices,
|
||
plan: profile?.plan ?? 'free',
|
||
devices,
|
||
},
|
||
});
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* requireAdmin — wirft 401 wenn nicht eingeloggt, 403 wenn nicht in admin_users.
|
||
* Wraps requireUser mit skipDeviceCheck=true (Admin-App hat kein Device-Binding).
|
||
*/
|
||
export async function requireAdmin(event: H3Event) {
|
||
const user = await requireUser(event, { skipDeviceCheck: true });
|
||
|
||
const admin = await isAdminUser(user.id);
|
||
if (!admin) {
|
||
throw createError({ statusCode: 403, message: 'Kein Admin-Zugang' });
|
||
}
|
||
|
||
return user;
|
||
} |