Skip to content

Commit 4d99419

Browse files
feat: Add Device Trust Service
1 parent 2c99a62 commit 4d99419

9 files changed

Lines changed: 513 additions & 2 deletions

File tree

app/Repositories/DoctrineUserTrustedDeviceRepository.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,30 @@ private function buildActiveExpiryExpr(): Comparison
3131
return Criteria::expr()->gt('expires_at', $now);
3232
}
3333

34+
public function getByUserAndDeviceIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice
35+
{
36+
$criteria = Criteria::create()
37+
->where(Criteria::expr()->eq('user', $user))
38+
->andWhere(Criteria::expr()->eq('device_identifier', $deviceIdentifier))
39+
->setMaxResults(1);
40+
41+
$result = $this->matching($criteria)->first();
42+
return $result instanceof UserTrustedDevice ? $result : null;
43+
}
44+
45+
public function revokeAllForUser(User $user): void
46+
{
47+
$this->getEntityManager()
48+
->createQueryBuilder()
49+
->update($this->getBaseEntity(), 'd')
50+
->set('d.is_revoked', ':revoked')
51+
->where('d.user = :user')
52+
->setParameter('revoked', true)
53+
->setParameter('user', $user)
54+
->getQuery()
55+
->execute();
56+
}
57+
3458
public function getActiveByUserAndIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice
3559
{
3660
$criteria = Criteria::create()
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
namespace App\Services\Auth;
3+
/**
4+
* Copyright 2025 OpenStack Foundation
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
**/
15+
16+
use App\libs\Auth\Models\UserTrustedDevice;
17+
use Auth\Repositories\IUserTrustedDeviceRepository;
18+
use Auth\User;
19+
use DateTime;
20+
use DateInterval;
21+
use DateTimeZone;
22+
23+
/**
24+
* Class DeviceTrustService
25+
* @package App\Services\Auth
26+
*/
27+
final class DeviceTrustService implements IDeviceTrustService
28+
{
29+
public function __construct(private readonly IUserTrustedDeviceRepository $repository)
30+
{
31+
}
32+
33+
public function generateDeviceIdentifier(string $token): string
34+
{
35+
return hash('sha256', $token);
36+
}
37+
38+
public function trustDevice(User $user, string $userAgent, string $ipAddress): string
39+
{
40+
$rawToken = bin2hex(random_bytes(64));
41+
42+
$lifetimeDays = (int) config('two_factor.device_trust_lifetime_days', 30);
43+
$now = new DateTime('now', new DateTimeZone('UTC'));
44+
$expiresAt = clone $now;
45+
$expiresAt->add(new DateInterval("P{$lifetimeDays}D"));
46+
47+
$device = new UserTrustedDevice();
48+
$device->setUser($user);
49+
$device->setDeviceIdentifier($this->generateDeviceIdentifier($rawToken));
50+
$device->setDeviceName(substr($userAgent, 0, 255));
51+
$device->setIpAddress($ipAddress);
52+
$device->setUserAgent($userAgent);
53+
$device->setTrustedAt($now);
54+
$device->setExpiresAt($expiresAt);
55+
$device->setLastSeenAt(clone $now);
56+
$device->setIsRevoked(false);
57+
58+
$this->repository->add($device, true);
59+
60+
return $rawToken;
61+
}
62+
63+
public function isDeviceTrusted(User $user, ?string $cookieToken): bool
64+
{
65+
if (empty($cookieToken)) {
66+
return false;
67+
}
68+
69+
$identifier = $this->generateDeviceIdentifier($cookieToken);
70+
$device = $this->repository->getByUserAndDeviceIdentifier($user, $identifier);
71+
72+
if (!$device instanceof UserTrustedDevice || $device->isRevoked() || $device->isExpired()) {
73+
return false;
74+
}
75+
76+
$device->setLastSeenAt(new DateTime('now', new DateTimeZone('UTC')));
77+
$this->repository->add($device, true);
78+
return true;
79+
}
80+
81+
public function removeTrustedDevices(User $user): void
82+
{
83+
$this->repository->revokeAllForUser($user);
84+
}
85+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php namespace App\Services\Auth;
2+
/**
3+
* Copyright 2025 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+
* Interface IDeviceTrustService
19+
* @package App\Services\Auth
20+
*/
21+
interface IDeviceTrustService
22+
{
23+
/**
24+
* Checks whether the device identified by the given cookie token is trusted for the user.
25+
* Updates last_seen_at on a valid match.
26+
*/
27+
public function isDeviceTrusted(User $user, ?string $cookieToken): bool;
28+
29+
/**
30+
* Marks the current device as trusted for the user.
31+
* Returns the raw 128-character hex token to be stored in the cookie.
32+
* The SHA-256 hash of the token (not the raw token) is persisted.
33+
*/
34+
public function trustDevice(User $user, string $userAgent, string $ipAddress): string;
35+
36+
/**
37+
* Revokes all trusted devices for the given user.
38+
*/
39+
public function removeTrustedDevices(User $user): void;
40+
41+
/**
42+
* Returns the SHA-256 hash of the given token used as the stored device identifier.
43+
*/
44+
public function generateDeviceIdentifier(string $token): string;
45+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
namespace App\Services\Auth;
3+
/**
4+
* Copyright 2025 OpenStack Foundation
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
**/
15+
16+
use Illuminate\Contracts\Support\DeferrableProvider;
17+
use Illuminate\Support\Facades\App;
18+
use Illuminate\Support\ServiceProvider;
19+
20+
/**
21+
* Class TwoFactorServiceProvider
22+
* @package App\Services\Auth
23+
*/
24+
final class TwoFactorServiceProvider extends ServiceProvider implements DeferrableProvider
25+
{
26+
public function boot(): void
27+
{
28+
}
29+
30+
public function register(): void
31+
{
32+
$this->app->singleton(IDeviceTrustService::class, DeviceTrustService::class);
33+
}
34+
35+
public function provides(): array
36+
{
37+
return [
38+
IDeviceTrustService::class,
39+
];
40+
}
41+
}

app/libs/Auth/Models/UserTrustedDevice.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
class UserTrustedDevice extends BaseEntity
2525
{
2626
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
27-
#[ORM\ManyToOne(targetEntity: \Auth\User::class)]
27+
#[ORM\ManyToOne(targetEntity: User::class)]
2828
private $user;
2929

3030
#[ORM\Column(name: 'device_identifier', type: 'string', length: 255)]
@@ -54,6 +54,7 @@ class UserTrustedDevice extends BaseEntity
5454
public function __construct()
5555
{
5656
parent::__construct();
57+
$this->last_seen_at = new \DateTime('now', new \DateTimeZone('UTC'));
5758
$this->is_revoked = false;
5859
}
5960

@@ -137,4 +138,10 @@ public function setIsRevoked(bool $value): void
137138
{
138139
$this->is_revoked = $value;
139140
}
141+
142+
public function isExpired(): bool
143+
{
144+
$now = new \DateTime('now', new \DateTimeZone('UTC'));
145+
return $this->expires_at < $now;
146+
}
140147
}

app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,17 @@
1818
interface IUserTrustedDeviceRepository extends IBaseRepository
1919
{
2020
/**
21-
* Look up an active (non-revoked) trusted device for a user by its hashed identifier.
21+
* Look up a trusted device record by user and hashed identifier (no revoked/expiry filter).
22+
*/
23+
public function getByUserAndDeviceIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice;
24+
25+
/**
26+
* Revoke all trusted devices for the given user (sets is_revoked = true).
27+
*/
28+
public function revokeAllForUser(User $user): void;
29+
30+
/**
31+
* Look up an active (non-revoked, non-expired) trusted device for a user by its hashed identifier.
2232
*/
2333
public function getActiveByUserAndIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice;
2434

config/app.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@
152152
Services\OpenId\OpenIdProvider::class,
153153
Auth\AuthenticationServiceProvider::class,
154154
Services\ServicesProvider::class,
155+
App\Services\Auth\TwoFactorServiceProvider::class,
155156
Strategies\StrategyProvider::class,
156157
OAuth2\OAuth2ServiceProvider::class,
157158
OpenId\OpenIdServiceProvider::class,

config/two_factor.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,12 @@
3030
IGroupSlugs::OAuth2ServerAdminGroup,
3131
IGroupSlugs::OpenIdServerAdminsGroup,
3232
],
33+
34+
/*
35+
|--------------------------------------------------------------------------
36+
| Device Trust
37+
|--------------------------------------------------------------------------
38+
*/
39+
'device_trust_lifetime_days' => env('DEVICE_TRUST_LIFETIME_DAYS', 30),
40+
'cookie_name' => env('DEVICE_TRUST_COOKIE_NAME', 'device_trust_token'),
3341
];

0 commit comments

Comments
 (0)