Bind TwoFAccounts to Users & Add relevant authorizations with policies

This commit is contained in:
Bubka 2023-02-22 20:21:36 +01:00
parent ed3a17a4fb
commit 3c77503fb1
10 changed files with 325 additions and 30 deletions

View File

@ -17,6 +17,7 @@
use App\Facades\TwoFAccounts; use App\Facades\TwoFAccounts;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\TwoFAccount; use App\Models\TwoFAccount;
use App\Helpers\Helpers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@ -29,7 +30,7 @@ class TwoFAccountController extends Controller
*/ */
public function index(Request $request) public function index(Request $request)
{ {
return new TwoFAccountCollection(TwoFAccount::ordered()->get()); return new TwoFAccountCollection($request->user()->twofaccounts->sortBy('order_column'));
} }
/** /**
@ -40,6 +41,8 @@ public function index(Request $request)
*/ */
public function show(TwoFAccount $twofaccount) public function show(TwoFAccount $twofaccount)
{ {
$this->authorize('view', $twofaccount);
return new TwoFAccountReadResource($twofaccount); return new TwoFAccountReadResource($twofaccount);
} }
@ -51,6 +54,8 @@ public function show(TwoFAccount $twofaccount)
*/ */
public function store(TwoFAccountDynamicRequest $request) public function store(TwoFAccountDynamicRequest $request)
{ {
$this->authorize('create', TwoFAccount::class);
// Two possible cases : // Two possible cases :
// - The most common case, an URI is provided by the QuickForm, thanks to a QR code live scan or file upload // - The most common case, an URI is provided by the QuickForm, thanks to a QR code live scan or file upload
// -> We use that URI to define the account // -> We use that URI to define the account
@ -65,7 +70,7 @@ public function store(TwoFAccountDynamicRequest $request)
} else { } else {
$twofaccount->fillWithOtpParameters($validated); $twofaccount->fillWithOtpParameters($validated);
} }
$twofaccount->save(); $request->user()->twofaccounts()->save($twofaccount);
// Possible group association // Possible group association
Groups::assign($twofaccount->id); Groups::assign($twofaccount->id);
@ -84,10 +89,12 @@ public function store(TwoFAccountDynamicRequest $request)
*/ */
public function update(TwoFAccountUpdateRequest $request, TwoFAccount $twofaccount) public function update(TwoFAccountUpdateRequest $request, TwoFAccount $twofaccount)
{ {
$this->authorize('update', $twofaccount);
$validated = $request->validated(); $validated = $request->validated();
$twofaccount->fillWithOtpParameters($validated); $twofaccount->fillWithOtpParameters($validated);
$twofaccount->save(); $request->user()->twofaccounts()->save($twofaccount);
return (new TwoFAccountReadResource($twofaccount)) return (new TwoFAccountReadResource($twofaccount))
->response() ->response()
@ -125,6 +132,9 @@ public function reorder(TwoFAccountReorderRequest $request)
{ {
$validated = $request->validated(); $validated = $request->validated();
$twofaccounts = TwoFAccount::whereIn('id', $validated['orderedIds'])->get();
$this->authorize('updateEach', [$twofaccounts[0], $twofaccounts]);
TwoFAccount::setNewOrder($validated['orderedIds']); TwoFAccount::setNewOrder($validated['orderedIds']);
return response()->json(['message' => 'order saved'], 200); return response()->json(['message' => 'order saved'], 200);
@ -161,7 +171,10 @@ public function export(TwoFAccountBatchRequest $request)
], 400); ], 400);
} }
return new TwoFAccountExportCollection(TwoFAccounts::export($validated['ids'])); $twofaccounts = TwoFAccounts::export($validated['ids']);
$this->authorize('viewEach', [$twofaccounts[0], $twofaccounts]);
return new TwoFAccountExportCollection($twofaccounts);
} }
/** /**
@ -178,6 +191,7 @@ public function otp(Request $request, $id = null)
// The request input is the ID of an existing account // The request input is the ID of an existing account
if ($id) { if ($id) {
$twofaccount = TwoFAccount::findOrFail((int) $id); $twofaccount = TwoFAccount::findOrFail((int) $id);
$this->authorize('view', $twofaccount);
} }
// The request input is an uri // The request input is an uri
@ -213,7 +227,7 @@ public function otp(Request $request, $id = null)
*/ */
public function count(Request $request) public function count(Request $request)
{ {
return response()->json(['count' => TwoFAccount::count()], 200); return response()->json(['count' => $request->user()->twofaccounts->count()], 200);
} }
/** /**
@ -233,7 +247,12 @@ public function withdraw(TwoFAccountBatchRequest $request)
], 400); ], 400);
} }
TwoFAccounts::withdraw($validated['ids']); $ids = Helpers::commaSeparatedToArray($validated['ids']);
$twofaccounts = TwoFAccount::whereIn('id', $ids)->get();
$this->authorize('updateEach', [$twofaccounts[0], $twofaccounts]);
TwoFAccounts::withdraw($ids);
return response()->json(['message' => 'accounts withdrawn'], 200); return response()->json(['message' => 'accounts withdrawn'], 200);
} }
@ -246,6 +265,8 @@ public function withdraw(TwoFAccountBatchRequest $request)
*/ */
public function destroy(TwoFAccount $twofaccount) public function destroy(TwoFAccount $twofaccount)
{ {
$this->authorize('delete', $twofaccount);
TwoFAccounts::delete($twofaccount->id); TwoFAccounts::delete($twofaccount->id);
return response()->json(null, 204); return response()->json(null, 204);
@ -268,6 +289,11 @@ public function batchDestroy(TwoFAccountBatchRequest $request)
], 400); ], 400);
} }
$ids = Helpers::commaSeparatedToArray($validated['ids']);
$twofaccounts = TwoFAccount::whereIn('id', $ids)->get();
$this->authorize('deleteEach', [$twofaccounts[0], $twofaccounts]);
TwoFAccounts::delete($validated['ids']); TwoFAccounts::delete($validated['ids']);
return response()->json(null, 204); return response()->json(null, 204);

