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