KeyHelp: Panel-Passwort neu vergeben und verschlüsselt hinterlegen (verblurrt wie Lizenzschlüssel)
This commit is contained in:
parent
3a2864ee76
commit
6fec6277f5
9 changed files with 136 additions and 20 deletions
|
|
@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { ACTION_CAPABILITY, ACTIONS, loadInstance, type ActionName } from '@kc/connectors';
|
||||
import { ConnectorError, type ChildKind } from '@kc/connector-sdk';
|
||||
import { encrypt } from '../../core/crypto.js';
|
||||
import { decrypt, encrypt, randomToken } from '../../core/crypto.js';
|
||||
import { CHILD_MANAGE, CUSTOMER_ACTIONS } from '../../core/actions.js';
|
||||
import { rl } from '../../core/config.js';
|
||||
import { DESTRUCTIVE_ACTIONS } from '@kc/connector-sdk';
|
||||
|
|
@ -27,6 +27,7 @@ function view(r: any, staff: boolean, a?: AuthContext) {
|
|||
return {
|
||||
id: r.id, type: r.type, name: r.name, state: r.state, orgId: r.org_id, validFrom: r.valid_from, validUntil: r.valid_until, syncedAt: r.synced_at, missing: !!r.missing_since,
|
||||
canReveal: a ? canReveal(r, a) : undefined,
|
||||
canResetPassword: a ? canResetPassword(r, a) : undefined,
|
||||
stale, staleReason: r.health !== 'ok' ? (r.health_message ?? 'Der Dienst ist derzeit nicht erreichbar.') : stale ? 'Die Daten sind älter als erwartet.' : null,
|
||||
...(staff ? { instance: r.instance_name, connector: r.connector_key, externalRef: r.external_ref } : {}),
|
||||
};
|
||||
|
|
@ -48,6 +49,11 @@ function canReveal(r: any, a: AuthContext): boolean {
|
|||
if (!caps.includes('secret.reveal') || !r.enabled) return false;
|
||||
return can(a.principal, 'resources.write') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write'));
|
||||
}
|
||||
/** Panel-Passwort neu vergeben/einsehen: nur Personal mit Schreibrecht (kein Kunden-Selfservice, ändert ein echtes Zugangsdatum). */
|
||||
function canResetPassword(r: any, a: AuthContext): boolean {
|
||||
const caps = json<string[]>(r.capabilities_json, []);
|
||||
return caps.includes('panel.password_reset') && !!r.enabled && r.health === 'ok' && can(a.principal, 'resources.write');
|
||||
}
|
||||
function access(a: AuthContext, r: any): boolean {
|
||||
return can(a.principal, 'resources.read') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'resources.read', 'resources.read'));
|
||||
}
|
||||
|
|
@ -81,7 +87,7 @@ export const resourcesModule: KcModule = {
|
|||
const staff = can(a.principal, 'resources.read');
|
||||
const jobs = await query("SELECT id, status, last_error, attempts, created_at, updated_at, JSON_VALUE(payload, '$.action') AS action FROM jobs WHERE type = 'connector.execute' AND JSON_VALUE(payload, '$.resourceId') = ? ORDER BY created_at DESC LIMIT 10", [id]);
|
||||
const data = json<{ limits?: object; usage?: object; details?: object }>(r.data_json, {});
|
||||
return { ...view(r, staff), limits: data.limits ?? {}, usage: data.usage ?? {}, details: data.details ?? {}, allowedActions: allowedActions(r, a), canReveal: canReveal(r, a), hasChildren: childCapsTop(r).includes('children.read'), canLogin: canLoginTop(r, a), customerActions: staff ? json(r.customer_actions, []) : undefined, jobs: jobs.map((j) => ({ id: j.id, action: j.action, status: j.status, error: j.last_error, attempts: j.attempts, createdAt: j.created_at, updatedAt: j.updated_at })) };
|
||||
return { ...view(r, staff), limits: data.limits ?? {}, usage: data.usage ?? {}, details: data.details ?? {}, allowedActions: allowedActions(r, a), canReveal: canReveal(r, a), canResetPassword: canResetPassword(r, a), hasChildren: childCapsTop(r).includes('children.read'), canLogin: canLoginTop(r, a), customerActions: staff ? json(r.customer_actions, []) : undefined, jobs: jobs.map((j) => ({ id: j.id, action: j.action, status: j.status, error: j.last_error, attempts: j.attempts, createdAt: j.created_at, updatedAt: j.updated_at })) };
|
||||
});
|
||||
|
||||
app.post('/resources/:id/actions', async (req, reply) => {
|
||||
|
|
@ -137,6 +143,43 @@ export const resourcesModule: KcModule = {
|
|||
return { items, hideAfterSec: 60 };
|
||||
});
|
||||
|
||||
// ---- Panel-Zugangsdaten: bestehende Passwörter sind bei Anbietern grundsätzlich nicht lesbar; hier wird
|
||||
// stattdessen ein NEUES Passwort erzeugt, beim Anbieter gesetzt und bei uns verschlüsselt hinterlegt. ----
|
||||
app.get('/resources/:id/panel-credentials', async (req) => {
|
||||
const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await loadResource(id); if (!r || !access(a, r)) throw notFound();
|
||||
const c = await one('SELECT set_at FROM panel_credentials WHERE resource_id = ?', [id]);
|
||||
return { canReset: canResetPassword(r, a), set: !!c, setAt: c?.set_at ?? null };
|
||||
});
|
||||
app.post('/resources/:id/panel-credentials/reset', async (req, reply) => {
|
||||
const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await loadResource(id); if (!r || !access(a, r)) throw notFound();
|
||||
if (!canResetPassword(r, a)) throw forbidden('Zugangsdaten können für diese Ressource nicht neu vergeben werden', 'RESET_FORBIDDEN');
|
||||
const password = randomToken(15); // ~20 Zeichen, base64url – druckbar, keine Sonderzeichen, die Formulare/Shells stören könnten
|
||||
const secretEnc = encrypt(JSON.stringify({ password }));
|
||||
const hdr = req.headers['idempotency-key'];
|
||||
const key = `pwreset:${typeof hdr === 'string' && /^[\w-]{8,100}$/.test(hdr) ? hdr : createHash('sha256').update(`${id}|${Math.floor(Date.now() / 10000)}`).digest('hex').slice(0, 40)}`;
|
||||
const existing = await one('SELECT id FROM jobs WHERE idempotency_key = ?', [key]);
|
||||
const jobId = existing?.id ?? randomUUID();
|
||||
if (!existing) {
|
||||
await run('INSERT INTO jobs (id, type, payload, idempotency_key, correlation_id) VALUES (?,?,?,?,?)',
|
||||
[jobId, 'connector.execute', JSON.stringify({ resourceId: id, action: 'reset_password', secretEnc, actorUserId: a.user.id, destructive: true }), key, req.correlationId]);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'resource.reset_password.request', resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req), after: { jobId } });
|
||||
}
|
||||
return reply.code(202).send({ jobId, duplicate: !!existing });
|
||||
});
|
||||
app.post('/resources/:id/panel-credentials/reveal', { config: rl(10, '1 minute') }, async (req, reply) => {
|
||||
const a = requireAuth(req); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const r = await loadResource(id); if (!r || !access(a, r)) throw notFound();
|
||||
if (!canResetPassword(r, a)) throw forbidden('Zugangsdaten können für diese Ressource nicht eingesehen werden', 'REVEAL_FORBIDDEN');
|
||||
const c = await one('SELECT secret_enc FROM panel_credentials WHERE resource_id = ?', [id]);
|
||||
if (!c) throw notFound('Es wurde noch kein Passwort vergeben.');
|
||||
const password = decrypt(c.secret_enc);
|
||||
await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'resource.panel_credentials.reveal', resourceType: 'resource', resourceId: id, connector: r.connector_key, correlationId: req.correlationId, ip: clientIp(req) });
|
||||
reply.header('cache-control', 'no-store');
|
||||
return { items: [{ label: 'Panel-Passwort', value: password }], hideAfterSec: 60 };
|
||||
});
|
||||
|
||||
// ---- Hosting: Unterobjekte (Domains, Postfächer, Datenbanken, FTP, SSL) und Panel-Login ----------
|
||||
const KINDS = ['domain', 'email', 'database', 'ftp', 'certificate'] as const;
|
||||
const kindParam = z.object({ id: z.string().uuid(), kind: z.enum(KINDS) });
|
||||
|
|
|
|||
|
|
@ -120,3 +120,39 @@ describe('KeyHelp: Verbindung, Kundenübernahme, neue Tarife, Hosting-Funktionen
|
|||
fake.opts.failGet503 = 50; const down = await call(app, owner, 'GET', `/resources/${cres.id}/children/domain`); expect(down.statusCode).toBe(502); fake.opts.failGet503 = 0;
|
||||
});
|
||||
});
|
||||
|
||||
describe('KeyHelp: Panel-Passwort neu vergeben', () => {
|
||||
it('erzeugt ein neues Passwort, setzt es beim Anbieter, speichert es verschlüsselt und lässt es auf Abruf ansehen', async () => {
|
||||
const admin = await staff('kh-pw-admin@x.test', 'admin'); const support = await staff('kh-pw-support@x.test', 'support');
|
||||
await call(app, admin, 'POST', '/admin/connectors', { connector: 'keyhelp', name: 'KeyHelp-PW-Test', values: { baseUrl: 'https://kh.test', apiKey: 'kh-test-key' } });
|
||||
await drain();
|
||||
const res = (await call(app, admin, 'GET', '/resources')).json(); const alpha = res.find((r: any) => r.name.includes('alpha'));
|
||||
|
||||
// ohne Berechtigung (Support nur lesend)
|
||||
expect((await call(app, support, 'POST', `/resources/${alpha.id}/panel-credentials/reset`)).statusCode).toBe(403);
|
||||
const before = (await call(app, admin, 'GET', `/resources/${alpha.id}/panel-credentials`)).json();
|
||||
expect(before).toEqual({ canReset: true, set: false, setAt: null });
|
||||
|
||||
const reset = await call(app, admin, 'POST', `/resources/${alpha.id}/panel-credentials/reset`); expect(reset.statusCode).toBe(202);
|
||||
await drain();
|
||||
const fakeClient = fake.state.clients.find((c) => c.username === 'alpha')!;
|
||||
const after = (await call(app, admin, 'GET', `/resources/${alpha.id}/panel-credentials`)).json();
|
||||
expect(after.set).toBe(true); expect(after.setAt).toBeTruthy();
|
||||
|
||||
const revealed = (await call(app, admin, 'POST', `/resources/${alpha.id}/panel-credentials/reveal`)).json();
|
||||
expect(revealed.items).toEqual([{ label: 'Panel-Passwort', value: (fakeClient as any).password }]);
|
||||
expect((revealed.items[0].value as string).length).toBeGreaterThan(15);
|
||||
expect((await call(app, support, 'POST', `/resources/${alpha.id}/panel-credentials/reveal`)).statusCode).toBe(403);
|
||||
|
||||
// in der Datenbank steht das Passwort nur verschlüsselt
|
||||
const row = await one('SELECT secret_enc FROM panel_credentials WHERE resource_id = ?', [alpha.id]);
|
||||
expect(row!.secret_enc).toMatch(/^v1:/); expect(row!.secret_enc).not.toContain((fakeClient as any).password);
|
||||
|
||||
// erneutes Vergeben ersetzt das alte Passwort (eigener Idempotenzschlüssel, damit es nicht als Doppelklick gilt)
|
||||
const oldPw = (fakeClient as any).password;
|
||||
await app.inject({ method: 'POST', url: `/v1/resources/${alpha.id}/panel-credentials/reset`, headers: { cookie: admin.cookie, 'x-csrf-token': admin.csrf, 'idempotency-key': 'zweiter-reset-test' } });
|
||||
await drain();
|
||||
const revealed2 = (await call(app, admin, 'POST', `/resources/${alpha.id}/panel-credentials/reveal`)).json();
|
||||
expect(revealed2.items[0].value).not.toBe(oldPw);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue