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