silverstripe /
silverstripe-mfa
| 1 | <?php |
||
| 2 | |||
| 3 | declare(strict_types=1); |
||
| 4 | |||
| 5 | namespace SilverStripe\MFA\Service; |
||
| 6 | |||
| 7 | use SilverStripe\Admin\LeftAndMain; |
||
| 8 | use SilverStripe\Core\Config\Config; |
||
| 9 | use SilverStripe\Core\Config\Configurable; |
||
| 10 | use SilverStripe\Core\Injector\Injectable; |
||
| 11 | use SilverStripe\MFA\Extension\MemberExtension; |
||
| 12 | use SilverStripe\MFA\Extension\SiteConfigExtension; |
||
| 13 | use SilverStripe\ORM\FieldType\DBDate; |
||
| 14 | use SilverStripe\Security\Member; |
||
| 15 | use SilverStripe\SiteConfig\SiteConfig; |
||
| 16 | |||
| 17 | /** |
||
| 18 | * The EnforcementManager class is responsible for making decisions regarding multi-factor authentication app flow, |
||
| 19 | * e.g. "should we redirect to the MFA section", "can the user skip MFA registration" etc. |
||
| 20 | */ |
||
| 21 | class EnforcementManager |
||
| 22 | { |
||
| 23 | use Configurable; |
||
| 24 | use Injectable; |
||
| 25 | |||
| 26 | /** |
||
| 27 | * Indicate how many MFA methods the user must authenticate with before they are considered logged in |
||
| 28 | * |
||
| 29 | * @config |
||
| 30 | * @var int |
||
| 31 | */ |
||
| 32 | private static $required_mfa_methods = 1; |
||
|
0 ignored issues
–
show
introduced
by
Loading history...
|
|||
| 33 | |||
| 34 | /** |
||
| 35 | * If true, redirects to MFA will only provided when the current user has access to some part of the CMS or |
||
| 36 | * administration area. |
||
| 37 | * |
||
| 38 | * @config |
||
| 39 | * @var bool |
||
| 40 | */ |
||
| 41 | private static $requires_admin_access = true; |
||
|
0 ignored issues
–
show
|
|||
| 42 | |||
| 43 | /** |
||
| 44 | * Whether enforcement of MFA is enabled. If this is disabled, users will not be redirected to MFA registration |
||
| 45 | * or verification on login flows. |
||
| 46 | * |
||
| 47 | * @config |
||
| 48 | * @var bool |
||
| 49 | */ |
||
| 50 | private static $enabled = true; |
||
|
0 ignored issues
–
show
|
|||
| 51 | |||
| 52 | /** |
||
| 53 | * Whether the current member can skip the multi-factor authentication registration process. |
||
| 54 | * |
||
| 55 | * This is determined by a combination of: |
||
| 56 | * - Whether MFA is required or optional |
||
| 57 | * - If MFA is required, whether there is a grace period |
||
| 58 | * - If MFA is required and there is a grace period, whether we're currently within that timeframe |
||
| 59 | * |
||
| 60 | * @param Member&MemberExtension $member |
||
| 61 | * @return bool |
||
| 62 | */ |
||
| 63 | public function canSkipMFA(Member $member): bool |
||
| 64 | { |
||
| 65 | if ($this->isMFARequired()) { |
||
| 66 | return false; |
||
| 67 | } |
||
| 68 | |||
| 69 | // If they've already registered MFA methods we will not allow them to skip the authentication process |
||
| 70 | $registeredMethods = $member->RegisteredMFAMethods(); |
||
| 71 | if ($registeredMethods->exists()) { |
||
| 72 | return false; |
||
| 73 | } |
||
| 74 | |||
| 75 | // MFA is optional, or is required but might be within a grace period (see isMFARequired) |
||
| 76 | return true; |
||
| 77 | } |
||
| 78 | |||
| 79 | /** |
||
| 80 | * Whether the authentication process should redirect the user to multi-factor authentication registration or |
||
| 81 | * login. |
||
| 82 | * |
||
| 83 | * This is determined by a combination of: |
||
| 84 | * - Whether MFA is enabled |
||
| 85 | * - Whether MFA is required or optional |
||
| 86 | * - Whether the user has registered MFA methods already |
||
| 87 | * - If the user doesn't have any registered MFA methods already, and MFA is optional, whether the user has opted |
||
| 88 | * to skip the registration process |
||
| 89 | * |
||
| 90 | * Note that in determining this, we ignore whether or not MFA is enabled for the site in general. |
||
| 91 | * |
||
| 92 | * @param Member&MemberExtension $member |
||
| 93 | * @return bool |
||
| 94 | */ |
||
| 95 | public function shouldRedirectToMFA(Member $member): bool |
||
| 96 | { |
||
| 97 | if (!$this->isEnabled()) { |
||
| 98 | return false; |
||
| 99 | } |
||
| 100 | |||
| 101 | if ($this->config()->get('requires_admin_access') && !$this->hasAdminAccess($member)) { |
||
| 102 | return false; |
||
| 103 | } |
||
| 104 | |||
| 105 | $methodRegistry = MethodRegistry::singleton(); |
||
| 106 | $methods = $methodRegistry->getMethods(); |
||
| 107 | // If there are no methods available excluding backup codes, do not redirect |
||
| 108 | if (!count($methods) || (count($methods) === 1 && $methodRegistry->getBackupMethod() !== null)) { |
||
| 109 | return false; |
||
| 110 | } |
||
| 111 | |||
| 112 | if ($member->RegisteredMFAMethods()->exists()) { |
||
| 113 | return true; |
||
| 114 | } |
||
| 115 | |||
| 116 | if ($this->isMFARequired()) { |
||
| 117 | return true; |
||
| 118 | } |
||
| 119 | |||
| 120 | if ($this->isGracePeriodInEffect()) { |
||
| 121 | return true; |
||
| 122 | } |
||
| 123 | |||
| 124 | if (!$member->HasSkippedMFARegistration) { |
||
| 125 | return true; |
||
| 126 | } |
||
| 127 | |||
| 128 | return false; |
||
| 129 | } |
||
| 130 | |||
| 131 | /** |
||
| 132 | * Check if the provided member has registered the required MFA methods. This includes a "back-up" method set in |
||
| 133 | * configuration plus at least one other method. |
||
| 134 | * Note that this method returns true if there is no backup method registered (and they have one other method |
||
| 135 | * |
||
| 136 | * @param Member&MemberExtension $member |
||
| 137 | * @return bool |
||
| 138 | */ |
||
| 139 | public function hasCompletedRegistration(Member $member): bool |
||
| 140 | { |
||
| 141 | $methodCount = $member->RegisteredMFAMethods()->count(); |
||
| 142 | |||
| 143 | $backupMethod = Config::inst()->get(MethodRegistry::class, 'default_backup_method'); |
||
| 144 | if (!$backupMethod) { |
||
| 145 | // Ensure they have at least one method |
||
| 146 | return $methodCount > 0; |
||
| 147 | } |
||
| 148 | |||
| 149 | // Ensure they have the required backup method and at least 2 methods (the backup method plus one other) |
||
| 150 | return ((bool) $member->RegisteredMFAMethods()->find('MethodClassName', $backupMethod)) && $methodCount > 1; |
||
| 151 | } |
||
| 152 | |||
| 153 | /** |
||
| 154 | * Whether multi-factor authentication is required for site members. This also takes into account whether a |
||
| 155 | * grace period is set and whether we're currently inside the window for it. |
||
| 156 | * |
||
| 157 | * Note that in determining this, we ignore whether or not MFA is enabled for the site in general. |
||
| 158 | * |
||
| 159 | * @return bool |
||
| 160 | */ |
||
| 161 | public function isMFARequired(): bool |
||
| 162 | { |
||
| 163 | /** @var SiteConfig&SiteConfigExtension $siteConfig */ |
||
| 164 | $siteConfig = SiteConfig::current_site_config(); |
||
| 165 | |||
| 166 | $isRequired = $siteConfig->MFARequired; |
||
| 167 | if (!$isRequired) { |
||
| 168 | return false; |
||
| 169 | } |
||
| 170 | |||
| 171 | $gracePeriod = $siteConfig->MFAGracePeriodExpires; |
||
| 172 | if ($isRequired && !$gracePeriod) { |
||
| 173 | return true; |
||
| 174 | } |
||
| 175 | |||
| 176 | /** @var DBDate $gracePeriodDate */ |
||
| 177 | $gracePeriodDate = $siteConfig->dbObject('MFAGracePeriodExpires'); |
||
| 178 | if ($isRequired && $gracePeriodDate->InPast()) { |
||
| 179 | return true; |
||
| 180 | } |
||
| 181 | |||
| 182 | // MFA is required, a grace period is set, and it's in the future |
||
| 183 | return false; |
||
| 184 | } |
||
| 185 | |||
| 186 | /** |
||
| 187 | * Specifically determines whether the MFA Grace Period is currently active. |
||
| 188 | * |
||
| 189 | * @return bool |
||
| 190 | */ |
||
| 191 | public function isGracePeriodInEffect(): bool |
||
| 192 | { |
||
| 193 | /** @var SiteConfig&SiteConfigExtension $siteConfig */ |
||
| 194 | $siteConfig = SiteConfig::current_site_config(); |
||
| 195 | |||
| 196 | $isRequired = $siteConfig->MFARequired; |
||
| 197 | if (!$isRequired) { |
||
| 198 | return false; |
||
| 199 | } |
||
| 200 | |||
| 201 | $gracePeriod = $siteConfig->MFAGracePeriodExpires; |
||
| 202 | if (!$gracePeriod) { |
||
| 203 | return false; |
||
| 204 | } |
||
| 205 | |||
| 206 | /** @var DBDate $gracePeriodDate */ |
||
| 207 | $gracePeriodDate = $siteConfig->dbObject('MFAGracePeriodExpires'); |
||
| 208 | if ($gracePeriodDate->InPast()) { |
||
| 209 | return false; |
||
| 210 | } |
||
| 211 | |||
| 212 | return true; |
||
| 213 | } |
||
| 214 | |||
| 215 | /** |
||
| 216 | * Decides whether the current user has access to any LeftAndMain controller, which indicates some level |
||
| 217 | * of access to the CMS. |
||
| 218 | * |
||
| 219 | * See LeftAndMain::init(). |
||
| 220 | * |
||
| 221 | * @param Member $member |
||
| 222 | * @return bool |
||
| 223 | */ |
||
| 224 | protected function hasAdminAccess(Member $member): bool |
||
| 225 | { |
||
| 226 | $leftAndMain = LeftAndMain::singleton(); |
||
| 227 | if ($leftAndMain->canView($member)) { |
||
| 228 | return true; |
||
| 229 | } |
||
| 230 | |||
| 231 | // Look through all LeftAndMain subclasses to find if one permits the member to view |
||
| 232 | $menu = $leftAndMain->MainMenu(); |
||
| 233 | foreach ($menu as $candidate) { |
||
| 234 | if ( |
||
| 235 | $candidate->Link |
||
| 236 | && $candidate->Link !== $leftAndMain->Link() |
||
| 237 | && $candidate->MenuItem->controller |
||
| 238 | && singleton($candidate->MenuItem->controller)->canView($member) |
||
| 239 | ) { |
||
| 240 | return true; |
||
| 241 | } |
||
| 242 | } |
||
| 243 | |||
| 244 | return false; |
||
| 245 | } |
||
| 246 | |||
| 247 | /** |
||
| 248 | * @return bool |
||
| 249 | */ |
||
| 250 | protected function isEnabled(): bool |
||
| 251 | { |
||
| 252 | return (bool) $this->config()->get('enabled'); |
||
| 253 | } |
||
| 254 | } |
||
| 255 |