HttpDigestAuthenticator   A
last analyzed

Complexity

Total Complexity 41

Size/Duplication

Total Lines 316
Duplicated Lines 0 %

Test Coverage

Coverage 82%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
wmc 41
eloc 95
c 2
b 0
f 0
dl 0
loc 316
ccs 82
cts 100
cp 0.82
rs 9.1199

16 Methods

Rating   Name   Duplication   Size   Complexity  
A parseAuthData() 0 19 4
A getDigestFromApacheHeaders() 0 10 5
A setPasswordField() 0 5 1
A setQop() 0 5 1
A setSecret() 0 5 1
A generatePasswordHash() 0 3 1
A setOpaque() 0 5 1
A generateResponseHash() 0 6 1
A setNonceLifetime() 0 5 1
A authenticate() 0 33 6
A getDigestOptions() 0 10 3
A getDigest() 0 11 3
A generateNonce() 0 7 1
A isNonceValid() 0 18 4
A formatOptions() 0 8 3
A loginHeaders() 0 15 5

How to fix   Complexity   

Complex Class

Complex classes like HttpDigestAuthenticator often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use HttpDigestAuthenticator, and based on these observations, apply Extract Interface, too.

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 56
     * @return $this
115
     */
116 56
    public function setOpaque(?string $opaque): self
