From: Christian Weiske Date: Tue, 6 Sep 2016 14:20:03 +0000 (+0200) Subject: "connect" works and stores token in config X-Git-Tag: v0.0.1~15 X-Git-Url: https://git.cweiske.de/shpub.git/commitdiff_plain/d573cd45cde3631ed5bdfd51e35084c1602cebc8?hp=be472fa254f752d28b4254fc308d27c8057f2aab "connect" works and stores token in config --- diff --git a/bin/shpub.php b/bin/shpub.php old mode 100644 new mode 100755 index 7e9729b..6bbd60f --- a/bin/shpub.php +++ b/bin/shpub.php @@ -1,3 +1,4 @@ +#!/usr/bin/env php run(); -exit(); -/** - * @link http://micropub.net/draft/ - * @link http://indieweb.org/authorization-endpoint - */ -$server = 'http://anoweco.bogo/'; -$user = 'http://anoweco.bogo/user/3.htm'; - -require_once 'HTTP/Request2.php'; - -$endpoints = discoverEndpoints($server); -list($accessToken, $userUrl) = getAuthCode($user, $endpoints); -var_dump($endpoints, $accessToken, $userUrl); - - -function getAuthCode($user, $endpoints) -{ - //fetch temporary authorization token - $redirect_uri = 'http://127.0.0.1:12345/callback'; - $state = time(); - $client_id = 'http://cweiske.de/shpub.htm'; - - $browserUrl = $endpoints->authorization - . '?me=' . urlencode($user) - . '&client_id=' . urlencode($client_id) - . '&redirect_uri=' . urlencode($redirect_uri) - . '&state=' . $state - . '&scope=post' - . '&response_type=code'; - echo "To authenticate, open the following URL:\n" - . $browserUrl . "\n"; - - $authParams = startHttpServer(); - - if ($authParams['state'] != $state) { - logError('Wrong "state" parameter value'); - exit(2); - } - - //verify indieauth params - $req = new HTTP_Request2($endpoints->authorization, 'POST'); - $req->setHeader('Content-Type: application/x-www-form-urlencoded'); - $req->setBody( - http_build_query( - [ - 'code' => $authParams['code'], - 'state' => $state, - 'client_id' => $client_id, - 'redirect_uri' => $redirect_uri, - ] - ) - ); - $res = $req->send(); - if ($res->getHeader('content-type') != 'application/x-www-form-urlencoded') { - logError('Wrong content type in auth verification response'); - exit(2); - } - parse_str($res->getBody(), $verifiedParams); - if (!isset($verifiedParams['me']) - || $verifiedParams['me'] !== $authParams['me'] - ) { - logError('Non-matching "me" values'); - exit(2); - } - - $userUrl = $verifiedParams['me']; - - - //fetch permanent access token - $req = new HTTP_Request2($endpoints->token, 'POST'); - $req->setHeader('Content-Type: application/x-www-form-urlencoded'); - $req->setBody( - http_build_query( - [ - 'me' => $userUrl, - 'code' => $authParams['code'], - 'redirect_uri' => $redirect_uri, - 'client_id' => $client_id, - 'state' => $state, - ] - ) - ); - $res = $req->send(); - if ($res->getHeader('content-type') != 'application/x-www-form-urlencoded') { - logError('Wrong content type in auth verification response'); - exit(2); - } - parse_str($res->getBody(), $tokenParams); - if (!isset($tokenParams['access_token'])) { - logError('"access_token" missing'); - exit(2); - } - - $accessToken = $tokenParams['access_token']; - - return [$accessToken, $userUrl]; -} - -function startHttpServer() -{ - $responseOk = "HTTP/1.0 200 OK\r\n" - . "Content-Type: text/plain\r\n" - . "\r\n" - . "Ok. You may close this tab and return to the shell.\r\n"; - $responseErr = "HTTP/1.0 400 Bad Request\r\n" - . "Content-Type: text/plain\r\n" - . "\r\n" - . "Bad Request\r\n"; - - //5 minutes should be enough for the user to confirm - ini_set('default_socket_timeout', 60 * 5); - $server = stream_socket_server( - 'tcp://127.0.0.1:12345', $errno, $errstr - ); - if (!$server) { - //TODO: log - return false; - } - - do { - $sock = stream_socket_accept($server); - if (!$sock) { - //TODO: log - exit(1); - } - - $headers = []; - $body = null; - $content_length = 0; - //read request headers - while (false !== ($line = trim(fgets($sock)))) { - if ('' === $line) { - break; - } - if (preg_match('#^Content-Length:\s*([[:digit:]]+)\s*$#i', $line, $matches)) { - $content_length = (int) $matches[1]; - } - $headers[] = $line; - } - - // read content/body - if ($content_length > 0) { - $body = fread($sock, $content_length); - } - - // send response - list($method, $url, $httpver) = explode(' ', $headers[0]); - if ($method == 'GET') { - $parts = parse_url($url); - if (isset($parts['path']) && $parts['path'] == '/callback' - && isset($parts['query']) - ) { - parse_str($parts['query'], $query); - if (isset($query['code']) - && isset($query['state']) - && isset($query['me']) - ) { - fwrite($sock, $responseOk); - fclose($sock); - return $query; - } - } - } - - fwrite($sock, $responseErr); - fclose($sock); - } while(true); -} - -class Config_Endpoints -{ - public $micropub; - public $media; - public $token; - public $authorization; -} - -function discoverEndpoints($url) -{ - $cfg = new Config_Endpoints(); - - //TODO: discovery via link headers - - $sx = simplexml_load_file($url); - $sx->registerXPathNamespace('h', 'http://www.w3.org/1999/xhtml'); - - $auths = $sx->xpath( - '/h:html/h:head/h:link[@rel="authorization_endpoint" and @href]' - ); - if (!count($auths)) { - logError('No authorization endpoint found'); - exit(1); - } - $cfg->authorization = (string) $auths[0]['href']; - - $tokens = $sx->xpath( - '/h:html/h:head/h:link[@rel="token_endpoint" and @href]' - ); - if (!count($tokens)) { - logError('No token endpoint found'); - exit(1); - } - $cfg->token = (string) $tokens[0]['href']; - - $mps = $sx->xpath( - '/h:html/h:head/h:link[@rel="micropub" and @href]' - ); - if (!count($mps)) { - logError('No micropub endpoint found'); - exit(1); - } - $cfg->micropub = (string) $mps[0]['href']; - - return $cfg; -} - -function logError($msg) -{ - file_put_contents('php://stderr', $msg . "\n", FILE_APPEND); -} ?> diff --git a/src/shpub/Cli.php b/src/shpub/Cli.php index 823e297..fb22d3f 100644 --- a/src/shpub/Cli.php +++ b/src/shpub/Cli.php @@ -23,7 +23,8 @@ class Cli $cmd->run( $res->command->args['server'], $res->command->args['user'], - $res->command->args['key'] + $res->command->args['key'], + $res->command->options['force'] ); break; case 'like': @@ -109,6 +110,16 @@ class Cli ); $cmd = $optParser->addCommand('connect'); + $cmd->addOption( + 'force', + array( + 'short_name' => '-f', + 'long_name' => '--force-update', + 'description' => 'Force token update if token already available', + 'action' => 'StoreTrue', + 'default' => false, + ) + ); $cmd->addArgument( 'server', [ diff --git a/src/shpub/Command/Connect.php b/src/shpub/Command/Connect.php index 867bbe3..2e7e266 100644 --- a/src/shpub/Command/Connect.php +++ b/src/shpub/Command/Connect.php @@ -1,15 +1,271 @@ cfg = $cfg; } - public function run($server, $user, $key) + public function run($server, $user, $newKey, $force) + { + $host = $this->getHost($newKey != '' ? $newKey : $server, $force); + if ($host->endpoints->incomplete()) { + $host->server = $server; + $this->discoverEndpoints($server, $host->endpoints); + } + + list($redirect_uri, $socketStr) = $this->getHttpServerData(); + $state = time(); + echo "To authenticate, open the following URL:\n" + . $this->getBrowserAuthUrl($host, $user, $redirect_uri, $state) + . "\n"; + + $authParams = $this->startHttpServer($socketStr); + if ($authParams['state'] != $state) { + Log::err('Wrong "state" parameter value: ' . $authParams['state']); + exit(2); + } + $code = $authParams['code']; + $userUrl = $authParams['me']; + $this->verifyAuthCode($host, $code, $state, $redirect_uri, $userUrl); + + $accessToken = $this->fetchAccessToken( + $host, $userUrl, $code, $redirect_uri, $state + ); + + //all fine. update config + $host->user = $userUrl; + $host->token = $accessToken; + + if ($newKey != '') { + $hostKey = $newKey; + } else { + $hostKey = $this->cfg->getHostByName($server); + if ($hostKey === null) { + $keyBase = parse_url($host->server, PHP_URL_HOST); + $newKey = $keyBase; + $count = 0; + while (isset($this->cfg->hosts[$newKey])) { + $newKey = $keyBase . ++$count; + } + $hostKey = $newKey; + } + } + $this->cfg->hosts[$hostKey] = $host; + $this->cfg->save(); + } + + protected function fetchAccessToken( + $host, $userUrl, $code, $redirect_uri, $state + ) { + $req = new \HTTP_Request2($host->endpoints->token, 'POST'); + $req->setHeader('Content-Type: application/x-www-form-urlencoded'); + $req->setBody( + http_build_query( + [ + 'me' => $userUrl, + 'code' => $code, + 'redirect_uri' => $redirect_uri, + 'client_id' => static::$client_id, + 'state' => $state, + ] + ) + ); + $res = $req->send(); + if ($res->getHeader('content-type') != 'application/x-www-form-urlencoded') { + Log::err('Wrong content type in auth verification response'); + exit(2); + } + parse_str($res->getBody(), $tokenParams); + if (!isset($tokenParams['access_token'])) { + Log::err('"access_token" missing'); + exit(2); + } + + $accessToken = $tokenParams['access_token']; + return $accessToken; + } + + protected function getBrowserAuthUrl($host, $user, $redirect_uri, $state) + { + return $host->endpoints->authorization + . '?me=' . urlencode($user) + . '&client_id=' . urlencode(static::$client_id) + . '&redirect_uri=' . urlencode($redirect_uri) + . '&state=' . $state + . '&scope=post' + . '&response_type=code'; + } + + protected function getHost($keyOrServer, $force) + { + $host = new Config_Host(); + $key = $this->cfg->getHostByName($keyOrServer); + if ($key !== null) { + $host = $this->cfg->hosts[$key]; + if (!$force && $host->token != '') { + Log::err('Token already available'); + return; + } + } + if ($host->endpoints === null) { + $host->endpoints = new Config_Endpoints(); + } + return $host; + } + + function discoverEndpoints($url, $cfg) { + //TODO: discovery via link headers + $sx = simplexml_load_file($url); + if ($sx === false) { + Log::err('Error loading URL: ' . $url); + exit(1); + } + $sx->registerXPathNamespace('h', 'http://www.w3.org/1999/xhtml'); + + $auths = $sx->xpath( + '/h:html/h:head/h:link[@rel="authorization_endpoint" and @href]' + ); + if (!count($auths)) { + Log::err('No authorization endpoint found'); + exit(1); + } + $cfg->authorization = (string) $auths[0]['href']; + + $tokens = $sx->xpath( + '/h:html/h:head/h:link[@rel="token_endpoint" and @href]' + ); + if (!count($tokens)) { + Log::err('No token endpoint found'); + exit(1); + } + $cfg->token = (string) $tokens[0]['href']; + + $mps = $sx->xpath( + '/h:html/h:head/h:link[@rel="micropub" and @href]' + ); + if (!count($mps)) { + Log::err('No micropub endpoint found'); + exit(1); + } + $cfg->micropub = (string) $mps[0]['href']; + } + + protected function getHttpServerData() + { + //FIXME: get IP from SSH_CONNECTION + $ip = '127.0.0.1'; + $port = 12345; + $redirect_uri = 'http://' . $ip . ':' . $port . '/callback'; + $socketStr = 'tcp://' . $ip . ':' . $port; + return [$redirect_uri, $socketStr]; + } + + protected function verifyAuthCode($host, $code, $state, $redirect_uri, $me) + { + $req = new \HTTP_Request2($host->endpoints->authorization, 'POST'); + $req->setHeader('Content-Type: application/x-www-form-urlencoded'); + $req->setBody( + http_build_query( + [ + 'code' => $code, + 'state' => $state, + 'client_id' => static::$client_id, + 'redirect_uri' => $redirect_uri, + ] + ) + ); + $res = $req->send(); + if ($res->getHeader('content-type') != 'application/x-www-form-urlencoded') { + Log::err('Wrong content type in auth verification response'); + exit(2); + } + parse_str($res->getBody(), $verifiedParams); + if (!isset($verifiedParams['me']) + || $verifiedParams['me'] !== $me + ) { + Log::err('Non-matching "me" values'); + exit(2); + } + } + + protected function startHttpServer($socketStr) + { + $responseOk = "HTTP/1.0 200 OK\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "Ok. You may close this tab and return to the shell.\r\n"; + $responseErr = "HTTP/1.0 400 Bad Request\r\n" + . "Content-Type: text/plain\r\n" + . "\r\n" + . "Bad Request\r\n"; + + //5 minutes should be enough for the user to confirm + ini_set('default_socket_timeout', 60 * 5); + $server = stream_socket_server($socketStr, $errno, $errstr); + if (!$server) { + Log::err('Error starting HTTP server'); + return false; + } + + do { + $sock = stream_socket_accept($server); + if (!$sock) { + Log::err('Error accepting socket connection'); + exit(1); + } + + $headers = []; + $body = null; + $content_length = 0; + //read request headers + while (false !== ($line = trim(fgets($sock)))) { + if ('' === $line) { + break; + } + $regex = '#^Content-Length:\s*([[:digit:]]+)\s*$#i'; + if (preg_match($regex, $line, $matches)) { + $content_length = (int) $matches[1]; + } + $headers[] = $line; + } + + // read content/body + if ($content_length > 0) { + $body = fread($sock, $content_length); + } + + // send response + list($method, $url, $httpver) = explode(' ', $headers[0]); + if ($method == 'GET') { + $parts = parse_url($url); + if (isset($parts['path']) && $parts['path'] == '/callback' + && isset($parts['query']) + ) { + parse_str($parts['query'], $query); + if (isset($query['code']) + && isset($query['state']) + && isset($query['me']) + ) { + fwrite($sock, $responseOk); + fclose($sock); + return $query; + } + } + } + + fwrite($sock, $responseErr); + fclose($sock); + } while (true); } } ?> diff --git a/src/shpub/Config.php b/src/shpub/Config.php index 5da9c4a..9a23705 100644 --- a/src/shpub/Config.php +++ b/src/shpub/Config.php @@ -58,7 +58,7 @@ class Config } $str .= '[' . $hostName . "]\n"; foreach ($host as $hostProp => $hostVal) { - if ($hostProp == 'cache') { + if ($hostProp == 'endpoints') { continue; } if ($hostVal == '') { diff --git a/src/shpub/Config/HostCache.php b/src/shpub/Config/Endpoints.php similarity index 64% rename from src/shpub/Config/HostCache.php rename to src/shpub/Config/Endpoints.php index 03311c3..c029b95 100644 --- a/src/shpub/Config/HostCache.php +++ b/src/shpub/Config/Endpoints.php @@ -1,7 +1,7 @@ authorization === null + || $this->token === null + || $this->micropub === null; + } } ?> diff --git a/src/shpub/Config/Host.php b/src/shpub/Config/Host.php index 6ef05d9..5dd23dd 100644 --- a/src/shpub/Config/Host.php +++ b/src/shpub/Config/Host.php @@ -25,11 +25,11 @@ class Config_Host public $token; /** - * Host information cache + * Host information * - * @var Config_HostCache + * @var Config_HostEndpoints */ - public $cache; + public $endpoints; /** * If this host is the default one