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