Test Failed
Branch ci (e543e4)
by Florian
03:15
created

HttpDigestAuthenticator::parseAuthData()   A

Complexity

Conditions 4
Paths 8

Size

Total Lines 19
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 4

Importance

Changes 0
Metric Value
eloc 11
dl 0
loc 19
ccs 12
cts 12
cp 1
rs 9.9
c 0
b 0
f 0
cc 4
nc 8
nop 1
crap 4
1
<?php
2
3
/**
4
 * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
5
 * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
6
 *
7
 * Licensed under The MIT License
8
 * For full copyright and license information, please see the LICENSE.txt
9
 * Redistributions of files must retain the above copyright notice.
10
 *
11
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
12
 * @link          http://cakephp.org CakePHP(tm) Project
13
 * @license       http://www.opensource.org/licenses/mit-license.php MIT License
14
 */
15
16
declare(strict_types=1);
17
18
namespace Phauthentic\Authentication\Authenticator;
19
20
use Phauthentic\Authentication\Identifier\IdentifierInterface;
21
use Psr\Http\Message\ServerRequestInterface;
22
23
/**
24
 * HttpDigest Authenticator
25
 *
26
 * Provides Digest HTTP authentication support.
27
 *
28
 * ### Generating passwords compatible with Digest authentication.
29
 *
30
 * DigestAuthenticate requires a special password hash that conforms to RFC2617.
31
 * You can generate this password using `HttpDigestAuthenticate::password()`
32
 *
33
 * ```
34
 * $digestPass = HttpDigestAuthenticator::password($username, $password, env('SERVER_NAME'));
35
 * ```
36
 *
37
 * If you wish to use digest authentication alongside other authentication methods,
38
 * it's recommended that you store the digest authentication separately. For
39
 * example `User.digest_pass` could be used for a digest password, while
40
 * `User.password` would store the password hash for use with other methods like
41
 * BasicHttp or Form.
42
 */
43
class HttpDigestAuthenticator extends HttpBasicAuthenticator
44
{
45
    /**
46
     * A string that must be returned unchanged by clients. Defaults to `md5($config['realm'])`
47
     *
48
     * @var string|null
49
     */
50
    protected ?string $opaque;
51
52
    /**
53
     * The number of seconds that nonces are valid for. Defaults to 300.
54
     *
55
     * @var int
56
     */
57
    protected int $nonceLifetime = 300;
58
59
    /**
60
     * @var string
61
     */
62
    protected string $secret = '';
63
64
    /**
65
     * Defaults to 'auth', no other values are supported at this time.
66
     *
67
     * @var string
68
     */
69
    protected string $qop = 'auth';
70
71
    /**
72
     * Sets the secret
73
     *
74
     * @param string $secret Secret
75
     * @return $this
76
     */
77
    public function setSecret(string $secret): self
78
    {
79
        $this->secret = $secret;
80
81
        return $this;
82
    }
83
84
    /**
85
     * Sets the Qop
86
     *
87
     * @param string $qop Qop
88
     * @return $this
89
     */
90
    public function setQop(string $qop): self
91
    {
92
        $this->qop = $qop;
93
94
        return $this;
95
    }
96
97
    /**
98
     * Sets the Nonce Lifetime
99
     *
100
     * @param int $lifeTime Lifetime
101
     * @return $this
102
     */
103
    public function setNonceLifetime(int $lifeTime): self
104
    {
105
        $this->nonceLifetime = $lifeTime;
106
107
        return $this;
108
    }
109
110
    /**
111
     * Sets the Opaque
112
     *
113
     * @param string|null $opaque Opaque
114
     * @return $this
115
     */
116 14
    public function setOpaque(?string $opaque): self
117
    {
118 14
        $this->opaque = $opaque;
119
120 14
        return $this;
121
    }
122
123
    /**
124
     * Sets the password field name
125
     *
126
     * @param string $field Field name
127
     * @return $this
128
     */
129
    public function setPasswordField(string $field)
130
    {
131
        $this->credentialFields[IdentifierInterface::CREDENTIAL_PASSWORD] = $field;
132
133
        return $this;
134
    }
135
136
    /**
137
     * Get a user based on information in the request. Used by cookie-less auth for stateless clients.
138
     *
139
     * @param \Psr\Http\Message\ServerRequestInterface $request The request that contains login information.
140
     * @return \Phauthentic\Authentication\Authenticator\ResultInterface
141
     */
142 6
    public function authenticate(ServerRequestInterface $request): ResultInterface
143
    {
144 6
        $digest = $this->getDigest($request);
145 6
        if ($digest === null) {
146 2
            return new Result(null, Result::FAILURE_CREDENTIALS_MISSING);
147
        }
148
149 4
        $user = $this->identifier->identify([
150 4
            IdentifierInterface::CREDENTIAL_USERNAME => $digest['username']
151 4
        ]);
152
153 4
        if (empty($user)) {
154
            return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND);
155
        }
156
157 4
        if (!$this->isNonceValid($digest['nonce'])) {
158 3
            return new Result(null, Result::FAILURE_CREDENTIALS_INVALID);
159
        }
160
161 1
        $field = $this->credentialFields[IdentifierInterface::CREDENTIAL_PASSWORD];
162 1
        $password = $user[$field];
163
164 1
        $server = $request->getServerParams();
165 1
        if (!isset($server['ORIGINAL_REQUEST_METHOD'])) {
166 1
            $server['ORIGINAL_REQUEST_METHOD'] = $server['REQUEST_METHOD'];
167
        }
168
169 1
        $hash = $this->generateResponseHash($digest, $password, $server['ORIGINAL_REQUEST_METHOD']);
170 1
        if (hash_equals($hash, $digest['response'])) {
171 1
            return new Result($user, Result::SUCCESS);
172
        }
173
174
        return new Result(null, Result::FAILURE_CREDENTIALS_INVALID);
175
    }
