diff --git a/app/Repositories/DoctrineUserTrustedDeviceRepository.php b/app/Repositories/DoctrineUserTrustedDeviceRepository.php index 29bd894a..37267066 100644 --- a/app/Repositories/DoctrineUserTrustedDeviceRepository.php +++ b/app/Repositories/DoctrineUserTrustedDeviceRepository.php @@ -31,6 +31,30 @@ private function buildActiveExpiryExpr(): Comparison return Criteria::expr()->gt('expires_at', $now); } + public function getByUserAndDeviceIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice + { + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('user', $user)) + ->andWhere(Criteria::expr()->eq('device_identifier', $deviceIdentifier)) + ->setMaxResults(1); + + $result = $this->matching($criteria)->first(); + return $result instanceof UserTrustedDevice ? $result : null; + } + + public function revokeAllForUser(User $user): void + { + $this->getEntityManager() + ->createQueryBuilder() + ->update($this->getBaseEntity(), 'd') + ->set('d.is_revoked', ':revoked') + ->where('d.user = :user') + ->setParameter('revoked', true) + ->setParameter('user', $user) + ->getQuery() + ->execute(); + } + public function getActiveByUserAndIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice { $criteria = Criteria::create() diff --git a/app/Services/Auth/DeviceTrustService.php b/app/Services/Auth/DeviceTrustService.php new file mode 100644 index 00000000..e6f317b6 --- /dev/null +++ b/app/Services/Auth/DeviceTrustService.php @@ -0,0 +1,85 @@ +add(new DateInterval("P{$lifetimeDays}D")); + + $device = new UserTrustedDevice(); + $device->setUser($user); + $device->setDeviceIdentifier($this->generateDeviceIdentifier($rawToken)); + $device->setDeviceName(substr($userAgent, 0, 255)); + $device->setIpAddress($ipAddress); + $device->setUserAgent($userAgent); + $device->setTrustedAt($now); + $device->setExpiresAt($expiresAt); + $device->setLastSeenAt(clone $now); + $device->setIsRevoked(false); + + $this->repository->add($device, true); + + return $rawToken; + } + + public function isDeviceTrusted(User $user, ?string $cookieToken): bool + { + if (empty($cookieToken)) { + return false; + } + + $identifier = $this->generateDeviceIdentifier($cookieToken); + $device = $this->repository->getByUserAndDeviceIdentifier($user, $identifier); + + if (!$device instanceof UserTrustedDevice || $device->isRevoked() || $device->isExpired()) { + return false; + } + + $device->setLastSeenAt(new DateTime('now', new DateTimeZone('UTC'))); + $this->repository->add($device, true); + return true; + } + + public function removeTrustedDevices(User $user): void + { + $this->repository->revokeAllForUser($user); + } +} diff --git a/app/Services/Auth/IDeviceTrustService.php b/app/Services/Auth/IDeviceTrustService.php new file mode 100644 index 00000000..2750335c --- /dev/null +++ b/app/Services/Auth/IDeviceTrustService.php @@ -0,0 +1,45 @@ +app->singleton(IDeviceTrustService::class, DeviceTrustService::class); + } + + public function provides(): array + { + return [ + IDeviceTrustService::class, + ]; + } +} diff --git a/app/libs/Auth/Models/UserTrustedDevice.php b/app/libs/Auth/Models/UserTrustedDevice.php index 3e2b96b5..16b836a8 100644 --- a/app/libs/Auth/Models/UserTrustedDevice.php +++ b/app/libs/Auth/Models/UserTrustedDevice.php @@ -24,7 +24,7 @@ class UserTrustedDevice extends BaseEntity { #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')] - #[ORM\ManyToOne(targetEntity: \Auth\User::class)] + #[ORM\ManyToOne(targetEntity: User::class)] private $user; #[ORM\Column(name: 'device_identifier', type: 'string', length: 255)] @@ -54,6 +54,7 @@ class UserTrustedDevice extends BaseEntity public function __construct() { parent::__construct(); + $this->last_seen_at = new \DateTime('now', new \DateTimeZone('UTC')); $this->is_revoked = false; } @@ -137,4 +138,10 @@ public function setIsRevoked(bool $value): void { $this->is_revoked = $value; } + + public function isExpired(): bool + { + $now = new \DateTime('now', new \DateTimeZone('UTC')); + return $this->expires_at < $now; + } } \ No newline at end of file diff --git a/app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php b/app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php index f369c051..04e86edf 100644 --- a/app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php +++ b/app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php @@ -18,7 +18,17 @@ interface IUserTrustedDeviceRepository extends IBaseRepository { /** - * Look up an active (non-revoked) trusted device for a user by its hashed identifier. + * Look up a trusted device record by user and hashed identifier (no revoked/expiry filter). + */ + public function getByUserAndDeviceIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice; + + /** + * Revoke all trusted devices for the given user (sets is_revoked = true). + */ + public function revokeAllForUser(User $user): void; + + /** + * Look up an active (non-revoked, non-expired) trusted device for a user by its hashed identifier. */ public function getActiveByUserAndIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice; diff --git a/config/app.php b/config/app.php index d3dd4ee7..b2d48591 100644 --- a/config/app.php +++ b/config/app.php @@ -152,6 +152,7 @@ Services\OpenId\OpenIdProvider::class, Auth\AuthenticationServiceProvider::class, Services\ServicesProvider::class, + App\Services\Auth\TwoFactorServiceProvider::class, Strategies\StrategyProvider::class, OAuth2\OAuth2ServiceProvider::class, OpenId\OpenIdServiceProvider::class, diff --git a/config/two_factor.php b/config/two_factor.php index dd876a6f..8a399806 100644 --- a/config/two_factor.php +++ b/config/two_factor.php @@ -30,4 +30,12 @@ IGroupSlugs::OAuth2ServerAdminGroup, IGroupSlugs::OpenIdServerAdminsGroup, ], + + /* + |-------------------------------------------------------------------------- + | Device Trust + |-------------------------------------------------------------------------- + */ + 'device_trust_lifetime_days' => env('DEVICE_TRUST_LIFETIME_DAYS', 30), + 'cookie_name' => env('DEVICE_TRUST_COOKIE_NAME', 'device_trust_token'), ]; diff --git a/tests/DeviceTrustServiceTest.php b/tests/DeviceTrustServiceTest.php new file mode 100644 index 00000000..5eb2bdfe --- /dev/null +++ b/tests/DeviceTrustServiceTest.php @@ -0,0 +1,290 @@ +repo = Mockery::mock(IUserTrustedDeviceRepository::class); + $this->service = new DeviceTrustService($this->repo); + } + + public function tearDown(): void + { + parent::tearDown(); + Mockery::close(); + } + + // ------------------------------------------------------------------------- + // isDeviceTrusted + // ------------------------------------------------------------------------- + + public function testIsDeviceTrustedNullCookie(): void + { + $user = Mockery::mock(User::class); + $this->repo->shouldNotReceive('getByUserAndDeviceIdentifier'); + + $this->assertFalse($this->service->isDeviceTrusted($user, null)); + } + + public function testIsDeviceTrustedEmptyCookie(): void + { + $user = Mockery::mock(User::class); + $this->repo->shouldNotReceive('getByUserAndDeviceIdentifier'); + + $this->assertFalse($this->service->isDeviceTrusted($user, '')); + } + + public function testIsDeviceTrustedWrongCookie(): void + { + $user = Mockery::mock(User::class); + $this->repo + ->shouldReceive('getByUserAndDeviceIdentifier') + ->once() + ->andReturn(null); + + $this->assertFalse($this->service->isDeviceTrusted($user, 'unknowntoken')); + } + + public function testIsDeviceTrustedRevokedDevice(): void + { + $user = Mockery::mock(User::class); + + $device = $this->makeDevice(expired: false, revoked: true); + + $this->repo + ->shouldReceive('getByUserAndDeviceIdentifier') + ->once() + ->andReturn($device); + + $this->assertFalse($this->service->isDeviceTrusted($user, 'sometoken')); + } + + public function testIsDeviceTrustedExpiredDevice(): void + { + $user = Mockery::mock(User::class); + + $device = $this->makeDevice(expired: true, revoked: false); + + $this->repo + ->shouldReceive('getByUserAndDeviceIdentifier') + ->once() + ->andReturn($device); + + $this->assertFalse($this->service->isDeviceTrusted($user, 'sometoken')); + } + + public function testIsDeviceTrustedValidDevice(): void + { + $user = Mockery::mock(User::class); + + $device = $this->makeDevice(expired: false, revoked: false); + + $this->repo + ->shouldReceive('getByUserAndDeviceIdentifier') + ->once() + ->andReturn($device); + $this->repo->shouldReceive('add')->once(); + + $this->assertTrue($this->service->isDeviceTrusted($user, 'sometoken')); + } + + public function testIsDeviceTrustedUpdatesLastSeenAt(): void + { + $user = Mockery::mock(User::class); + + $device = $this->makeDevice(expired: false, revoked: false); + // set last_seen_at to a known old value so the update is detectable + $oldDate = new DateTime('2000-01-01', new DateTimeZone('UTC')); + $device->setLastSeenAt($oldDate); + + $this->repo + ->shouldReceive('getByUserAndDeviceIdentifier') + ->once() + ->andReturn($device); + $this->repo->shouldReceive('add')->once(); + + $this->service->isDeviceTrusted($user, 'sometoken'); + + $this->assertNotNull($device); + $this->assertGreaterThan($oldDate, $device->getLastSeenAt()); + } + + // ------------------------------------------------------------------------- + // trustDevice + // ------------------------------------------------------------------------- + + public function testTrustDeviceReturnsToken(): void + { + $user = Mockery::mock(User::class); + + $this->repo->shouldReceive('add')->once(); + + $token = $this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1'); + + $this->assertSame(128, strlen($token)); + $this->assertMatchesRegularExpression('/^[0-9a-f]{128}$/', $token); + } + + public function testTrustDeviceStoresHash(): void + { + $user = Mockery::mock(User::class); + + /** @var UserTrustedDevice|null $persistedDevice */ + $persistedDevice = null; + + $this->repo + ->shouldReceive('add') + ->once() + ->withArgs(function ($device) use (&$persistedDevice) { + $persistedDevice = $device; + return true; + }); + + $rawToken = $this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1'); + + $this->assertNotNull($persistedDevice); + $this->assertSame(hash('sha256', $rawToken), $persistedDevice->getDeviceIdentifier()); + } + + public function testTrustDeviceRawTokenNotStored(): void + { + $user = Mockery::mock(User::class); + + /** @var UserTrustedDevice|null $persistedDevice */ + $persistedDevice = null; + + $this->repo + ->shouldReceive('add') + ->once() + ->withArgs(function ($device) use (&$persistedDevice) { + $persistedDevice = $device; + return true; + }); + + $rawToken = $this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1'); + + $this->assertNotNull($persistedDevice); + $this->assertNotSame($rawToken, $persistedDevice->getDeviceIdentifier()); + } + + public function testTrustDeviceCreatesExactlyOneRecord(): void + { + $user = Mockery::mock(User::class); + + $this->repo->shouldReceive('add')->once(); + + $this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1'); + } + + public function testTrustDeviceSetsExpiresAtFromConfig(): void + { + $user = Mockery::mock(User::class); + + /** @var UserTrustedDevice|null $persistedDevice */ + $persistedDevice = null; + + $this->repo + ->shouldReceive('add') + ->once() + ->withArgs(function ($device) use (&$persistedDevice) { + $persistedDevice = $device; + return true; + }); + + $this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1'); + + $this->assertNotNull($persistedDevice); + + $lifetimeDays = (int) config('two_factor.device_trust_lifetime_days', 30); + $diff = $persistedDevice->getTrustedAt()->diff($persistedDevice->getExpiresAt()); + $this->assertSame($lifetimeDays, $diff->days); + } + + // ------------------------------------------------------------------------- + // removeTrustedDevices + // ------------------------------------------------------------------------- + + public function testRemoveTrustedDevicesRevokesAll(): void + { + $user = Mockery::mock(User::class); + + $this->repo + ->shouldReceive('revokeAllForUser') + ->once() + ->with($user); + + $this->service->removeTrustedDevices($user); + } + + // ------------------------------------------------------------------------- + // generateDeviceIdentifier + // ------------------------------------------------------------------------- + + public function testGenerateDeviceIdentifierReturnsSha256(): void + { + $token = 'test_token_value'; + $expected = hash('sha256', $token); + + $this->assertSame($expected, $this->service->generateDeviceIdentifier($token)); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function makeDevice(bool $expired, bool $revoked): UserTrustedDevice + { + $device = new UserTrustedDevice(); + + $now = new DateTime('now', new DateTimeZone('UTC')); + + if ($expired) { + $expiresAt = clone $now; + $expiresAt->sub(new DateInterval('P1D')); // 1 day in the past + } else { + $expiresAt = clone $now; + $expiresAt->add(new DateInterval('P30D')); // 30 days in the future + } + + $device->setExpiresAt($expiresAt); + $device->setIsRevoked($revoked); + $device->setDeviceIdentifier($this->service->generateDeviceIdentifier('sometoken')); + $device->setIpAddress('127.0.0.1'); + $device->setTrustedAt($now); + $device->setLastSeenAt(clone $now); + + return $device; + } +}