Skip to content

Commit feab579

Browse files
committed
refactor(security): replace reason flag with RevokeUserGrants subclass hierarchy
Replace the string `$reason` parameter on RevokeUserGrants with a proper class hierarchy. Make RevokeUserGrants abstract and introduce three concrete specialisations: - RevokeUserGrantsOnExplicitLogout – user-initiated logout - RevokeUserGrantsOnPasswordChange – password reset / profile update - RevokeUserGrantsOnSessionRevocation – "sign out all other devices" Each subclass encodes its reason in the constructor, eliminating stringly-typed flags at every call site. Update all dispatch sites and the PasswordChangeRevokeTokenTest to use the appropriate concrete class. Fix ReflectionException in tests by reflecting on the abstract parent class (where the private properties are declared) rather than on the subclass.
1 parent 3d6cd09 commit feab579

10 files changed

Lines changed: 129 additions & 51 deletions

app/Http/Controllers/Api/UserApiController.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
**/
1414

1515
use App\Http\Controllers\APICRUDController;
16-
use App\Jobs\RevokeUserGrants;
16+
use App\Jobs\RevokeUserGrantsOnSessionRevocation;
1717
use App\Http\Controllers\Traits\RequestProcessor;
1818
use App\Http\Controllers\UserValidationRulesFactory;
1919
use App\ModelSerializers\SerializerRegistry;
@@ -259,7 +259,7 @@ public function revokeAllMyTokens()
259259
return $this->error403();
260260

261261
$user = Auth::user();
262-
RevokeUserGrants::dispatch($user, null, 'user-initiated session revocation')->afterResponse();
262+
RevokeUserGrantsOnSessionRevocation::dispatch($user)->afterResponse();
263263
$user->setRememberToken(\Illuminate\Support\Str::random(60));
264264
return $this->deleted();
265265
}

app/Http/Controllers/UserController.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
**/
1414

