Skip to content

Commit d273d48

Browse files
committed
Introduce support for encrypted storage
See updated README for more information.
1 parent df37d19 commit d273d48

File tree

8 files changed

+378
-19
lines changed

8 files changed

+378
-19
lines changed

Classes/Authorization.php

+57-7
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
use Doctrine\ORM\Mapping as ORM;
1717
use Exception;
18+
use InvalidArgumentException;
1819
use JsonException;
1920
use League\OAuth2\Client\Token\AccessToken;
2021
use League\OAuth2\Client\Token\AccessTokenInterface;
@@ -70,6 +71,17 @@ class Authorization
7071
*/
7172
protected $serializedAccessToken;
7273

74+
/**
75+
* @var string
76+
* @ORM\Column(nullable = true, type = "text")
77+
*/
78+
protected $encryptedSerializedAccessToken;
79+
80+
/**
81+
* @var EncryptionService
82+
*/
83+
protected $encryptionService;
84+
7385
/**
7486
* @param string $authorizationId
7587
* @param string $serviceName
@@ -86,6 +98,14 @@ public function __construct(string $authorizationId, string $serviceName, string
8698
$this->scope = $scope;
8799
}
88100

101+
/**
102+
* @param EncryptionService $encryptionService
103+
*/
104+
public function injectEncryptionService(EncryptionService $encryptionService): void
105+
{
106+
$this->encryptionService = $encryptionService;
107+
}
108+
89109
/**
90110
* Calculate an authorization identifier (for this model) from the given parameters.
91111
*
@@ -99,7 +119,7 @@ public static function generateAuthorizationIdForAuthorizationCodeGrant(string $
99119
{
100120
try {
101121
return $serviceType . '-' . $serviceName . '-' . Uuid::uuid4()->toString();
102-
// @codeCoverageIgnoreStart
122+
// @codeCoverageIgnoreStart
103123
} catch (Exception $e) {
104124
throw new OAuthClientException(sprintf('Failed generating authorization id for %s %s', $serviceName, $clientId), 1597311416, $e);
105125
}
@@ -185,18 +205,38 @@ public function setSerializedAccessToken(string $serializedAccessToken): void
185205
$this->serializedAccessToken = $serializedAccessToken;
186206
}
187207

208+
/**
209+
* @return string
210+
*/
211+
public function getEncryptedSerializedAccessToken(): string
212+
{
213+
return $this->encryptedSerializedAccessToken ?? '';
214+
}
215+
216+
/**
217+
* @param string $encryptedSerializedAccessToken
218+
*/
219+
public function setEncryptedSerializedAccessToken(string $encryptedSerializedAccessToken): void
220+
{
221+
$this->encryptedSerializedAccessToken = $encryptedSerializedAccessToken;
222+
}
223+
188224
/**
189225
* @param AccessTokenInterface $accessToken
190226
* @return void
191-
* @throws \InvalidArgumentException
227+
* @throws InvalidArgumentException
192228
*/
193229
public function setAccessToken(AccessTokenInterface $accessToken): void
194230
{
195231
try {
196-
$this->serializedAccessToken = json_encode($accessToken, JSON_THROW_ON_ERROR, 512);
232+
if ($this->encryptionService !== null && $this->encryptionService->isConfigured()) {
233+
$this->encryptedSerializedAccessToken = $this->encryptionService->encryptAndEncode(json_encode($accessToken, JSON_THROW_ON_ERROR, 512));
234+
} else {
235+
$this->serializedAccessToken = json_encode($accessToken, JSON_THROW_ON_ERROR, 512);
236+
}
197237
// @codeCoverageIgnoreStart
198-
} catch (JsonException $e) {
199-
throw new \InvalidArgumentException('Failed serializing the given access token', 1602515717);
238+
} catch (JsonException | Exception $e) {
239+
throw new InvalidArgumentException('Failed serializing the given access token', 1602515717, $e);
200240
// @codeCoverageIgnoreEnd
201241
}
202242
}
@@ -206,10 +246,20 @@ public function setAccessToken(AccessTokenInterface $accessToken): void
206246
*/
207247
public function getAccessToken(): ?AccessToken
208248
{
249+
if (empty($this->serializedAccessToken) && empty($this->encryptedSerializedAccessToken)) {
250+
return null;
251+
}
252+
if (!empty($this->encryptedSerializedAccessToken) && !$this->encryptionService->isConfigured()) {
253+
return null;
254+
}
209255
try {
256+
if (!empty($this->encryptedSerializedAccessToken)) {
257+
$deserializedAccessToken = json_decode($this->encryptionService->decodeAndDecrypt($this->encryptedSerializedAccessToken), true, 512, JSON_THROW_ON_ERROR);
258+
return new AccessToken($deserializedAccessToken);
259+
}
210260
if (!empty($this->serializedAccessToken)) {
211-
$unserializedAccessToken = json_decode($this->serializedAccessToken, true, 512, JSON_THROW_ON_ERROR);
212-
return new AccessToken($unserializedAccessToken);
261+
$deserializedAccessToken = json_decode($this->serializedAccessToken, true, 512, JSON_THROW_ON_ERROR);
262+
return new AccessToken($deserializedAccessToken);
213263
}
214264
} catch (JsonException $e) {
215265
}

Classes/Command/OAuthCommandController.php

+28-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
use Doctrine\ORM\EntityManagerInterface as DoctrineEntityManagerInterface;
55
use Doctrine\ORM\OptimisticLockException;
66
use Doctrine\ORM\ORMException;
7+
use Exception;
78
use Flownative\OAuth2\Client\Authorization;
9+
use Flownative\OAuth2\Client\EncryptionService;
810
use Neos\Flow\Cli\CommandController;
911
use Neos\Flow\Persistence\Doctrine\Query;
1012

@@ -47,6 +49,7 @@ public function listAuthorizationsCommand(): void
4749

4850
$rows[] = [
4951
$authorization->getAuthorizationId(),
52+
empty($authorization->getEncryptedSerializedAccessToken()) ? 'no' : 'yes',
5053
$authorization->getServiceName(),
5154
$authorization->getClientId(),
5255
$authorization->getGrantType(),
@@ -55,7 +58,7 @@ public function listAuthorizationsCommand(): void
5558
$values
5659
];
5760
}
58-
$this->output->outputTable($rows, ['Authorization Id', 'Service Name', 'Client ID', 'Grant Type', 'Scope', 'Expiration Time', 'Values']);
61+
$this->output->outputTable($rows, ['Authorization Id', 'Encrypted', 'Service Name', 'Client ID', 'Grant Type', 'Scope', 'Expiration Time', 'Values']);
5962
}
6063