176
177
    /**
178
     * Gets the digest headers from the request/environment.
179
     *
180
     * @param \Psr\Http\Message\ServerRequestInterface $request The request that contains login information.
181
     * @return array<string, mixed>|null Array of digest information.
182
     */
183 9
    protected function getDigest(ServerRequestInterface $request): ?array
184
    {
185 9
        $server = $request->getServerParams();
186 9
        $digest = empty($server['PHP_AUTH_DIGEST']) ? null : $server['PHP_AUTH_DIGEST'];
187 9
        $digest = $this->getDigestFromApacheHeaders($digest);
188
189 9
        if (empty($digest)) {
190 3
            return null;
191
        }
192
193 6
        return $this->parseAuthData($digest);
194
    }
195
196
    /**
197
     * Fallback to apache_request_headers()
198
     *
199
     * @param null|string $digest Digest
200
     * @return null|string
201
     */
202 9
    protected function getDigestFromApacheHeaders(?string $digest)
203
    {
204 9
        if (empty($digest) && function_exists('apache_request_headers')) {
205
            $headers = (array)apache_request_headers();
206
            if (!empty($headers['Authorization']) && strpos($headers['Authorization'], 'Digest ') === 0) {
207
                $digest = substr($headers['Authorization'], 7);
208
            }
209
        }
210
211 9
        return $digest;
212
    }
213
214
    /**
215
     * Parse the digest authentication headers and split them up.
216
     *
217
     * @param string $digest The raw digest authentication headers.
218
     * @return array<string, mixed>|null An array of digest authentication headers
219
     */
220 9
    public function parseAuthData(string $digest): ?array
221
    {
222 9
        if (strpos($digest, 'Digest ') === 0) {
223 6
            $digest = substr($digest, 7);
224
        }
225 9
        $keys = $match = [];
226 9
        $req = ['nonce' => 1, 'nc' => 1, 'cnonce' => 1, 'qop' => 1, 'username' => 1, 'uri' => 1, 'response' => 1];
227 9
        preg_match_all('/(\w+)=([\'"]?)([a-zA-Z0-9\:\#\%\?\&@=\.\/_-]+)\2/', $digest, $match, PREG_SET_ORDER);
228
229 9
        foreach ($match as $i) {
230 9
            $keys[$i[1]] = $i[3];
231 9
            unset($req[$i[1]]);
232
        }
233
234 9
        if (empty($req)) {
235 9
            return $keys;
236
        }
237
238 1
        return null;
239
    }
240
241
    /**
242
     * Generate the response hash for a given digest array.
243
     *
244
     * @param array<string, mixed> $digest Digest information containing data from HttpDigestAuthenticate::parseAuthData().
245
     * @param string $password The digest hash password generated with HttpDigestAuthenticate::password()
246
     * @param string $method Request method
247
     * @return string Response hash
248
     */
