phaziz.com

Startseite > 15.10.2017 Microsoft-Graph und Slim3

15.10.2017 Microsoft-Graph und Slim3

Kommunikation zwischen Slim3 und Microsoft-Graph

Slim Framework

Ein einfaches Login-System durch Integration von Microsoft-Graph für Web-Applikationen

Microsoft-Graph what???

Microsoft-Graph (https://developer.microsoft.com/en-us/graph) bietet die Möglichkeit der Authentifizierung von Microsoft-Konten (Office365, ...), sowie die Kommunikation mit den entsprechenden Graph-Endpunkten per HTTP-Requests um zum Beispiel: Benutzerdaten auszulesen, eMails über Outlook zu generieren, Dokumente zu verwalten, OneDrive.com abzufragen, ...eine Kommunikation mit Microsoft ohne deren eigene Web-Apps.

Projektgrundlagen

Was wird alles benötigt? Schauen wir uns folgende composer.json-Datei an:

{
    "require": {
        "slim/slim": "^3.0",
        "slim/csrf": "0.8.1",
        "slim/flash": "*",
        "monolog/monolog": "1.23.0",
        "twig/twig": "*",
        "ramsey/uuid": "3.7.1",
        "league/oauth2-client": "*",
        "vlucas/phpdotenv": "^2.4",
        "adbario/slim-secure-session-middleware": "^1.3"
    }
}

Wir benutzen Slim3 mit der Middleware CSRF, Flash und Secure Sessions, sowie Monolog für die Logfiles, Twig für die Templates, UUID für die Benutzerverwaltung intern, DotEnv für Environment-Variablen und zu guter Letzt den PHPLeague-Oauth Client für die Authentifizierung bei Microsoft und Interaktion durch Guzzle-Request's. Oben beschriebene composer-json-Datei anlegen, per SSH auf der Programmierumgebung ein kleines:

php composer.phar update

und alle Abhängigkeiten werden eingerichtet und die entsprechenden Autoloader erstellt.

Struktur

Wir richten folgende Struktur auf dem Webserver ein:

/cache
/env
/logs
/public
  /assets
  /.htaccess
  /index.php
/sessions
/settings
/vendor
/views
/composer.json
/composer.phar

Das Verzeichnis /public nehmen wir wieder einmal für das Routing der eingerichteten Hauptdomain. /vendor wird durch Composer eingerichtet. /cache wird das Verzeichnis für erzeugte Cache-Dateien. /env ist unser Verzeichnis für die erstellten PHP Environment-Variablen. Unter /logs speichern wir unsere Monolog-Logfiles. Das Verzeichnis /sessions Verzeichnis nehmen wir für die Ablage unserer Session-Dateien - sensible Informationen in Session-Dateien sollten niemals in den öffentlichen Standard-Verzeichnissen abgelegt werden - schon gar nicht auf einem Shared Webhoster. Unter /settings legen wir unsere Konfiguration für die Slim3-WebApp ab. Unsere View-Dateien für das eigentliche Frontend werden unter /views abgelegt.

Die index.php Datei in /public für unsere Slim3-App

Bevor es vergessen wird, referenzieren wir unseren Composer-Autoloader und unsere im Verzeichnis /settings angelegte Datei für die Konfiguration:

require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../settings/settings.php';

Konfiguration

Die Settings-Datei /settings/settings.php richten wir auch gleich mit ein:

$config = [
  'settings' => [
    'displayErrorDetails' => true,
    'determineRouteBeforeAppMiddleware' => true,
    'debug' => true,
    'addContentLengthHeader' => true,
    'routerCacheFile' => __DIR__ . '/../cache/routes.cache'
    'session' => [
      'name'           => 'Meine_Session',
      'lifetime'       => 480, // 8 Stunden
      'path'           => '/',
      'domain'         => 'HTTP://MEINE DOMAIN.TLD',
      'secure'         => true,
      'httponly'       => true,
      'cookie_autoset' => true,
      'save_path'      => __DIR__ . '/../sessions',
      'cache_limiter'  => 'nocache',
      'autorefresh'    => false,
      'encryption_key' => 'Mein_Super_Sicheres_Salz_in_der_Suppe',
      'namespace'      => 'MEIN_NAMESPACE'
    ],
    'twig' => [
      'cache' => __DIR__ . '/../cache/',
      'debug' => false,
      'strict_variables' => true,
      'autoescape' => 'html',
      'optimizations' => -1,
      'charset' => 'utf-8'
    ],
    'monolog' => [
      'name' => 'Mein_Logger_Name',
    ],
    'uuid' => [
      'namespace' => 'BlahBlahBlah...' . date('YmdHis')
    ]
  ]
];

Somit sind die Bestandteile Slim3, Secure-Sessions, Twig, Monolog und UUID fertig Konfiguriert. Weiteres zur Konfiguration findet man auf den entsprechenden Seiten unter GitHub - einfach den Composer-Bezeichner mit der Kombination GitHub in einer Suchmaschine suchen. Man dann landet relativ sicher auf den entsprechenden Seiten auf GitHub und kann sich in die einzelnen Bestandteile einarbeiten - z.B.:

league/oauth2-client GitHub

Weiter mit der index.php-Datei in /public für unsere Slim3-App

Wir richten für unsere App die entsprechenden Namespace Usings ein:

use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use \Psr\Http\Message\ServerRequestInterface as Request;
use \Psr\Http\Message\ResponseInterface as Response;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\Exception\UnsatisfiedDependencyException;

Selbstbezeichnend. Richten anschließend unsere Slim3-App mit der oben angelegten Konfiguration ein:

$app = new \Slim\App($config);

Erstellen uns den Dependency-Injection Container:

$container = $app -> getContainer();

Und richten die entsprechenden Container ein. Unser Monolog-Logger:

$container['logger'] = function()
{
  $logger = new \Monolog\Logger($config['settings']['monolog']['name']);
  $file_handler = new \Monolog\Handler\StreamHandler(__DIR__ . '/../logs/' . date('Y-m-d') . '-log.logfile');
  $logger -> pushHandler($file_handler);
  return $logger;
};

Den Container für sichere Formularübermittlung in Slim3 - CSRF - Cross-Site-Request-Forgery (https://de.wikipedia.org/wiki/Cross-Site-Request-Forgery):

$container['csrf'] = function ()
{
  return new \Slim\Csrf\Guard;
};

Einen Container für PHP-Environment-Variablen (http://php.net/manual/de/reserved.variables.environment.php):

$container['env'] = function()
{
  $dotenv = new Dotenv\Dotenv(__DIR__ . '/../env', '.env');
  $dotenv -> load();
  return $dotenv;
};

Einen Container für unsere UUID-Middleware:

$container['uuid'] = function()
{
  try
  {
    $uuid5 = Uuid::uuid5(Uuid::NAMESPACE_DNS, $config['settings']['uuid']['namespace']);
    return $uuid5 -> toString();
  }
  catch (UnsatisfiedDependencyException $e)
  {
    return 'Caught exception: ' . $e -> getMessage();
  }
};

Einen Container für Slim3-Flash-Messages:

$container['flash'] = function ()
{
  return new \Slim\Flash\Messages();
};

Unseren Container für die Twig-Template-Engine:

$container['twig'] = function()
{
  $loader = new Twig_Loader_Filesystem( __DIR__ . '/../views/');
  $twig = new Twig_Environment($loader,
    [
      $config['settings']['twig']
    ]
  );
  return $twig;
};

Einen Container für unsere Secure-Sessions-Middleware:

$container['session'] = function ($container)
{
    return new \Adbar\Session($config['settings']['session']['namespace']);
};

Und letztendlich unsere Container für den League/Oauth-Client und Guzzle:

$container['oauthprovider'] = function ()
{
  $provider = new \League\OAuth2\Client\Provider\GenericProvider
  (
    [
      'clientId' => getenv('ENV_CLIENT_ID'),
      'clientSecret' => getenv('ENV_CLIENT_SECRET'),
      'redirectUri' => getenv('ENV_REDIRECT_URL'),
      'urlAuthorize' => getenv('ENV_AUTHORIZE_URL'),
      'urlAccessToken' => getenv('ENV_URL_ACCESS_TOKEN'),
      'urlResourceOwnerDetails' => '',
      'scopes' => 'offline_access user.read people.read user.read.all openid mail.send'
    ]
  );

  return $provider;
};

$container['guzzzle'] = function ()
{
  return new \GuzzleHttp\Client();
};

Für die Interaktion mit Microsoft-Graph richten wir auch schnell unsere /env/.env-Datei ein:

ENV_CLIENT_ID=9f26...SUPER_SUPER_GEHEIM
ENV_CLIENT_SECRET=wT:::SUPER_SUPER_GEHEIM
ENV_REDIRECT_URL=UNSERE_EINGERICHTETE_REDIRECT_URL
ENV_AUTHORIZE_URL=https://login.microsoftonline.com/common/oauth2/v2.0/authorize
ENV_URL_ACCESS_TOKEN=https://login.microsoftonline.com/common/oauth2/v2.0/token

Aber um an die Daten wie App-ID und App-Secret zu kommen, müssen wir unter Microsoft eine WebApp einrichten - sozusagen unsere Anwendung legitimieren, mit Microsoft-Graph zu kommunizieren... Wir besuchen hierfür das Microsoft App Registration Center:

https://apps.dev.microsoft.com

Legen dort eine neue App an und geben dem Kind einen Namen, legen die Plattform fest, rufen die Client-ID und das Client-Secret ab, richten eine Redirect-Url ein und legen unseren Umfang für die angefragten Rechte fest.

Wofür wird die Redirect-URL benötigt?

Ganz einfach - Beispielhaft der Flow einer Microsoft-Graph Interaktion:

  • 1) Login-Seite der eingenen Anwendung ->
  • 2) Weiterleitung zum Microsoft-Login ->
  • 3) Redirect zur eigenen Anwendung als authentifizierter Benutzer

