Skip to content

Commit 561ef7c

Browse files
matiasperrone-exoCopilot
andcommitted
feat: Add MultiFactor Authentication
feat(2fa): add migration for 2FA schema foundation (Phase I) feat(2fa): add UserTrustedDevice entity feat(2fa): add TwoFactorAuditLog entity feat(2fa): add UserRecoveryCode entity feat(2fa): add repository interfaces for 2FA entities feat(2fa): add Doctrine implementations for 2FA repositories feat(2fa): register 2FA repositories in service container test(2fa): add repository round-trip tests for 2FA entities Co-authored-by: Copilot <copilot@github.com>
1 parent db9f777 commit 561ef7c

16 files changed

Lines changed: 1256 additions & 101 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php namespace App\Repositories;
2+
/**
3+
* Copyright 2026 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+
use App\libs\Auth\Models\TwoFactorAuditLog;
15+
use Auth\Repositories\ITwoFactorAuditLogRepository;
16+
use Auth\User;
17+
18+
final class DoctrineTwoFactorAuditLogRepository
19+
extends ModelDoctrineRepository implements ITwoFactorAuditLogRepository
20+
{
21+
protected function getBaseEntity()
22+
{
23+
return TwoFactorAuditLog::class;
24+
}
25+
26+
public function getRecentByUser(User $user, int $limit = 50): array
27+
{
28+
return $this->findBy(
29+
['user' => $user],
30+
['created_at' => 'DESC'],
31+
$limit
32+
);
33+
}
34+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php namespace App\Repositories;
2+
/**
3+
* Copyright 2026 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+
use App\libs\Auth\Models\UserRecoveryCode;
15+
use Auth\Repositories\IUserRecoveryCodeRepository;
16+
use Auth\User;
17+
18+
final class DoctrineUserRecoveryCodeRepository
19+
extends ModelDoctrineRepository implements IUserRecoveryCodeRepository
20+
{
21+
protected function getBaseEntity()
22+
{
23+
return UserRecoveryCode::class;
24+
}
25+
26+
public function getUnusedByUser(User $user): array
27+
{
28+
return $this->findBy([
29+
'user' => $user,
30+
'used_at' => null,
31+
]);
32+
}
33+
34+
public function deleteAllForUser(User $user): int
35+
{
36+
$em = $this->getEntityManager();
37+
$qb = $em->createQueryBuilder()
38+
->delete(UserRecoveryCode::class, 'c')
39+
->where('c.user = :user')
40+
->setParameter('user', $user);
41+
return (int) $qb->getQuery()->execute();
42+
}
43+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php namespace App\Repositories;
2+
/**
3+
* Copyright 2026 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+
use App\libs\Auth\Models\UserTrustedDevice;
15+
use Auth\Repositories\IUserTrustedDeviceRepository;
16+
use Auth\User;
17+
18+
final class DoctrineUserTrustedDeviceRepository
19+
extends ModelDoctrineRepository implements IUserTrustedDeviceRepository
20+
{
21+
protected function getBaseEntity()
22+
{
23+
return UserTrustedDevice::class;
24+
}
25+
26+
public function getActiveByUserAndIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice
27+
{
28+
return $this->findOneBy([
29+
'user' => $user,
30+
'device_identifier' => $deviceIdentifier,
31+
'is_revoked' => false,
32+
]);
33+
}
34+
35+
public function getActiveByUser(User $user): array
36+
{
37+
return $this->findBy([
38+
'user' => $user,
39+
'is_revoked' => false,
40+
]);
41+
}
42+
}

app/Repositories/RepositoriesProvider.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
**/
1414

1515
use App\libs\Auth\Models\SpamEstimatorFeed;
16+
use App\libs\Auth\Models\TwoFactorAuditLog;
17+
use App\libs\Auth\Models\UserRecoveryCode;
1618
use App\libs\Auth\Models\UserRegistrationRequest;
19+
use App\libs\Auth\Models\UserTrustedDevice;
1720
use App\libs\Auth\Repositories\IBannedIPRepository;
1821
use App\libs\Auth\Repositories\IGroupRepository;
1922
use App\libs\Auth\Repositories\ISpamEstimatorFeedRepository;
@@ -32,7 +35,10 @@
3235
use App\Repositories\IServerConfigurationRepository;
3336
use App\Repositories\IServerExtensionRepository;
3437
use Auth\Group;
38+
use Auth\Repositories\ITwoFactorAuditLogRepository;
3539
use Auth\Repositories\IUserActionRepository;
40+
use Auth\Repositories\IUserRecoveryCodeRepository;
41+
use Auth\Repositories\IUserTrustedDeviceRepository;
3642
use Auth\User;
3743
use Auth\UserPasswordResetRequest;
3844
use Illuminate\Contracts\Support\DeferrableProvider;
@@ -271,6 +277,27 @@ function () {
271277
}
272278
);
273279

