Ctrl K
ComunidadGuías…

tests/Unit/AuthenticatorTest.php

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?php

declare(strict_types=1);

/**
 * Copyright 2026 codaipro — Lucky Yaduvanshi (https://luckyyaduvanshi.in)
 * Original source: https://github.com/Luckyyaduvanshiofficial/php-s3
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

namespace PhpS3\Tests\Unit;

use PhpS3\Auth\AuthContext;
use PhpS3\Auth\Authenticator;
use PhpS3\Auth\CredentialProvider;
use PhpS3\Auth\SigningKey;
use PhpS3\S3\Exception\S3Exception;
use PHPUnit\Framework\TestCase;

final class AuthenticatorTest extends TestCase
{
    public const SECRET = 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY';
    public const AKID = 'AKIAEXAMPLEKEY000EX';
    public const HOST = 'localhost:8099';

    private Authenticator $auth;

    protected function setUp(): void
    {
        $provider = new class implements CredentialProvider {
            public function find(string $accessKeyId): ?array
            {
                if ($accessKeyId !== AuthenticatorTest::AKID) {
                    return null;
                }

                return [
                    'secret' => AuthenticatorTest::SECRET,
                    'owner_id' => 1,
                    'allowed_buckets' => null,
                    'enabled' => true,
                ];
            }

            public function touch(string $accessKeyId): void
            {
            }
        };
        $this->auth = new Authenticator($provider, 'us-east-1');
    }

    /* ------------------------------------------------------------ happy */

    public function testValidPresignedGetAuthenticates(): void
    {
        $request = $this->presignedRequest('GET', '/bucket/obj.txt');

        $ctx = $this->auth->authenticate($request);

        self::assertTrue($ctx->isPresigned);
        self::assertSame('UNSIGNED-PAYLOAD', $ctx->payloadHash);
        self::assertSame(self::AKID, $ctx->accessKeyId);
        self::assertSame(1, $ctx->ownerId);
    }

    public function testPresignedWithExtraQueryParamsAuthenticates(): void
    {
        $request = $this->presignedRequest('GET', '/bucket/obj.txt', [
            'response-content-disposition' => 'attachment; filename="a b.txt"',
            'versionId' => '3',
        ]);

        self::assertTrue($this->auth->authenticate($request)->isPresigned);
    }

    public function testPresignedGetWithUnsortedQueryOrderAuthenticates(): void
    {
        // Real SDKs emit X-Amz-* params in arbitrary order (e.g. sha256 first);
        // the server must canonicalize (sort + re-encode) before verifying.
        $uri = $this->presignedUri('GET', '/bucket/obj.txt');
        $parts = parse_url($uri);
        self::assertIsArray($parts);
        parse_str((string) ($parts['query'] ?? ''), $q);
        krsort($q);
        $qs = implode('&', array_map(
            static fn (string $k, string $v): string => rawurlencode($k) . '=' . rawurlencode($v),
            array_keys($q),
            array_values($q),
        ));

        $request = php_s3_test_request(
            'GET',
            ($parts['path'] ?? '/bucket/obj.txt') . '?' . $qs,
            ['host' => self::HOST],
        );

        self::assertTrue($this->auth->authenticate($request)->isPresigned);
    }

    public function testHeaderModeStillAuthenticates(): void
    {
        $amzDate = gmdate('Ymd\THis\Z');
        $shortDate = substr($amzDate, 0, 8);
        $scope = $shortDate . '/us-east-1/s3/aws4_request';
        $payload = Authenticator::EMPTY_SHA256;
        $headers = [
            'host' => self::HOST,
            'x-amz-date' => $amzDate,
            'x-amz-content-sha256' => $payload,
        ];
        $signed = ['host', 'x-amz-content-sha256', 'x-amz-date'];
        $canonical = \PhpS3\Auth\CanonicalRequest::build('GET', '/bucket/obj.txt', '', $headers, $signed, $payload);
        $stringToSign = SigningKey::stringToSign('AWS4-HMAC-SHA256', $amzDate, $scope, hash('sha256', $canonical));
        $signature = SigningKey::signature(SigningKey::derive(self::SECRET, $shortDate, 'us-east-1', 's3'), $stringToSign);

        $request = php_s3_test_request('GET', '/bucket/obj.txt', [
            'host' => self::HOST,
            'x-amz-date' => $amzDate,
            'x-amz-content-sha256' => $payload,
            'authorization' => sprintf(
                'AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s',
                self::AKID,
                $scope,
                implode(';', $signed),
                $signature,
            ),
        ]);

        $ctx = $this->auth->authenticate($request);

        self::assertFalse($ctx->isPresigned);
        self::assertSame(self::AKID, $ctx->accessKeyId);
    }

    public function testStreamingHeaderModeAuthenticates(): void
    {
        [$request, $signature, $amzDate] = $this->signedHeaderRequest(
            'STREAMING-AWS4-HMAC-SHA256-PAYLOAD',
            ['content-encoding' => 'aws-chunked', 'x-amz-decoded-content-length' => '11'],
        );

        $ctx = $this->auth->authenticate($request);

        self::assertTrue($ctx->isStreaming());
        self::assertTrue($ctx->chunksAreSigned());
        self::assertSame($signature, $ctx->chunkSeedSignature);
        self::assertSame($amzDate, $ctx->amzDate);
    }

    public function testStreamingUnsignedTrailerHasNoSeed(): void
    {
        [$request] = $this->signedHeaderRequest(
            'STREAMING-UNSIGNED-PAYLOAD-TRAILER',
            [
                'content-encoding' => 'aws-chunked',
                'x-amz-decoded-content-length' => '11',
                'x-amz-trailer' => 'x-amz-checksum-crc32',
            ],
        );

        $ctx = $this->auth->authenticate($request);

        self::assertTrue($ctx->isStreaming());
        self::assertFalse($ctx->chunksAreSigned());
        self::assertNull($ctx->chunkSeedSignature);
    }

    public function testStreamingWithoutDecodedLengthRejected(): void
    {
        [$request] = $this->signedHeaderRequest(
            'STREAMING-AWS4-HMAC-SHA256-PAYLOAD',
            ['content-encoding' => 'aws-chunked'],
        );

        $this->assertAuthError(fn () => $this->auth->authenticate($request), 'InvalidRequest');
    }

    /** @return array{0: \PhpS3\Http\Request, 1: string, 2: string} request, signature, amzDate */
    private function signedHeaderRequest(string $payload, array $extraHeaders, string $method = 'PUT', string $uri = '/bucket/chunked.bin'): array
    {
        $amzDate = gmdate('Ymd\THis\Z');
        $shortDate = substr($amzDate, 0, 8);
        $scope = $shortDate . '/us-east-1/s3/aws4_request';
        $headers = array_merge([
            'host' => self::HOST,
            'x-amz-date' => $amzDate,
            'x-amz-content-sha256' => $payload,
        ], $extraHeaders);
        $signed = ['host', 'x-amz-content-sha256', 'x-amz-date'];
        $canonical = \PhpS3\Auth\CanonicalRequest::build($method, $uri, '', $headers, $signed, $payload);
        $stringToSign = SigningKey::stringToSign('AWS4-HMAC-SHA256', $amzDate, $scope, hash('sha256', $canonical));
        $signature = SigningKey::signature(SigningKey::derive(self::SECRET, $shortDate, 'us-east-1', 's3'), $stringToSign);
        $headers['authorization'] = sprintf(
            'AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s',
            self::AKID,
            $scope,
            implode(';', $signed),
            $signature,
        );

        return [php_s3_test_request($method, $uri, $headers), $signature, $amzDate];
    }

    /* ---------------------------------------------------------- failures */

    public function testTamperedSignatureRejected(): void
    {
        $request = $this->presignedRequest('GET', '/bucket/obj.txt', [], signature: str_repeat('0', 64));

        $this->assertAuthError(fn () => $this->auth->authenticate($request), 'SignatureDoesNotMatch');
    }

    public function testExpiredPresignRejected(): void
    {
        // signed 2000 s ago with a 100 s window → long expired (signature itself valid)
        $request = $this->presignedRequest('GET', '/bucket/obj.txt', [], date: gmdate('Ymd\THis\Z', time() - 2000), expires: 100);

        $e = $this->assertAuthError(fn () => $this->auth->authenticate($request), 'AccessDenied');
        self::assertStringContainsString('expired', $e->awsMessage);
    }

    public function testFutureDatedPresignRejected(): void
    {
        $request = $this->presignedRequest('GET', '/bucket/obj.txt', [], date: gmdate('Ymd\THis\Z', time() + 1000));

        $this->assertAuthError(fn () => $this->auth->authenticate($request), 'RequestTimeTooSkewed');
    }

    public function testExpiresBeyondOneWeekRejected(): void
    {
        $request = $this->presignedRequest('GET', '/bucket/obj.txt', [], expires: 604801);

        $this->assertAuthError(fn () => $this->auth->authenticate($request), 'AuthorizationQueryParametersError');
    }

    public function testMissingSignatureParameterRejected(): void
    {
        $request = php_s3_test_request('GET', $this->presignedUri('GET', '/bucket/obj.txt', signature: ''), ['host' => self::HOST]);

        $e = $this->assertAuthError(fn () => $this->auth->authenticate($request), 'AuthorizationQueryParametersError');
        self::assertStringContainsString('X-Amz-Signature', $e->awsMessage);
    }

    public function testWrongAlgorithmRejected(): void
    {
        $uri = $this->presignedUri('GET', '/bucket/obj.txt', algorithm: 'AWS4-HMAC-SHA1');
        $request = php_s3_test_request('GET', $uri, ['host' => self::HOST]);

        $this->assertAuthError(fn () => $this->auth->authenticate($request), 'AuthorizationQueryParametersError');
    }

    public function testUnknownAccessKeyIdRejected(): void
    {
        $uri = $this->presignedUri('GET', '/bucket/obj.txt', akid: 'AKIADKNOTEXIST00000');
        $request = php_s3_test_request('GET', $uri, ['host' => self::HOST]);

        $this->assertAuthError(fn () => $this->auth->authenticate($request), 'InvalidAccessKeyId');
    }

    public function testNoAuthAtAllStillRequiresAuthorizationHeader(): void
    {
        $request = php_s3_test_request('GET', '/bucket/obj.txt', ['host' => self::HOST]);

        $this->assertAuthError(fn () => $this->auth->authenticate($request), 'MissingSecurityHeader');
    }

    /* ---------------------------------------------------------- helpers */

    private function presignedRequest(string $method, string $uri, array $extraQuery = [], ?string $date = null, ?int $expires = null, ?string $signature = null, string $akid = self::AKID, string $algorithm = 'AWS4-HMAC-SHA256')
    {
        return php_s3_test_request($method, $this->presignedUri($method, $uri, $extraQuery, $date, $expires, $signature, $akid, $algorithm), ['host' => self::HOST]);
    }

    private function presignedUri(
        string $method,
        string $uri,
        array $extraQuery = [],
        ?string $date = null,
        ?int $expires = null,
        ?string $signature = null,
        string $akid = self::AKID,
        string $algorithm = 'AWS4-HMAC-SHA256',
    ): string {
        $date ??= gmdate('Ymd\THis\Z');
        $expires ??= 900;
        $shortDate = substr($date, 0, 8);
        $scope = $shortDate . '/us-east-1/s3/aws4_request';

        $params = array_merge([
            'X-Amz-Algorithm' => $algorithm,
            'X-Amz-Credential' => "{$akid}/{$scope}",
            'X-Amz-Date' => $date,
            'X-Amz-Expires' => (string) $expires,
            'X-Amz-SignedHeaders' => 'host',
        ], $extraQuery);

        if ($signature === null) {
            $qs = implode('&', array_map(
                static fn (string $k, string $v): string => rawurlencode($k) . '=' . rawurlencode($v),
                array_keys($params),
                array_values($params),
            ));
            $canonical = \PhpS3\Auth\CanonicalRequest::build(
                $method,
                \PhpS3\Auth\CanonicalRequest::canonicalUri($uri),
                \PhpS3\Auth\CanonicalRequest::canonicalQueryString($qs),
                ['host' => self::HOST],
                ['host'],
                'UNSIGNED-PAYLOAD',
            );
            $stringToSign = SigningKey::stringToSign('AWS4-HMAC-SHA256', $date, $scope, hash('sha256', $canonical));
            $signature = SigningKey::signature(SigningKey::derive(self::SECRET, $shortDate, 'us-east-1', 's3'), $stringToSign);
        }
        if ($signature === '') {
            // explicit empty = omit the parameter entirely
            return $uri . '?' . implode('&', array_map(
                static fn (string $k, string $v): string => rawurlencode($k) . '=' . rawurlencode($v),
                array_keys($params),
                array_values($params),
            ));
        }
        $params['X-Amz-Signature'] = $signature;

        return $uri . '?' . implode('&', array_map(
            static fn (string $k, string $v): string => rawurlencode($k) . '=' . rawurlencode($v),
            array_keys($params),
            array_values($params),
        ));
    }

    private function assertAuthError(callable $fn, string $expectedCode): S3Exception
    {
        try {
            $fn();
        } catch (S3Exception $e) {
            self::assertSame($expectedCode, $e->errorCode, $e->getMessage());

            return $e;
        }
        self::fail("Expected S3Exception {$expectedCode}, none thrown");
    }
}