Hierfür legen wir in unserer Slim-App eine Route /backend an. Der Benutzer besucht also unsere Web-App, möchte sich als registrierter Microsoft-Benutzer bei uns anmelden. Wir leiten aus unserer App auf die Microsoft Seite für Benutzeranmeldungen um. Dort kann sich der Benutzer gegenüber Microsoft als registrierter Benutzer anmelden. Wird der Benutzer von Microsoft akzeptiert, wird Microsoft den Benutzer über die definierte Redirect-URL auf unsere Web-App zurückleiten und wir können sicher sein, das der Benutzer sich richtig bei Microsoft angemeldet hat, wenn dies erfolgt. Gleichzeitig wird über GET-Parameter ein Code von Microsoft mitgesendet - dieser enthält den Token für die Authentifizierung von weiteren Abfragen an Microsoft-Graph.

Oben in unserem Container für den League/Oauth Client steht folgende Zeile:

'scopes' => 'offline_access user.read people.read user.read.all openid mail.send'

Hiermit legen wir für den Start unsere Rechte fest, die wir uns mit der Anmeldung bei Micrososft für unsere Applikation einrichten möchten. Der Parameter offline_access ist wichtig um einen Refresh-Token zu bekommen, mit dem wir unseren Zugriff re-authentifizieren können, sobald unser anfangs erteilter Zugangs-Token abgelaufen ist (regulär innerhalb einer Stunde). Rechte wie user.read people.read user.read.all openid mail.send sind selbsterklärend.

