Es gelingt mir nicht immer, auf dem Laufenden zu bleiben, was technische Entwicklungen im Web angeht. Vorsichtig ausgedrückt. Obwohl ich mich durchaus bemühe. Aber da draußen ist einfach zu viel los. So ging es auch an mir vorbei, dass Google angekündigt hatte, seinen Client-Login im April 2015 abschalten zu wollen. Oder ich habe einfach nicht verstanden, dass mich das betrifft. Hat es mich aber, und das gleich an verschiedenen Stellen.
Zum einen nutze ich an verschiedenen Stellen im Netz die Möglichkeit, Daten aus Google Tabellen auszulesen und diese weiter zu verarbeiten. Dieser Weg hat im Gegensatz zur Nutzung einer Datenbank den Charme, dass auch eine technisch nicht so versierte Gruppe von Usern sehr leicht Daten editieren kann, die dann direkt auf irgendwelchen Webseiten dargestellt oder für andere Dienste verarbeitet werden können.
Zum anderen brauchte ich diese Art der Authentifizierung für das Backup meiner Google Kontakte auf dem Raspberry Pi. Ich hatte darüber mal ausführlich geschrieben.
Die Nutzung des Client-Logins sah (vereinfacht) so aus, dass man sich bei Google-APIs mit seinen Google-Zugangsdaten authentifizieren konnte. Einen Schritt besser war es schon, dazu eine sog. „anwendungsspezifisches Passwort“ zu verwenden. D.h. man generierte sich bei Google ein weiteres Passwort, welches dann nur eine einzige Anwendung verwenden durfte. Vorteil: Durch Löschen des vergebenen Passworts war auch der Anwendung der Zugriff auf die Daten entzogen, außerdem musste man nicht sein richtiges Google-Passwort in irgendwelchen Skripten hinterlegen. Umgesetzt hatte ich das alles mit Hilfe des Zend Frameworks.
Diese Möglichkeit gibt es jetzt nicht mehr. OAUth 2.0 ist angesagt. OAuth ist an sich nichts neues, aber das Ding ist einfach deutlich komplexer als die herkömmliche Authentifizierung. Wie OAuth 2.0 funktioniert genau funktioniert erkläre ich hier nicht weiter, man kann das z.B. hier schön nachlesen. Ein Artikel in 5 Teilen, das sagt schon alles. Die Grundidee ist die, dass eine Anwendung einen z.B. auf eine Google-Seite weiterleitet, man sich dort authentifiziert und somit seiner Anwendung bestimmte Rechte zuweist, ohne dass die Anwendung Details der Zugangsberechtigung erhält. Das typische „Loggen Sie sich einfach mit Facebook ein“-Ding. Der große Vorteil ist hier, dass keine Passwörter weitergegeben werden, sondern lediglich sog. Access-Tokens generiert werden, die in der Regel auch eine Verfallsdatum haben. Alles crispy, alles funky, aber eben auch alles etwas komplizierter.
Wie gesagt war der Anlass, mich wieder mal mit dem Thema zu befassen, die Reaktivierung des Backups meiner Google Kontakte mit dem Raspberry Pi. Deshalb werde ich hier mal kurz umreißen, wie das Prozedere jetzt aussieht.
Der PHP-Code
Mir ist klar, dass sich meine PHP-Künste im Rahmen halten. Der Code hier ist also nur zum Hausgebrauch und als Arbeitsgrundlage für weitere Optimierungen geeignet. Außerdem enthält er eine Reihe an Kommentaren, die eher den Stil eines Selbstgesprächs als einer sinnvollen Code-Kommentierung haben. Egal.
<?
// Backup der Google Kontakte per Cronjob funktioniert nur noch mit OAuth2
// Es gäbe jetzt 2 Wege:
// 1) User authentifiziert sich per Web
// Problem:
// - Die Authentifizierung läuft schnell ab.
// - Daher ist das per Skript nicht praktikabel.
// 2) Authentifizierung über ein "Dienskonto" ("service account") mit einem P12-Schlüssel
// Problem:
// - Das Dienstkonto mit seiner speziell generierten Mailadresse kann sich zwar dauerhaft authentifizieren,
// hat aber keinen Zugriff auf die Kontakte des Users, der das Dienstkonto erstellt hat...
// Hinweise:
// - Erstellen eines Projekts bei Google und erzeugen der Schlüssel: https://console.developers.google.com/project
// - API Client Library for PHP: https://developers.google.com/api-client-library/php/start/get_started
// - Google Contacts API version 3.0: https://developers.google.com/google-apps/contacts/v3/index#retrieving_all_contacts
// - google-api-php-client: https://github.com/google/google-api-php-client
// - Beispielcode für Ahthentifizierung per Web: https://github.com/google/google-api-php-client/issues/462
// - Warum es nicht geht, sondern nur mit einer "Google managed domain": http://stackoverflow.com/questions/14407415/accessing-google-contacts-api-via-oauth-2-0-and-private-key-aka-service-account
// - Vielleicht geht es doch per Web-Authentifizierung??? http://stackoverflow.com/questions/13851157/oauth2-and-google-api-access-token-expiration-time
// >>> However, after a successful completion of the OAuth2 installed application flow,
// you will get back a refresh token.
// This refresh token never expires, and you can use it to exchange it for an access token as needed.
// Save the refresh tokens, and use them to get access tokens on-demand
// (which should then immediately be used to get access to user data).
# Debug-Modus. Wenn der TRUE ist werden nur Debug-Infos rausgeschickt:
define("DEBUGMODUS", FALSE);
# # # # VERSION MIT DER WEB-AUTHENTIFIZIERUNG
# Diese Daten hier erhält man nach der Registrierung eines Projekts in der Developers Console (https://console.developers.google.com/):
# Client-ID:
$client_id = 'HIER KOMMT DEINE CLIENT-ID REIN';
# Clientschlüssel:
$client_secret = 'HIER KOMMT DEIN CLIENT-SCHLÜSSEL REIN';
# Nach dem ersten Aufruf des Skripts per Webbrowser bekommt man einen RefreshToken,
# den man hier einträgt. Mit diesem kann dann bei jedem weiteren Aufruf
# ein neuer AccessToken geholt werden, mit dem dann wiederrum die Daten geholt werden:
$refresh_token = 'HIER KOMMT DEIN REFRESH-TOKEN REIN';
/*
* Copyright 2011 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
# Falls das Skript per Kommandozeile ausgeführt werden soll sollen die Fehler unterdrückt werden (z.B. die Warnings wg. der Session):
if ($refresh_token) error_reporting(0);
session_start();
// # Session manuell killen, und damit neue Token erzwingen:
// unset($_SESSION['access_token']);
# Diese Library muss eingebunden werden: https://github.com/google/google-api-php-client
require_once __DIR__ . '/google-api-php-client-master/examples/templates/base.php';
require_once __DIR__ . '/google-api-php-client-master/src/Google/autoload.php';
$scriptUri = "http://".$_SERVER["HTTP_HOST"].$_SERVER['PHP_SELF'];
$redirect_uri = $scriptUri;
/************************************************
Make an API request on behalf of a user. In
this case we need to have a valid OAuth 2.0
token for the user, so we need to send them
through a login flow. To do this we need some
information from our API console project.
************************************************/
$client = new Google_Client();
$client->setClientId($client_id);
$client->setClientSecret($client_secret);
$client->setRedirectUri($redirect_uri);
//$client->addScope("https://www.googleapis.com/auth/contacts.readonly");
$client->addScope("https://www.google.com/m8/feeds");
# HIER KOMMT DER ENTSCHEIDENDE PUNKT!
# Der AccessToken soll OFFLINE sein, d.h. ich bekomme auch einen 'refresh_token',
# mit dem ich mir dann immer wieder neu einen 'access_token' holen kann,
# ohne die Web-Authentifizierung neu machen zu müssen.
# Siehe https://developers.google.com/identity/protocols/OAuth2WebServer#refresh
# PHP-Demo: http://stackoverflow.com/questions/9241213/how-to-refresh-token-with-google-api-client
# Außerdem muss dazu noch approval_prompt=force gesetzt werden.
# Siehe https://github.com/google/google-api-php-client/issues/263
$client->setAccessType('offline');
$client->setApprovalPrompt('force');
/************************************************
Boilerplate auth management - see
user-example.php for details.
************************************************/
if (isset($_REQUEST['logout'])) {
unset($_SESSION['access_token']);
}
if (isset($_GET['code'])) {
$client->authenticate($_GET['code']);
$_SESSION['access_token'] = $client->getAccessToken();
$redirect = 'http://' . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF'];
header('Location: ' . filter_var($redirect, FILTER_SANITIZE_URL));
}
if (isset($_SESSION['access_token']) && $_SESSION['access_token']) {
$client->setAccessToken($_SESSION['access_token']);
} else {
$authUrl = $client->createAuthUrl();
}
# Falls schon ein RefreshToken gesetzt ist kann das Skript per Kommandozeile ausgeführt werden:
if ($refresh_token) {
# Damit hole ich mir bei JEDEM Aufruf einfach einen neuen Access-Token ;-)
$client->refreshToken($refresh_token);
# Außerdem die Variable $authUrl löschen, sonst wird weiter unten nur die URL zum Login angezeigt:
unset($authUrl);
}
/************************************************
If we're signed in, retrieve contacts
************************************************/
if ($client->getAccessToken()) {
if (DEBUGMODUS) echo "<hr><h1>Session-Variablen:</h1><pre>".print_r($_SESSION, true)."</pre>";
$_SESSION['access_token'] = $client->getAccessToken();
$access_token = json_decode($client->getAccessToken())->access_token;
if (DEBUGMODUS) echo "<hr><h1>Client:</h1><pre>".print_r($client, true)."</pre>";
if (DEBUGMODUS) echo "<hr><h1>Access-Token:</h1><pre>".print_r(json_decode($client->getAccessToken()), true)."</pre>";
if (DEBUGMODUS) echo "<hr><h1>Weitere Infos zum Access-Token:</h1><pre>".print_r(json_decode(file_get_contents('https://www.googleapis.com/oauth2/v3/tokeninfo?access_token='.$access_token)), true)."</pre>";
# Beim ersten Aufruf soll einfach nur der Refresh-Token zurückgegeben werden, der muss dann hier ins Skript eingetragen werden:
if (!$refresh_token) {
echo "Bitte den Code unterhalb der Linie in das Skript in die Variable $refresh_token eintragen:<hr>";
echo "<pre>".json_decode($client->getAccessToken())->refresh_token."</pre>";
exit();
}
//$url = 'https://www.google.com/m8/feeds/contacts/default/full?max-results=3&alt=json&v=3.0&oauth_token='.$access_token;
$url = 'https://www.google.com/m8/feeds/contacts/default/full?max-results=3000&alt=json&v=3.0&oauth_token='.$access_token;
$response = file_get_contents($url);
}
if (isset($authUrl)) {
echo "<a class='login' href='" . $authUrl . "'>Zugriff auf Google Contacts autorisieren > KLICK!</a>";
} else {
if (DEBUGMODUS) echo "<hr><h1>Kontakte:</h1><pre>".print_r(json_decode($response), true)."</pre>";
else echo $response;
}
Dieses Skript muss auf den Raspberry Pi kopiert und so eingerichtet werden, dass es (vorübergehend) aus dem Netz erreichbar ist. Man sollte also irgend einen Webserver auf dem Pi laufen haben.
Außerdem braucht man den Google API PHP-Client, den man herunterladen und in sein Projekt einbinden muss. Könnte man auch per Composer machen, ist in diesem Fall aber vielleicht ein bisschen zu viel des Guten. Immerhin gibt es keine Abhängigkeiten von anderen Paketen.
Die Authentifizierung
Um dem Skript nun Zugriff auf die eigenen Daten zu gewähren, muss in der Google Developers Console ein neues Projekt angelegt werden: https://console.developers.google.com/ Das Projekt braucht einen beliebigen Namen. Außerdem muss man folgende APIs aktivieren:
- Contacts API
- Google Contacts CardDAV API
- Google Partners API
Bin mir nicht wirklich sicher, ob man die „Google Contacts CardDAV API“ hier auch braucht, aber sie schadet nicht…
Anschließend brauchen wir Zugangsdaten, die wir (tataaa!) unter „Zugangsdaten“ generieren. Und zwar wählen wir „Client-ID für Webanwendung erstellen“. Wichtig ist an dieser Stelle, dass eine Weiterleitungs-URI gesetzt wird, die auf genau die URL zeigt, unter der unser PHP-Skript gerade im Netz erreichbar ist. Wenn wir die Client-ID erstellt haben bekommen wir unter anderem folgende Parameter: Eine „Client-ID“ und einen sog. „Clientschlüssel“. Diese beiden Werte müssen wir jetzt in unser PHP-Skript eintragen:
# Diese Daten hier erhält man nach der Registrierung eines Projekts in der Developers Console (https://console.developers.google.com/):
# Client-ID:
$client_id = 'HIER KOMMT DEINE CLIENT-ID REIN';
# Clientschlüssel:
$client_secret = 'HIER KOMMT DEIN CLIENT-SCHLÜSSEL REIN';
Wenn wir jetzt das PHP-Skript vom Web aus aufrufen, können wir den bekannten Authentifizierungsprozess starten: Das Skript leitet weiter zu einer Google-Authentifizierungsseite, dort sollte man dann alles gut finden und brav bestätigen, und am Ende wird man zurück zu unserem Skript geleitet. Das Skript ist jetzt authentifiziert, die Kontakte bei Google abzuholen. Allerdings verfällt der dazu vergeben Access Token schon innerhalb einer Stunde. Unpraktisch, wenn das Skript die Kontakte z.B. täglich ohne Zutun des Nutzers sichern soll. Daher wird von Google gleichzeitig ein sog. „Refresh Token“ angefordert. Mit Hilfe dieses Refresh Tokens kann das Skript bei jedem Aufruf einen neuen Access Token anfordern und anschließend mit diesem die Kontakte anfordern. Dazu muss man den Refresh Token irgendwo hinterlegen. Üblicherweise macht man das in einer Datenbank, der Einfachheit halber speichern wir ihn aber direkt im Skript. Das Skript zeigt den Refresh Token nach erfolgreicher Authentifizierung im Browser an, von dort kopieren wir ihn jetzt in unser Skript:
# Nach dem ersten Aufruf des Skripts per Webbrowser bekommt man einen RefreshToken,
# den man hier einträgt. Mit diesem kann dann bei jedem weiteren Aufruf
# ein neuer AccessToken geholt werden, mit dem dann wiederrum die Daten geholt werden:
$refresh_token = 'HIER KOMMT DEIN REFRESH-TOKEN REIN';
Die Ausführung
Ein Zugriff auf das Skript über das Internet ist nun nicht mehr nötig und sollte auch unterbunden werden. Denn ab jetzt soll das Skript nur noch per Kommandozeile oder Crontab ausgeführt werden, z.B. so:
php ~/skripte/backup-google-contacts.php | gzip > ~/backup/Google\ Kontakte/backup-google-contacts-`date +\%Y-\%m-\%d`.json.gz
Geschafft! Ich hoffe, der eine oder andere kann etwas mit dem Code-Chaos hier anfangen. Der Code sollte auf jeden Fall ein paar Anhaltspunkte für jemanden enthalten, der vor ähnlichen Problemen steht. Ich glaube, mir hätte das eine oder andere Code-Schnipsel weitergeholfen und mir die eine oder andere Nachtschicht erspart. Wobei, wäre vielleicht sogar schade gewesen, so ein Erfolgserlebnis ist ja auch was wert 😉