View File

@ -5,6 +5,14 @@
use App\Services\TwoFAccountService; use App\Services\TwoFAccountService;
use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\Facade;
/**
* @method static void withdraw(int|array|string $ids)
* @method \Illuminate\Support\Collection<int|string, \App\Models\TwoFAccount> migrate(string $migrationPayload)
* @method static \Illuminate\Support\Collection<int, \App\Models\TwoFAccount> export(int|array|string $ids)
* @method static int delete(int|array|string $ids)
*
* @see \App\Services\TwoFAccountService
*/
class TwoFAccounts extends Facade class TwoFAccounts extends Facade
{ {
protected static function getFacadeAccessor() protected static function getFacadeAccessor()

View File

@ -26,4 +26,21 @@ public static function PadToBase32Format(?string $str) : string
{ {
return blank($str) ? '' : strtoupper(str_pad($str, (int) ceil(strlen($str) / 8) * 8, '=')); return blank($str) ? '' : strtoupper(str_pad($str, (int) ceil(strlen($str) / 8) * 8, '='));
} }
/**
* Identify comma separated list of values and explode it to an array of values
*
* @param mixed $ids
*/
public static function commaSeparatedToArray($ids) : mixed
{
if (is_string($ids)) {
$regex = "/^\d+(,{1}\d+)*$/";
if (preg_match($regex, $ids)) {
$ids = explode(',', $ids);
}
}
return $ids;
}
} }

View File

@ -695,4 +695,9 @@ private function encryptOrReturn(mixed $value) : mixed
// should be replaced by laravel 8 attribute encryption casting // should be replaced by laravel 8 attribute encryption casting
return Settings::get('useEncryption') ? Crypt::encryptString($value) : $value; return Settings::get('useEncryption') ? Crypt::encryptString($value) : $value;
} }
public function buildSortQuery()
{
return static::query()->where('user_id', $this->user_id);
}
} }

View File

@ -10,7 +10,6 @@
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Laragear\WebAuthn\WebAuthnAuthentication; use Laragear\WebAuthn\WebAuthnAuthentication;
use Laravel\Passport\HasApiTokens; use Laravel\Passport\HasApiTokens;
use Illuminate\Support\Arr;
class User extends Authenticatable implements WebAuthnAuthenticatable class User extends Authenticatable implements WebAuthnAuthenticatable
{ {
@ -42,8 +41,9 @@ class User extends Authenticatable implements WebAuthnAuthenticatable
* @var array<string,string> * @var array<string,string>
*/ */
protected $casts = [ protected $casts = [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'is_admin' => 'boolean', 'is_admin' => 'boolean',
'twofaccounts_count' => 'integer',
]; ];
/** /**
@ -97,4 +97,14 @@ static function ($query) use ($id) {
} }
)->first(); )->first();
} }
/**
* Get the TwoFAccounts of the user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany<TwoFAccount>
*/
public function twofaccounts()
{
return $this->hasMany(\App\Models\TwoFAccount::class);
}
} }

View File