Jetzt können wir noch unserer App die Benutzung der Secure-Session Middleware mitgeben:

$app -> add(new \Adbar\SessionMiddleware($config['settings']['session']));

Und dann können wir auch schon beginnen, die einzelnen Routen einzurichten. Unsere Route für die Home-Ansicht:

$app -> get('/',function (Request $request, Response $response, $args) use ($app)
  {
    $this -> logger -> addInfo('RootPath');
    $session = new \Adbar\Session($config['settings']['session']['namespace']);

    return $this -> twig -> render('index.html', [
      'PageTitle' => 'Anmeldung | ...'
    ]);
  }
) -> setName('home');

Das dazugehörende Twig-Template index.html schaut wie folgt aus:

<!doctype html>
<html>
    <head>
      <meta charset="utf-8">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <title>{{ PageTitle|raw }}</title>
    </head>
    <body>
      <div class="container">
        <div class="row">
          <div class="col s12">
            <p class="center">
              <a href="/oauth" class="waves-effect waves-light btn-large" role="button"><i class="material-icons right">chevron_right</i>Anmelden</a>
            </p>
          </div>
        </div>
      </div>
    </body>
</html>

Ein ganz simples Twig-Template mit einem einzigen Button. Dieser Button leitet den Benutzer auf unsere Slim3-Route /oauth weiter. Diese /oauth-Route schaut jetzt schon interessanter aus:

