Accepting PayPal in games(完整的Paypal在Unity的支付)

 

Hello and welcome back to my blog!

In this article I’m going to talk about the process of accepting payment via PayPal in your own games. Although I focus on Flash, the techniques and web-calls can be performed in pretty much any language, as long as there is internet access and the platform is capable of opening up a browser tab/page after clicking a button in game.

Requirements

The prerequisites for the readers of this article:

  • Must have access to a PayPal business account
  • Must have access to a server with a LAMP stack
  • Must understand php/mysql and basic server configurations

If you don’t have a server, but still want to be able to accept PayPal, or if the idea of server-side security scares you, scroll to the bottom of this article for details on how you can achieve this.

Lets set out the requirements for the design of the system as well:

  • Enable users to buy individual items like levels, or the full version of their game
  • Automated delivery system so users get immediate access to their purchases
  • Login/registration system so users can access their purchased items from any computer
  • Users, purchases and transactions stored in database
  • Must be secure
  • Item prices must be editable in real time
  • Must have automated password-reset system
  • Must have automated email sending system on transaction failure
  • Must leave the IPN message notification URL free for other applications

How will it work?

PayPal has a system called IPN, which stands for Instant Payment Notification. What this means is that when someone buys something via your PayPal account, PayPal will send a notification to a location you specify somewhere on the internet.

Figure 1

Once you have been notified and have verified the details (to combat fraud), you can then give the purchaser access to the item in the database. The game will be polling your server in the background for changes in a user’s purchases, once it detects one, the game can finally give the user access to the item they purchased and the transaction is complete. This sounds like a long winded process, and technically it is, but its all over in the course of a few seconds in reality.

Setting up your PayPal business account

In order for this process to be possible IPN must be enabled on your PayPal business account. First locate the settings area in Profile->My selling preferences:

Click for enlargement

Set ‘Message Delivery’ to Enabled – you can leave the Notification URL as it was, this enables you to use other applications which require an IPN URL to be set:

Click for enlargement

Next (and this is optional), I recommend blocking eCheques. The reason is they are not an instant form of payment, and can take days to clear leading to a bad customer experience and support requests for you to deal with:

Click for enlargement

Blocking continued:

Click for enlargement

Set up a PayPal sandbox account

This is a good time to set yourself up with a PayPal sandbox account. This will let you send a lot of dummy transactions without actually spending any real money, which is good because there will no doubt be a lot of transactions sent in the course of development!

The IPN script on your server

PayPal provide some example code of what the server-side script should look like which receives notifications from PayPal:

// PHP 4.1
 
// read the post from PayPal system and add 'cmd'
$req = 'cmd=_notify-validate';
 
foreach ($_POST as $key => $value) {
$value = urlencode(stripslashes($value));
$req .= "&$key=$value";
}
 
// post back to PayPal system to validate
$header .= "POST /cgi-bin/webscr HTTP/1.0
";
$header .= "Content-Type: application/x-www-form-urlencoded
";
$header .= "Content-Length: " . strlen($req) . "

";
$fp = fsockopen ('ssl://www.paypal.com', 443, $errno, $errstr, 30);
 
// assign posted variables to local variables
$item_name = $_POST['item_name'];
$item_number = $_POST['item_number'];
$payment_status = $_POST['payment_status'];
$payment_amount = $_POST['mc_gross'];
$payment_currency = $_POST['mc_currency'];
$txn_id = $_POST['txn_id'];
$receiver_email = $_POST['receiver_email'];
$payer_email = $_POST['payer_email'];
 
if (!$fp) {
// HTTP ERROR
} else {
fputs ($fp, $header . $req);
while (!feof($fp)) {
$res = fgets ($fp, 1024);
if (strcmp ($res, "VERIFIED") == 0) {
// check the payment_status is Completed
// check that txn_id has not been previously processed
// check that receiver_email is your Primary PayPal email
// check that payment_amount/payment_currency are correct
// process payment
}
else if (strcmp ($res, "INVALID") == 0) {
// log for manual investigation
}
}
fclose ($fp);
}
?>

Its quite basic but it gives us a decent starting point.

Mysql set-up

In order to process transactions which arrive at the script above, we will need to be able to log them in a database, so its worth setting up a new Mysql database at this point with three tables:

  • One for the users of your game
  • One for the items they can buy
  • One for the transactions they make

Data stored on the server

In order to facilitate the requirements I outlined at the start, the database must store the users of the game, so they can log in/out and still have access to their purchases.