@ -0,0 +1,44 @@
<?php
namespace App\Policies;
use App\Models\User;
use Illuminate\Contracts\Support\Arrayable;
trait OwnershipTrait
{
/**
* Ownership of single item condition
*
* @param \App\Models\User $user
* @param mixed $item
* @return bool
*/
protected function isOwnerOf(User $user, mixed $item)
{
return $user->id === $item->user_id;
}
/**
* Ownership of collection condition
*
* @template TKey of array-key
* @template TValue
*
* @param \App\Models\User $user
* @param \Illuminate\Contracts\Support\Arrayable<TKey, TValue>|iterable<TKey, TValue> $items
* @return bool
*/
protected function isOwnerOfEach(User $user, $items)
{
foreach ($items as $item) {
if (! $this->isOwnerOf($user, $item)) {
return false;
}
}
return true;
}
}

View File

@ -0,0 +1,136 @@
<?php
namespace App\Policies;
use App\Models\TwoFAccount;
use App\Models\User;
use Illuminate\Auth\Access\Response;
use Illuminate\Auth\Access\HandlesAuthorization;
class TwoFAccountPolicy
{
use HandlesAuthorization, OwnershipTrait;
/**
* Determine whether the user can view any models.
*
* @param \App\Models\User $user
* @return \Illuminate\Auth\Access\Response|bool
*/
public function viewAny(User $user)
{
return false;
}
/**
* Determine whether the user can view the model.
*
* @param \App\Models\User $user
* @param \App\Models\TwoFAccount $twofaccount
* @return \Illuminate\Auth\Access\Response|bool
*/
public function view(User $user, TwoFAccount $twofaccount)
{
return $this->isOwnerOf($user, $twofaccount);
}
/**
* Determine whether the user can view all provided models.
*
* @param \App\Models\User $user
* @param \App\Models\TwoFAccount $twofaccount
* @param \Illuminate\Support\Collection<int, \App\Models\TwoFAccount> $twofaccounts
* @return \Illuminate\Auth\Access\Response|bool
*/
public function viewEach(User $user, TwoFAccount $twofaccount, $twofaccounts)
{
return $this->isOwnerOfEach($user, $twofaccounts);
}
/**
* Determine whether the user can create models.
*
* @param \App\Models\User $user
* @return \Illuminate\Auth\Access\Response|bool
*/
public function create(User $user)
{
return true;
}
/**
* Determine whether the user can update the model.
*
* @param \App\Models\User $user
* @param \App\Models\TwoFAccount $twofaccount
* @return \Illuminate\Auth\Access\Response|bool
*/
public function update(User $user, TwoFAccount $twofaccount)
{
return $this->isOwnerOf($user, $twofaccount);
// ? Response::allow()
// : Response::deny('You do not own this post.');
}
/**
* Determine whether the user can update all provided models.
*
* @param \App\Models\User $user
* @param \App\Models\TwoFAccount $twofaccount
* @param \Illuminate\Support\Collection<int, \App\Models\TwoFAccount> $twofaccounts
* @return \Illuminate\Auth\Access\Response|bool
*/
public function updateEach(User $user, TwoFAccount $twofaccount, $twofaccounts)
{
return $this->isOwnerOfEach($user, $twofaccounts);
}
/**
* Determine whether the user can delete the model.
*
* @param \App\Models\User $user
* @param \App\Models\TwoFAccount $twofaccount
* @return \Illuminate\Auth\Access\Response|bool
*/
public function delete(User $user, TwoFAccount $twofaccount)
{
return $this->isOwnerOf($user, $twofaccount);
}
/**
* Determine whether the user can delete all provided models.
*
* @param \App\Models\User $user
* @param \App\Models\TwoFAccount $twofaccount
* @param \Illuminate\Support\Collection<int, \App\Models\TwoFAccount> $twofaccounts
* @return \Illuminate\Auth\Access\Response|bool
*/
public function deleteEach(User $user, TwoFAccount $twofaccount, $twofaccounts)
{
return $this->isOwnerOfEach($user, $twofaccounts);
}
/**
* Determine whether the user can restore the model.
*
* @param \App\Models\User $user
* @param \App\Models\TwoFAccount $twofaccount
* @return \Illuminate\Auth\Access\Response|bool
*/
public function restore(User $user, TwoFAccount $twofaccount)
{
return $this->isOwnerOf($user, $twofaccount);
}
/**
* Determine whether the user can permanently delete the model.
*
* @param \App\Models\User $user
* @param \App\Models\TwoFAccount $twofaccount
* @return \Illuminate\Auth\Access\Response|bool
*/
public function forceDelete(User $user, TwoFAccount $twofaccount)
{
return $this->isOwnerOf($user, $twofaccount);
}
}