$app -> get('/oauth', function (Request $request, Response $response, $args) use ($app)
  {
    $this -> logger -> addInfo('oAuthPath');
    $session = new \Adbar\Session($config['settings']['session']['namespace']);
    $this -> env;
    $provider = $this -> oauthprovider;
    $CODE = filter_var($_GET['code'], FILTER_SANITIZE_FULL_SPECIAL_CHARS);

    if (!$CODE)
    {
      try
      {
        header('Location: ' . $provider -> getAuthorizationUrl());
      }
      catch(Exception $ex)
      {
        $this -> logger -> addInfo('Fehler oAuthPath: ' . $ex -> getMessage());
      }
    }
    else
    {
      try
      {
        $accessToken = $provider -> getAccessToken('authorization_code',
          ['code' => $CODE]
        );

        $existingAccessToken = $accessToken -> getToken();
        $refreshToken = $accessToken -> getRefreshToken();
        $expiresIn = $accessToken -> getExpires();
        $expired = ($accessToken -> hasExpired() ? true : false);
        $client = $this -> guzzzle;

        $me_response = $client -> request('GET', "https://graph.microsoft.com/v1.0/me", [
          'headers' => [
            'Authorization' => 'Bearer ' . $accessToken -> getToken(),
            'Content-Type' => 'application/json;odata.metadata=minimal;odata.streaming=true'
          ]
        ]);

        $this -> logger -> addInfo('Status Code oAuthPath me_response: ' . $me_response -> getStatusCode());

        $IDENTITY = json_decode($me_response -> getBody() -> getContents());
        $EMAIL = '';
        $DISPLAY_NAME = '';
        $GIVEN_NAME = '';
        $SURNAME = '';
        $USER_PRINCIPAL_NAME = '';
        $USER_ID = '';
        $JOB_TITLE = '';

        foreach($IDENTITY AS $KEY => $VALUE)
        {
          if($KEY == 'mail')
          {
            $EMAIL = $VALUE;
          }
          else if($KEY == 'displayName')
          {
            $DISPLAY_NAME = $VALUE;
          }
          else if($KEY == 'givenName')
          {
            $GIVEN_NAME = $VALUE;
          }
          else if($KEY == 'surname')
          {
            $SURNAME = $VALUE;
          }
          else if($KEY == 'userPrincipalName')
          {
            $USER_PRINCIPAL_NAME = $VALUE;
          }
          else if($KEY == 'id')
          {
            $USER_ID = $VALUE;
          }
          else if($KEY == 'jobTitle')
          {
            $JOB_TITLE = $VALUE;
          }
        }

        $session -> set('uuid', $this -> uuid);
        $session -> set('code', $accessToken -> getToken());
        $session -> set('refresh_token', $refreshToken);
        $session -> set('token', $accessToken -> getToken());
        $session -> set('email', $EMAIL);
        $session -> set('display_name', $DISPLAY_NAME);
        $session -> set('given_name', $GIVEN_NAME);
        $session -> set('surname', $SURNAME);
        $session -> set('user_principal_name', $USER_PRINCIPAL_NAME);
        $session -> set('user_id', $USER_ID);
        $session -> set('job_title', $JOB_TITLE);
        $session -> set('status_code', $me_response -> getStatusCode());
        $session -> set('existing_access_token', $existingAccessToken);
        $session -> set('expires_in', date('H:i:s',$expiresIn));
        $session -> set('expires_in_s', $expiresIn);
        $session -> set('expired', ($accessToken -> hasExpired() ? 'true' : 'false'));

        return $response -> withRedirect('./backend');
      }
      catch (Exception $ex)
      {
          exit('Fehler: ' . $ex -> getMessage());
      }
    }
  }
) -> setName('oauth');

Was passiert hier?

Wir öffnen unsere Ansicht unter der Route /oauth und überprüfen, ob ein $_GET-Parameter code existiert. Ist dies nicht der Fall, stellen wir eine Anfrage an Microsoft um den Benutzer bei Microsoft anzumelden - wir leiten den Beutzer also einfach zum Microsoft-Login weiter:

$CODE = filter_var($_GET['code'], FILTER_SANITIZE_FULL_SPECIAL_CHARS);

...

if (!$CODE)
{
  ...

    header('Location: ' . $provider -> getAuthorizationUrl());

  ...
}
else...

Meldet sich der Benutzer bei Microsoft an, wird er über die definierte Redirect-URL wieder zu unserer Web-App zurückgeleitet mit dem $_GET-Parameter code der unsere wichtigen Tokens von Microsoft enthält.

Mit Hilfe unseres League/Oauth Client's wandeln wir den code in die entsprechenden Informationen um:

$accessToken = $provider -> getAccessToken('authorization_code',
  ['code' => $CODE]
);

$existingAccessToken = $accessToken -> getToken();
$refreshToken = $accessToken -> getRefreshToken();
$expiresIn = $accessToken -> getExpires();
$expired = ($accessToken -> hasExpired() ? true : false);

Wir haben also einen Token, RefreshToken und Informationen über den Token in unserer Applikation parat und können mit der eigentlichen Arbeit loslegen - eine Anfrage über die persönlichen Informationen des angemeldeten Benutzers:

$client = $this -> guzzzle;

$me_response = $client -> request('GET', "https://graph.microsoft.com/v1.0/me", [
  'headers' => [
    'Authorization' => 'Bearer ' . $accessToken -> getToken(),
    'Content-Type' => 'application/json;odata.metadata=minimal;odata.streaming=true'
  ]
]);

Mit Guzzle als HTTP-Client - dieser ist Bestandteil des League/Oauth Packages - können wir eine Anfrage über den gerade aktuell bei Microsoft angemeldeten Benutzer erfragen über den definierten Endpoint:

https://graph.microsoft.com/v1.0/me

Bei Microsoft gibt es auch den Graph-Explorer - eine Internetseite mit der man die Kommunikation zwischen Microsoft-Graph und einer Anwendung simulieren kann - inklusive Request und Response:

https://developer.microsoft.com/de-de/graph/graph-explorer

Meldet man sich auf dieser Seite mit einem qualifizierten Microsoft-Konto an, kann man seine eigenen Informationen abfragen und schön verfolgen, wie der eigentliche Mechanismus überhaupt funktioniert, welche Elemente in den Antworten mitgeliefert werden und wie man diese aufbereiten kann. Die Antwort unserer ersten o.g. Abfrage bereiten wir hier noch ein wenig auf:

