Permalink
Browse files

sharedSession support for better security on shared domains

  • Loading branch information...
1 parent 8e6e7e0 commit 42481fa03f98465fd6dc6eb99a5124c01e8797d2 @daaku daaku committed Jul 19, 2012
Showing with 612 additions and 34 deletions.
  1. +113 −29 src/base_facebook.php
  2. +71 −4 src/facebook.php
  3. +428 −1 tests/tests.php
View
@@ -120,7 +120,12 @@ public function __toString() {
/**
* Version.
*/
- const VERSION = '3.1.1';
+ const VERSION = '3.2.0';
+
+ /**
+ * Signed Request Algorithm.
+ */
+ const SIGNED_REQUEST_ALGORITHM = 'HMAC-SHA256';
/**
* Default options for curl.
@@ -129,7 +134,7 @@ public function __toString() {
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
- CURLOPT_USERAGENT => 'facebook-php-3.1',
+ CURLOPT_USERAGENT => 'facebook-php-3.2',
);
/**
@@ -201,6 +206,13 @@ public function __toString() {
protected $fileUploadSupport = false;
/**
+ * Indicates if we trust HTTP_X_FORWARDED_* headers.
+ *
+ * @var boolean
+ */
+ protected $trustForwarded = false;
+
+ /**
* Initialize a Facebook Application.
*
* The configuration:
@@ -216,7 +228,9 @@ public function __construct($config) {
if (isset($config['fileUpload'])) {
$this->setFileUploadSupport($config['fileUpload']);
}
-
+ if (isset($config['trustForwarded']) && $config['trustForwarded']) {
+ $this->trustForwarded = true;
+ }
$state = $this->getPersistentData('state');
if (!empty($state)) {
$this->state = $state;
@@ -934,8 +948,9 @@ protected function parseSignedRequest($signed_request) {
$sig = self::base64UrlDecode($encoded_sig);
$data = json_decode(self::base64UrlDecode($payload), true);
- if (strtoupper($data['algorithm']) !== 'HMAC-SHA256') {
- self::errorLog('Unknown algorithm. Expected HMAC-SHA256');
+ if (strtoupper($data['algorithm']) !== self::SIGNED_REQUEST_ALGORITHM) {
+ self::errorLog(
+ 'Unknown algorithm. Expected ' . self::SIGNED_REQUEST_ALGORITHM);
return null;
}
@@ -951,6 +966,26 @@ protected function parseSignedRequest($signed_request) {
}
/**
+ * Makes a signed_request blob using the given data.
+ *
+ * @param array The data array.
+ * @return string The signed request.
+ */
+ protected function makeSignedRequest($data) {
+ if (!is_array($data)) {
+ throw new InvalidArgumentException(
+ 'makeSignedRequest expects an array. Got: ' . print_r($data, true));
+ }
+ $data['algorithm'] = self::SIGNED_REQUEST_ALGORITHM;
+ $data['issued_at'] = time();
+ $json = json_encode($data);
+ $b64 = self::base64UrlEncode($json);
+ $raw_sig = hash_hmac('sha256', $b64, $this->getAppSecret(), $raw = true);
+ $sig = self::base64UrlEncode($raw_sig);
+ return $sig.'.'.$b64;
+ }
+
+ /**
* Build the URL for api given parameters.
*
* @param $method String the method name.
@@ -1051,25 +1086,52 @@ protected function getUrl($name, $path='', $params=array()) {
return $url;
}
+ protected function getHttpHost() {
+ if ($this->trustForwarded && isset($_SERVER['HTTP_X_FORWARDED_HOST'])) {
+ return $_SERVER['HTTP_X_FORWARDED_HOST'];
+ }
+ return $_SERVER['HTTP_HOST'];
+ }
+
+ protected function getHttpProtocol() {
+ if ($this->trustForwarded && isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
+ if ($_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
+ return 'https';
+ }
+ return 'http';
+ }
+ if (isset($_SERVER['HTTPS']) &&
+ ($_SERVER['HTTPS'] === 'on' || $_SERVER['HTTPS'] == 1)) {
+ return 'https';
+ }
+ return 'http';
+ }
+
+ /**
+ * Get the base domain used for the cookie.
+ */
+ protected function getBaseDomain() {
+ // The base domain is stored in the metadata cookie if not we fallback
+ // to the current hostname
+ $metadata = $this->getMetadataCookie();
+ if (array_key_exists('base_domain', $metadata) &&
+ !empty($metadata['base_domain'])) {
+ return trim($metadata['base_domain'], '.');
+ }
+ return $this->getHttpHost();
+ }
+
+ /**
+
/**
* Returns the Current URL, stripping it of known FB parameters that should
* not persist.
*
* @return string The current URL
*/
protected function getCurrentUrl() {
- if (isset($_SERVER['HTTPS']) &&
- ($_SERVER['HTTPS'] == 'on' || $_SERVER['HTTPS'] == 1) ||
- isset($_SERVER['HTTP_X_FORWARDED_PROTO']) &&
- $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') {
- $protocol = 'https://';
- }
- else {
- $protocol = 'http://';
- }
- $host = isset($_SERVER['HTTP_X_FORWARDED_HOST'])
- ? $_SERVER['HTTP_X_FORWARDED_HOST']
- : $_SERVER['HTTP_HOST'];
+ $protocol = $this->getHttpProtocol() . '://';
+ $host = $this->getHttpHost();
$currentUrl = $protocol.$host.$_SERVER['REQUEST_URI'];
$parts = parse_url($currentUrl);
@@ -1173,6 +1235,7 @@ protected static function errorLog($msg) {
* Exactly the same as base64_encode except it uses
* - instead of +
* _ instead of /
+ * No padded =
*
* @param string $input base64UrlEncoded string
* @return string
@@ -1182,6 +1245,21 @@ protected static function base64UrlDecode($input) {
}
/**
+ * Base64 encoding that doesn't need to be urlencode()ed.
+ * Exactly the same as base64_encode except it uses
+ * - instead of +
+ * _ instead of /
+ *
+ * @param string $input string
+ * @return string base64Url encoded string
+ */
+ protected static function base64UrlEncode($input) {
+ $str = strtr(base64_encode($input), '+/', '-_');
+ $str = str_replace('=', '', $str);
+ return $str;
+ }
+
+ /**
* Destroy the current session
*/
public function destroySession() {
@@ -1196,23 +1274,14 @@ public function destroySession() {
if (array_key_exists($cookie_name, $_COOKIE)) {
unset($_COOKIE[$cookie_name]);
if (!headers_sent()) {
- // The base domain is stored in the metadata cookie if not we fallback
- // to the current hostname
- $base_domain = '.'. $_SERVER['HTTP_HOST'];
-
- $metadata = $this->getMetadataCookie();
- if (array_key_exists('base_domain', $metadata) &&
- !empty($metadata['base_domain'])) {
- $base_domain = $metadata['base_domain'];
- }
-
- setcookie($cookie_name, '', 0, '/', $base_domain);
+ $base_domain = $this->getBaseDomain();
+ setcookie($cookie_name, '', 1, '/', '.'.$base_domain);
} else {
// @codeCoverageIgnoreStart
self::errorLog(
'There exists a cookie that we wanted to clear that we couldn\'t '.
'clear because headers was already sent. Make sure to do the first '.
- 'API call before outputing anything'
+ 'API call before outputing anything.'
);
// @codeCoverageIgnoreEnd
}
@@ -1250,6 +1319,21 @@ protected function getMetadataCookie() {
return $metadata;
}
+ protected static function isAllowedDomain($big, $small) {
+ if ($big === $small) {
+ return true;
+ }
+ return self::endsWith($big, '.'.$small);
+ }
+
+ protected static function endsWith($big, $small) {
+ $len = strlen($small);
+ if ($len === 0) {
+ return true;
+ }
+ return substr($big, -$len) === $small;
+ }
+
/**
* Each of the following four methods should be overridden in
* a concrete subclass, as they are in the provided Facebook class.
View
@@ -23,25 +23,76 @@
*/
class Facebook extends BaseFacebook
{
+ const FBSS_COOKIE_NAME = 'fbss';
+
+ // We can set this to a high number because the main session
+ // expiration will trump this.
+ const FBSS_COOKIE_EXPIRE = 31556926; // 1 year
+
+ // Stores the shared session ID if one is set.
+ protected $sharedSessionID;
+
/**
* Identical to the parent constructor, except that
* we start a PHP session to store the user ID and
* access token if during the course of execution
* we discover them.
*
- * @param Array $config the application configuration.
+ * @param Array $config the application configuration. Additionally
+ * accepts "sharedSession" as a boolean to turn on a secondary
+ * cookie for environments with a shared session (that is, your app
+ * shares the domain with other apps).
* @see BaseFacebook::__construct in facebook.php
*/
public function __construct($config) {
if (!session_id()) {
session_start();
}
parent::__construct($config);
+ if (!empty($config['sharedSession'])) {
+ $this->initSharedSession();
+ }
}
protected static $kSupportedKeys =
array('state', 'code', 'access_token', 'user_id');
+ protected function initSharedSession() {
+ $cookie_name = $this->getSharedSessionCookieName();
+ if (isset($_COOKIE[$cookie_name])) {
+ $data = $this->parseSignedRequest($_COOKIE[$cookie_name]);
+ if ($data && !empty($data['domain']) &&
+ self::isAllowedDomain($this->getHttpHost(), $data['domain'])) {
+ // good case
+ $this->sharedSessionID = $data['id'];
+ return;
+ }
+ // ignoring potentially unreachable data
+ }
+ // evil/corrupt/missing case
+ $base_domain = $this->getBaseDomain();
+ $this->sharedSessionID = md5(uniqid(mt_rand(), true));
+ $cookie_value = $this->makeSignedRequest(
+ array(
+ 'domain' => $base_domain,
+ 'id' => $this->sharedSessionID,
+ )
+ );
+ $_COOKIE[$cookie_name] = $cookie_value;
+ if (!headers_sent()) {
+ $expire = time() + self::FBSS_COOKIE_EXPIRE;
+ setcookie($cookie_name, $cookie_value, $expire, '/', '.'.$base_domain);
+ } else {
+ // @codeCoverageIgnoreStart
+ self::errorLog(
+ 'Shared session ID cookie could not be set! You must ensure you '.
+ 'create the Facebook instance before headers have been sent. This '.
+ 'will cause authentication issues after the first request.'
+ );
+ // @codeCoverageIgnoreEnd
+ }
+ }
+
/**
* Provides the implementations of the inherited abstract
* methods. The implementation uses PHP sessions to maintain
@@ -83,11 +134,27 @@ protected function clearAllPersistentData() {
foreach (self::$kSupportedKeys as $key) {
$this->clearPersistentData($key);
}
+ if ($this->sharedSessionID) {
+ $this->deleteSharedSessionCookie();
+ }
+ }
+
+ protected function deleteSharedSessionCookie() {
+ $cookie_name = $this->getSharedSessionCookieName();
+ unset($_COOKIE[$cookie_name]);
+ $base_domain = $this->getBaseDomain();
+ setcookie($cookie_name, '', 1, '/', '.'.$base_domain);
+ }
+
+ protected function getSharedSessionCookieName() {
+ return self::FBSS_COOKIE_NAME . '_' . $this->getAppId();
}
protected function constructSessionVariableName($key) {
- return implode('_', array('fb',
- $this->getAppId(),
- $key));
+ $parts = array('fb', $this->getAppId(), $key);
+ if ($this->sharedSessionID) {
+ array_unshift($parts, $this->sharedSessionID);
+ }
+ return implode('_', $parts);
}
}
Oops, something went wrong.

1 comment on commit 42481fa

Maybe I'm missing something, but what's the point of the sharedSessionID?
The session key already includes the app ID, so different apps running on the same domain won't conflict, and the random sharedSessionID is stored in a cookie that includes the app ID, so different instances of the same app will still share the session.

Please sign in to comment.