<?php

declare(strict_types=1);

namespace Sabre\HTTP\Auth;

use Sabre\HTTP;

/**
 * HTTP AWS Authentication handler.
 *
 * Use this class to leverage amazon's AWS authentication header
 *
 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
 * @author Evert Pot (http://evertpot.com/)
 * @license http://sabre.io/license/ Modified BSD License
 */
class AWS extends AbstractAuth
{
    /**
     * The signature supplied by the HTTP client.
     *
     * @var string
     */
    private $signature = null;

    /**
     * The accesskey supplied by the HTTP client.
     *
     * @var string
     */
    private $accessKey = null;

    /**
     * An error code, if any.
     *
     * This value will be filled with one of the ERR_* constants
     *
     * @var int
     */
    public $errorCode = 0;

    const ERR_NOAWSHEADER = 1;
    const ERR_MD5CHECKSUMWRONG = 2;
    const ERR_INVALIDDATEFORMAT = 3;
    const ERR_REQUESTTIMESKEWED = 4;
    const ERR_INVALIDSIGNATURE = 5;

    /**
     * Gathers all information from the headers.
     *
     * This method needs to be called prior to anything else.
     */
    public function init(): bool
    {
        $authHeader = $this->request->getHeader('Authorization');

        if (null === $authHeader) {
            $this->errorCode = self::ERR_NOAWSHEADER;

            return false;
        }
        $authHeader = explode(' ', $authHeader);

        if ('AWS' !== $authHeader[0] || !isset($authHeader[1])) {
            $this->errorCode = self::ERR_NOAWSHEADER;

            return false;
        }

        list($this->accessKey, $this->signature) = explode(':', $authHeader[1]);

        return true;
    }

    /**
     * Returns the username for the request.
     */
    public function getAccessKey(): string
    {
        return $this->accessKey;
    }

    /**
     * Validates the signature based on the secretKey.
     */
    public function validate(string $secretKey): bool
    {
        $contentMD5 = $this->request->getHeader('Content-MD5');

        if ($contentMD5) {
            // We need to validate the integrity of the request
            $body = $this->request->getBody();
            $this->request->setBody($body);

            if ($contentMD5 !== base64_encode(md5((string) $body, true))) {
                // content-md5 header did not match md5 signature of body
                $this->errorCode = self::ERR_MD5CHECKSUMWRONG;

                return false;
            }
        }

        if (!$requestDate = $this->request->getHeader('x-amz-date')) {
            $requestDate = $this->request->getHeader('Date');
        }

        if (!$this->validateRFC2616Date((string) $requestDate)) {
            return false;
        }

        $amzHeaders = $this->getAmzHeaders();

        $signature = base64_encode(
            $this->hmacsha1($secretKey,
                $this->request->getMethod()."\n".
                $contentMD5."\n".
                $this->request->getHeader('Content-type')."\n".
                $requestDate."\n".
                $amzHeaders.
                $this->request->getUrl()
            )
        );

        if ($this->signature !== $signature) {
            $this->errorCode = self::ERR_INVALIDSIGNATURE;

            return false;
        }

        return true;
    }

    /**
     * Returns an HTTP 401 header, forcing login.
     *
     * This should be called when username and password are incorrect, or not supplied at all
     */
    public function requireLogin()
    {
        $this->response->addHeader('WWW-Authenticate', 'AWS');
        $this->response->setStatus(401);
    }

    /**
     * Makes sure the supplied value is a valid RFC2616 date.
     *
     * If we would just use strtotime to get a valid timestamp, we have no way of checking if a
     * user just supplied the word 'now' for the date header.
     *
     * This function also makes sure the Date header is within 15 minutes of the operating
     * system date, to prevent replay attacks.
     */
    protected function validateRFC2616Date(string $dateHeader): bool
    {
        $date = HTTP\parseDate($dateHeader);

        // Unknown format
        if (!$date) {
            $this->errorCode = self::ERR_INVALIDDATEFORMAT;

            return false;
        }

        $min = new \DateTime('-15 minutes');
        $max = new \DateTime('+15 minutes');

        // We allow 15 minutes around the current date/time
        if ($date > $max || $date < $min) {
            $this->errorCode = self::ERR_REQUESTTIMESKEWED;

            return false;
        }

        return true;
    }

    /**
     * Returns a list of AMZ headers.
     */
    protected function getAmzHeaders(): string
    {
        $amzHeaders = [];
        $headers = $this->request->getHeaders();
        foreach ($headers as $headerName => $headerValue) {
            if (0 === strpos(strtolower($headerName), 'x-amz-')) {
                $amzHeaders[strtolower($headerName)] = str_replace(["\r\n"], [' '], $headerValue[0])."\n";
            }
        }
        ksort($amzHeaders);

        $headerStr = '';
        foreach ($amzHeaders as $h => $v) {
            $headerStr .= $h.':'.$v;
        }

        return $headerStr;
    }

    /**
     * Generates an HMAC-SHA1 signature.
     */
    private function hmacsha1(string $key, string $message): string
    {
        if (function_exists('hash_hmac')) {
            return hash_hmac('sha1', $message, $key, true);
        }

        $blocksize = 64;
        if (strlen($key) > $blocksize) {
            $key = pack('H*', sha1($key));
        }
        $key = str_pad($key, $blocksize, chr(0x00));
        $ipad = str_repeat(chr(0x36), $blocksize);
        $opad = str_repeat(chr(0x5c), $blocksize);
        $hmac = pack('H*', sha1(($key ^ $opad).pack('H*', sha1(($key ^ $ipad).$message))));

        return $hmac;
    }
}
