From cbe88f169da2fb6a287a27875ddd868b8a28e748 Mon Sep 17 00:00:00 2001 From: matiasperrone-exo Date: Tue, 21 Apr 2026 21:38:58 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Implement=20Two-Factor=20Authentication?= =?UTF-8?q?=20(2FA)=20support=20for=20users=20=20=20Created=20PHPUnit=20te?= =?UTF-8?q?st=20suit=20"Two=20Factor=20Authentication=20Test=20Suite"=20?= =?UTF-8?q?=20=20Modified=20app/libs/Auth/Models/User.php:=20=20=20-=20pub?= =?UTF-8?q?lic=20const=20ValidMFAMethods=20=3D=20['email=5Fotp'];=20=20=20?= =?UTF-8?q?-=203=20new=20Doctrine-mapped=20private=20fields=20(two=5Ffacto?= =?UTF-8?q?r=5Fenabled,=20two=5Ffactor=5Fmethod,=20two=5Ffactor=5Fenforced?= =?UTF-8?q?=5Fat)=20matching=20the=20Phase=201=20migration=20columns=20=20?= =?UTF-8?q?=20-=20Constructor=20initializers=20for=20the=20three=20fields?= =?UTF-8?q?=20=20=20-=20Getters/setters:=20isTwoFactorEnabled/setTwoFactor?= =?UTF-8?q?Enabled,=20getTwoFactorMethod/setTwoFactorMethod,=20getTwoFacto?= =?UTF-8?q?rEnforcedAt/setTwoFactorEnforcedAt=20=20=20-=20shouldRequire2FA?= =?UTF-8?q?()=20=E2=80=94=20config-driven=20via=20two=5Ffactor.enforced=5F?= =?UTF-8?q?groups,=20falls=20through=20to=20the=20stored=20flag=20=20=20-?= =?UTF-8?q?=20enable2FA(string=20$method)=20=E2=80=94=20whitelists=20via?= =?UTF-8?q?=20ValidMFAMethods,=20throws=20ValidationException=20otherwise?= =?UTF-8?q?=20=20=20-=20getAvailableTwoFactorMethods()=20/=20isTwoFactorMe?= =?UTF-8?q?thodEnable()=20=20=20-=20Phase=20II/III=20stubs=20returning=20f?= =?UTF-8?q?alse:=20isPhoneNumberVerified,=20isTOTPConfirmed,=20isPassKeyEn?= =?UTF-8?q?abled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/libs/Auth/Models/User.php | 140 +++++++++++++++++++++++++ config/two_factor.php | 33 ++++++ phpunit.xml | 4 + tests/unit/UserTwoFactorTest.php | 173 +++++++++++++++++++++++++++++++ 4 files changed, 350 insertions(+) create mode 100644 config/two_factor.php create mode 100644 tests/unit/UserTwoFactorTest.php 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')); + } +}