2b1cfc15efe4d3069bd5feb5492c816fce04a3bc
[shpub.git] / src / shpub / Command / Connect.php
1 <?php
2 namespace shpub;
3
4 /**
5  * @link http://micropub.net/draft/
6  * @link http://indieweb.org/authorization-endpoint
7  */
8 class Command_Connect
9 {
10     public static $client_id = 'http://cweiske.de/shpub.htm';
11
12     public function __construct(Config $cfg)
13     {
14         $this->cfg = $cfg;
15     }
16
17     public static function opts(\Console_CommandLine $optParser)
18     {
19         $cmd = $optParser->addCommand('connect');
20         $cmd->addOption(
21             'force',
22             array(
23                 'short_name'  => '-f',
24                 'long_name'   => '--force-update',
25                 'description' => 'Force token update if token already available',
26                 'action'      => 'StoreTrue',
27                 'default'     => false,
28             )
29         );
30         $cmd->addArgument(
31             'server',
32             [
33                 'optional'    => false,
34                 'description' => 'Server URL',
35             ]
36         );
37         $cmd->addArgument(
38             'user',
39             [
40                 'optional'    => true,
41                 'description' => 'User URL',
42             ]
43         );
44         $cmd->addArgument(
45             'key',
46             [
47                 'optional'    => true,
48                 'description' => 'Short name (key)',
49             ]
50         );
51     }
52
53     public function run($server, $user, $newKey, $force)
54     {
55         $server = Validator::url($server, 'server');
56         if ($user === null) {
57             //indieweb: homepage is your identity
58             $user = $server;
59         } else {
60             $user = Validator::url($user, 'user');
61         }
62
63         $host = $this->getHost($newKey != '' ? $newKey : $server, $force);
64         if ($host === null) {
65             //already taken
66             return;
67         }
68         if ($host->endpoints->incomplete()) {
69             $host->server = $server;
70             $host->loadEndpoints();
71         }
72
73         list($redirect_uri, $socketStr) = $this->getHttpServerData();
74         $state = time();
75         Log::msg(
76             "To authenticate, open the following URL:\n"
77             . $this->getBrowserAuthUrl($host, $user, $redirect_uri, $state)
78         );
79
80         $authParams = $this->startHttpServer($socketStr);
81         if ($authParams['state'] != $state) {
82             Log::err('Wrong "state" parameter value: ' . $authParams['state']);
83             exit(2);
84         }
85         $code    = $authParams['code'];
86         $userUrl = $authParams['me'];
87
88         $accessToken = $this->fetchAccessToken(
89             $host, $userUrl, $code, $redirect_uri, $state
90         );
91
92         //all fine. update config
93         $host->user  = $userUrl;
94         $host->token = $accessToken;
95
96         if ($newKey != '') {
97             $hostKey = $newKey;
98         } else {
99             $hostKey = $this->cfg->getHostByName($server);
100             if ($hostKey === null) {
101                 $keyBase = parse_url($host->server, PHP_URL_HOST);
102                 $newKey  = $keyBase;
103                 $count = 0;
104                 while (isset($this->cfg->hosts[$newKey])) {
105                     $newKey = $keyBase . ++$count;
106                 }
107                 $hostKey = $newKey;
108             }
109         }
110         $this->cfg->hosts[$hostKey] = $host;
111         $this->cfg->save();
112         Log::info("Server configuration $hostKey saved successfully.");
113     }
114
115     protected function fetchAccessToken(
116         $host, $userUrl, $code, $redirect_uri, $state
117     ) {
118         $req = new \HTTP_Request2($host->endpoints->token, 'POST');
119         if (version_compare(PHP_VERSION, '5.6.0', '<')) {
120             //correct ssl validation on php 5.5 is a pain, so disable
121             $req->setConfig('ssl_verify_host', false);
122             $req->setConfig('ssl_verify_peer', false);
123         }
124         $req->setHeader('Content-Type: application/x-www-form-urlencoded');
125         $req->setBody(
126             http_build_query(
127                 [
128                     'me'           => $userUrl,
129                     'code'         => $code,
130                     'redirect_uri' => $redirect_uri,
131                     'client_id'    => static::$client_id,
132                     'state'        => $state,
133                 ]
134             )
135         );
136         $res = $req->send();
137         if (intval($res->getStatus() / 100) !== 2) {
138             Log::err('Failed to fetch access token');
139             Log::err('Server responded with HTTP status code ' . $res->getStatus());
140             Log::err($res->getBody());
141             exit(2);
142         }
143         if ($res->getHeader('content-type') != 'application/x-www-form-urlencoded') {
144             Log::err('Wrong content type in auth verification response');
145             exit(2);
146         }
147         parse_str($res->getBody(), $tokenParams);
148         if (!isset($tokenParams['access_token'])) {
149             Log::err('"access_token" missing');
150             exit(2);
151         }
152
153         $accessToken = $tokenParams['access_token'];
154         return $accessToken;
155     }
156
157     protected function getBrowserAuthUrl($host, $user, $redirect_uri, $state)
158     {
159         return $host->endpoints->authorization
160             . '?me=' . urlencode($user)
161             . '&client_id=' . urlencode(static::$client_id)
162             . '&redirect_uri=' . urlencode($redirect_uri)
163             . '&state=' . $state
164             . '&scope=post'
165             . '&response_type=code';
166     }
167
168     protected function getHost($keyOrServer, $force)
169     {
170         $host = new Config_Host();
171         $key = $this->cfg->getHostByName($keyOrServer);
172         if ($key !== null) {
173             $host = $this->cfg->hosts[$key];
174             if (!$force && $host->token != '') {
175                 Log::err('Token already available');
176                 return;
177             }
178         }
179         return $host;
180     }
181
182     protected function getHttpServerData()
183     {
184         $ip   = '127.0.0.1';
185         $port = 12345;
186
187         if (isset($_SERVER['SSH_CONNECTION'])) {
188             $parts = explode(' ', $_SERVER['SSH_CONNECTION']);
189             if (count($parts) >= 3) {
190                 $ip = $parts[2];
191             }
192         }
193         if (strpos($ip, ':') !== false) {
194             //ipv6
195             $ip = '[' . $ip . ']';
196         }
197
198         $redirect_uri = 'http://' . $ip . ':' . $port . '/callback';
199         $socketStr    = 'tcp://' . $ip . ':' . $port;
200         return [$redirect_uri, $socketStr];
201     }
202
203     protected function startHttpServer($socketStr)
204     {
205         $responseOk = "HTTP/1.0 200 OK\r\n"
206             . "Content-Type: text/plain\r\n"
207             . "\r\n"
208             . "Ok. You may close this tab and return to the shell.\r\n";
209         $responseErr = "HTTP/1.0 400 Bad Request\r\n"
210             . "Content-Type: text/plain\r\n"
211             . "\r\n"
212             . "Bad Request\r\n";
213
214         //5 minutes should be enough for the user to confirm
215         ini_set('default_socket_timeout', 60 * 5);
216         $server = stream_socket_server($socketStr, $errno, $errstr);
217         if (!$server) {
218             Log::err('Error starting HTTP server');
219             return false;
220         }
221
222         do {
223             $sock = stream_socket_accept($server);
224             if (!$sock) {
225                 Log::err('Error accepting socket connection');
226                 exit(1);
227             }
228
229             $headers = [];
230             $body    = null;
231             $content_length = 0;
232             //read request headers
233             while (false !== ($line = trim(fgets($sock)))) {
234                 if ('' === $line) {
235                     break;
236                 }
237                 $regex = '#^Content-Length:\s*([[:digit:]]+)\s*$#i';
238                 if (preg_match($regex, $line, $matches)) {
239                     $content_length = (int) $matches[1];
240                 }
241                 $headers[] = $line;
242             }
243
244             // read content/body
245             if ($content_length > 0) {
246                 $body = fread($sock, $content_length);
247             }
248
249             // send response
250             list($method, $url, $httpver) = explode(' ', $headers[0]);
251             if ($method == 'GET') {
252                 $parts = parse_url($url);
253                 if (isset($parts['path']) && $parts['path'] == '/callback'
254                     && isset($parts['query'])
255                 ) {
256                     parse_str($parts['query'], $query);
257                     if (isset($query['code'])
258                         && isset($query['state'])
259                         && isset($query['me'])
260                     ) {
261                         fwrite($sock, $responseOk);
262                         fclose($sock);
263                         return $query;
264                     }
265                 }
266             }
267
268             fwrite($sock, $responseErr);
269             fclose($sock);
270         } while (true);
271     }
272 }
273 ?>