diff --git a/app/libs/Auth/Models/User.php b/app/libs/Auth/Models/User.php
index c1d70d96..bb8c5373 100644
--- a/app/libs/Auth/Models/User.php
+++ b/app/libs/Auth/Models/User.php
@@ -76,6 +76,15 @@ class User extends BaseEntity
self::SpamTypeHam
];
+ public const MFAMethod_OTP = 'email_otp';
+ public const MFAMethod_SMS = 'sms_otp';
+ public const MFAMethod_TOTP = 'totp';
+ public const MFAMethod_PASSKEY = 'passkey';
+
+ public const ValidMFAMethods = [
+ self::MFAMethod_OTP
+ ];
+
/**
* @var string
*/
@@ -303,6 +312,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' => false])]
+ private $two_factor_enabled;
+
+ /**
+ * @var string
+ */
+ #[ORM\Column(name: 'two_factor_method', type: 'string', length: 32, options: ['default' => self::MFAMethod_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
*/
@@ -457,6 +485,9 @@ public function __construct()
parent::__construct();
$this->active = true;
$this->email_verified = false;
+ $this->two_factor_enabled = false;
+ $this->two_factor_method = self::MFAMethod_OTP;
+ $this->two_factor_enforced_at = null;
// user profile settings
$this->public_profile_show_photo = false;
$this->public_profile_show_email = false;
@@ -2359,4 +2390,142 @@ 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;
+ }
+
+ /**
+ * @throws ValidationException
+ */
+ protected 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
+ {
+ $enforcedGroups = config('two_factor.enforced_groups', []);
+ foreach ($enforcedGroups as $slug) {
+ if($this->belongToGroup($slug)) {
+ return true;
+ }
+ }
+ return (bool) $this->two_factor_enabled;
+ }
+
+ /**
+ * @throws ValidationException
+ */
+ public function enable2FA(string $method): void
+ {
+ $availableMethods = $this->getAvailableTwoFactorMethods();
+ if(!in_array($method, self::ValidMFAMethods, true)) {
+ throw new ValidationException(
+ sprintf(
+ "Invalid 2FA method '%s'. Allowed methods: %s. Enabled methods: %s",
+ $method,
+ implode(', ', self::ValidMFAMethods),
+ implode(', ', $availableMethods)
+ )
+ );
+ }
+
+ if(!in_array($method, $availableMethods, true)) {
+ throw new ValidationException(
+ sprintf(
+ "Disabled 2FA method '%s'. Enabled methods: %s",
+ $method,
+ implode(', ', $availableMethods)
+ )
+ );
+ }
+
+ $this->setTwoFactorMethod($method);
+ $this->setTwoFactorEnabled(true);
+ $this->setTwoFactorEnforcedAt(new \DateTime('now', new \DateTimeZone('UTC')));
+ }
+
+ public function disable2FA(): void
+ {
+ $this->setTwoFactorEnabled(false);
+ $this->setTwoFactorEnforcedAt(null);
+ }
+
+ /**
+ * 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() && in_array(self::MFAMethod_OTP, self::ValidMFAMethods, true)) {
+ $methods[] = self::MFAMethod_OTP;
+ }
+ if($this->isPhoneNumberVerified() && in_array(self::MFAMethod_SMS, self::ValidMFAMethods, true)) {
+ $methods[] = self::MFAMethod_SMS;
+ }
+ if($this->isTOTPConfirmed() && in_array(self::MFAMethod_TOTP, self::ValidMFAMethods, true)) {
+ $methods[] = self::MFAMethod_TOTP;
+ }
+ if($this->isPassKeyEnabled() && in_array(self::MFAMethod_PASSKEY, self::ValidMFAMethods, true)) {
+ $methods[] = self::MFAMethod_PASSKEY;
+ }
+ return $methods;
+ }
+
+ public function isTwoFactorMethodEnabled(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;
+ }
+
}
\ No newline at end of file
diff --git a/config/two_factor.php b/config/two_factor.php
new file mode 100644
index 00000000..dd876a6f
--- /dev/null
+++ b/config/two_factor.php
@@ -0,0 +1,33 @@
+ [
+ IGroupSlugs::SuperAdminGroup,
+ IGroupSlugs::AdminGroup,
+ IGroupSlugs::OAuth2ServerAdminGroup,
+ IGroupSlugs::OpenIdServerAdminsGroup,
+ ],
+];
diff --git a/phpunit.xml b/phpunit.xml
index 7515f39f..4750f428 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -22,6 +22,10 @@
./tests/OpenTelemetry/Formatters/
+
+ ./tests/TwoFactorRepositoriesTest.php
+ ./tests/unit/UserTwoFactorTest.php
+
diff --git a/tests/unit/UserTwoFactorTest.php b/tests/unit/UserTwoFactorTest.php
new file mode 100644
index 00000000..6e32921d
--- /dev/null
+++ b/tests/unit/UserTwoFactorTest.php
@@ -0,0 +1,246 @@
+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_BelongsToAnEnforcedGroup(): void
+ {
+ config(['two_factor.enforced_groups' => []]);
+ $this->assertSame([], config('two_factor.enforced_groups'), "Config value 'two_factor.enforced_groups' is set");
+
+ $groups = [
+ IGroupSlugs::SuperAdminGroup,
+ IGroupSlugs::AdminGroup,
+ IGroupSlugs::OAuth2ServerAdminGroup,
+ ];
+ config(['two_factor.enforced_groups' => $groups]);
+ $this->assertSame($groups, config('two_factor.enforced_groups'), "Config value 'two_factor.enforced_groups' is set");
+
+ $user = new User();
+ $this->assertFalse($user->shouldRequire2FA(), "The user does not belong to any enforced group");
+
+
+ $this->assignGroups($user, array_map([$this, 'buildGroup'], $groups));
+ $this->assertTrue($user->belongToGroup(IGroupSlugs::SuperAdminGroup), "The user belongs to ".IGroupSlugs::SuperAdminGroup." group");
+ $this->assertTrue($user->belongToGroup(IGroupSlugs::AdminGroup), "The user belongs to ".IGroupSlugs::AdminGroup." group");
+ $this->assertTrue($user->belongToGroup(IGroupSlugs::OAuth2ServerAdminGroup), "The user belongs to ".IGroupSlugs::OAuth2ServerAdminGroup." group");
+ $this->assertTrue($user->shouldRequire2FA(), "The user does belong to an enforced group");
+
+ $groups = [IGroupSlugs::RawUsersGroup];
+ $user = new User();
+ $this->assertFalse($user->shouldRequire2FA(), "The user does not belong to any enforced group");
+ $this->assignGroups($user, array_map([$this, 'buildGroup'], $groups));
+ $this->assertFalse($user->shouldRequire2FA(), "The user does not belong to any enforced group");
+ }
+
+ 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();
+ // email_otp is only "available" to a user whose email is verified —
+ // enable2FA() now whitelists via getAvailableTwoFactorMethods().
+ $this->setEmailVerified($user, true);
+ $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 testDisable2FA(): void
+ {
+ $user = new User();
+ $this->setEmailVerified($user, true);
+ $user->enable2FA('email_otp');
+ $this->assertTrue($user->isTwoFactorEnabled());
+ $this->assertSame('email_otp', $user->getTwoFactorMethod());
+ $user->disable2FA();
+ $this->assertFalse($user->isTwoFactorEnabled());
+ $this->assertNull($user->getTwoFactorEnforcedAt());
+ $this->assertSame('email_otp', $user->getTwoFactorMethod());
+ }
+
+ 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->isTwoFactorMethodEnabled('email_otp'));
+ $this->assertFalse($user->isTwoFactorMethodEnabled('sms_otp'));
+ $this->assertFalse($user->isTwoFactorMethodEnabled('totp'));
+ $this->assertFalse($user->isTwoFactorMethodEnabled('passkey'));
+ $this->assertFalse($user->isTwoFactorMethodEnabled('garbage'));
+ }
+
+ public function testShouldRequire2FA_configDrivenEnforcement(): void
+ {
+ // Users in enforced_groups must require 2FA even if two_factor_enabled=false
+ // and even if isAdmin() returns false for their group (OAuth2/OpenId admins).
+ $groupsUnderTest = [
+ IGroupSlugs::OAuth2ServerAdminGroup,
+ IGroupSlugs::OpenIdServerAdminsGroup,
+ ];
+
+ foreach ($groupsUnderTest as $groupSlug) {
+ $user = new User();
+ $this->assignGroups($user, [$this->buildGroup($groupSlug)]);
+ $this->assertFalse($user->isTwoFactorEnabled());
+ $this->assertFalse($user->isAdmin(), "$groupSlug must NOT be covered by isAdmin()");
+ $this->assertTrue($user->shouldRequire2FA(), "$groupSlug is in enforced_groups — shouldRequire2FA() must return true regardless of the stored flag");
+ }
+
+ // Sanity: a regular user with two_factor_enabled=false is NOT enforced
+ $regular = new User();
+ $this->assignGroups($regular, [$this->buildGroup(IGroupSlugs::RawUsersGroup)]);
+ $this->assertFalse($regular->shouldRequire2FA());
+ }
+
+ public function testShouldRequire2FA_emptyEnforcedGroups_fallsThroughToFlag(): void
+ {
+ // Locks in the config fall-through: when enforced_groups is empty,
+ // shouldRequire2FA() must mirror the stored two_factor_enabled flag.
+ Config::set('two_factor.enforced_groups', []);
+
+ $user = new User();
+ $this->assignGroups($user, [$this->buildGroup(IGroupSlugs::RawUsersGroup)]);
+ $user->setTwoFactorEnabled(true);
+
+ $this->assertTrue($user->isTwoFactorEnabled());
+ $this->assertTrue($user->shouldRequire2FA());
+ }
+}