-
-
Save solariz/a7b7b09e46303223523bba2b66b9b341 to your computer and use it in GitHub Desktop.
<?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; | |
} |
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.
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.
Looks like you need to put the shouldEndSession into the response array, not after it.
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();
}