Skip to content

Commit 9faa517

Browse files
feat: Add Device Trust Service
1 parent d9beb67 commit 9faa517

10 files changed

Lines changed: 573 additions & 11 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: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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 === false || $device->getIsRevoked() || $device->isExpired()) {
73+
return false;
74+
}
75+
76+
$device->setLastSeenAt(new DateTime('now', new DateTimeZone('UTC')));
77+
return true;
78+
}
79+
80+
public function removeTrustedDevices(User $user): void
81+
{
82+
$this->repository->revokeAllForUser($user);
83+
}
84+
}
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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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 Illuminate\Contracts\Support\DeferrableProvider;
16+
use Illuminate\Support\Facades\App;
17+
use Illuminate\Support\ServiceProvider;
18+
19+
/**
20+
* Class TwoFactorServiceProvider
21+
* @package App\Services\Auth
22+
*/
23+
final class TwoFactorServiceProvider extends ServiceProvider implements DeferrableProvider
24+
{
25+
public function boot(): void
26+
{
27+
}
28+
29+
public function register(): void
30+
{
31+
App::singleton(IDeviceTrustService::class, DeviceTrustService::class);
32+
}
33+
34+
public function provides(): array
35+
{
36+
return [
37+
IDeviceTrustService::class,
38+
];
39+
}
40+
}

app/libs/Auth/Models/UserTrustedDevice.php

Lines changed: 34 additions & 10 deletions
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,87 +54,111 @@ class UserTrustedDevice extends BaseEntity
5454
public function __construct()
5555
{
5656
parent::__construct();
57+
$now = new \DateTime('now', new \DateTimeZone('UTC'));
58+
$this->trusted_at = $now;
59+
$this->last_seen_at = clone $now;
5760
$this->is_revoked = false;
5861
}
5962

6063
public function getUser(): User
6164
{
6265
return $this->user;
6366
}
64-
public function setUser(User $user): void
67+
public function setUser(User $user): static
6568
{
6669
$this->user = $user;
70+
return $this;
6771
}
6872

6973
public function getDeviceIdentifier(): string
7074
{
7175
return $this->device_identifier;
7276
}
73-
public function setDeviceIdentifier(string $value): void
77+
public function setDeviceIdentifier(string $value): static
7478
{
7579
$this->device_identifier = $value;
80+
return $this;
7681
}
7782

7883
public function getDeviceName(): string
7984
{
8085
return $this->device_name;
8186
}
82-
public function setDeviceName(string $value): void
87+
public function setDeviceName(string $value): static
8388
{
8489
$this->device_name = $value;
90+
return $this;
8591
}
8692

8793
public function getIpAddress(): string
8894
{
8995
return $this->ip_address;
9096
}
91-
public function setIpAddress(string $value): void
97+
public function setIpAddress(string $value): static
9298
{
9399
$this->ip_address = $value;
100+
return $this;
94101
}
95102

96103
public function getUserAgent(): string
97104
{
98105
return $this->user_agent;
99106
}
100-
public function setUserAgent(string $value): void
107+
public function setUserAgent(string $value): static
101108
{
102109
$this->user_agent = $value;
110+
return $this;
103111
}
104112

105113
public function getTrustedAt(): \DateTime
106114
{
107115
return $this->trusted_at;
108116
}
109-
public function setTrustedAt(\DateTime $value): void
117+
public function setTrustedAt(\DateTime $value): static
110118
{
111119
$this->trusted_at = $value;
120+
return $this;
112121
}
113122

114123
public function getExpiresAt(): \DateTime
115124
{
116125
return $this->expires_at;
117126
}
118-
public function setExpiresAt(\DateTime $value): void
127+
public function setExpiresAt(\DateTime $value): static
119128
{
120129
$this->expires_at = $value;
130+
return $this;
121131
}
122132

123133
public function getLastSeenAt(): \DateTime
124134
{
125135
return $this->last_seen_at;
126136
}
127-
public function setLastSeenAt(\DateTime $value): void
137+
public function setLastSeenAt(\DateTime $value): static
128138
{
129139
$this->last_seen_at = $value;
140+
return $this;
130141
}
131142

132143
public function isRevoked(): bool
133144
{
134145
return (bool) $this->is_revoked;
135146
}
136-
public function setIsRevoked(bool $value): void
147+
148+
public function getIsRevoked(): bool
149+
{
150+
return $this->isRevoked();
151+
}
152+
153+
public function setIsRevoked(bool $value): static
137154
{
138155
$this->is_revoked = $value;
156+
return $this;
157+
}
158+
159+
public function isExpired(): bool
160+
{
161+
$now = new \DateTime('now', new \DateTimeZone('UTC'));
162+
return $this->expires_at < $now;
139163
}
140164
}

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)