117
    {
118 56
        $this->opaque = $opaque;
119
120
        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 24
     * @return \Phauthentic\Authentication\Authenticator\ResultInterface
141
     */
142 24
    public function authenticate(ServerRequestInterface $request): ResultInterface
143 24
    {
144 8
        $digest = $this->getDigest($request);
145
        if ($digest === null) {
146
            return new Result(null, Result::FAILURE_CREDENTIALS_MISSING);
147 16
        }
148 16
149
        $user = $this->identifier->identify([
150
            IdentifierInterface::CREDENTIAL_USERNAME => $digest['username']
151 16
        ]);
152
153
        if (empty($user)) {
154
            return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND);
155 16
        }
156 12
157
        if (!$this->isNonceValid($digest['nonce'])) {
158
            return new Result(null, Result::FAILURE_CREDENTIALS_INVALID);
159 4
        }
160 4
161
        $field = $this->credentialFields[IdentifierInterface::CREDENTIAL_PASSWORD];
162 4
        $password = $user[$field];
163 4
164 4
        $server = $request->getServerParams();
165
        if (!isset($server['ORIGINAL_REQUEST_METHOD'])) {
166
            $server['ORIGINAL_REQUEST_METHOD'] = $server['REQUEST_METHOD'];
167 4
        }
168 4
169 4
        $hash = $this->generateResponseHash($digest, $password, $server['ORIGINAL_REQUEST_METHOD']);
170
        if (hash_equals($hash, $digest['response'])) {
171
            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 36
     * @return array<string, mixed>|null Array of digest information.
182
     */
183 36
    protected function getDigest(ServerRequestInterface $request): ?array
184 36
    {
185 36
        $server = $request->getServerParams();
186
        $digest = empty($server['PHP_AUTH_DIGEST']) ? null : $server['PHP_AUTH_DIGEST'];
187 36
        $digest = $this->getDigestFromApacheHeaders($digest);
188 12
189
        if (empty($digest)) {
190
            return null;
191 24
        }
192
193
        return $this->parseAuthData($digest);
194
    }
195
196
    /**
197
     * Fallback to apache_request_headers()
198
     *
199
     * @param null|string $digest Digest
200 36
     * @return null|string
201
     */
202 36
    protected function getDigestFromApacheHeaders(?string $digest)
203
    {
204
        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 36
        }
210
211
        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 36
     * @return array<string, mixed>|null An array of digest authentication headers
219
     */
220 36
    public function parseAuthData(string $digest): ?array
221 24
    {
222
        if (strpos($digest, 'Digest ') === 0) {
223 36
            $digest = substr($digest, 7);
224 36
        }
225 36
        $keys = $match = [];
226
        $req = ['nonce' => 1, 'nc' => 1, 'cnonce' => 1, 'qop' => 1, 'username' => 1, 'uri' => 1, 'response' => 1];
227 36
        preg_match_all('/(\w+)=([\'"]?)([a-zA-Z0-9\:\#\%\?\&@=\.\/_-]+)\2/', $digest, $match, PREG_SET_ORDER);
228 36
229 36
        foreach ($match as $i) {
230
            $keys[$i[1]] = $i[3];
231
            unset($req[$i[1]]);
232 36
        }
233 36
234
        if (empty($req)) {
235
            return $keys;
236 4
        }
237
238
        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 20
     * @return string Response hash
248
     */
249 20
    public function generateResponseHash(array $digest, string $password, string $method): string
250
    {
251 20
        return md5(
252 20
            $password .
253
            ':' . $digest['nonce'] . ':' . $digest['nc'] . ':' . $digest['cnonce'] . ':' . $digest['qop'] . ':' .
254
            md5($method . ':' . $digest['uri'])
255
        );
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 8
     * @return string the hashed password that can later be used with Digest authentication.
265
     */
266 8
    public static function generatePasswordHash(string $username, string $password, string $realm): string
267
    {
268
        return md5($username . ':' . $realm . ':' . $password);
269
    }
270
271
    /**
272
     * @param \Psr\Http\Message\ServerRequestInterface $request
273
     * @return array<string, mixed>
274
     */
275 12
    protected function getDigestOptions(ServerRequestInterface $request): array
276
    {
277 12
        $server = $request->getServerParams();
278 12
        $realm = $this->realm ?: $server['SERVER_NAME'];
279
280
        return [
281 12
            'realm' => $realm,
282 12
            'qop' => $this->qop,
283 12
            'nonce' => $this->generateNonce(),
284 12
            'opaque' => $this->opaque ?: md5($realm)
285
        ];
286
    }
287 12
288 12
    protected function formatOptions(string $key, mixed $value): string
289 4
    {
290
        if (is_bool($value)) {
291
            $value = $value ? 'true' : 'false';
292 12
            return sprintf('%s=%s', $key, $value);
293 12
        }
294 12
295 4
        return sprintf('%s="%s"', $key, $value);
296 4
    }
297
298 12
    /**
299
     * Generate the login headers
300
     *
301
     * @param \Psr\Http\Message\ServerRequestInterface $request The request that contains login information.
302 12
     * @return array<string, string> Headers for logging in.
303
     */
304
    protected function loginHeaders(ServerRequestInterface $request): array
305
    {
306
        $options = $this->getDigestOptions($request);
307
        $digest = $this->getDigest($request);
308
309
        if ($digest !== null && isset($digest['nonce']) && !$this->isNonceValid($digest['nonce'])) {
310 12
            $options['stale'] = true;
311
        }
312 12
313 12
        $formattedOptions = [];
314 12
        foreach ($options as $key => $value) {
315
            $formattedOptions[] = $this->formatOptions($key, $value);
316 12
        }
317
318
        return ['WWW-Authenticate' => 'Digest ' . implode(',', $formattedOptions)];
319
    }
320
321
    /**
322
     * Generate a nonce value that is validated in future requests.
323
     *
324
     * @return string
325 24
     */
326
    protected function generateNonce(): string
327 24
    {
328 24
        $expiryTime = microtime(true) + $this->nonceLifetime;
329
        $signatureValue = hash_hmac('sha1', $expiryTime . ':' . $this->secret, $this->secret);
330
        $nonceValue = $expiryTime . ':' . $signatureValue;
331 24
332 24
        return base64_encode($nonceValue);
333 8
    }
334
335 16
    /**
336 16
     * Check the nonce to ensure it is valid and not expired.
337 8
     *
338
     * @param string $nonce The nonce value to check.
339 8
     * @return bool
340 8
     */
341
    protected function isNonceValid(string $nonce): bool
342 8
    {
343
        $value = base64_decode($nonce);
344
        if (!is_string($value)) {
0 ignored issues
show
introduced by
The condition is_string($value) is always true.
Loading history...
345
            return false;
346
        }
347
        $parts = explode(':', $value);
348
        if (count($parts) !== 2) {
349
            return false;
350
        }
351
        [$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