Passed
Push — master ( 52e05d...6f0859 )
by
unknown
08:31 queued 03:15
created

AccountStore::deleteAccount()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 5
c 0
b 0
f 0
nc 2
nop 1
dl 0
loc 9
rs 10
1
<?php
2
3
/**
4
 * This class offers functions to handle file backend accounts.
5
 *
6
 * @class AccountStore
7
 */
8
9
namespace Files\Core;
10
11
require_once __DIR__ . "/class.account.php";
12
require_once __DIR__ . "/class.exception.php";
13
require_once __DIR__ . "/../Backend/class.backendstore.php";
14
require_once __DIR__ . "/../Backend/class.exception.php";
15
16
use Files\Backend\BackendStore;
17
use Files\Backend\Exception as BackendException;
18
use Files\Core\Util\Logger;
19
20
class AccountStore {
21
	public const LOG_CONTEXT = "AccountStore"; // Context for the Logger
22
	public const ACCOUNT_STORAGE_PATH = "zarafa/v1/plugins/files/accounts";
23
	public const ACCOUNT_VERSION = 1;
24
25
	/**
26
	 * @var Account[] Account array
27
	 */
28
	private $accounts = [];
29
30
	public function __construct() {
31
		$this->initialiseAccounts();
32
	}
33
34
	/**
35
	 * @param array $backendConfig Backend specific account settings
36
	 *                             like username, password, serveraddress, ...
37
	 * @param mixed $name
38
	 * @param mixed $backend
39
	 *
40
	 * @return Account
41
	 */
42
	public function createAccount($name, $backend, $backendConfig) {
43
		$newID = $this->createNewId($backendConfig); // create id out of the configuration
44
45
		// create instance of backend to get features
46
		$backendStore = BackendStore::getInstance();
47
		$backend = $backendStore->normalizeBackendName($backend);
48
		$backendInstance = $backendStore->getInstanceOfBackend($backend);
49
		$features = $backendInstance->getAvailableFeatures();
50
51
		// check backend_config for validity
52
		$status = $this->checkBackendConfig($backendInstance, $backendConfig);
53
54
		// get sequence number
55
		$sequence = $this->getNewSequenceNumber();
56
57
		$newAccount = new Account($newID, strip_tags((string) $name), $status[0], $status[1], strip_tags((string) $backend), $backendConfig, $features, $sequence, false);
58
59
		// now store all the values to the user settings
60
		$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $newID . "/id", $newAccount->getId());
61
		$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $newID . "/name", $newAccount->getName());
62
		$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $newID . "/status", $newAccount->getStatus());
63
		$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $newID . "/status_description", $newAccount->getStatusDescription());
64
		$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $newID . "/backend", $newAccount->getBackend());
65
		$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $newID . "/account_sequence", $newAccount->getSequence());
66
		// User defined accounts are never administrative. So set cannot_change to false.
67
		$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $newID . "/cannot_change", false);
68
		$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $newID . "/backend_config/version", self::ACCOUNT_VERSION);
69
		// store all backend configurations
70
		foreach ($newAccount->getBackendConfig() as $key => $value) {
71
			if ($key !== "version") {
72
				$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $newID . "/backend_config/" . $key, $this->encryptBackendConfigProperty($value, self::ACCOUNT_VERSION));
73
			}
74
		}
75
76
		// store all features
77
		foreach ($newAccount->getFeatures() as $feature) {
78
			$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $newID . "/backend_features/" . $feature, true);
79
		}
80
81
		$GLOBALS["settings"]->saveSettings(); // save to MAPI storage
82
83
		// add account to our local store after it was saved to the zarafa-settings
84
		$this->accounts[$newID] = $newAccount;
85
86
		return $newAccount;
87
	}
88
89
	/**
90
	 * @param Account $account
91
	 *
92
	 * @return Account
93
	 */
