Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 107 additions & 1 deletion app/Http/Controllers/Api/UserApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -40,6 +44,8 @@ final class UserApiController extends APICRUDController

use RequestProcessor;

use MFACookieManager;

/**
* @var ITokenService
*/
Expand All @@ -50,26 +56,34 @@ final class UserApiController extends APICRUDController
*/
private $recovery_code_service;

/**
* @var IDeviceTrustService
*/
private $device_trust_service;

/**
* UserApiController constructor.
* @param IUserRepository $user_repository
* @param ILogService $log_service
* @param IUserService $user_service
* @param ITokenService $token_service
* @param IRecoveryCodeService $recovery_code_service
* @param IDeviceTrustService $device_trust_service
*/
public function __construct
(
IUserRepository $user_repository,
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;
}

/**
Expand Down Expand Up @@ -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())
Expand Down
32 changes: 32 additions & 0 deletions app/Http/Controllers/Traits/MFACookieManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
}
58 changes: 58 additions & 0 deletions app/ModelSerializers/Auth/UserTrustedDeviceSerializer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php namespace App\ModelSerializers\Auth;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use App\libs\Auth\Models\UserTrustedDevice;
use App\ModelSerializers\BaseSerializer;

/**
* Class UserTrustedDeviceSerializer
*
* Never exposes device_identifier (the SHA-256 of the device-trust cookie
* token) nor the raw user agent.
*
* @package App\ModelSerializers\Auth
*/
final class UserTrustedDeviceSerializer extends BaseSerializer
{
/**
* Pass the hashed device identifier of the current request's device-trust
* cookie under this key to get the "is_current" flag computed.
*/
public const ParamCurrentDeviceIdentifier = 'current_device_identifier';

protected static $array_mappings = [
'DeviceName' => '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;
}
}
3 changes: 3 additions & 0 deletions app/ModelSerializers/SerializerRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"] = [
Expand Down
18 changes: 8 additions & 10 deletions app/Repositories/DoctrineUserTrustedDeviceRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 67 additions & 8 deletions app/Services/Auth/DeviceTrustService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
}
}
Loading
Loading