Comment sécuriser le endpoint de mon application Skills Alexa

Bienvenue dans ce deuxieme tutoriel sur le développement de Skills Alexa, dans le premier tutoriel on a vu comment faire un helloworld, aujourd'hui dans ce tutoriel on va voir comment sécuriser notre script. Aujourd'hui je vais passer sur Symfony pour continuer les tutos, mais vous pouvez bien sur l'adapter à n'importe quel framework PHP.

Etape 1 - Sécuriser le endpoint qui réceptionne la requête Alexa Skills, la théorie :

Actuellement notre script fait un helloworld mais soyons honnête avec nous même...Niveau sécu c'est pas terrible.
En effet, n'importe qui peut utiliser votre webservice.

On va donc rajouter plusieurs contrôles :
- Vérifier que la requête vient bien d'une IP d'Amazon.
- Vérifier que c'est bien notre application ID qui est appelé
- Vérifier la signature SSL
- vérifier que la signature de la requête correspond bien à la signature Amazon
- Vérifier le subjectActName (SAN)
- Vérifier l'expiration du certificat SSL
- Vérifier que la requête a bien été émise il y a moins de 60 secondes

Etape 2 - Faire les vérifications sur ma requête, la pratique

Voici déjà a quoi ressemble mon action pour le moment :

 /**
   * @Route("/helloworld.json",  defaults={"_format"="json"}, name="alexa_search_helloworld")
   */
  public function helloworldAction(Request $request)
  {
      $response = array(
          'outputSpeech' => array(
              'type' => 'PlainText',
              'text' => "Hello World"
          )
      );
      $data = array(
              'version' => "0.1",
              'sessionAttributes' => array(
                  'countActionList' => array(
                      'read' => true,
                      'category' => true
                  )
              ) ,
              'response' => $response,
              'shouldEndSession' => false
          );

      return new JsonResponse($data);
  }
Afin de sécuriser ma requête je vais rajouter au début de mon action un appel à la méthode validate() de ma classe AlexaSkills (qu'on va créer par la suite). Pour l'utiliser vous devez déclarer l'objet :

use AlexaBundle\Service\AlexaSkills;

Puis rajouter dans le contrôleur, l'instanciation de la classe et l'appel à la méthode validate :

    $json = file_get_contents('php://input');
    $requete = json_decode($json);
    $alexaSkillsService = new AlexaSkills();
    $alexaSkillsService->validate($requete);

On va ensuite créer notre classe comme ceci :

class AlexaSkills
{
    private $_appId;

    public function __construct() {
        // Remplacer ici par votre app ID
        $this->_appId = 'amzn1.ask.skill.XXXXX';
     }

    public function validate($requete)
    {
    }
}

Vous remarquerez que je mets en attribut privé l'app ID de mon skill Amazon, que vous pouvez récupérer dans la "Alexa Skills Kit Developer Console" sur la page "Alexa Skills" qui liste tous vos skills, vous avez en dessous du nom de votre skill un lien "View Skill ID" qui vous permet de récupérer l'ID de votre skill, à insérer dans notre exemple dans notre attribut _appId. Ensuite on va faire chaque vérification une par une dans la méthode validate.

Vérifier que la requête vient bien d'une IP d'Amazon

Les IP d'Amazon pour faire les reqûetes Alexa sont : 72.21.217 et 54.240.197
On va commencer par créer une fonction, qui permet de vérifier ces 2 IP (en vérifiant la variable serveur REMOTE_ADDR):

    /**
     * Check if the request is from Amazon Ip
     */
    public function isRequestFromAmazon()
    {
        $amazon_ip = array("72.21.217.","54.240.197.");
        foreach($amazon_ip as $ip) {
        	if (stristr($_SERVER['REMOTE_ADDR'], $ip)) {
        		return true;
        	}
        }
        return false;

    }

Puis on va l'utiliser dans notre méthode validate comme ceci :

     //check if the request come from amazon
        $isAmazonIp = $this->isRequestFromAmazon();
        if ( !$isAmazonIp) {
            throw new BadRequestHttpException("Forbidden, your Host is not allowed to make this request!", null, 400);
        }

Vérifier l'application ID

On va ensuite vérifier que c'est le bon Skill Id qui fait la requête via l'applicationID contenu dans le contenu du JSON (dans session):

  //check my Amazon IP
        if (strtolower($requete->session->application->applicationId) != strtolower($this->_appId)) {
            throw new BadRequestHttpException("Forbidden, your App ID is not allowed to make this request!", null, 400);
        }

Vérifier la signature SSL

Puis on va vérifier la signature SSL, en comparant la variable serveur HTTP_SIGNATURECERTCHAINURL avec l'adresse s3.amazonaws.com/echo.api (voir la regex pour l'adresse exacte):

 // Check SSL signature
        if (preg_match("/https:\/\/s3.amazonaws.com(\:443)?\/echo.api\/*/i", $_SERVER['HTTP_SIGNATURECERTCHAINURL']) == false) {
        	throw new BadRequestHttpException( "Forbidden, unkown SSL Chain Origin!", null, 400);
        }

