Allow PAT usage while useSsoOnly is enabled - Resolves #474

This commit is contained in:
Bubka 2025-06-02 14:13:56 +02:00
parent f0eec6582a
commit 12228bc536
18 changed files with 461 additions and 91 deletions

View File

@ -32,7 +32,8 @@ class Handler extends ExceptionHandler
$this->renderable(function (\Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException $exception, $request) { $this->renderable(function (\Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException $exception, $request) {
return response()->json([ return response()->json([
'message' => 'unauthorized', 'message' => 'forbidden',
'reason' => $exception->getMessage(),
], 403); ], 403);
}); });

View File

@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Auth;
use Laravel\Passport\Http\Controllers\PersonalAccessTokenController as PassportPatController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class PersonalAccessTokenController extends PassportPatController
{
/**
* Get all of the personal access tokens for the authenticated user.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Database\Eloquent\Collection<int, \Laravel\Passport\Token>|\Illuminate\Http\JsonResponse
*/
public function forUser(Request $request)
{
if (Gate::denies('manage-pat')) {
throw new AccessDeniedHttpException(__('errors.unsupported_with_sso_only'));
}
return parent::forUser($request);
}
/**
* Create a new personal access token for the user.
*
* @param \Illuminate\Http\Request $request
* @return \Laravel\Passport\PersonalAccessTokenResult|\Illuminate\Http\JsonResponse
*/
public function store(Request $request)
{
if (Gate::denies('manage-pat')) {
throw new AccessDeniedHttpException(__('errors.unsupported_with_sso_only'));
}
return parent::store($request);
}
/**
* Delete the given token.
*
* @param \Illuminate\Http\Request $request
* @param string $tokenId
* @return \Illuminate\Http\Response|\Illuminate\Http\JsonResponse
*/
public function destroy(Request $request, $tokenId)
{
if (Gate::denies('manage-pat')) {
throw new AccessDeniedHttpException(__('errors.unsupported_with_sso_only'));
}
return parent::destroy($request, $tokenId);
}
}

View File