6164
/**
@@ -99,4 +102,28 @@ public function removeAuthorizationsCommand(string $id = '', bool $all = false):
99102
}
100103
$this->outputLine('<success>Done</success>');
101104
}
105+
106+
/**
107+
* Generate encryption key
108+
*
109+
* this command generates a random encryption key which can be used as a vale of the
110+
* "encryption.base64EncodedKey" setting.
111+
*
112+
* @param string $construction
113+
* @return void
114+
* @throws Exception
115+
*/
116+
public function generateEncryptionKeyCommand(string $construction = 'ChaCha20-Poly1305-IETF'): void
117+
{
118+
if (!extension_loaded('sodium')) {
119+
$this->outputLine('<error>This command requires the "sodium" PHP extension to be installed</error>');
120+
exit(1);
121+
}
122+
if ($construction !== 'ChaCha20-Poly1305-IETF') {
123+
$this->outputLine('<error>Currently only ChaCha20-Poly1305-IETF is supported</error>');
124+
}
125+
126+
$encryptionService = new EncryptionService();
127+
$this->outputLine(base64_encode($encryptionService->generateEncryptionKey()));
128+
}
102129
}

Classes/EncryptionService.php

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
namespace Flownative\OAuth2\Client;
4+
5+
use Exception;
6+
use Neos\Flow\Annotations as Flow;
7+
8+
/**
9+
* @Flow\Scope("singleton")
10+
*/
11+
class EncryptionService {
12+
13+
/**
14+
* @Flow\InjectConfiguration(path="encryption.base64EncodedKey")
15+
* @var string
16+
*/
17+
protected $base64EncodedKey;
18+
19+
/**
20+
* @var string
21+
*/
22+
protected $key;
23+
24+
/**
25+
* @return void
26+
*/
27+
public function initializeObject(): void
28+
{
29+
$this->key = base64_decode($this->base64EncodedKey, true);
30+
if ($this->key === false) {
31+
throw new \RuntimeException('Failed base64-decoding the encryption key provided as setting encryption.base64EncodedKey', 1604935600);
32+
}
33+
}
34+
35+
/**
36+
* @param string $key
37+
*/
38+
public function setKey(string $key): void
39+
{
40+
$this->key = $key;
41+
}
42+
43+
/**
44+
* @return bool
45+
*/
46+
public function isConfigured(): bool
47+
{
48+
return !empty($this->key);
49+
}
50+
51+
/**
52+
* Encrypts the given data using the configured encryption method and returns a string
53+
* containing the construction name and the base64-encoded nonce and encrypted data.
54+
*
55+
* @param string $data Data to encrypt
56+
* @return string Encoded, encrypted data, suitable for storage (e.g. in the database)
57+
* @throws Exception
58+
*/
59+
public function encryptAndEncode(string $data): string
60+
{
61+
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_NPUBBYTES);
62+
$encryptedData = sodium_crypto_aead_chacha20poly1305_ietf_encrypt(
63+
$data,
64+
$nonce,
65+
$nonce,
66+
$this->key
67+
);
68+
69+
return 'ChaCha20-Poly1305-IETF$' . base64_encode($nonce) . '$' . base64_encode($encryptedData);
70+
}
71+
72+
/**
73+
* Decrypts the given encoded and encrypted data using the configured encryption method
74+
* and returns the decrypted data.
75+
*
76+
* @param string $encodedAndEncryptedData The data originally created by encryptAndEncode()
77+
* @return string Decrypted data
78+
*/
79+
public function decodeAndDecrypt(string $encodedAndEncryptedData): string
80+
{
81+
list($construction, $encodedNonce, $encodedEncryptedSerializedAccessToken) = explode('$', $encodedAndEncryptedData);
82+
if ($construction !== 'ChaCha20-Poly1305-IETF') {
83+
throw new \RuntimeException(sprintf('Failed decrypting serialized access token: unsupported AEAD construction "%s"', $construction), 1604938723);
84+
}
85+
86+
$nonce = base64_decode($encodedNonce);
87+
return sodium_crypto_aead_chacha20poly1305_ietf_decrypt(
88+
base64_decode($encodedEncryptedSerializedAccessToken),
89+
$nonce,
90+
$nonce,
91+
$this->key
92+
);
93+
}
94+
95+
/**
96+
* @return string
97+
* @throws Exception
98+
*/
99+
public function generateEncryptionKey(): string
100+
{
101+
return sodium_crypto_aead_chacha20poly1305_ietf_keygen();
102+
}
103+
104+
}

