Completed
Push — bugfix/institution-based-on-sh... ( ea4980...f3b374 )
by
unknown
02:14
created

StepUpAuthenticationService   D

Complexity

Total Complexity 40

Size/Duplication

Total Lines 376
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 24

Importance

Changes 2
Bugs 0 Features 0
Metric Value
wmc 40
c 2
b 0
f 0
lcom 1
cbo 24
dl 0
loc 376
rs 4.6136

13 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 19 1
B determineViableSecondFactors() 0 35 4
D resolveHighestRequiredLoa() 0 94 15
A isIntrinsicLoa() 0 4 1
B verifyYubikeyOtp() 0 30 3
A getSecondFactorIdentifier() 0 7 1
A getSmsOtpRequestsRemainingCount() 0 4 1
A getSmsMaximumOtpRequestsCount() 0 4 1
A sendSmsChallenge() 0 15 1
A verifySmsChallenge() 0 4 1
A clearSmsVerificationState() 0 4 1
C determineInstitutionsByIdentityNameId() 0 37 7
A hasDefaultSpConfig() 0 7 3

How to fix   Complexity   

Complex Class

Complex classes like StepUpAuthenticationService often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use StepUpAuthenticationService, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * Copyright 2014 SURFnet bv
5
 *
6
 * Licensed under the Apache License, Version 2.0 (the "License");
7
 * you may not use this file except in compliance with the License.
8
 * You may obtain a copy of the License at
9
 *
10
 *     http://www.apache.org/licenses/LICENSE-2.0
11
 *
12
 * Unless required by applicable law or agreed to in writing, software
13
 * distributed under the License is distributed on an "AS IS" BASIS,
14
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
 * See the License for the specific language governing permissions and
16
 * limitations under the License.
17
 */
18
19
namespace Surfnet\StepupGateway\GatewayBundle\Service;
20
21
use Doctrine\Common\Collections\ArrayCollection;
22
use Psr\Log\LoggerInterface;
23
use Surfnet\SamlBundle\Entity\ServiceProvider;
24
use Surfnet\StepupBundle\Command\SendSmsChallengeCommand as StepupSendSmsChallengeCommand;
25
use Surfnet\StepupBundle\Command\VerifyPossessionOfPhoneCommand;
26
use Surfnet\StepupBundle\Service\LoaResolutionService;
27
use Surfnet\StepupBundle\Service\SecondFactorTypeService;
28
use Surfnet\StepupBundle\Service\SmsSecondFactor\OtpVerification;
29
use Surfnet\StepupBundle\Service\SmsSecondFactorService;
30
use Surfnet\StepupBundle\Value\Loa;
31
use Surfnet\StepupBundle\Value\PhoneNumber\InternationalPhoneNumber;
32
use Surfnet\StepupBundle\Value\YubikeyOtp;
33
use Surfnet\StepupBundle\Value\YubikeyPublicId;
34
use Surfnet\StepupGateway\ApiBundle\Dto\Otp as ApiOtp;
35
use Surfnet\StepupGateway\ApiBundle\Dto\Requester;
36
use Surfnet\StepupGateway\ApiBundle\Service\YubikeyService;
37
use Surfnet\StepupGateway\GatewayBundle\Command\SendSmsChallengeCommand;
38
use Surfnet\StepupGateway\GatewayBundle\Command\VerifyYubikeyOtpCommand;
39
use Surfnet\StepupGateway\GatewayBundle\Entity\SecondFactor;
40
use Surfnet\StepupGateway\GatewayBundle\Entity\SecondFactorRepository;
41
use Surfnet\StepupGateway\GatewayBundle\Exception\RuntimeException;
42
use Surfnet\StepupGateway\GatewayBundle\Service\StepUp\YubikeyOtpVerificationResult;
43
use Symfony\Component\Translation\TranslatorInterface;
44
45
/**
46
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
47
 */
