Skip to content
Closed
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
140 changes: 140 additions & 0 deletions app/libs/Auth/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ class User extends BaseEntity
self::SpamTypeHam
];

public const ValidMFAMethods = ['email_otp'];

/**
* @var string
*/
Expand Down Expand Up @@ -303,6 +305,25 @@ class User extends BaseEntity
*/
#[ORM\Column(name: 'email_verified_date', nullable: true, type: 'datetime')]
private $email_verified_date;

/**
* @var bool
*/
#[ORM\Column(name: 'two_factor_enabled', type: 'boolean', options: ['default' => 0])]
private $two_factor_enabled;

/**
* @var string
*/
#[ORM\Column(name: 'two_factor_method', type: 'string', length: 32, options: ['default' => 'email_otp'])]
private $two_factor_method;

/**
* @var \DateTime|null
*/
#[ORM\Column(name: 'two_factor_enforced_at', nullable: true, type: 'datetime')]
private $two_factor_enforced_at;

/**
* @var string
*/
Expand Down Expand Up @@ -457,6 +478,9 @@ public function __construct()
parent::__construct();
$this->active = true;
$this->email_verified = false;
$this->two_factor_enabled = false;
$this->two_factor_method = 'email_otp';
$this->two_factor_enforced_at = null;
// user profile settings
$this->public_profile_show_photo = false;
$this->public_profile_show_email = false;
Expand Down Expand Up @@ -2359,4 +2383,120 @@ public function getAuthPasswordName()
return 'password';
}

// --- Two-factor authentication ---------------------------------------

public function isTwoFactorEnabled(): bool
{
return (bool) $this->two_factor_enabled;
}

public function setTwoFactorEnabled(bool $enabled): void
{
$this->two_factor_enabled = $enabled;
}

public function getTwoFactorMethod(): string
{
return $this->two_factor_method;
}

public function setTwoFactorMethod(string $method): void
{
$this->two_factor_method = $method;
}

public function getTwoFactorEnforcedAt(): ?\DateTime
{
return $this->two_factor_enforced_at;
}

public function setTwoFactorEnforcedAt(?\DateTime $at): void
{
$this->two_factor_enforced_at = $at;
}

/**
* Whether this user is required to complete 2FA to sign in.
*
* A user is required when they belong to any of the groups listed in
* config('two_factor.enforced_groups'); otherwise the stored flag applies.
*/
public function shouldRequire2FA(): bool
{
$enforced_groups = Config::get('two_factor.enforced_groups', []);
foreach ($enforced_groups as $slug) {
if ($this->belongToGroup($slug)) {
return true;
}
}
return (bool) $this->two_factor_enabled;
}

/**
* @throws ValidationException
*/
public function enable2FA(string $method): void
{
if (!in_array($method, self::ValidMFAMethods, true)) {
throw new ValidationException(
sprintf(
"Invalid 2FA method '%s'. Allowed methods: %s",
$method,
implode(', ', self::ValidMFAMethods)
)
);
}
$this->two_factor_enabled = true;
$this->two_factor_method = $method;
$this->two_factor_enforced_at = new \DateTime('now', new \DateTimeZone('UTC'));
}

/**
* Returns the set of 2FA methods currently available to this user.
* Phase I only supports email_otp; other methods are stubs that will
* light up in Phase II/III once the backing verifications exist.
*
* @return string[]
*/
public function getAvailableTwoFactorMethods(): array
{
$methods = [];
if ($this->isEmailVerified()) {
$methods[] = 'email_otp';
}
if ($this->isPhoneNumberVerified()) {
$methods[] = 'sms_otp';
}
if ($this->isTOTPConfirmed()) {
$methods[] = 'totp';
}
if ($this->isPassKeyEnabled()) {
$methods[] = 'passkey';
}
return $methods;
}

public function isTwoFactorMethodEnable(string $method): bool
{
return in_array($method, $this->getAvailableTwoFactorMethods(), true);
}

// Phase II stub
public function isPhoneNumberVerified(): bool
{
return false;
}

// Phase III stub
public function isTOTPConfirmed(): bool
{
return false;
}

// Phase III stub
public function isPassKeyEnabled(): bool
{
return false;
}

}
33 changes: 33 additions & 0 deletions config/two_factor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php
/**
* 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\IGroupSlugs;

return [
/*
|--------------------------------------------------------------------------
| Enforced Groups
|--------------------------------------------------------------------------
|
| Users that belong to any of these groups are required to complete 2FA
| regardless of the value of their `two_factor_enabled` flag.
|
*/
'enforced_groups' => [
IGroupSlugs::SuperAdminGroup,
IGroupSlugs::AdminGroup,
IGroupSlugs::OAuth2ServerAdminGroup,
IGroupSlugs::OpenIdServerAdminsGroup,
],
];
4 changes: 4 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
<testsuite name="OTEL Custom Formatters Test Suite">
<directory>./tests/OpenTelemetry/Formatters/</directory>
</testsuite>
<testsuite name="Two Factor Authentication Test Suite">
<file>./tests/TwoFactorRepositoriesTest.php</file>
<file>./tests/unit/UserTwoFactorTest.php</file>
</testsuite>
</testsuites>
<php>
<env name="APP_ENV" value="testing"/>
Expand Down
173 changes: 173 additions & 0 deletions tests/unit/UserTwoFactorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<?php namespace Tests\unit;

