Skip to content

Instantly share code, notes, and snippets.

@solariz
Last active November 9, 2019 00:30
Show Gist options
  • Save solariz/a7b7b09e46303223523bba2b66b9b341 to your computer and use it in GitHub Desktop.
Save solariz/a7b7b09e46303223523bba2b66b9b341 to your computer and use it in GitHub Desktop.
Amazon Echo / Alexa Intent example in PHP with Security validation
<?php
/* This is a simple PHP example to host your own Amazon Alexa Skill written in PHP.
In my Case it connects to my smarthome Raspberry pi Cat Feeder with two intents;
1: Dispense Food to the cats.
2: When did the Feeder last time feed the cats? Return a spoken time / date
This Script contains neccessary calls and security to give you a easy to use DIY example.
v2016.12.29
Details in my Blogpost: https://solariz.de/de/amazon-echo-alexa-meets-catfeeder.htm
*/
header('Cache-Control: no-cache, must-revalidate');
header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
// SETUP / CONFIG
$SETUP = array(
'SkillName' => "CatFeeder",
'SkillVersion' => '1.0',
'ApplicationID' => 'amzn1.ask.skill.45c11234-123a-1234-ffaa-1234567890a', // From your ALEXA developer console like: 'amzn1.ask.skill.45c11234-123a-1234-ffaa-1234567890a'
'CheckSignatureChain' => true, // make sure the request is a true amazonaws api call
'ReqValidTime' => 60, // Time in Seconds a request is valid
'AWSaccount' => 'amzn1.ask.account.O3SYEMU2PH2QSTY8OCXLFOZ98T3IJYYJWSAZT48Q', //If this is != empty the specified session->user->userId is required. This is usefull for account bound private only skills
'validIP' => array(
"72.21.217.",
"54.240.197."
) , // Limit allowed requests to specified IPv4, set to FALSE to disable the check.
'LC_TIME' => "de_DE"
// We use german Echo so we want our date output to be german
);
setlocale(LC_TIME, $SETUP['LC_TIME']);
// Getting Input
$rawJSON = file_get_contents('php://input');
$EchoReqObj = json_decode($rawJSON);
if (is_object($EchoReqObj) === false) ThrowRequestError();
$RequestType = $EchoReqObj->request->type;
// Check if Amazon is the Origin
if (is_array($SETUP['validIP']))
{
$isAllowedHost = false;
foreach($SETUP['validIP'] as $ip)
{
if (stristr($_SERVER['REMOTE_ADDR'], $ip))
{
$isAllowedHost = true;
break;
}
}
if ($isAllowedHost == false) ThrowRequestError(403, "Forbidden, your Host is not allowed to make this request!");
unset($isAllowedHost);
}
// Check if correct requestId
if (strtolower($EchoReqObj->session->application->applicationId) != strtolower($SETUP['ApplicationID']) || empty($EchoReqObj->session->application->applicationId))
{
ThrowRequestError(401, "Forbidden, unkown Application ID!");
}
// Check SSL Signature Chain
if ($SETUP['CheckSignatureChain'] == true)
{
if (preg_match("/https:\/\/s3.amazonaws.com(\:443)?\/echo.api\/*/i", $_SERVER['HTTP_SIGNATURECERTCHAINURL']) == false)
{
ThrowRequestError(403, "Forbidden, unkown SSL Chain Origin!");
}
// PEM Certificate signing Check
// First we try to cache the pem file locally
$local_pem_hash_file = sys_get_temp_dir() . '/' . hash("sha256", $_SERVER['HTTP_SIGNATURECERTCHAINURL']) . ".pem";
if (!file_exists($local_pem_hash_file))
{
file_put_contents($local_pem_hash_file, file_get_contents($_SERVER['HTTP_SIGNATURECERTCHAINURL']));
}
$local_pem = file_get_contents($local_pem_hash_file);
if (openssl_verify($rawJSON, base64_decode($_SERVER['HTTP_SIGNATURE']) , $local_pem) !== 1)
{
ThrowRequestError(403, "Forbidden, failed to verify SSL Signature!");
}
// Parse the Certificate for additional Checks
$cert = openssl_x509_parse($local_pem);
if (empty($cert)) ThrowRequestError(424, "Certificate parsing failed!");
// SANs Check
if (stristr($cert['extensions']['subjectAltName'], 'echo-api.amazon.com') != true) ThrowRequestError(403, "Forbidden! Certificate SANs Check failed!");
// Check Certificate Valid Time
if ($cert['validTo_time_t'] < time())
{
ThrowRequestError(403, "Forbidden! Certificate no longer Valid!");
// Deleting locally cached file to fetch a new at next req
if (file_exists($local_pem_hash_file)) unlink($local_pem_hash_file);
}
// Cleanup
unset($local_pem_hash_file, $cert, $local_pem);
}
// Check Valid Time
if (time() - strtotime($EchoReqObj->request->timestamp) > $SETUP['ReqValidTime']) ThrowRequestError(408, "Request Timeout! Request timestamp is to old.");
// Check AWS Account bound, if this is set only a specific aws account can run the skill
if (!empty($SETUP['AWSaccount']))
{
if (empty($EchoReqObj->session->user->userId) || $EchoReqObj->session->user->userId != $SETUP['AWSaccount'])
{
ThrowRequestError(403, "Forbidden! Access is limited to one configured AWS Account.");
}
}
$JsonOut = GetJsonMessageResponse($RequestType, $EchoReqObj);
header('Content-Type: application/json');
header("Content-length: " . strlen($JsonOut));
echo $JsonOut;
exit();
// -----------------------------------------------------------------------------------------//
// functions
// -----------------------------------------------------------------------------------------//
// This function returns a json blob for output
function GetJsonMessageResponse($RequestMessageType, $EchoReqObj)
{
GLOBAL $SETUP;
$RequestId = $EchoReqObj->request->requestId;
$ReturnValue = "";
if ($RequestMessageType == "LaunchRequest")
{
$return_defaults = array(
'version' => $SETUP['SkillVersion'],
'sessionAttributes' => array(
'countActionList' => array(
'read' => true,
'category' => true
)
) ,
'response' => array(
'outputSpeech' => array(
'type' => "PlainText",
'text' => "Willkommen beim CatFeeder Beispiel"
) ,
'card' => array(
'type' => "Simple",
'title' => "CatFeeder",
'content' => "Test Content"
) ,
'reprompt' => array(
'outputSpeech' => array(
'type' => "PlainText",
'text' => "Kann ich dir noch weiter behilflich sein?"
)
)
) ,
'shouldEndSession' => true
);
$ReturnValue = json_encode($return_defaults);
}
elseif ($RequestMessageType == "SessionEndedRequest")
{
$ReturnValue = json_encode(array(
'type' => "SessionEndedRequest",
'requestId' => $RequestId,
'timestamp' => date("c") ,
'reason' => "USER_INITIATED"
));
}
elseif ($RequestMessageType == "IntentRequest")
{
if ($EchoReqObj->request->intent->name == "CatFeederFeed") // Alexa Intent name
{
// do what ever your intent should do here. In my Case I call home to my raspberry pi, see function comment for more info.
getRequestPayload(array(
'action' => "feed",
'size' => 1
));
$SpeakPhrase = "OK";
}
elseif ($EchoReqObj->request->intent->name == "CatFeederLast") // 2nd Alexa Intent name
{
// do what ever your intent should do here. In my Case I call home to my raspberry pi, see function comment for more info.
$last_feed = getRequestPayload(array(
'action' => "LastFeed"
));
if (!$last_feed || $last_feed <= 1000000000)
{ // Should return a linux timestamp, check if plausible, else Error.
$SpeakPhrase = "Ich kann diese Information momentan leider nicht ermitteln.";
}
elseif (time() - $last_feed < 900) $SpeakPhrase = "Die Katzen wurden gerade eben gefüttert.";
elseif (time() - $last_feed < 3600) $SpeakPhrase = "Die Katzen wurden vor weniger als einer Stunde gefüttert.";
elseif (time() - $last_feed < 7200) $SpeakPhrase = "Die Katzen wurden vor eins bis zwei Stunden gefüttert.";
else
{ // More human readable Date formating:
if (strftime("%e") == strftime("%e", $last_feed))
{
$day = "heute";
}
elseif (intval(strftime("%e", $last_feed)) == intval(strftime("%e") - 1))
{
$day = "gestern";
}
else
{
$day = strftime("%e. %B", $last_feed);
}
$SpeakPhrase = "Die letzte Fütterung war " . $day . " um " . strftime("%H:%M", $last_feed);
}
}
$ReturnValue = json_encode(array(
'version' => $SETUP['SkillVersion'],
'sessionAttributes' => array(
'countActionList' => array(
'read' => true,
'category' => true
)
) ,
'response' => array(
'outputSpeech' => array(
'type' => "PlainText",
'text' => $SpeakPhrase
) ,
'card' => array(
'type' => "Simple",
'title' => "CatFeeder",
'content' => $SpeakPhrase
)
) ,
'shouldEndSession' => true
));
}
else
{
ThrowRequestError();
}
return $ReturnValue;
} // end function GetJsonMessageResponse
function ThrowRequestError($code = 400, $msg = 'Bad Request')
{
GLOBAL $SETUP;
http_response_code($code);
echo "Error " . $code . "<br />\n" . $msg;
error_log("alexa/" . $SETUP['SkillName'] . ":\t" . $msg, 0);
exit();
}
function getRequestPayload($payload)
{
/* this is just a custom function to get my connection to my home device.
In this example it's a raspberry pi based CatFeeder listening to POST requests.
It is using a let's encrypt SSL cert and a basic HTTP Auth.
Use it as a example to DIY here: */
$username = "basicauthuser";
$password = "basicauthpasswd";
$host = "https://myawesome.raspberry.pi.at.home/catfeed_post.php";
$process = curl_init($host);
curl_setopt($process, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($process, CURLOPT_SSL_VERIFYPEER, 1);
curl_setopt($process, CURLOPT_USE_SSL, CURLUSESSL_ALL);
curl_setopt($process, CURLOPT_SSL_VERIFYSTATUS, 1);
curl_setopt($process, CURLOPT_HEADER, FALSE);
curl_setopt($process, CURLOPT_USERPWD, $username . ":" . $password);
curl_setopt($process, CURLOPT_TIMEOUT, 8);
curl_setopt($process, CURLOPT_POST, 1);
curl_setopt($process, CURLOPT_VERBOSE, FALSE);
curl_setopt($process, CURLOPT_POSTFIELDS, http_build_query($payload));
curl_setopt($process, CURLOPT_RETURNTRANSFER, TRUE);
$return = curl_exec($process);
curl_close($process);
return $return;
}
@jeremywj
Copy link

jeremywj commented Jan 17, 2018

Amazon will reject any skill made using this script. Luckily, the fix is simple. The error return code for all the checks (IP, Cert, etc) must return with status code 400.

Below is the easy/lazy way to fix this.

function ThrowRequestError($code = 400, $msg = 'Bad Request')
{
GLOBAL $SETUP;
$code = 400; //this is the fix
http_response_code($code);
echo "Error " . $code . "
\n" . $msg;
error_log("alexa/" . $SETUP['SkillName'] . ":\t" . $msg, 0);
exit();
}

@PaulWebster
Copy link

PaulWebster commented May 15, 2018

Thanks for this - am experimenting with it now - building an audio player.

Some requests come in without a session - and in that case the ApplicationID is at $EchoReqObj->context->System->application->applicationId
So that needs to be added into the Application Id check.

FYI - from Amazon docs
Requests from interfaces such as AudioPlayer and PlaybackController are not sent in the context of a session, so they do not include the session object. The context.System.user and context.System.application objects provide the same user and application information as the same objects within session – see Context Object.

https://developer.amazon.com/docs/custom-skills/request-and-response-json-reference.html#session-object

Update: 22/05/2018
My submitted Skill was accepted and is now published. The code for validating the request uses this example - with the 400 code change from jeremywj. As an experiment I left in the 403 returns and it failed the Amazon automatic testing. Changing it to force 400 as the response passed the automatic testing and subsequently the Skill as a whole passed the human checks as well.

@Joghurt
Copy link

Joghurt commented Oct 19, 2018

Looks like you need to put the shouldEndSession into the response array, not after it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment