Skip to content
34 changes: 34 additions & 0 deletions app/Repositories/DoctrineTwoFactorAuditLogRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php namespace App\Repositories;
/**
* 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\TwoFactorAuditLog;
use Auth\Repositories\ITwoFactorAuditLogRepository;
use Auth\User;

final class DoctrineTwoFactorAuditLogRepository
extends ModelDoctrineRepository implements ITwoFactorAuditLogRepository
{
protected function getBaseEntity()
{
return TwoFactorAuditLog::class;
}

public function getRecentByUser(User $user, int $limit = 50): array
{
return $this->findBy(
['user' => $user],
['created_at' => 'DESC'],
$limit
);
}
}
43 changes: 43 additions & 0 deletions app/Repositories/DoctrineUserRecoveryCodeRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php namespace App\Repositories;
/**
* 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\UserRecoveryCode;
use Auth\Repositories\IUserRecoveryCodeRepository;
use Auth\User;

final class DoctrineUserRecoveryCodeRepository
extends ModelDoctrineRepository implements IUserRecoveryCodeRepository
{
protected function getBaseEntity()
{
return UserRecoveryCode::class;
}

public function getUnusedByUser(User $user): array
{
return $this->findBy([
'user' => $user,
'used_at' => null,
]);
}

public function deleteAllForUser(User $user): int
{
$em = $this->getEntityManager();
$qb = $em->createQueryBuilder()
->delete(UserRecoveryCode::class, 'c')
->where('c.user = :user')
->setParameter('user', $user);
return (int) $qb->getQuery()->execute();
}
}
56 changes: 56 additions & 0 deletions app/Repositories/DoctrineUserTrustedDeviceRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php namespace App\Repositories;
/**
* 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 Auth\Repositories\IUserTrustedDeviceRepository;
use Auth\User;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Expr\Comparison;

final class DoctrineUserTrustedDeviceRepository
extends ModelDoctrineRepository implements IUserTrustedDeviceRepository
{
protected function getBaseEntity()
{
return UserTrustedDevice::class;
}

private function buildActiveExpiryExpr(): Comparison
{
$now = new \DateTime('now', new \DateTimeZone('UTC'));
return Criteria::expr()->gt('expires_at', $now);
}

public function getActiveByUserAndIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice
{
$criteria = Criteria::create()
->where(Criteria::expr()->eq('user', $user))
->andWhere(Criteria::expr()->eq('device_identifier', $deviceIdentifier))
->andWhere(Criteria::expr()->eq('is_revoked', false))
->andWhere($this->buildActiveExpiryExpr())
->setMaxResults(1);

$result = $this->matching($criteria)->first();
return $result instanceof UserTrustedDevice ? $result : null;
}

public function getActiveByUser(User $user): array
{
$criteria = Criteria::create()
->where(Criteria::expr()->eq('user', $user))
->andWhere(Criteria::expr()->eq('is_revoked', false))
->andWhere($this->buildActiveExpiryExpr());

return $this->matching($criteria)->toArray();
}
}
30 changes: 30 additions & 0 deletions app/Repositories/RepositoriesProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
**/

use App\libs\Auth\Models\SpamEstimatorFeed;
use App\libs\Auth\Models\TwoFactorAuditLog;
use App\libs\Auth\Models\UserRecoveryCode;
use App\libs\Auth\Models\UserRegistrationRequest;
use App\libs\Auth\Models\UserTrustedDevice;
use App\libs\Auth\Repositories\IBannedIPRepository;
use App\libs\Auth\Repositories\IGroupRepository;
use App\libs\Auth\Repositories\ISpamEstimatorFeedRepository;
Expand All @@ -32,7 +35,10 @@
use App\Repositories\IServerConfigurationRepository;
use App\Repositories\IServerExtensionRepository;
use Auth\Group;
use Auth\Repositories\ITwoFactorAuditLogRepository;
use Auth\Repositories\IUserActionRepository;
use Auth\Repositories\IUserRecoveryCodeRepository;
use Auth\Repositories\IUserTrustedDeviceRepository;
use Auth\User;
use Auth\UserPasswordResetRequest;
use Illuminate\Contracts\Support\DeferrableProvider;
Expand Down Expand Up @@ -271,6 +277,27 @@ function () {
}
);

App::singleton(
IUserTrustedDeviceRepository::class,
function () {
return EntityManager::getRepository(UserTrustedDevice::class);
}
);