94
	public function updateAccount($account) {
95
		$accId = $account->getId();
96
		$isAdministrativeAccount = $account->getCannotChangeFlag();
97
98
		// create instance of backend to get features
99
		$backendStore = BackendStore::getInstance();
100
		$normalizedBackend = $backendStore->normalizeBackendName($account->getBackend());
101
		$account->setBackend($normalizedBackend);
102
		$backendInstance = $backendStore->getInstanceOfBackend($normalizedBackend);
103
		$features = $backendInstance->getAvailableFeatures();
104
		$account->setFeatures($features);
105
106
		// check backend_config for validity
107
		$status = $this->checkBackendConfig($backendInstance, $account->getBackendConfig());
108
		$account->setStatus($status[0]); // update status
109
		$account->setStatusDescription($status[1]); // update status description
110
111
		// add account to local store
112
		$this->accounts[$accId] = $account;
113
114
		// save values to MAPI settings
115
		// now store all the values to the user settings
116
		// but if we have an administrative account only save the account sequence
117
		if (!$isAdministrativeAccount) {
118
			$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $accId . "/name", $account->getName());
119
			$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $accId . "/status", $account->getStatus());
120
			$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $accId . "/status_description", $account->getStatusDescription());
121
			$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $accId . "/backend", $account->getBackend());
122
123
			$acc = $account->getBackendConfig();
124
			$version = 0;
125
			if (isset($acc["version"])) {
126
				$version = $acc["version"];
127
			}
128
129
			// Unable to decrypt, don't update
130
			if ($version == 0 && !defined('FILES_PASSWORD_IV') && !defined('FILES_PASSWORD_KEY')) {
131
				Logger::error(self::LOG_CONTEXT, "Unable to update the account to as FILES_PASSWORD_IV/FILES_PASSWORD_KEY is not set");
132
			}
133
			else {
134
				// store all backend configurations
135
				foreach ($account->getBackendConfig() as $key => $value) {
136
					if ($key !== "version") {
137
						$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $accId . "/backend_config/" . $key, $this->encryptBackendConfigProperty($value, self::ACCOUNT_VERSION));
138
					}
139
				}
140
141
				$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $accId . "/backend_config/version", self::ACCOUNT_VERSION);
142
			}
143
144
			// store all features
145
			foreach ($account->getFeatures() as $feature) {
146
				$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $accId . "/backend_features/" . $feature, true);
147
			}
148
		}
149
		// when getSequence returns 0, there is no account_sequence setting yet. So create one.
150
		$account_sequence = ($account->getSequence() === 0 ? $this->getNewSequenceNumber() : $account->getSequence());
151
		$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $accId . "/account_sequence", $account_sequence);
152
153
		$GLOBALS["settings"]->saveSettings(); // save to MAPI storage
154
155
		return $account;
156
	}
157
158
	/**
159
	 * Delete account from local store and from the MAPI settings.
160
	 *
161
	 * @param mixed $accountId
162
	 *
163
	 * @return bool
164
	 */
165
	public function deleteAccount($accountId) {
166
		$account = $this->getAccount($accountId);
167
		// Do not allow deleting administrative accounts, but fail silently.
168
		if (!$account->getCannotChangeFlag()) {
169
			$GLOBALS["settings"]->delete(self::ACCOUNT_STORAGE_PATH . "/" . $accountId);
170
			$GLOBALS["settings"]->saveSettings(); // save to MAPI storage
171
		}
172
173
		return true;
174
	}
175
176
	/**
177
	 * Return the instance of the local account.
178
	 *
179
	 * @param mixed $accountId
180
	 *
181
	 * @return Account
182
	 */
183
	public function getAccount($accountId) {
184
		return $this->accounts[$accountId];
185
	}
186
187
	/**
188
	 * @return Account[] all Accounts
189
	 */
190
	public function getAllAccounts() {
191
		return $this->accounts;
192
	}
193
194
	/**
195
	 * Initialize the accountstore. Reads all accountinformation from the MAPI settings.
196
	 */
