You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

315 lines
13 KiB

4 years ago
4 years ago
4 years ago
  1. <?php
  2. /*
  3. * TokenController.php
  4. * Copyright (c) 2021 james@firefly-iii.org
  5. *
  6. * This file is part of the Firefly III Data Importer
  7. * (https://github.com/firefly-iii/data-importer).
  8. *
  9. * This program is free software: you can redistribute it and/or modify
  10. * it under the terms of the GNU Affero General Public License as
  11. * published by the Free Software Foundation, either version 3 of the
  12. * License, or (at your option) any later version.
  13. *
  14. * This program is distributed in the hope that it will be useful,
  15. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. * GNU Affero General Public License for more details.
  18. *
  19. * You should have received a copy of the GNU Affero General Public License
  20. * along with this program. If not, see <https://www.gnu.org/licenses/>.
  21. */
  22. declare(strict_types=1);
  23. namespace App\Http\Controllers;
  24. use App\Exceptions\ImporterErrorException;
  25. use App\Services\Shared\Authentication\SecretManager;
  26. use GrumpyDictator\FFIIIApiSupport\Exceptions\ApiHttpException;
  27. use GrumpyDictator\FFIIIApiSupport\Request\SystemInformationRequest;
  28. use GuzzleHttp\Client;
  29. use GuzzleHttp\Exception\ClientException;
  30. use GuzzleHttp\Exception\GuzzleException;
  31. use Illuminate\Contracts\Foundation\Application;
  32. use Illuminate\Contracts\View\Factory;
  33. use Illuminate\Http\JsonResponse;
  34. use Illuminate\Http\RedirectResponse;
  35. use Illuminate\Http\Request;
  36. use Illuminate\Routing\Redirector;
  37. use Illuminate\View\View;
  38. use InvalidArgumentException;
  39. use JsonException;
  40. use Log;
  41. use Str;
  42. use Throwable;
  43. /**
  44. * Class TokenController
  45. */
  46. class TokenController extends Controller
  47. {
  48. /**
  49. * Start page where the user will always end up on. Will do either of 3 things:
  50. *
  51. * 1. All info is present. Set some cookies and continue.
  52. * 2. Has client ID + URL. Will send user to Firefly III for permission.
  53. * 3. Has either 1 of those. Will show user some input form.
  54. * @param Request $request
  55. *
  56. * @return Application|Factory|RedirectResponse|Redirector|View
  57. */
  58. public function index(Request $request)
  59. {
  60. $pageTitle = 'Data importer';
  61. Log::debug(sprintf('Now at %s', __METHOD__));
  62. $accessToken = SecretManager::getAccessToken();
  63. $clientId = SecretManager::getClientId();
  64. $baseUrl = SecretManager::getBaseUrl();
  65. $vanityUrl = SecretManager::getVanityUrl();
  66. Log::info('The following configuration information was found:');
  67. Log::info(sprintf('Personal Access Token: "%s" (limited to 25 chars if present)', substr($accessToken, 0, 25)));
  68. Log::info(sprintf('Client ID : "%s"', $clientId));
  69. Log::info(sprintf('Base URL : "%s"', $baseUrl));
  70. Log::info(sprintf('Vanity URL : "%s"', $vanityUrl));
  71. // Option 1: access token and url are present:
  72. if ('' !== $accessToken && '' !== $baseUrl) {
  73. Log::debug(sprintf('Found personal access token + URL "%s" in config, set cookie and return to index.', $baseUrl));
  74. $cookies = [
  75. SecretManager::saveAccessToken($accessToken),
  76. SecretManager::saveBaseUrl($baseUrl),
  77. SecretManager::saveVanityUrl($vanityUrl),
  78. SecretManager::saveRefreshToken(''),
  79. ];
  80. return redirect(route('index'))->withCookies($cookies);
  81. }
  82. // Option 2: client ID + base URL.
  83. if (0 !== $clientId && '' !== $baseUrl) {
  84. Log::debug(sprintf('Found client ID "%d" + URL "%s" in config, redirect to Firefly III for permission.', $clientId, $baseUrl));
  85. return $this->redirectForPermission($request, $baseUrl, $vanityUrl, $clientId);
  86. }
  87. // Option 3: either is empty, ask for client ID and/or base URL:
  88. $clientId = 0 === $clientId ? '' : $clientId;
  89. return view('token.client_id', compact('baseUrl', 'clientId', 'pageTitle'));
  90. }
  91. /**
  92. * This method forwards the user to Firefly III. Some parameters are stored in the user's session.
  93. *
  94. * @param Request $request
  95. * @param string $baseURL
  96. * @param string $vanityURL
  97. * @param int $clientId
  98. *
  99. * @return RedirectResponse
  100. */
  101. private function redirectForPermission(Request $request, string $baseURL, string $vanityURL, int $clientId): RedirectResponse
  102. {
  103. $baseURL = rtrim($baseURL, '/');
  104. $vanityURL = rtrim($vanityURL, '/');
  105. Log::debug(sprintf('Now in %s(request, "%s", "%s", %d)', __METHOD__, $baseURL, $vanityURL, $clientId));
  106. $state = Str::random(40);
  107. $codeVerifier = Str::random(128);
  108. $request->session()->put('state', $state);
  109. $request->session()->put('code_verifier', $codeVerifier);
  110. $request->session()->put('form_client_id', $clientId);
  111. $request->session()->put('form_base_url', $baseURL);
  112. $request->session()->put('form_vanity_url', $vanityURL);
  113. $codeChallenge = strtr(rtrim(base64_encode(hash('sha256', $codeVerifier, true)), '='), '+/', '-_');
  114. $params = [
  115. 'client_id' => $clientId,
  116. 'redirect_uri' => route('token.callback'),
  117. 'response_type' => 'code',
  118. 'scope' => '',
  119. 'state' => $state,
  120. 'code_challenge' => $codeChallenge,
  121. 'code_challenge_method' => 'S256',
  122. ];
  123. $query = http_build_query($params);
  124. // we redirect the user to the vanity URL, which is the same as the base_url, unless the user actually set a vanity URL.
  125. $finalURL = sprintf('%s/oauth/authorize?', $vanityURL);
  126. Log::debug('Query parameters are', $params);
  127. Log::debug(sprintf('Now redirecting to "%s" (params omitted)', $finalURL));
  128. return redirect($finalURL . $query);
  129. }
  130. /**
  131. * User submits the client ID + optionally the base URL.
  132. * Whatever happens, we redirect the user to Firefly III and beg for permission.
  133. *
  134. * @param Request $request
  135. * @return Application|RedirectResponse|Redirector
  136. */
  137. public function submitClientId(Request $request)
  138. {
  139. Log::debug(sprintf('Now at %s', __METHOD__));
  140. $data = $request->validate(
  141. [
  142. 'client_id' => 'required|numeric|min:1|max:65536',
  143. 'base_url' => 'url',
  144. ]
  145. );
  146. Log::debug('Submitted data: ', $data);
  147. if (true === config('importer.expect_secure_url') && array_key_exists('base_url', $data) && !str_starts_with($data['base_url'], 'https://')) {
  148. $request->session()->flash('secure_url', 'URL must start with https://');
  149. return redirect(route('token.index'));
  150. }
  151. $data['client_id'] = (int) $data['client_id'];
  152. // grab base URL from config first, otherwise from submitted data:
  153. $baseURL = config('importer.url');
  154. Log::debug(sprintf('Base URL is "%s"', $baseURL));
  155. $vanityURL = $baseURL;
  156. Log::debug(sprintf('Vanity URL is now "%s"', $vanityURL));
  157. // if the config has a vanity URL it will always overrule.
  158. if ('' !== (string) config('importer.vanity_url')) {
  159. $vanityURL = config('importer.vanity_url');
  160. Log::debug(sprintf('Vanity URL is now "%s"', $vanityURL));
  161. }
  162. // otherwise take base URL from the submitted data:
  163. if (array_key_exists('base_url', $data) && '' !== $data['base_url']) {
  164. $baseURL = $data['base_url'];
  165. Log::debug(sprintf('Base URL is now "%s"', $baseURL));
  166. }
  167. if ('' === (string) $vanityURL) {
  168. $vanityURL = $baseURL;
  169. Log::debug(sprintf('Vanity URL is now "%s"', $vanityURL));
  170. }
  171. // return request for permission:
  172. return $this->redirectForPermission($request, $baseURL, $vanityURL, $data['client_id']);
  173. }
  174. /**
  175. * This method will check if Firefly III accepts the access_token from the cookie
  176. * and the base URL (also from the cookie). The base_url is NEVER the vanity URL.§
  177. *
  178. * @param Request $request
  179. * @return JsonResponse
  180. */
  181. public function doValidate(Request $request): JsonResponse
  182. {
  183. Log::debug(sprintf('Now at %s', __METHOD__));
  184. $response = ['result' => 'OK', 'message' => null];
  185. // get values from secret manager:
  186. $url = SecretManager::getBaseUrl();
  187. $token = SecretManager::getAccessToken();
  188. $request = new SystemInformationRequest($url, $token);
  189. $request->setVerify(config('importer.connection.verify'));
  190. $request->setTimeOut(config('importer.connection.timeout'));
  191. try {
  192. $result = $request->get();
  193. } catch (ApiHttpException $e) {
  194. return response()->json(['result' => 'NOK', 'message' => $e->getMessage()]);
  195. }
  196. // -1 = OK (minimum is smaller)
  197. // 0 = OK (same version)
  198. // 1 = NOK (too low a version)
  199. $minimum = (string) config('importer.minimum_version');
  200. $compare = version_compare($minimum, $result->version);
  201. if (1 === $compare) {
  202. $errorMessage = sprintf(
  203. 'Your Firefly III version %s is below the minimum required version %s',
  204. $result->version, $minimum
  205. );
  206. $response = ['result' => 'NOK', 'message' => $errorMessage];
  207. }
  208. return response()->json($response);
  209. }
  210. /**
  211. * The user ends up here when they come back from Firefly III.
  212. *
  213. * @param Request $request
  214. * @return Application|Factory|\Illuminate\Contracts\View\View|RedirectResponse|Redirector
  215. * @throws ImporterErrorException
  216. * @throws GuzzleException
  217. * @throws Throwable
  218. */
  219. public function callback(Request $request)
  220. {
  221. Log::debug(sprintf('Now at %s', __METHOD__));
  222. $state = (string) $request->session()->pull('state');
  223. $codeVerifier = (string) $request->session()->pull('code_verifier');
  224. $clientId = (int) $request->session()->pull('form_client_id');
  225. $baseURL = (string) $request->session()->pull('form_base_url');
  226. $vanityURL = (string) $request->session()->pull('form_vanity_url');
  227. $code = $request->get('code');
  228. throw_unless(
  229. strlen($state) > 0 && $state === $request->state,
  230. InvalidArgumentException::class
  231. );
  232. // always POST to the base URL, never the vanity URL.
  233. $finalURL = sprintf('%s/oauth/token', $baseURL);
  234. $params = [
  235. 'form_params' => [
  236. 'grant_type' => 'authorization_code',
  237. 'client_id' => $clientId,
  238. 'redirect_uri' => route('token.callback'),
  239. 'code_verifier' => $codeVerifier,
  240. 'code' => $code,
  241. ],
  242. ];
  243. Log::debug('State is valid!');
  244. Log::debug('Params for access token', $params);
  245. Log::debug(sprintf('Will contact "%s" for a token.', $finalURL));
  246. $opts = [
  247. 'verify' => config('importer.connection.verify'),
  248. 'connect_timeout' => config('importer.connection.timeout'),
  249. ];
  250. try {
  251. $response = (new Client($opts))->post($finalURL, $params);
  252. } catch (ClientException $e) {
  253. $body = (string) $e->getResponse()->getBody();
  254. Log::error(sprintf('Client exception when decoding response: %s', $e->getMessage()));
  255. Log::error(sprintf('Response from server: "%s"', $body));
  256. Log::error($e->getTraceAsString());
  257. return view('error')->with('message', $e->getMessage())->with('body', $body);
  258. }
  259. try {
  260. $data = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
  261. } catch (JsonException $e) {
  262. Log::error(sprintf('JSON exception when decoding response: %s', $e->getMessage()));
  263. Log::error(sprintf('Response from server: "%s"', (string) $response->getBody()));
  264. Log::error($e->getTraceAsString());
  265. throw new ImporterErrorException(sprintf('JSON exception when decoding response: %s', $e->getMessage()));
  266. }
  267. Log::debug('Response', $data);
  268. // set cookies.
  269. $cookies = [
  270. SecretManager::saveAccessToken((string) $data['access_token']),
  271. SecretManager::saveBaseUrl($baseURL),
  272. SecretManager::saveVanityUrl($vanityURL),
  273. SecretManager::saveRefreshToken((string) $data['refresh_token']),
  274. ];
  275. Log::debug(sprintf('Return redirect with cookies to "%s"', route('index')));
  276. return redirect(route('index'))->withCookies($cookies);
  277. }
  278. }