$IDENTITY = json_decode($me_response -> getBody() -> getContents());

Und schon können wir über unsere Secure-Session-Middleware die entsprechenden Informationen in unserer App abspeichern:

$session -> set('uuid', $this -> uuid);
$session -> set('code', $accessToken -> getToken());
$session -> set('refresh_token', $refreshToken);
$session -> set('token', $accessToken -> getToken());
$session -> set('email', $EMAIL);
...

Schließlich leiten wir den Benutzer dann noch in den geschützen Berich weiter - hier ./backend/:

return $response -> withRedirect('./backend');

Fertig!

Der Backend-Bereich

Unser Backend-Bereich erhält ein eMail-Formular mit dem Sahnehäubchen eine eMail an eine Adresse zu versenden - als Empfänger könnten wir Leute der eigenen Organisation vorschlagen... die wir vorher erfragen müssen - z.B. Mitglieder des selben Unternehmens. Wir gestalten also unsere ./backend-Route in Slim3 wie folgt:

$app -> get('/backend', function (Request $request, Response $response, $args) use ($app)
  {
    $session = new \Adbar\Session($config['settings']['session']['namespace']);

    if($session -> get('uuid') !== null && $session -> get('uuid') != '')
    {
      $this -> logger -> addInfo('BackendPath: ' . $session -> get('uuid'));

      if (time() >= $session -> get('expires_in_s'))
      {
        $this -> logger -> addInfo('Expired: ' . time() . ' > ' . $session -> get('expires_in_s'));
        return $response -> withRedirect('./oauth');
      }

      $nameKeyCSRF = $this -> csrf -> getTokenNameKey();
      $valueKeyCSRF = $this -> csrf -> getTokenValueKey();
      $nameCSRF = $request -> getAttribute($nameKeyCSRF);
      $valueCSRF = $request -> getAttribute($valueKeyCSRF);
      $messages = $this -> flash -> getMessages();
      $client = $this -> guzzzle;

      $people_response = $client -> request('GET', "https://graph.microsoft.com/v1.0/users", [
        'headers' => [
          'Authorization' => 'Bearer ' . $session -> get('code'),
          'Content-Type' => 'application/json;odata.metadata=minimal;odata.streaming=true'
        ]
      ]);

      $this -> logger -> addInfo('Status Code Backend people_response: ' . $people_response -> getStatusCode());

      $ME_PEOPLE_JSON = json_decode($people_response -> getBody() -> getContents(), true);
      $ME_PEOPLE_STR = '{';

      for($i = 0; $i <= count($ME_PEOPLE_JSON[value]); $i++)
      {
        if($ME_PEOPLE_JSON[value][$i][userPrincipalName])
        {
          $ME_PEOPLE_STR .= '"' . $ME_PEOPLE_JSON['value'][$i]['userPrincipalName'] . '": null, ';
        }
      }

      $ME_PEOPLE_STR .= '}';
      $ME_PEOPLE_STR = str_replace('null, }','null}',$ME_PEOPLE_STR);

      return $this -> twig -> render('backend.html', [
        'PageTitle' => 'Backend | ...',
        'nameKeyCSRF' => $nameKeyCSRF,
        'valueKeyCSRF' => $valueKeyCSRF,
        'nameCSRF' => $nameCSRF,
        'valueCSRF' => $valueCSRF,
        'code' => $session -> get('code'),
        'uuid' => $session -> get('uuid'),
        'token' => $session -> get('code'),
        'email' => $session -> get('email'),
        'display_name' => $session -> get('display_name'),
        'given_name' => $session -> get('given_name'),
        'surname' => $session -> get('surname'),
        'user_principal_name' => $session -> get('user_principal_name'),
        'user_id' => $session -> get('user_id'),
        'job_title' => $session -> get('job_title'),
        'status_code' => $session -> get('status_code'),
        'existing_access_token' => $session -> get('existing_access_token'),
        'refresh_token' => $session -> get('refresh_token'),
        'expires_in_s' => $session -> get('expires_in_s'),
        'expires_in' => $session -> get('expires_in'),
        'expired' => $session -> get('expired'),
        'messages' => $messages,
        'me_people_string' => $ME_PEOPLE_STR
      ]);
    }
    else
    {
      return $response -> withRedirect('./logout');
    }
  }
) -> setName('backend') -> add($container -> get('csrf'));

Mit dem Teil:

