Monday, June 22, 2009

WSF/PHP password callback improvements

So you are working with WSO2's WSF/PHP web services for PHP framework and you need to receive web service calls that contain a username token so you can identify the caller, and apply some password authentication. You read the documentation and find that the WSSecurityToken object will take a callback specification.

Being the good engineer that you are, you write a class, and attempt to have one of its methods called by using "array($theObject, 'mycallback')" as the callback. It is, after all, a standard way of specifying callbacks in PHP. Then you find out that your callback is not being called, but messages in the log files are not helpful. I'll save you the rest of the story and trouble, because it involves digging through the C source code. It turns out that, basically, only a string is accepted for the callback, thus only allowing the identification of a global callback function. Ugggh!

So, I couldn't let this stand. I asked the WSO2 folks to implement proper support, which should not be that hard, but so far there is no action on that topic. So I decided to build a little framework to give me what I wanted. First, let's have a look at how you would define the security options for a simple example:
$securityToken = array(
'callback' => array(
array(
'class' => 'SF_Callback_PasswordCallbackImpl',
'options' => array(
'opt1',
'opt2'
),
'passwordType' => 'Digest'
)
)
);
Notice the "normal" "passwordType" option, but the non-standard "callback" option. The contents of this option is what needs to be converted into a "passwordCallback" that actually calls a method in an object.

To make this all flexible, we will have some code that inspects an object for the interfaces it implements (using reflection), and then configures the appropriate callbacks. First we define a class that will text a set of options, typically specified in an array, and convert it to its "live" form.
interface SF_Callback_ContextProvider
{
/**
* Create a "context" object for a callback.
*
* There is no implied requirement on the object/data created,
* and in fact, it need not be an object, it could be a simple
* type, an array, or an object.
*
* @param mixed $options
* @return mixed Created context object
*/
public function createContext($options);
}
This functionality will be used for all kinds of callbacks, so we then define additional interfaces that specify the particular callbacks (don't worry about the purpose of the above yet, it'll be explained later). Here is one for the password callback function:
interface SF_Callback_Password extends SF_Callback_ContextProvider
{
/**
* Get the password for a specified user
*
* @param string $username Incoming username
* @param mixed $context Additional context data
* @return string The password for the user (for comparison), or null if not found
*/
public function getPassword($username, $context);
}
So with these defined, lets have a look at how you could put together a class that will "transfer" the options so that you can feed them to the constructor of the WSSecurityToken class. The class in question is pretty straightforward, but a little large to present in a single piece, so we'll do it in fragments.