View File

@ -5,6 +5,8 @@
use App\Extensions\RemoteUserProvider; use App\Extensions\RemoteUserProvider;
use App\Extensions\WebauthnCredentialBroker; use App\Extensions\WebauthnCredentialBroker;
use App\Facades\Settings; use App\Facades\Settings;
use App\Models\TwoFAccount;
use App\Policies\TwoFAccountPolicy;
use App\Services\Auth\ReverseProxyGuard; use App\Services\Auth\ReverseProxyGuard;
use Illuminate\Auth\Passwords\DatabaseTokenRepository; use Illuminate\Auth\Passwords\DatabaseTokenRepository;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
@ -20,7 +22,7 @@ class AuthServiceProvider extends ServiceProvider
* @var array<class-string, class-string> * @var array<class-string, class-string>
*/ */
protected $policies = [ protected $policies = [
// 'App\Models\Model' => 'App\Policies\ModelPolicy', TwoFAccount::class => TwoFAccountPolicy::class,
]; ];
/** /**

View File

@ -4,6 +4,7 @@
use App\Factories\MigratorFactoryInterface; use App\Factories\MigratorFactoryInterface;
use App\Models\TwoFAccount; use App\Models\TwoFAccount;
use App\Helpers\Helpers;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -31,7 +32,7 @@ public static function withdraw($ids) : void
{ {
// $ids as string could be a comma-separated list of ids // $ids as string could be a comma-separated list of ids
// so in this case we explode the string to an array // so in this case we explode the string to an array
$ids = self::commaSeparatedToArray($ids); $ids = Helpers::commaSeparatedToArray($ids);
// whereIn() expects an array // whereIn() expects an array
$ids = is_array($ids) ? $ids : func_get_args(); $ids = is_array($ids) ? $ids : func_get_args();
@ -66,7 +67,7 @@ public function migrate(string $migrationPayload) : Collection
*/ */
public static function export($ids) : Collection public static function export($ids) : Collection
{ {
$ids = self::commaSeparatedToArray($ids); $ids = Helpers::commaSeparatedToArray($ids);
$twofaccounts = TwoFAccount::whereIn('id', $ids)->get(); $twofaccounts = TwoFAccount::whereIn('id', $ids)->get();
return $twofaccounts; return $twofaccounts;
@ -82,7 +83,7 @@ public static function delete($ids) : int
{ {
// $ids as string could be a comma-separated list of ids // $ids as string could be a comma-separated list of ids
// so in this case we explode the string to an array // so in this case we explode the string to an array
$ids = self::commaSeparatedToArray($ids); $ids = Helpers::commaSeparatedToArray($ids);
Log::info(sprintf('Deletion of TwoFAccounts #%s requested', is_array($ids) ? implode(',#', $ids) : $ids)); Log::info(sprintf('Deletion of TwoFAccounts #%s requested', is_array($ids) ? implode(',#', $ids) : $ids));
$deleted = TwoFAccount::destroy($ids); $deleted = TwoFAccount::destroy($ids);
@ -116,21 +117,5 @@ private static function markAsDuplicate(Collection $twofaccounts) : Collection
return $twofaccounts; return $twofaccounts;
} }
/**
* Explode a comma separated list of IDs to an array of IDs
*
* @param int|array|string $ids
*/
private static function commaSeparatedToArray($ids) : mixed
{
if (is_string($ids)) {
$regex = "/^\d+(,{1}\d+)*$/";
if (preg_match($regex, $ids)) {
$ids = explode(',', $ids);
}
}
return $ids;
}
} }

View File

@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('twofaccounts', function (Blueprint $table) {
$table->unsignedInteger('user_id')
->after('id')
->nullable();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
});
Schema::table('groups', function (Blueprint $table) {
$table->unsignedInteger('user_id')
->after('id')
->nullable();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
});
if ($legacySingleUser = DB::table('users')->first()) {
DB::table('twofaccounts')->update(['user_id' => $legacySingleUser->id]);
DB::table('groups')->update(['user_id' => $legacySingleUser->id]);
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::whenTableHasColumn('twofaccounts', 'user_id', function (Blueprint $table) {
// cannot drop foreign keys in SQLite:
if (DB::getDriverName() !== 'sqlite') {
$table->dropForeign(['user_id']);
}
$table->dropColumn('user_id');
});
Schema::whenTableHasColumn('groups', 'user_id', function (Blueprint $table) {
// cannot drop foreign keys in SQLite:
if (DB::getDriverName() !== 'sqlite') {
$table->dropForeign(['user_id']);
}
$table->dropColumn('user_id');
});
}
};