280+
App::singleton(
281+
IUserTrustedDeviceRepository::class,
282+
function () {
283+
return EntityManager::getRepository(UserTrustedDevice::class);
284+
}
285+
);
286+
287+
App::singleton(
288+
ITwoFactorAuditLogRepository::class,
289+
function () {
290+
return EntityManager::getRepository(TwoFactorAuditLog::class);
291+
}
292+
);
293+
294+
App::singleton(
295+
IUserRecoveryCodeRepository::class,
296+
function () {
297+
return EntityManager::getRepository(UserRecoveryCode::class);
298+
}
299+
);
300+
274301
}
275302

276303
public function provides()
@@ -304,6 +331,9 @@ public function provides()
304331
IStreamChatSSOProfileRepository::class,
305332
IOAuth2OTPRepository::class,
306333
IUserActionRepository::class,
334+
IUserTrustedDeviceRepository::class,
335+
ITwoFactorAuditLogRepository::class,
336+
IUserRecoveryCodeRepository::class,
307337
];
308338
}
309339
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php namespace App\libs\Auth\Models;
2+
/**
3+
* Copyright 2026 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+
use Doctrine\ORM\Mapping AS ORM;
17+
18+
#[ORM\Table(name: 'two_factor_audit_log')]
19+
#[ORM\Entity(repositoryClass: \App\Repositories\DoctrineTwoFactorAuditLogRepository::class)]
20+
class TwoFactorAuditLog
21+
{
22+
public const EventChallengeIssued = 'challenge_issued';
23+
public const EventChallengeSucceeded = 'challenge_succeeded';
24+
public const EventChallengeFailed = 'challenge_failed';
25+
public const EventEnrollmentChanged = 'enrollment_changed';
26+
public const EventDeviceTrusted = 'device_trusted';
27+
public const EventDeviceRevoked = 'device_revoked';
28+
public const EventRecoveryUsed = 'recovery_used';
29+
public const EventSettingsChanged = 'settings_changed';
30+
31+
public const MethodEmailOtp = 'email_otp';
32+
public const MethodSmsOtp = 'sms_otp';
33+
public const MethodTotp = 'totp';
34+
public const MethodPasskey = 'passkey';
35+
public const MethodRecovery = 'recovery';
36+
37+
#[ORM\Id]
38+
#[ORM\GeneratedValue]
39+
#[ORM\Column(name: 'id', type: 'integer', unique: true, nullable: false)]
40+
protected $id;
41+
42+
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
43+
#[ORM\ManyToOne(targetEntity: \Auth\User::class)]
44+
private $user;
45+
46+
#[ORM\Column(name: 'event_type', type: 'string', length: 64)]
47+
private $event_type;
48+
49+
#[ORM\Column(name: 'method', type: 'string', length: 32)]
50+
private $method;
51+
52+
#[ORM\Column(name: 'ip_address', type: 'string', length: 45)]
53+
private $ip_address;
54+
55+
#[ORM\Column(name: 'user_agent', type: 'text')]
56+
private $user_agent;
57+
58+
#[ORM\Column(name: 'metadata', type: 'json', nullable: true)]
59+
private $metadata;
60+
61+
#[ORM\Column(name: 'created_at', type: 'datetime')]
62+
private $created_at;
63+
64+
public function __construct()
65+
{
66+
$this->created_at = new \DateTime('now', new \DateTimeZone('UTC'));
67+
$this->metadata = null;
68+
}
69+
70+
public function getId(): int { return (int) $this->id; }
71+
72+
public function getUser(): User { return $this->user; }
73+
public function setUser(User $user): void { $this->user = $user; }
74+
75+
public function getEventType(): string { return $this->event_type; }
76+
public function setEventType(string $value): void { $this->event_type = $value; }
77+
78+
public function getMethod(): string { return $this->method; }
79+
public function setMethod(string $value): void { $this->method = $value; }
80+
81+
public function getIpAddress(): string { return $this->ip_address; }
82+
public function setIpAddress(string $value): void { $this->ip_address = $value; }
83+
84+
public function getUserAgent(): string { return $this->user_agent; }
85+
public function setUserAgent(string $value): void { $this->user_agent = $value; }
86+
87+
public function getMetadata(): ?array { return $this->metadata; }
88+
public function setMetadata(?array $value): void { $this->metadata = $value; }
89+
90+
public function getCreatedAt(): \DateTime { return $this->created_at; }
91+
92+
public function __get($name) { return $this->{$name}; }
93+
}

0 commit comments

Comments
 (0)