Files
Deploymentcenter/src/Core/ApiAuth.php
T

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;
}
}