197
	private function initialiseAccounts() {
198
		// Parse accounts from the Settings
199
		$tmpAccs = $GLOBALS["settings"]->get(self::ACCOUNT_STORAGE_PATH);
200
		$backendStore = BackendStore::getInstance();
201
202
		if (is_array($tmpAccs)) {
203
			$this->accounts = [];
204
205
			foreach ($tmpAccs as $acc) {
206
				// set backend_features if it is not set to prevent warning
207
				if (!isset($acc["backend_features"])) {
208
					$acc["backend_features"] = [];
209
				}
210
				// account_sequence was introduced later. So set and save it if missing.
211
				if (!isset($acc["account_sequence"])) {
212
					$acc["account_sequence"] = $this->getNewSequenceNumber();
213
					Logger::debug(self::LOG_CONTEXT, "Account sequence missing. New seq: " . $acc["account_sequence"]);
214
					$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $acc["id"] . "/account_sequence", $acc["account_sequence"]);
215
					$GLOBALS["settings"]->saveSettings();
216
				}
217
				// cannot_change flag was introduced later. So set it to false and save it if missing.
218
				if (!isset($acc["cannot_change"])) {
219
					$acc["cannot_change"] = false;
220
					Logger::debug(self::LOG_CONTEXT, "Cannot change flag missing. Setting to false.");
221
					$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $acc["id"] . "/cannot_change", false);
222
					$GLOBALS["settings"]->saveSettings();
223
				}
224
225
				$backend_config = $acc["backend_config"];
226
				$version = 0;
227
228
				if (isset($acc["backend_config"], $acc["backend_config"]["version"])) {
229
					$version = $acc["backend_config"]["version"];
230
				}
231
232
				if (($version === 0 && defined('FILES_PASSWORD_IV') && defined('FILES_PASSWORD_KEY')) || $version === self::ACCOUNT_VERSION) {
233
					$backend_config = $this->decryptBackendConfig($acc["backend_config"], $version);
234
					// version is lost after decryption, add it again
235
					$backend_config["version"] = $version;
236
				}
237
				elseif ($version === 0) {
238
					Logger::error(self::LOG_CONTEXT, "FILES_PASSWORD_IV or FILES_PASSWORD_KEY not set, unable to decrypt backend configuration");
239
				}
240
				else {
241
					Logger::error(self::LOG_CONTEXT, "Unsupported account version {$version}, unable to decrypt backend configuration");
242
				}
243
244
				$normalizedBackend = $backendStore->normalizeBackendName($acc["backend"]);
245
				if ($normalizedBackend !== $acc["backend"]) {
246
					$GLOBALS["settings"]->set(self::ACCOUNT_STORAGE_PATH . "/" . $acc["id"] . "/backend", $normalizedBackend);
247
					$GLOBALS["settings"]->saveSettings();
248
				}
249
				$this->accounts[$acc["id"]] = new Account(
250
					$acc["id"],
251
					$acc["name"],
252
					$acc["status"],
253
					$acc["status_description"],
254
					$normalizedBackend,
255
					$backend_config,
256
					array_keys($acc["backend_features"]),
257
					$acc["account_sequence"],
258
					$acc["cannot_change"]
259
				);
260
			}
261
		}
262
		Logger::debug(self::LOG_CONTEXT, "Found " . count($this->accounts) . " accounts.");
263
	}
264
265
	/**
266
	 * @param AbstractBackend $backendInstance
0 ignored issues
show
Bug introduced by
The type Files\Core\AbstractBackend was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
267
	 * @param array           $backendConfig   Backend specific account settings
268
	 *                                         like username, password, serveraddress, ...
269
	 *
270
	 * @return array
271
	 */
272
	private function checkBackendConfig($backendInstance, $backendConfig) {
273
		$status = Account::STATUS_NEW;
0 ignored issues
show
Unused Code introduced by
The assignment to $status is dead and can be removed.
Loading history...
274
		$description = _('Account is ready to use.');
275
276
		try {
277
			$backendInstance->init_backend($backendConfig);
278
			$backendInstance->open();
279
			$backendInstance->ls("/");
280
			$status = Account::STATUS_OK;
281
		}
282
		catch (BackendException $e) {
283
			$status = Account::STATUS_ERROR;
284
			$description = $e->getMessage();
285
286
			Logger::error(self::LOG_CONTEXT, "Account check failed: " . $description);
287
		}
288
289
		return [$status, $description];
290
	}
291
292
	/**
293
	 * @param array $backendConfig Backend specific account settings
294
	 *                             like username, password, serveraddress, ...
295
	 *
296
	 * @return an unique id
0 ignored issues
show
Bug introduced by
The type Files\Core\an was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
297
	 */
298
	private function createNewId($backendConfig) {
299
		// lets create a hash
300
		return md5(json_encode($backendConfig) . time()); // json_encode is faster than serialize
0 ignored issues
show
Bug Best Practice introduced by
The expression return md5(json_encode($backendConfig) . time()) returns the type string which is incompatible with the documented return type Files\Core\an.
Loading history...
301
	}
