From 9f26757e8a7b50ce7a083ba30a8080117c473d8f Mon Sep 17 00:00:00 2001 From: James Cole Date: Mon, 14 May 2018 20:21:00 +0200 Subject: [PATCH] First code for Spectre login and import routine. --- .../Import/JobStatusController.php | 2 +- .../Prerequisites/SpectrePrerequisites.php | 82 ++++++++++- app/Import/Routine/SpectreRoutine.php | 79 ++++++---- app/Services/Spectre/Object/Token.php | 12 ++ .../Routine/Spectre/StageNewHandler.php | 135 ++++++++++++++++++ config/import.php | 3 +- resources/lang/en_US/import.php | 2 + .../views/import/spectre/prerequisites.twig | 6 +- 8 files changed, 286 insertions(+), 35 deletions(-) create mode 100644 app/Support/Import/Routine/Spectre/StageNewHandler.php diff --git a/app/Http/Controllers/Import/JobStatusController.php b/app/Http/Controllers/Import/JobStatusController.php index 081e59591e..54fa516f05 100644 --- a/app/Http/Controllers/Import/JobStatusController.php +++ b/app/Http/Controllers/Import/JobStatusController.php @@ -120,7 +120,7 @@ class JobStatusController extends Controller Log::error('Job is not ready.'); $this->repository->setStatus($importJob, 'error'); - return response()->json(['status' => 'NOK', 'message' => 'JobStatusController::start expects status "ready_to_run".']); + return response()->json(['status' => 'NOK', 'message' => sprintf('JobStatusController::start expects status "ready_to_run" instead of "%s".', $importJob->status)]); } $importProvider = $importJob->provider; $key = sprintf('import.routine.%s', $importProvider); diff --git a/app/Import/Prerequisites/SpectrePrerequisites.php b/app/Import/Prerequisites/SpectrePrerequisites.php index 719e176603..cc2ec4c9c1 100644 --- a/app/Import/Prerequisites/SpectrePrerequisites.php +++ b/app/Import/Prerequisites/SpectrePrerequisites.php @@ -25,6 +25,7 @@ namespace FireflyIII\Import\Prerequisites; use FireflyIII\Models\Preference; use FireflyIII\User; use Illuminate\Support\MessageBag; +use Log; /** * This class contains all the routines necessary to connect to Spectre. @@ -204,16 +205,15 @@ class SpectrePrerequisites implements PrerequisitesInterface /** @var Preference $appIdPreference */ $appIdPreference = app('preferences')->getForUser($this->user, 'spectre_app_id', null); $appId = null === $appIdPreference ? '' : $appIdPreference->data; - /** @var Preference $secretPreference */ $secretPreference = app('preferences')->getForUser($this->user, 'spectre_secret', null); $secret = null === $secretPreference ? '' : $secretPreference->data; - - + $publicKey = $this->getPublicKey(); return [ - 'app_id' => $appId, - 'secret' => $secret, + 'app_id' => $appId, + 'secret' => $secret, + 'public_key' => $publicKey, ]; } @@ -224,7 +224,7 @@ class SpectrePrerequisites implements PrerequisitesInterface */ public function isComplete(): bool { - return false; + return $this->hasAppId() && $this->hasSecret(); } /** @@ -248,9 +248,63 @@ class SpectrePrerequisites implements PrerequisitesInterface */ public function storePrerequisites(array $data): MessageBag { + Log::debug('Storing Spectre API keys..'); + app('preferences')->setForUser($this->user, 'spectre_app_id',$data['app_id'] ?? null); + app('preferences')->setForUser($this->user, 'spectre_secret', $data['secret'] ?? null); + Log::debug('Done!'); + return new MessageBag; } + /** + * This method creates a new public/private keypair for the user. This isn't really secure, since the key is generated on the fly with + * no regards for HSM's, smart cards or other things. It would require some low level programming to get this right. But the private key + * is stored encrypted in the database so it's something. + */ + private function createKeyPair(): void + { + Log::debug('Generate new Spectre key pair for user.'); + $keyConfig = [ + 'digest_alg' => 'sha512', + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]; + // Create the private and public key + $res = openssl_pkey_new($keyConfig); + + // Extract the private key from $res to $privKey + $privKey = ''; + openssl_pkey_export($res, $privKey); + + // Extract the public key from $res to $pubKey + $pubKey = openssl_pkey_get_details($res); + + app('preferences')->setForUser($this->user, 'spectre_private_key', $privKey); + app('preferences')->setForUser($this->user, 'spectre_public_key', $pubKey['key']); + Log::debug('Created key pair'); + + } + + /** + * Get a public key from the users preferences. + * + * @return string + */ + private function getPublicKey(): string + { + Log::debug('get public key'); + $preference = app('preferences')->getForUser($this->user, 'spectre_public_key', null); + if (null === $preference) { + Log::debug('public key is null'); + // create key pair + $this->createKeyPair(); + } + $preference = app('preferences')->getForUser($this->user, 'spectre_public_key', null); + Log::debug('Return public key for user'); + + return $preference->data; + } + /** * @return bool */ @@ -266,4 +320,20 @@ class SpectrePrerequisites implements PrerequisitesInterface return true; } + + /** + * @return bool + */ + private function hasSecret(): bool + { + $secret = app('preferences')->getForUser($this->user, 'spectre_secret', null); + if (null === $secret) { + return false; + } + if ('' === (string)$secret->data) { + return false; + } + + return true; + } } diff --git a/app/Import/Routine/SpectreRoutine.php b/app/Import/Routine/SpectreRoutine.php index 710ceba367..616dd0a1ab 100644 --- a/app/Import/Routine/SpectreRoutine.php +++ b/app/Import/Routine/SpectreRoutine.php @@ -24,14 +24,68 @@ namespace FireflyIII\Import\Routine; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\ImportJob; +use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; +use FireflyIII\Support\Import\Routine\Spectre\StageNewHandler; /** - * @deprecated * @codeCoverageIgnore * Class FileRoutine */ class SpectreRoutine implements RoutineInterface { + + /** @var ImportJob */ + private $importJob; + + /** @var ImportJobRepositoryInterface */ + private $repository; + + /** + * At the end of each run(), the import routine must set the job to the expected status. + * + * The final status of the routine must be "provider_finished". + * + * Spectre: + * Stage new: + * - StageNewHandler + * + * @return bool + * @throws FireflyException + */ + public function run(): void + { + if ($this->importJob->status === 'ready_to_run') { + + switch ($this->importJob->stage) { + default: + throw new FireflyException(sprintf('SpectreRoutine cannot handle stage "%s".', $this->importJob->stage)); + case 'new': + /** @var StageNewHandler $handler */ + $handler = app(StageNewHandler::class); + $handler->setImportJob($this->importJob); + $handler->run(); + $this->repository->setStage($this->importJob, 'authenticate'); + break; + } + } + + + } + + /** + * @param ImportJob $importJob + * + * @return void + */ + public function setImportJob(ImportJob $importJob): void + { + $this->importJob = $importJob; + $this->repository = app(ImportJobRepositoryInterface::class); + $this->repository->setUser($importJob->user); + } + + + // /** @var Collection */ // public $errors; // /** @var Collection */ @@ -570,28 +624,5 @@ class SpectreRoutine implements RoutineInterface // { // $this->repository->setStatus($this->job, $status); // } - /** - * At the end of each run(), the import routine must set the job to the expected status. - * - * The final status of the routine must be "provider_finished". - * - * @return bool - * @throws FireflyException - */ - public function run(): void - { - // TODO: Implement run() method. - throw new NotImplementedException; - } - /** - * @param ImportJob $importJob - * - * @return void - */ - public function setImportJob(ImportJob $importJob): void - { - // TODO: Implement setImportJob() method. - throw new NotImplementedException; - } } diff --git a/app/Services/Spectre/Object/Token.php b/app/Services/Spectre/Object/Token.php index 28b5494c6c..da0e3314ac 100644 --- a/app/Services/Spectre/Object/Token.php +++ b/app/Services/Spectre/Object/Token.php @@ -73,4 +73,16 @@ class Token extends SpectreObject return $this->token; } + /** + * + */ + public function toArray(): array + { + return [ + 'connect_url' => $this->connectUrl, + 'expires_at' => $this->expiresAt->toW3cString(), + 'token' => $this->token, + ]; + } + } diff --git a/app/Support/Import/Routine/Spectre/StageNewHandler.php b/app/Support/Import/Routine/Spectre/StageNewHandler.php new file mode 100644 index 0000000000..823354632b --- /dev/null +++ b/app/Support/Import/Routine/Spectre/StageNewHandler.php @@ -0,0 +1,135 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Import\Routine\Spectre; + +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\ImportJob; +use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; +use FireflyIII\Services\Spectre\Object\Customer; +use FireflyIII\Services\Spectre\Object\Token; +use FireflyIII\Services\Spectre\Request\CreateTokenRequest; +use FireflyIII\Services\Spectre\Request\ListCustomersRequest; +use FireflyIII\Services\Spectre\Request\NewCustomerRequest; +use Log; + +/** + * Class StageNewHandler + * + * @package FireflyIII\Support\Import\Routine\Spectre + */ +class StageNewHandler +{ + /** @var ImportJob */ + private $importJob; + + /** @var ImportJobRepositoryInterface */ + private $repository; + + /** + * Tasks for this stage: + * + * - Get the user's customer from Spectre. + * - Create a new customer if it does not exist. + * - Store it in the job either way. + * - Use it to grab a token. + * - Store the token in the job. + * + * @throws FireflyException + */ + public function run(): void + { + $customer = $this->getCustomer(); + // get token using customer. + $token = $this->getToken($customer); + + app('preferences')->setForUser($this->importJob->user, 'spectre_customer', $customer->toArray()); + app('preferences')->setForUser($this->importJob->user, 'spectre_token', $token->toArray()); + } + + /** + * @param ImportJob $importJob + */ + public function setImportJob(ImportJob $importJob): void + { + $this->importJob = $importJob; + $this->repository = app(ImportJobRepositoryInterface::class); + $this->repository->setUser($importJob->user); + } + + /** + * @return Customer + * @throws FireflyException + */ + private function getCustomer(): Customer + { + $customer = $this->getExistingCustomer(); + if (null === $customer) { + $newCustomerRequest = new NewCustomerRequest($this->importJob->user); + $customer = $newCustomerRequest->getCustomer(); + + } + + return $customer; + } + + /** + * @return Customer|null + * @throws FireflyException + */ + private function getExistingCustomer(): ?Customer + { + $customer = null; + $getCustomerRequest = new ListCustomersRequest($this->importJob->user); + $getCustomerRequest->call(); + $customers = $getCustomerRequest->getCustomers(); + /** @var Customer $current */ + foreach ($customers as $current) { + if ('default_ff3_customer' === $current->getIdentifier()) { + $customer = $current; + break; + } + } + + return $customer; + } + + /** + * @param Customer $customer + * + * @throws FireflyException + * @return Token + */ + private function getToken(Customer $customer): Token + { + $request = new CreateTokenRequest($this->importJob->user); + $request->setUri(route('import.job.status.index', [$this->importJob->key])); + $request->setCustomer($customer); + $request->call(); + Log::debug('Call to get token is finished'); + + return $request->getToken(); + } + + +} \ No newline at end of file diff --git a/config/import.php b/config/import.php index 1f1dd6e8d0..010f012a1b 100644 --- a/config/import.php +++ b/config/import.php @@ -28,6 +28,7 @@ use FireflyIII\Import\Prerequisites\FakePrerequisites; use FireflyIII\Import\Prerequisites\SpectrePrerequisites; use FireflyIII\Import\Routine\FakeRoutine; use FireflyIII\Import\Routine\FileRoutine; +use FireflyIII\Import\Routine\SpectreRoutine; use FireflyIII\Support\Import\Routine\File\CSVProcessor; return [ @@ -106,7 +107,7 @@ return [ 'fake' => FakeRoutine::class, 'file' => FileRoutine::class, 'bunq' => false, - 'spectre' => false, + 'spectre' => SpectreRoutine::class, 'plaid' => false, 'quovo' => false, 'yodlee' => false, diff --git a/resources/lang/en_US/import.php b/resources/lang/en_US/import.php index de5e397c0c..0a9b3cc20b 100644 --- a/resources/lang/en_US/import.php +++ b/resources/lang/en_US/import.php @@ -26,6 +26,7 @@ return [ // ALL breadcrumbs and subtitles: 'index_breadcrumb' => 'Import data into Firefly III', 'prerequisites_breadcrumb_fake' => 'Prerequisites for the fake import provider', + 'prerequisites_breadcrumb_spectre' => 'Prerequisites for Spectre', 'job_configuration_breadcrumb' => 'Configuration for ":key"', 'job_status_breadcrumb' => 'Import status for ":key"', 'cannot_create_for_provider' => 'Firefly III cannot create a job for the ":provider"-provider.', @@ -73,6 +74,7 @@ return [ 'prereq_spectre_pub' => 'Likewise, the Spectre API needs to know the public key you see below. Without it, it will not recognize you. Please enter this public key on your secrets page.', // prerequisites success messages: 'prerequisites_saved_for_fake' => 'Fake API key stored successfully!', + 'prerequisites_saved_for_spectre' => 'App ID and secret stored!', // job configuration: 'job_config_apply_rules_title' => 'Job configuration - apply your rules?', diff --git a/resources/views/import/spectre/prerequisites.twig b/resources/views/import/spectre/prerequisites.twig index 391b53b27a..73b0e89824 100644 --- a/resources/views/import/spectre/prerequisites.twig +++ b/resources/views/import/spectre/prerequisites.twig @@ -23,8 +23,8 @@
- {{ ExpandedForm.text('app_id') }} - {{ ExpandedForm.text('secret') }} + {{ ExpandedForm.text('app_id', app_id) }} + {{ ExpandedForm.text('secret', secret) }}
@@ -36,7 +36,7 @@
+ id="ffInput_pub_key_holder" name="pub_key_holder" contenteditable="false">{{ public_key }}