Configuration/Settings.yaml

+7
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ Flownative:
1313
services: []
1414
# - name: 'flownative-beach'
1515
# className: 'Flownative\Beach\BeachClient'
16+
encryption:
17+
18+
# A base64-encoded random key, for example generated with ./flow oauth:generateencryptionkey
19+
base64EncodedKey: ''
20+
21+
# AEAD construction to use; currently only "ChaCha20-Poly1305-IETF" is supported
22+
construction: 'ChaCha20-Poly1305-IETF'
1623

1724
Neos:
1825
Flow:
+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
namespace Neos\Flow\Persistence\Doctrine\Migrations;
3+
4+
use Doctrine\Migrations\AbstractMigration;
5+
use Doctrine\DBAL\Schema\Schema;
6+
use Doctrine\DBAL\Migrations\AbortMigrationException;
7+
8+
/**
9+
* Introduce encrypted serialized access token
10+
*/
11+
class Version20201109140652 extends AbstractMigration
12+
{
13+
14+
/**
15+
* @return string
16+
*/
17+
public function getDescription(): string
18+
{
19+
return 'Introduce encrypted serialized access token';
20+
}
21+
22+
/**
23+
* @param Schema $schema
24+
* @return void
25+
* @throws AbortMigrationException
26+
*/
27+
public function up(Schema $schema): void
28+
{
29+
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on "mysql".');
30+
31+
$this->addSql('ALTER TABLE flownative_oauth2_client_authorization ADD encryptedserializedaccesstoken LONGTEXT DEFAULT NULL');
32+
}
33+
34+
/**
35+
* @param Schema $schema
36+
* @return void
37+
* @throws AbortMigrationException
38+
*/
39+
public function down(Schema $schema): void
40+
{
41+
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on "mysql".');
42+
43+
$this->addSql('ALTER TABLE flownative_oauth2_client_authorization DROP encryptedserializedaccesstoken');
44+
}
45+
}

0 commit comments

Comments
 (0)