Yubico.php 12 KB

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