Vérifier que la signature de la requête correspond bien à la signature Amazon


 $pem_file = sys_get_temp_dir() . '/' . hash("sha256", $_SERVER['HTTP_SIGNATURECERTCHAINURL']) . ".pem";
        if (!file_exists($pem_file)) {
        	file_put_contents($pem_file, file_get_contents($_SERVER['HTTP_SIGNATURECERTCHAINURL']));
        }
        $pem = file_get_contents($pem_file);

        $json = file_get_contents('php://input');
        if (openssl_verify($json, base64_decode($_SERVER['HTTP_SIGNATURE']) , $pem) !== 1){
        	throw new BadRequestHttpException( "Forbidden, failed to verify SSL Signature!", null, 400);
        }

Puis on vérifiequ'on arrive à parser le pem avec Open SSL:

       // check we can parse the pem content
        $cert = openssl_x509_parse($pem);
        if (empty($cert)) {
             throw new BadRequestHttpException("Certificate parsing failed!", null, 400);
        }

Vérifier le subjectActName (SAN)

On vérifie que le subject alt name c'est bien echo-api.amazon.com :

        // Check subjectAltName
        if (stristr($cert['extensions']['subjectAltName'], 'echo-api.amazon.com') != true) {
            throw new BadRequestHttpException( "Forbidden! Certificate subjectAltName Check failed!", null, 400);
        }

Vérifier l'expiration du certificat SSL

Et on vérifie la date du certificat :

        // check expiration date of the certificate
        if ($cert['validTo_time_t'] < time()){
        	throw new BadRequestHttpException( "Forbidden! Certificate no longer Valid!", null, 400);
        	if (file_exists($pem_file)){
                unlink($pem_file);
            }
        }

Vérifier que la requête a bien été émise il y a moins de 60 secondes

Le titre parle de lui même, on regarde dans request, le timestamp comme ceci pour le faire :

       if (time() - strtotime($requete->request->timestamp) > 60) {
            throw new BadRequestHttpException( "Request Timeout! Request timestamp is to old.",null, 400);
        }

Ce qui vous donne au final :

  <?php
namespace AlexaBundle\Service;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

class AlexaSkills
{
    private $_appId;

    public function __construct() {
        // Remplacer ici par votre app ID
        $this->_appId = 'amzn1.ask.skill.XXXXX';
     }

    /**
     * Check if the request is from Amazon Ip
     */
    public function isRequestFromAmazon()
    {
        $amazon_ip = array("72.21.217.","54.240.197.");
        foreach($amazon_ip as $ip) {
        	if (stristr($_SERVER['REMOTE_ADDR'], $ip)) {
        		return true;
        	}
        }
        return false;

    }

    public function validate($requete)
    {

        //check if the request come from amazon
        $isAmazonIp = $this->isRequestFromAmazon();
        if ( !$isAmazonIp) {
            throw new BadRequestHttpException("Forbidden, your Host is not allowed to make this request!", null, 400);
        }

        //check my Amazon IP
        if (strtolower($requete->session->application->applicationId) != strtolower($this->_appId)) {
            throw new BadRequestHttpException("Forbidden, your App ID is not allowed to make this request!", null, 400);
        }

        // Check SSL signature
        if (preg_match("/https:\/\/s3.amazonaws.com(\:443)?\/echo.api\/*/i", $_SERVER['HTTP_SIGNATURECERTCHAINURL']) == false) {
        	throw new BadRequestHttpException( "Forbidden, unkown SSL Chain Origin!", null, 400);
        }

        $pem_file = sys_get_temp_dir() . '/' . hash("sha256", $_SERVER['HTTP_SIGNATURECERTCHAINURL']) . ".pem";
        if (!file_exists($pem_file)) {
        	file_put_contents($pem_file, file_get_contents($_SERVER['HTTP_SIGNATURECERTCHAINURL']));
        }
        $pem = file_get_contents($pem_file);

        $json = file_get_contents('php://input');
        if (openssl_verify($json, base64_decode($_SERVER['HTTP_SIGNATURE']) , $pem) !== 1){
        	throw new BadRequestHttpException( "Forbidden, failed to verify SSL Signature!", null, 400);
        }
        // check we can parse the pem content
        $cert = openssl_x509_parse($pem);
        if (empty($cert)) {
             throw new BadRequestHttpException("Certificate parsing failed!", null, 400);
        }
        // Check subjectAltName
        if (stristr($cert['extensions']['subjectAltName'], 'echo-api.amazon.com') != true) {
            throw new BadRequestHttpException( "Forbidden! Certificate subjectAltName Check failed!", null, 400);
        }

        // check expiration date of the certificate
        if ($cert['validTo_time_t'] < time()){
        	throw new BadRequestHttpException( "Forbidden! Certificate no longer Valid!", null, 400);
        	if (file_exists($pem_file)){
                unlink($pem_file);
            }
        }
        if (time() - strtotime($requete->request->timestamp) > 60) {
            throw new BadRequestHttpException( "Request Timeout! Request timestamp is to old.",null, 400);
        }
    }
}

Et voilà votre endpoint est maintenant sécurisé correctement, il pourra passer les tests de sécurité de la certification Amazon.
Attention, le fait que votre appli passe ces tests ne garanti pas que votre appli sera publié...il y a encore beaucoup de regles à respecter.
Dans le prochain tutoriel on verra comment prendre en compte certaines variables appelé "Slots" dans nos requêtes pour répondre à l'utilisateur de facon dynamique.
Questions sur cette leçon
Pas de questions pour cette leçon. Soyez le premier !

Vous devez etre connecté pour demander de l'aide sur une leçon.