class SF_Callback_OptionConverter
{
private static $_CALLBACK_CONFIG = array(
'SF_Callback_Password' => array(
'getPassword',
'WSF_CallbackFunction1',
SF_Constants::CONFIG_SECURITY_PASSWORD_CALLBACK,
SF_Constants::CONFIG_SECURITY_PASSWORD_CALLBACK_DATA
),
'SF_Callback_ReplayDetection' => array(
'isReplay',
'WSF_CallbackFunction2',
SF_Constants::CONFIG_SECURITY_REPLAY_DETECTION_CALLBACK,
SF_Constants::CONFIG_SECURITY_REPLAY_DETECTION_CALLBACK_DATA
)
);


private function installInterfaceCallback(array &$options, $callbackObject,
$method, $globalCallback,
$callbackOption,
$callbackDataOption = null,
$dataOptions = null) {
}

private function getReflectionClass($callback) {
}

private function installCallbackForInterface(array &$options, $callbackConfig,
$callbackObject, $interfaceName) {
}

public function installCallbacks(array &$options) {
$callbacks = $options['callback'];
foreach ($callbacks as $callbackConfig) {
if (is_array($callbackConfig)) {
$reflectionClass = $this->getReflectionClass(
$callbackConfig['class']);
/**
* Inspect implemented interfaces, and for each one recognized
* install a callback to the specified method.
*/
$callbackObject = null;
$interfaces = $reflectionClass->getInterfaceNames();
foreach ($interfaces as $interfaceName) {
// Create the object once we have a workable interface
if (empty(
$callbackObject)) {
$callbackObject = $reflectionClass->newInstance();
}
$this->installCallbackForInterface(
$options,
$callbackConfig,
$callbackObject,
$interfaceName);
}
}
else {
// Regular callback specification
}
}
}
Let's start with "installCallbacks." You would feed it the array for "securityToken" we have above and it would transform that array. In fact, as you can see, you could have more than one "callback" element because this method iterates over all of them. For each such configuration found, it extracts the name of the implementing class, and uses reflection to find out more about it. In particular, it extracts all interfaces it implements and iterates over those.

Then it gets interesting. The creation of the actual callback object is delayed until we have found at least one interface (it is also done only once, because one object implements all interfaces). So, let's have a look at how a callback for an interface is installed.
private function installCallbackForInterface(array &$options, $callbackConfig,
$callbackObject, $interfaceName) {
if (isset(self::$_CALLBACK_CONFIG[$interfaceName])) {
list($method, $globalCallback, $cbOptionName, $cbDataOptionName) = self::$_CALLBACK_CONFIG[$interfaceName];
$cbConfigOptions = isset(
$callbackConfig['options']) ? $callbackConfig['options'] : array();
$this->installInterfaceCallback($options,
$callbackObject,
$method,
$globalCallback,
$cbOptionName,
$cbDataOptionName,
$cbConfigOptions);
unset($options[SF_Service_W'callback']);
}
}
We inspect our global configuration array to find a configuration for the named interface. If there is one, it will contain the name of the method in the class that should be called, the name of a global function that will be used as the callback installed for WSF/PHP, and the name of the WSF/PHP option under which this callback should be installed. Finally, there is also the name of the WSF/PHP option that can be used to pass additional information to the callback. With that, we can install a callback for one interface:
private function installInterfaceCallback(array &$options, $callbackObject,
$method, $globalCallback,
$callbackOption,
$callbackDataOption = null,
$dataOptions = null) {
$options[$callbackOption] = $globalCallback;
$callbackData = array(
array(
$callbackObject,
$method
)
);
if (!empty($callbackDataOption)) {
$callbackData[] = $callbackObject->createContext(
$dataOptions);
}
$options[$callbackDataOption] = $callbackData;
}
The first thing done here is to add the actual callback option to the array, pointing to the global function. WSF/PHP allows us to pass additional data to this callback, so we go about constructing it here. Only a single value may be passed, and we need more than one, so we'll use an array with at least one element. That is the function of the "createContext" method we ignored earlier. Not only will it put whatever it needs from the "dataOptions" array in a single array, it can also inspect the options and modify them (replace them with objects etc.).

The first element will be the "normal" callback specification that will actually call the method in the object we want. If the configuration did not specify any more options, that would be it, but if it did (as in the example with opt1, and opt2), we add them to the array here and install the array in the configuration as well.

What remains, then, is to show you the global function required to complete this picture.

function WSF_CallbackFunction1() {
return WSF_GenericCallbackFunction(1, func_get_args());
}

function WSF_CallbackFunction2() {
return WSF_GenericCallbackFunction(2, func_get_args());
}
As you can see, the only purpose of each is to call a generic function that needs to know how many of the arguments passed to it are meant for the object's method. The actual number of arguments passed will be one more, as the first one will be the PHP callback specification to the method:
function WSF_GenericCallbackFunction($numArgs, array $args) {
/**
* First several are "real" supplied arguments, and next
* one is the specially coded "data" argument. We agree that
* this will be an array of two elements:
* 1) A callback specification to a "real" callback, meaning it
* can be class method using array specification
* 2) The "data" to be passed on the the actual callback.
*/
@list($realCallback, $data) = $args[$numArgs];
$callbackArgs = array_slice($args, 0, $numArgs);
if (isset($data))
$callbackArgs[] = $data;
return call_user_func_array($realCallback, $callbackArgs);
}
So, the overall chain of events will be that WSF/PHP calls the global callback that we installed (e.g. "WSF_CallbackFunction1"), which will receive the arguments the parameters document with WSF/PHP. The last of these will be the "context" parameter which will have to be "unwrapped" here. Then "WSF_GenericCallbackFunction" will be called. It peels off the last element of the argument array as the "context" and keeps the rest to pass on to the callback (shortening the argument array to remove the "context").

The last element itself is split up into the "real callback" and the additional data that it should receive (opt1, opt2, etc.). We then tack these extra data elements on to the end of the parameter list for the callback, and call it. There you have it.

Oh no, but not quiet!

One little problem. When you try this, you will find your callback does get called, but the "context" parameter never arrives, at least not in version 2.0.0. of WSF/PHP. Inspection of source code reveals this as an oversight/bug. I have passed the patch on the the WSO2 folks, but they are non too speedy, so here is the patch. If you can compile WSF/PHP from source, you can use this patch and be on your way. You'll have to patch the "wsf.c" file:

diff -b wso2-2.2.0/wso2-wsf-php-src-2.0.0/src/wsf.c wso2DS/wso2-wsf-php-src-2.0.0/src/wsf.c
2011a2012,2016
> if(zend_hash_find(ht, WSF_PASSWORD_CALLBACK_ARGS, sizeof(WSF_PASSWORD_CALLBACK_ARGS),
> (void **)&tmp) == SUCCESS)
> {
> add_property_zval(object, WSF_PASSWORD_CALLBACK_ARGS, *tmp);;
> }
Live after the patch makes it into WSF/PHP

All the above will not become obsolete once the patch is in WSF/PHP. While you will be able to directly pass a "real callback," there is still the limitation of only a single "context" argument. It would be easy to modify the code above to no longer use the global callbacks, but leave everything else in place.

3 comments:

pixelchutes said...
This comment has been removed by the author.
pixelchutes said...

Very nice mod! It is indeed annoying having to declare the callback function outside of my class... does your example of how you'd reference the class-based callback mean that the method can be a part of the main service class mapped via the "classes" argument of the WSService()? Can this method be private or must it be public?

dolf said...

Yes it can be any method in any object. Since you will be passing back both the object and the name of the method you wish called, the code above handles the rest. So yes, if you want to call a method in the same class that handles the actual web calls you can.

The method has to be public because it lives inside an object and is called from the outside. If you choose a method in the same class that handles the web service calls, be careful if you use automatically generated WSDLs. Since the method is public it could be added to the WSDL unless you leave it out of the operations specification).