Skip to content

Commit cbe88f1

Browse files
feat: Implement Two-Factor Authentication (2FA) support for users
Created PHPUnit test suit "Two Factor Authentication Test Suite" Modified app/libs/Auth/Models/User.php: - public const ValidMFAMethods = ['email_otp']; - 3 new Doctrine-mapped private fields (two_factor_enabled, two_factor_method, two_factor_enforced_at) matching the Phase 1 migration columns - Constructor initializers for the three fields - Getters/setters: isTwoFactorEnabled/setTwoFactorEnabled, getTwoFactorMethod/setTwoFactorMethod, getTwoFactorEnforcedAt/setTwoFactorEnforcedAt - shouldRequire2FA() — config-driven via two_factor.enforced_groups, falls through to the stored flag - enable2FA(string $method) — whitelists via ValidMFAMethods, throws ValidationException otherwise - getAvailableTwoFactorMethods() / isTwoFactorMethodEnable() - Phase II/III stubs returning false: isPhoneNumberVerified, isTOTPConfirmed, isPassKeyEnabled Created config/two_factor.php with enforced_groups referencing IGroupSlugs constants (super-admins, administrators, oauth2-server-admins, openid-server-admins). Created tests/unit/UserTwoFactorTest.php — 11 test methods (25 assertions), all green. Verification: - doctrine:schema:validate: no new diffs on users relating to the 2FA columns — only the pre-existing documented noise (signed/unsigned, index renames). - UserTwoFactorTest: 11/11 passing. - TwoFactorRepositoriesTest (Phase 1): 3/3 still passing.
1 parent 6a2bd34 commit cbe88f1

4 files changed

Lines changed: 350 additions & 0 deletions

File tree

app/libs/Auth/Models/User.php

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ class User extends BaseEntity
7676
self::SpamTypeHam
7777
];
7878

79+
public const ValidMFAMethods = ['email_otp'];
80+
7981
/**
8082
* @var string
8183
*/
@@ -303,6 +305,25 @@ class User extends BaseEntity
303305
*/
304306
#[ORM\Column(name: 'email_verified_date', nullable: true, type: 'datetime')]
305307
private $email_verified_date;
308+
309+
/**
310+
* @var bool
311+
*/
312+
#[ORM\Column(name: 'two_factor_enabled', type: 'boolean', options: ['default' => 0])]
313+
private $two_factor_enabled;
314+
315+
/**
316+
* @var string
317+
*/
318+
#[ORM\Column(name: 'two_factor_method', type: 'string', length: 32, options: ['default' => 'email_otp'])]
319+
private $two_factor_method;
320+
321+
/**
322+
* @var \DateTime|null
323+
*/
324+
#[ORM\Column(name: 'two_factor_enforced_at', nullable: true, type: 'datetime')]
325+
private $two_factor_enforced_at;
326+
306327
/**
307328
* @var string
308329
*/
@@ -457,6 +478,9 @@ public function __construct()
457478
parent::__construct();
458479
$this->active = true;
459480
$this->email_verified = false;
481+
$this->two_factor_enabled = false;
482+
$this->two_factor_method = 'email_otp';
483+
$this->two_factor_enforced_at = null;
460484
// user profile settings
461485
$this->public_profile_show_photo = false;
462486
$this->public_profile_show_email = false;
@@ -2359,4 +2383,120 @@ public function getAuthPasswordName()
23592383
return 'password';
23602384
}
23612385

