Passed
Push — master ( a717b5...dd05c8 )
by Florian
03:07 queued 01:19
created

HttpDigestAuthenticator::getDigest()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 3

Importance

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