Passed
Push — master ( c81968...b18996 )
by Maximilian
01:27 queued 12s
created

RequestValidator::validateCertUrl()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 2
dl 0
loc 4
ccs 3
cts 3
cp 1
rs 10
c 0
b 0
f 0
cc 2
nc 2
nop 1
crap 2
1
<?php
2
3
namespace MaxBeckers\AmazonAlexa\Validation;
4
5
use GuzzleHttp\Client;
6
use MaxBeckers\AmazonAlexa\Exception\OutdatedCertExceptionException;
7
use MaxBeckers\AmazonAlexa\Exception\RequestInvalidSignatureException;
8
use MaxBeckers\AmazonAlexa\Exception\RequestInvalidTimestampException;
9
use MaxBeckers\AmazonAlexa\Request\Request;
10
11
/**
12
 * This is a validator for amazon echo requests. It validates the timestamp of the request and the request signature.
13
 *
14
 * @author Maximilian Beckers <[email protected]>
15
 */
16
class RequestValidator
17
{
18
    /**
19
     * Basic value for timestamp validation. 150 seconds is suggested by amazon.
20
     */
21
    const TIMESTAMP_VALID_TOLERANCE_SECONDS = 150;
22
23
    /**
24
     * @var Client
25
     */
26
    public $client;
27
28
    /**
29
     * @var int
30
     */
31
    protected $timestampTolerance;
32
33
    /**
34
     * @param int         $timestampTolerance
35
     * @param Client|null $client
36
     */
37 4
    public function __construct($timestampTolerance = self::TIMESTAMP_VALID_TOLERANCE_SECONDS, Client $client = null)
38
    {
39 4
        $this->timestampTolerance = $timestampTolerance;
40 4
        $this->client             = $client ?: new Client();
41
    }
42
43
    /**
44
     * Validate request data.
45
     *
46
     * @param Request $request
47
     *
48
     * @throws OutdatedCertExceptionException
49
     * @throws RequestInvalidSignatureException
50
     * @throws RequestInvalidTimestampException
51
     */
52 4
    public function validate(Request $request)
53
    {
54 4
        $this->validateTimestamp($request);
55
        try {
56 3
            $this->validateSignature($request);
57 3
        } catch (OutdatedCertExceptionException $e) {
58
            // load cert again and validate because temp file was outdatet.
59
            $this->validateSignature($request);
60
        }
61
    }
62
63
    /**
64
     * Validate request timestamp. Request tolerance should be 150 seconds.
65
     * For more details @see https://developer.amazon.com/public/solutions/alexa/alexa-skills-kit/docs/developing-an-alexa-skill-as-a-web-service#timestamp.
66
     *
67
     * @param Request $request
68
     *
69
     * @throws RequestInvalidTimestampException
70
     */
71 4
    private function validateTimestamp(Request $request)
72
    {
73 4
        if (null === $request->request || !$request->request->validateTimestamp()) {
74
            return;
75
        }
76
77 4
        $differenceInSeconds = time() - $request->request->timestamp->getTimestamp();
78
79 4
        if ($differenceInSeconds > $this->timestampTolerance) {
80 1
            throw new RequestInvalidTimestampException('Invalid timestamp.');
81
        }
82
    }
83
84
    /**
85
     * Validate request signature. The steps for signature validation are described at developer page.
86
     *
87
     * @see https://developer.amazon.com/public/solutions/alexa/alexa-skills-kit/docs/developing-an-alexa-skill-as-a-web-service#checking-the-signature-of-the-request
88
     *
89
     * @param Request $request
90
     *
91
     * @throws OutdatedCertExceptionException
92
     * @throws RequestInvalidSignatureException
93
     */
94 3
    private function validateSignature(Request $request)
95
    {
96 3
        if (null === $request->request || !$request->request->validateSignature()) {
97
            return;
98
        }
99
100
        // validate cert url
101 3
        $this->validateCertUrl($request);
102
103
        // generate local cert path
104 2
        $localCertPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.md5($request->signatureCertChainUrl).'.pem';
105
106
        // check if pem file is already downloaded to temp or download.
107 2
        $certData = $this->fetchCertData($request, $localCertPath);
108
109
        // openssl cert validation
110 2
        $this->verifyCert($request, $certData);
111
112
        // parse cert
113
        $certContent = $this->parseCertData($certData);
114
115
        // validate cert
116
        $this->validateCertContent($certContent, $localCertPath);
117
    }
118
119
    /**
120
     * @param Request $request
121
     *
122
     * @throws RequestInvalidSignatureException
123
     */
124 3
    private function validateCertUrl(Request $request)
125
    {
126 3
        if (false === (bool) preg_match("/https:\/\/s3.amazonaws.com(\:443)?\/echo.api\/*/i", $request->signatureCertChainUrl)) {
127 1
            throw new RequestInvalidSignatureException('Invalid cert url.');
128
        }
129
    }
130
131
    /**
132
     * @param Request $request
133
     * @param string  $localCertPath
134
     *
135
     * @throws RequestInvalidSignatureException
136
     *
137
     * @return string
138
     */
139 2
    private function fetchCertData(Request $request, string $localCertPath): string
140
    {
141 2
        if (!file_exists($localCertPath)) {
142 1
            $response = $this->client->request('GET', $request->signatureCertChainUrl);
143
144 1
            if ($response->getStatusCode() !== 200) {
145
                throw new RequestInvalidSignatureException('Can\'t fetch cert from URL.');
146
            }
147
148 1
            $certData = $response->getBody()->getContents();
149 1
            @file_put_contents($localCertPath, $certData);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for file_put_contents(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

149
            /** @scrutinizer ignore-unhandled */ @file_put_contents($localCertPath, $certData);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
150
        } else {
151 1
            $certData = @file_get_contents($localCertPath);
152
        }
153
154 2
        return $certData;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $certData could return the type false which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
155
    }
156
157
    /**
158
     * @param Request $request
159
     * @param string  $certData
160
     *
161
     * @throws RequestInvalidSignatureException
162
     */
163 2
    private function verifyCert(Request $request, string $certData)
164
    {
165 2
        if (1 !== @openssl_verify($request->amazonRequestBody, base64_decode($request->signature, true), $certData, 'sha1')) {
166 2
            throw new RequestInvalidSignatureException('Cert ssl verification failed.');
167
        }
168
    }
169
170
    /**
171
     * @param string $certData
172
     *
173
     * @throws RequestInvalidSignatureException
174
     *
175
     * @return array
176
     */
177
    private function parseCertData(string $certData): array
178
    {
179
        $certContent = @openssl_x509_parse($certData);
180
        if (empty($certContent)) {
181
            throw new RequestInvalidSignatureException('Parse cert failed.');
182
        }
183
184
        return $certContent;
185
    }
186
187
    /**
188
     * @param array  $cert
189
     * @param string $localCertPath
190
     *
191
     * @throws OutdatedCertExceptionException
192
     * @throws RequestInvalidSignatureException
193
     */
194
    private function validateCertContent(array $cert, string $localCertPath)
195
    {
196
        $this->validateCertSubject($cert);
197
        $this->validateCertValidTime($cert, $localCertPath);
198
    }
199
200
    /**
201
     * @param array $cert
202
     *
203
     * @throws RequestInvalidSignatureException
204
     */
205
    private function validateCertSubject(array $cert)
206
    {
207
        if (false === isset($cert['extensions']['subjectAltName']) ||
208
            false === stristr($cert['extensions']['subjectAltName'], 'echo-api.amazon.com')
209
        ) {
210
            throw new RequestInvalidSignatureException('Cert subject error.');
211
        }
212
    }
213
214
    /**
215
     * @param array  $cert
216
     * @param string $localCertPath
217
     *
218
     * @throws OutdatedCertExceptionException
219
     */
220
    private function validateCertValidTime(array $cert, string $localCertPath)
221
    {
222
        if (false === isset($cert['validTo_time_t']) || time() > $cert['validTo_time_t'] || false === isset($cert['validFrom_time_t']) || time() < $cert['validFrom_time_t']) {
223
            if (file_exists($localCertPath)) {
224
                /* @scrutinizer ignore-unhandled */ @unlink($localCertPath);
225
            }
226
            throw new OutdatedCertExceptionException('Cert is outdated.');
227
        }
228
    }
229
}
230