Yubico.php 13 KB

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