Passed
Pull Request — master (#3)
by Florian
03:07
created

HttpDigestAuthenticator::getDigest()   B

Complexity

Conditions 7
Paths 12

Size

Total Lines 15
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 8.323

Importance

Changes 0
Metric Value
eloc 9
dl 0
loc 15
ccs 7
cts 10
cp 0.7
rs 8.8333
c 0
b 0
f 0
cc 7
nc 12
nop 1
crap 8.323
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
        if (empty($digest) && function_exists('apache_request_headers')) {
186
            $headers = apache_request_headers();
187
            if (!empty($headers['Authorization']) && substr($headers['Authorization'], 0, 7) === 'Digest ') {
188
                $digest = substr($headers['Authorization'], 7);
189
            }
190
        }
191 27
        if (empty($digest)) {
192 9
            return null;
193
        }
194
195 18
        return $this->parseAuthData($digest);
196
    }
197
198
    /**
199
     * Parse the digest authentication headers and split them up.
200
     *
201
     * @param string $digest The raw digest authentication headers.
202
     * @return array|null An array of digest authentication headers
203
     */
204 27
    public function parseAuthData($digest): ?array
205
    {
206 27
        if (substr($digest, 0, 7) === 'Digest ') {
207 18
            $digest = substr($digest, 7);
208
        }
209 27
        $keys = $match = [];
210 27
        $req = ['nonce' => 1, 'nc' => 1, 'cnonce' => 1, 'qop' => 1, 'username' => 1, 'uri' => 1, 'response' => 1];
211 27
        preg_match_all('/(\w+)=([\'"]?)([a-zA-Z0-9\:\#\%\?\&@=\.\/_-]+)\2/', $digest, $match, PREG_SET_ORDER);
212
213 27
        foreach ($match as $i) {
214 27
            $keys[$i[1]] = $i[3];
215 27
            unset($req[$i[1]]);
216
        }
217
218 27
        if (empty($req)) {
219 27
            return $keys;
220
        }
221
222 3
        return null;
223
    }
224
225
    /**
226
     * Generate the response hash for a given digest array.
227
     *
228
     * @param array $digest Digest information containing data from HttpDigestAuthenticate::parseAuthData().
229
     * @param string $password The digest hash password generated with HttpDigestAuthenticate::password()
230
     * @param string $method Request method
231
     * @return string Response hash
232
     */
233 15
    public function generateResponseHash($digest, $password, $method): string
234
    {
235 15
        return md5(
236
            $password .
237 15
            ':' . $digest['nonce'] . ':' . $digest['nc'] . ':' . $digest['cnonce'] . ':' . $digest['qop'] . ':' .
238 15
            md5($method . ':' . $digest['uri'])
239
        );
240
    }
241
242
    /**
243
     * Creates an auth digest password hash to store
244
     *
245
     * @param string $username The username to use in the digest hash.
246
     * @param string $password The unhashed password to make a digest hash for.
247
     * @param string $realm The realm the password is for.
248
     * @return string the hashed password that can later be used with Digest authentication.
249
     */
250 6
    public static function generatePasswordHash(string $username, string $password, string $realm): string
251
    {
252 6
        return md5($username . ':' . $realm . ':' . $password);
253
    }
254
255
    /**
256
     * Generate the login headers
257
     *
258
     * @param \Psr\Http\Message\ServerRequestInterface $request The request that contains login information.
259
     * @return array Headers for logging in.
260
     */
261 9
    protected function loginHeaders(ServerRequestInterface $request): array
262
    {
263 9
        $server = $request->getServerParams();
264 9
        $realm = $this->realm ?: $server['SERVER_NAME'];
265
266
        $options = [
267 9
            'realm' => $realm,
268 9
            'qop' => $this->qop,
269 9
            'nonce' => $this->generateNonce(),
270 9
            'opaque' => $this->opaque ?: md5($realm)
271
        ];
272
273 9
        $digest = $this->getDigest($request);
274 9
        if ($digest !== null && isset($digest['nonce']) && !$this->isNonceValid($digest['nonce'])) {
275 3
            $options['stale'] = true;
276
        }
277
278 9
        $opts = [];
279 9
        foreach ($options as $k => $v) {
280 9
            if (is_bool($v)) {
281 3
                $v = $v ? 'true' : 'false';
282 3
                $opts[] = sprintf('%s=%s', $k, $v);
283
            } else {
284 9
                $opts[] = sprintf('%s="%s"', $k, $v);
285
            }
286
        }
287
288 9
        return ['WWW-Authenticate' => 'Digest ' . implode(',', $opts)];
289
    }
290
291
    /**
292
     * Generate a nonce value that is validated in future requests.
293
     *
294
     * @return string
295
     */
296 9
    protected function generateNonce(): string
297
    {
298 9
        $expiryTime = microtime(true) + $this->nonceLifetime;
299 9
        $signatureValue = hash_hmac('sha1', $expiryTime . ':' . $this->secret, $this->secret);
300 9
        $nonceValue = $expiryTime . ':' . $signatureValue;
301
302 9
        return base64_encode($nonceValue);
303
    }
304
305
    /**
306
     * Check the nonce to ensure it is valid and not expired.
307
     *
308
     * @param string $nonce The nonce value to check.
309
     * @return bool
310
     */
311 18
    protected function isNonceValid(string $nonce): bool
312
    {
313 18
        $value = base64_decode($nonce);
314 18
        if ($value === false) {
315
            return false;
316
        }
317 18
        $parts = explode(':', $value);
318 18
        if (count($parts) !== 2) {
319 6
            return false;
320
        }
321 12
        list($expires, $checksum) = $parts;
322 12
        if ($expires < microtime(true)) {
323 6
            return false;
324
        }
325 6
        $secret = $this->secret;
326 6
        $check = hash_hmac('sha1', $expires . ':' . $secret, $secret);
327
328 6
        return hash_equals($check, $checksum);
329
    }
330
}
331