From 0d91011e4bf1862a23a996d39db005d96b768151 Mon Sep 17 00:00:00 2001 From: romanetar Date: Fri, 25 Sep 2026 15:35:54 +0200 Subject: [PATCH 1/2] feat(2fa): trusted devices list and revoke endpoints Add GET/DELETE /admin/api/v1/users/me/2fa/devices[/{id}] so a user can list their active trusted devices and revoke one or all of them (SDS idp-mfa.md 5.6, CU-86bc647cd). - Revocation is scoped by owner: another user's device id returns 404. - Revoking an already revoked or expired device is a 204 no-op and is not audited; revoke-all logs one device_revoked event per device it actually revokes and none when there are no active devices. - Audit runs best-effort after the revocation commits, following RecoveryCodeService: audit_service->log() opens its own transaction, which cannot be nested. - The device-trust cookie is expired when the revoked device is the one the request came from. - The list never exposes device_identifier or user_agent and flags the current device via is_current; it does not touch last_seen_at. - Replace the bulk revokeAllForUser() UPDATE with a per-entity revoke of active devices so each revoked device can be audited. --- .../Controllers/Api/UserApiController.php | 108 ++++- .../Controllers/Traits/MFACookieManager.php | 32 ++ .../Auth/UserTrustedDeviceSerializer.php | 58 +++ app/ModelSerializers/SerializerRegistry.php | 3 + .../DoctrineUserTrustedDeviceRepository.php | 18 +- app/Services/Auth/DeviceTrustService.php | 75 ++- app/Services/Auth/IDeviceTrustService.php | 25 +- .../IUserTrustedDeviceRepository.php | 5 +- routes/web.php | 3 + tests/DeviceTrustServiceTest.php | 111 ++++- tests/TrustedDevicesApiTest.php | 428 ++++++++++++++++++ tests/TwoFactorProfileRoutesCsrfTest.php | 10 +- 12 files changed, 844 insertions(+), 32 deletions(-) create mode 100644 app/ModelSerializers/Auth/UserTrustedDeviceSerializer.php create mode 100644 tests/TrustedDevicesApiTest.php diff --git a/app/Http/Controllers/Api/UserApiController.php b/app/Http/Controllers/Api/UserApiController.php index b334959b..3bc1f5c6 100644 --- a/app/Http/Controllers/Api/UserApiController.php +++ b/app/Http/Controllers/Api/UserApiController.php @@ -13,9 +13,13 @@ **/ use App\Http\Controllers\APICRUDController; +use App\Http\Controllers\Traits\MFACookieManager; use App\Http\Controllers\Traits\RequestProcessor; use App\Http\Controllers\UserValidationRulesFactory; +use App\libs\Auth\Models\UserTrustedDevice; +use App\ModelSerializers\Auth\UserTrustedDeviceSerializer; use App\ModelSerializers\SerializerRegistry; +use App\Services\Auth\IDeviceTrustService; use App\Services\Auth\IRecoveryCodeService; use Auth\Repositories\IUserRepository; use Auth\User; @@ -40,6 +44,8 @@ final class UserApiController extends APICRUDController use RequestProcessor; + use MFACookieManager; + /** * @var ITokenService */ @@ -50,6 +56,11 @@ final class UserApiController extends APICRUDController */ private $recovery_code_service; + /** + * @var IDeviceTrustService + */ + private $device_trust_service; + /** * UserApiController constructor. * @param IUserRepository $user_repository @@ -57,6 +68,7 @@ final class UserApiController extends APICRUDController * @param IUserService $user_service * @param ITokenService $token_service * @param IRecoveryCodeService $recovery_code_service + * @param IDeviceTrustService $device_trust_service */ public function __construct ( @@ -64,12 +76,14 @@ public function __construct ILogService $log_service, IUserService $user_service, ITokenService $token_service, - IRecoveryCodeService $recovery_code_service + IRecoveryCodeService $recovery_code_service, + IDeviceTrustService $device_trust_service ) { parent::__construct($user_repository, $user_service, $log_service); $this->token_service = $token_service; $this->recovery_code_service = $recovery_code_service; + $this->device_trust_service = $device_trust_service; } /** @@ -319,6 +333,98 @@ public function regenerateRecoveryCodes() }); } + /** + * Lists the current user's active trusted devices. "is_current" flags the + * device whose device-trust cookie came with this request. + * + * @return \Illuminate\Http\JsonResponse|mixed + */ + public function getMyTrustedDevices() + { + if (!Auth::check()) + return $this->error403(); + + return $this->processRequest(function () { + $params = [ + UserTrustedDeviceSerializer::ParamCurrentDeviceIdentifier => $this->getCurrentDeviceIdentifier(), + ]; + + $data = array_map( + fn(UserTrustedDevice $device) => SerializerRegistry::getInstance() + ->getSerializer($device) + ->serialize(null, [], [], $params), + $this->device_trust_service->getActiveTrustedDevices(Auth::user()) + ); + + return $this->ok(['data' => array_values($data)]); + }); + } + + /** + * Revokes one of the current user's trusted devices. Only the MFA bypass is + * removed; the current session stays active. + * + * @param $id + * @return \Illuminate\Http\JsonResponse|mixed + */ + public function revokeMyTrustedDevice($id) + { + if (!Auth::check()) + return $this->error403(); + + return $this->processRequest(function () use ($id) { + $device = $this->device_trust_service->revokeTrustedDevice(Auth::user(), intval($id)); + + if ($this->isCurrentDevice($device)) { + $this->expireDeviceTrustCookie(); + } + + return $this->deleted(); + }); + } + + /** + * Revokes all of the current user's active trusted devices. Only the MFA + * bypass is removed; the current session stays active. + * + * @return \Illuminate\Http\JsonResponse|mixed + */ + public function revokeAllMyTrustedDevices() + { + if (!Auth::check()) + return $this->error403(); + + return $this->processRequest(function () { + $devices = $this->device_trust_service->removeTrustedDevices(Auth::user()); + + foreach ($devices as $device) { + if ($this->isCurrentDevice($device)) { + $this->expireDeviceTrustCookie(); + break; + } + } + + return $this->deleted(); + }); + } + + /** + * Hashed identifier of the device-trust cookie sent with this request, or + * null when there is none. + */ + private function getCurrentDeviceIdentifier(): ?string + { + $token = $this->getCookieToken(); + if (empty($token)) return null; + return $this->device_trust_service->generateDeviceIdentifier($token); + } + + private function isCurrentDevice(UserTrustedDevice $device): bool + { + $current = $this->getCurrentDeviceIdentifier(); + return !is_null($current) && hash_equals($device->getDeviceIdentifier(), $current); + } + public function revokeAllMyTokens() { if (!Auth::check()) diff --git a/app/Http/Controllers/Traits/MFACookieManager.php b/app/Http/Controllers/Traits/MFACookieManager.php index 718b9305..65efb65d 100644 --- a/app/Http/Controllers/Traits/MFACookieManager.php +++ b/app/Http/Controllers/Traits/MFACookieManager.php @@ -83,4 +83,36 @@ protected function queueDeviceTrustCookie(User $user): void ); } + + /** + * Queues an already-expired trusted-device cookie so the browser drops it. + * Name, path, domain and flags must match queueDeviceTrustCookie() or the + * browser treats it as a different cookie and keeps the original. + * + * @return void + */ + protected function expireDeviceTrustCookie(): void + { + $name = Config::get('two_factor.cookie_name', 'device_trust_token'); + $path = Config::get('session.path'); + $domain = Config::get('session.domain'); + $secure = true; + $httpOnly = true; + $raw = false; + $sameSite = 'lax'; + + // Negative lifetime, same as \Illuminate\Cookie\CookieJar::forget() + Cookie::queue + ( + $name, + '', // value + -2628000, + $path, + $domain, + $secure, + $httpOnly, + $raw, + $sameSite + ); + } } diff --git a/app/ModelSerializers/Auth/UserTrustedDeviceSerializer.php b/app/ModelSerializers/Auth/UserTrustedDeviceSerializer.php new file mode 100644 index 00000000..134b18fc --- /dev/null +++ b/app/ModelSerializers/Auth/UserTrustedDeviceSerializer.php @@ -0,0 +1,58 @@ + 'device_name:json_string', + 'IpAddress' => 'ip_address:json_string', + 'TrustedAt' => 'trusted_at:datetime_epoch', + 'ExpiresAt' => 'expires_at:datetime_epoch', + 'LastSeenAt' => 'last_seen_at:datetime_epoch', + ]; + + /** + * @param null $expand + * @param array $fields + * @param array $relations + * @param array $params + * @return array + */ + public function serialize($expand = null, array $fields = [], array $relations = [], array $params = []) + { + $device = $this->object; + if (!$device instanceof UserTrustedDevice) return []; + $values = parent::serialize($expand, $fields, $relations, $params); + $current = $params[self::ParamCurrentDeviceIdentifier] ?? null; + $values['is_current'] = is_string($current) && hash_equals($device->getDeviceIdentifier(), $current); + return $values; + } +} diff --git a/app/ModelSerializers/SerializerRegistry.php b/app/ModelSerializers/SerializerRegistry.php index 12250286..e65b716c 100644 --- a/app/ModelSerializers/SerializerRegistry.php +++ b/app/ModelSerializers/SerializerRegistry.php @@ -16,6 +16,7 @@ use App\ModelSerializers\Auth\PublicUserSerializer; use App\ModelSerializers\Auth\UserActionSerializer; use App\ModelSerializers\Auth\UserRegistrationRequestSerializer; +use App\ModelSerializers\Auth\UserTrustedDeviceSerializer; use App\ModelSerializers\OAuth2\AccessTokenSerializer; use App\ModelSerializers\OAuth2\ApiEndpointSerializer; use App\ModelSerializers\OAuth2\ApiScopeGroupSerializer; @@ -83,6 +84,8 @@ private function __construct() $this->registry["UserAction"] = UserActionSerializer::class; + $this->registry["UserTrustedDevice"] = UserTrustedDeviceSerializer::class; + $this->registry["UserRegistrationRequest"] = UserRegistrationRequestSerializer::class; $this->registry["Group"] = [ diff --git a/app/Repositories/DoctrineUserTrustedDeviceRepository.php b/app/Repositories/DoctrineUserTrustedDeviceRepository.php index 37267066..b3953940 100644 --- a/app/Repositories/DoctrineUserTrustedDeviceRepository.php +++ b/app/Repositories/DoctrineUserTrustedDeviceRepository.php @@ -42,17 +42,15 @@ public function getByUserAndDeviceIdentifier(User $user, string $deviceIdentifie return $result instanceof UserTrustedDevice ? $result : null; } - public function revokeAllForUser(User $user): void + public function getByIdAndUser(int $id, User $user): ?UserTrustedDevice { - $this->getEntityManager() - ->createQueryBuilder() - ->update($this->getBaseEntity(), 'd') - ->set('d.is_revoked', ':revoked') - ->where('d.user = :user') - ->setParameter('revoked', true) - ->setParameter('user', $user) - ->getQuery() - ->execute(); + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('id', $id)) + ->andWhere(Criteria::expr()->eq('user', $user)) + ->setMaxResults(1); + + $result = $this->matching($criteria)->first(); + return $result instanceof UserTrustedDevice ? $result : null; } public function getActiveByUserAndIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice diff --git a/app/Services/Auth/DeviceTrustService.php b/app/Services/Auth/DeviceTrustService.php index d4242c2f..4057cee8 100644 --- a/app/Services/Auth/DeviceTrustService.php +++ b/app/Services/Auth/DeviceTrustService.php @@ -20,6 +20,8 @@ use DateTime; use DateInterval; use DateTimeZone; +use Illuminate\Support\Facades\Log; +use models\exceptions\EntityNotFoundException; use Utils\IPHelper; use Utils\Db\ITransactionService; @@ -96,15 +98,72 @@ public function isDeviceTrusted(User $user, ?string $cookieToken): bool return true; } - public function removeTrustedDevices(User $user): void + public function getActiveTrustedDevices(User $user): array { - $this->repository->revokeAllForUser($user); + return $this->repository->getActiveByUser($user); + } - $this->audit_service->log( - $user, - TwoFactorAuditLog::EventDeviceRevoked, - $user->getTwoFactorMethod(), - IPHelper::getUserIp() - ); + public function revokeTrustedDevice(User $user, int $deviceId): UserTrustedDevice + { + // Scoped by owner: another user's device id is indistinguishable from a + // missing one, so the caller cannot probe for ids that exist. + $device = $this->repository->getByIdAndUser($deviceId, $user); + if (!$device instanceof UserTrustedDevice) { + throw new EntityNotFoundException('Trusted device not found.'); + } + + // Idempotent: a device that no longer bypasses the challenge has nothing + // to revoke, and logging it again would only add noise to the audit trail. + if ($device->isRevoked() || $device->isExpired()) { + return $device; + } + + $this->tx_service->transaction(function () use ($device) { + $device->setIsRevoked(true); + $this->repository->add($device, false); + }); + + $this->logDeviceRevoked($user, $device); + + return $device; + } + + public function removeTrustedDevices(User $user): array + { + $devices = $this->tx_service->transaction(function () use ($user) { + $devices = $this->repository->getActiveByUser($user); + foreach ($devices as $device) { + $device->setIsRevoked(true); + $this->repository->add($device, false); + } + return $devices; + }); + + foreach ($devices as $device) { + $this->logDeviceRevoked($user, $device); + } + + return $devices; + } + + /** + * Best-effort, after the revocation is committed: an audit failure must not + * 500 a request whose device is already revoked, and audit_service->log() + * opens its own transaction, which cannot be nested inside ours (see + * RecoveryCodeService::enableTwoFactorAndGenerateCodes()). + */ + private function logDeviceRevoked(User $user, UserTrustedDevice $device): void + { + try { + $this->audit_service->log( + $user, + TwoFactorAuditLog::EventDeviceRevoked, + $user->getTwoFactorMethod(), + IPHelper::getUserIp(), + ['device_id' => $device->getId()] + ); + } catch (\Throwable $ex) { + Log::warning($ex); + } } } diff --git a/app/Services/Auth/IDeviceTrustService.php b/app/Services/Auth/IDeviceTrustService.php index 2750335c..e629cf74 100644 --- a/app/Services/Auth/IDeviceTrustService.php +++ b/app/Services/Auth/IDeviceTrustService.php @@ -12,7 +12,9 @@ * limitations under the License. **/ +use App\libs\Auth\Models\UserTrustedDevice; use Auth\User; +use models\exceptions\EntityNotFoundException; /** * Interface IDeviceTrustService @@ -34,9 +36,28 @@ public function isDeviceTrusted(User $user, ?string $cookieToken): bool; public function trustDevice(User $user, string $userAgent, string $ipAddress): string; /** - * Revokes all trusted devices for the given user. + * Returns the user's active (non-revoked, non-expired) trusted devices. + * Read-only: unlike isDeviceTrusted() it never touches last_seen_at. + * + * @return UserTrustedDevice[] */ - public function removeTrustedDevices(User $user): void; + public function getActiveTrustedDevices(User $user): array; + + /** + * Revokes one of the user's trusted devices. Idempotent: an already revoked + * or expired device is returned untouched and no audit event is logged. + * + * @throws EntityNotFoundException if the device does not exist or belongs to another user + */ + public function revokeTrustedDevice(User $user, int $deviceId): UserTrustedDevice; + + /** + * Revokes all active trusted devices for the given user, logging one + * device_revoked audit event per revoked device. + * + * @return UserTrustedDevice[] the devices that were revoked by this call + */ + public function removeTrustedDevices(User $user): array; /** * Returns the SHA-256 hash of the given token used as the stored device identifier. diff --git a/app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php b/app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php index 04e86edf..60f43477 100644 --- a/app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php +++ b/app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php @@ -23,9 +23,10 @@ interface IUserTrustedDeviceRepository extends IBaseRepository public function getByUserAndDeviceIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice; /** - * Revoke all trusted devices for the given user (sets is_revoked = true). + * Look up a trusted device record by id, scoped to its owner (no revoked/expiry filter). + * Returns null when the id does not exist or belongs to another user. */ - public function revokeAllForUser(User $user): void; + public function getByIdAndUser(int $id, User $user): ?UserTrustedDevice; /** * Look up an active (non-revoked, non-expired) trusted device for a user by its hashed identifier. diff --git a/routes/web.php b/routes/web.php index d0fa0aef..b1123e10 100644 --- a/routes/web.php +++ b/routes/web.php @@ -200,6 +200,9 @@ Route::get('actions', "UserActionApiController@getActionsByCurrentUser"); Route::post('recovery-codes/regenerate', ['middleware' => ['csrf'], 'uses' => "UserApiController@regenerateRecoveryCodes"]); Route::post('2fa/enable', ['middleware' => ['csrf'], 'uses' => "UserApiController@enableTwoFactor"]); + Route::get('2fa/devices', "UserApiController@getMyTrustedDevices"); + Route::delete('2fa/devices', ['middleware' => ['csrf'], 'uses' => "UserApiController@revokeAllMyTrustedDevices"]); + Route::delete('2fa/devices/{id}', ['middleware' => ['csrf'], 'uses' => "UserApiController@revokeMyTrustedDevice"]); }); Route::get('access-tokens', ['middleware' => ['openstackid.currentuser.serveradmin.json'], 'uses' => 'ClientApiController@getAllAccessTokens']); diff --git a/tests/DeviceTrustServiceTest.php b/tests/DeviceTrustServiceTest.php index 4d8ae8fb..247cdc8b 100644 --- a/tests/DeviceTrustServiceTest.php +++ b/tests/DeviceTrustServiceTest.php @@ -13,6 +13,7 @@ * limitations under the License. **/ +use App\libs\Auth\Models\TwoFactorAuditLog; use App\libs\Auth\Models\UserTrustedDevice; use App\Services\Auth\DeviceTrustService; use App\Services\Auth\ITwoFactorAuditService; @@ -22,6 +23,7 @@ use DateInterval; use DateTimeZone; use Mockery; +use models\exceptions\EntityNotFoundException; use Utils\Db\ITransactionService; /** @@ -275,22 +277,121 @@ public function testTrustDeviceSetsExpiresAtFromConfig(): void // removeTrustedDevices // ------------------------------------------------------------------------- - public function testRemoveTrustedDevicesRevokesAll(): void + public function testRemoveTrustedDevicesRevokesEveryActiveDeviceAndLogsOneEventEach(): void { $user = Mockery::mock(User::class); $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + $devices = [ + $this->makeDevice(expired: false, revoked: false), + $this->makeDevice(expired: false, revoked: false), + ]; + $this->repo - ->shouldReceive('revokeAllForUser') + ->shouldReceive('getActiveByUser') ->once() - ->with($user); + ->with($user) + ->andReturn($devices); + $this->repo->shouldReceive('add')->twice(); + + $this->audit_service + ->shouldReceive('log') + ->twice() + ->with($user, TwoFactorAuditLog::EventDeviceRevoked, User::MFAMethod_OTP, Mockery::type('string'), Mockery::type('array')); + + $revoked = $this->service->removeTrustedDevices($user); + + $this->assertSame($devices, $revoked); + foreach ($devices as $device) { + $this->assertTrue($device->isRevoked()); + } + } + + public function testRemoveTrustedDevicesWithoutActiveDevicesLogsNothing(): void + { + $user = Mockery::mock(User::class); + + $this->repo->shouldReceive('getActiveByUser')->once()->with($user)->andReturn([]); + $this->repo->shouldNotReceive('add'); + $this->audit_service->shouldNotReceive('log'); + + $this->assertSame([], $this->service->removeTrustedDevices($user)); + } + + public function testRemoveTrustedDevicesSurvivesAuditFailure(): void + { + $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + + $device = $this->makeDevice(expired: false, revoked: false); + $this->repo->shouldReceive('getActiveByUser')->once()->andReturn([$device]); + $this->repo->shouldReceive('add')->once(); + $this->audit_service->shouldReceive('log')->once()->andThrow(new \RuntimeException('audit down')); + + $revoked = $this->service->removeTrustedDevices($user); + + $this->assertCount(1, $revoked); + $this->assertTrue($device->isRevoked(), 'an audit failure must not undo or block the revocation'); + } + + // ------------------------------------------------------------------------- + // revokeTrustedDevice + // ------------------------------------------------------------------------- + + public function testRevokeTrustedDeviceRevokesAndLogsOnce(): void + { + $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + + $device = $this->makeDevice(expired: false, revoked: false); + $this->repo->shouldReceive('getByIdAndUser')->once()->with(42, $user)->andReturn($device); + $this->repo->shouldReceive('add')->once()->with($device, false); $this->audit_service ->shouldReceive('log') ->once() - ->with($user, \App\libs\Auth\Models\TwoFactorAuditLog::EventDeviceRevoked, User::MFAMethod_OTP, Mockery::type('string')); + ->with($user, TwoFactorAuditLog::EventDeviceRevoked, User::MFAMethod_OTP, Mockery::type('string'), Mockery::type('array')); + + $this->assertSame($device, $this->service->revokeTrustedDevice($user, 42)); + $this->assertTrue($device->isRevoked()); + } + + public function testRevokeTrustedDeviceThrowsWhenNotOwnedOrMissing(): void + { + $user = Mockery::mock(User::class); + + $this->repo->shouldReceive('getByIdAndUser')->once()->with(42, $user)->andReturn(null); + $this->repo->shouldNotReceive('add'); + $this->audit_service->shouldNotReceive('log'); + + $this->expectException(EntityNotFoundException::class); + $this->service->revokeTrustedDevice($user, 42); + } + + public function testRevokeAlreadyRevokedDeviceIsNoOp(): void + { + $user = Mockery::mock(User::class); + + $device = $this->makeDevice(expired: false, revoked: true); + $this->repo->shouldReceive('getByIdAndUser')->once()->andReturn($device); + $this->repo->shouldNotReceive('add'); + $this->audit_service->shouldNotReceive('log'); + + $this->assertSame($device, $this->service->revokeTrustedDevice($user, 42)); + } + + public function testRevokeExpiredDeviceIsNoOp(): void + { + $user = Mockery::mock(User::class); + + $device = $this->makeDevice(expired: true, revoked: false); + $this->repo->shouldReceive('getByIdAndUser')->once()->andReturn($device); + $this->repo->shouldNotReceive('add'); + $this->audit_service->shouldNotReceive('log'); + + $this->service->revokeTrustedDevice($user, 42); - $this->service->removeTrustedDevices($user); + $this->assertFalse($device->isRevoked(), 'an expired device is left as is'); } // ------------------------------------------------------------------------- diff --git a/tests/TrustedDevicesApiTest.php b/tests/TrustedDevicesApiTest.php new file mode 100644 index 00000000..734fc654 --- /dev/null +++ b/tests/TrustedDevicesApiTest.php @@ -0,0 +1,428 @@ +admin(); + $activeToken = $this->trustDevice($admin); + $this->trustDevice($admin); + $revokedId = $this->deviceIdFor($this->trustDevice($admin)); + $this->markRevoked($revokedId); + $expiredId = $this->deviceIdFor($this->trustDevice($admin)); + $this->markExpired($expiredId); + + $other = $this->user($this->createPlainUser()); + $otherId = $this->deviceIdFor($this->trustDevice($other)); + + $this->be($this->admin()); + $response = $this->listDevices([Config::get('two_factor.cookie_name') => $activeToken]); + + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $this->assertCount(2, $payload['data'], 'only the caller\'s non-revoked, non-expired devices must be listed'); + + $ids = array_column($payload['data'], 'id'); + $this->assertNotContains($revokedId, $ids); + $this->assertNotContains($expiredId, $ids); + $this->assertNotContains($otherId, $ids, 'another user\'s devices must never be listed'); + + foreach ($payload['data'] as $row) { + $this->assertArrayNotHasKey('device_identifier', $row); + $this->assertArrayNotHasKey('user_agent', $row); + foreach (['id', 'device_name', 'ip_address', 'trusted_at', 'expires_at', 'last_seen_at', 'is_current'] as $key) { + $this->assertArrayHasKey($key, $row); + } + } + + $current = array_values(array_filter($payload['data'], fn($row) => $row['is_current'])); + $this->assertCount(1, $current, 'exactly the device matching the request cookie must be flagged as current'); + $this->assertSame($this->deviceIdFor($activeToken), $current[0]['id']); + } + + public function testListWithoutCookieFlagsNoDeviceAsCurrent(): void + { + $admin = $this->admin(); + $this->trustDevice($admin); + + $this->be($admin); + $response = $this->listDevices(); + + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $this->assertCount(1, $payload['data']); + $this->assertFalse($payload['data'][0]['is_current']); + } + + public function testListDoesNotTouchLastSeenAt(): void + { + $admin = $this->admin(); + $token = $this->trustDevice($admin); + $id = $this->deviceIdFor($token); + $before = $this->device($id)->getLastSeenAt()->getTimestamp(); + + sleep(1); + $this->be($admin); + $this->listDevices([Config::get('two_factor.cookie_name') => $token]); + $this->assertResponseStatus(200); + + $this->assertSame($before, $this->device($id)->getLastSeenAt()->getTimestamp(), 'listing must not refresh last_seen_at'); + } + + // ------------------------------------------------------------------------- + // DELETE /2fa/devices/{id} + // ------------------------------------------------------------------------- + + public function testRevokeDeviceMarksRowAndLogsOneAuditEvent(): void + { + $admin = $this->admin(); + $id = $this->deviceIdFor($this->trustDevice($admin)); + $auditBefore = $this->countAudit($admin->getId(), TwoFactorAuditLog::EventDeviceRevoked); + + $this->be($this->admin()); + $this->revokeDevice($id); + + $this->assertResponseStatus(204); + $this->assertTrue($this->device($id)->isRevoked()); + $this->assertSame($auditBefore + 1, $this->countAudit($admin->getId(), TwoFactorAuditLog::EventDeviceRevoked)); + } + + public function testRevokeAlreadyRevokedDeviceIsIdempotentAndNotAudited(): void + { + $admin = $this->admin(); + $id = $this->deviceIdFor($this->trustDevice($admin)); + + $this->be($this->admin()); + $this->revokeDevice($id); + $this->assertResponseStatus(204); + $auditAfterFirst = $this->countAudit($admin->getId(), TwoFactorAuditLog::EventDeviceRevoked); + + $this->be($this->admin()); + $this->revokeDevice($id); + + $this->assertResponseStatus(204); + $this->assertSame($auditAfterFirst, $this->countAudit($admin->getId(), TwoFactorAuditLog::EventDeviceRevoked), 'a no-op revoke must not be audited'); + } + + public function testRevokeExpiredDeviceIsIdempotentAndNotAudited(): void + { + $admin = $this->admin(); + $id = $this->deviceIdFor($this->trustDevice($admin)); + $this->markExpired($id); + $auditBefore = $this->countAudit($admin->getId(), TwoFactorAuditLog::EventDeviceRevoked); + + $this->be($this->admin()); + $this->revokeDevice($id); + + $this->assertResponseStatus(204); + $this->assertSame($auditBefore, $this->countAudit($admin->getId(), TwoFactorAuditLog::EventDeviceRevoked)); + } + + public function testRevokeAnotherUsersDeviceReturns404AndLeavesRowUnchanged(): void + { + $other = $this->user($this->createPlainUser()); + $otherId = $this->deviceIdFor($this->trustDevice($other)); + + $this->be($this->admin()); + $this->revokeDevice($otherId); + + $this->assertResponseStatus(404); + $this->assertFalse($this->device($otherId)->isRevoked(), 'another user\'s device must not be revoked'); + $this->assertSame(0, $this->countAudit($other->getId(), TwoFactorAuditLog::EventDeviceRevoked)); + } + + public function testRevokeUnknownDeviceReturns404(): void + { + $this->be($this->admin()); + $this->revokeDevice(PHP_INT_MAX); + + $this->assertResponseStatus(404); + } + + public function testRevokingCurrentDeviceExpiresCookieAndNextLoginIsChallenged(): void + { + $admin = $this->admin(); + $token = $this->trustDevice($admin); + $id = $this->deviceIdFor($token); + + $this->be($this->admin()); + $response = $this->revokeDevice($id, [Config::get('two_factor.cookie_name') => $token]); + + $this->assertResponseStatus(204); + $cookie = $this->deviceTrustCookie($response); + $this->assertNotNull($cookie, 'revoking the current device must send back the device-trust cookie'); + $this->assertTrue($cookie->isCleared(), 'the device-trust cookie must be expired'); + + // Even if the browser kept the old token, the server-side row is revoked. + Auth::logout(); + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD, [Config::get('two_factor.cookie_name') => $token]); + + $this->assertResponseStatus(302); + $this->assertFalse(Auth::check(), 'a revoked device must no longer bypass the challenge'); + $this->assertSame('2fa', Session::get('flow'), 'the next login must land on the 2FA challenge'); + } + + public function testRevokingAnotherDeviceKeepsCurrentCookie(): void + { + $admin = $this->admin(); + $currentToken = $this->trustDevice($admin); + $otherId = $this->deviceIdFor($this->trustDevice($admin)); + + $this->be($this->admin()); + $response = $this->revokeDevice($otherId, [Config::get('two_factor.cookie_name') => $currentToken]); + + $this->assertResponseStatus(204); + $this->assertNull($this->deviceTrustCookie($response), 'the current device\'s cookie must be left alone'); + $this->assertFalse($this->device($this->deviceIdFor($currentToken))->isRevoked()); + } + + // ------------------------------------------------------------------------- + // DELETE /2fa/devices + // ------------------------------------------------------------------------- + + public function testRevokeAllRevokesEveryActiveDeviceAndLogsOneEventPerDevice(): void + { + $admin = $this->admin(); + $token = $this->trustDevice($admin); + $ids = [$this->deviceIdFor($token), $this->deviceIdFor($this->trustDevice($admin)), $this->deviceIdFor($this->trustDevice($admin))]; + $alreadyRevoked = $this->deviceIdFor($this->trustDevice($admin)); + $this->markRevoked($alreadyRevoked); + + $other = $this->user($this->createPlainUser()); + $otherId = $this->deviceIdFor($this->trustDevice($other)); + + $auditBefore = $this->countAudit($admin->getId(), TwoFactorAuditLog::EventDeviceRevoked); + + $this->be($this->admin()); + $response = $this->revokeAllDevices([Config::get('two_factor.cookie_name') => $token]); + + $this->assertResponseStatus(204); + foreach ($ids as $id) { + $this->assertTrue($this->device($id)->isRevoked()); + } + $this->assertSame( + $auditBefore + count($ids), + $this->countAudit($admin->getId(), TwoFactorAuditLog::EventDeviceRevoked), + 'exactly one audit event per device actually revoked' + ); + $this->assertFalse($this->device($otherId)->isRevoked(), 'another user\'s devices must not be touched'); + + $cookie = $this->deviceTrustCookie($response); + $this->assertNotNull($cookie); + $this->assertTrue($cookie->isCleared()); + } + + public function testRevokeAllWithoutActiveDevicesLogsNothing(): void + { + $admin = $this->admin(); + $auditBefore = $this->countAudit($admin->getId(), TwoFactorAuditLog::EventDeviceRevoked); + + $this->be($admin); + $response = $this->revokeAllDevices(); + + $this->assertResponseStatus(204); + $this->assertSame($auditBefore, $this->countAudit($admin->getId(), TwoFactorAuditLog::EventDeviceRevoked)); + $this->assertNull($this->deviceTrustCookie($response)); + } + + // ------------------------------------------------------------------------- + // unauthenticated + // ------------------------------------------------------------------------- + + /** + * @dataProvider deviceRoutes + */ + public function testUnauthenticatedCallIsRedirectedToLogin(string $method, string $uri): void + { + $admin = $this->admin(); + $id = $this->deviceIdFor($this->trustDevice($admin)); + + // Sent over HTTPS so the ssl middleware lets it through and the auth + // middleware is the one being exercised. + $response = $this->call($method, 'https://localhost' . str_replace('{id}', (string)$id, $uri)); + + // The admin/api/v1 group's auth middleware rejects guests before the + // controller runs, same as the sibling 2fa/enable route. + $this->assertResponseStatus(302); + $this->assertStringContainsString('/auth/login', $response->headers->get('Location')); + $this->assertFalse($this->device($id)->isRevoked()); + } + + public static function deviceRoutes(): array + { + return [ + 'list' => ['GET', '/admin/api/v1/users/me/2fa/devices'], + 'revoke one' => ['DELETE', '/admin/api/v1/users/me/2fa/devices/{id}'], + 'revoke all' => ['DELETE', '/admin/api/v1/users/me/2fa/devices'], + ]; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function listDevices(array $cookies = []) + { + return $this->action('GET', 'Api\\UserApiController@getMyTrustedDevices', [], [], $cookies); + } + + private function revokeDevice(int $id, array $cookies = []) + { + return $this->action('DELETE', 'Api\\UserApiController@revokeMyTrustedDevice', ['id' => $id], [], $cookies); + } + + private function revokeAllDevices(array $cookies = []) + { + return $this->action('DELETE', 'Api\\UserApiController@revokeAllMyTrustedDevices', [], [], $cookies); + } + + private function postLogin(string $username, string $password, array $cookies = []) + { + return $this->action('POST', 'UserController@postLogin', [ + 'username' => $username, + 'password' => $password, + 'flow' => 'password', + '_token' => Session::token(), + ], [], $cookies); + } + + private function trustDevice(User $user): string + { + // Helpers below clear the entity manager, so re-attach the owner first. + $user = EntityManager::getRepository(User::class)->find($user->getId()); + /** @var IDeviceTrustService $service */ + $service = App::make(IDeviceTrustService::class); + return $service->trustDevice($user, 'Mozilla/5.0 (test)', '127.0.0.1'); + } + + private function deviceIdFor(string $rawToken): int + { + EntityManager::clear(); + $device = EntityManager::getRepository(UserTrustedDevice::class) + ->findOneBy(['device_identifier' => hash('sha256', $rawToken)]); + $this->assertInstanceOf(UserTrustedDevice::class, $device); + return $device->getId(); + } + + private function device(int $id): UserTrustedDevice + { + EntityManager::clear(); + $device = EntityManager::getRepository(UserTrustedDevice::class)->find($id); + $this->assertInstanceOf(UserTrustedDevice::class, $device); + return $device; + } + + private function markRevoked(int $id): void + { + $device = $this->device($id); + $device->setIsRevoked(true); + EntityManager::flush(); + } + + private function markExpired(int $id): void + { + $device = $this->device($id); + $past = new DateTime('now', new DateTimeZone('UTC')); + $past->sub(new DateInterval('P1D')); + $device->setExpiresAt($past); + EntityManager::flush(); + } + + private function deviceTrustCookie($response): ?Cookie + { + foreach ($response->headers->getCookies() as $cookie) { + if ($cookie->getName() === Config::get('two_factor.cookie_name')) { + return $cookie; + } + } + return null; + } + + private function admin(): User + { + return $this->user(self::ADMIN_EMAIL); + } + + private function user(string $email): User + { + EntityManager::clear(); + $user = EntityManager::getRepository(User::class)->getByEmailOrName($email); + $this->assertInstanceOf(User::class, $user, "user {$email} not found"); + return $user; + } + + private function createPlainUser(): string + { + $email = 'plain.' . uniqid() . '@test.invalid'; + $user = UserFactory::build([ + 'first_name' => 'Plain', + 'last_name' => 'User', + 'email' => $email, + 'password' => self::SEED_PASSWORD, + 'password_enc' => AuthHelper::AlgSHA1_V2_4, + 'active' => true, + 'email_verified' => true, + 'identifier' => 'plain.' . uniqid(), + ]); + EntityManager::persist($user); + EntityManager::flush(); + return $email; + } + + private function countAudit(int $userId, string $eventType): int + { + EntityManager::clear(); + return count( + EntityManager::getRepository(TwoFactorAuditLog::class) + ->findBy(['user' => $userId, 'event_type' => $eventType]) + ); + } +} diff --git a/tests/TwoFactorProfileRoutesCsrfTest.php b/tests/TwoFactorProfileRoutesCsrfTest.php index 940ef3ce..de4c88d3 100644 --- a/tests/TwoFactorProfileRoutesCsrfTest.php +++ b/tests/TwoFactorProfileRoutesCsrfTest.php @@ -32,9 +32,9 @@ final class TwoFactorProfileRoutesCsrfTest extends TestCase /** * @dataProvider stateChangingRoutes */ - public function testTwoFactorProfileRouteRequiresCsrf(string $uri): void + public function testTwoFactorProfileRouteRequiresCsrf(string $uri, string $method = 'POST'): void { - $route = Route::getRoutes()->match(Request::create($uri, 'POST')); + $route = Route::getRoutes()->match(Request::create($uri, $method)); $this->assertContains('csrf', $route->gatherMiddleware()); } @@ -42,8 +42,10 @@ public function testTwoFactorProfileRouteRequiresCsrf(string $uri): void public static function stateChangingRoutes(): array { return [ - 'enable 2fa' => ['/admin/api/v1/users/me/2fa/enable'], - 'regenerate recovery codes' => ['/admin/api/v1/users/me/recovery-codes/regenerate'], + 'enable 2fa' => ['/admin/api/v1/users/me/2fa/enable'], + 'regenerate recovery codes' => ['/admin/api/v1/users/me/recovery-codes/regenerate'], + 'revoke trusted device' => ['/admin/api/v1/users/me/2fa/devices/1', 'DELETE'], + 'revoke all trusted devices' => ['/admin/api/v1/users/me/2fa/devices', 'DELETE'], ]; } } From e1547497062e008a8d1d83696fe93770620af23b Mon Sep 17 00:00:00 2001 From: romanetar Date: Fri, 25 Sep 2026 16:01:59 +0200 Subject: [PATCH 2/2] feat(profile): trusted devices subsection with revoke actions Add a Trusted Devices subsection to the profile page (SDS idp-mfa.md 5.5, CU-86bc647cd), shown only when 2FA applies to the user. It lists the user's active trusted devices (name, IP, trusted, last seen, expiry), labels the current one, and lets the user revoke a single device or all of them (with a confirmation), updating the table in place. Buttons are plain onClick handlers since the profile page is a single
. Expose the three device endpoints on window from profile.blade.php and add getTrustedDevices / revokeTrustedDevice / revokeAllTrustedDevices helpers, covered by Jest tests for the component and the unmocked request layer. --- .../js/components/trusted_devices_section.js | 139 ++++++++++++++++++ resources/js/profile/actions.js | 12 ++ resources/js/profile/profile.js | 12 ++ resources/views/profile.blade.php | 3 + .../trusted_devices_section.test.js | 112 ++++++++++++++ tests/js/profile/actions.test.js | 45 +++++- 6 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 resources/js/components/trusted_devices_section.js create mode 100644 tests/js/components/trusted_devices_section.test.js diff --git a/resources/js/components/trusted_devices_section.js b/resources/js/components/trusted_devices_section.js new file mode 100644 index 00000000..f7bab60e --- /dev/null +++ b/resources/js/components/trusted_devices_section.js @@ -0,0 +1,139 @@ +import React, {useEffect, useState} from "react"; +import Box from "@material-ui/core/Box"; +import Button from "@material-ui/core/Button"; +import Chip from "@material-ui/core/Chip"; +import Table from "@material-ui/core/Table"; +import TableBody from "@material-ui/core/TableBody"; +import TableCell from "@material-ui/core/TableCell"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import Typography from "@material-ui/core/Typography"; +import moment from "moment"; +import Swal from "sweetalert2"; +import {getTrustedDevices, revokeAllTrustedDevices, revokeTrustedDevice} from "../profile/actions"; +import {handleErrorResponse} from "../utils"; + +const formatEpoch = (value) => value ? moment.utc(value * 1000).format("DD/MM/YYYY hh:mm A") : ""; + +const TrustedDevicesSection = () => { + const [devices, setDevices] = useState([]); + const [loaded, setLoaded] = useState(false); + const [busy, setBusy] = useState(false); + + useEffect(() => { + getTrustedDevices().then(({response}) => { + setDevices(response?.data ?? []); + setLoaded(true); + }).catch((err) => { + setLoaded(true); + handleErrorResponse(err); + }); + }, []); + + // Buttons are plain onClick handlers: the whole profile page is one , + // so anything of type="submit" here would submit the profile instead. + const handleRevoke = (id) => { + setBusy(true); + revokeTrustedDevice(id).then(() => { + setBusy(false); + setDevices((current) => current.filter((device) => device.id !== id)); + }).catch((err) => { + setBusy(false); + handleErrorResponse(err); + }); + }; + + const handleRevokeAll = () => { + Swal({ + title: 'Revoke all trusted devices?', + text: 'Every device will be asked for a verification code on its next login. Your current session stays active.', + showCancelButton: true, + confirmButtonColor: '#3085d6', + cancelButtonColor: '#d33', + confirmButtonText: 'Yes, revoke all' + }).then((result) => { + if (!result.value) return; + setBusy(true); + revokeAllTrustedDevices().then(() => { + setBusy(false); + setDevices([]); + }).catch((err) => { + setBusy(false); + handleErrorResponse(err); + }); + }); + }; + + if (!loaded) return null; + + return ( + + {devices.length === 0 ? ( + + You have no trusted devices. Devices you mark as trusted when completing a + two-factor challenge will appear here. + + ) : ( + <> + + + + Device + IP Address + Trusted + Last Seen + Expires + + + + + {devices.map((device) => ( + + + {device.device_name} + {device.is_current && ( + + )} + + {device.ip_address} + {formatEpoch(device.trusted_at)} + {formatEpoch(device.last_seen_at)} + {formatEpoch(device.expires_at)} + + + + + ))} + +
+ + + + Revoking a device only removes its two-factor bypass: your current session + stays active, and the next login from that device will ask for a code. + + + + )} +
+ ); +}; + +export default TrustedDevicesSection; diff --git a/resources/js/profile/actions.js b/resources/js/profile/actions.js index 0c9ea422..91bf4dd5 100644 --- a/resources/js/profile/actions.js +++ b/resources/js/profile/actions.js @@ -97,6 +97,18 @@ export const enableTwoFactor = async (method) => { return postRawRequestFull(window.ENABLE_TWO_FACTOR_ENDPOINT)(params, {'X-CSRF-TOKEN': window.CSFR_TOKEN}); } +export const getTrustedDevices = async () => { + return getRawRequest(window.GET_TRUSTED_DEVICES_ENDPOINT)({}); +} + +export const revokeTrustedDevice = async (id) => { + return deleteRawRequest(window.REVOKE_TRUSTED_DEVICE_ENDPOINT.replace('@id', id))({'X-CSRF-TOKEN': window.CSFR_TOKEN}); +} + +export const revokeAllTrustedDevices = async () => { + return deleteRawRequest(window.REVOKE_ALL_TRUSTED_DEVICES_ENDPOINT)({'X-CSRF-TOKEN': window.CSFR_TOKEN}); +} + const normalizeEntity = (entity) => { entity.public_profile_show_photo = entity.public_profile_show_photo ? 1 : 0; entity.public_profile_show_fullname = entity.public_profile_show_fullname ? 1 : 0; diff --git a/resources/js/profile/profile.js b/resources/js/profile/profile.js index acfdc062..67288531 100644 --- a/resources/js/profile/profile.js +++ b/resources/js/profile/profile.js @@ -29,6 +29,7 @@ import Divider from "@material-ui/core/Divider"; import Link from "@material-ui/core/Link"; import PasswordChangePanel from "../components/password_change_panel"; import TwoFactorSection from "../components/two_factor_section"; +import TrustedDevicesSection from "../components/trusted_devices_section"; import LoadingIndicator from "../components/loading_indicator"; import TopLogo from "../components/top_logo/top_logo"; import {handleErrorResponse} from "../utils"; @@ -795,6 +796,17 @@ const ProfilePage = ({ appName={appName}/> + {twoFactorEnabled && ( + + + + Trusted Devices + + + + + )} {!! script_to('assets/profile.js') !!} diff --git a/tests/js/components/trusted_devices_section.test.js b/tests/js/components/trusted_devices_section.test.js new file mode 100644 index 00000000..d0977806 --- /dev/null +++ b/tests/js/components/trusted_devices_section.test.js @@ -0,0 +1,112 @@ +import React from 'react'; +import {render, screen, fireEvent, waitFor} from '@testing-library/react'; +import Swal from 'sweetalert2'; +import TrustedDevicesSection from '../../../resources/js/components/trusted_devices_section'; +import { + getTrustedDevices, + revokeAllTrustedDevices, + revokeTrustedDevice +} from '../../../resources/js/profile/actions'; + +jest.mock('../../../resources/js/profile/actions'); +jest.mock('sweetalert2', () => jest.fn()); + +const device = (id, overrides = {}) => ({ + id, + device_name: `Mozilla/5.0 device ${id}`, + ip_address: '10.0.0.' + id, + trusted_at: 1790000000, + last_seen_at: 1790100000, + expires_at: 1792592000, + is_current: false, + ...overrides, +}); + +describe('TrustedDevicesSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the trusted devices table and marks the current device', async () => { + getTrustedDevices.mockResolvedValue({response: {data: [device(1, {is_current: true}), device(2)]}}); + + render(); + + expect(await screen.findByTestId('trusted-devices-table')).toBeInTheDocument(); + expect(screen.getByText('Mozilla/5.0 device 1')).toBeInTheDocument(); + expect(screen.getByText('10.0.0.2')).toBeInTheDocument(); + expect(screen.getAllByTestId('trusted-device-current')).toHaveLength(1); + expect(screen.getByTestId('trusted-device-row-1')).toContainElement(screen.getByTestId('trusted-device-current')); + expect(screen.getByTestId('revoke-trusted-device-1')).toBeInTheDocument(); + expect(screen.getByTestId('revoke-all-trusted-devices')).toBeInTheDocument(); + }); + + it('shows the empty state when there are no trusted devices', async () => { + getTrustedDevices.mockResolvedValue({response: {data: []}}); + + render(); + + expect(await screen.findByTestId('trusted-devices-empty')).toBeInTheDocument(); + expect(screen.queryByTestId('trusted-devices-table')).not.toBeInTheDocument(); + expect(screen.queryByTestId('revoke-all-trusted-devices')).not.toBeInTheDocument(); + }); + + it('revokes a single device and removes its row without reloading', async () => { + getTrustedDevices.mockResolvedValue({response: {data: [device(1), device(2)]}}); + revokeTrustedDevice.mockResolvedValue({response: {}}); + + render(); + fireEvent.click(await screen.findByTestId('revoke-trusted-device-1')); + + expect(revokeTrustedDevice).toHaveBeenCalledWith(1); + await waitFor(() => expect(screen.queryByTestId('trusted-device-row-1')).not.toBeInTheDocument()); + expect(screen.getByTestId('trusted-device-row-2')).toBeInTheDocument(); + expect(getTrustedDevices).toHaveBeenCalledTimes(1); + }); + + it('keeps the row when the revoke request fails', async () => { + getTrustedDevices.mockResolvedValue({response: {data: [device(1)]}}); + revokeTrustedDevice.mockRejectedValue({status: 500}); + + render(); + fireEvent.click(await screen.findByTestId('revoke-trusted-device-1')); + + await waitFor(() => expect(screen.getByTestId('revoke-trusted-device-1')).not.toBeDisabled()); + expect(screen.getByTestId('trusted-device-row-1')).toBeInTheDocument(); + expect(Swal).toHaveBeenCalledWith('Something went wrong!', null, 'error'); + }); + + it('revokes all devices after confirmation and shows the empty state', async () => { + getTrustedDevices.mockResolvedValue({response: {data: [device(1), device(2)]}}); + revokeAllTrustedDevices.mockResolvedValue({response: {}}); + Swal.mockResolvedValue({value: true}); + + render(); + fireEvent.click(await screen.findByTestId('revoke-all-trusted-devices')); + + expect(Swal).toHaveBeenCalledTimes(1); + expect(await screen.findByTestId('trusted-devices-empty')).toBeInTheDocument(); + expect(revokeAllTrustedDevices).toHaveBeenCalledTimes(1); + }); + + it('does not revoke anything when the revoke-all confirmation is cancelled', async () => { + getTrustedDevices.mockResolvedValue({response: {data: [device(1)]}}); + Swal.mockResolvedValue({dismiss: 'cancel'}); + + render(); + fireEvent.click(await screen.findByTestId('revoke-all-trusted-devices')); + + await waitFor(() => expect(Swal).toHaveBeenCalledTimes(1)); + expect(revokeAllTrustedDevices).not.toHaveBeenCalled(); + expect(screen.getByTestId('trusted-device-row-1')).toBeInTheDocument(); + }); + + it('never renders a submit button, since the profile page is a single form', async () => { + getTrustedDevices.mockResolvedValue({response: {data: [device(1)]}}); + + const {container} = render(); + await screen.findByTestId('trusted-devices-table'); + + expect(container.querySelectorAll('button[type="submit"]')).toHaveLength(0); + }); +}); diff --git a/tests/js/profile/actions.test.js b/tests/js/profile/actions.test.js index a07af381..b48b85fe 100644 --- a/tests/js/profile/actions.test.js +++ b/tests/js/profile/actions.test.js @@ -6,10 +6,18 @@ // a test that mocks profile/actions can never catch that. jest.mock("superagent", () => ({ post: jest.fn(), + get: jest.fn(), + delete: jest.fn(), })); import request from "superagent"; -import { enableTwoFactor, regenerateRecoveryCodes } from "profile/actions"; +import { + enableTwoFactor, + regenerateRecoveryCodes, + getTrustedDevices, + revokeTrustedDevice, + revokeAllTrustedDevices, +} from "profile/actions"; function makeChainableRequest({ body = {}, error = null } = {}) { const req = {}; @@ -25,9 +33,14 @@ function makeChainableRequest({ body = {}, error = null } = {}) { describe("profile/actions (unmocked request layer)", () => { beforeEach(() => { request.post.mockReset(); + request.get.mockReset(); + request.delete.mockReset(); window.ENABLE_TWO_FACTOR_ENDPOINT = "https://idp.test/api/v2/users/me/2fa/enable"; window.REGENERATE_RECOVERY_CODES_ENDPOINT = "https://idp.test/api/v2/users/me/2fa/recovery-codes"; window.CSFR_TOKEN = "test-csrf-token"; + window.GET_TRUSTED_DEVICES_ENDPOINT = "https://idp.test/admin/api/v1/users/me/2fa/devices"; + window.REVOKE_TRUSTED_DEVICE_ENDPOINT = "https://idp.test/admin/api/v1/users/me/2fa/devices/@id"; + window.REVOKE_ALL_TRUSTED_DEVICES_ENDPOINT = "https://idp.test/admin/api/v1/users/me/2fa/devices"; }); it("enableTwoFactor resolves the recovery codes through postRawRequestFull", async () => { @@ -54,4 +67,34 @@ describe("profile/actions (unmocked request layer)", () => { expect(req.send).toHaveBeenCalledWith({ current_password: "super-secret-password" }); expect(response.recovery_codes).toEqual(["WXYZ-5678"]); }); + + it("getTrustedDevices resolves the device list through getRawRequest", async () => { + const req = makeChainableRequest({ body: { data: [{ id: 1 }] } }); + request.get.mockReturnValue(req); + + const { response } = await getTrustedDevices(); + + expect(request.get).toHaveBeenCalledWith(window.GET_TRUSTED_DEVICES_ENDPOINT); + expect(response.data).toEqual([{ id: 1 }]); + }); + + it("revokeTrustedDevice sends a DELETE for that device id with the CSRF token", async () => { + const req = makeChainableRequest(); + request.delete.mockReturnValue(req); + + await revokeTrustedDevice(42); + + expect(request.delete).toHaveBeenCalledWith("https://idp.test/admin/api/v1/users/me/2fa/devices/42"); + expect(req.set).toHaveBeenCalledWith({ "X-CSRF-TOKEN": "test-csrf-token" }); + }); + + it("revokeAllTrustedDevices sends a DELETE on the collection with the CSRF token", async () => { + const req = makeChainableRequest(); + request.delete.mockReturnValue(req); + + await revokeAllTrustedDevices(); + + expect(request.delete).toHaveBeenCalledWith(window.REVOKE_ALL_TRUSTED_DEVICES_ENDPOINT); + expect(req.set).toHaveBeenCalledWith({ "X-CSRF-TOKEN": "test-csrf-token" }); + }); });