if($session -> get('uuid') !== null && $session -> get('uuid') != '')
{
  $this -> logger -> addInfo('BackendPath: ' . $session -> get('uuid'));

überprüfen wir einfach, ob eine angelegte uuid-Session existiert - diese soll bei unserer beispielhaften Anwendung als Beweis ausreichen, das es sich um einen angemeldeten Benutzer handelt. Sollte diese uuid-Session nicht vorhanden sein, leiten wir um zu unserem Login per Microsoft unter ./oauth. Jetzt beginnt der interessante Teil - eine Abfrage der Personen aus meinem Umfeld:

...

$client = $this -> guzzzle;

$people_response = $client -> request('GET', "https://graph.microsoft.com/v1.0/users", [
  'headers' => [
    'Authorization' => 'Bearer ' . $session -> get('code'),
    'Content-Type' => 'application/json;odata.metadata=minimal;odata.streaming=true'
  ]
]);

...

Wichtig für Abfragen über Microsoft Graph ist unser Token den wir über die Informationen Bearer+Token in unserem Abfrage-Header an den definierten Endpoint https://graph.microsoft.com/v1.0/users mitsenden. Bekommen wir hier ein Ergebnis zurück, können wir die zurückgelieferten Informationen für unsere Slim-App aufbereiten:

...
$ME_PEOPLE_JSON = json_decode($people_response -> getBody() -> getContents(), true);
$ME_PEOPLE_STR = '{';

for($i = 0; $i <= count($ME_PEOPLE_JSON[value]); $i++)
{
  if($ME_PEOPLE_JSON[value][$i][userPrincipalName])
  {
    $ME_PEOPLE_STR .= '"' . $ME_PEOPLE_JSON['value'][$i]['userPrincipalName'] . '": null, ';
  }
}

$ME_PEOPLE_STR .= '}';
$ME_PEOPLE_STR = str_replace('null, }','null}',$ME_PEOPLE_STR);
...

Und über

'me_people_string' => $ME_PEOPLE_STR

Im Twig-Template für die Backend-Ansicht weiter verarbeiten. Das zugehörige Twig-Template für die ./backend-Ausgabe gestaltet sich wie folgt:

<!doctype html>
<html>
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>{{ PageTitle|raw }}</title>
        <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.100.2/css/materialize.min.css">
    </head>
    <body>
      <nav class="white">
        <div class="nav-wrapper">
          <a href="#" data-activates="mobile-demo" class="button-collapse black-text"><i class="material-icons">menu</i></a>
          <ul class="right hide-on-med-and-down">
            <li><a class="modal-trigger black-text" href="#emailmodal">eMail</a></li>
            <li><a class="black-text" href="./logout">Abmelden</a></li>
          </ul>
          <ul class="side-nav" id="mobile-demo">
            <li><a class="modal-trigger black-text" href="#emailmodal">eMail</a></li>
            <li><a class="black-text" href="./logout">Abmelden</a></li>
          </ul>
        </div>
      </nav>
      <div class="container">
        {% if messages %}
        <div class="row">
          <div class="col s12 center">
            {% for message in messages %}
              <div class="card-panel green darken-1"><span class="white-text">{{ message.0 }}</span></div>
            {% endfor %}
          </div><!--EOF COL-->
        </div><!--EOF ROW-->
        {% endif %}
        </div><!--EOF ROW-->
        <div id="emailmodal" class="modal modal-fixed-footer">
          <div class="modal-content">
            <h4>Eine eMail schreiben</h4>
            <form action="./postmaster" enctype="application/x-www-form-urlencoded" method="post">
              <input type="hidden" name="code" value="{{ existing_access_token }}">
              <input type="hidden" name="refreshToken" value="{{ refresh_token }}">
              <input type="hidden" name="{{ nameKeyCSRF }}" value="{{ nameCSRF }}">
              <input type="hidden" name="{{ valueKeyCSRF }}" value="{{ valueCSRF }}">
              <div class="row">
                <form class="col s12">
                  <div class="row">
                    <div class="input-field col s12">
                      <input placeholder="Absender" readonly="readonly" data-length="50" id="inputSender" name="inputSender" required="required" type="email" value="{{ email }}">
                      <label for="inputSender">Absender</label>
                    </div>
                  </div>
                  <div class="row">
                    <div class="input-field col s12">
                      <input class="autocomplete" autocomplete="off" placeholder="Empfänger" data-length="50" id="inputRecipient" name="inputRecipient" required="required" type="email" value="">
                      <label for="inputRecipient">Empfänger</label>
                    </div>
                  </div>
                  <div class="row">
                    <div class="input-field col s12">
                      <input placeholder="Betreff" autocomplete="off" data-length="200" id="inputSubject" name="inputSubject" required="required" type="text" value="">
                      <label for="inputSubject">Betreff</label>
                    </div>
                  </div>
                  <div class="row">
                    <div class="input-field col s12">
                      <textarea placeholder="Ihre Nachricht" id="inputText" name="inputText" required="required" class="materialize-textarea"></textarea>
                      <label for="inputText">Ihre Nachricht</label>
                    </div>
                  </div>
                  <div class="row">
                    <div class="col s12">
                      <button type="submit" class="waves-effect waves-light btn green" role="button"><small><strong><i class="material-icons right">chevron_right</i>Absenden</strong></small></button>
                    </div>
                  </div>
                </form>
              </div>
            </form>
          </div>
          <div class="modal-footer">
            <a href="#!" class="modal-action modal-close waves-effect waves-light btn red"><small><strong><i class="material-icons right">close</i>Fenster schließen</strong></small></a>
          </div>
        </div>
      </div><!--EOF CONTAINER-->
      <script type="text/javascript" src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/0.100.2/js/materialize.min.js"></script>
      <script>
        $(document).ready(function()
          {
            $(".button-collapse").sideNav();
            $(".dropdown-button").dropdown();
            $('.modal').modal({dismissible:true,opacity:.5,inDuration:300,outDuration:200,startingTop:'1%',endingTop:'10%',ready: function(modal, trigger){$('#inputRecipient').focus();}});
            $('#inputText').trigger('autoresize');
            $('#inputRecipient').autocomplete({data:{{ me_people_string|raw }},limit:35,minLength:1});
          }
        );
      </script>
    </body>
</html>

Das Template flux ein wenig mit dem Materialize-Framework verschönert. Dieses bietet bei Formularen auch ein schönes Autocomplete an das wir verwenden - für den Vorschlag der sonstigen gefundenen Personen in unserer Organisation. Unser Autocomplete Empfänger-Feld:

<div class="input-field col s12">
  <input class="autocomplete" autocomplete="off" placeholder="Empfänger" data-length="50" id="inputRecipient" name="inputRecipient" required="required" type="email" value="">
  <label for="inputRecipient">Empfänger</label>
</div>

Und das Javascript für die Steuerung der autocomplete-Funktion:

$('#inputRecipient').autocomplete({data:{{ me_people_string|raw }},limit:35,minLength:1});

Das Formular bekommt unsere ./postmaster-Route in Slim. Diese Route bauen wir wie folgt auf:

$app -> post('/postmaster', function (Request $request, Response $response, $args) use ($app)
  {
    $session = new \Adbar\Session($config['settings']['session']['namespace']);

    if (time() >= $session -> get('expires_in_s'))
    {
      $this -> logger -> addInfo('Expired: ' . time() . ' > ' . $session -> get('expires_in_s'));
      return $response -> withRedirect('./oauth');
    }

    if($session -> get('uuid') !== null && $session -> get('uuid') != '')
    {
      try
      {
        $this -> logger -> addInfo('PostmasterPath: ' . $session -> get('uuid') . ' - ' . $session -> get('email'));

        $inputSender = filter_var($_POST['inputSender'], FILTER_VALIDATE_EMAIL);
        $inputRecipient = filter_var($_POST['inputRecipient'], FILTER_VALIDATE_EMAIL);
        $inputSubject = filter_var($_POST['inputSubject'], FILTER_SANITIZE_FULL_SPECIAL_CHARS);
        $inputText = filter_var($_POST['inputText'], FILTER_SANITIZE_FULL_SPECIAL_CHARS);
        $code = filter_var($_POST['code'], FILTER_SANITIZE_FULL_SPECIAL_CHARS);
        $refreshToken = filter_var($_POST['refreshToken'], FILTER_SANITIZE_FULL_SPECIAL_CHARS);
        $client = $this -> guzzzle;

        $email = "
        {
          Message:
          {
            Subject: '" . $inputSubject . "',
            Body:
            {
              ContentType: 'text',
              Content: '" . $inputText . "'
            },
            ToRecipients: [
            {
              EmailAddress:
              {
                Address: '" . $inputRecipient . "'
              }
            }
          ]
        }}";

        $guzzleresponse = $client -> request('POST', 'https://graph.microsoft.com/v1.0/me/sendmail', [
          'headers' => [
            'Authorization' => 'Bearer ' . $code,
            'Content-Type' => 'application/json;odata.metadata=minimal;odata.streaming=true'
          ],
          'body' => $email
        ]);

        $this -> logger -> addInfo('Status Code Postmaster guzzleresponse: ' . $guzzleresponse -> getStatusCode());
        $this -> flash -> addMessage('eMail', 'Die eMail wurde erfolgreich an Microsoft übermittelt');

        return $response -> withRedirect('./backend');
      }
      catch(Exception $ex)
      {
        exit('Fehler: ' . $ex -> getMessage());
      }
    }
    else
    {
      return $response -> withRedirect('./logout');
    }
  }
) -> setName('postmaster') -> add($container -> get('csrf'));

Im Postmaster fragen wir unsere Variablen ab:

$inputSender = filter_var($_POST['inputSender'], FILTER_VALIDATE_EMAIL);
$inputRecipient = filter_var($_POST['inputRecipient'], FILTER_VALIDATE_EMAIL);
$inputSubject = filter_var($_POST['inputSubject'], FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$inputText = filter_var($_POST['inputText'], FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$code = filter_var($_POST['code'], FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$refreshToken = filter_var($_POST['refreshToken'], FILTER_SANITIZE_FULL_SPECIAL_CHARS);

Und geben eine Anfrage an Microsoft-Graph ab um unsere eMail über Microsoft Outlook zu senden:

$client = $this -> guzzzle;

$email = "
{
  Message:
  {
    Subject: '" . $inputSubject . "',
    Body:
    {
      ContentType: 'text',
      Content: '" . $inputText . "'
    },
    ToRecipients: [
    {
      EmailAddress:
      {
        Address: '" . $inputRecipient . "'
      }
    }
  ]
}}";

$guzzleresponse = $client -> request('POST', 'https://graph.microsoft.com/v1.0/me/sendmail', [
  'headers' => [
    'Authorization' => 'Bearer ' . $code,
    'Content-Type' => 'application/json;odata.metadata=minimal;odata.streaming=true'
  ],
  'body' => $email
]);

Eine Route für den Token Refresh ist auch nicht verkehrt - der Token nach dem Login hat eine Lebensdauer von einer Stunde - danach muss über den erteilten RefreshToken ein TokenRefresh stattfinden:

$app -> get('/refresh_token', function (Request $request, Response $response, $args) use ($app)
  {

    ...

    $client = $this -> guzzzle;
    $this -> env;

    // {--- TENANT ---} mit dem Organisationsnamen ersetzen - Firmenname
    $req = $client -> request('GET', "https://login.microsoftonline.com/{--- TENANT ---}/oauth2/v2.0/token", [
      'form_params' => [
        'accept' => 'application/json',
        'grant_type'=> 'refresh_token',
        'client_id' => getenv('ENV_CLIENT_ID'),
        'client_secret' => getenv('ENV_CLIENT_SECRET'),
        'refresh_token' => (string) $refreshToken,
        'redirect_uri' => getenv('ENV_REDIRECT_URL')
      ]
    ]);

    $resp = json_decode($req -> getBody() -> getContents(), true);

    ...

    // Aktualisierung der Cookies und Sessions... etc.

  }
) -> setName('refresh_token');

Jetzt können wir noch eine kleine Logout-Route bereitstellen - da wir diese ja schon in unseren Twig-Templates eingebaut haben:

$app -> get('/logout', function (Request $request, Response $response, $args) use ($app)
  {
    $session = new \Adbar\Session($config['settings']['session']['namespace']);
    $this -> logger -> addInfo('LogoutPath: ' . $session -> get('uuid'));
    $session -> deleteNamespace($config['settings']['session']['namespace']);
    $session -> destroy();
    $session -> regenerateId();

    return $response -> withRedirect('../');
  }
) -> setName('logout');

In dieser Route benutzen wir die in der Secure-Sessions-Middleware zur Verfügung gestellten Funktionen deleteNamespace, destroy und regenerateID... schwupps, ist unser Benutzer ausgelogged, weil die Session uuid nicht mehr existiert.

Wir haben eine eMail als Benutzer über diese Web-App verschickt

Diese eMail taucht dann selbstverständlich unter Office.com -> Outlook/Outlook.com in unseren gesendeten eMails auf. Wir haben die Kommunikation mit unserem eMail-Postfach also von Outlook.com entkoppelt und sind nun in der Lage uns ein eigenes Outlook.com zu bauen... eigenes Design, eigene Funktionen, ..., eigene Geschäftsanwendung.

Ein kleines Update zu den Endpunkten

Wenn man sicherstellen möchte, dass sich nur Benutzer der eigenen Organisation (Firma - Tenant im Wortlaut von Microsoft) anmelden und mit der WebApp interagieren können, muss man die Endpunkte anpassen. Aus:

ENV_AUTHORIZE_URL=https://login.microsoftonline.com/common/oauth2/v2.0/authorize
ENV_URL_ACCESS_TOKEN=https://login.microsoftonline.com/common/oauth2/v2.0/token

Wird dann:

ENV_AUTHORIZE_URL=https://login.microsoftonline.com/{---TENANT---}/oauth2/v2.0/authorize
ENV_URL_ACCESS_TOKEN=https://login.microsoftonline.com/{---TENANT---}/oauth2/v2.0/token

Wobei man {---TENANT---} mit der Bezeichnung der eigenen oder gewünschten Organisation (Firma/Tenant) ersetzt. Außerdem gibt es auch im AppManifest den Punkt:

"availableToOtherTenants":true,

Ändert man diesen Eintrag im App-Manifest auf false, wird erfolgreich verhindert, das sich Außenstehende anmelden können.

Suche

Suchbegriffe mit mindestens 3 Zeichen! Suchvorgang mit ENTER-Taste starten.