/**
* 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\IGroupSlugs;
use Auth\Group;
use Auth\User;
use Illuminate\Support\Facades\Config;
use models\exceptions\ValidationException;
use Tests\TestCase;

/**
* Class UserTwoFactorTest
* Unit tests for the 2FA-related methods on the User entity.
*/
class UserTwoFactorTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Config::set('two_factor.enforced_groups', [
IGroupSlugs::SuperAdminGroup,
IGroupSlugs::AdminGroup,
IGroupSlugs::OAuth2ServerAdminGroup,
IGroupSlugs::OpenIdServerAdminsGroup,
]);
}

private function buildGroup(string $slug): Group
{
$group = new Group();
$group->setName($slug);
$group->setSlug($slug);
return $group;
}

private function assignGroups(User $user, array $groups): void
{
$reflection = new \ReflectionClass(User::class);
$property = $reflection->getProperty('groups');
$property->setAccessible(true);
$collection = $property->getValue($user);
foreach ($groups as $group) {
$collection->add($group);
}
}

private function setEmailVerified(User $user, bool $verified): void
{
$reflection = new \ReflectionClass(User::class);
$property = $reflection->getProperty('email_verified');
$property->setAccessible(true);
$property->setValue($user, $verified);
}

public function testShouldRequire2FA_superAdminUser(): void
{
$user = new User();
$this->assignGroups($user, [$this->buildGroup(IGroupSlugs::SuperAdminGroup)]);

$this->assertFalse($user->isTwoFactorEnabled());
$this->assertTrue($user->shouldRequire2FA());
}

public function testShouldRequire2FA_adminUser(): void
{
$user = new User();
$this->assignGroups($user, [$this->buildGroup(IGroupSlugs::AdminGroup)]);

$this->assertFalse($user->isTwoFactorEnabled());
$this->assertTrue($user->shouldRequire2FA());
}

public function testShouldRequire2FA_oauth2AdminUser(): void
{
// Guard against the gotcha: shouldRequire2FA() must look at the full
// config('two_factor.enforced_groups') list, not just call isAdmin().
$user = new User();
$this->assignGroups($user, [$this->buildGroup(IGroupSlugs::OAuth2ServerAdminGroup)]);

$this->assertFalse($user->isAdmin(), 'oauth2-server-admins is NOT an admin group per isAdmin()');
$this->assertTrue($user->shouldRequire2FA());
}

public function testShouldRequire2FA_regularUser_enabled(): void
{
$user = new User();
$this->assignGroups($user, [$this->buildGroup(IGroupSlugs::RawUsersGroup)]);
$user->setTwoFactorEnabled(true);

$this->assertTrue($user->shouldRequire2FA());
}

public function testShouldRequire2FA_regularUser_disabled(): void
{
$user = new User();
$this->assignGroups($user, [$this->buildGroup(IGroupSlugs::RawUsersGroup)]);

$this->assertFalse($user->isTwoFactorEnabled());
$this->assertFalse($user->shouldRequire2FA());
}

public function testEnable2FA_validMethod(): void
{
$user = new User();
$before = new \DateTime('now', new \DateTimeZone('UTC'));

$user->enable2FA('email_otp');

$after = new \DateTime('now', new \DateTimeZone('UTC'));

$this->assertTrue($user->isTwoFactorEnabled());
$this->assertSame('email_otp', $user->getTwoFactorMethod());
$enforcedAt = $user->getTwoFactorEnforcedAt();
$this->assertInstanceOf(\DateTime::class, $enforcedAt);
$this->assertGreaterThanOrEqual($before->getTimestamp(), $enforcedAt->getTimestamp());
$this->assertLessThanOrEqual($after->getTimestamp(), $enforcedAt->getTimestamp());
}

public function testEnable2FA_invalidMethod_throws(): void
{
$user = new User();
$this->expectException(ValidationException::class);
$user->enable2FA('invalid_method');
}

public function testEnable2FA_phaseTwoMethod_throwsInPhaseOne(): void
{
// sms_otp/totp/passkey are Phase II/III — ValidMFAMethods should reject them in Phase I.
$user = new User();
$this->expectException(ValidationException::class);
$user->enable2FA('sms_otp');
}

public function testGetAvailableTwoFactorMethods_emailVerified(): void
{
$user = new User();
$this->setEmailVerified($user, true);

$this->assertSame(['email_otp'], $user->getAvailableTwoFactorMethods());
}

public function testGetAvailableTwoFactorMethods_emailNotVerified(): void
{
$user = new User();
$this->setEmailVerified($user, false);

$this->assertSame([], $user->getAvailableTwoFactorMethods());
}

public function testIsTwoFactorMethodEnable(): void
{
$user = new User();
$this->setEmailVerified($user, true);

$this->assertTrue($user->isTwoFactorMethodEnable('email_otp'));
$this->assertFalse($user->isTwoFactorMethodEnable('sms_otp'));
$this->assertFalse($user->isTwoFactorMethodEnable('totp'));
$this->assertFalse($user->isTwoFactorMethodEnable('passkey'));
$this->assertFalse($user->isTwoFactorMethodEnable('garbage'));
}
}
Loading