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()); + } +}