Yubico.php 12 KB

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