App::singleton(
ITwoFactorAuditLogRepository::class,
function () {
return EntityManager::getRepository(TwoFactorAuditLog::class);
}
);

App::singleton(
IUserRecoveryCodeRepository::class,
function () {
return EntityManager::getRepository(UserRecoveryCode::class);
}
);

}

public function provides()
Expand Down Expand Up @@ -304,6 +331,9 @@ public function provides()
IStreamChatSSOProfileRepository::class,
IOAuth2OTPRepository::class,
IUserActionRepository::class,
IUserTrustedDeviceRepository::class,
ITwoFactorAuditLogRepository::class,
IUserRecoveryCodeRepository::class,
];
}
}
166 changes: 166 additions & 0 deletions app/libs/Auth/Models/TwoFactorAuditLog.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<?php
namespace App\libs\Auth\Models;
/**
* 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 Auth\User;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Table(name: 'two_factor_audit_log')]
#[ORM\Entity(repositoryClass: \App\Repositories\DoctrineTwoFactorAuditLogRepository::class)]
class TwoFactorAuditLog
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@matiasperrone-exo this should exted from BaseEntity too

{
public const EventChallengeIssued = 'challenge_issued';
public const EventChallengeSucceeded = 'challenge_succeeded';
public const EventChallengeFailed = 'challenge_failed';
public const EventEnrollmentChanged = 'enrollment_changed';
public const EventDeviceTrusted = 'device_trusted';
public const EventDeviceRevoked = 'device_revoked';
public const EventRecoveryUsed = 'recovery_used';
public const EventSettingsChanged = 'settings_changed';

public const MethodEmailOtp = 'email_otp';
public const MethodSmsOtp = 'sms_otp';
public const MethodTotp = 'totp';
public const MethodPasskey = 'passkey';
public const MethodRecovery = 'recovery';


private const ALLOWED_EVENT_TYPES = [
self::EventChallengeIssued,
self::EventChallengeSucceeded,
self::EventChallengeFailed,
self::EventEnrollmentChanged,
self::EventDeviceTrusted,
self::EventDeviceRevoked,
self::EventRecoveryUsed,
self::EventSettingsChanged,
];

private const ALLOWED_METHODS = [
self::MethodEmailOtp,
self::MethodSmsOtp,
self::MethodTotp,
self::MethodPasskey,
self::MethodRecovery,
];

#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(name: 'id', type: 'integer', unique: true, nullable: false)]
protected $id;

#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\ManyToOne(targetEntity: \Auth\User::class)]
private $user;

#[ORM\Column(name: 'event_type', type: 'string', length: 64)]
private $event_type;

#[ORM\Column(name: 'method', type: 'string', length: 32)]
private $method;

#[ORM\Column(name: 'ip_address', type: 'string', length: 45)]
private $ip_address;

#[ORM\Column(name: 'user_agent', type: 'text')]
private $user_agent;

#[ORM\Column(name: 'metadata', type: 'json', nullable: true)]
private $metadata;

#[ORM\Column(name: 'created_at', type: 'datetime')]
private $created_at;

public function __construct()
{
$this->created_at = new \DateTime('now', new \DateTimeZone('UTC'));
$this->metadata = null;
}

public function getId(): int
{
return (int) $this->id;
}

public function getUser(): User
{
return $this->user;
}

public function setUser(User $user): void
Comment thread
matiasperrone-exo marked this conversation as resolved.
{
$this->user = $user;
}

public function getEventType(): string
{
return $this->event_type;
}

public function setEventType(string $value): void
{
if (!in_array($value, self::ALLOWED_EVENT_TYPES, true)) {
throw new \InvalidArgumentException('Unsupported 2FA audit event type.');
}
$this->event_type = $value;
}

public function getMethod(): string
{
return $this->method;
}

public function setMethod(string $value): void
{
if (!in_array($value, self::ALLOWED_METHODS, true)) {
throw new \InvalidArgumentException('Unsupported 2FA audit method.');
}
$this->method = $value;
}

public function getIpAddress(): string
{
return $this->ip_address;
}

public function setIpAddress(string $value): void
{
$this->ip_address = $value;
}

public function getUserAgent(): string
{
return $this->user_agent;
}

public function setUserAgent(string $value): void
{
$this->user_agent = $value;
}

public function getMetadata(): ?array
{
return $this->metadata;
}

public function setMetadata(?array $value): void
{
$this->metadata = $value;
}

public function getCreatedAt(): \DateTime
{
return $this->created_at;
}
}
Loading
Loading