It must store the individual items which are for sale, along with their prices, because we would like to be able to fine tune the prices in real time as people are playing the game.

It must also store the transactions which occur in case of disputes or support requests.

Finally, and most importantly, it must store an inventory of the actual items which each user has bought – this will get sent from the server to the game when a user logs in so the game can ‘unlock’ those items for that user.

The inventory of items

As the requirements call for an inventory of items, the most compact way this can be stored is in a uint32, with one bit per item, leaving a possible maximum of 32 items purchasable. This should be plenty for most games. This uint32 is stored directly on the user row, which makes for excellent data locality. If your game requires a virtual currency system, you can also add this to the user row in a similar fashion, but I’m not going to cover virtual currency in this article.

Note that because php doesn’t actually have a unsigned integer data type, we end up with an int32 and 31 possible purchasable items.

The full user row

Here is what the full user row looks like for this system:

Figure 2

I’ve chosen to store the user’s email address as their username (just because its much easier for them to remember), a salted sha1 hash of their password, some date information, their inventory items (item_flags) and finally one bit to indicate if there has been a transaction error which has not yet been displayed to the user.

Security note: never store a user’s password in plain-text, if your database were to be compromised you could be responsible for leaking a password which they may well use for other purposes. Its much better to hash that password. Even better is to salt and hash their password, that way you avoid possible rainbow table attacks on the hashed password, should it be leaked. To see what I mean, just type 2fd4e1c67a2d28fced849ee1bb76e7391b93eb12 into google.

The purchasable items

Because I’ve chosen a uint32 to store the users items, each individual item must be represented by a single number which must be a power of two, otherwise known as a ‘bit’.

Figure 3

Figure 3 shows the full data set for the items. An important point to note is that the price is stored decimal ‘fixed point’, to avoid any floating point rounding problems, so each actual price is multiplied by 100.

There is a gottcha associated with this decimal fixed point system; in php, if you take the following example:

$n = "19.99";
$d = intval($n*100);
echo $d;

You end up with 1998 being echoed. Now, this wouldn’t be a problem if php was less weakly typed, but because it isn’t you need to take special care when converting between decimal and fixed point:

$n = "19.99";
$d = intval(strval($n*100));
echo $d;

Yields the correct result.

The transactions

Figure 4 - click to enlarge

Finally, Figure 4 shows the transactions table entries; we’re storing everything which PayPal sends us via the IPN script as well as some extra data indicating the actual user who’s purchase this is etc.

Foreign key constraint

Its also worth taking advantage of the relational nature of mysql by adding a foreign key constraint to the transactions table. This simply says that the user_uid field of the transactions table is tied to the uid field of the users table. Additionally, a transaction cannot exist without the user who made it existing first, and indeed cannot be deleted without the user being deleted first.

It will look something like this:

ALTER TABLE transactions
ADD CONSTRAINT FKT_user
FOREIGN KEY (user_uid) REFERENCES users(uid)  
ON UPDATE CASCADE  
ON DELETE CASCADE;

Sql query to set it all up

Here is an sql query which will set up all these tables:

-- phpMyAdmin SQL Dump
-- version 3.4.9
-- http://www.phpmyadmin.net
--
-- Host: localhost
-- Generation Time: Mar 16, 2012 at 05:41 AM
-- Server version: 5.1.56
-- PHP Version: 5.2.6
 
SET SQL_MODE="NO_AUTO_VALUE_ON_ZERO";
SET time_zone = "+00:00";
 
 
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8 */;
 
-- --------------------------------------------------------
 
--
-- Table structure for table `items`
--
 
CREATE TABLE IF NOT EXISTS `items` (
  `uid` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(32) NOT NULL,
  `number` int(10) unsigned NOT NULL,
  `price` int(10) unsigned NOT NULL,
  PRIMARY KEY (`uid`)
) ENGINE=InnoDB  DEFAULT CHARSET=latin1 AUTO_INCREMENT=9 ;
 
--
-- Dumping data for table `items`
--
 
INSERT INTO `items` (`uid`, `name`, `number`, `price`) VALUES
(5, 'Lightning', 1, 1),
(6, 'Wood', 4, 3),
(7, 'Pie', 2, 2),
(8, 'Fez hat', 8, 4);
 
-- --------------------------------------------------------
 
--
-- Table structure for table `transactions`
--
 
