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

<?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);
}
}