суббота, 9 января 2010 г.

Flex, PHP: Безопасная передача данных с клиента на сервер

В ходе построения клиент-серверного приложения, требуется реализовать возможность защищенной передачи данных.
В этом случае я использую следующий подход:

1. Подписывание данных секретным ключом.
Это дает гарантию, что данные не будут подменены на промежуточных узлах до сервера.
Технология следующая:

a. Генерируем секретный ключ, о котором знает только приложения клиента и сервера
b. К передаваемым данным на сервер добавляем еще один параметр равный хэшу данных с секретным ключом. Пример на Flex:
var key:String = "0000-0000-0000-0000-0000";
var sreq:String = "method=setBalance&addMoney=1000"; //сам запрос
//отсортируем параметры
//чтобы последовательность параметром и на клиенте и на сервере была одинакова. Это гарантирует схождение хэшей.
sreq = sreq.split("&").sort().join("&");
//добавим к параметрам время, чтобы нельзя было продублировать запрос 
var now:Date = new Date();
var sec = now.getTime().toString().substr(0, -3);
//конечный запрос к серверу с дополнительным параметром = хэшу запроса и секретного ключа
sreq = sreq + "&timestamp="+sec+"&sig="+MD5.hash(sreq+key);
c. Сервер принимает запрос и проверяет данные используя свой секретный ключ.
К сожалению нельзя использовать четкое совпадение времени клиента и сервера, т.к. оно может отличаться или сообщение может идти до нескольких секунд. Из-за этого на сервере допускается расхождение timestamp до 100 секунд. Серверный код на PHP:
$_TIME    = $_SERVER['REQUEST_TIME'];//время запроса
$_KEY     = "0000-0000-0000-0000-0000";//тотже ключ, что и на клиенте
$_TIMEOUT = 100; //допустимое отклонение времени
// Проверим валидность данных
//присланная подпись должна сойтись со сгенерированной на основе закрытого ключа 
$q = $_GET;
//отсортируем также как в flex
unset($q['timestamp'], $q['sig']);
ksort($q);
$req = "";
foreach($q as $k=>$v) {
  $req .= $k."=".$v."&";
}
$req = substr($req, 0, -1);

if($_GET['sig'] != md5($req.$_KEY)) {
  //подписи не сошлись. Данные были подменены.
  exit;
}
//Проверим расхождение времени
if($_GET['timestamp'] + $_TIMEOUT < $_TIME) {
  //Расхождение более чем на 100 секунд. Прерываем запрос
  exit;
}

К сожалению такой метод не гарантирует нам, что запрос не будет перехвачен на промежуточном узле и не продублирован N раз в течении этих 100 секунд. Для устранения этого служит следующий пункт:

2. Проверка запроса на дублирование.
Идею я позаимствовал из TCP/IP. Добавим к запросу еще один параметр = счетчику сообщений, а на сервере будем хранить значение последнего счетчика. На клиенте достаточно сгенерировать счетчик = текущее значение + 1, а на сервере принять, проверить что новое значение больше старого и обновить серверный счетчик. Это гарантирует нам, что если злоумышленник перехватит запрос и пошлет его заново, то сервер его отвергнет, т.к. значение будет меньше хранящегося на сервере.
Для реализации этого механизма на сервере нам уже понадобится БД, а на клиенте дополнительный запрос на получение счетчика.

Под катом можно увидеть полный код клиента и сервера:

Сторона клиента (Flex):
1. Класс реализующий передачу данных и их защиту:
package Lib {  
 import com.adobe.crypto.MD5;
 
 import flash.events.*;
 import flash.net.URLLoader;
 import flash.net.URLRequest;
 import flash.net.URLRequestHeader;
 import flash.net.URLRequestMethod;
 import flash.net.URLVariables;
  
 public class HttpRequest {
  
  private var header:URLRequestHeader;
  private var request:URLRequest;
  private var loader:URLLoader;
  
  public var uid:int = 1;
  private var key:String = "0000-0000-0000-0000-0000";
 
  public function HttpRequest(url: String) {
   loader = new URLLoader();
   header = new URLRequestHeader("pragma", "no-cache");
   request = new URLRequest(url);
  }
  
  public function getData(req: String, func, method:String = "get"):void {
   loader.addEventListener(Event.COMPLETE, func);
   var sreq:String = req + "&uid="+this.uid;
   sreq = sreq.split("&").sort().join("&"); //отсортируем параметры (чтобы хэши сошлись)
   var now:Date = new Date();
   var sec = now.getTime().toString().substr(0, -3);
   sreq = sreq + "×tamp="+sec+"&sig="+MD5.hash(sreq+this.key);
   request.data = new URLVariables(sreq);
   if(method == "post") {
    request.method = URLRequestMethod.POST;
   } else {
    request.method = URLRequestMethod.GET;
   }
   request.requestHeaders.push(header);
   loader.load(request);
  }
 }
}