CREATE TABLE IF NOT EXISTS `transactions` (
  `txn_id` varchar(12) NOT NULL,
  `item_name` text NOT NULL,
  `item_number` int(10) unsigned NOT NULL,
  `payment_status` text NOT NULL,
  `payment_amount` int(11) NOT NULL,
  `payment_currency` varchar(10) NOT NULL,
  `receiver_email` text NOT NULL,
  `payer_email` text NOT NULL,
  `date` datetime NOT NULL,
  `status` enum('Ok','PayPalInvalid','HttpError','NoStatus','PaymentNotCompleted','ItemNotFound','UserNotFound','SellerEmailIncorrect','ItemHashMismatch','MultipleErrors','IncorrectToken') NOT NULL DEFAULT 'NoStatus',
  `user_uid` int(10) unsigned NOT NULL,
  PRIMARY KEY (`txn_id`),
  KEY `FKT_users` (`user_uid`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
 
-- --------------------------------------------------------
 
--
-- Table structure for table `users`
--
 
CREATE TABLE IF NOT EXISTS `users` (
  `uid` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `email` text NOT NULL,
  `password_hash` varchar(40) NOT NULL,
  `register_date` datetime NOT NULL,
  `unregister_date` datetime NOT NULL,
  `item_flags` int(10) unsigned NOT NULL DEFAULT '0',
  `ipn_error` tinyint(1) NOT NULL DEFAULT '0',
  PRIMARY KEY (`uid`)
) ENGINE=InnoDB  DEFAULT CHARSET=latin1 AUTO_INCREMENT=2 ;
 
--
-- Constraints for dumped tables
--
 
--
-- Constraints for table `transactions`
--
ALTER TABLE `transactions`
  ADD CONSTRAINT `FKT_users` FOREIGN KEY (`user_uid`) REFERENCES `users` (`uid`) ON DELETE CASCADE ON UPDATE CASCADE;
 
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;

Sql injection

The phrase which fills every web developer with fear. Mysql injection is covered here, the summary is: anything which is stored in your database from user input is vulnerable to malicious attacks by users who attempt to insert SQL commands into the data being input to gain access to secret information stored in your database, or just to do harm to it.

Luckily Mysql has this covered in the form of prepared statements which handle this problem for you. The disadvantage is they are quite cumbersome compared to the traditional way of performing queries, as you can see from the following example, which attempts to find out the district which a city is located within:

/* create a prepared statement */
if ($stmt = $mysqli->prepare("SELECT District FROM City WHERE Name=?")) {
 
    /* bind parameters for markers */
    $stmt->bind_param("s", $city);
 
    /* execute query */
    $stmt->execute();
 
    /* bind result variables */
    $stmt->bind_result($district);
 
    /* fetch value */
    $stmt->fetch();
 
    printf("%s is in district %s
", $city, $district);
 
    /* close statement */
    $stmt->close();
}

However, luckily someone has made a wrapper function to do all the hard work for you and can be found in the comments on the php documentation for mysqli::prepare(). Using this, the above becomes:

$results = mysqli_prepared_query($mysqli, "SELECT District FROM City WHERE Name=?", array("s", $city));
printf("%s is in district %s
", $city, $results[0]['district']);

This should not be used if you need to return huge amounts of data in a single query because it packages the data up into a php array, but for simple results its more than adequate. In the example code which follows, I’ve packaged this up into a Database class for neatness.

Altering the IPN script to log the data

As I mentioned above the IPN script example which PayPal give is a good starting point, but it needs a lot of verification and logging adding to it before it will be ready for production. Here is my finished IPN script, you should take this as psudocode because it relies on a lot of the library functions I’ve built up during the course of writing the demo accompanying this article – hopefully it should be obvious enough what is happening, though:

if (	isset($_POST['item_name']) &&
	isset($_POST['item_number']) &&
	isset($_POST['payment_status']) &&
	isset($_POST['mc_gross']) &&
	isset($_POST['mc_currency']) &&
	isset($_POST['txn_id']) &&
	isset($_POST['receiver_email']) &&
	isset($_POST['payer_email']) &&
	isset($_POST['custom']) &&
	isset($_GET['token']) &&
	isset($_GET['item_hash']))
{
	require_once 'database.php';
 
	// read the post from PayPal system and add 'cmd'
	$req = 'cmd=_notify-validate';
 
	foreach ($_POST as $key => $value)
	{
		$value = urlencode(stripslashes($value));
		$req .= "&$key=$value";
	}
 
	// post back to PayPal system to validate
	$header = "POST /cgi-bin/webscr HTTP/1.0
";
	$header .= "Content-Type: application/x-www-form-urlencoded
";
	$header .= "Content-Length: " . strlen($req) . "

";
	$fp = fsockopen (PAYPAL_END_POINT, 443, $errno, $errstr, 30);
 
	// assign posted variables to local variables
	$item_name = $_POST['item_name'];
	$item_number = $_POST['item_number'];
	$payment_status = $_POST['payment_status'];
	$payment_amount = $_POST['mc_gross'];
	$payment_currency = $_POST['mc_currency'];
	$txn_id = $_POST['txn_id'];
	$receiver_email = $_POST['receiver_email'];
	$payer_email = $_POST['payer_email'];
	$uid = $_POST['custom'];
	$token = $_GET['token'];
	$item_hash = $_GET['item_hash'];
 
	$status = eTransactionStatus::NoStatus;
 
	// open the database
	$dataBase = new Database(DB_HOST, DB_USER, DB_PASS, DB_NAME);
 
	// check uid exists as a user
	$userRow = $dataBase->GetUser($uid);
 
	// check token
	if ($userRow && GenerateSaltedHash($userRow['email']) == $token)
	{
		if (!$fp)
		{
			// HTTP ERROR
			SetStatus(eTransactionStatus::HttpError);
		}
		else
		{
			fputs ($fp, $header . $req);
 
			while (!feof($fp))
			{
				$res = fgets ($fp, 1024);
				if (strcmp ($res, "VERIFIED") == 0)
				{
					// check the payment_status is Completed
					// check that txn_id has not been previously processed
					// check that receiver_email is your Primary PayPal email
					// check that payment_amount/payment_currency are correct
					// process payment
 
					// mismatched email = problem
					if (strcasecmp($receiver_email, PAYPAL_EMAIL)!=0)
					{
						SetStatus(eTransactionStatus::SellerEmailIncorrect);
					}
 
					// look up the item number
					$result = $dataBase->Query("SELECT price FROM items WHERE number=?", array("i", $item_number));
 
					// no lookup = problem
					if ($result==null)
					{
						SetStatus(eTransactionStatus::ItemNotFound);
					}
					else
					{
						$item = array('name'=>$item_name, 'number'=>$item_number, 'price'=>Database::ConvertPriceToInteger($payment_amount));
 
						// hash doesn't match = problem
						if (GenerateItemHash($item) != $item_hash)
						{
							SetStatus(eTransactionStatus::ItemHashMismatch);
						}
					}
 
					// payment status not completed = problem
					if (strcasecmp($payment_status, "Completed") != 0)
					{
						SetStatus(eTransactionStatus::PaymentNotCompleted);
					}
 
					if ($userRow!=null && $status == eTransactionStatus::NoStatus)
					{
						// upgrade the user's item flags with this new item
						$dataBase->UpdateItemFlags($uid, $item_number);
 
						// clear any transaction error
						$dataBase->SetIpnError($uid, 0);
 
						// finally we got a good transaction status!
						SetStatus(eTransactionStatus::Ok);
					}
 
					break;
				}
				else if (strcmp ($res, "INVALID") == 0)
				{
					SetStatus(eTransactionStatus::PayPalInvalid);
					break;
				}
			}
 
			fclose ($fp);
		}
 
		// log this transaction no matter what happens
		$dataBase->InsertTransaction($item_name, $item_number, $payment_status, $payment_amount, $payment_currency, $txn_id, $receiver_email, $payer_email, GetMySqlDateTime(), $status, $uid);
 
		if ($status != eTransactionStatus::Ok)
		{
			//
			// set ipn_error on user so client gets notified
			//
 
			$result = $dataBase->GetUser($uid);
			if ($result)
			{
				$dataBase->SetIpnError($uid, 1);
			}
 
			//
			// email buyer
			//
 
			$subject = 'Problem with PayPal transaction';
			$message = '
 
			Sorry, we had a problem processing your PayPal transaction for item "'. $item_name . '":
 
			Transaction ID: ' . $txn_id . '
 
			Please contact customer support to resolve this issue. You can do that by replying to this email.
			';
			$headers = 'From:'. SUPPORT_EMAIL . "
";
 
			// if we're in sandbox mode, send this email to the same location as the next one - the seller
			if (strstr(PAYPAL_END_POINT, "sandbox"))
			{
				mail(PROBLEM_TRANSACTION_EMAIL, $subject, $message, $headers);
			}
			else
			{
				mail($payer_email, $subject, $message, $headers);
			}
 
			//
			// email seller
			//
 
			$subject = 'User ' . $payer_email . ' failed transaction ' . $txn_id;
			$message = '
 
			User '. $payer_email . ' failed the transactions because: ' . $status . '
 
			They were trying to buy item "' . $item_name . '".
 
			They have been sent an email asking them to contact ' . SUPPORT_EMAIL;
			$headers = 'From:'. SUPPORT_EMAIL . "
";
			mail(PROBLEM_TRANSACTION_EMAIL, $subject, $message, $headers);
		}
	}
	else
	{
		error_log("payPalNotify.php - bad token:" . $token . " or unknown user uid: " . $uid . " POST=".print_r($_POST, true));
	}
}
else
{
	error_log("payPalNotify.php - incorrect parameters: got POST=" . print_r($_POST, true) . " GET=".print_r($_GET, true));
}
 
 
/**
 * Set the status variable
 *
 * @param <eTransactionStatus> $newStatus
 */
function SetStatus($newStatus)
{
	global $status;
 
	if ($status != eTransactionStatus::NoStatus)
	{
		$status = eTransactionStatus::MultipleErrors;
	}
	else
	{
		$status = $newStatus;
	}
}

The script now performs a series of operations:

  • Checks for correct parameters
  • Checks for valid security token (more on that soon)
  • Checks the user exists
  • Checks transaction is actually valid via PayPal
  • Checks the merchant email (your PayPal business email) is correct
  • Checks the amount paid for the given item is correct (more on that soon)
  • Checks the item exists
  • Checks the money actually entered your PayPal account
  • Unlocks the item in the database for the correct user
  • Logs the complete transaction
  • Logs any failed transaction on the user row (more on that soon)
  • Sends an email to your support email address and the user on failure
  • Finally, it logs any special case failures in the system error_log

PAYPAL_END_POINT can either be ssl://www.sandbox.paypal.com or ssl://www.paypal.com, depending on whether you want to use sandbox or live mode.

Fraud detection

There are a couple of things going on in the script to check for fraud.

No.1 is actually confirming that the item price and number hasn’t been tampered with.

The reason for this is that the price for an item is simply a GET parameter on the HTTP request to the PayPal servers (i.e. a URL which gets opened in the browser). A fraudster* could intercept the URL the game was generating and replace the price with 0.01, which is the lowest price PayPal will allow, thereby paying far less than he should have. Instead of looking up the item number in the database and checking the price against the price paid, I check the item_hash passed in the $_GET parameters matches a hash of the given name, number and price of the item. The reason for this is that there is an edge case because the prices are transmitted to the game at log-in time. If I just checked the item price in the database, the database entry could have been updated with new price values (tweaked as per the requirements) while the user was playing the game. This would cause the IPN script would fail. Doing things with a hash mean the user always pays the price that was when he first logged in, yet you are still free to tune your prices in real time.

* I like the word ‘fraudster’. It puts be in mind of the one of the cars designed by the late Boyd Coddington, the Boydster.

No.2 is to check for a valid security token.

I will cover this in more detail later, but the idea is that this is a unique key generated on your server when a user logs in, and passed back to the client side. All future web-requests coming from the client must be accompanied with this token to be accepted.

No.3 is to check that the transaction was actually real.

If a fraudster finds your ipn script address, he could post to it with the correct parameters to fake a transaction which didn’t actually occur. In order to check for this, the script calls PayPal to confirm the transaction actually occurred.

Client side

 

We’ve talked exclusively about the server-side of this process up until now, but lets take a look at the client side.

Web-requests

Key to this process is the client’s ability to send web-requests to the server. The platform being used must have this ability or this process will not be possible.

I’m using POST requests almost exclusively in this article. Furthermore, I’m expecting the response to these web-requests to be in JSON format.

Here is a simple AS3 class to let you perform web-requests:

package Wildbunny.System
{
	import flash.net.*;
	import flash.events.*;
 
	public class WebRequest
	{
		protected var m_success:Function;
		protected var m_failure:Function;
 
		/// <summary>
		/// Perform a web request
		///
		/// @param <String> url The url of the web request
		/// @param <URLVariables> parameters Parameters for this web request
		/// @param <Function> success Function to perform on success - must accept one parameter of type Object which is the response from the web request
		/// @param <Function> failure Function to perform on failure - must accept one parameter of type WildError
		/// @param <String> method Of type URLRequestMethod which determins whether this is to be a POST or GET request
		/// </summary>
		public function WebRequest( url:String, parameters:URLVariables, success:Function, failure:Function, method:String = URLRequestMethod.POST )
		{
			m_success = success;
			m_failure = failure;
 
			var loader : URLLoader = new URLLoader();  
			var request : URLRequest = new URLRequest(url);  
 
			request.method = method;  
			request.data = parameters;
 
			//  Handlers  
			loader.addEventListener(Event.COMPLETE, OnSuccess);  
			loader.addEventListener(SecurityErrorEvent.SECURITY_ERROR, OnSecurityError);
			loader.addEventListener(IOErrorEvent.IO_ERROR, OnIoError);
 
			// do it
			loader.load(request);  
		}
 
 
		/// <summary>
		/// Called when the web request was successful, calls out to the given callback functions in the constructor.
		///
		/// @param <Event> e Response from web request
		/// </summary>
		protected function OnSuccess( e:Event ):void
		{
			var loader:URLLoader = URLLoader(e.target);
			m_success( loader.data );
		}
 
		/// <summary>
		/// Gets called when there is a security error
		///
		/// @param <SecurityErrorEvent> e Security error
		/// </summary>
		private function OnSecurityError(e:SecurityErrorEvent):void 
		{
			m_failure( new WildError("Security Error", ""+e) );
		}
 
		/// <summary>
		/// Gets called when there is a io error - usually the server is inaccessable.
		///
		/// @param <IOErrorEvent> e Io error
		/// </summary>
		private function OnIoError(e:IOErrorEvent):void 
		{
			m_failure( new WildError("IO Error", "Whoops, something went wrong trying to communicate with the server...") );
		}
	}
}

And here is that class extended to handle JSON deserialisation:

package Wildbunny.System
{
	import flash.events.*;
	import flash.net.*;
	import com.serialization.json.*;
 
	public class WebRequestJsonResponse extends WebRequest
	{
		/// <summary>
		/// Perform a web request which expects a json encoded response
		///
		/// @param <String> url The url of the web request
		/// @param <URLVariables> parameters Parameters for this web request
		/// @param <Function> success Function to perform on success - must accept one parameter of type Object which is the response from the web request
		/// @param <Function> failure Function to perform on failure - must accept one parameter of type WildError
		/// @param <String> method Of type URLRequestMethod which determins whether this is to be a POST or GET request
		/// </summary>
		public function WebRequestJsonResponse( url:String, parameters:URLVariables, success:Function, failure:Function, method:String = URLRequestMethod.POST )
		{
			super( url, parameters, success, failure, method );
		}
 
		/// <summary>
		/// Overrides the success function in WebRequest, calls out to the given callback functions in the constructor.
		/// @param <Event> e Response from web request
		/// </summary>
		protected override function OnSuccess( e:Event ):void
		{
			var loader:URLLoader = URLLoader(e.target);
 
			var obj:Object;
			var error:Boolean = false;
			try
			{
				obj = JSON.deserialize(loader.data);
			}
			catch ( e:Error )
			{
				error = true;
				m_failure( new WildError( "Json error", "Invalid json response" ) );
			}
 
			if ( !error )
			{
				m_success( obj );
			}
		}
	}
}

In order to actually perform the deserialisation, I’ve used the excellent JSON Lite, available here.

Annoyingly, Flash doesn’t let you access the content-type headers of the data returned from a web-request, so I am unable to check that the data is of type “application/json”. You should be able to get access to this in all other languages, though.

Registration

The very first thing a user will do when he is ready to commit to playing your game regularly is to register. This allows you to have a permanent record of this user and his purchases which can then be stored and accessed no matter what computer the user is using; this is in contrast to just using a flash cookie, or local store on the current machine the user is on.

Once the user has entered his details, and you’ve confirmed the email address is valid and the password is of acceptable length, he’ll press ‘Register’, and fire off a web-request to the server.

The web-request simply fills in two POST parameters, one called ‘email’, the other ‘password’ and fires off a request to a script on the server called ‘register.php’. ‘register.php’ does some validity checking (as the IPN script did), and then it creates an entry in the ‘users’ table in the database and generates the all important unique security token.

/**
 * Generate an sha1 email hash using the secret above
 *
 * @param <String> $string
 * @return <String>
 */
function GenerateSaltedHash($string)
{
	return hash_hmac("sha1", $string, SECRET);
}

Update: thanks to user FrogsEye on reddit for pointing out that the previous version of this function was potentially vulnerable to length extension attacks.

Above is the code I use to generate a security token. As you can see the original string (which in this case is simply the users email address, since that’s unique to the user) is salted with a security secret which is known only by the server, and then passed to the HMAC method which is specifically designed to securely generate salted hashes without being vulnerable to length extension attacks which concatenated salts are subject to.

Do not be tempted to use md5 for your hashing as its extremely vulnerable to collision attacks.

The script finally sends a reply in JSON format which contains most of the data in the newly added user row, along with this security token. On the client side this security token is stored for all later web-requests. The JSON reply also contains the entire contents of the ‘items’ table in the database, along with a hash for each item. This is to allow the client side to know what items are available for purchase, how much they are and to be able to post the hash of the item required by the IPN script to verify validity.

Log-in

Returned users are allowed to log-in to the server.

Similarly to the registration process, a web-request is fired off to the server (albeit to a different script), this one attempts to look a user up in the database by username and password:

class Database
{
	...
 
	/**
	 * Attempt to log in with the given credentials
	 *
	 * @param <String> $email
	 * @param <String> $password
	 * @return <Object> User row.
	 */
	public function Login($email, $password)
	{
		$passwordHash = GenerateSaltedHash($password);
		$results = $this->Query( "SELECT " . $this->m_allowedUserFlags . " FROM users WHERE email=? AND password_hash=?", array('ss',$email, $passwordHash) );
		if ($results)
		{
			return $results[0];
		}
		else
		{
			return null;
		}
	}
}
 
// look up the user
$user = $dataBase->Login($_POST['email'], $_POST['password']);

If successful, it returns the user row just as the registration script did, along with the security token and contents of the ‘items’ table.

Password reset

In order to reduce support requests to deal with forgotten passwords, its essential to provide users with a way to reset their passwords. Again this is a web-request which only sends the user’s email address.

In order to make this process secure, never send a user his password via email – in fact this is impossible in this system because we don’t store his password, just a salted hash of it.

What the server-side script does is to look up this email address in the table of users, and send an email to it containing a URL which the user can follow in order to enter a new password. This url is also accompanied by another security token (again the salted hash of his email address). This ensures that the user can only change his own password and not anybody else’s because the token is checked again at the final stage after the user is directed to enter a new password and that has been posted to the script which actually changes the password.

  • Client requests password reset
  • Server confirms email exists
  • Server generates token and emails user with password reset URL
  • User follows URL in browser, enters new password
  • Server POSTs new password with original token (and email address) to final php script
  • Final script confirms token (from email address) and actually changes password

Buying an item with PayPal

Ok, now we’re starting to connect everything together, the key part is how the client actually begins the process of buying something with PayPal.

The item names and prices are sent from the server when the user registers or logs in, so they are easy to display in a list like above. When the user actually selects an item and clicks ‘Continue’ the game must open PayPal in a browser tab or page.

/// <summary>
/// Open the PayPal url in the browser.
///
/// @param <String> itemName The name of the item to purchase - from the 'items' table in the database.
/// 
/// </summary>
public function OpenPayPal( itemName:String ):void
{
	Assert( m_User!=null, "OpenPayPal(): User not logged in!" );
	Assert( m_token!=null, "OpenPayPal(): No security token!" );
 
	var item:Object = FindItemInPricePoints( itemName );
	Assert( item!=null, "OpenPayPal(): Unable to find item named "" + itemName + ""!" );
 
	Utils.OpenLink( kPayPalEndPoint + "?cmd=_xclick&no_shipping=1&no_note=1" +
					"&currency_code="+m_currencyCode+
					"&custom="+m_userObject.uid+
					"&business="+UrlEncode(m_palPalEmail)+
					"&notify_url="+UrlEncode(kNotifyEndPoint + "?token=" + m_token + "&item_hash=" + item.hash)+
					"&amount="+(item.price/100)+
					"&item_name="+UrlEncode(item.name)+
					"&item_number="+UrlEncode(item.number)+
					"&return="+UrlEncode(kPayPalReturnEndPoint)+
					"&cancel_return=" );
}

The above is the function which builds the PayPal GET request. There are a few important points here:

  • kPayPalEndPoint can either be https://www.sandbox.paypal.com/cgi-bin/webscr or https://www.paypal.com/cgi-bin/webscr depending on whether you want the sandbox or live PayPal service
  • ‘currency_code’ must be your PayPal currency code
  • ‘custom’ is any custom parameter you want passed to your IPN script, in this case I’m putting the users uid in there
  • ‘business’ must be your PayPal business email address
  • ‘notify_url’ is the web-address of your IPN script, where PayPal will call with the result of the transaction. Some extra parameters have been added to this, including the item_hash value which the IPN script will use to verify the purchase
  • ‘amount’ is the price of the item (from the items table)
  • ‘item_name’ is the name of the item (from the items table)
  • ‘item_number’ is the number of the item (from the items table)
  • ‘return’ is the address you’d like to send the user once their purchase is complete. This should be a page in accordance with PayPal rules for transactions.

The user in then presented with a PayPal browser tab:

Click to enlarge

Once they confirm payment, PayPal will fire off a call to the script we specified above.

If all goes well in the verification of the transaction in the IPN script on the server, the user will have the item_number field bitwise or’ed into his item_flags field on the users table:

class Database
{
	...
 
	/**
	 * Update the given users item_flags with this new flag
	 *
	 * @param <uint> $userUid
	 * @param <uint> $newFlag
	 */
	public function UpdateItemFlags($userUid, $newFlag)
	{
		$this->Query("UPDATE users SET item_flags=item_flags|? WHERE uid=?", array("ii", $newFlag, $userUid));
	}
}

In the meantime the client will have been polling the server at regular intervals via a web-request, the server will be returning the user row from the database each time. When the client detects a difference between its version of the user’s item_flags and the new version it knows that the user has been awarded his item on the server and can actually unlock that item in game. Additionally, the client will be checking for the ipn_error bit which is getting sent back as it polls the server; if this bit is set, the client should display a message informing the user that there was a problem with their transaction and to check their email for details.

The email was sent via the IPN script above. Once this message has been displayed to the user, its important to clear the ipn_error bit on the users row so that the user doesn’t get shown the same message multiple times. Again this is just another web-request to a different script on the server, signed with the same security token that accompanies all web-requests.

Flash security sandbox settings

In order for flash to be able to access your server while running your project locally on your machine, you will need set your security sandbox settings to allow your swf to access external domains. You do this by going to this web address in your browser Global security settings for content creators and selecting the .swf you are working on.

Click for enlargement

Crossdomain.xml

You will also need a crossdomain.xml file in the root of the domain where your php scripts are located. This allows your flash client to connect with your server wherever the client is located on the internet. It should look something like this:

<?xml version="1.0"?>
<!DOCTYPE cross-domain-policy SYSTEM "http://www.macromedia.com/xml/dtds/cross-domain-policy.dtd">
<cross-domain-policy>
	<allow-access-from domain="*" />
	<site-control permitted-cross-domain-policies="master-only"/>
</cross-domain-policy>

Server security

Its worth mentioning a few points about server-side security set-up at this point.

Disallow directory browsing
You don’t want potential hackers looking at the names of all the files you’ve written.

Move all possible files outside of the web-root
Collect all library functions and globals, especially those containing sensitive information such as your mysql login details and security hash secret and move them outside of the web-server root directory. Doing this safeguards you in case you server gets hacked and suddenly starts displaying the code inside the .php files instead of executing it. You will need to add this new include path via .htaccess files like this:

php_value include_path “.:/usr/local/lib/php:/somewhere outside your web root”

Obviously you can’t do this with files which need to be visible to the client.

Stop displaying php errors to the web-browser
Have them logged in error_log instead. You don’t want an errant message to display your php code, or mysql queries.

Set up a separate sub-domain
Its advisable to set up a separate sub-domain just for your php scripts which are visible to the web-service from the client. This way, the crossdomain.xml is restricted to only allowing access to those scripts from Flash, rather than your entire website.

Back-up your data
Set up a cron job to perform a nightly back-up of your database and files.

The demo

That pretty much covers the implementation of this system, all that remains is to show the live demo:

What if I don’t have a server?

If you don’t have access to a secure server, or the thought of getting all the details right fills you with dread, don’t fear! There are services out there which will provide you with the same feature set. Indeed, here at Wildbunny I have just released such a service!

PayDirt will allow you to accept PayPal in your own Flash games, its free to sign up, come take a look!

I’d love to get some feedback :)

Buy the source code?

I’ve been umming and arring about allowing people to buy the source code which accompanies the demo above. The main reason I not currently going to offer it is because I don’t think I will have enough time to be able to field support requests regarding the code while also looking after the running and support of PayDirt.

If you really want to buy the code, leave a comment on this article and if I get enough responses I will make it available. Please be aware that it’s likely to be around $99 USD because of the amount of time and effort which went into creating it.

Until next time, have fun!

Cheers, Paul.

原文地址:https://www.cnblogs.com/android-blogs/p/6409123.html