1<?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-2015 Yubico AB 9 * @license http://opensource.org/licenses/bsd-license.php New BSD License 10 * @version 2.0 11 * @link http://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 * URL part of validation server 55 * @var string 56 */ 57 var $_url; 58 59 /** 60 * List with URL part of validation servers 61 * @var array 62 */ 63 var $_url_list; 64 65 /** 66 * index to _url_list 67 * @var int 68 */ 69 var $_url_index; 70 71 /** 72 * Last query to server 73 * @var string 74 */ 75 var $_lastquery; 76 77 /** 78 * Response from server 79 * @var string 80 */ 81 var $_response; 82 83 /** 84 * Flag whether to use https or not. 85 * @var boolean 86 */ 87 var $_https; 88 89 /** 90 * Flag whether to verify HTTPS server certificates or not. 91 * @var boolean 92 */ 93 var $_httpsverify; 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 Flag whether to use https (optional) 102 * @param boolean $httpsverify Flag whether to use verify HTTPS 103 * server certificates (optional, 104 * default true) 105 * @access public 106 */ 107 function Auth_Yubico($id, $key = '', $https = 0, $httpsverify = 1) 108 { 109 $this->_id = $id; 110 $this->_key = base64_decode($key); 111 $this->_https = $https; 112 $this->_httpsverify = $httpsverify; 113 } 114 115 /** 116 * Specify to use a different URL part for verification. 117 * The default is "api.yubico.com/wsapi/verify". 118 * 119 * @param string $url New server URL part to use 120 * @access public 121 */ 122 function setURLpart($url) 123 { 124 $this->_url = $url; 125 } 126 127 /** 128 * Get URL part to use for validation. 129 * 130 * @return string Server URL part 131 * @access public 132 */ 133 function getURLpart() 134 { 135 if ($this->_url) { 136 return $this->_url; 137 } else { 138 return "api.yubico.com/wsapi/verify"; 139 } 140 } 141 142 143 /** 144 * Get next URL part from list to use for validation. 145 * 146 * @return mixed string with URL part of false if no more URLs in list 147 * @access public 148 */ 149 function getNextURLpart() 150 { 151 if ($this->_url_list) $url_list=$this->_url_list; 152 else $url_list=array('api.yubico.com/wsapi/2.0/verify', 153 'api2.yubico.com/wsapi/2.0/verify', 154 'api3.yubico.com/wsapi/2.0/verify', 155 'api4.yubico.com/wsapi/2.0/verify', 156 'api5.yubico.com/wsapi/2.0/verify'); 157 158 if ($this->_url_index>=count($url_list)) return false; 159 else return $url_list[$this->_url_index++]; 160 } 161 162 /** 163 * Resets index to URL list 164 * 165 * @access public 166 */ 167 function URLreset() 168 { 169 $this->_url_index=0; 170 } 171 172 /** 173 * Add another URLpart. 174 * 175 * @access public 176 */ 177 function addURLpart($URLpart) 178 { 179 $this->_url_list[]=$URLpart; 180 } 181 182 /** 183 * Return the last query sent to the server, if any. 184 * 185 * @return string Request to server 186 * @access public 187 */ 188 function getLastQuery() 189 { 190 return $this->_lastquery; 191 } 192 193 /** 194 * Return the last data received from the server, if any. 195 * 196 * @return string Output from server 197 * @access public 198 */ 199 function getLastResponse() 200 { 201 return $this->_response; 202 } 203 204 /** 205 * Parse input string into password, yubikey prefix, 206 * ciphertext, and OTP. 207 * 208 * @param string Input string to parse 209 * @param string Optional delimiter re-class, default is '[:]' 210 * @return array Keyed array with fields 211 * @access public 212 */ 213 function parsePasswordOTP($str, $delim = '[:]') 214 { 215 if (!preg_match("/^((.*)" . $delim . ")?" . 216 "(([cbdefghijklnrtuv]{0,16})" . 217 "([cbdefghijklnrtuv]{32}))$/i", 218 $str, $matches)) { 219 /* Dvorak? */ 220 if (!preg_match("/^((.*)" . $delim . ")?" . 221 "(([jxe\.uidchtnbpygk]{0,16})" . 222 "([jxe\.uidchtnbpygk]{32}))$/i", 223 $str, $matches)) { 224 return false; 225 } else { 226 $ret['otp'] = strtr($matches[3], "jxe.uidchtnbpygk", "cbdefghijklnrtuv"); 227 } 228 } else { 229 $ret['otp'] = $matches[3]; 230 } 231 $ret['password'] = $matches[2]; 232 $ret['prefix'] = $matches[4]; 233 $ret['ciphertext'] = $matches[5]; 234 return $ret; 235 } 236 237 /* TODO? Add functions to get parsed parts of server response? */ 238 239 /** 240 * Parse parameters from last response 241 * 242 * example: getParameters("timestamp", "sessioncounter", "sessionuse"); 243 * 244 * @param array @parameters Array with strings representing 245 * parameters to parse 246 * @return array parameter array from last response 247 * @access public 248 */ 249 function getParameters($parameters) 250 { 251 if ($parameters == null) { 252 $parameters = array('timestamp', 'sessioncounter', 'sessionuse'); 253 } 254 $param_array = array(); 255 foreach ($parameters as $param) { 256 if(!preg_match("/" . $param . "=([0-9]+)/", $this->_response, $out)) { 257 return PEAR::raiseError('Could not parse parameter ' . $param . ' from response'); 258 } 259 $param_array[$param]=$out[1]; 260 } 261 return $param_array; 262 } 263 264 /** 265 * Verify Yubico OTP against multiple URLs 266 * Protocol specification 2.0 is used to construct validation requests 267 * 268 * @param string $token Yubico OTP 269 * @param int $use_timestamp 1=>send request with ×tamp=1 to 270 * get timestamp and session information 271 * in the response 272 * @param boolean $wait_for_all If true, wait until all 273 * servers responds (for debugging) 274 * @param string $sl Sync level in percentage between 0 275 * and 100 or "fast" or "secure". 276 * @param int $timeout Max number of seconds to wait 277 * for responses 278 * @return mixed PEAR error on error, true otherwise 279 * @access public 280 */ 281 function verify($token, $use_timestamp=null, $wait_for_all=False, 282 $sl=null, $timeout=null) 283 { 284 /* Construct parameters string */ 285 $ret = $this->parsePasswordOTP($token); 286 if (!$ret) { 287 return PEAR::raiseError('Could not parse Yubikey OTP'); 288 } 289 $params = array('id'=>$this->_id, 290 'otp'=>$ret['otp'], 291 'nonce'=>md5(uniqid(rand()))); 292 /* Take care of protocol version 2 parameters */ 293 if ($use_timestamp) $params['timestamp'] = 1; 294 if ($sl) $params['sl'] = $sl; 295 if ($timeout) $params['timeout'] = $timeout; 296 ksort($params); 297 $parameters = ''; 298 foreach($params as $p=>$v) $parameters .= "&" . $p . "=" . $v; 299 $parameters = ltrim($parameters, "&"); 300 301 /* Generate signature. */ 302 if($this->_key <> "") { 303 $signature = base64_encode(hash_hmac('sha1', $parameters, 304 $this->_key, true)); 305 $signature = preg_replace('/\+/', '%2B', $signature); 306 $parameters .= '&h=' . $signature; 307 } 308 309 /* Generate and prepare request. */ 310 $this->_lastquery=null; 311 $this->URLreset(); 312 $mh = curl_multi_init(); 313 $ch = array(); 314 while($URLpart=$this->getNextURLpart()) 315 { 316 /* Support https. */ 317 if ($this->_https) { 318 $query = "https://"; 319 } else { 320 $query = "http://"; 321 } 322 $query .= $URLpart . "?" . $parameters; 323 324 if ($this->_lastquery) { $this->_lastquery .= " "; } 325 $this->_lastquery .= $query; 326 327 $handle = curl_init($query); 328 curl_setopt($handle, CURLOPT_USERAGENT, "PEAR Auth_Yubico"); 329 curl_setopt($handle, CURLOPT_RETURNTRANSFER, 1); 330 if (!$this->_httpsverify) { 331 curl_setopt($handle, CURLOPT_SSL_VERIFYPEER, 0); 332 curl_setopt($handle, CURLOPT_SSL_VERIFYHOST, 0); 333 } 334 curl_setopt($handle, CURLOPT_FAILONERROR, true); 335 /* If timeout is set, we better apply it here as well 336 in case the validation server fails to follow it. 337 */ 338 if ($timeout) curl_setopt($handle, CURLOPT_TIMEOUT, $timeout); 339 curl_multi_add_handle($mh, $handle); 340 341 $ch[(int)$handle] = $handle; 342 } 343 344 /* Execute and read request. */ 345 $this->_response=null; 346 $replay=False; 347 $valid=False; 348 do { 349 /* Let curl do its work. */ 350 while (($mrc = curl_multi_exec($mh, $active)) 351 == CURLM_CALL_MULTI_PERFORM) 352 ; 353 354 while ($info = curl_multi_info_read($mh)) { 355 if ($info['result'] == CURLE_OK) { 356 357 /* We have a complete response from one server. */ 358 359 $str = curl_multi_getcontent($info['handle']); 360 $cinfo = curl_getinfo ($info['handle']); 361 362 if ($wait_for_all) { # Better debug info 363 $this->_response .= 'URL=' . $cinfo['url'] ."\n" 364 . $str . "\n"; 365 } 366 367 if (preg_match("/status=([a-zA-Z0-9_]+)/", $str, $out)) { 368 $status = $out[1]; 369 370 /* 371 * There are 3 cases. 372 * 373 * 1. OTP or Nonce values doesn't match - ignore 374 * response. 375 * 376 * 2. We have a HMAC key. If signature is invalid - 377 * ignore response. Return if status=OK or 378 * status=REPLAYED_OTP. 379 * 380 * 3. Return if status=OK or status=REPLAYED_OTP. 381 */ 382 if (!preg_match("/otp=".$params['otp']."/", $str) || 383 !preg_match("/nonce=".$params['nonce']."/", $str)) { 384 /* Case 1. Ignore response. */ 385 } 386 elseif ($this->_key <> "") { 387 /* Case 2. Verify signature first */ 388 $rows = explode("\r\n", trim($str)); 389 $response=array(); 390 while (list($key, $val) = each($rows)) { 391 /* = is also used in BASE64 encoding so we only replace the first = by # which is not used in BASE64 */ 392 $val = preg_replace('/=/', '#', $val, 1); 393 $row = explode("#", $val); 394 $response[$row[0]] = $row[1]; 395 } 396 397 $parameters=array('nonce','otp', 'sessioncounter', 'sessionuse', 'sl', 'status', 't', 'timeout', 'timestamp'); 398 sort($parameters); 399 $check=Null; 400 foreach ($parameters as $param) { 401 if (array_key_exists($param, $response)) { 402 if ($check) $check = $check . '&'; 403 $check = $check . $param . '=' . $response[$param]; 404 } 405 } 406 407 $checksignature = 408 base64_encode(hash_hmac('sha1', utf8_encode($check), 409 $this->_key, true)); 410 411 if($response['h'] == $checksignature) { 412 if ($status == 'REPLAYED_OTP') { 413 if (!$wait_for_all) { $this->_response = $str; } 414 $replay=True; 415 } 416 if ($status == 'OK') { 417 if (!$wait_for_all) { $this->_response = $str; } 418 $valid=True; 419 } 420 } 421 } else { 422 /* Case 3. We check the status directly */ 423 if ($status == 'REPLAYED_OTP') { 424 if (!$wait_for_all) { $this->_response = $str; } 425 $replay=True; 426 } 427 if ($status == 'OK') { 428 if (!$wait_for_all) { $this->_response = $str; } 429 $valid=True; 430 } 431 } 432 } 433 if (!$wait_for_all && ($valid || $replay)) 434 { 435 /* We have status=OK or status=REPLAYED_OTP, return. */ 436 foreach ($ch as $h) { 437 curl_multi_remove_handle($mh, $h); 438 curl_close($h); 439 } 440 curl_multi_close($mh); 441 if ($replay) return PEAR::raiseError('REPLAYED_OTP'); 442 if ($valid) return true; 443 return PEAR::raiseError($status); 444 } 445 446 curl_multi_remove_handle($mh, $info['handle']); 447 curl_close($info['handle']); 448 unset ($ch[(int)$info['handle']]); 449 } 450 curl_multi_select($mh); 451 } 452 } while ($active); 453 454 /* Typically this is only reached for wait_for_all=true or 455 * when the timeout is reached and there is no 456 * OK/REPLAYED_REQUEST answer (think firewall). 457 */ 458 459 foreach ($ch as $h) { 460 curl_multi_remove_handle ($mh, $h); 461 curl_close ($h); 462 } 463 curl_multi_close ($mh); 464 465 if ($replay) return PEAR::raiseError('REPLAYED_OTP'); 466 if ($valid) return true; 467 return PEAR::raiseError('NO_VALID_ANSWER'); 468 } 469} 470?> 471