diff --git a/app/libs/Auth/Models/User.php b/app/libs/Auth/Models/User.php
index c1d70d96..7f0fe763 100644
--- a/app/libs/Auth/Models/User.php
+++ b/app/libs/Auth/Models/User.php
@@ -76,6 +76,8 @@ class User extends BaseEntity
self::SpamTypeHam
];
+ public const ValidMFAMethods = ['email_otp'];
+
/**
* @var string
*/
@@ -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
*/
@@ -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;
@@ -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;
+ }
+
}
\ 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..2935d0aa
--- /dev/null
+++ b/tests/unit/UserTwoFactorTest.php
@@ -0,0 +1,173 @@
+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'));
+ }
+}