48
class StepUpAuthenticationService
49
{
50
    /**
51
     * @var \Surfnet\StepupBundle\Service\LoaResolutionService
52
     */
53
    private $loaResolutionService;
54
55
    /**
56
     * @var \Surfnet\StepupGateway\GatewayBundle\Entity\SecondFactorRepository
57
     */
58
    private $secondFactorRepository;
59
60
    /**
61
     * @var \Surfnet\StepupGateway\ApiBundle\Service\YubikeyService
62
     */
63
    private $yubikeyService;
64
65
    /**
66
     * @var \Surfnet\StepupBundle\Service\SmsSecondFactorService
67
     */
68
    private $smsService;
69
70
    /** @var InstitutionMatchingHelper */
71
    private $institutionMatchingHelper;
72
73
    /**
74
     * @var \Symfony\Component\Translation\TranslatorInterface
75
     */
76
    private $translator;
77
78
    /**
79
     * @var \Psr\Log\LoggerInterface
80
     */
81
    private $logger;
82
83
    /**
84
     * @var SecondFactorTypeService
85
     */
86
    private $secondFactorTypeService;
87
88
    /**
89
     * @param LoaResolutionService   $loaResolutionService
90
     * @param SecondFactorRepository $secondFactorRepository
91
     * @param YubikeyService         $yubikeyService
92
     * @param SmsSecondFactorService $smsService
93
     * @param InstitutionMatchingHelper $institutionMatchingHelper
94
     * @param TranslatorInterface    $translator
95
     * @param LoggerInterface        $logger
96
     * @param SecondFactorTypeService $secondFactorTypeService
97
     */
98
    public function __construct(
99
        LoaResolutionService $loaResolutionService,
100
        SecondFactorRepository $secondFactorRepository,
101
        YubikeyService $yubikeyService,
102
        SmsSecondFactorService $smsService,
103
        InstitutionMatchingHelper $institutionMatchingHelper,
104
        TranslatorInterface $translator,
105
        LoggerInterface $logger,
106
        SecondFactorTypeService $secondFactorTypeService
107
    ) {
108
        $this->loaResolutionService = $loaResolutionService;
109
        $this->secondFactorRepository = $secondFactorRepository;
110
        $this->yubikeyService = $yubikeyService;
111
        $this->smsService = $smsService;
112
        $this->institutionMatchingHelper = $institutionMatchingHelper;
113
        $this->translator = $translator;
114
        $this->logger = $logger;
115
        $this->secondFactorTypeService = $secondFactorTypeService;
116
    }
117
118
    /**
119
     * @param string $identityNameId
120
     * @param Loa $requiredLoa
121
     * @param WhitelistService $whitelistService
122
     * @return \Doctrine\Common\Collections\Collection
123
     */
124
    public function determineViableSecondFactors(
125
        $identityNameId,
126
        Loa $requiredLoa,
127
        WhitelistService $whitelistService
128
    ) {
129
130
        $candidateSecondFactors = $this->secondFactorRepository->getAllMatchingFor(
131
            $requiredLoa,
132
            $identityNameId,
133
            $this->secondFactorTypeService
134
        );
135
        $this->logger->info(
136
            sprintf('Loaded %d matching candidate second factors', count($candidateSecondFactors))
137
        );
138
139
        foreach ($candidateSecondFactors as $key => $secondFactor) {
140
            if (!$whitelistService->contains($secondFactor->institution)) {
141
                $this->logger->notice(
142
                    sprintf(
143
                        'Second factor "%s" is listed for institution "%s" which is not on the whitelist',
144
                        $secondFactor->secondFactorId,
145
                        $secondFactor->institution
146
                    )
147
                );
148
149
                $candidateSecondFactors->remove($key);
150
            }
151
        }
152
153
        if ($candidateSecondFactors->isEmpty()) {
154
            $this->logger->alert('No suitable candidate second factors found, sending Loa cannot be given response');
155
        }
156
157
        return $candidateSecondFactors;
158
    }
159
160
    /**
161
     * @param string $requestedLoa
162
     * @param $identityNameId
163
     * @param string $identityInstitution
164
     * @param ServiceProvider $serviceProvider
165
     * @return null|Loa
166
     * @SuppressWarnings(PHPMD.CyclomaticComplexity) see https://www.pivotaltracker.com/story/show/96065350
167
     * @SuppressWarnings(PHPMD.NPathComplexity)      see https://www.pivotaltracker.com/story/show/96065350
168
     */
169
    public function resolveHighestRequiredLoa(
170
        $requestedLoa,
171
        $identityNameId,
172
        $identityInstitution,
173
        ServiceProvider $serviceProvider
174
    ) {
175
        $loaCandidates = new ArrayCollection();
176
177
        if ($requestedLoa) {
178
            $loaCandidates->add($requestedLoa);
179
            $this->logger->info(sprintf('Added requested Loa "%s" as candidate', $requestedLoa));
180
        }
181
182
        $spConfiguredLoas = $serviceProvider->get('configuredLoas');
183
184
        if (array_key_exists('__default__', $spConfiguredLoas) &&
185
            !$loaCandidates->contains($spConfiguredLoas['__default__'])
186
        ) {
187
            $loaCandidates->add($spConfiguredLoas['__default__']);
188
            $this->logger->info(sprintf('Added SP\'s default Loa "%s" as candidate', $spConfiguredLoas['__default__']));
189
        }
190
191
        if (count($spConfiguredLoas) > 1 && is_null($identityInstitution)) {
192
            throw new RuntimeException(
193
                'SP configured LOA\'s are applicable but the authenticating user has no ' .
194
                'schacHomeOrganization in the assertion.'
195
            );
196
        }
197
198
        $institutionsBasedOnVettedTokens = [];
0 ignored issues
show
Comprehensibility Naming introduced by
The variable name $institutionsBasedOnVettedTokens exceeds the maximum configured length of 30.

Very long variable names usually make code harder to read. It is therefore recommended not to make variable names too verbose.

Loading history...
199
        if (!$this->hasDefaultSpConfig($spConfiguredLoas)) {
200
            // Load the institutions the user has vetted a second factor
201
            $institutionsBasedOnVettedTokens = $this->determineInstitutionsByIdentityNameId(
202
                $identityNameId,
203
                $identityInstitution,
204
                $spConfiguredLoas
205
            );
206
        }
207
208
        $this->logger->info(sprintf('Loaded institution(s) for "%s"', $identityNameId));
209
210
        // Match the users institutions loas against the SP configured institutions
211
        $matchingInstitutions = $this->institutionMatchingHelper->findMatches(
212
            array_keys($spConfiguredLoas),
213
            $institutionsBasedOnVettedTokens
214
        );
215
216
        if (count($matchingInstitutions) > 0) {
217
            $this->logger->info('Found matching SP configured LoA\'s');
218
            foreach ($matchingInstitutions as $matchingInstitution) {
219
                $loaCandidates->add($spConfiguredLoas[$matchingInstitution]);
220
                $this->logger->info(sprintf(
221
                    'Added SP\'s Loa "%s" as candidate',
222
                    $spConfiguredLoas[$matchingInstitution]
223
                ));
224
            }
225
        }
226
227
        if (!count($loaCandidates)) {
228
            throw new RuntimeException('No Loa can be found, at least one Loa (SP default) should be found');
229
        }
230
231
        $actualLoas = new ArrayCollection();
232
        foreach ($loaCandidates as $loaDefinition) {
233
            $loa = $this->loaResolutionService->getLoa($loaDefinition);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $loa is correct as $this->loaResolutionServ...>getLoa($loaDefinition) (which targets Surfnet\StepupBundle\Ser...lutionService::getLoa()) seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
234
            if ($loa) {
235
                $actualLoas->add($loa);
236
            }
237
        }
238
239
        if (!count($actualLoas)) {
240
            $this->logger->info(sprintf(
241
                'Out of "%d" candidates, no existing Loa could be found, no authentication is possible.',
242
                count($loaCandidates)
243
            ));
244
245
            return null;
246
        }
247
248
        /** @var \Surfnet\StepupBundle\Value\Loa $highestLoa */
249
        $highestLoa = $actualLoas->first();
250
        foreach ($actualLoas as $loa) {
251
            // if the current highest Loa cannot satisfy the next Loa, that must be of a higher level...
252
            if (!$highestLoa->canSatisfyLoa($loa)) {
253
                $highestLoa = $loa;
254
            }
255
        }
256
257
        $this->logger->info(
258
            sprintf('Out of %d candidate Loa\'s, Loa "%s" is the highest', count($loaCandidates), $highestLoa)
259
        );
260
261
        return $highestLoa;
262
    }
263
264
    /**
265
     * Returns whether the given Loa identifier identifies the minimum Loa, intrinsic to being authenticated via an IdP.
266
     *
267
     * @param Loa $loa
268
     * @return bool
269
     */
270
    public function isIntrinsicLoa(Loa $loa)
271
    {
272
        return $loa->levelIsLowerOrEqualTo(Loa::LOA_1);
273
    }
274
275
    /**
276
     * @param VerifyYubikeyOtpCommand $command
277
     * @return YubikeyOtpVerificationResult
278
     */
279
    public function verifyYubikeyOtp(VerifyYubikeyOtpCommand $command)
280
    {
281
        /** @var SecondFactor $secondFactor */
282
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($command->secondFactorId);
283
284
        $requester = new Requester();
285
        $requester->identity = $secondFactor->identityId;
286
        $requester->institution = $secondFactor->institution;
287
288
        $otp = new ApiOtp();
289
        $otp->value = $command->otp;
290
291
        $result = $this->yubikeyService->verify($otp, $requester);
292
293
        if (!$result->isSuccessful()) {
294
            return new YubikeyOtpVerificationResult(YubikeyOtpVerificationResult::RESULT_OTP_VERIFICATION_FAILED, null);
295
        }
296
297
        $otp = YubikeyOtp::fromString($command->otp);
298
        $publicId = YubikeyPublicId::fromOtp($otp);
299
300
        if (!$publicId->equals(new YubikeyPublicId($secondFactor->secondFactorIdentifier))) {
301
            return new YubikeyOtpVerificationResult(
302
                YubikeyOtpVerificationResult::RESULT_PUBLIC_ID_DID_NOT_MATCH,
303
                $publicId
304
            );
305
        }
306
307
        return new YubikeyOtpVerificationResult(YubikeyOtpVerificationResult::RESULT_PUBLIC_ID_MATCHED, $publicId);
308
    }
309
310
    /**
311
     * @param string $secondFactorId
312
     * @return string
313
     */
314
    public function getSecondFactorIdentifier($secondFactorId)
315
    {
316
        /** @var SecondFactor $secondFactor */
317
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($secondFactorId);
318
319
        return $secondFactor->secondFactorIdentifier;
320
    }
321
322
    /**
323
     * @return int
324
     */
325
    public function getSmsOtpRequestsRemainingCount()
326
    {
327
        return $this->smsService->getOtpRequestsRemainingCount();
328
    }
329
330
    /**
331
     * @return int
332
     */
333
    public function getSmsMaximumOtpRequestsCount()
334
    {
335
        return $this->smsService->getMaximumOtpRequestsCount();
336
    }
337
338
    /**
339
     * @param SendSmsChallengeCommand $command
340
     * @return bool
341
     */
342
    public function sendSmsChallenge(SendSmsChallengeCommand $command)
343
    {
344
        /** @var SecondFactor $secondFactor */
345
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($command->secondFactorId);
346
347
        $phoneNumber = InternationalPhoneNumber::fromStringFormat($secondFactor->secondFactorIdentifier);
348
349
        $stepupCommand = new StepupSendSmsChallengeCommand();
350
        $stepupCommand->phoneNumber = $phoneNumber;
351
        $stepupCommand->body = $this->translator->trans('gateway.second_factor.sms.challenge_body');
352
        $stepupCommand->identity = $secondFactor->identityId;
353
        $stepupCommand->institution = $secondFactor->institution;
354
355
        return $this->smsService->sendChallenge($stepupCommand);
356
    }
357
358
    /**
359
     * @param VerifyPossessionOfPhoneCommand $command
360
     * @return OtpVerification
361
     */
362
    public function verifySmsChallenge(VerifyPossessionOfPhoneCommand $command)
363
    {
364
        return $this->smsService->verifyPossession($command);
365
    }
366
367
    public function clearSmsVerificationState()
368
    {
369
        $this->smsService->clearSmsVerificationState();
370
    }
371
372
    /**
373
     * @param string $identityNameId
374
     * @param string $identityInstitution
375
     * @param array $spConfiguredLoas
376
     * @return array
377
     */
378
    private function determineInstitutionsByIdentityNameId($identityNameId, $identityInstitution, $spConfiguredLoas)
379
    {
380
        $institutionsBasedOnVettedTokens = $this->secondFactorRepository->getAllInstitutions($identityNameId);
0 ignored issues
show
Comprehensibility Naming introduced by
The variable name $institutionsBasedOnVettedTokens exceeds the maximum configured length of 30.

Very long variable names usually make code harder to read. It is therefore recommended not to make variable names too verbose.

Loading history...
381
382
        if (empty($institutionsBasedOnVettedTokens) && array_key_exists($identityInstitution, $spConfiguredLoas)) {
383
            throw new RuntimeException(
384
                'The authenticating user cannot provide a token for the institution it is authenticating for.'
385
            );
386
        }
387
388
        if (empty($institutionsBasedOnVettedTokens)) {
389
            throw new RuntimeException(
390
                'The authenticating user does not have any vetted tokens.'
391
            );
392
        }
393
394
        if (!is_null($identityInstitution) && !empty($institutionsBasedOnVettedTokens)) {
395
396
            $institutionMatches = $this->institutionMatchingHelper->findMatches(
397
                $institutionsBasedOnVettedTokens,
398
                [$identityInstitution]
399
            );
400
401
            if (empty($institutionMatches)) {
402
                throw new RuntimeException(
403
                    'None of the authenticating users tokens are registered at an institution the user is currently ' .
404
                    'authenticating from.'
405
                );
406
            }
407
            // Add the SHO of the authenticating user to the list of institutions based on vetted tokens. This ensures
408
            // the correct LOA can be based on the organisation of the user.
409
            $institutionsBasedOnVettedTokens[] = $identityInstitution;
410
        }
411
412
413
        return $institutionsBasedOnVettedTokens;
414
    }
415
416
    private function hasDefaultSpConfig($spConfiguredLoas)
417
    {
418
        if (array_key_exists('__default__', $spConfiguredLoas) && count($spConfiguredLoas) === 1) {
0 ignored issues
show
Unused Code introduced by
This if statement, and the following return statement can be replaced with return array_key_exists(...pConfiguredLoas) === 1;.
Loading history...
419
            return true;
420
        }
421
        return false;
422
    }
423
}
424