300 lines
11 KiB
PHP
300 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Deploymentcenter\Core;
|
|
|
|
use PDO;
|
|
|
|
/**
|
|
* Einheitliche Authentifizierung fuer alle API-Endpunkte.
|
|
*
|
|
* Drei akzeptierte Wege, in dieser Reihenfolge geprueft:
|
|
* 1. Shared Key aus der Konfiguration (Server-zu-Server, z. B. Cron)
|
|
* 2. Aktive WebUI-Session (nur wenn ausdruecklich erlaubt)
|
|
* 3. Master-/Sub-Token aus dc_tokens mit passendem Scope
|
|
*
|
|
* Frueher hatte jeder Endpunkt seine eigene Variante davon - mit jeweils
|
|
* leicht abweichendem Verhalten. Diese Klasse ersetzt alle.
|
|
*/
|
|
final class ApiAuth
|
|
{
|
|
/**
|
|
* Erzwingt Authentifizierung mit einem bestimmten Scope.
|
|
* Bricht die Anfrage bei Fehlschlag mit 401 ab.
|
|
*
|
|
* @return array{method:string,actor:string,token:?array,project_slug:?string,environment:?string}
|
|
*/
|
|
public static function requireScope(
|
|
PDO $db,
|
|
string $scope,
|
|
?string $environment = null,
|
|
bool $allowSession = true
|
|
): array {
|
|
$context = self::resolve($db, $scope, $environment, $allowSession);
|
|
|
|
if ($context === null) {
|
|
Http::fail(
|
|
401,
|
|
'unauthorized',
|
|
sprintf('Authentifizierung erforderlich. Erwartet wird ein Token mit dem Recht "%s".', $scope),
|
|
null,
|
|
['required_scope' => $scope]
|
|
);
|
|
}
|
|
|
|
return $context;
|
|
}
|
|
|
|
/**
|
|
* Wie requireScope(), bricht aber nicht ab, sondern liefert null.
|
|
*
|
|
* @return array{method:string,actor:string,token:?array,project_slug:?string,environment:?string}|null
|
|
*/
|
|
public static function resolve(
|
|
PDO $db,
|
|
string $scope,
|
|
?string $environment = null,
|
|
bool $allowSession = true
|
|
): ?array {
|
|
// 0. IP-Sperre pruefen
|
|
$ip = Http::clientIp();
|
|
$ipLockout = Auth::lockoutSeconds($db, $ip);
|
|
if ($ipLockout > 0) {
|
|
SecurityLogger::ipLocked($ip, (int)Config::get('security.max_ip_attempts', Auth::DEFAULT_MAX_IP_ATTEMPTS), $ipLockout, ['context' => 'api']);
|
|
Http::fail(429, 'locked_out', 'IP-Adresse aufgrund wiederholter Fehlversuche vorübergehend gesperrt.', null, ['retry_after_seconds' => $ipLockout]);
|
|
}
|
|
|
|
$presented = Http::bearerToken();
|
|
|
|
// 1. Shared Key
|
|
$sharedKey = (string)Config::get('security.shared_key', '');
|
|
if ($presented !== null && $sharedKey !== '' && hash_equals($sharedKey, $presented)) {
|
|
return [
|
|
'method' => 'shared_key',
|
|
'actor' => 'system:shared-key',
|
|
'token' => null,
|
|
'project_slug' => null,
|
|
'environment' => null,
|
|
];
|
|
}
|
|
|
|
// 2. WebUI-Session
|
|
if ($allowSession && Auth::isLoggedIn()) {
|
|
return [
|
|
'method' => 'session',
|
|
'actor' => Auth::username(),
|
|
'token' => null,
|
|
'project_slug' => null,
|
|
'environment' => null,
|
|
];
|
|
}
|
|
|
|
// 3. Agenten-Token
|
|
if ($presented !== null) {
|
|
$manager = new TokenManager($db);
|
|
$token = $manager->validateToken($presented, $scope, $environment);
|
|
|
|
if (is_array($token)) {
|
|
$name = isset($token['name']) && $token['name'] !== ''
|
|
? (string)$token['name']
|
|
: (string)$token['token_id'];
|
|
|
|
return [
|
|
'method' => 'token',
|
|
'actor' => 'agent:' . $name,
|
|
'token' => $token,
|
|
'project_slug' => self::stringOrNull($token['project_slug'] ?? null),
|
|
'environment' => self::stringOrNull($token['environment'] ?? null),
|
|
];
|
|
}
|
|
|
|
SecurityLogger::apiAuthFail($ip, Http::path(), 'token_rejected', ['scope' => $scope]);
|
|
|
|
// Drosselung bei wiederholtem Vorlegen ungueltiger Tokens
|
|
$failLimiter = new RateLimiter($db, 20, 300, 'api_token_failures');
|
|
if (!$failLimiter->check($ip)) {
|
|
SecurityLogger::log('API_BRUTE_FORCE', $ip, [
|
|
'path' => Http::path(),
|
|
'reason' => 'too_many_invalid_tokens',
|
|
]);
|
|
Auth::triggerSecurityAlert($db, 'security.attack', [
|
|
'ip' => $ip,
|
|
'details' => 'Mehr als 20 ungültige API-Tokens innerhalb von 5 Minuten übermittelt.',
|
|
'target' => Http::path(),
|
|
'type' => 'API-Token-Brute-Force',
|
|
]);
|
|
Http::fail(429, 'rate_limited', 'Zu viele fehlerhafte Authentifizierungsversuche. Bitte 5 Minuten warten.');
|
|
}
|
|
|
|
Logger::warning('Token abgelehnt', [
|
|
'scope' => $scope,
|
|
'ip' => $ip,
|
|
'path' => Http::path(),
|
|
]);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Ist das ein Werkbank-Token?
|
|
*
|
|
* Der Unterschied, der zaehlt, ist nicht "welches Projekt", sondern WO das
|
|
* Token liegt:
|
|
*
|
|
* - Laufzeit-Token stecken im ausgelieferten Client, also auf fremder
|
|
* Hardware. Sie sind extrahierbar. Ein solches Token darf ausschliesslich
|
|
* in sein eigenes Projekt schreiben - sonst koennte ein Kunde mit einem
|
|
* ausgelesenen Token in fremde Tracker schreiben.
|
|
* - Werkbank-Token (owner_type "dev_agent") liegen auf einer eigenen
|
|
* Maschine: Claude Code, der MCP-Adapter, die CI. Sie duerfen ueber die
|
|
* Projekte hinweg arbeiten.
|
|
*
|
|
* Die Instanz fuehrt ausschliesslich eigene Projekte; eine geteilte Instanz
|
|
* fuer Fremde ist ausgeschlossen. Deshalb genuegt der Token-Typ als
|
|
* Unterscheidung - ohne Mandantentrennung und ohne Liste erlaubter Ziele.
|
|
*
|
|
* @param array{token:?array} $context
|
|
*/
|
|
public static function isWorkbench(array $context): bool
|
|
{
|
|
$token = $context['token'] ?? null;
|
|
|
|
return is_array($token) && ($token['owner_type'] ?? null) === 'dev_agent';
|
|
}
|
|
|
|
/**
|
|
* Das Projekt, in dem der Aufrufer zuhause ist - unabhaengig davon, welches
|
|
* Projekt er gerade anspricht.
|
|
*
|
|
* Auch ein Werkbank-Token bleibt an sein Heimatprojekt gebunden, obwohl es
|
|
* fremde ansprechen darf. Das ist der Punkt: die Herkunft einer
|
|
* Fremdmeldung (reported_from) wird daraus abgeleitet und ist damit
|
|
* bezeugt statt behauptet. Koennte ein Agent sie frei angeben, waere das
|
|
* Signal "drei Projekte melden dasselbe" wertlos.
|
|
*
|
|
* @param array{project_slug:?string} $context
|
|
*/
|
|
public static function homeProject(array $context): ?string
|
|
{
|
|
$bound = $context['project_slug'] ?? null;
|
|
|
|
return is_string($bound) && $bound !== '' ? $bound : null;
|
|
}
|
|
|
|
/**
|
|
* Stellt sicher, dass ein projektgebundenes Token nur auf sein eigenes
|
|
* Projekt zugreift. Bricht sonst mit 403 ab.
|
|
*
|
|
* $allowWorkbench oeffnet die Schranke fuer Werkbank-Token. Der Vorgabewert
|
|
* false ist Absicht: die Ausnahme wird an jeder Aufrufstelle einzeln
|
|
* gesetzt, nie global. Ein Werkbank-Token darf melden und kommentieren -
|
|
* aber nicht in einem fremden Projekt veroeffentlichen, einrichten oder
|
|
* Items abschliessen. Ein pauschaler Freibrief haette genau das getan.
|
|
*
|
|
* @param array{project_slug:?string,method:string,token:?array} $context
|
|
*/
|
|
public static function enforceProject(
|
|
array $context,
|
|
?string $requestedSlug,
|
|
bool $allowWorkbench = false
|
|
): void {
|
|
$bound = $context['project_slug'] ?? null;
|
|
|
|
if ($bound === null || $bound === '') {
|
|
return; // Token ist nicht projektgebunden
|
|
}
|
|
|
|
if ($requestedSlug === null || $requestedSlug === '' || $requestedSlug === $bound) {
|
|
return;
|
|
}
|
|
|
|
if ($allowWorkbench && self::isWorkbench($context)) {
|
|
return;
|
|
}
|
|
|
|
Http::fail(
|
|
403,
|
|
'project_forbidden',
|
|
sprintf('Dieses Token ist an das Projekt "%s" gebunden und darf nicht auf "%s" zugreifen.', $bound, $requestedSlug),
|
|
null,
|
|
['bound_project' => $bound]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Liefert den Projekt-Slug, auf den eine Anfrage eingeschraenkt werden muss,
|
|
* oder null bei uneingeschraenktem Zugriff.
|
|
*
|
|
* $allowWorkbench hebt die Einschraenkung fuer Werkbank-Token auf. Beim
|
|
* Lesen ist das gewollt: ein Agent, der entscheiden soll, ob ein Fehler ins
|
|
* eigene oder ins fremde Projekt gehoert, muss die Landschaft sehen. Beim
|
|
* Schreiben bleibt der Vorgabewert false stehen - dort schuetzt genau diese
|
|
* Einschraenkung fremde Items vor Zugriff.
|
|
*
|
|
* @param array{project_slug:?string,token:?array} $context
|
|
*/
|
|
public static function projectFilter(array $context, bool $allowWorkbench = false): ?string
|
|
{
|
|
if ($allowWorkbench && self::isWorkbench($context)) {
|
|
return null;
|
|
}
|
|
|
|
$bound = $context['project_slug'] ?? null;
|
|
return is_string($bound) && $bound !== '' ? $bound : null;
|
|
}
|
|
|
|
/**
|
|
* Sagt es, wenn eine Antwort ein anderes Projekt betrifft als das angefragte.
|
|
*
|
|
* DAS PROBLEM IST DIE STILLE, nicht die Verengung. Ein an Projekt X
|
|
* gebundenes Token, das nach Projekt Y fragt, bekommt beim SCHREIBEN eine
|
|
* klare Absage (403 project_forbidden) und beim LESEN HTTP 200 - mit den
|
|
* Items von X und einem Umschlag, in dem der Projektfilter bereits auf X
|
|
* umgeschrieben ist. Die Antwort sieht damit aus wie eine gueltige
|
|
* Auskunft ueber Y.
|
|
*
|
|
* Das ist keine theoretische Verwechslung: Die Prompt-Vorlage schickt
|
|
* jeden Agenten vor jeder Meldung durch dc_search ("gibt es das schon?").
|
|
* Mit der falschen Tokenart bekommt er auf "gibt es das im
|
|
* Deploymentcenter schon?" eine leere, plausibel aussehende Antwort - und
|
|
* legt das Duplikat an. Genau so ist es einem Melder beinahe passiert;
|
|
* tatsaechlich waren es 71 Items.
|
|
*
|
|
* Die Schranke bleibt unveraendert. Sie sagt jetzt nur, dass sie zugegangen
|
|
* ist. (#131)
|
|
*
|
|
* @param ?string $requested Was der Aufrufer angefragt hat, oder null.
|
|
* @param string $actual Worueber tatsaechlich geantwortet wird.
|
|
* @return array{scoped_to?:string,scope_note?:string} Leer, wenn nichts verengt wurde.
|
|
*/
|
|
public static function scopeNotice(?string $requested, string $actual): array
|
|
{
|
|
if ($requested === null || $requested === '' || $requested === $actual) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
'scoped_to' => $actual,
|
|
'scope_note' => sprintf(
|
|
'Angefragt war "%s", geantwortet wird ueber "%s": Dieses Token ist an "%s" '
|
|
. 'gebunden. Die Antwort sagt NICHTS ueber "%s" aus - insbesondere heisst eine '
|
|
. 'leere Liste hier nicht, dass es dort nichts gibt.',
|
|
$requested,
|
|
$actual,
|
|
$actual,
|
|
$requested
|
|
),
|
|
];
|
|
}
|
|
|
|
private static function stringOrNull($value): ?string
|
|
{
|
|
if (!is_string($value)) {
|
|
return null;
|
|
}
|
|
return $value === '' ? null : $value;
|
|
}
|
|
}
|