diff --git a/app/libs/Auth/Models/User.php b/app/libs/Auth/Models/User.php index c1d70d96..30af6eee 100644 --- a/app/libs/Auth/Models/User.php +++ b/app/libs/Auth/Models/User.php @@ -1,4 +1,5 @@ - 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 +483,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; @@ -527,13 +556,13 @@ public function getAuthPassword() */ public function getIdentifier(): ?string { - if(empty($this->identifier)) + if (empty($this->identifier)) return $this->email; return $this->identifier; } - public function getEmail():?string + public function getEmail(): ?string { return PunnyCodeHelper::decodeEmail($this->email); } @@ -547,12 +576,12 @@ public function getFullName(): ?string return !empty(trim($full_name)) ? $full_name : $this->getEmail(); } - public function getFirstName():?string + public function getFirstName(): ?string { return $this->first_name; } - public function getLastName():?string + public function getLastName(): ?string { return $this->last_name; } @@ -567,7 +596,7 @@ public function getGender(): ?string return $this->gender; } - public function getCountry():?string + public function getCountry(): ?string { return $this->country_iso_code; } @@ -587,20 +616,21 @@ public function getDateOfBirth(): ?\DateTime */ public function getDateOfBirthNice(): ?string { - if (is_null($this->birthday)) return null; + if (is_null($this->birthday)) + return null; return $this->birthday->format("Y-m-d H:i:s"); } - public function getId():int + public function getId(): int { - return (int)$this->id; + return (int) $this->id; } /** * @return bool */ - public function getShowProfileFullName():bool + public function getShowProfileFullName(): bool { return $this->public_profile_show_fullname > 0; } @@ -608,7 +638,7 @@ public function getShowProfileFullName():bool /** * @return bool */ - public function getShowProfilePic():bool + public function getShowProfilePic(): bool { return $this->public_profile_show_photo > 0; } @@ -616,7 +646,7 @@ public function getShowProfilePic():bool /** * @return bool */ - public function getShowProfileBio():bool + public function getShowProfileBio(): bool { return false; } @@ -624,7 +654,7 @@ public function getShowProfileBio():bool /** * @return bool */ - public function getShowProfileEmail():bool + public function getShowProfileEmail(): bool { return $this->public_profile_show_email > 0; } @@ -663,7 +693,8 @@ public function getManagedClients() */ public function canUseSystemScopes(): bool { - if ($this->isSuperAdmin()) return true; + if ($this->isSuperAdmin()) + return true; return $this->belongToGroup(IOAuth2User::OAuth2SystemScopeAdminGroup); } @@ -673,7 +704,8 @@ public function canUseSystemScopes(): bool */ public function isOAuth2ServerAdmin(): bool { - if ($this->isSuperAdmin()) return true; + if ($this->isSuperAdmin()) + return true; return $this->belongToGroup(IOAuth2User::OAuth2ServerAdminGroup); } @@ -682,7 +714,8 @@ public function isOAuth2ServerAdmin(): bool */ public function isOpenIdServerAdmin(): bool { - if ($this->isSuperAdmin()) return true; + if ($this->isSuperAdmin()) + return true; return $this->belongToGroup(IOpenIdUser::OpenIdServerAdminGroup); } @@ -732,7 +765,7 @@ public function addToGroup(Group $group) $current_user = Auth::user(); $action_by = ''; - if($current_user instanceof User){ + if ($current_user instanceof User) { Log::debug ( sprintf @@ -746,10 +779,10 @@ public function addToGroup(Group $group) ) ); - if(!$current_user->isActive()) + if (!$current_user->isActive()) throw new ValidationException("Current User is not active."); - if(!$current_user->isSuperAdmin() && $group->getSlug() != IGroupSlugs::RawUsersGroup) { + if (!$current_user->isSuperAdmin() && $group->getSlug() != IGroupSlugs::RawUsersGroup) { $current_user->deActivate(); throw new ValidationException ( @@ -781,7 +814,7 @@ public function addToGroup(Group $group) // slugs $monitored_security_groups = Config::get("audit.monitored_security_groups_set"); Log::debug(sprintf("User::addToGroup monitored security groups %s", implode(',', $monitored_security_groups))); - if(in_array($group->getSlug(), $monitored_security_groups)) { + if (in_array($group->getSlug(), $monitored_security_groups)) { // trigger job Log::debug(sprintf("User::addToGroup dispatching NotifyMonitoredSecurityGroupActivity for user %s group %s", $this->id, $group->getSlug())); NotifyMonitoredSecurityGroupActivity::dispatch( @@ -814,7 +847,7 @@ public function removeFromGroup(Group $group) ); $current_user = Auth::user(); $action_by = ''; - if($current_user instanceof User){ + if ($current_user instanceof User) { Log::debug ( sprintf @@ -828,10 +861,10 @@ public function removeFromGroup(Group $group) ) ); - if(!$current_user->isActive()) + if (!$current_user->isActive()) throw new ValidationException("Current User is not active."); - if(!$current_user->isSuperAdmin()) { + if (!$current_user->isSuperAdmin()) { $current_user->deActivate(); throw new ValidationException ( @@ -852,12 +885,13 @@ public function removeFromGroup(Group $group) $action_by = sprintf("%s (%s)", $current_user->getFullName(), $current_user->getEmail()); } - if (!$this->groups->contains($group)) return; + if (!$this->groups->contains($group)) + return; $this->groups->removeElement($group); // slugs $monitored_security_groups = Config::get("audit.monitored_security_groups_set"); Log::debug(sprintf("User::removeFromGroup monitored security groups %s", implode(',', $monitored_security_groups))); - if(in_array($group->getSlug(), $monitored_security_groups)) { + if (in_array($group->getSlug(), $monitored_security_groups)) { // trigger job Log::debug(sprintf("User::removeFromGroup dispatching NotifyMonitoredSecurityGroupActivity for user %s group %s", $this->id, $group->getSlug())); NotifyMonitoredSecurityGroupActivity::dispatch( @@ -873,23 +907,23 @@ public function removeFromGroup(Group $group) } } - public function getStreetAddress():?string + public function getStreetAddress(): ?string { return $this->address1 . ' ' . $this->address2; } - public function getRegion():?string + public function getRegion(): ?string { return $this->state; } - public function getLocality():?string + public function getLocality(): ?string { return $this->city; } - public function getPostalCode():?string + public function getPostalCode(): ?string { return $this->post_code; } @@ -904,7 +938,8 @@ public function getTrustedSites() */ public function addTrustedSite(OpenIdTrustedSite $site) { - if ($this->trusted_sites->contains($site)) return; + if ($this->trusted_sites->contains($site)) + return; $this->trusted_sites->add($site); $site->setOwner($this); } @@ -935,7 +970,7 @@ public function getExternalIdentifier() /** * @return string */ - public function getFormattedAddress():?string + public function getFormattedAddress(): ?string { $street = $this->getStreetAddress(); $region = $this->getRegion(); @@ -1007,17 +1042,20 @@ public function getGroupScopes() */ public function isGroupScopeAllowed(ApiScope $scope): bool { - if (!$scope->isAssignedByGroups()) throw new ValidationException("scope is not assigned by groups!"); + if (!$scope->isAssignedByGroups()) + throw new ValidationException("scope is not assigned by groups!"); $criteria = Criteria::create(); $criteria->where(Criteria::expr()->eq('active', true)); $active_scope_groups = $this->scope_groups->matching($criteria); foreach ($active_scope_groups as $group) { - if ($group->hasScope($scope)) return true; + if ($group->hasScope($scope)) + return true; } return false; } - public function clearEmailVerification(){ + public function clearEmailVerification() + { $this->email_verified = false; $this->email_verified_date = null; } @@ -1054,50 +1092,49 @@ public function getPic(): string try { $pic_key = sprintf("%s_user_pic", $this->id); $pic = Cache::get($pic_key); - if(!empty($pic)) return $pic; + if (!empty($pic)) + return $pic; if (!empty($this->pic)) { $storage = Storage::disk(Config::get("filesystems.cloud")); - if(!is_null($storage)) { + if (!is_null($storage)) { $path = self::getProfilePicFolder(); $pic = null; - if($storage->exists(sprintf("%s/%s/%s", $path, $this->id, $this->pic))) { + if ($storage->exists(sprintf("%s/%s/%s", $path, $this->id, $this->pic))) { Log::debug(sprintf("User::getPic Getting profile pic from %s/%s/%s", $path, $this->id, $this->pic)); $pic = $storage->url(sprintf("%s/%s/%s", $path, $this->id, $this->pic)); } // legacy path format - if(empty($pic) && $storage->exists(sprintf("%s/%s", $path, $this->pic))) { + if (empty($pic) && $storage->exists(sprintf("%s/%s", $path, $this->pic))) { Log::debug(sprintf("User::getPic Getting profile pic from %s/%s", $path, $this->pic)); $pic = $storage->url(sprintf("%s/%s", $path, $this->pic)); } - if(!empty($pic)) { + if (!empty($pic)) { Cache::forever($pic_key, $pic); return $pic; } } } - if(!empty($this->external_pic)){ + if (!empty($this->external_pic)) { return $this->external_pic; } - if(!empty($default_pic)) + if (!empty($default_pic)) return $default_pic; return $this->getGravatarUrl(); - } - catch(RequestException $ex1){ + } catch (RequestException $ex1) { Log::warning($ex1); - } - catch (\Exception $ex) { + } catch (\Exception $ex) { Log::warning($ex); } - if(!empty($default_pic)) + if (!empty($default_pic)) return $default_pic; return $this->getGravatarUrl(); } @@ -1105,7 +1142,8 @@ public function getPic(): string /** * @param string $pic */ - public function setPic(string $pic){ + public function setPic(string $pic) + { $this->pic = $pic; $pic_key = sprintf("%s_user_pic", $this->id); Cache::forget($pic_key); @@ -1128,14 +1166,12 @@ private function getGravatarUrl(): string */ public function checkPassword(string $password): bool { - if(empty($this->password)) - { + if (empty($this->password)) { Log::warning(sprintf("User %s (%s) has not password set.", $this->id, $this->getEmail())); return false; } - if(empty($this->password_enc)) - { + if (empty($this->password_enc)) { Log::warning(sprintf("User %s (%s) has not password encoding set.", $this->id, $this->getEmail())); return false; } @@ -1165,7 +1201,7 @@ public function lock() Event::dispatch(new UserLocked($this->getId())); $action = 'User Locked.'; $current_user = Auth::user(); - if($current_user instanceof User) { + if ($current_user instanceof User) { $action = sprintf ( "User Locked by user %s (%s)", @@ -1186,7 +1222,7 @@ public function unlock() $action = 'User Unlocked.'; $current_user = Auth::user(); - if($current_user instanceof User) { + if ($current_user instanceof User) { $action = sprintf ( "User Unlocked by user %s (%s)", @@ -1326,7 +1362,8 @@ public function setLoginFailedAttempt(int $login_failed_attempt): void $this->login_failed_attempt = $login_failed_attempt; } - public function resetLoginFailedAttempts():void{ + public function resetLoginFailedAttempts(): void + { $this->login_failed_attempt = 0; } @@ -1582,7 +1619,7 @@ public function setPassword(string $password): void $action = 'User set new password.'; $current_user = Auth::user(); - if($current_user instanceof User) { + if ($current_user instanceof User) { $action = sprintf ( "User set new password by user %s (%s)", @@ -1717,7 +1754,8 @@ public function getActions() */ public function addUserAction(UserAction $action) { - if ($this->actions->contains($action)) return; + if ($this->actions->contains($action)) + return; $this->actions->add($action); $action->setOwner($this); } @@ -1748,15 +1786,16 @@ public function findFirstConsentByClientAndScopes(Client $client, string $scopes $criteria = new Criteria(); $criteria->where(Criteria::expr()->eq("client", $client)); $consents = $this->consents->matching($criteria); - if ($consents->count() == 0) return null; + if ($consents->count() == 0) + return null; $scope_set = explode(' ', $scopes); sort($scope_set); $query = <<setParameter("scopes", join(' ', $scope_set)); $consent = $query->getOneOrNullResult(); - if (!is_null($consent)) return $consent; + if (!is_null($consent)) + return $consent; foreach ($consents as $consent) { $former_scope_set = explode(' ', $consent->getScope()); @@ -1786,7 +1826,8 @@ public function findFirstConsentByClientAndScopes(Client $client, string $scopes */ public function addConsent(UserConsent $consent) { - if ($this->consents->contains($consent)) return; + if ($this->consents->contains($consent)) + return; $this->consents->add($consent); $consent->setOwner($this); } @@ -1858,24 +1899,28 @@ public function setBio(string $bio): void $this->bio = TextUtils::trim($bio); } - public function activate():void { - if(!$this->active) { + public function activate(): void + { + if (!$this->active) { $this->active = true; $this->spam_type = self::SpamTypeHam; // reset it $this->login_failed_attempt = 0; - Event::dispatch(new UserSpamStateUpdated( + Event::dispatch( + new UserSpamStateUpdated( $this->getId() ) ); } } - public function deActivate():void { - if( $this->active) { + public function deActivate(): void + { + if ($this->active) { $this->active = false; $this->spam_type = self::SpamTypeSpam; - Event::dispatch(new UserSpamStateUpdated( + Event::dispatch( + new UserSpamStateUpdated( $this->getId() ) ); @@ -1892,14 +1937,14 @@ public function verifyEmail(bool $send_email_verified_notice = true) if (!$this->email_verified) { Log::debug(sprintf("User::verifyEmail verifying email %s", $this->getEmail())); - $this->email_verified = true; - $this->spam_type = self::SpamTypeHam; - $this->active = true; - $this->lock = false; + $this->email_verified = true; + $this->spam_type = self::SpamTypeHam; + $this->active = true; + $this->lock = false; $this->login_failed_attempt = 0; $this->email_verified_date = new \DateTime('now', new \DateTimeZone('UTC')); - if($send_email_verified_notice) + if ($send_email_verified_notice) Event::dispatch(new UserEmailVerified($this->getId())); Event::dispatch(new UserSpamStateUpdated($this->getId())); } @@ -1912,7 +1957,7 @@ public function verifyEmail(bool $send_email_verified_notice = true) */ public function generateEmailVerificationToken(): string { - if($this->isEmailVerified()){ + if ($this->isEmailVerified()) { throw new ValidationException(sprintf("User %s (%s) is already verified.", $this->id, $this->getEmail())); } @@ -1973,11 +2018,12 @@ public function preRemove($args) #[PreUpdate] public function preUpdate(PreUpdateEventArgs $args) { - if($this->spam_type != self::SpamTypeNone ){ - if($args->hasChangedField("active")) return; + if ($this->spam_type != self::SpamTypeNone) { + if ($args->hasChangedField("active")) + return; $bio_changed = $args->hasChangedField("bio") && !empty($args->getNewValue('bio')); $email_changed = $args->hasChangedField("email"); - if( $bio_changed|| $email_changed) { + if ($bio_changed || $email_changed) { // enqueue user for spam re checker Log::warning(sprintf("User::preUpdate user %s was marked for spam type reclasification.", $this->getEmail())); $this->resetSpamTypeClassification(); @@ -2002,7 +2048,7 @@ public function __get($name) if ($name == "email" || $name == 'second_email' || $name == 'third_email') $res = PunnyCodeHelper::decodeEmail($res); - if(is_string($res)) + if (is_string($res)) Log::debug(sprintf("User::__get name %s res %s", $name, $res)); return $res; @@ -2029,7 +2075,8 @@ public function setGenderSpecify(string $gender_specify): void */ public function addPasswordResetRequest(UserPasswordResetRequest $request) { - if ($this->reset_password_requests->contains($request)) return; + if ($this->reset_password_requests->contains($request)) + return; $this->reset_password_requests->add($request); } @@ -2104,27 +2151,30 @@ public function getSpamType(): ?string */ public function setSpamType(string $spam_type): void { - if(!in_array($spam_type, self::ValidSpamTypes)) + if (!in_array($spam_type, self::ValidSpamTypes)) throw new ValidationException(sprintf("Not valid %s spam type value.", $spam_type)); $this->spam_type = $spam_type; } - public function resetSpamTypeClassification():void{ + public function resetSpamTypeClassification(): void + { $this->spam_type = self::SpamTypeNone; } /** * @return bool */ - public function isHam():bool{ + public function isHam(): bool + { return $this->spam_type == self::SpamTypeHam; } /** * @return bool */ - public function isSpam():bool{ + public function isSpam(): bool + { return $this->spam_type == self::SpamTypeSpam; } @@ -2162,7 +2212,8 @@ public function setPhoneNumber(string $phone_number): void const ProfilePicFolder = 'profile_pics'; - public static function getProfilePicFolder():string{ + public static function getProfilePicFolder(): string + { return self::ProfilePicFolder; } @@ -2233,26 +2284,29 @@ public function setExternalId(string $external_id): void /** * @param string $full_name */ - public function setFullName(string $full_name):void{ + public function setFullName(string $full_name): void + { $full_name = TextUtils::trim($full_name); $name_parts = explode(" ", $full_name); - if(count($name_parts) > 0) + if (count($name_parts) > 0) $this->first_name = $name_parts[0]; - if(count($name_parts) > 1) + if (count($name_parts) > 1) $this->last_name = $name_parts[1]; } /** * @return bool */ - public function hasPasswordSet():bool{ + public function hasPasswordSet(): bool + { return !empty($this->password); } /** * @return bool */ - public function hasIdentifier():bool{ + public function hasIdentifier(): bool + { return !empty($this->identifier); } @@ -2275,7 +2329,8 @@ public function setCreatedByOtp(OAuth2OTP $created_by_otp): void /** * @return bool */ - public function createdByOTP():bool{ + public function createdByOTP(): bool + { return !is_null($this->created_by_otp); } @@ -2285,11 +2340,14 @@ public function updated($args) } - private function formatFieldValue(string $field, $value):string{ - if($field === 'password') $value = "********"; - if($value instanceof \DateTime) + private function formatFieldValue(string $field, $value): string + { + if ($field === 'password') + $value = "********"; + if ($value instanceof \DateTime) $value = $value->format('Y-m-d H:i:s'); - if(empty($value)) $value = "EMPTY"; + if (empty($value)) + $value = "EMPTY"; return $value; } #[ORM\PreUpdate] // : @@ -2336,19 +2394,23 @@ public function updating(PreUpdateEventArgs $args) $old_fields_changed = []; $new_fields_changed = []; - foreach($fields_2_check as $field){ - if($args->hasChangedField($field)){ + foreach ($fields_2_check as $field) { + if ($args->hasChangedField($field)) { $old_fields_changed[] = sprintf("%s: %s", $field, self::formatFieldValue($field, $args->getOldValue($field))); $new_fields_changed[] = sprintf("%s: %s", $field, self::formatFieldValue($field, $args->getNewValue($field))); } } - if(count($old_fields_changed) == 0) return; - if(count($new_fields_changed) == 0) return; + if (count($old_fields_changed) == 0) + return; + if (count($new_fields_changed) == 0) + return; $action = sprintf ( - "USER UPDATED from %s to %s", implode(", ", $old_fields_changed), implode(", ", $new_fields_changed) + "USER UPDATED from %s to %s", + implode(", ", $old_fields_changed), + implode(", ", $new_fields_changed) ); AddUserAction::dispatch($this->id, IPHelper::getUserIp(), $action); @@ -2359,4 +2421,127 @@ 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 = $this->shouldRequire2FA() || $enabled; + } + + public function getTwoFactorMethod(): string + { + return $this->two_factor_method; + } + + /** + * @throws ValidationException + */ + protected function setTwoFactorMethod(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_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 + { + return ($this->isAdmin() || (bool) $this->two_factor_enabled); + } + + /** + * @throws ValidationException + */ + public function enable2FA(string $method): void + { + $methods = $this->getAvailableTwoFactorMethods(); + if (!in_array($method, $methods, true)) { + throw new ValidationException( + sprintf( + "Invalid 2FA method '%s'. Allowed methods: %s", + $method, + implode(', ', $methods) + ) + ); + } + $this->setTwoFactorEnabled(true); + $this->setTwoFactorMethod($method); + $this->setTwoFactorEnforcedAt(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() && 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..eb291d83 --- /dev/null +++ b/tests/unit/UserTwoFactorTest.php @@ -0,0 +1,216 @@ +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::AdminGroup)]); + + $this->assertTrue($user->isAdmin(), IGroupSlugs::AdminGroup.' is an admin group per isAdmin()'); + $this->assertTrue($user->shouldRequire2FA()); + + $user = new User(); + $this->assignGroups($user, [$this->buildGroup(IGroupSlugs::SuperAdminGroup)]); + $this->assertTrue($user->isAdmin(), IGroupSlugs::SuperAdminGroup.' is an admin group per isAdmin()'); + $this->assertTrue($user->shouldRequire2FA()); + + $user = new User(); + $this->assignGroups($user, [$this->buildGroup(IGroupSlugs::OAuth2ServerAdminGroup)]); + $this->assertFalse($user->isAdmin(), IGroupSlugs::OAuth2ServerAdminGroup.' is NOT an admin group per isAdmin()'); + $this->assertFalse($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(); + // 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 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 testSetTwoFactorMethod_invalidMethod_throws(): void + { + // Regression guard for the setter's whitelist: a bogus method must be + // rejected even though setTwoFactorMethod() is protected (invoked here + // via reflection, the same pattern used by other helpers in this file). + $user = new User(); + + $reflection = new \ReflectionClass(User::class); + $setter = $reflection->getMethod('setTwoFactorMethod'); + $setter->setAccessible(true); + + $this->expectException(ValidationException::class); + $setter->invoke($user, 'bogus'); + } + + 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()); + } +}