249 5
    public function generateResponseHash(array $digest, string $password, string $method): string
250
    {
251 5
        return md5(
252 5
            $password .
253 5
            ':' . $digest['nonce'] . ':' . $digest['nc'] . ':' . $digest['cnonce'] . ':' . $digest['qop'] . ':' .
254 5
            md5($method . ':' . $digest['uri'])
255 5
        );
256
    }
257
258
    /**
259
     * Creates an auth digest password hash to store
260
     *
261
     * @param string $username The username to use in the digest hash.
262
     * @param string $password The unhashed password to make a digest hash for.
263
     * @param string $realm The realm the password is for.
264
     * @return string the hashed password that can later be used with Digest authentication.
265
     */
266 2
    public static function generatePasswordHash(string $username, string $password, string $realm): string
267
    {
268 2
        return md5($username . ':' . $realm . ':' . $password);
269
    }
270
271
    /**
272
     * @param \Psr\Http\Message\ServerRequestInterface $request
273
     * @return array<string, mixed>
274
     */
275 3
    protected function getDigestOptions(ServerRequestInterface $request): array
276
    {
277 3
        $server = $request->getServerParams();
278 3
        $realm = $this->realm ?: $server['SERVER_NAME'];
279
280 3
        return [
281 3
            'realm' => $realm,
282 3
            'qop' => $this->qop,
283 3
            'nonce' => $this->generateNonce(),
284 3
            'opaque' => $this->opaque ?: md5($realm)
285 3
        ];
286
    }
287
288
    protected function formatOptions(string $key, mixed $value): string
289
    {
290
        if (is_bool($value)) {
291
            $value = $value ? 'true' : 'false';
292
            return sprintf('%s=%s', $key, $value);
293
        }
294 3
295
        return sprintf('%s="%s"', $key, $value);
296 3
    }
297 3
298
    /**
299 3
     * Generate the login headers
300 1
     *
301
     * @param \Psr\Http\Message\ServerRequestInterface $request The request that contains login information.
302
     * @return array<string, string> Headers for logging in.
303 3
     */
304 3
    protected function loginHeaders(ServerRequestInterface $request): array
305 3
    {
306 1
        $options = $this->getDigestOptions($request);
307 1
        $digest = $this->getDigest($request);
308
309
        if ($digest !== null && isset($digest['nonce']) && !$this->isNonceValid($digest['nonce'])) {
310
            $options['stale'] = true;
311 3
        }
312
313
        $formattedOptions = [];
314
        foreach ($options as $key => $value) {
315
            $formattedOptions[] = $this->formatOptions($key, $value);
316
        }
317
318
        return ['WWW-Authenticate' => 'Digest ' . implode(',', $formattedOptions)];
319 3
    }
320
321 3
    /**
322 3
     * Generate a nonce value that is validated in future requests.
323 3
     *
324
     * @return string
325 3
     */
326
    protected function generateNonce(): string
327
    {
328
        $expiryTime = microtime(true) + $this->nonceLifetime;
329
        $signatureValue = hash_hmac('sha1', $expiryTime . ':' . $this->secret, $this->secret);
330
        $nonceValue = $expiryTime . ':' . $signatureValue;
331
332
        return base64_encode($nonceValue);
333
    }
334 6
335
    /**
336 6
     * Check the nonce to ensure it is valid and not expired.
337 6
     *
338
     * @param string $nonce The nonce value to check.
339
     * @return bool
340 6
     */
341 6
    protected function isNonceValid(string $nonce): bool
342 2
    {
343
        $value = base64_decode($nonce);
344 4
        if (!is_string($value)) {
0 ignored issues
show
introduced by
The condition is_string($value) is always true.
Loading history...
345 4
            return false;
346 2
        }
347
        $parts = explode(':', $value);
348 2
        if (count($parts) !== 2) {
349 2
            return false;
350
        }
351 2
        [$expires, $checksum] = $parts;
352
        if ($expires < microtime(true)) {
353
            return false;
354
        }
355
        $secret = $this->secret;
356
        $check = hash_hmac('sha1', $expires . ':' . $secret, $secret);
357
358
        return hash_equals($check, $checksum);
359
    }
360
}
361