2386+
// --- Two-factor authentication ---------------------------------------
2387+
2388+
public function isTwoFactorEnabled(): bool
2389+
{
2390+
return (bool) $this->two_factor_enabled;
2391+
}
2392+
2393+
public function setTwoFactorEnabled(bool $enabled): void
2394+
{
2395+
$this->two_factor_enabled = $enabled;
2396+
}
2397+
2398+
public function getTwoFactorMethod(): string
2399+
{
2400+
return $this->two_factor_method;
2401+
}
2402+
2403+
public function setTwoFactorMethod(string $method): void
2404+
{
2405+
$this->two_factor_method = $method;
2406+
}
2407+
2408+
public function getTwoFactorEnforcedAt(): ?\DateTime
2409+
{
2410+
return $this->two_factor_enforced_at;
2411+
}
2412+
2413+
public function setTwoFactorEnforcedAt(?\DateTime $at): void
2414+
{
2415+
$this->two_factor_enforced_at = $at;
2416+
}
2417+
2418+
/**
2419+
* Whether this user is required to complete 2FA to sign in.
2420+
*
2421+
* A user is required when they belong to any of the groups listed in
2422+
* config('two_factor.enforced_groups'); otherwise the stored flag applies.
2423+
*/
2424+
public function shouldRequire2FA(): bool
2425+
{
2426+
$enforced_groups = Config::get('two_factor.enforced_groups', []);
2427+
foreach ($enforced_groups as $slug) {
2428+
if ($this->belongToGroup($slug)) {
2429+
return true;
2430+
}
2431+
}
2432+
return (bool) $this->two_factor_enabled;
2433+
}
2434+
2435+
/**
2436+
* @throws ValidationException
2437+
*/
2438+
public function enable2FA(string $method): void
2439+
{
2440+
if (!in_array($method, self::ValidMFAMethods, true)) {
2441+
throw new ValidationException(
2442+
sprintf(
2443+
"Invalid 2FA method '%s'. Allowed methods: %s",
2444+
$method,
2445+
implode(', ', self::ValidMFAMethods)
2446+
)
2447+
);
2448+
}
2449+
$this->two_factor_enabled = true;
2450+
$this->two_factor_method = $method;
2451+
$this->two_factor_enforced_at = new \DateTime('now', new \DateTimeZone('UTC'));
2452+
}
2453+
2454+
/**
2455+
* Returns the set of 2FA methods currently available to this user.
2456+
* Phase I only supports email_otp; other methods are stubs that will
2457+
* light up in Phase II/III once the backing verifications exist.
2458+
*
2459+
* @return string[]
2460+
*/
2461+
public function getAvailableTwoFactorMethods(): array
2462+
{
2463+
$methods = [];
2464+
if ($this->isEmailVerified()) {
2465+
$methods[] = 'email_otp';
2466+
}
2467+
if ($this->isPhoneNumberVerified()) {
2468+
$methods[] = 'sms_otp';
2469+
}
2470+
if ($this->isTOTPConfirmed()) {
2471+
$methods[] = 'totp';
2472+
}
2473+
if ($this->isPassKeyEnabled()) {
2474+
$methods[] = 'passkey';
2475+
}
2476+
return $methods;
2477+
}
2478+
2479+
public function isTwoFactorMethodEnable(string $method): bool
2480+
{
2481+
return in_array($method, $this->getAvailableTwoFactorMethods(), true);
2482+
}
2483+
2484+
// Phase II stub
2485+
public function isPhoneNumberVerified(): bool
2486+
{
2487+
return false;
2488+
}
2489+
2490+
// Phase III stub
2491+
public function isTOTPConfirmed(): bool
2492+
{
2493+
return false;
2494+
}
2495+
2496+
// Phase III stub
2497+
public function isPassKeyEnabled(): bool
2498+
{
2499+
return false;
2500+
}
2501+
23622502
}

config/two_factor.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
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 App\libs\Auth\Models\IGroupSlugs;
16+
17+
return [
18+
/*
19+
|--------------------------------------------------------------------------
20+
| Enforced Groups
21+
|--------------------------------------------------------------------------
22+
|
23+
| Users that belong to any of these groups are required to complete 2FA
24+
| regardless of the value of their `two_factor_enabled` flag.
25+
|
26+
*/
27+
'enforced_groups' => [
28+
IGroupSlugs::SuperAdminGroup,
29+
IGroupSlugs::AdminGroup,
30+
IGroupSlugs::OAuth2ServerAdminGroup,
31+
IGroupSlugs::OpenIdServerAdminsGroup,
32+
],
33+
];

phpunit.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
<testsuite name="OTEL Custom Formatters Test Suite">
2323
<directory>./tests/OpenTelemetry/Formatters/</directory>
2424
</testsuite>
25+
<testsuite name="Two Factor Authentication Test Suite">
26+
<file>./tests/TwoFactorRepositoriesTest.php</file>
27+
<file>./tests/unit/UserTwoFactorTest.php</file>
28+
</testsuite>
2529
</testsuites>
2630
<php>
2731
<env name="APP_ENV" value="testing"/>

