2019-05-20 07:37:41 +02:00
|
|
|
<?php
|
|
|
|
|
2021-12-02 13:15:53 +01:00
|
|
|
namespace App\Models;
|
2019-05-20 07:37:41 +02:00
|
|
|
|
2021-11-30 17:34:35 +01:00
|
|
|
use App\Events\TwoFAccountDeleted;
|
2022-07-05 10:10:24 +02:00
|
|
|
use App\Exceptions\InvalidOtpParameterException;
|
2022-11-22 15:15:52 +01:00
|
|
|
use App\Exceptions\InvalidSecretException;
|
2022-07-05 10:10:24 +02:00
|
|
|
use App\Exceptions\UndecipherableException;
|
2022-11-22 15:15:52 +01:00
|
|
|
use App\Exceptions\UnsupportedOtpTypeException;
|
|
|
|
use App\Facades\Settings;
|
|
|
|
use App\Helpers\Helpers;
|
|
|
|
use App\Models\Dto\HotpDto;
|
|
|
|
use App\Models\Dto\TotpDto;
|
|
|
|
use App\Services\LogoService;
|
|
|
|
use Exception;
|
|
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
2019-05-20 07:37:41 +02:00
|
|
|
use Illuminate\Database\Eloquent\Model;
|
2022-07-05 10:10:24 +02:00
|
|
|
use Illuminate\Support\Arr;
|
2022-11-22 15:15:52 +01:00
|
|
|
use Illuminate\Support\Facades\App;
|
2023-06-07 16:24:06 +02:00
|
|
|
use Illuminate\Support\Facades\Auth;
|
2020-10-31 01:16:15 +01:00
|
|
|
use Illuminate\Support\Facades\Crypt;
|
2022-11-22 15:15:52 +01:00
|
|
|
use Illuminate\Support\Facades\Http;
|
2021-10-15 23:46:21 +02:00
|
|
|
use Illuminate\Support\Facades\Log;
|
2022-07-05 10:10:24 +02:00
|
|
|
use Illuminate\Support\Facades\Storage;
|
2022-12-13 09:08:22 +01:00
|
|
|
use Illuminate\Support\Facades\Validator;
|
2022-12-13 12:07:29 +01:00
|
|
|
use Illuminate\Support\Str;
|
2022-11-22 15:15:52 +01:00
|
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
use OTPHP\Factory;
|
|
|
|
use OTPHP\HOTP;
|
|
|
|
use OTPHP\TOTP;
|
2022-07-05 10:10:24 +02:00
|
|
|
use ParagonIE\ConstantTime\Base32;
|
2022-11-22 15:15:52 +01:00
|
|
|
use Spatie\EloquentSortable\Sortable;
|
|
|
|
use Spatie\EloquentSortable\SortableTrait;
|
|
|
|
use SteamTotp\SteamTotp;
|
2019-05-20 07:37:41 +02:00
|
|
|
|
2023-03-02 15:24:57 +01:00
|
|
|
/**
|
|
|
|
* App\Models\TwoFAccount
|
|
|
|
*
|
|
|
|
* @property int $id
|
|
|
|
* @property string|null $service
|
|
|
|
* @property string $legacy_uri
|
|
|
|
* @property string $account
|
|
|
|
* @property string|null $icon
|
|
|
|
* @property \Illuminate\Support\Carbon|null $created_at
|
|
|
|
* @property \Illuminate\Support\Carbon|null $updated_at
|
|
|
|
* @property int|null $order_column
|
|
|
|
* @property int|null $group_id
|
|
|
|
* @property string $otp_type
|
|
|
|
* @property string $secret
|
|
|
|
* @property string $algorithm
|
|
|
|
* @property int $digits
|
|
|
|
* @property int|null $period
|
|
|
|
* @property int|null $counter
|
|
|
|
* @property int|null $user_id
|
|
|
|
* @property-read \App\Models\User|null $user
|
|
|
|
*/
|
2020-03-25 21:58:05 +01:00
|
|
|
class TwoFAccount extends Model implements Sortable
|
2019-05-20 07:37:41 +02:00
|
|
|
{
|
2021-12-02 13:15:53 +01:00
|
|
|
use SortableTrait, HasFactory;
|
2020-03-25 21:58:05 +01:00
|
|
|
|
2022-11-22 15:15:52 +01:00
|
|
|
const TOTP = 'totp';
|
|
|
|
|
|
|
|
const HOTP = 'hotp';
|
|
|
|
|
2022-07-05 10:10:24 +02:00
|
|
|
const STEAM_TOTP = 'steamtotp';
|
|
|
|
|
2022-11-22 15:15:52 +01:00
|
|
|
const SHA1 = 'sha1';
|
|
|
|
|
|
|
|
const MD5 = 'md5';
|
|
|
|
|
|
|
|
const SHA256 = 'sha256';
|
|
|
|
|
|
|
|
const SHA512 = 'sha512';
|
|
|
|
|
2022-07-05 10:10:24 +02:00
|
|
|
const DEFAULT_PERIOD = 30;
|
2022-11-22 15:15:52 +01:00
|
|
|
|
2022-07-05 10:10:24 +02:00
|
|
|
const DEFAULT_COUNTER = 0;
|
2022-11-22 15:15:52 +01:00
|
|
|
|
2022-07-05 10:10:24 +02:00
|
|
|
const DEFAULT_DIGITS = 6;
|
2022-11-22 15:15:52 +01:00
|
|
|
|
2022-07-05 10:10:24 +02:00
|
|
|
const DEFAULT_ALGORITHM = self::SHA1;
|
|
|
|
|
2022-10-10 13:44:12 +02:00
|
|
|
const DUPLICATE_ID = -1;
|
2022-11-22 15:15:52 +01:00
|
|
|
|
2022-10-10 13:44:12 +02:00
|
|
|
const FAKE_ID = -2;
|
|
|
|
|
2022-07-05 10:10:24 +02:00
|
|
|
/**
|
|
|
|
* List of OTP types supported by 2FAuth
|
|
|
|
*/
|
|
|
|
private array $generatorClassMap = [
|
|
|
|
'OTPHP\TOTP' => self::TOTP,
|
|
|
|
'OTPHP\HOTP' => self::HOTP,
|
|
|
|
];
|
2020-03-25 21:58:05 +01:00
|
|
|
|
2020-01-10 13:43:36 +01:00
|
|
|
/**
|
|
|
|
* model's array form.
|
|
|
|
*
|
2022-08-26 15:57:18 +02:00
|
|
|
* @var string[]
|
2020-01-10 13:43:36 +01:00
|
|
|
*/
|
2022-07-05 10:10:24 +02:00
|
|
|
protected $fillable = [
|
|
|
|
// 'service',
|
|
|
|
// 'account',
|
|
|
|
// 'otp_type',
|
|
|
|
// 'digits',
|
|
|
|
// 'secret',
|
|
|
|
// 'algorithm',
|
|
|
|
// 'counter',
|
|
|
|
// 'period',
|
|
|
|
// 'icon'
|
|
|
|
];
|
2019-05-24 14:44:41 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* The table associated with the model.
|
|
|
|
*
|
|
|
|
* @var string
|
|
|
|
*/
|
|
|
|
protected $table = 'twofaccounts';
|
2020-01-08 17:03:41 +01:00
|
|
|
|
2020-01-24 12:56:38 +01:00
|
|
|
/**
|
|
|
|
* The accessors to append to the model's array form.
|
|
|
|
*
|
|
|
|
* @var array
|
|
|
|
*/
|
2021-09-17 23:55:09 +02:00
|
|
|
public $appends = [];
|
2022-11-22 15:15:52 +01:00
|
|
|
|
2022-07-05 10:10:24 +02:00
|
|
|
/**
|
2022-11-22 15:15:52 +01:00
|
|
|
* The model's default values for attributes.
|
|
|
|
*
|
|
|
|
* @var array
|
|
|
|
*/
|
2022-07-05 10:10:24 +02:00
|
|
|
protected $attributes = [
|
2022-11-22 15:15:52 +01:00
|
|
|
'digits' => 6,
|
2022-07-05 10:10:24 +02:00
|
|
|
'algorithm' => self::SHA1,
|
|
|
|
];
|
2020-01-24 12:56:38 +01:00
|
|
|
|
2020-11-06 15:51:52 +01:00
|
|
|
/**
|
|
|
|
* The attributes that should be hidden for arrays.
|
|
|
|
*
|
2022-11-17 16:46:29 +01:00
|
|
|
* @var array<int, string>
|
2020-11-06 15:51:52 +01:00
|
|
|
*/
|
2021-09-17 23:55:09 +02:00
|
|
|
protected $hidden = [];
|
2020-11-12 00:09:33 +01:00
|
|
|
|
2020-11-23 12:54:06 +01:00
|
|
|
/**
|
|
|
|
* The attributes that should be cast.
|
|
|
|
*
|
2022-11-17 16:46:29 +01:00
|
|
|
* @var array<string, string>
|
2020-11-23 12:54:06 +01:00
|
|
|
*/
|
2021-09-17 23:55:09 +02:00
|
|
|
protected $casts = [];
|
2020-11-06 15:51:52 +01:00
|
|
|
|
2021-11-30 17:34:35 +01:00
|
|
|
/**
|
|
|
|
* The event map for the model.
|
|
|
|
*
|
|
|
|
* @var array
|
|
|
|
*/
|
|
|
|
protected $dispatchesEvents = [
|
|
|
|
'deleted' => TwoFAccountDeleted::class,
|
|
|
|
];
|
|
|
|
|
2020-01-31 23:05:06 +01:00
|
|
|
/**
|
|
|
|
* Override The "booting" method of the model
|
|
|
|
*
|
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
protected static function boot()
|
|
|
|
{
|
|
|
|
parent::boot();
|
2021-11-30 17:34:35 +01:00
|
|
|
|
2022-08-26 15:57:18 +02:00
|
|
|
static::saving(function (TwoFAccount $twofaccount) {
|
2022-12-13 12:07:29 +01:00
|
|
|
if (! $twofaccount->legacy_uri) {
|
2022-11-22 15:15:52 +01:00
|
|
|
$twofaccount->legacy_uri = $twofaccount->getURI();
|
|
|
|
}
|
2022-12-13 12:07:29 +01:00
|
|
|
if ($twofaccount->otp_type == TwoFAccount::TOTP && ! $twofaccount->period) {
|
2022-11-22 15:15:52 +01:00
|
|
|
$twofaccount->period = TwoFAccount::DEFAULT_PERIOD;
|
|
|
|
}
|
2022-12-13 12:07:29 +01:00
|
|
|
if ($twofaccount->otp_type == TwoFAccount::HOTP && ! $twofaccount->counter) {
|
2022-11-22 15:15:52 +01:00
|
|
|
$twofaccount->counter = TwoFAccount::DEFAULT_COUNTER;
|
|
|
|
}
|
2022-07-05 10:10:24 +02:00
|
|
|
});
|
|
|
|
|
2023-03-08 09:41:18 +01:00
|
|
|
static::created(function (object $model) {
|
|
|
|
Log::info(sprintf('TwoFAccount ID #%d created for user ID #%s', $model->id, $model->user_id));
|
|
|
|
});
|
|
|
|
static::updated(function (object $model) {
|
|
|
|
Log::info(sprintf('TwoFAccount ID #%d updated by user ID #%s', $model->id, $model->user_id));
|
|
|
|
});
|
|
|
|
static::deleted(function (object $model) {
|
|
|
|
Log::info(sprintf('TwoFAccount ID #%d deleted ', $model->id));
|
|
|
|
});
|
2020-01-31 23:05:06 +01:00
|
|
|
}
|
|
|
|
|
2020-03-25 21:58:05 +01:00
|
|
|
/**
|
2021-09-17 23:55:09 +02:00
|
|
|
* Settings for @spatie/eloquent-sortable package
|
2020-03-25 21:58:05 +01:00
|
|
|
*
|
|
|
|
* @var array
|
|
|
|
*/
|
|
|
|
public $sortable = [
|
2022-11-22 15:15:52 +01:00
|
|
|
'order_column_name' => 'order_column',
|
2020-03-25 21:58:05 +01:00
|
|
|
'sort_when_creating' => true,
|
|
|
|
];
|
|
|
|
|
2022-07-05 10:10:24 +02:00
|
|
|
/**
|
|
|
|
* The OTP generator.
|
|
|
|
* Instanciated as null to keep the model light
|
|
|
|
*
|
2022-08-26 15:57:18 +02:00
|
|
|
* @var \OTPHP\OTPInterface|null
|
2022-07-05 10:10:24 +02:00
|
|
|
*/
|
|
|
|
protected $generator = null;
|
|
|
|
|
2023-02-27 00:32:49 +01:00
|
|
|
/**
|
|
|
|
* Get the user that owns the twofaccount.
|
|
|
|
*
|
|
|
|
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\App\Models\User, \App\Models\TwoFAccount>
|
|
|
|
*/
|
|
|
|
public function user()
|
|
|
|
{
|
|
|
|
return $this->belongsTo(\App\Models\User::class);
|
|
|
|
}
|
|
|
|
|
2023-04-19 08:38:48 +02:00
|
|
|
/**
|
|
|
|
* Scope a query to only include orphan (userless) accounts.
|
|
|
|
*
|
|
|
|
* @param \Illuminate\Database\Eloquent\Builder<User> $query
|
|
|
|
* @return \Illuminate\Database\Eloquent\Builder<User>
|
|
|
|
*/
|
|
|
|
public function scopeOrphans($query)
|
|
|
|
{
|
|
|
|
return $query->where('user_id', null);
|
|
|
|
}
|
|
|
|
|
2020-01-24 12:56:38 +01:00
|
|
|
/**
|
2021-09-17 23:55:09 +02:00
|
|
|
* Get legacy_uri attribute
|
2020-11-12 00:09:33 +01:00
|
|
|
*
|
|
|
|
* @param string $value
|
|
|
|
* @return string
|
|
|
|
*/
|
2021-09-17 23:55:09 +02:00
|
|
|
public function getLegacyUriAttribute($value)
|
2020-01-24 12:56:38 +01:00
|
|
|
{
|
2021-09-17 23:55:09 +02:00
|
|
|
return $this->decryptOrReturn($value);
|
2020-01-24 12:56:38 +01:00
|
|
|
}
|
2022-11-22 15:15:52 +01:00
|
|
|
|
2020-01-21 21:31:28 +01:00
|
|
|
/**
|
2021-09-17 23:55:09 +02:00
|
|
|
* Set legacy_uri attribute
|
2020-01-21 21:31:28 +01:00
|
|
|
*
|
|
|
|
* @param string $value
|
|
|
|
* @return void
|
|
|
|
*/
|
2021-09-17 23:55:09 +02:00
|
|
|
public function setLegacyUriAttribute($value)
|
2020-10-31 01:16:15 +01:00
|
|
|
{
|
2020-11-18 01:13:00 +01:00
|
|
|
// Encrypt if needed
|
2021-09-17 23:55:09 +02:00
|
|
|
$this->attributes['legacy_uri'] = $this->encryptOrReturn($value);
|
2020-10-31 01:16:15 +01:00
|
|
|
}
|
2020-01-21 21:31:28 +01:00
|
|
|
|
|
|
|
/**
|
2020-11-18 01:13:00 +01:00
|
|
|
* Get account attribute
|
2020-01-21 21:31:28 +01:00
|
|
|
*
|
|
|
|
* @param string $value
|
|
|
|
* @return string
|
|
|
|
*/
|
2020-11-12 00:09:33 +01:00
|
|
|
public function getAccountAttribute($value)
|
2020-10-31 01:16:15 +01:00
|
|
|
{
|
2021-09-17 23:55:09 +02:00
|
|
|
return $this->decryptOrReturn($value);
|
2020-10-31 01:16:15 +01:00
|
|
|
}
|
2022-11-22 15:15:52 +01:00
|
|
|
|
2020-10-31 01:16:15 +01:00
|
|
|
/**
|
2021-09-17 23:55:09 +02:00
|
|
|
* Set account attribute
|
2020-10-31 01:16:15 +01:00
|
|
|
*
|
2022-11-22 15:15:52 +01:00
|
|
|
* @param string $value
|
2020-10-31 01:16:15 +01:00
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
public function setAccountAttribute($value)
|
|
|
|
{
|
2020-11-18 01:13:00 +01:00
|
|
|
// Encrypt when needed
|
2021-09-17 23:55:09 +02:00
|
|
|
$this->attributes['account'] = $this->encryptOrReturn($value);
|
2020-10-31 01:16:15 +01:00
|
|
|
}
|
2020-01-21 21:31:28 +01:00
|
|
|
|
2020-10-31 01:16:15 +01:00
|
|
|
/**
|
2021-09-17 23:55:09 +02:00
|
|
|
* Get secret attribute
|
2020-10-31 01:16:15 +01:00
|
|
|
*
|
2021-09-17 23:55:09 +02:00
|
|
|
* @param string $value
|
|
|
|
* @return string
|
2020-10-31 01:16:15 +01:00
|
|
|
*/
|
2021-09-17 23:55:09 +02:00
|
|
|
public function getSecretAttribute($value)
|
2020-10-31 01:16:15 +01:00
|
|
|
{
|
2021-09-17 23:55:09 +02:00
|
|
|
return $this->decryptOrReturn($value);
|
2020-11-14 18:55:10 +01:00
|
|
|
}
|
2022-11-22 15:15:52 +01:00
|
|
|
|
2020-11-14 18:55:10 +01:00
|
|
|
/**
|
2021-09-17 23:55:09 +02:00
|
|
|
* Set secret attribute
|
|
|
|
*
|
2022-11-22 15:15:52 +01:00
|
|
|
* @param string $value
|
2021-09-17 23:55:09 +02:00
|
|
|
* @return void
|
2020-11-14 18:55:10 +01:00
|
|
|
*/
|
2021-09-17 23:55:09 +02:00
|
|
|
public function setSecretAttribute($value)
|
2020-11-14 18:55:10 +01:00
|
|
|
{
|
2021-09-17 23:55:09 +02:00
|
|
|
// Encrypt when needed
|
2022-12-13 09:05:56 +01:00
|
|
|
$this->attributes['secret'] = $this->encryptOrReturn(Helpers::PadToBase32Format($value));
|
2020-11-14 18:55:10 +01:00
|
|
|
}
|
|
|
|
|
2022-07-05 10:10:24 +02:00
|
|
|
/**
|
|
|
|
* Set digits attribute
|
|
|
|
*
|
2022-11-22 15:15:52 +01:00
|
|
|
* @param string $value
|
2022-07-05 10:10:24 +02:00
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
public function setDigitsAttribute($value)
|
|
|
|
{
|
2022-12-13 12:07:29 +01:00
|
|
|
$this->attributes['digits'] = ! $value ? 6 : $value;
|
2022-07-05 10:10:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Set algorithm attribute
|
|
|
|
*
|
2022-11-22 15:15:52 +01:00
|
|
|
* @param string $value
|
2022-07-05 10:10:24 +02:00
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
public function setAlgorithmAttribute($value)
|
|
|
|
{
|
2022-12-13 12:07:29 +01:00
|
|
|
$this->attributes['algorithm'] = ! $value ? self::SHA1 : strtolower($value);
|
2022-07-05 10:10:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Set period attribute
|
|
|
|
*
|
2022-11-22 15:15:52 +01:00
|
|
|
* @param string $value
|
2022-07-05 10:10:24 +02:00
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
public function setPeriodAttribute($value)
|
|
|
|
{
|
2022-12-13 12:07:29 +01:00
|
|
|
$this->attributes['period'] = ! $value && $this->otp_type === self::TOTP ? self::DEFAULT_PERIOD : $value;
|
2022-07-05 10:10:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Set counter attribute
|
|
|
|
*
|
2022-11-22 15:15:52 +01:00
|
|
|
* @param string $value
|
2022-07-05 10:10:24 +02:00
|
|
|
* @return void
|
|
|
|
*/
|
|
|
|
public function setCounterAttribute($value)
|
|
|
|
{
|
2022-09-07 17:54:27 +02:00
|
|
|
$this->attributes['counter'] = blank($value) && $this->otp_type === self::HOTP ? self::DEFAULT_COUNTER : $value;
|
2022-07-05 10:10:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns a One-Time Password with its parameters
|
2022-11-22 15:15:52 +01:00
|
|
|
*
|
|
|
|
* @return TotpDto|HotpDto
|
|
|
|
*
|
2022-07-05 10:10:24 +02:00
|
|
|
* @throws InvalidSecretException The secret is not a valid base32 encoded string
|
|
|
|
* @throws UndecipherableException The secret cannot be deciphered
|
2022-09-07 17:54:27 +02:00
|
|
|
* @throws UnsupportedOtpTypeException The defined OTP type is not supported
|
|
|
|
* @throws InvalidOtpParameterException One OTP parameter is invalid
|
2022-07-05 10:10:24 +02:00
|
|
|
*/
|
2023-04-14 17:13:28 +02:00
|
|
|
public function getOTP(?int $time = null)
|
2022-07-05 10:10:24 +02:00
|
|
|
{
|
2022-11-22 15:15:52 +01:00
|
|
|
Log::info(sprintf('OTP requested for TwoFAccount (%s)', $this->id ? 'id:' . $this->id : 'preview'));
|
2022-07-05 10:10:24 +02:00
|
|
|
|
|
|
|
// Early exit if the model has an undecipherable secret
|
|
|
|
if (strtolower($this->secret) === __('errors.indecipherable')) {
|
|
|
|
Log::error('Secret cannot be deciphered, OTP generation aborted');
|
|
|
|
|
|
|
|
throw new UndecipherableException();
|
|
|
|
}
|
|
|
|
|
|
|
|
$this->initGenerator();
|
2022-09-07 17:54:27 +02:00
|
|
|
|
2022-11-22 15:15:52 +01:00
|
|
|
try {
|
|
|
|
if ($this->otp_type === self::HOTP) {
|
|
|
|
$OtpDto = new HotpDto();
|
|
|
|
$OtpDto->otp_type = $this->otp_type;
|
|
|
|
$counter = $this->generator->getParameter('counter');
|
|
|
|
$OtpDto->password = $this->generator->at($counter);
|
|
|
|
$OtpDto->counter = $this->counter = $counter + 1;
|
2022-09-14 16:37:58 +02:00
|
|
|
|
|
|
|
// The updated HOTP counter must be saved to db for persisted account only
|
|
|
|
if ($this->id) {
|
|
|
|
$this->save();
|
|
|
|
}
|
2022-11-22 15:15:52 +01:00
|
|
|
} else {
|
|
|
|
$OtpDto = new TotpDto();
|
|
|
|
$OtpDto->otp_type = $this->otp_type;
|
2023-04-14 17:13:28 +02:00
|
|
|
$OtpDto->generated_at = $time ?: time();
|
2022-11-22 15:15:52 +01:00
|
|
|
$OtpDto->password = $this->otp_type === self::TOTP
|
2022-12-09 10:55:11 +01:00
|
|
|
? $this->generator->at($OtpDto->generated_at)
|
|
|
|
: SteamTotp::getAuthCode(base64_encode(Base32::decodeUpper($this->secret)));
|
2022-11-22 15:15:52 +01:00
|
|
|
$OtpDto->period = $this->period;
|
2022-07-05 10:10:24 +02:00
|
|
|
}
|
|
|
|
|
2022-11-22 15:15:52 +01:00
|
|
|
Log::info(sprintf('New OTP generated for TwoFAccount (%s)', $this->id ? 'id:' . $this->id : 'preview'));
|
2022-07-05 10:10:24 +02:00
|
|
|
|
2022-11-22 15:15:52 +01:00
|
|
|
return $OtpDto;
|
2023-03-26 17:13:32 +02:00
|
|
|
} catch (\Exception|\Throwable $ex) {
|
2022-07-05 10:10:24 +02:00
|
|
|
Log::error('An error occured, OTP generation aborted');
|
|
|
|
// Currently a secret issue is the only possible exception thrown by OTPHP for this stack
|
|
|
|
// so it is Ok to send the corresponding 2FAuth exception.
|
|
|
|
// If the generator package change it could be necessary to throw a more generic exception.
|
|
|
|
throw new InvalidSecretException($ex->getMessage());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Fill the model using an array of OTP parameters.
|
|
|
|
* Missing parameters will be set with default values
|
2022-11-22 15:15:52 +01:00
|
|
|
*
|
2022-07-05 10:10:24 +02:00
|
|
|
* @return $this
|
|
|
|
*/
|
2022-07-26 22:35:04 +02:00
|
|
|
public function fillWithOtpParameters(array $parameters, bool $skipIconFetching = false)
|
2022-07-05 10:10:24 +02:00
|
|
|
{
|
2022-11-22 15:15:52 +01:00
|
|
|
$this->otp_type = strtolower(Arr::get($parameters, 'otp_type'));
|
|
|
|
$this->account = Arr::get($parameters, 'account');
|
|
|
|
$this->service = Arr::get($parameters, 'service');
|
|
|
|
$this->icon = Arr::get($parameters, 'icon');
|
|
|
|
$this->secret = Arr::get($parameters, 'secret');
|
|
|
|
$this->algorithm = strtolower(Arr::get($parameters, 'algorithm', self::SHA1));
|
|
|
|
$this->digits = Arr::get($parameters, 'digits', self::DEFAULT_DIGITS);
|
|
|
|
$this->period = Arr::get($parameters, 'period', $this->otp_type == self::TOTP ? self::DEFAULT_PERIOD : null);
|
|
|
|
$this->counter = Arr::get($parameters, 'counter', $this->otp_type == self::HOTP ? self::DEFAULT_COUNTER : null);
|
2022-07-05 10:10:24 +02:00
|
|
|
|
|
|
|
$this->initGenerator();
|
2022-10-10 13:02:36 +02:00
|
|
|
|
|
|
|
// The generator could have been initialized without a secret, in that case it generates one on the fly.
|
|
|
|
// The secret attribute has thus to be updated
|
|
|
|
$this->secret = $this->secret ?: $this->generator->getSecret();
|
2022-11-22 15:15:52 +01:00
|
|
|
|
2022-07-07 11:43:23 +02:00
|
|
|
if ($this->otp_type === self::STEAM_TOTP || strtolower($this->service) === 'steam') {
|
2022-07-05 10:10:24 +02:00
|
|
|
$this->enforceAsSteam();
|
|
|
|
}
|
|
|
|
|
2023-06-07 16:24:06 +02:00
|
|
|
if (! $this->icon && ! $skipIconFetching) {
|
2022-07-26 22:35:04 +02:00
|
|
|
$this->icon = $this->getDefaultIcon();
|
2022-11-22 15:15:52 +01:00
|
|
|
}
|
2022-07-26 22:35:04 +02:00
|
|
|
|
2022-07-05 10:10:24 +02:00
|
|
|
Log::info(sprintf('TwoFAccount filled with OTP parameters'));
|
|
|
|
|
|
|
|
return $this;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Fill the model by parsing an otpauth URI
|
2022-11-22 15:15:52 +01:00
|
|
|
*
|
2022-07-05 10:10:24 +02:00
|
|
|
* @return $this
|
|
|
|
*/
|
2022-07-26 22:35:04 +02:00
|
|
|
public function fillWithURI(string $uri, bool $isSteamTotp = false, bool $skipIconFetching = false)
|
2022-07-05 10:10:24 +02:00
|
|
|
{
|
|
|
|
// First we instanciate the OTP generator
|
|
|
|
try {
|
2023-01-25 13:12:03 +01:00
|
|
|
$this->generator = Factory::loadFromProvisioningUri($isSteamTotp ? str_replace('otpauth://steam', 'otpauth://totp', $uri) : $uri);
|
2023-03-26 23:04:06 +02:00
|
|
|
} catch (\Exception|\Throwable $ex) {
|
2023-07-06 17:10:19 +02:00
|
|
|
|
|
|
|
// Hack for Microsoft corporate 2FAs for whom the Issuer query parameter != the Issuer aside the account
|
|
|
|
$parsed_uri = \OTPHP\Url::fromString($uri);
|
|
|
|
$pathChunks = explode(':', rawurldecode(mb_substr($parsed_uri->getPath(), 1)));
|
|
|
|
$service = $pathChunks[0];
|
|
|
|
$issuer = data_get($parsed_uri->getQuery(), 'issuer', $service);
|
|
|
|
|
|
|
|
if (count($pathChunks) == 2 && strtolower($issuer) == 'microsoft' && strcasecmp($issuer, $service) != 0) {
|
|
|
|
$newUri = str_replace($pathChunks[0] . ':', '', rawurldecode($uri));
|
|
|
|
try {
|
|
|
|
$this->generator = Factory::loadFromProvisioningUri($newUri);
|
|
|
|
$this->generator->setLabel($service . '_' . $this->generator->getLabel());
|
|
|
|
} catch (\Exception|\Throwable $ex) {
|
|
|
|
throw ValidationException::withMessages([
|
|
|
|
'uri' => __('validation.custom.uri.regex', ['attribute' => 'uri']),
|
|
|
|
]);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
throw ValidationException::withMessages([
|
|
|
|
'uri' => __('validation.custom.uri.regex', ['attribute' => 'uri']),
|
|
|
|
]);
|
|
|
|
}
|
2022-07-05 10:10:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// As loadFromProvisioningUri() accept URI without label (nor account nor service) we check
|
|
|
|
// that the account is set
|
2022-12-13 12:07:29 +01:00
|
|
|
if (! $this->generator->getLabel()) {
|
2022-07-05 10:10:24 +02:00
|
|
|
Log::error('URI passed to fillWithURI() must contain a label');
|
|
|
|
|
|
|
|
throw ValidationException::withMessages([
|
2022-11-22 15:15:52 +01:00
|
|
|
'label' => __('validation.custom.label.required'),
|
2022-07-05 10:10:24 +02:00
|
|
|
]);
|
|
|
|
}
|
|
|
|
|
2022-11-22 15:15:52 +01:00
|
|
|
$this->otp_type = $this->getGeneratorOtpType();
|
|
|
|
$this->account = $this->generator->getLabel();
|
|
|
|
$this->secret = $this->generator->getSecret();
|
|
|
|
$this->service = $this->generator->getIssuer();
|
|
|
|
$this->algorithm = $this->generator->getDigest();
|
|
|
|
$this->digits = $this->generator->getDigits();
|
|
|
|
$this->period = $this->generator->hasParameter('period') ? $this->generator->getParameter('period') : null;
|
|
|
|
$this->counter = $this->generator->hasParameter('counter') ? $this->generator->getParameter('counter') : null;
|
|
|
|
$this->legacy_uri = $uri;
|
|
|
|
|
2022-07-07 11:43:23 +02:00
|
|
|
if ($isSteamTotp || strtolower($this->service) === 'steam') {
|
2022-07-05 10:10:24 +02:00
|
|
|
$this->enforceAsSteam();
|
|
|
|
}
|
2022-07-19 17:27:23 +02:00
|
|
|
if ($this->generator->hasParameter('image')) {
|
2022-12-13 09:08:22 +01:00
|
|
|
self::setIcon($this->generator->getParameter('image'));
|
2022-07-19 17:27:23 +02:00
|
|
|
}
|
2023-03-10 22:59:46 +01:00
|
|
|
|
2023-06-07 16:24:06 +02:00
|
|
|
if (! $this->icon && ! $skipIconFetching) {
|
2022-07-20 13:31:51 +02:00
|
|
|
$this->icon = $this->getDefaultIcon();
|
2022-11-22 15:15:52 +01:00
|
|
|
}
|
2022-07-05 10:10:24 +02:00
|
|
|
|
|
|
|
Log::info(sprintf('TwoFAccount filled with an URI'));
|
|
|
|
|
|
|
|
return $this;
|
|
|
|
}
|
|
|
|
|
2022-12-09 10:52:17 +01:00
|
|
|
/**
|
|
|
|
* Compare 2 TwoFAccounts
|
|
|
|
*/
|
2022-12-13 12:07:29 +01:00
|
|
|
public function equals(self $other) : bool
|
2022-12-09 10:52:17 +01:00
|
|
|
{
|
|
|
|
return $this->service === $other->service &&
|
|
|
|
$this->account === $other->account &&
|
|
|
|
$this->icon === $other->icon &&
|
|
|
|
$this->otp_type === $other->otp_type &&
|
|
|
|
$this->secret === $other->secret &&
|
|
|
|
$this->digits === $other->digits &&
|
|
|
|
$this->algorithm === $other->algorithm &&
|
|
|
|
$this->period === $other->period &&
|
|
|
|
$this->counter === $other->counter;
|
|
|
|
}
|
|
|
|
|
2022-07-05 10:10:24 +02:00
|
|
|
/**
|
|
|
|
* Sets model attributes to STEAM values
|
|
|
|
*/
|
2022-12-13 12:07:29 +01:00
|
|
|
private function enforceAsSteam() : void
|
2022-07-05 10:10:24 +02:00
|
|
|
{
|
|
|
|
$this->otp_type = self::STEAM_TOTP;
|
2023-01-25 13:12:03 +01:00
|
|
|
$this->service = 'Steam';
|
2022-07-05 10:10:24 +02:00
|
|
|
$this->digits = 5;
|
|
|
|
$this->algorithm = self::SHA1;
|
|
|
|
$this->period = 30;
|
2022-11-22 15:15:52 +01:00
|
|
|
|
2022-07-14 18:05:19 +02:00
|
|
|
Log::info(sprintf('TwoFAccount configured as Steam account'));
|
2022-07-05 10:10:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns the OTP type of the instanciated OTP generator
|
2022-11-22 15:15:52 +01:00
|
|
|
*
|
2022-09-07 17:54:27 +02:00
|
|
|
* @return mixed
|
2022-07-05 10:10:24 +02:00
|
|
|
*/
|
|
|
|
private function getGeneratorOtpType()
|
|
|
|
{
|
2022-07-13 11:02:09 +02:00
|
|
|
return Arr::get($this->generatorClassMap, get_class($this->generator));
|
2022-07-05 10:10:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns an otpauth URI built with model attribute values
|
|
|
|
*/
|
2022-12-13 12:07:29 +01:00
|
|
|
public function getURI() : string
|
2022-07-05 10:10:24 +02:00
|
|
|
{
|
|
|
|
$this->initGenerator();
|
|
|
|
|
|
|
|
return $this->generator->getProvisioningUri();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Instanciates the OTP generator with model attribute values
|
2022-11-22 15:15:52 +01:00
|
|
|
*
|
2022-09-07 17:54:27 +02:00
|
|
|
* @throws UnsupportedOtpTypeException The defined OTP type is not supported
|
|
|
|
* @throws InvalidOtpParameterException One OTP parameter is invalid
|
2022-07-05 10:10:24 +02:00
|
|
|
*/
|
2022-12-13 12:07:29 +01:00
|
|
|
private function initGenerator() : void
|
2022-07-05 10:10:24 +02:00
|
|
|
{
|
|
|
|
try {
|
|
|
|
switch ($this->otp_type) {
|
|
|
|
case self::TOTP:
|
|
|
|
$this->generator = TOTP::create(
|
|
|
|
$this->secret,
|
|
|
|
$this->period ?: self::DEFAULT_PERIOD,
|
|
|
|
$this->algorithm ?: self::DEFAULT_ALGORITHM,
|
|
|
|
$this->digits ?: self::DEFAULT_DIGITS
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
|
|
|
|
case self::STEAM_TOTP:
|
|
|
|
$this->generator = TOTP::create($this->secret, 30, self::SHA1, 5);
|
|
|
|
break;
|
|
|
|
|
|
|
|
case self::HOTP:
|
|
|
|
$this->generator = HOTP::create(
|
|
|
|
$this->secret,
|
|
|
|
$this->counter ?: self::DEFAULT_COUNTER,
|
|
|
|
$this->algorithm ?: self::DEFAULT_ALGORITHM,
|
|
|
|
$this->digits ?: self::DEFAULT_DIGITS
|
|
|
|
);
|
|
|
|
break;
|
2022-11-22 15:15:52 +01:00
|
|
|
|
2022-07-05 10:10:24 +02:00
|
|
|
default:
|
|
|
|
throw new UnsupportedOtpTypeException();
|
|
|
|
}
|
|
|
|
|
2022-11-22 15:15:52 +01:00
|
|
|
if ($this->service) {
|
|
|
|
$this->generator->setIssuer($this->service);
|
|
|
|
}
|
|
|
|
if ($this->account) {
|
|
|
|
$this->generator->setLabel($this->account);
|
|
|
|
}
|
|
|
|
} catch (UnsupportedOtpTypeException $exception) {
|
2022-07-05 10:10:24 +02:00
|
|
|
Log::error(sprintf('%s is not an OTP type supported by the current generator', $this->otp_type));
|
|
|
|
throw $exception;
|
2023-03-26 17:13:32 +02:00
|
|
|
} catch (\Exception|\Throwable $exception) {
|
2022-07-05 10:10:24 +02:00
|
|
|
throw new InvalidOtpParameterException($exception->getMessage());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-12-13 09:08:22 +01:00
|
|
|
/**
|
|
|
|
* Store and set the provided icon
|
2022-12-13 12:07:29 +01:00
|
|
|
*
|
2022-12-13 09:08:22 +01:00
|
|
|
* @param \Psr\Http\Message\StreamInterface|\Illuminate\Http\File|\Illuminate\Http\UploadedFile|string|resource $data
|
|
|
|
* @param string|null $extension The resource extension, without the dot
|
|
|
|
*/
|
2022-12-13 12:07:29 +01:00
|
|
|
public function setIcon($data, $extension = null) : void
|
2022-12-13 09:08:22 +01:00
|
|
|
{
|
|
|
|
$isRemoteData = Str::startsWith($data, ['http://', 'https://']) && Validator::make(
|
|
|
|
[$data],
|
|
|
|
['url']
|
|
|
|
)->passes();
|
|
|
|
|
|
|
|
if ($isRemoteData) {
|
|
|
|
$icon = $this->storeRemoteImageAsIcon($data);
|
|
|
|
} else {
|
|
|
|
$icon = $extension ? $this->storeFileDataAsIcon($data, $extension) : null;
|
|
|
|
}
|
|
|
|
|
|
|
|
$this->icon = $icon ?: $this->icon;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Store img data as an icon file.
|
|
|
|
*
|
|
|
|
* @param \Psr\Http\Message\StreamInterface|\Illuminate\Http\File|\Illuminate\Http\UploadedFile|string|resource $content
|
|
|
|
* @param string $extension The file extension, without the dot
|
|
|
|
* @return string|null The filename of the stored icon or null if the operation fails
|
|
|
|
*/
|
2022-12-13 12:07:29 +01:00
|
|
|
private function storeFileDataAsIcon($content, $extension) : string|null
|
2022-12-13 09:08:22 +01:00
|
|
|
{
|
|
|
|
$filename = self::getUniqueFilename($extension);
|
|
|
|
|
|
|
|
if (Storage::disk('icons')->put($filename, $content)) {
|
|
|
|
if (self::isValidIcon($filename, 'icons')) {
|
2023-02-27 00:33:42 +01:00
|
|
|
Log::info(sprintf('Image "%s" successfully stored for import', $filename));
|
2022-12-13 09:08:22 +01:00
|
|
|
|
|
|
|
return $filename;
|
|
|
|
} else {
|
|
|
|
Storage::disk('icons')->delete($filename);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Generate a unique filename
|
|
|
|
*
|
|
|
|
* @return string The filename
|
|
|
|
*/
|
2022-12-13 12:07:29 +01:00
|
|
|
private function getUniqueFilename(string $extension) : string
|
2022-12-13 09:08:22 +01:00
|
|
|
{
|
|
|
|
return Str::random(40) . '.' . $extension;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Validate a file is a valid image
|
|
|
|
*
|
|
|
|
* @param string $filename
|
|
|
|
* @param string $disk
|
|
|
|
*/
|
2022-12-13 12:07:29 +01:00
|
|
|
private function isValidIcon($filename, $disk) : bool
|
2022-12-13 09:08:22 +01:00
|
|
|
{
|
|
|
|
return in_array(Storage::disk($disk)->mimeType($filename), [
|
|
|
|
'image/png',
|
|
|
|
'image/jpeg',
|
|
|
|
'image/webp',
|
|
|
|
'image/bmp',
|
|
|
|
'image/x-ms-bmp',
|
2022-12-13 12:07:29 +01:00
|
|
|
'image/svg+xml',
|
2022-12-13 09:08:22 +01:00
|
|
|
]) && (Storage::disk($disk)->mimeType($filename) !== 'image/svg+xml' ? getimagesize(Storage::disk($disk)->path($filename)) : true);
|
|
|
|
}
|
|
|
|
|
2022-07-05 10:10:24 +02:00
|
|
|
/**
|
2022-07-07 11:43:23 +02:00
|
|
|
* Gets the image resource pointed by the image url and store it as an icon
|
2022-11-22 15:15:52 +01:00
|
|
|
*
|
2022-07-05 10:10:24 +02:00
|
|
|
* @return string|null The filename of the stored icon or null if the operation fails
|
|
|
|
*/
|
2022-12-13 12:07:29 +01:00
|
|
|
private function storeRemoteImageAsIcon(string $url) : string|null
|
2022-07-05 10:10:24 +02:00
|
|
|
{
|
|
|
|
try {
|
2022-11-22 15:15:52 +01:00
|
|
|
$path_parts = pathinfo($url);
|
2022-12-13 09:08:22 +01:00
|
|
|
$newFilename = self::getUniqueFilename($path_parts['extension']);
|
2022-07-05 10:10:24 +02:00
|
|
|
|
2022-07-19 18:27:09 +02:00
|
|
|
try {
|
|
|
|
$response = Http::retry(3, 100)->get($url);
|
2022-11-22 15:15:52 +01:00
|
|
|
|
2022-07-19 18:27:09 +02:00
|
|
|
if ($response->successful()) {
|
|
|
|
Storage::disk('imagesLink')->put($newFilename, $response->body());
|
|
|
|
}
|
2022-11-22 15:15:52 +01:00
|
|
|
} catch (\Exception $exception) {
|
2022-07-19 18:27:09 +02:00
|
|
|
Log::error(sprintf('Cannot fetch imageLink at "%s"', $url));
|
|
|
|
}
|
2022-07-05 10:10:24 +02:00
|
|
|
|
2022-12-13 09:08:22 +01:00
|
|
|
if (self::isValidIcon($newFilename, 'imagesLink')) {
|
2022-07-19 18:27:09 +02:00
|
|
|
// Should be a valid image, we move it to the icons disk
|
|
|
|
if (Storage::disk('icons')->put($newFilename, Storage::disk('imagesLink')->get($newFilename))) {
|
|
|
|
Storage::disk('imagesLink')->delete($newFilename);
|
|
|
|
}
|
2022-11-22 15:15:52 +01:00
|
|
|
|
2023-02-27 00:33:42 +01:00
|
|
|
Log::info(sprintf('Icon file "%s" stored', $newFilename));
|
2022-11-22 15:15:52 +01:00
|
|
|
} else {
|
2022-07-19 18:27:09 +02:00
|
|
|
Storage::disk('imagesLink')->delete($newFilename);
|
2022-07-14 18:05:19 +02:00
|
|
|
throw new \Exception('Unsupported mimeType or missing image on storage');
|
2022-07-05 10:10:24 +02:00
|
|
|
}
|
2022-11-22 15:15:52 +01:00
|
|
|
|
2022-12-13 09:08:22 +01:00
|
|
|
return Storage::disk('icons')->exists($newFilename) ? $newFilename : null;
|
2022-07-05 10:10:24 +02:00
|
|
|
}
|
|
|
|
// @codeCoverageIgnoreStart
|
2023-03-26 17:13:32 +02:00
|
|
|
catch (\Exception|\Throwable $ex) {
|
2022-07-14 18:05:19 +02:00
|
|
|
Log::error(sprintf('Icon storage failed: %s', $ex->getMessage()));
|
2022-11-22 15:15:52 +01:00
|
|
|
|
2022-07-05 10:10:24 +02:00
|
|
|
return null;
|
|
|
|
}
|
|
|
|
// @codeCoverageIgnoreEnd
|
|
|
|
}
|
|
|
|
|
2022-07-19 17:27:23 +02:00
|
|
|
/**
|
2023-06-07 16:24:06 +02:00
|
|
|
* Triggers logo fetching if necessary
|
2022-11-22 15:15:52 +01:00
|
|
|
*
|
2022-07-19 17:27:23 +02:00
|
|
|
* @return string|null The icon
|
|
|
|
*/
|
2022-07-20 13:31:51 +02:00
|
|
|
private function getDefaultIcon()
|
2022-07-19 17:27:23 +02:00
|
|
|
{
|
2023-06-07 16:24:06 +02:00
|
|
|
// $logoService = App::make(LogoService::class);
|
2022-07-19 17:27:23 +02:00
|
|
|
|
2023-06-07 16:24:06 +02:00
|
|
|
return (bool) Auth::user()?->preferences['getOfficialIcons']
|
|
|
|
? App::make(LogoService::class)->getIcon($this->service)
|
|
|
|
: null;
|
2022-07-19 17:27:23 +02:00
|
|
|
}
|
|
|
|
|
2020-11-12 00:09:33 +01:00
|
|
|
/**
|
2021-09-17 23:55:09 +02:00
|
|
|
* Returns an acceptable value
|
2020-11-06 21:49:43 +01:00
|
|
|
*/
|
2022-12-13 12:07:29 +01:00
|
|
|
private function decryptOrReturn(mixed $value) : mixed
|
2020-11-06 21:49:43 +01:00
|
|
|
{
|
2021-09-17 23:55:09 +02:00
|
|
|
// Decipher when needed
|
2022-11-22 15:15:52 +01:00
|
|
|
if (Settings::get('useEncryption') && $value) {
|
2021-09-17 23:55:09 +02:00
|
|
|
try {
|
|
|
|
return Crypt::decryptString($value);
|
2022-11-22 15:15:52 +01:00
|
|
|
} catch (Exception $ex) {
|
2021-10-11 23:11:52 +02:00
|
|
|
return __('errors.indecipherable');
|
2021-09-17 23:55:09 +02:00
|
|
|
}
|
2022-11-22 15:15:52 +01:00
|
|
|
} else {
|
2021-09-17 23:55:09 +02:00
|
|
|
return $value;
|
2020-11-22 11:00:55 +01:00
|
|
|
}
|
2020-11-12 00:09:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2021-09-17 23:55:09 +02:00
|
|
|
* Encrypt a value
|
2020-11-12 00:09:33 +01:00
|
|
|
*/
|
2022-12-13 12:07:29 +01:00
|
|
|
private function encryptOrReturn(mixed $value) : mixed
|
2020-11-12 00:09:33 +01:00
|
|
|
{
|
2021-09-17 23:55:09 +02:00
|
|
|
// should be replaced by laravel 8 attribute encryption casting
|
2022-07-30 17:51:02 +02:00
|
|
|
return Settings::get('useEncryption') ? Crypt::encryptString($value) : $value;
|
2020-11-06 21:49:43 +01:00
|
|
|
}
|
2023-02-22 20:21:36 +01:00
|
|
|
|
2023-02-25 21:12:10 +01:00
|
|
|
/**
|
|
|
|
* @return \Illuminate\Database\Eloquent\Builder<TwoFAccount>
|
|
|
|
*/
|
2023-02-22 20:21:36 +01:00
|
|
|
public function buildSortQuery()
|
|
|
|
{
|
|
|
|
return static::query()->where('user_id', $this->user_id);
|
|
|
|
}
|
2022-11-22 15:15:52 +01:00
|
|
|
}
|