Fix #132 Move the provisioning_tokens and recovery_codes to dedicated table

This commit is contained in:
Timothée Jaussoin 2023-12-14 14:13:41 +00:00
parent b409e37ab1
commit 5717994ab8
18 changed files with 472 additions and 53 deletions

View file

@ -38,9 +38,9 @@ class Account extends Authenticatable
use HasFactory;
use Compoships;
protected $with = ['passwords', 'admin', 'alias', 'activationExpiration', 'emailChangeCode', 'types', 'actions'];
protected $hidden = ['alias', 'expire_time', 'confirmation_key', 'provisioning_token', 'pivot'];
protected $appends = ['realm', 'phone', 'confirmation_key_expires'];
protected $with = ['passwords', 'admin', 'alias', 'currentRecoveryCode', 'activationExpiration', 'emailChangeCode', 'types', 'actions'];
protected $hidden = ['alias', 'expire_time', 'confirmation_key', 'pivot'];
protected $appends = ['realm', 'phone', 'confirmation_key_expires', 'provisioning_token'];
protected $casts = [
'activated' => 'boolean',
];
@ -52,15 +52,20 @@ class Account extends Authenticatable
{
parent::boot();
static::deleted(function ($item) {
StatisticsMessage::where('from_username', $item->username)
->where('from_domain', $item->domain)
static::deleted(function (Account $account) {
StatisticsMessage::where('from_username', $account->username)
->where('from_domain', $account->domain)
->delete();
StatisticsCall::where('from_username', $item->username)
->where('from_domain', $item->domain)
StatisticsCall::where('from_username', $account->username)
->where('from_domain', $account->domain)
->delete();
});
static::created(function (Account $account) {
$account->provision();
$account->refresh();
});
}
protected static function booted()
@ -182,14 +187,44 @@ class Account extends Authenticatable
/**
* Tokens and codes
*/
public function currentRecoveryCode()
{
return $this->hasOne(RecoveryCode::class)->whereNotNull('code')->latestOfMany();
}
public function recoveryCodes()
{
return $this->hasMany(RecoveryCode::class)->latest();
}
public function phoneChangeCode()
{
return $this->hasOne(PhoneChangeCode::class);
return $this->hasOne(phoneChangeCode::class)->whereNotNull('code')->latestOfMany();
}
public function phoneChangeCodes()
{
return $this->hasMany(PhoneChangeCode::class)->latest();
}
public function emailChangeCode()
{
return $this->hasOne(EmailChangeCode::class);
return $this->hasOne(EmailChangeCode::class)->whereNotNull('code')->latestOfMany();
}
public function emailChangeCodes()
{
return $this->hasMany(EmailChangeCode::class)->latest();
}
public function currentProvisioningToken()
{
return $this->hasOne(ProvisioningToken::class)->where('used', false)->latestOfMany();
}
public function provisioningTokens()
{
return $this->hasMany(ProvisioningToken::class)->latest();
}
public function authTokens()
@ -200,12 +235,30 @@ class Account extends Authenticatable
/**
* Attributes
*/
public function getIdentifierAttribute()
public function getRecoveryCodeAttribute(): ?string
{
if ($this->currentRecoveryCode) {
return $this->currentRecoveryCode->code;
}
return null;
}
public function getProvisioningTokenAttribute(): ?string
{
if ($this->currentProvisioningToken) {
return $this->currentProvisioningToken->token;
}
return null;
}
public function getIdentifierAttribute(): string
{
return $this->attributes['username'] . '@' . $this->attributes['domain'];
}
public function getFullIdentifierAttribute()
public function getFullIdentifierAttribute(): string
{
$displayName = $this->attributes['display_name']
? '"' . $this->attributes['display_name'] . '" '
@ -309,10 +362,24 @@ class Account extends Authenticatable
return $authToken;
}
public function provision(): string
public function recover(?string $code = null): string
{
$this->provisioning_token = Str::random(WebAuthenticateController::$emailCodeSize);
return $this->provisioning_token;
$recoveryCode = new RecoveryCode;
$recoveryCode->code = $code ?? generatePin();
$recoveryCode->account_id = $this->id;
$recoveryCode->save();
return $recoveryCode->code;
}
public function provision(?string $token = null): string
{
$provisioningToken = new ProvisioningToken;
$provisioningToken->token = $token ?? Str::random(WebAuthenticateController::$emailCodeSize);
$provisioningToken->account_id = $this->id;
$provisioningToken->save();
return $provisioningToken->token;
}
public function getAdminAttribute(): bool

View file

@ -20,9 +20,8 @@
namespace App;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class AccountCreationRequestToken extends Model
class AccountCreationRequestToken extends Consommable
{
use HasFactory;
@ -40,4 +39,10 @@ class AccountCreationRequestToken extends Model
? route('account.creation_request_token.check', $this->token)
: null;
}
public function consume()
{
$this->used = true;
$this->save();
}
}

View file

@ -20,9 +20,8 @@
namespace App;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class AccountCreationToken extends Model
class AccountCreationToken extends Consommable
{
use HasFactory;
@ -32,4 +31,10 @@ class AccountCreationToken extends Model
{
return $this->hasOne(AccountCreationRequestToken::class, 'acc_creation_token_id');
}
public function consume()
{
$this->used = true;
$this->save();
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
abstract class Consommable extends Model
{
protected string $consommableAttribute = 'code';
public function consume()
{
$this->{$this->consommableAttribute} = null;
$this->save();
}
}

View file

@ -20,9 +20,8 @@
namespace App;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class EmailChangeCode extends Model
class EmailChangeCode extends Consommable
{
use HasFactory;

View file

@ -42,7 +42,12 @@ class ProvisioningController extends Controller
public function qrcode(Request $request, string $provisioningToken)
{
$account = Account::withoutGlobalScopes()
->where('provisioning_token', $provisioningToken)
->where('id', function ($query) use ($provisioningToken) {
$query->select('account_id')
->from('provisioning_tokens')
->where('used', false)
->where('token', $provisioningToken);
})
->firstOrFail();
if ($account->activationExpired()) abort(404);
@ -108,7 +113,12 @@ class ProvisioningController extends Controller
public function provision(Request $request, string $provisioningToken)
{
$account = Account::withoutGlobalScopes()
->where('provisioning_token', $provisioningToken)
->where('id', function ($query) use ($provisioningToken) {
$query->select('account_id')
->from('provisioning_tokens')
->where('used', false)
->where('token', $provisioningToken);
})
->firstOrFail();
if ($account->activationExpired() || ($provisioningToken != $account->provisioning_token)) {
@ -116,7 +126,7 @@ class ProvisioningController extends Controller
}
$account->activated = true;
$account->provisioning_token = null;
$account->currentProvisioningToken->consume();
$account->save();
return $this->generateProvisioning($request, $account);

View file

@ -135,13 +135,12 @@ class AccountController extends Controller
$account->ip_address = $request->ip();
$account->created_at = Carbon::now();
$account->user_agent = $request->header('User-Agent') ?? config('app.name');
$account->provision();
$account->save();
$account->updatePassword($request->get('password'), $request->get('algorithm'));
$token = AccountCreationToken::where('token', $request->get('account_creation_token'))->first();
$token->used = true;
$token->consume();
$token->account_id = $account->id;
$token->save();
@ -208,7 +207,7 @@ class AccountController extends Controller
$account->save();
$token = AccountCreationToken::where('token', $request->get('account_creation_token'))->first();
$token->used = true;
$token->consume();
$token->account_id = $account->id;
$token->save();
@ -255,11 +254,6 @@ class AccountController extends Controller
public function activateEmail(Request $request, string $sip)
{
// For retro-compatibility
if ($request->has('code')) {
$request->merge(['confirmation_key' => $request->get('code')]);
}
$request->validate([
'confirmation_key' => 'required|size:' . WebAuthenticateController::$emailCodeSize
]);
@ -281,11 +275,6 @@ class AccountController extends Controller
public function activatePhone(Request $request, string $sip)
{
// For retro-compatibility
if ($request->has('code')) {
$request->merge(['confirmation_key' => $request->get('code')]);
}
$request->validate([
'confirmation_key' => 'required|digits:4'
]);

View file

@ -90,7 +90,7 @@ class CreationTokenController extends Controller
$accountCreationToken->token = Str::random(WebAuthenticateController::$emailCodeSize);
$accountCreationToken->save();
$creationRequestToken->used = true;
$creationRequestToken->consume();
$creationRequestToken->acc_creation_token_id = $accountCreationToken->id;
$creationRequestToken->save();

View file

@ -131,7 +131,6 @@ class AccountController extends Controller
if (!$request->has('activated') || !(bool)$request->get('activated')) {
$account->confirmation_key = Str::random(WebAuthenticateController::$emailCodeSize);
$account->provision();
}
$account->save();

View file

@ -20,9 +20,8 @@
namespace App;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PhoneChangeCode extends Model
class PhoneChangeCode extends Consommable
{
use HasFactory;

View file

@ -0,0 +1,33 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2020 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class ProvisioningToken extends Consommable
{
use HasFactory;
public function consume()
{
$this->used = true;
$this->save();
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class RecoveryCode extends Consommable
{
use HasFactory;
}

View file

@ -70,14 +70,13 @@ class AccountService
$account->user_agent = config('app.name');
$account->dtmf_protocol = $request->get('dtmf_protocol');
$account->confirmation_key = generatePin();
$account->provision();
$account->save();
$account->updatePassword($request->get('password'), $request->has('algorithm') ? $request->get('algorithm') : 'SHA-256');
if ($this->api) {
$token = AccountCreationToken::where('token', $request->get('account_creation_token'))->first();
$token->used = true;
$token->consume();
$token->account_id = $account->id;
$token->save();
}
@ -109,7 +108,7 @@ class AccountService
$account = $request->user();
$phoneChangeCode = $account->phoneChangeCode ?? new PhoneChangeCode();
$phoneChangeCode = $account->phoneChangeCode ?? new PhoneChangeCode;
$phoneChangeCode->account_id = $account->id;
$phoneChangeCode->phone = $request->get('phone');
$phoneChangeCode->code = generatePin();
@ -150,8 +149,6 @@ class AccountService
Log::channel('events')->info('Account Service: Account phone changed using SMS', ['id' => $account->identifier]);
$phoneChangeCode->delete();
$account->activated = true;
$account->save();
@ -160,7 +157,7 @@ class AccountService
return $account;
}
$phoneChangeCode->delete();
$phoneChangeCode->consume();
if ($this->api) {
abort(403);
@ -230,7 +227,7 @@ class AccountService
return $account;
}
$emailChangeCode->delete();
$emailChangeCode->consume();
if ($this->api) {
abort(403);
@ -268,9 +265,10 @@ class AccountService
private function recoverAccount(Account $account): Account
{
$account->recovery_code = generatePin();
$account->recover();
$account->provision();
$account->save();
$account->refresh();
return $account;
}

View file

@ -15,6 +15,7 @@
"endroid/qr-code": "^4.8",
"fakerphp/faker": "^1.23",
"laravel/framework": "^9.52",
"laravel/tinker": "^2.8",
"namoshek/laravel-redis-sentinel": "^0.1",
"ovh/ovh": "^3.2",
"parsedown/laravel": "^1.2",

151
flexiapi/composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c40b2725aff311036d760b67e577fdef",
"content-hash": "920b287a3d53f86cce05f254b7e3cb7b",
"packages": [
{
"name": "anhskohbo/no-captcha",
@ -2189,6 +2189,75 @@
},
"time": "2023-11-08T14:08:06+00:00"
},
{
"name": "laravel/tinker",
"version": "v2.8.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/tinker.git",
"reference": "b936d415b252b499e8c3b1f795cd4fc20f57e1f3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/tinker/zipball/b936d415b252b499e8c3b1f795cd4fc20f57e1f3",
"reference": "b936d415b252b499e8c3b1f795cd4fc20f57e1f3",
"shasum": ""
},
"require": {
"illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0",
"illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0",
"illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0",
"php": "^7.2.5|^8.0",
"psy/psysh": "^0.10.4|^0.11.1",
"symfony/var-dumper": "^4.3.4|^5.0|^6.0"
},
"require-dev": {
"mockery/mockery": "~1.3.3|^1.4.2",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^8.5.8|^9.3.3"
},
"suggest": {
"illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0)."
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.x-dev"
},
"laravel": {
"providers": [
"Laravel\\Tinker\\TinkerServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Tinker\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Powerful REPL for the Laravel framework.",
"keywords": [
"REPL",
"Tinker",
"laravel",
"psysh"
],
"support": {
"issues": "https://github.com/laravel/tinker/issues",
"source": "https://github.com/laravel/tinker/tree/v2.8.2"
},
"time": "2023-08-15T14:27:00+00:00"
},
{
"name": "league/commonmark",
"version": "2.4.1",
@ -4382,6 +4451,86 @@
},
"time": "2021-10-29T13:26:27+00:00"
},
{
"name": "psy/psysh",
"version": "v0.11.22",
"source": {
"type": "git",
"url": "https://github.com/bobthecow/psysh.git",
"reference": "128fa1b608be651999ed9789c95e6e2a31b5802b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/128fa1b608be651999ed9789c95e6e2a31b5802b",
"reference": "128fa1b608be651999ed9789c95e6e2a31b5802b",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-tokenizer": "*",
"nikic/php-parser": "^4.0 || ^3.1",
"php": "^8.0 || ^7.0.8",
"symfony/console": "^6.0 || ^5.0 || ^4.0 || ^3.4",
"symfony/var-dumper": "^6.0 || ^5.0 || ^4.0 || ^3.4"
},
"conflict": {
"symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.2"
},
"suggest": {
"ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)",
"ext-pdo-sqlite": "The doc command requires SQLite to work.",
"ext-posix": "If you have PCNTL, you'll want the POSIX extension as well.",
"ext-readline": "Enables support for arrow-key history navigation, and showing and manipulating command history."
},
"bin": [
"bin/psysh"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-0.11": "0.11.x-dev"
},
"bamarni-bin": {
"bin-links": false,
"forward-command": false
}
},
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Psy\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Justin Hileman",
"email": "justin@justinhileman.info",
"homepage": "http://justinhileman.com"
}
],
"description": "An interactive shell for modern PHP.",
"homepage": "http://psysh.org",
"keywords": [
"REPL",
"console",
"interactive",
"shell"
],
"support": {
"issues": "https://github.com/bobthecow/psysh/issues",
"source": "https://github.com/bobthecow/psysh/tree/v0.11.22"
},
"time": "2023-10-14T21:56:36+00:00"
},
{
"name": "ralouphie/getallheaders",
"version": "3.0.3",

View file

@ -40,7 +40,6 @@ class AccountFactory extends Factory
'email' => $this->faker->email,
'user_agent' => $this->faker->userAgent,
'confirmation_key' => Str::random(WebAuthenticateController::$emailCodeSize),
'provisioning_token' => Str::random(WebAuthenticateController::$emailCodeSize),
'ip_address' => $this->faker->ipv4,
'created_at' => $this->faker->dateTimeBetween('-1 year'),
'dtmf_protocol' => array_rand(Account::$dtmfProtocols),

View file

@ -0,0 +1,140 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Output\ConsoleOutput;
use App\Account;
use App\ProvisioningToken;
use App\RecoveryCode;
return new class extends Migration
{
public function up()
{
Schema::table('phone_change_codes', function (Blueprint $table) {
$table->string('code')->nullable(true)->change();
});
Schema::table('email_change_codes', function (Blueprint $table) {
$table->string('code')->nullable(true)->change();
});
// Move the provisioning tokens and recovery code to a dedicated table
Schema::create('provisioning_tokens', function (Blueprint $table) {
$table->increments('id');
$table->integer('account_id')->unsigned();
$table->string('token')->nullable();
$table->boolean('used')->default(false);
$table->timestamps();
$table->foreign('account_id')->references('id')
->on('accounts')->onDelete('cascade');
});
Schema::create('recovery_codes', function (Blueprint $table) {
$table->increments('id');
$table->integer('account_id')->unsigned();
$table->string('code')->nullable();
$table->timestamps();
$table->foreign('account_id')->references('id')
->on('accounts')->onDelete('cascade');
});
// Using a Query Builder as we don't want to use Eloquent magic there
$accounts = DB::table('accounts')->whereNotNull('provisioning_token')->orWhereNotNull('recovery_code')->get();
if (DB::getDriverName() !== 'sqlite') {
$progress = new ProgressBar(new ConsoleOutput, $accounts->count());
$progress->start();
}
foreach ($accounts as $account) {
if ($account->provisioning_token) {
$provisioningToken = new ProvisioningToken;
$provisioningToken->token = $account->provisioning_token;
$provisioningToken->account_id = $account->id;
$provisioningToken->save();
}
if ($account->recovery_code) {
$recoveryCode = new RecoveryCode;
$recoveryCode->code = $account->recovery_code;
$recoveryCode->account_id = $account->id;
$recoveryCode->save();
}
if (DB::getDriverName() !== 'sqlite') $progress->advance();
}
if (DB::getDriverName() !== 'sqlite') $progress->finish();
// In two steps for SQLite
Schema::table('accounts', function (Blueprint $table) {
$table->dropColumn('provisioning_token');
});
Schema::table('accounts', function (Blueprint $table) {
$table->dropColumn('recovery_code');
});
}
public function down()
{
Schema::table('phone_change_codes', function (Blueprint $table) {
$table->string('code')->nullable(false)->change();
});
Schema::table('email_change_codes', function (Blueprint $table) {
$table->string('code')->nullable(false)->change();
});
Schema::table('accounts', function (Blueprint $table) {
$table->string('provisioning_token')->nullable();
$table->string('recovery_code')->nullable();
});
// Provisioning tokens
$provisioningTokens = ProvisioningToken::all();
if (DB::getDriverName() !== 'sqlite') {
$progress = new ProgressBar(new ConsoleOutput, $provisioningTokens->count());
$progress->start();
}
foreach ($provisioningTokens as $provisioningToken) {
$account = Account::where('id', $provisioningToken->account_id)->first();
$account->provisioning_token = $provisioningToken->token;
$account->save();
if (DB::getDriverName() !== 'sqlite') $progress->advance();
}
if (DB::getDriverName() !== 'sqlite') $progress->finish();
// Recovery codes
$recoveryCodes = RecoveryCode::all();
if (DB::getDriverName() !== 'sqlite') {
$progress = new ProgressBar(new ConsoleOutput, $recoveryCodes->count());
$progress->start();
}
foreach ($recoveryCodes as $recoveryCode) {
$account = Account::where('id', $recoveryCode->account_id)->first();
$account->recovery_code = $recoveryCode->code;
$account->save();
if (DB::getDriverName() !== 'sqlite') $progress->advance();
}
if (DB::getDriverName() !== 'sqlite') $progress->finish();
Schema::dropIfExists('provisioning_tokens');
Schema::dropIfExists('recovery_codes');
}
};

View file

@ -370,7 +370,6 @@ class ApiAccountTest extends TestCase
]);
$this->assertTrue(empty($response1['confirmation_key']));
$this->assertTrue(empty($response1['provisioning_token']));
}
public function testNotActivated()