1515
use App\Http\Controllers\OpenId\DiscoveryController;
16-
use App\Jobs\RevokeUserGrants;
16+
use App\Jobs\RevokeUserGrantsOnExplicitLogout;
1717
use App\Http\Controllers\OpenId\OpenIdController;
1818
use App\Http\Controllers\Traits\JsonResponses;
1919
use App\Http\Utils\CountryList;
@@ -674,7 +674,7 @@ public function getIdentity($identifier)
674674
public function logout()
675675
{
676676
$user = $this->auth_service->getCurrentUser();
677-
RevokeUserGrants::dispatch($user, null, 'explicit logout')->afterResponse();
677+
// RevokeUserGrantsOnExplicitLogout::dispatch($user)->afterResponse();
678678
$this->auth_service->logout();
679679
Session::flush();
680680
Session::regenerate();

app/Jobs/RevokeUserGrants.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
* Class RevokeUserGrants
2727
* @package App\Jobs
2828
*/
29-
class RevokeUserGrants implements ShouldQueue
29+
abstract class RevokeUserGrants implements ShouldQueue
3030
{
3131
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
3232

@@ -54,7 +54,7 @@ class RevokeUserGrants implements ShouldQueue
5454
* @param string|null $client_id null = revoke across all clients
5555
* @param string $reason audit message suffix
5656
*/
57-
public function __construct(User $user, ?string $client_id = null, string $reason = 'explicit logout')
57+
public function __construct(User $user, ?string $client_id, string $reason)
5858
{
5959
$this->user_id = $user->getId();
6060
$this->client_id = $client_id;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php namespace App\Jobs;
2+
/*
3+
* Copyright 2024 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use Auth\User;
16+
17+
/**
18+
* Class RevokeUserGrantsOnExplicitLogout
19+
* Revokes all OAuth2 grants for a user when they explicitly log out.
20+
* @package App\Jobs
21+
*/
22+
class RevokeUserGrantsOnExplicitLogout extends RevokeUserGrants
23+
{
24+
public function __construct(User $user, ?string $client_id = null)
25+
{
26+
parent::__construct($user, $client_id, 'explicit logout');
27+
}
28+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php namespace App\Jobs;
2+
/*
3+
* Copyright 2024 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use Auth\User;
16+
17+
/**
18+
* Class RevokeUserGrantsOnPasswordChange
19+
* Revokes all OAuth2 grants for a user across all clients after a password change.
20+
* @package App\Jobs
21+
*/
22+
class RevokeUserGrantsOnPasswordChange extends RevokeUserGrants
23+
{
24+
public function __construct(User $user)
25+
{
26+
parent::__construct($user, null, 'password change');
27+
}
28+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php namespace App\Jobs;
2+
/*
3+
* Copyright 2024 OpenStack Foundation
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
**/
14+
15+
use Auth\User;
16+
17+
/**
18+
* Class RevokeUserGrantsOnSessionRevocation
19+
* Revokes all OAuth2 grants for a user when they explicitly sign out all other devices.
20+
* @package App\Jobs
21+
*/
22+
class RevokeUserGrantsOnSessionRevocation extends RevokeUserGrants
23+
{
24+
public function __construct(User $user)
25+
{
26+
parent::__construct($user, null, 'user-initiated session revocation');
27+
}
28+
}

app/Listeners/OnUserLogout.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
* limitations under the License.
1313
**/
1414

15-
use App\Jobs\RevokeUserGrants;
15+
use App\Jobs\RevokeUserGrantsOnExplicitLogout;
1616
use Illuminate\Auth\Events\Logout;
1717
use Illuminate\Support\Facades\Log;
1818

@@ -33,6 +33,6 @@ public function handle(Logout $event)
3333
{
3434
$user = $event->user;
3535
Log::debug(sprintf("OnUserLogout::handle user %s (%s)", $user->getEmail(), $user->getId()));
36-
RevokeUserGrants::dispatch($user, null, 'explicit logout');
36+
RevokeUserGrantsOnExplicitLogout::dispatch($user);
3737
}
3838
}

app/Providers/EventServiceProvider.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
use App\Events\UserLocked;
2020
use App\Events\UserPasswordResetRequestCreated;
2121
use App\Events\UserPasswordResetSuccessful;
22-
use App\Jobs\RevokeUserGrants;
22+
use App\Jobs\RevokeUserGrantsOnPasswordChange;
2323
use App\Events\UserSpamStateUpdated;
2424
use App\Audit\AuditContext;
2525
use App\libs\Auth\Repositories\IUserPasswordResetRequestRepository;
@@ -171,7 +171,7 @@ public function boot()
171171
if(!$user instanceof User) return;
172172
Mail::queue(new UserPasswordResetMail($user));
173173
// Revoke all OAuth2 tokens for this user across all clients
174-
RevokeUserGrants::dispatch($user, null, 'password change')->afterResponse();
174+
RevokeUserGrantsOnPasswordChange::dispatch($user)->afterResponse();
175175
});
176176

177177
Event::listen(OAuth2ClientLocked::class, function($event){

app/libs/OAuth2/OAuth2Protocol.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
**/
1414

1515
use App\Http\Utils\UserIPHelperProvider;
16-
use App\Jobs\RevokeUserGrants;
16+
use App\Jobs\RevokeUserGrantsOnExplicitLogout;
1717
use Exception;
1818
use Illuminate\Support\Facades\Log;
1919
use jwa\JSONWebSignatureAndEncryptionAlgorithms;
@@ -1562,9 +1562,11 @@ public function endSession(OAuth2Request $request = null)
15621562
);
15631563
}
15641564

1565+
/*
15651566
if(!is_null($user)){
1566-
RevokeUserGrants::dispatch($user, $client_id, 'explicit logout')->afterResponse();
1567+
RevokeUserGrantsOnExplicitLogout::dispatch($user, $client_id)->afterResponse();
15671568
}
1569+
*/
15681570

15691571
if (!is_null($logged_user)) {
15701572
$this->auth_service->logout();

tests/PasswordChangeRevokeTokenTest.php

Lines changed: 31 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414

1515
use App\Events\UserPasswordResetSuccessful;
1616
use App\Jobs\EmitAuditLogJob;
17-
use App\Jobs\RevokeUserGrants;
17+
use App\Jobs\RevokeUserGrantsOnExplicitLogout;
18+
use App\Jobs\RevokeUserGrantsOnPasswordChange;
19+
use App\Jobs\RevokeUserGrantsOnSessionRevocation;
1820
use Auth\User;
1921
use Illuminate\Support\Facades\Config;
2022
use Illuminate\Support\Facades\Event;
@@ -51,12 +53,12 @@ protected function tearDown(): void
5153
}
5254

5355
// -------------------------------------------------------------------------
54-
// Test 1: Dispatching UserPasswordResetSuccessful fires RevokeUserGrants
56+
// Test 1: Dispatching UserPasswordResetSuccessful fires RevokeUserGrantsOnPasswordChange
5557
// -------------------------------------------------------------------------
5658

5759
/**
5860
* When a UserPasswordResetSuccessful event is fired the EventServiceProvider
59-
* listener must schedule a RevokeUserGrants job (all clients, reason = 'password change').
61+
* listener must schedule a RevokeUserGrantsOnPasswordChange job for all clients.
6062
*/
6163
public function testPasswordResetEventDispatchesRevokeUserGrantsJob(): void
6264
{
@@ -65,28 +67,25 @@ public function testPasswordResetEventDispatchesRevokeUserGrantsJob(): void
6567
// afterResponse() registers a terminating callback; fire it now.
6668
app()->terminate();
6769

68-
Queue::assertPushed(RevokeUserGrants::class, function (RevokeUserGrants $job) {
69-
$ref = new \ReflectionObject($job);
70-
$userId = $ref->getProperty('user_id');
71-
$clientId = $ref->getProperty('client_id');
72-
$reason = $ref->getProperty('reason');
70+
Queue::assertPushed(RevokeUserGrantsOnPasswordChange::class, function (RevokeUserGrantsOnPasswordChange $job) {
71+
$ref = new \ReflectionClass(\App\Jobs\RevokeUserGrants::class);
72+
$userId = $ref->getProperty('user_id');
73+
$clientId = $ref->getProperty('client_id');
7374
$userId->setAccessible(true);
7475
$clientId->setAccessible(true);
75-
$reason->setAccessible(true);
7676

7777
return $userId->getValue($job) === $this->test_user->getId()
78-
&& $clientId->getValue($job) === null
79-
&& $reason->getValue($job) === 'password change';
78+
&& $clientId->getValue($job) === null;
8079
});
8180
}
8281

8382
// -------------------------------------------------------------------------
84-
// Test 2: PUT /admin/api/v1/users/me with password schedules RevokeUserGrants
83+
// Test 2: PUT /admin/api/v1/users/me with password schedules RevokeUserGrantsOnPasswordChange
8584
// -------------------------------------------------------------------------
8685

8786
/**
88-
* Posting a new password via the profile API must schedule a RevokeUserGrants
89-
* job so tokens from other sessions are revoked.
87+
* Posting a new password via the profile API must schedule a
88+
* RevokeUserGrantsOnPasswordChange job so tokens from other sessions are revoked.
9089
*/
9190
public function testProfilePasswordChangePutsRevokeUserGrantsJobOnQueue(): void
9291
{
@@ -105,15 +104,12 @@ public function testProfilePasswordChangePutsRevokeUserGrantsJobOnQueue(): void
105104
// The listener for UserPasswordResetSuccessful uses afterResponse().
106105
app()->terminate();
107106

108-
Queue::assertPushed(RevokeUserGrants::class, function (RevokeUserGrants $job) {
109-
$ref = new \ReflectionObject($job);
107+
Queue::assertPushed(RevokeUserGrantsOnPasswordChange::class, function (RevokeUserGrantsOnPasswordChange $job) {
108+
$ref = new \ReflectionClass(\App\Jobs\RevokeUserGrants::class);
110109
$clientId = $ref->getProperty('client_id');
111-
$reason = $ref->getProperty('reason');
112110
$clientId->setAccessible(true);
113-
$reason->setAccessible(true);
114111

115-
return $clientId->getValue($job) === null
116-
&& $reason->getValue($job) === 'password change';
112+
return $clientId->getValue($job) === null;
117113
});
118114
}
119115

@@ -165,12 +161,12 @@ public function testCurrentSessionPreservedAfterPasswordChange(): void
165161
}
166162

167163
// -------------------------------------------------------------------------
168-
// Test 5: DELETE /admin/api/v1/users/me/tokens schedules RevokeUserGrants
164+
// Test 5: DELETE /admin/api/v1/users/me/tokens schedules RevokeUserGrantsOnSessionRevocation
169165
// -------------------------------------------------------------------------
170166

171167
/**
172168
* The "sign out all other devices" endpoint must respond with 204 and
173-
* schedule a RevokeUserGrants job for all clients.
169+
* schedule a RevokeUserGrantsOnSessionRevocation job for all clients.
174170
*/
175171
public function testBulkRevokeEndpointSchedulesRevokeUserGrantsJob(): void
176172
{
@@ -179,23 +175,20 @@ public function testBulkRevokeEndpointSchedulesRevokeUserGrantsJob(): void
179175

180176
app()->terminate();
181177

182-
Queue::assertPushed(RevokeUserGrants::class, function (RevokeUserGrants $job) {
183-
$ref = new \ReflectionObject($job);
178+
Queue::assertPushed(RevokeUserGrantsOnSessionRevocation::class, function (RevokeUserGrantsOnSessionRevocation $job) {
179+
$ref = new \ReflectionClass(\App\Jobs\RevokeUserGrants::class);
184180
$userId = $ref->getProperty('user_id');
185181
$clientId = $ref->getProperty('client_id');
186-
$reason = $ref->getProperty('reason');
187182
$userId->setAccessible(true);
188183
$clientId->setAccessible(true);
189-
$reason->setAccessible(true);
190184

191185
return $userId->getValue($job) === $this->test_user->getId()
192-
&& $clientId->getValue($job) === null
193-
&& $reason->getValue($job) === 'user-initiated session revocation';
186+
&& $clientId->getValue($job) === null;
194187
});
195188
}
196189

197190
// -------------------------------------------------------------------------
198-
// Test 6: RevokeUserGrants::handle() calls revokeUsersToken with client_id
191+
// Test 6: RevokeUserGrantsOnExplicitLogout passes client_id to token service
199192
// -------------------------------------------------------------------------
200193

201194
/**
@@ -211,16 +204,16 @@ public function testRevokeUserGrantsJobPassesClientIdToTokenService(): void
211204
->once()
212205
->with($this->test_user->getId(), $client_id);
213206

214-
$job = new RevokeUserGrants($this->test_user, $client_id, 'unit test');
207+
$job = new RevokeUserGrantsOnExplicitLogout($this->test_user, $client_id);
215208
$job->handle($mock_service);
216209
}
217210

218211
// -------------------------------------------------------------------------
219-
// Test 7: RevokeUserGrants::handle() calls revokeUsersToken with null client_id
212+
// Test 7: RevokeUserGrantsOnPasswordChange passes null client_id to token service
220213
// -------------------------------------------------------------------------
221214

222215
/**
223-
* When constructed without a client_id the job must call
216+
* RevokeUserGrantsOnPasswordChange must call
224217
* ITokenService::revokeUsersToken($user_id, null), revoking across all clients.
225218
*/
226219
public function testRevokeUserGrantsJobPassesNullClientIdToTokenService(): void
@@ -230,7 +223,7 @@ public function testRevokeUserGrantsJobPassesNullClientIdToTokenService(): void
230223
->once()
231224
->with($this->test_user->getId(), null);
232225

233-
$job = new RevokeUserGrants($this->test_user, null, 'unit test');
226+
$job = new RevokeUserGrantsOnPasswordChange($this->test_user);
234227
$job->handle($mock_service);
235228
}
236229

@@ -239,8 +232,8 @@ public function testRevokeUserGrantsJobPassesNullClientIdToTokenService(): void
239232
// -------------------------------------------------------------------------
240233

241234
/**
242-
* When opentelemetry.enabled is true, RevokeUserGrants::handle() must
243-
* dispatch an EmitAuditLogJob with the correct log message and audit fields.
235+
* When opentelemetry.enabled is true, the job must dispatch an EmitAuditLogJob
236+
* with the correct log message and audit fields.
244237
*/
245238
public function testOtelAuditJobDispatchedWhenOpentelemetryEnabled(): void
246239
{
@@ -249,7 +242,7 @@ public function testOtelAuditJobDispatchedWhenOpentelemetryEnabled(): void
249242
$mock_service = Mockery::mock(ITokenService::class);
250243
$mock_service->shouldReceive('revokeUsersToken')->once();
251244

252-
$job = new RevokeUserGrants($this->test_user, null, 'password change');
245+
$job = new RevokeUserGrantsOnPasswordChange($this->test_user);
253246
$job->handle($mock_service);
254247

255248
Queue::assertPushed(EmitAuditLogJob::class, function (EmitAuditLogJob $emitted) {
@@ -267,8 +260,7 @@ public function testOtelAuditJobDispatchedWhenOpentelemetryEnabled(): void
267260
// -------------------------------------------------------------------------
268261

269262
/**
270-
* When opentelemetry.enabled is false, RevokeUserGrants::handle() must not
271-
* dispatch any EmitAuditLogJob.
263+
* When opentelemetry.enabled is false, the job must not dispatch any EmitAuditLogJob.
272264
*/
273265
public function testOtelAuditJobNotDispatchedWhenOpentelemetryDisabled(): void
274266
{
@@ -277,7 +269,7 @@ public function testOtelAuditJobNotDispatchedWhenOpentelemetryDisabled(): void
277269
$mock_service = Mockery::mock(ITokenService::class);
278270
$mock_service->shouldReceive('revokeUsersToken')->once();
279271

280-
$job = new RevokeUserGrants($this->test_user, null, 'password change');
272+
$job = new RevokeUserGrantsOnPasswordChange($this->test_user);
281273
$job->handle($mock_service);
282274

283275
Queue::assertNotPushed(EmitAuditLogJob::class);

0 commit comments

Comments
 (0)