tests/unit/UserTwoFactorTest.php

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
<?php namespace Tests\unit;
2+
3+
/**
4+
* Copyright 2026 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\IGroupSlugs;
17+
use Auth\Group;
18+
use Auth\User;
19+
use Illuminate\Support\Facades\Config;
20+
use models\exceptions\ValidationException;
21+
use Tests\TestCase;
22+
23+
/**
24+
* Class UserTwoFactorTest
25+
* Unit tests for the 2FA-related methods on the User entity.
26+
*/
27+
class UserTwoFactorTest extends TestCase
28+
{
29+
protected function setUp(): void
30+
{
31+
parent::setUp();
32+
Config::set('two_factor.enforced_groups', [
33+
IGroupSlugs::SuperAdminGroup,
34+
IGroupSlugs::AdminGroup,
35+
IGroupSlugs::OAuth2ServerAdminGroup,
36+
IGroupSlugs::OpenIdServerAdminsGroup,
37+
]);
38+
}
39+
40+
private function buildGroup(string $slug): Group
41+
{
42+
$group = new Group();
43+
$group->setName($slug);
44+
$group->setSlug($slug);
45+
return $group;
46+
}
47+
48+
private function assignGroups(User $user, array $groups): void
49+
{
50+
$reflection = new \ReflectionClass(User::class);
51+
$property = $reflection->getProperty('groups');
52+
$property->setAccessible(true);
53+
$collection = $property->getValue($user);
54+
foreach ($groups as $group) {
55+
$collection->add($group);
56+
}
57+
}
58+
59+
private function setEmailVerified(User $user, bool $verified): void
60+
{
61+
$reflection = new \ReflectionClass(User::class);
62+
$property = $reflection->getProperty('email_verified');
63+
$property->setAccessible(true);
64+
$property->setValue($user, $verified);
65+
}
66+
67+
public function testShouldRequire2FA_superAdminUser(): void
68+
{
69+
$user = new User();
70+
$this->assignGroups($user, [$this->buildGroup(IGroupSlugs::SuperAdminGroup)]);
71+
72+
$this->assertFalse($user->isTwoFactorEnabled());
73+
$this->assertTrue($user->shouldRequire2FA());
74+
}
75+
76+
public function testShouldRequire2FA_adminUser(): void
77+
{
78+
$user = new User();
79+
$this->assignGroups($user, [$this->buildGroup(IGroupSlugs::AdminGroup)]);
80+
81+
$this->assertFalse($user->isTwoFactorEnabled());
82+
$this->assertTrue($user->shouldRequire2FA());
83+
}
84+
85+
public function testShouldRequire2FA_oauth2AdminUser(): void
86+
{
87+
// Guard against the gotcha: shouldRequire2FA() must look at the full
88+
// config('two_factor.enforced_groups') list, not just call isAdmin().
89+
$user = new User();
90+
$this->assignGroups($user, [$this->buildGroup(IGroupSlugs::OAuth2ServerAdminGroup)]);
91+
92+
$this->assertFalse($user->isAdmin(), 'oauth2-server-admins is NOT an admin group per isAdmin()');
93+
$this->assertTrue($user->shouldRequire2FA());
94+
}
95+
96+
public function testShouldRequire2FA_regularUser_enabled(): void
97+
{
98+
$user = new User();
99+
$this->assignGroups($user, [$this->buildGroup(IGroupSlugs::RawUsersGroup)]);
100+
$user->setTwoFactorEnabled(true);
101+
102+
$this->assertTrue($user->shouldRequire2FA());
103+
}
104+
105+
public function testShouldRequire2FA_regularUser_disabled(): void
106+
{
107+
$user = new User();
108+
$this->assignGroups($user, [$this->buildGroup(IGroupSlugs::RawUsersGroup)]);
109+
110+
$this->assertFalse($user->isTwoFactorEnabled());
111+
$this->assertFalse($user->shouldRequire2FA());
112+
}
113+
114+
public function testEnable2FA_validMethod(): void
115+
{
116+
$user = new User();
117+
$before = new \DateTime('now', new \DateTimeZone('UTC'));
118+
119+
$user->enable2FA('email_otp');
120+
121+
$after = new \DateTime('now', new \DateTimeZone('UTC'));
122+
123+
$this->assertTrue($user->isTwoFactorEnabled());
124+
$this->assertSame('email_otp', $user->getTwoFactorMethod());
125+
$enforcedAt = $user->getTwoFactorEnforcedAt();
126+
$this->assertInstanceOf(\DateTime::class, $enforcedAt);
127+
$this->assertGreaterThanOrEqual($before->getTimestamp(), $enforcedAt->getTimestamp());
128+
$this->assertLessThanOrEqual($after->getTimestamp(), $enforcedAt->getTimestamp());
129+
}
130+
131+
public function testEnable2FA_invalidMethod_throws(): void
132+
{
133+
$user = new User();
134+
$this->expectException(ValidationException::class);
135+
$user->enable2FA('invalid_method');
136+
}
137+
138+
public function testEnable2FA_phaseTwoMethod_throwsInPhaseOne(): void
139+
{
140+
// sms_otp/totp/passkey are Phase II/III — ValidMFAMethods should reject them in Phase I.
141+
$user = new User();
142+
$this->expectException(ValidationException::class);
143+
$user->enable2FA('sms_otp');
144+
}
145+
146+
public function testGetAvailableTwoFactorMethods_emailVerified(): void
147+
{
148+
$user = new User();
149+
$this->setEmailVerified($user, true);
150+
151+
$this->assertSame(['email_otp'], $user->getAvailableTwoFactorMethods());
152+
}
153+
154+
public function testGetAvailableTwoFactorMethods_emailNotVerified(): void
155+
{
156+
$user = new User();
157+
$this->setEmailVerified($user, false);
158+
159+
$this->assertSame([], $user->getAvailableTwoFactorMethods());
160+
}
161+
162+
public function testIsTwoFactorMethodEnable(): void
163+
{
164+
$user = new User();
165+
$this->setEmailVerified($user, true);
166+
167+
$this->assertTrue($user->isTwoFactorMethodEnable('email_otp'));
168+
$this->assertFalse($user->isTwoFactorMethodEnable('sms_otp'));
169+
$this->assertFalse($user->isTwoFactorMethodEnable('totp'));
170+
$this->assertFalse($user->isTwoFactorMethodEnable('passkey'));
171+
$this->assertFalse($user->isTwoFactorMethodEnable('garbage'));
172+
}
173+
}

0 commit comments

Comments
 (0)