302
303
	/**
304
	 * Generate a new sequence number. It will always be the highest used sequence number +1.
305
	 *
306
	 * @return int
307
	 */
308
	private function getNewSequenceNumber() {
309
		$seq = 0;
310
		foreach ($this->accounts as $acc) {
311
			if ($acc->getSequence() > $seq) {
312
				$seq = $acc->getSequence();
313
			}
314
		}
315
316
		return $seq + 1;
317
	}
318
319
	/**
320
	 * Decrypt the backend configuration using the standard grommunio Web key.
321
	 *
322
	 * @param array $backendConfig Backend specific account settings
323
	 *                             like username, password, serveraddress, ...
324
	 * @param mixed $version
325
	 *
326
	 * @return array
327
	 */
328
	private function decryptBackendConfig($backendConfig, $version = 0) {
329
		$decBackendConfig = [];
330
331
		foreach ($backendConfig as $key => $value) {
332
			if ($key !== "version") {
333
				try {
334
					$decBackendConfig[$key] = $this->decryptBackendConfigProperty($value, $version);
335
				}
336
				catch (Exception $e) {
337
					Logger::error(self::LOG_CONTEXT, sprintf("Unable to decrypt backend configuration: '%s'", $e->getMessage()));
338
				}
339
			}
340
		}
341
342
		return $decBackendConfig;
343
	}
344
345
	/**
346
	 * Encrypt the given string.
347
	 *
348
	 * @param       $version the storage version used to identify what encryption to use
0 ignored issues
show
Bug introduced by
The type Files\Core\the was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
349
	 * @param mixed $value
350
	 *
351
	 * @return string
352
	 */
353
	private function encryptBackendConfigProperty($value, $version = 0) {
354
		if ($version == self::ACCOUNT_VERSION && !is_bool($value)) {
355
			// Guard against missing libsodium extension on PHP 8.1/8.2
356
			if (!defined('SODIUM_CRYPTO_SECRETBOX_NONCEBYTES') || !function_exists('sodium_crypto_secretbox')) {
357
				// Keep original value to avoid fatals; features may be limited
358
				error_log('[Files][AccountStore] Libsodium not available for encryption; storing value as-is.');
359
360
				return $value;
361
			}
362
363
			$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
364
			$key = $GLOBALS["operations"]->getFilesEncryptionKey();
365
			$encrypted = sodium_crypto_secretbox((string) $value, $nonce, (string) $key);
366
			$value = bin2hex($nonce) . bin2hex($encrypted);
367
		}
368
		elseif ($version !== self::ACCOUNT_VERSION) {
369
			throw new Exception("Unable to encrypt backend configuration unsupported version {$version}");
370
		}
371
372
		return $value;
373
	}
374
375
	/**
376
	 * Decrypt the given string.
377
	 *
378
	 * @param       $version the storage version used to identify what encryption to use
379
	 * @param mixed $value
380
	 *
381
	 * @return string
382
	 */
383
	private function decryptBackendConfigProperty($value, $version = 0) {
384
		if (is_bool($value)) {
385
			return $value;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $value returns the type boolean which is incompatible with the documented return type string.
Loading history...
386
		}
387
388
		if ($version == self::ACCOUNT_VERSION) {
389
			// Guard against missing libsodium extension to avoid fatal errors
390
			if (!defined('SODIUM_CRYPTO_SECRETBOX_NONCEBYTES') || !function_exists('sodium_crypto_secretbox_open')) {
391
				// Return input as-is to avoid breaking the request path.
392
				error_log('[Files][AccountStore] Libsodium not available for decryption; returning raw value.');
393
394
				return $value;
395
			}
396
397
			$value = hex2bin((string) $value);
398
			$nonce = substr($value, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
399
			$encrypted = substr($value, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, strlen($value));
400
			$key = $GLOBALS["operations"]->getFilesEncryptionKey();
401
			$value = sodium_crypto_secretbox_open($encrypted, $nonce, $key);
402
403
			// Decryption failed, password might have changed
404
			if ($value === false) {
405
				throw new Exception("invalid password");
406
			}
407
		}
408
409
		return $value;
410
	}
411
}
412