blob: cde9f4d1d86b9fd2c4e365149d4bcda42b50d7f5 [file] [log] [blame]
Matthias Andreas Benkardb382b102021-01-02 15:32:21 +01001<?php
2 /**
3 * Class for verifying Yubico One-Time-Passcodes
4 *
5 * @category Auth
6 * @package Auth_Yubico
7 * @author Simon Josefsson <simon@yubico.com>, Olov Danielson <olov@yubico.com>
8 * @copyright 2007-2020 Yubico AB
9 * @license https://opensource.org/licenses/bsd-license.php New BSD License
10 * @version 2.0
11 * @link https://www.yubico.com/
12 */
13
14require_once 'PEAR.php';
15
16/**
17 * Class for verifying Yubico One-Time-Passcodes
18 *
19 * Simple example:
20 * <code>
21 * require_once 'Auth/Yubico.php';
22 * $otp = "ccbbddeertkrctjkkcglfndnlihhnvekchkcctif";
23 *
24 * # Generate a new id+key from https://api.yubico.com/get-api-key/
25 * $yubi = new Auth_Yubico('42', 'FOOBAR=');
26 * $auth = $yubi->verify($otp);
27 * if (PEAR::isError($auth)) {
28 * print "<p>Authentication failed: " . $auth->getMessage();
29 * print "<p>Debug output from server: " . $yubi->getLastResponse();
30 * } else {
31 * print "<p>You are authenticated!";
32 * }
33 * </code>
34 */
35class Auth_Yubico
36{
37 /**#@+
38 * @access private
39 */
40
41 /**
42 * Yubico client ID
43 * @var string
44 */
45 var $_id;
46
47 /**
48 * Yubico client key
49 * @var string
50 */
51 var $_key;
52
53 /**
54 * List with URL part of validation servers
55 * @var array
56 */
57 var $_url_list;
58
59 /**
60 * index to _url_list
61 * @var int
62 */
63 var $_url_index;
64
65 /**
66 * Last query to server
67 * @var string
68 */
69 var $_lastquery;
70
71 /**
72 * Response from server
73 * @var string
74 */
75 var $_response;
76
77 /**
78 * Number of times we retried in our last validation
79 * @var int
80 */
81 var $_retries;
82
83 /**
84 * Flag whether to verify HTTPS server certificates or not.
85 * @var boolean
86 */
87 var $_httpsverify;
88
89 /**
90 * Maximum number of times we will retry transient HTTP errors
91 * @var int
92 */
93 var $_max_retries;
94
95 /**
96 * Constructor
97 *
98 * Sets up the object
99 * @param string $id The client identity
100 * @param string $key The client MAC key (optional)
101 * @param boolean $https noop
102 * @param boolean $httpsverify Flag whether to use verify HTTPS
103 * server certificates (optional,
104 * default true)
105 * @access public
106 */
107 public function __construct($id, $key = '', $https = 0, $httpsverify = 1, $max_retries = 3)
108 {
109 $this->_id = $id;
110 $this->_key = base64_decode($key);
111 $this->_httpsverify = $httpsverify;
112 $this->_max_retries = $max_retries;
113 }
114
115 /**
116 * Specify to use a different URL part for verification.
117 * The default is "https://api.yubico.com/wsapi/2.0/verify".
118 *
119 * @param string $url New server URL part to use
120 * @access public
121 * @deprecated
122 */
123 function setURLpart($url)
124 {
125 $this->_url_list = array($url);
126 }
127
128 /**
129 * Get next URL part from list to use for validation.
130 *
131 * @return mixed string with URL part or false if no more URLs in list
132 * @access public
133 */
134 function getNextURLpart()
135 {
136 if ($this->_url_list) $url_list=$this->_url_list;
137 else $url_list=array('https://api.yubico.com/wsapi/2.0/verify');
138
139 if ($this->_url_index>=count($url_list)) return false;
140 else return $url_list[$this->_url_index++];
141 }
142
143 /**
144 * Resets index to URL list
145 *
146 * @access public
147 */
148 function URLreset()
149 {
150 $this->_url_index=0;
151 }
152
153 /**
154 * Add another URLpart.
155 *
156 * @access public
157 */
158 function addURLpart($URLpart)
159 {
160 $this->_url_list[]=$URLpart;
161 }
162
163 /**
164 * Return the last query sent to the server, if any.
165 *
166 * @return string Request to server
167 * @access public
168 */
169 function getLastQuery()
170 {
171 return $this->_lastquery;
172 }
173
174 /**
175 * Return the last data received from the server, if any.
176 *
177 * @return string Output from server
178 * @access public
179 */
180 function getLastResponse()
181 {
182 return $this->_response;
183 }
184
185 /**
186 * Return the number of retries that were used in the last validation
187 *
188 * @return int Number of retries
189 * @access public
190 */
191 function getRetries()
192 {
193 return $this->_retries;
194 }
195
196 /**
197 * Parse input string into password, yubikey prefix,
198 * ciphertext, and OTP.
199 *
200 * @param string Input string to parse
201 * @param string Optional delimiter re-class, default is '[:]'
202 * @return array Keyed array with fields
203 * @access public
204 */
205 function parsePasswordOTP($str, $delim = '[:]')
206 {
207 if (!preg_match("/^((.*)" . $delim . ")?" .
208 "(([cbdefghijklnrtuv]{0,16})" .
209 "([cbdefghijklnrtuv]{32}))$/i",
210 $str, $matches)) {
211 /* Dvorak? */
212 if (!preg_match("/^((.*)" . $delim . ")?" .
213 "(([jxe\.uidchtnbpygk]{0,16})" .
214 "([jxe\.uidchtnbpygk]{32}))$/i",
215 $str, $matches)) {
216 return false;
217 } else {
218 $ret['otp'] = strtr($matches[3], "jxe.uidchtnbpygk", "cbdefghijklnrtuv");
219 }
220 } else {
221 $ret['otp'] = $matches[3];
222 }
223 $ret['password'] = $matches[2];
224 $ret['prefix'] = $matches[4];
225 $ret['ciphertext'] = $matches[5];
226 return $ret;
227 }
228
229 /* TODO? Add functions to get parsed parts of server response? */
230
231 /**
232 * Parse parameters from last response
233 *
234 * example: getParameters("timestamp", "sessioncounter", "sessionuse");
235 *
236 * @param array @parameters Array with strings representing
237 * parameters to parse
238 * @return array parameter array from last response
239 * @access public
240 */
241 function getParameters($parameters)
242 {
243 if ($parameters == null) {
244 $parameters = array('timestamp', 'sessioncounter', 'sessionuse');
245 }
246 $param_array = array();
247 foreach ($parameters as $param) {
248 if(!preg_match("/" . $param . "=([0-9]+)/", $this->_response, $out)) {
249 return PEAR::raiseError('Could not parse parameter ' . $param . ' from response');
250 }
251 $param_array[$param]=$out[1];
252 }
253 return $param_array;
254 }
255
256 function _make_curl_handle($query, $timeout=null)
257 {
258 flush();
259 $handle = curl_init($query);
260 curl_setopt($handle, CURLOPT_USERAGENT, "PEAR Auth_Yubico");
261 curl_setopt($handle, CURLOPT_RETURNTRANSFER, 1);
262 if (!$this->_httpsverify) {
263 curl_setopt($handle, CURLOPT_SSL_VERIFYPEER, 0);
264 curl_setopt($handle, CURLOPT_SSL_VERIFYHOST, 0);
265 }
266 curl_setopt($handle, CURLOPT_FAILONERROR, true);
267 /* If timeout is set, we better apply it here as well
268 * in case the validation server fails to follow it. */
269 if ($timeout) {
270 curl_setopt($handle, CURLOPT_TIMEOUT, $timeout);
271 }
272
273 return $handle;
274 }
275
276 /**
277 * Verify Yubico OTP against multiple URLs
278 * Protocol specification 2.0 is used to construct validation requests
279 *
280 * @param string $token Yubico OTP
281 * @param int $use_timestamp 1=>send request with &timestamp=1 to
282 * get timestamp and session information
283 * in the response
284 * @param boolean $wait_for_all If true, wait until all
285 * servers responds (for debugging)
286 * @param string $sl Sync level in percentage between 0
287 * and 100 or "fast" or "secure".
288 * @param int $timeout Max number of seconds to wait
289 * for responses
290 * @param int $max_retries Max number of times we will retry on
291 * transient errors.
292 * @return mixed PEAR error on error, true otherwise
293 * @access public
294 */
295 function verify($token, $use_timestamp=null, $wait_for_all=False,
296 $sl=null, $timeout=null, $max_retries=null)
297 {
298 /* If maximum retries is not set, default from instance */
299 if (is_null($max_retries)) {
300 $max_retries = $this->_max_retries;
301 }
302
303 /* Construct parameters string */
304 $ret = $this->parsePasswordOTP($token);
305 if (!$ret) {
306 return PEAR::raiseError('Could not parse Yubikey OTP');
307 }
308 $params = array('id'=>$this->_id,
309 'otp'=>$ret['otp'],
310 'nonce'=>md5(uniqid(rand())));
311 /* Take care of protocol version 2 parameters */
312 if ($use_timestamp) $params['timestamp'] = 1;
313 if ($sl) $params['sl'] = $sl;
314 if ($timeout) $params['timeout'] = $timeout;
315 ksort($params);
316 $parameters = '';
317 foreach($params as $p=>$v) $parameters .= "&" . $p . "=" . $v;
318 $parameters = ltrim($parameters, "&");
319
320 /* Generate signature. */
321 if($this->_key <> "") {
322 $signature = base64_encode(hash_hmac('sha1', $parameters,
323 $this->_key, true));
324 $signature = preg_replace('/\+/', '%2B', $signature);
325 $parameters .= '&h=' . $signature;
326 }
327
328 /* Generate and prepare request. */
329 $this->_lastquery = null;
330 $this->_retries = 0;
331 $this->URLreset();
332
333 $mh = curl_multi_init();
334 $ch = array();
335 $retries = array();
336 while($URLpart=$this->getNextURLpart())
337 {
338 $query = $URLpart . "?" . $parameters;
339
340 if ($this->_lastquery) { $this->_lastquery .= " "; }
341 $this->_lastquery .= $query;
342
343 $handle = $this->_make_curl_handle($query, $timeout);
344 curl_multi_add_handle($mh, $handle);
345
346 $ch[(int)$handle] = $handle;
347 $retries[$query] = 0;
348 }
349
350 /* Execute and read request. */
351 $this->_response=null;
352 $replay=False;
353 $valid=False;
354 do {
355 /* Let curl do its work. */
356 while (($mrc = curl_multi_exec($mh, $active))
357 == CURLM_CALL_MULTI_PERFORM) {
358 curl_multi_select($mh);
359 }
360
361 while ($info = curl_multi_info_read($mh)) {
362 $cinfo = curl_getinfo ($info['handle']);
363 if ($info['result'] == CURLE_OK) {
364 /* We have a complete response from one server. */
365
366 $str = curl_multi_getcontent($info['handle']);
367
368 if ($wait_for_all) { # Better debug info
369 $this->_response .= 'URL=' . $cinfo['url'] . ' HTTP_CODE='
370 . $cinfo['http_code'] . "\n"
371 . $str . "\n";
372 }
373
374 if (preg_match("/status=([a-zA-Z0-9_]+)/", $str, $out)) {
375 $status = $out[1];
376
377 /*
378 * There are 3 cases.
379 *
380 * 1. OTP or Nonce values doesn't match - ignore
381 * response.
382 *
383 * 2. We have a HMAC key. If signature is invalid -
384 * ignore response. Return if status=OK or
385 * status=REPLAYED_OTP.
386 *
387 * 3. Return if status=OK or status=REPLAYED_OTP.
388 */
389 if (!preg_match("/otp=".$params['otp']."/", $str) ||
390 !preg_match("/nonce=".$params['nonce']."/", $str)) {
391 /* Case 1. Ignore response. */
392 }
393 elseif ($this->_key <> "") {
394 /* Case 2. Verify signature first */
395 $rows = explode("\r\n", trim($str));
396 $response=array();
397 foreach ($rows as $key => $val) {
398 /* = is also used in BASE64 encoding so we only replace the first = by # which is not used in BASE64 */
399 $val = preg_replace('/=/', '#', $val, 1);
400 $row = explode("#", $val);
401 $response[$row[0]] = $row[1];
402 }
403
404 $parameters=array('nonce','otp', 'sessioncounter', 'sessionuse', 'sl', 'status', 't', 'timeout', 'timestamp');
405 sort($parameters);
406 $check=Null;
407 foreach ($parameters as $param) {
408 if (array_key_exists($param, $response)) {
409 if ($check) $check = $check . '&';
410 $check = $check . $param . '=' . $response[$param];
411 }
412 }
413
414 $checksignature =
415 base64_encode(hash_hmac('sha1', utf8_encode($check),
416 $this->_key, true));
417
418 if($response['h'] == $checksignature) {
419 if ($status == 'REPLAYED_OTP') {
420 if (!$wait_for_all) { $this->_response = $str; }
421 $replay=True;
422 }
423 if ($status == 'OK') {
424 if (!$wait_for_all) { $this->_response = $str; }
425 $valid=True;
426 }
427 }
428 } else {
429 /* Case 3. We check the status directly */
430 if ($status == 'REPLAYED_OTP') {
431 if (!$wait_for_all) { $this->_response = $str; }
432 $replay=True;
433 }
434 if ($status == 'OK') {
435 if (!$wait_for_all) { $this->_response = $str; }
436 $valid=True;
437 }
438 }
439 }
440 if (!$wait_for_all && ($valid || $replay))
441 {
442 /* We have status=OK or status=REPLAYED_OTP, return. */
443 foreach ($ch as $h) {
444 curl_multi_remove_handle($mh, $h);
445 curl_close($h);
446 }
447 curl_multi_close($mh);
448 if ($replay) return PEAR::raiseError('REPLAYED_OTP');
449 if ($valid) return true;
450 return PEAR::raiseError($status);
451 }
452 } else {
453 /* Some kind of error, but def. not a 200 response */
454 /* No status= in response body */
455 $http_status_code = (int)$cinfo['http_code'];
456 $query = $cinfo['url'];
457 if ($http_status_code == 400 ||
458 ($http_status_code >= 500 && $http_status_code < 600)) {
459 /* maybe retry */
460 if ($retries[$query] < $max_retries) {
461 $retries[$query]++; // for this server
462 $this->_retries++; // for this validation attempt
463
464 $newhandle = $this->_make_curl_handle($query, $timeout);
465
466 curl_multi_add_handle($mh, $newhandle);
467 $ch[(int)$newhandle] = $newhandle;
468
469 // Loop back up to curl_multi_exec, even if this
470 // was the last handle and curl_multi_exec _was_
471 // no longer active, it's active again now we've
472 // added a retry.
473 $active = true;
474 }
475 }
476 }
477 /* Done with this handle */
478 curl_multi_remove_handle($mh, $info['handle']);
479 curl_close($info['handle']);
480 unset ($ch[(int)$info['handle']]);
481 }
482 } while ($active);
483
484 /* Typically this is only reached for wait_for_all=true or
485 * when the timeout is reached and there is no
486 * OK/REPLAYED_REQUEST answer (think firewall).
487 */
488
489 foreach ($ch as $h) {
490 curl_multi_remove_handle ($mh, $h);
491 curl_close ($h);
492 }
493 curl_multi_close ($mh);
494
495 if ($replay) return PEAR::raiseError('REPLAYED_OTP');
496 if ($valid) return true;
497 return PEAR::raiseError('NO_VALID_ANSWER');
498 }
499}
500?>