@ -5,7 +5,9 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\WebauthnRenameRequest; use App\Http\Requests\WebauthnRenameRequest;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class WebAuthnManageController extends Controller class WebAuthnManageController extends Controller
{ {
@ -16,6 +18,10 @@ class WebAuthnManageController extends Controller
*/ */
public function index(Request $request) public function index(Request $request)
{ {
if (Gate::denies('manage-webauthn-credentials')) {
throw new AccessDeniedHttpException(__('errors.unsupported_with_sso_only'));
}
$allUserCredentials = $request->user()->webAuthnCredentials()->WhereEnabled()->get(); $allUserCredentials = $request->user()->webAuthnCredentials()->WhereEnabled()->get();
return response()->json($allUserCredentials, 200); return response()->json($allUserCredentials, 200);
@ -46,6 +52,10 @@ class WebAuthnManageController extends Controller
public function delete(Request $request, $credential) public function delete(Request $request, $credential)
{ {
Log::info('Deletion of security device requested'); Log::info('Deletion of security device requested');
if (Gate::denies('manage-webauthn-credentials')) {
throw new AccessDeniedHttpException(__('errors.unsupported_with_sso_only'));
}
$user = $request->user(); $user = $request->user();
$user->flushCredential($credential); $user->flushCredential($credential);

View File

@ -3,19 +3,19 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\WebauthnAttestationRequest;
use App\Http\Requests\WebauthnAttestedRequest;
use Illuminate\Contracts\Support\Responsable; use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Laragear\WebAuthn\Enums\UserVerification; use Laragear\WebAuthn\Enums\UserVerification;
use Laragear\WebAuthn\Http\Requests\AttestationRequest;
use Laragear\WebAuthn\Http\Requests\AttestedRequest;
class WebAuthnRegisterController extends Controller class WebAuthnRegisterController extends Controller
{ {
/** /**
* Returns a challenge to be verified by the user device. * Returns a challenge to be verified by the user device.
*/ */
public function options(AttestationRequest $request) : Responsable public function options(WebauthnAttestationRequest $request) : Responsable
{ {
switch (config('webauthn.user_verification')) { switch (config('webauthn.user_verification')) {
case UserVerification::DISCOURAGED: case UserVerification::DISCOURAGED:
@ -35,7 +35,7 @@ class WebAuthnRegisterController extends Controller
/** /**
* Registers a device for further WebAuthn authentication. * Registers a device for further WebAuthn authentication.
*/ */
public function register(AttestedRequest $request) : Response public function register(WebauthnAttestedRequest $request) : Response
{ {
$request->save(); $request->save();

View File

@ -29,6 +29,7 @@ class SinglePageController extends Controller
'disableRegistration', 'disableRegistration',
'enableSso', 'enableSso',
'useSsoOnly', 'useSsoOnly',
'allowPatWhileSsoOnly',
]); ]);
$settings = $appSettings->map(function (mixed $item, string $key) { $settings = $appSettings->map(function (mixed $item, string $key) {
return null; return null;

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Support\Facades\Gate;
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
use Laragear\WebAuthn\Http\Requests\AttestationRequest;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class WebauthnAttestationRequest extends AttestationRequest
{
/**
* Handle a failed authorization attempt.
*
* @return void
*
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
*/
protected function failedAuthorization()
{
throw new AccessDeniedHttpException(__('errors.unsupported_with_sso_only'));
}
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(?WebAuthnAuthenticatable $user): bool
{
return (bool) $user && Gate::allows('manage-webauthn-credentials');
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Support\Facades\Gate;
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
use Laragear\WebAuthn\Http\Requests\AttestedRequest;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class WebauthnAttestedRequest extends AttestedRequest
{
/**
* Handle a failed authorization attempt.
*
* @return void
*
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
*/
protected function failedAuthorization()
{
throw new AccessDeniedHttpException(__('errors.unsupported_with_sso_only'));
}
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(?WebAuthnAuthenticatable $user): bool
{
return (bool) $user && Gate::allows('manage-webauthn-credentials');
}
}

View File

@ -4,9 +4,23 @@ namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class WebauthnRenameRequest extends FormRequest class WebauthnRenameRequest extends FormRequest
{ {
/**
* Handle a failed authorization attempt.
*
* @return void
*
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
*/
protected function failedAuthorization()
{
throw new AccessDeniedHttpException(__('errors.unsupported_with_sso_only'));
}
/** /**
* Determine if the user is authorized to make this request. * Determine if the user is authorized to make this request.
* *
@ -14,7 +28,7 @@ class WebauthnRenameRequest extends FormRequest
*/ */
public function authorize() public function authorize()
{ {
return Auth::check(); return Auth::check() && Gate::allows('manage-webauthn-credentials');
} }
/** /**

View File

@ -2,7 +2,10 @@
namespace App\Providers; namespace App\Providers;
use App\Facades\Settings;
use App\Models\User;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Laravel\Passport\Console\ClientCommand; use Laravel\Passport\Console\ClientCommand;
@ -39,5 +42,21 @@ class AppServiceProvider extends ServiceProvider
ClientCommand::class, ClientCommand::class,
KeysCommand::class, KeysCommand::class,
]); ]);
Gate::before(function (User $user, string $ability) {
if ($user->isAdministrator()) {
return true;
}
});
Gate::define('manage-pat', function (User $user) {
$useSsoOnly = Settings::get('useSsoOnly');
return ($useSsoOnly && Settings::get('allowPatWhileSsoOnly')) || $useSsoOnly !== true;
});
Gate::define('manage-webauthn-credentials', function (User $user) {
return ! Settings::get('useSsoOnly');
});
} }
} }

View File

@ -139,6 +139,7 @@ return [
'disableRegistration' => false, 'disableRegistration' => false,
'enableSso' => true, 'enableSso' => true,
'useSsoOnly' => false, 'useSsoOnly' => false,
'allowPatWhileSsoOnly' => false,
'restrictRegistration' => false, 'restrictRegistration' => false,
'restrictList' => '', 'restrictList' => '',
'restrictRule' => '', 'restrictRule' => '',

View File

@ -80,6 +80,8 @@
<FormCheckbox v-model="appSettings.enableSso" @update:model-value="val => useAppSettingsUpdater('enableSso', val)" fieldName="enableSso" label="admin.forms.enable_sso.label" help="admin.forms.enable_sso.help" /> <FormCheckbox v-model="appSettings.enableSso" @update:model-value="val => useAppSettingsUpdater('enableSso', val)" fieldName="enableSso" label="admin.forms.enable_sso.label" help="admin.forms.enable_sso.help" />
<!-- use SSO only --> <!-- use SSO only -->
<FormCheckbox v-model="appSettings.useSsoOnly" @update:model-value="val => useAppSettingsUpdater('useSsoOnly', val)" fieldName="useSsoOnly" label="admin.forms.use_sso_only.label" help="admin.forms.use_sso_only.help" :isDisabled="!appSettings.enableSso" :isIndented="true" /> <FormCheckbox v-model="appSettings.useSsoOnly" @update:model-value="val => useAppSettingsUpdater('useSsoOnly', val)" fieldName="useSsoOnly" label="admin.forms.use_sso_only.label" help="admin.forms.use_sso_only.help" :isDisabled="!appSettings.enableSso" :isIndented="true" />
<!-- Allow Pat In SSO Only -->
<FormCheckbox v-model="appSettings.allowPatWhileSsoOnly" @update:model-value="val => useAppSettingsUpdater('allowPatWhileSsoOnly', val)" fieldName="allowPatWhileSsoOnly" label="admin.forms.allow_pat_in_sso_only.label" help="admin.forms.allow_pat_in_sso_only.help" :isDisabled="!appSettings.useSsoOnly" :isIndented="true" />
<h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('admin.registrations') }}</h4> <h4 class="title is-4 pt-4 has-text-grey-light">{{ $t('admin.registrations') }}</h4>
<!-- restrict registration --> <!-- restrict registration -->
<FormCheckbox v-model="appSettings.restrictRegistration" @update:model-value="val => useAppSettingsUpdater('restrictRegistration', val)" fieldName="restrictRegistration" :isDisabled="appSettings.disableRegistration" label="admin.forms.restrict_registration.label" help="admin.forms.restrict_registration.help" /> <FormCheckbox v-model="appSettings.restrictRegistration" @update:model-value="val => useAppSettingsUpdater('restrictRegistration', val)" fieldName="restrictRegistration" :isDisabled="appSettings.disableRegistration" label="admin.forms.restrict_registration.label" help="admin.forms.restrict_registration.help" />

View File

@ -2,11 +2,13 @@
import Form from '@/components/formElements/Form' import Form from '@/components/formElements/Form'
import userService from '@/services/userService' import userService from '@/services/userService'
import SettingTabs from '@/layouts/SettingTabs.vue' import SettingTabs from '@/layouts/SettingTabs.vue'
import { useAppSettingsStore } from '@/stores/appSettings'
import { useNotifyStore } from '@/stores/notify' import { useNotifyStore } from '@/stores/notify'
import { UseColorMode } from '@vueuse/components' import { UseColorMode } from '@vueuse/components'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import Spinner from '@/components/Spinner.vue' import Spinner from '@/components/Spinner.vue'
const appSettings = useAppSettingsStore()
const $2fauth = inject('2fauth') const $2fauth = inject('2fauth')
const notify = useNotifyStore() const notify = useNotifyStore()
const user = useUserStore() const user = useUserStore()
@ -20,7 +22,7 @@
const visibleTokenId = ref(null) const visibleTokenId = ref(null)
const isDisabled = computed(() => { const isDisabled = computed(() => {
return (appSettings.enableSso && appSettings.useSsoOnly) || user.authenticated_by_proxy return (appSettings.enableSso && appSettings.useSsoOnly && ! appSettings.allowPatWhileSsoOnly) || user.authenticated_by_proxy
}) })
onMounted(() => { onMounted(() => {
@ -51,8 +53,8 @@
}) })
}) })
.catch(error => { .catch(error => {
if( error.response.status === 405 ) { if( error.response.status === 403 ) {
// The backend returns a 405 response if the user is authenticated by a reverse proxy // The backend returns a 403 response if the user is authenticated by a reverse proxy
// or if SSO only is enabled. // or if SSO only is enabled.
// The form is already disabled (see isDisabled) so we do nothing more here // The form is already disabled (see isDisabled) so we do nothing more here
} }

View File

@ -98,8 +98,8 @@
credentials.value = response.data credentials.value = response.data
}) })
.catch(error => { .catch(error => {
if( error.response.status === 405 ) { if( error.response.status === 403 ) {
// The backend returns a 405 response if the user is authenticated by a reverse proxy // The backend returns a 403 response if the user is authenticated by a reverse proxy
// or if SSO only is enabled. // or if SSO only is enabled.
// The form is already disabled (see isDisabled) so we do nothing more here // The form is already disabled (see isDisabled) so we do nothing more here
} }

View File

@ -118,6 +118,10 @@ return [
'label' => 'Use SSO only', 'label' => 'Use SSO only',
'help' => 'Make SSO the only available method to log in to 2FAuth. Password login and Webauthn are then disabled for regular users. Administrators are not affected by this restriction.', 'help' => 'Make SSO the only available method to log in to 2FAuth. Password login and Webauthn are then disabled for regular users. Administrators are not affected by this restriction.',
], ],
'allow_pat_in_sso_only' => [
'label' => 'Allow PAT usage',
'help' => 'Let users create personal access tokens and use them to authenticate with third party application like the 2FAuth web extension',
],
'keep_sso_registration_enabled' => [ 'keep_sso_registration_enabled' => [
'label' => 'Keep SSO registration enabled', 'label' => 'Keep SSO registration enabled',
'help' => 'Allow new users to sign in for the first time via SSO whereas registration is disabled', 'help' => 'Allow new users to sign in for the first time via SSO whereas registration is disabled',

View File

@ -3,6 +3,7 @@
use App\Http\Controllers\Auth\ForgotPasswordController; use App\Http\Controllers\Auth\ForgotPasswordController;
use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\PasswordController; use App\Http\Controllers\Auth\PasswordController;
use App\Http\Controllers\Auth\PersonalAccessTokenController;
use App\Http\Controllers\Auth\RegisterController; use App\Http\Controllers\Auth\RegisterController;
use App\Http\Controllers\Auth\ResetPasswordController; use App\Http\Controllers\Auth\ResetPasswordController;
use App\Http\Controllers\Auth\SocialiteController; use App\Http\Controllers\Auth\SocialiteController;
@ -23,7 +24,6 @@ use Illuminate\Session\Middleware\StartSession;
// use Illuminate\Foundation\Events\DiagnosingHealth; // use Illuminate\Foundation\Events\DiagnosingHealth;
// use Illuminate\Support\Facades\Event; // use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Laravel\Passport\Http\Controllers\PersonalAccessTokenController;
// use App\Models\User; // use App\Models\User;
// use App\Notifications\SignedInWithNewDeviceNotification; // use App\Notifications\SignedInWithNewDeviceNotification;
@ -67,7 +67,7 @@ Route::group(['middleware' => ['forceLogout', 'throttle:10,1']], function () {
/** /**
* Routes protected by an authentication guard but rejected when the reverse-proxy * Routes protected by an authentication guard but rejected when the reverse-proxy
* guard is enabled or SSO only is enabled * guard is enabled
*/ */
Route::group(['middleware' => ['behind-auth', 'rejectIfReverseProxy']], function () { Route::group(['middleware' => ['behind-auth', 'rejectIfReverseProxy']], function () {
Route::put('user', [UserController::class, 'update'])->name('user.update'); Route::put('user', [UserController::class, 'update'])->name('user.update');
@ -75,15 +75,16 @@ Route::group(['middleware' => ['behind-auth', 'rejectIfReverseProxy']], function
Route::get('user/logout', [LoginController::class, 'logout'])->name('user.logout'); Route::get('user/logout', [LoginController::class, 'logout'])->name('user.logout');
Route::delete('user', [UserController::class, 'delete'])->name('user.delete')->middleware('rejectIfDemoMode'); Route::delete('user', [UserController::class, 'delete'])->name('user.delete')->middleware('rejectIfDemoMode');
Route::get('oauth/personal-access-tokens', [PersonalAccessTokenController::class, 'forUser'])->name('passport.personal.tokens.index')->middleware('RejectIfSsoOnlyAndNotForAdmin'); // Following routes are also forbidden to regular users when "SSO only" is enabled, but using Authorization gates
Route::post('oauth/personal-access-tokens', [PersonalAccessTokenController::class, 'store'])->name('passport.personal.tokens.store')->middleware('RejectIfSsoOnlyAndNotForAdmin'); Route::get('oauth/personal-access-tokens', [PersonalAccessTokenController::class, 'forUser'])->name('passport.personal.tokens.index');
Route::delete('oauth/personal-access-tokens/{token_id}', [PersonalAccessTokenController::class, 'destroy'])->name('passport.personal.tokens.destroy')->middleware('RejectIfSsoOnlyAndNotForAdmin'); Route::post('oauth/personal-access-tokens', [PersonalAccessTokenController::class, 'store'])->name('passport.personal.tokens.store');
Route::delete('oauth/personal-access-tokens/{token_id}', [PersonalAccessTokenController::class, 'destroy'])->name('passport.personal.tokens.destroy');
Route::post('webauthn/register/options', [WebAuthnRegisterController::class, 'options'])->name('webauthn.register.options')->middleware('RejectIfSsoOnlyAndNotForAdmin'); Route::post('webauthn/register/options', [WebAuthnRegisterController::class, 'options'])->name('webauthn.register.options');
Route::post('webauthn/register', [WebAuthnRegisterController::class, 'register'])->name('webauthn.register')->middleware('RejectIfSsoOnlyAndNotForAdmin'); Route::post('webauthn/register', [WebAuthnRegisterController::class, 'register'])->name('webauthn.register');
Route::get('webauthn/credentials', [WebAuthnManageController::class, 'index'])->name('webauthn.credentials.index')->middleware('RejectIfSsoOnlyAndNotForAdmin'); Route::get('webauthn/credentials', [WebAuthnManageController::class, 'index'])->name('webauthn.credentials.index');
Route::patch('webauthn/credentials/{credential}/name', [WebAuthnManageController::class, 'rename'])->name('webauthn.credentials.rename')->middleware('RejectIfSsoOnlyAndNotForAdmin'); Route::patch('webauthn/credentials/{credential}/name', [WebAuthnManageController::class, 'rename'])->name('webauthn.credentials.rename');
Route::delete('webauthn/credentials/{credential}', [WebAuthnManageController::class, 'delete'])->name('webauthn.credentials.delete')->middleware('RejectIfSsoOnlyAndNotForAdmin'); Route::delete('webauthn/credentials/{credential}', [WebAuthnManageController::class, 'delete'])->name('webauthn.credentials.delete');
}); });
/** /**

View File

@ -133,74 +133,4 @@ class RejectIfSsoOnlyAndNotForAdminMiddlewareTest extends FeatureTestCase
], ],
]; ];
} }
#[Test]
#[DataProvider('provideProtectedEndPoints')]
public function test_protected_endpoint_are_allowed_if_requested_by_an_admin(string $method, string $url)
{
$expectedResponseCodes = [
Response::HTTP_OK,
Response::HTTP_UNPROCESSABLE_ENTITY,
Response::HTTP_NOT_FOUND,
Response::HTTP_CREATED,
Response::HTTP_NO_CONTENT,
];
$response = $this->actingAs($this->admin, 'web-guard')
->json($method, $url, [
'email' => $this->admin->email,
]);
$this->assertContains($response->getStatusCode(), $expectedResponseCodes);
}
#[Test]
#[DataProvider('provideProtectedEndPoints')]
public function test_protected_endpoint_returns_NOT_ALLOWED_if_requested_by_regular_user(string $method, string $url)
{
$this->actingAs($this->user, 'web-guard')
->json($method, $url)
->assertMethodNotAllowed();
}
/**
* Provide Valid data for validation test
*/
public static function provideProtectedEndPoints() : array
{
return [
'WEBAUTHN_REGISTER' => [
'method' => 'POST',
'url' => '/webauthn/register',
],
'WEBAUTHN_REGISTER_OPTIONS' => [
'method' => 'POST',
'url' => '/webauthn/register/options',
],
'WEBAUTHN_CREDENTIALS_ALL' => [
'method' => 'GET',
'url' => '/webauthn/credentials',
],
'WEBAUTHN_CREDENTIALS_PATCH' => [
'method' => 'PATCH',
'url' => '/webauthn/credentials/FAKE_CREDENTIAL_ID/name',
],
'WEBAUTHN_CREDENTIALS_DELETE' => [
'method' => 'DELETE',
'url' => '/webauthn/credentials/FAKE_CREDENTIAL_ID',
],
'OAUTH_PAT_ALL' => [
'method' => 'GET',
'url' => '/oauth/personal-access-tokens',
],
'OAUTH_PAT_STORE' => [
'method' => 'POST',
'url' => '/oauth/personal-access-tokens',
],
'OAUTH_PAT_DELETE' => [
'method' => 'DELETE',
'url' => '/oauth/personal-access-tokens/FAKE_TOKEN_ID',
],
];
}
} }

View File

@ -0,0 +1,142 @@
<?php
namespace Tests\Feature\Http\Middlewares;
use App\Facades\Settings;
use App\Http\Controllers\Auth\PersonalAccessTokenController;
use App\Models\User;
use App\Providers\AppServiceProvider;
use Illuminate\Http\Response;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Tests\FeatureTestCase;
/**
* ManagePatPermissionsTest test class
*/
#[CoversClass(PersonalAccessTokenController::class)]
#[CoversMethod(AppServiceProvider::class, 'boot')]
class ManagePatPermissionsTest extends FeatureTestCase
{
/**
* @var \App\Models\User|\Illuminate\Contracts\Auth\Authenticatable
*/
protected $user;
/**
* @var \App\Models\User|\Illuminate\Contracts\Auth\Authenticatable
*/
protected $admin;
private const PASSWORD = 'password';
public function setUp() : void
{
parent::setUp();
$this->user = User::factory()->create();
$this->admin = User::factory()->administrator()->create([
'password' => self::PASSWORD,
]);
}
#[Test]
#[DataProvider('providePatManagementEndPoints')]
public function test_pat_management_endpoint_is_permitted_to_regular_user_without_useSsoOnly(string $method, string $url)
{
Settings::set('useSsoOnly', false);
Settings::set('allowPatWhileSsoOnly', false);
$response = $this->actingAs($this->user, 'web-guard')
->json($method, $url);
$this->assertNotEquals($response->getStatusCode(), Response::HTTP_FORBIDDEN);
}
#[Test]
#[DataProvider('providePatManagementEndPoints')]
public function test_pat_management_endpoint_is_forbidden_to_regular_user_with_useSsoOnly(string $method, string $url)
{
Settings::set('useSsoOnly', true);
Settings::set('allowPatWhileSsoOnly', false);
$this->actingAs($this->user, 'web-guard')
->json($method, $url)
->assertForbidden();
}
#[Test]
#[DataProvider('providePatManagementEndPoints')]
public function test_pat_management_endpoint_is_permitted_to_regular_user_with_useSsoOnly_bypassed(string $method, string $url)
{
Settings::set('useSsoOnly', true);
Settings::set('allowPatWhileSsoOnly', true);
$response = $this->actingAs($this->user, 'web-guard')
->json($method, $url);
$this->assertNotEquals($response->getStatusCode(), Response::HTTP_FORBIDDEN);
}
#[Test]
#[DataProvider('providePatManagementEndPoints')]
public function test_pat_management_endpoint_is_permitted_to_admin_without_useSsoOnly(string $method, string $url)
{
Settings::set('useSsoOnly', false);
Settings::set('allowPatWhileSsoOnly', false);
$response = $this->actingAs($this->admin, 'web-guard')
->json($method, $url);
$this->assertNotEquals($response->getStatusCode(), Response::HTTP_FORBIDDEN);
}
#[Test]
#[DataProvider('providePatManagementEndPoints')]
public function test_pat_management_endpoint_is_permitted_to_admin_with_useSsoOnly(string $method, string $url)
{
Settings::set('useSsoOnly', true);
Settings::set('allowPatWhileSsoOnly', false);
$response = $this->actingAs($this->admin, 'web-guard')
->json($method, $url);
$this->assertNotEquals($response->getStatusCode(), Response::HTTP_FORBIDDEN);
}
#[Test]
#[DataProvider('providePatManagementEndPoints')]
public function test_pat_management_endpoint_is_permitted_to_admin_with_useSsoOnly_bypassed(string $method, string $url)
{
Settings::set('useSsoOnly', true);
Settings::set('allowPatWhileSsoOnly', true);
$response = $this->actingAs($this->admin, 'web-guard')
->json($method, $url);
$this->assertNotEquals($response->getStatusCode(), Response::HTTP_FORBIDDEN);
}
/**
* Provide Valid data for validation test
*/
public static function providePatManagementEndPoints() : array
{
return [
'OAUTH_PAT_ALL' => [
'method' => 'GET',
'url' => '/oauth/personal-access-tokens',
],
'OAUTH_PAT_STORE' => [
'method' => 'POST',
'url' => '/oauth/personal-access-tokens',
],
'OAUTH_PAT_DELETE' => [
'method' => 'DELETE',
'url' => '/oauth/personal-access-tokens/FAKE_TOKEN_ID',
],
];
}
}

View File

@ -0,0 +1,122 @@
<?php
namespace Tests\Feature\Http\Middlewares;
use App\Facades\Settings;
use App\Http\Controllers\Auth\WebAuthnManageController;
use App\Http\Controllers\Auth\WebAuthnRegisterController;
use App\Models\User;
use App\Providers\AppServiceProvider;
use Illuminate\Http\Response;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Tests\FeatureTestCase;
/**
* ManagePatPermissionsTest test class
*/
#[CoversClass(WebAuthnManageController::class)]
#[CoversClass(WebAuthnRegisterController::class)]
#[CoversMethod(AppServiceProvider::class, 'boot')]
class ManageWebauthnPermissionsTest extends FeatureTestCase
{
/**
* @var \App\Models\User|\Illuminate\Contracts\Auth\Authenticatable
*/
protected $user;
/**
* @var \App\Models\User|\Illuminate\Contracts\Auth\Authenticatable
*/
protected $admin;
private const PASSWORD = 'password';
public function setUp() : void
{
parent::setUp();
$this->user = User::factory()->create();
$this->admin = User::factory()->administrator()->create([
'password' => self::PASSWORD,
]);
}
#[Test]
#[DataProvider('provideWebauthnManagementEndPoints')]
public function test_webauthn_management_endpoint_is_permitted_to_regular_user_without_useSsoOnly(string $method, string $url)
{
Settings::set('useSsoOnly', false);
$response = $this->actingAs($this->user, 'web-guard')
->json($method, $url);
$this->assertNotEquals($response->getStatusCode(), Response::HTTP_FORBIDDEN);
}
#[Test]
#[DataProvider('provideWebauthnManagementEndPoints')]
public function test_webauthn_management_endpoint_is_forbidden_to_regular_user_with_useSsoOnly(string $method, string $url)
{
Settings::set('useSsoOnly', true);
$this->actingAs($this->user, 'web-guard')
->json($method, $url)
->assertForbidden();
}
#[Test]
#[DataProvider('provideWebauthnManagementEndPoints')]
public function test_webauthn_management_endpoint_is_permitted_to_admin_without_useSsoOnly(string $method, string $url)
{
Settings::set('useSsoOnly', false);
$response = $this->actingAs($this->admin, 'web-guard')
->json($method, $url);
$this->assertNotEquals($response->getStatusCode(), Response::HTTP_FORBIDDEN);
}
#[Test]
#[DataProvider('provideWebauthnManagementEndPoints')]
public function test_webauthn_management_endpoint_is_permitted_to_admin_with_useSsoOnly(string $method, string $url)
{
Settings::set('useSsoOnly', true);
$response = $this->actingAs($this->admin, 'web-guard')
->json($method, $url);
$this->assertNotEquals($response->getStatusCode(), Response::HTTP_FORBIDDEN);
}
/**
* Provide Valid data for validation test
*/
public static function provideWebauthnManagementEndPoints() : array
{
return [
'WEBAUTHN_REGISTER_OPTIONS' => [
'method' => 'POST',
'url' => '/webauthn/register/options',
],
'WEBAUTHN_REGISTER' => [
'method' => 'POST',
'url' => '/webauthn/register',
],
'WEBAUTHN_CREDENTIALS_ALL' => [
'method' => 'GET',
'url' => '/webauthn/credentials',
],
'WEBAUTHN_CREDENTIALS_PATCH' => [
'method' => 'PATCH',
'url' => '/webauthn/credentials/FAKE_CREDENTIAL_ID/name',
],
'WEBAUTHN_CREDENTIALS_DELETE' => [
'method' => 'DELETE',
'url' => '/webauthn/credentials/FAKE_CREDENTIAL_ID',
],
];
}
}