2. Пример исопльзования:
public static function _get(req:String, func, method:String = "get", server:String = "http://server/listener.php"):void {
 var http:HttpRequest = new HttpRequest(server);
 http.getData(req, function complete(event:Event):void {
  var loader:URLLoader = URLLoader(event.target);
  //генерируем объект (подробнее)
  var xmlObj:XMLTree = new XMLTree(loader.data);
  var data:Object = xmlObj.getData();
  func(data.response);//вызываем колбэк с данными
 }, method);
}

Functions._get("method=user", function(data:Object) {
 Alert.show(data.toString());
});

Серверная сторона (PHP):
$_TIME    = $_SERVER['REQUEST_TIME'];
$_KEY     = "0000-0000-0000-0000-0000";
$_TIMEOUT = 100;
$_TIME_BEFORE_EVENT = 3600*1;
//db
$_SERV    = "localhost";
$_USR     = "root";
$_PASW    = "";
$_DB      = "bookie";

// 1. Проверим валидность данных
//присланная подпись должна сойтись с сгенерированной на основе закрытого ключа (в флэш аналогичная генерация)
//когда данные подтверждены, можно двигаться дальше
$q = $_GET;
//отсортируем также как в flex
unset($q['timestamp'], $q['sig']);
ksort($q);
$req = "";
foreach($q as $k=>$v) {
  $req .= $k."=".$v."&";
}
$req = substr($req, 0, -1);

if($_GET['sig'] != md5($req.$_KEY)) {
  XMLError(1, 'Erorr Sign');
}
//2. Проверим расхождение времени
if($_GET['timestamp'] + $_TIMEOUT < $_TIME) {
  XMLError(2, 'Request Expired');
}

//далее нужна БД
$link = mysql_connect($_SERV, $_USR, $_PASW);
if(!$link) {
  XMLError(5, 'DB Server Unaviable');
}
if(!mysql_select_db($_DB, $link)) {
  XMLError(6, 'DB Not Exist');
}

//основные методы
if($_GET['method'] == 'section') {
   checkRepeat($uid);
   XMLSelect('SELECT id, name, last_update, type_name, cnt_elem FROM section WHERE tid = '.intval($_GET['tid']).getFilter(), 'section');
} else {
  XMLError(4, 'Unknown Method');
}

function checkRepeat($uid) {
  //будем контролировать только запросы на обновление, на просмотр пох
  $id = mysql_result(mysql_query("SELECT lastid FROM `user` WHERE id = ".$uid), 0);

  //Проверим на повтор
  //пришедший счетчик должен быть больше текущего значения. Не важно на сколько, т.к. верность данных мы уже проверили на шаге 1.
  if($_GET['lid'] <= $id) {
    //вопрос: а если запрос долго идет и счетчик уже увеличился?
    XMLError(3, 'Dublicate request');
  }
  if(!mysql_query("UPDATE `user` SET lastid = lastid + 1 WHERE id = ".$uid)) echo mysql_error();
}

function XMLSelect($sql, $name = 'element', $list = true) {
  $result = mysql_query($sql);
  if (!$result) XMLError(7, mysql_error());
  
  header('Content-type: application/xml');
  $ret = '<?xml version="1.0" encoding="utf-8"?>'.
  '<response>';
  $keys = NULL;
  while ($row = mysql_fetch_assoc($result)) {
    $ret .= '<'.$name.'>';
    if(!$keys) {
      $keys = array_keys($row);
    }
    if($list) {
      foreach($keys as $key) {
        $ret .= '<'.$key.'>'.$row[$key].'</'.$key.'>';
      }
    } else {
      $ret .= $row[$keys[0]];
    }
    $ret .= '</'.$name.'>';
  }
  echo $ret.'</response>';
  exit;
}

function XMLUpdate($sql, $uid = NULL) {
  if(!empty($uid))
    checkRepeat($uid);
  $result = mysql_query($sql);
  if (!$result) XMLError(7, mysql_error());
  header('Content-type: application/xml');
  echo '<?xml version="1.0" encoding="utf-8"?>'.
       '<response>1</response>';
  exit;
}

function XMLError($code, $str = 'Unknown Error') {
  header('Content-type: application/xml');
  echo '<?xml version="1.0" encoding="utf-8"?>'.
       '<error>'.
       ' <error_code>'.$code.'</error_code>'.
       ' <error_msg>'.$str.'</error_msg>'.
       '</error>';
  exit;
}

Структура БД не имеет значения. Надеюсь общий смысл понятен.