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
315 lines
13 KiB
<?php
|
|
/*
|
|
* TokenController.php
|
|
* Copyright (c) 2021 james@firefly-iii.org
|
|
*
|
|
* This file is part of the Firefly III Data Importer
|
|
* (https://github.com/firefly-iii/data-importer).
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as
|
|
* published by the Free Software Foundation, either version 3 of the
|
|
* License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Exceptions\ImporterErrorException;
|
|
use App\Services\Shared\Authentication\SecretManager;
|
|
use GrumpyDictator\FFIIIApiSupport\Exceptions\ApiHttpException;
|
|
use GrumpyDictator\FFIIIApiSupport\Request\SystemInformationRequest;
|
|
use GuzzleHttp\Client;
|
|
use GuzzleHttp\Exception\ClientException;
|
|
use GuzzleHttp\Exception\GuzzleException;
|
|
use Illuminate\Contracts\Foundation\Application;
|
|
use Illuminate\Contracts\View\Factory;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Routing\Redirector;
|
|
use Illuminate\View\View;
|
|
use InvalidArgumentException;
|
|
use JsonException;
|
|
use Log;
|
|
use Str;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Class TokenController
|
|
*/
|
|
class TokenController extends Controller
|
|
{
|
|
/**
|
|
* Start page where the user will always end up on. Will do either of 3 things:
|
|
*
|
|
* 1. All info is present. Set some cookies and continue.
|
|
* 2. Has client ID + URL. Will send user to Firefly III for permission.
|
|
* 3. Has either 1 of those. Will show user some input form.
|
|
* @param Request $request
|
|
*
|
|
* @return Application|Factory|RedirectResponse|Redirector|View
|
|
*/
|
|
public function index(Request $request)
|
|
{
|
|
$pageTitle = 'Data importer';
|
|
Log::debug(sprintf('Now at %s', __METHOD__));
|
|
|
|
$accessToken = SecretManager::getAccessToken();
|
|
$clientId = SecretManager::getClientId();
|
|
$baseUrl = SecretManager::getBaseUrl();
|
|
$vanityUrl = SecretManager::getVanityUrl();
|
|
|
|
Log::info('The following configuration information was found:');
|
|
Log::info(sprintf('Personal Access Token: "%s" (limited to 25 chars if present)', substr($accessToken, 0, 25)));
|
|
Log::info(sprintf('Client ID : "%s"', $clientId));
|
|
Log::info(sprintf('Base URL : "%s"', $baseUrl));
|
|
Log::info(sprintf('Vanity URL : "%s"', $vanityUrl));
|
|
|
|
// Option 1: access token and url are present:
|
|
if ('' !== $accessToken && '' !== $baseUrl) {
|
|
Log::debug(sprintf('Found personal access token + URL "%s" in config, set cookie and return to index.', $baseUrl));
|
|
|
|
$cookies = [
|
|
SecretManager::saveAccessToken($accessToken),
|
|
SecretManager::saveBaseUrl($baseUrl),
|
|
SecretManager::saveVanityUrl($vanityUrl),
|
|
SecretManager::saveRefreshToken(''),
|
|
];
|
|
return redirect(route('index'))->withCookies($cookies);
|
|
}
|
|
|
|
// Option 2: client ID + base URL.
|
|
if (0 !== $clientId && '' !== $baseUrl) {
|
|
Log::debug(sprintf('Found client ID "%d" + URL "%s" in config, redirect to Firefly III for permission.', $clientId, $baseUrl));
|
|
return $this->redirectForPermission($request, $baseUrl, $vanityUrl, $clientId);
|
|
}
|
|
|
|
// Option 3: either is empty, ask for client ID and/or base URL:
|
|
$clientId = 0 === $clientId ? '' : $clientId;
|
|
|
|
return view('token.client_id', compact('baseUrl', 'clientId', 'pageTitle'));
|
|
}
|
|
|
|
/**
|
|
* This method forwards the user to Firefly III. Some parameters are stored in the user's session.
|
|
*
|
|
* @param Request $request
|
|
* @param string $baseURL
|
|
* @param string $vanityURL
|
|
* @param int $clientId
|
|
*
|
|
* @return RedirectResponse
|
|
*/
|
|
private function redirectForPermission(Request $request, string $baseURL, string $vanityURL, int $clientId): RedirectResponse
|
|
{
|
|
$baseURL = rtrim($baseURL, '/');
|
|
$vanityURL = rtrim($vanityURL, '/');
|
|
|
|
|
|
Log::debug(sprintf('Now in %s(request, "%s", "%s", %d)', __METHOD__, $baseURL, $vanityURL, $clientId));
|
|
$state = Str::random(40);
|
|
$codeVerifier = Str::random(128);
|
|
$request->session()->put('state', $state);
|
|
$request->session()->put('code_verifier', $codeVerifier);
|
|
$request->session()->put('form_client_id', $clientId);
|
|
$request->session()->put('form_base_url', $baseURL);
|
|
$request->session()->put('form_vanity_url', $vanityURL);
|
|
|
|
$codeChallenge = strtr(rtrim(base64_encode(hash('sha256', $codeVerifier, true)), '='), '+/', '-_');
|
|
$params = [
|
|
'client_id' => $clientId,
|
|
'redirect_uri' => route('token.callback'),
|
|
'response_type' => 'code',
|
|
'scope' => '',
|
|
'state' => $state,
|
|
'code_challenge' => $codeChallenge,
|
|
'code_challenge_method' => 'S256',
|
|
];
|
|
$query = http_build_query($params);
|
|
// we redirect the user to the vanity URL, which is the same as the base_url, unless the user actually set a vanity URL.
|
|
$finalURL = sprintf('%s/oauth/authorize?', $vanityURL);
|
|
Log::debug('Query parameters are', $params);
|
|
Log::debug(sprintf('Now redirecting to "%s" (params omitted)', $finalURL));
|
|
|
|
return redirect($finalURL . $query);
|
|
}
|
|
|
|
/**
|
|
* User submits the client ID + optionally the base URL.
|
|
* Whatever happens, we redirect the user to Firefly III and beg for permission.
|
|
*
|
|
* @param Request $request
|
|
* @return Application|RedirectResponse|Redirector
|
|
*/
|
|
public function submitClientId(Request $request)
|
|
{
|
|
Log::debug(sprintf('Now at %s', __METHOD__));
|
|
$data = $request->validate(
|
|
[
|
|
'client_id' => 'required|numeric|min:1|max:65536',
|
|
'base_url' => 'url',
|
|
]
|
|
);
|
|
Log::debug('Submitted data: ', $data);
|
|
|
|
if (true === config('importer.expect_secure_url') && array_key_exists('base_url', $data) && !str_starts_with($data['base_url'], 'https://')) {
|
|
$request->session()->flash('secure_url', 'URL must start with https://');
|
|
|
|
return redirect(route('token.index'));
|
|
}
|
|
|
|
$data['client_id'] = (int) $data['client_id'];
|
|
|
|
// grab base URL from config first, otherwise from submitted data:
|
|
$baseURL = config('importer.url');
|
|
Log::debug(sprintf('Base URL is "%s"', $baseURL));
|
|
$vanityURL = $baseURL;
|
|
|
|
Log::debug(sprintf('Vanity URL is now "%s"', $vanityURL));
|
|
|
|
// if the config has a vanity URL it will always overrule.
|
|
if ('' !== (string) config('importer.vanity_url')) {
|
|
$vanityURL = config('importer.vanity_url');
|
|
Log::debug(sprintf('Vanity URL is now "%s"', $vanityURL));
|
|
}
|
|
|
|
// otherwise take base URL from the submitted data:
|
|
if (array_key_exists('base_url', $data) && '' !== $data['base_url']) {
|
|
$baseURL = $data['base_url'];
|
|
Log::debug(sprintf('Base URL is now "%s"', $baseURL));
|
|
}
|
|
if ('' === (string) $vanityURL) {
|
|
$vanityURL = $baseURL;
|
|
Log::debug(sprintf('Vanity URL is now "%s"', $vanityURL));
|
|
}
|
|
|
|
// return request for permission:
|
|
return $this->redirectForPermission($request, $baseURL, $vanityURL, $data['client_id']);
|
|
}
|
|
|
|
/**
|
|
* This method will check if Firefly III accepts the access_token from the cookie
|
|
* and the base URL (also from the cookie). The base_url is NEVER the vanity URL.§
|
|
*
|
|
* @param Request $request
|
|
* @return JsonResponse
|
|
*/
|
|
public function doValidate(Request $request): JsonResponse
|
|
{
|
|
Log::debug(sprintf('Now at %s', __METHOD__));
|
|
$response = ['result' => 'OK', 'message' => null];
|
|
|
|
// get values from secret manager:
|
|
$url = SecretManager::getBaseUrl();
|
|
$token = SecretManager::getAccessToken();
|
|
$request = new SystemInformationRequest($url, $token);
|
|
|
|
$request->setVerify(config('importer.connection.verify'));
|
|
$request->setTimeOut(config('importer.connection.timeout'));
|
|
|
|
try {
|
|
$result = $request->get();
|
|
} catch (ApiHttpException $e) {
|
|
return response()->json(['result' => 'NOK', 'message' => $e->getMessage()]);
|
|
}
|
|
// -1 = OK (minimum is smaller)
|
|
// 0 = OK (same version)
|
|
// 1 = NOK (too low a version)
|
|
|
|
$minimum = (string) config('importer.minimum_version');
|
|
$compare = version_compare($minimum, $result->version);
|
|
if (1 === $compare) {
|
|
$errorMessage = sprintf(
|
|
'Your Firefly III version %s is below the minimum required version %s',
|
|
$result->version, $minimum
|
|
);
|
|
$response = ['result' => 'NOK', 'message' => $errorMessage];
|
|
}
|
|
|
|
return response()->json($response);
|
|
}
|
|
|
|
/**
|
|
* The user ends up here when they come back from Firefly III.
|
|
*
|
|
* @param Request $request
|
|
* @return Application|Factory|\Illuminate\Contracts\View\View|RedirectResponse|Redirector
|
|
* @throws ImporterErrorException
|
|
* @throws GuzzleException
|
|
* @throws Throwable
|
|
*/
|
|
public function callback(Request $request)
|
|
{
|
|
Log::debug(sprintf('Now at %s', __METHOD__));
|
|
$state = (string) $request->session()->pull('state');
|
|
$codeVerifier = (string) $request->session()->pull('code_verifier');
|
|
$clientId = (int) $request->session()->pull('form_client_id');
|
|
$baseURL = (string) $request->session()->pull('form_base_url');
|
|
$vanityURL = (string) $request->session()->pull('form_vanity_url');
|
|
$code = $request->get('code');
|
|
|
|
throw_unless(
|
|
strlen($state) > 0 && $state === $request->state,
|
|
InvalidArgumentException::class
|
|
);
|
|
// always POST to the base URL, never the vanity URL.
|
|
$finalURL = sprintf('%s/oauth/token', $baseURL);
|
|
$params = [
|
|
'form_params' => [
|
|
'grant_type' => 'authorization_code',
|
|
'client_id' => $clientId,
|
|
'redirect_uri' => route('token.callback'),
|
|
'code_verifier' => $codeVerifier,
|
|
'code' => $code,
|
|
],
|
|
];
|
|
Log::debug('State is valid!');
|
|
Log::debug('Params for access token', $params);
|
|
Log::debug(sprintf('Will contact "%s" for a token.', $finalURL));
|
|
|
|
$opts = [
|
|
'verify' => config('importer.connection.verify'),
|
|
'connect_timeout' => config('importer.connection.timeout'),
|
|
];
|
|
try {
|
|
$response = (new Client($opts))->post($finalURL, $params);
|
|
} catch (ClientException $e) {
|
|
$body = (string) $e->getResponse()->getBody();
|
|
Log::error(sprintf('Client exception when decoding response: %s', $e->getMessage()));
|
|
Log::error(sprintf('Response from server: "%s"', $body));
|
|
Log::error($e->getTraceAsString());
|
|
return view('error')->with('message', $e->getMessage())->with('body', $body);
|
|
}
|
|
|
|
try {
|
|
$data = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (JsonException $e) {
|
|
Log::error(sprintf('JSON exception when decoding response: %s', $e->getMessage()));
|
|
Log::error(sprintf('Response from server: "%s"', (string) $response->getBody()));
|
|
Log::error($e->getTraceAsString());
|
|
throw new ImporterErrorException(sprintf('JSON exception when decoding response: %s', $e->getMessage()));
|
|
}
|
|
Log::debug('Response', $data);
|
|
|
|
// set cookies.
|
|
$cookies = [
|
|
SecretManager::saveAccessToken((string) $data['access_token']),
|
|
SecretManager::saveBaseUrl($baseURL),
|
|
SecretManager::saveVanityUrl($vanityURL),
|
|
SecretManager::saveRefreshToken((string) $data['refresh_token']),
|
|
];
|
|
Log::debug(sprintf('Return redirect with cookies to "%s"', route('index')));
|
|
|
|
return redirect(route('index'))->withCookies($cookies);
|
|
}
|
|
}
|