mirror of
https://github.com/Bubka/2FAuth.git
synced 2024-12-04 22:31:58 +01:00
281 lines
8.3 KiB
PHP
281 lines
8.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Events\StoreIconsInDatabaseSettingChanged;
|
|
use App\Exceptions\DbEncryptionException;
|
|
use App\Models\Option;
|
|
use Exception;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Throwable;
|
|
|
|
class SettingService
|
|
{
|
|
/**
|
|
* All settings
|
|
*
|
|
* @var Collection<string, mixed>
|
|
*/
|
|
private Collection $settings;
|
|
|
|
/**
|
|
* Cache duration
|
|
*/
|
|
private int $minutes = 10;
|
|
|
|
/**
|
|
* Name of the cache item where options are persisted
|
|
*/
|
|
public const CACHE_ITEM_NAME = 'adminOptions';
|
|
|
|
/**
|
|
* Constructor
|
|
*/
|
|
public function __construct()
|
|
{
|
|
$this->settings = Cache::remember(self::CACHE_ITEM_NAME, now()->addMinutes($this->minutes), function () {
|
|
self::build();
|
|
|
|
return $this->settings;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get a setting
|
|
*
|
|
* @param string $setting A single setting name
|
|
* @return mixed string|int|boolean|null
|
|
*/
|
|
public function get($setting)
|
|
{
|
|
return $this->settings->get($setting);
|
|
}
|
|
|
|
/**
|
|
* Get all settings
|
|
*
|
|
* @return Collection<string, mixed> the Settings collection
|
|
*/
|
|
public function all() : Collection
|
|
{
|
|
return $this->settings;
|
|
}
|
|
|
|
/**
|
|
* Set a setting
|
|
*
|
|
* @param string $setting A single setting name
|
|
* @param string|int|bool $value The value for single setting
|
|
*/
|
|
public function set($setting, $value) : void
|
|
{
|
|
// TODO: Move setEncryptionTo() logic to a dedicated class
|
|
if ($setting === 'useEncryption') {
|
|
$this->setEncryptionTo($value);
|
|
}
|
|
|
|
if ($setting === 'storeIconsInDatabase') {
|
|
StoreIconsInDatabaseSettingChanged::dispatch($value);
|
|
}
|
|
|
|
Option::updateOrCreate(['key' => $setting], ['value' => $this->replaceBoolean($value)]);
|
|
Log::notice(sprintf('App setting %s set to %s', var_export($setting, true), var_export($this->restoreType($value), true)));
|
|
|
|
self::buildAndCache();
|
|
}
|
|
|
|
/**
|
|
* Delete a setting
|
|
*
|
|
* @param string $name The setting name
|
|
*/
|
|
public function delete(string $name) : void
|
|
{
|
|
Option::where('key', $name)->delete();
|
|
Log::notice(sprintf('App setting %s reset to default', var_export($name, true)));
|
|
|
|
self::buildAndCache();
|
|
}
|
|
|
|
/**
|
|
* Determine if the given setting has been edited
|
|
*
|
|
* @param string $key
|
|
*/
|
|
public function isEdited($key) : bool
|
|
{
|
|
return DB::table('options')->where('key', $key)->exists();
|
|
}
|
|
|
|
/**
|
|
* Set the settings collection
|
|
*
|
|
* @return void
|
|
*/
|
|
private function build()
|
|
{
|
|
// Get a collection of saved options
|
|
$options = DB::table('options')->pluck('value', 'key');
|
|
$options->transform(function ($item, $key) {
|
|
return $this->restoreType($item);
|
|
});
|
|
|
|
// Merge customized values with app default values
|
|
$settings = collect(config('2fauth.settings'))->merge($options); /** @phpstan-ignore-line */
|
|
$this->settings = $settings;
|
|
}
|
|
|
|
/**
|
|
* Build and cache the options collection
|
|
*
|
|
* @return void
|
|
*/
|
|
private function buildAndCache()
|
|
{
|
|
self::build();
|
|
Cache::put(self::CACHE_ITEM_NAME, $this->settings, now()->addMinutes($this->minutes));
|
|
}
|
|
|
|
/**
|
|
* Replaces boolean by a patterned string as appstrack/laravel-options package does not support var type
|
|
*
|
|
* @return string
|
|
*/
|
|
private function replaceBoolean(mixed $value)
|
|
{
|
|
return is_bool($value) ? '{{' . $value . '}}' : $value;
|
|
}
|
|
|
|
/**
|
|
* Replaces patterned string that represent booleans with real booleans
|
|
*
|
|
* @return mixed
|
|
*/
|
|
private function restoreType(mixed $value)
|
|
{
|
|
if (is_numeric($value)) {
|
|
$value = is_float($value + 0) ? (float) $value : (int) $value;
|
|
}
|
|
|
|
if ($value === '{{}}') {
|
|
return false;
|
|
} elseif ($value === '{{1}}') {
|
|
return true;
|
|
} else {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enable or Disable encryption of 2FAccounts sensible data
|
|
*
|
|
*
|
|
* @throws DbEncryptionException Something failed, everything have been rolled back
|
|
*/
|
|
private function setEncryptionTo(bool $state) : void
|
|
{
|
|
// We don't want the records to be encrypted/decrypted multiple successive times
|
|
$isInUse = $this->get('useEncryption');
|
|
|
|
if ($isInUse === ! $state) {
|
|
if ($this->updateRecords($state)) {
|
|
if ($state) {
|
|
Log::notice('Sensible data are now encrypted');
|
|
} else {
|
|
Log::notice('Sensible data are now decrypted');
|
|
}
|
|
} else {
|
|
Log::warning('Some data cannot be encrypted/decrypted, the useEncryption setting remain unchanged');
|
|
throw new DbEncryptionException($state === true ? __('errors.error_during_encryption') : __('errors.error_during_decryption'));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Encrypt/Decrypt accounts in database
|
|
*
|
|
* @param bool $encrypted Whether the record should be encrypted or not
|
|
* @return bool Whether the operation completed successfully
|
|
*/
|
|
private function updateRecords(bool $encrypted) : bool
|
|
{
|
|
$success = true;
|
|
$twofaccounts = DB::table('twofaccounts')->get();
|
|
$icons = DB::table('icons')->get();
|
|
|
|
$twofaccounts->each(function ($item, $key) use (&$success, $encrypted) {
|
|
try {
|
|
// encrypting a null value generate a hash which once decrypted gives an empty string.
|
|
// As Service is nullable, we handle it only if the fiel contains a value
|
|
if ($item->service) {
|
|
$item->service = $encrypted ? Crypt::encryptString($item->service) : Crypt::decryptString($item->service);
|
|
}
|
|
|
|
$item->legacy_uri = $encrypted ? Crypt::encryptString($item->legacy_uri) : Crypt::decryptString($item->legacy_uri);
|
|
$item->account = $encrypted ? Crypt::encryptString($item->account) : Crypt::decryptString($item->account);
|
|
$item->secret = $encrypted ? Crypt::encryptString($item->secret) : Crypt::decryptString($item->secret);
|
|
} catch (Exception $ex) {
|
|
$success = false;
|
|
|
|
// Exit the each iteration
|
|
return false;
|
|
}
|
|
});
|
|
|
|
$icons->each(function ($item, $key) use (&$success, $encrypted) {
|
|
try {
|
|
$item->content = $encrypted ? Crypt::encryptString($item->content) : Crypt::decryptString($item->content);
|
|
} catch (Exception $ex) {
|
|
$success = false;
|
|
|
|
// Exit the each iteration
|
|
return false;
|
|
}
|
|
});
|
|
|
|
if ($success) {
|
|
// The whole collection has now its sensible data encrypted/decrypted
|
|
// We update the db using a transaction that can rollback everything if an error occured
|
|
DB::beginTransaction();
|
|
|
|
try {
|
|
$twofaccounts->each(function ($item, $key) {
|
|
DB::table('twofaccounts')
|
|
->where('id', $item->id)
|
|
->update([
|
|
'service' => $item->service,
|
|
'legacy_uri' => $item->legacy_uri,
|
|
'account' => $item->account,
|
|
'secret' => $item->secret,
|
|
]);
|
|
});
|
|
|
|
$icons->each(function ($item, $key) {
|
|
DB::table('icons')
|
|
->where('name', $item->name)
|
|
->update([
|
|
'content' => $item->content,
|
|
]);
|
|
});
|
|
|
|
DB::commit();
|
|
|
|
return true;
|
|
}
|
|
// @codeCoverageIgnoreStart
|
|
catch (Throwable $ex) {
|
|
DB::rollBack();
|
|
|
|
return false;
|
|
}
|
|
// @codeCoverageIgnoreEnd
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
}
|