EnforcementManager::canSkipMFA()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 14
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 6
c 1
b 0
f 0
nc 3
nop 1
dl 0
loc 14
rs 10
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
The private property $required_mfa_methods is not used, and could be removed.
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
introduced by
The private property $requires_admin_access is not used, and could be removed.
Loading history...
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
introduced by
The private property $enabled is not used, and could be removed.
Loading history...
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