Completed
Push — feature/refactor-loa-determina... ( 42efb1 )
by
unknown
28:09
created

resolveHighestRequiredLoa()   F

Complexity

Conditions 18
Paths 268

Size

Total Lines 86
Code Lines 43

Duplication

Lines 8
Ratio 9.3 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
c 2
b 0
f 0
dl 8
loc 86
rs 3.6714
cc 18
eloc 43
nc 268
nop 4

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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\StepupBundle\Command\SendSmsChallengeCommand as StepupSendSmsChallengeCommand;
24
use Surfnet\StepupBundle\Command\VerifyPossessionOfPhoneCommand;
25
use Surfnet\StepupBundle\Service\LoaResolutionService;
26
use Surfnet\StepupBundle\Service\SecondFactorTypeService;
27
use Surfnet\StepupBundle\Service\SmsSecondFactor\OtpVerification;
28
use Surfnet\StepupBundle\Service\SmsSecondFactorService;
29
use Surfnet\StepupBundle\Value\Loa;
30
use Surfnet\StepupBundle\Value\PhoneNumber\InternationalPhoneNumber;
31
use Surfnet\StepupBundle\Value\YubikeyOtp;
32
use Surfnet\StepupBundle\Value\YubikeyPublicId;
33
use Surfnet\StepupGateway\ApiBundle\Dto\Otp as ApiOtp;
34
use Surfnet\StepupGateway\ApiBundle\Dto\Requester;
35
use Surfnet\StepupGateway\ApiBundle\Service\YubikeyService;
36
use Surfnet\StepupGateway\GatewayBundle\Command\SendSmsChallengeCommand;
37
use Surfnet\StepupGateway\GatewayBundle\Command\VerifyYubikeyOtpCommand;
38
use Surfnet\StepupGateway\GatewayBundle\Entity\SecondFactor;
39
use Surfnet\StepupGateway\GatewayBundle\Entity\SecondFactorRepository;
40
use Surfnet\StepupGateway\GatewayBundle\Exception\InstitutionMismatchException;
41
use Surfnet\StepupGateway\GatewayBundle\Exception\InvalidStepupShoFormatException;
42
use Surfnet\StepupGateway\GatewayBundle\Exception\LoaCannotBeGivenException;
43
use Surfnet\StepupGateway\GatewayBundle\Exception\UnknownInstitutionException;
44
use Surfnet\StepupGateway\GatewayBundle\Service\StepUp\YubikeyOtpVerificationResult;
45
use Symfony\Component\Translation\TranslatorInterface;
46
47
/**
48
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
49
 */
50
class StepUpAuthenticationService
51
{
52
    /**
53
     * @var \Surfnet\StepupBundle\Service\LoaResolutionService
54
     */
55
    private $loaResolutionService;
56
57
    /**
58
     * @var \Surfnet\StepupGateway\GatewayBundle\Entity\SecondFactorRepository
59
     */
60
    private $secondFactorRepository;
61
62
    /**
63
     * @var \Surfnet\StepupGateway\ApiBundle\Service\YubikeyService
64
     */
65
    private $yubikeyService;
66
67
    /**
68
     * @var \Surfnet\StepupBundle\Service\SmsSecondFactorService
69
     */
70
    private $smsService;
71
72
    /** @var InstitutionMatchingHelper */
73
    private $institutionMatchingHelper;
74
75
    /**
76
     * @var \Symfony\Component\Translation\TranslatorInterface
77
     */
78
    private $translator;
79
80
    /**
81
     * @var \Psr\Log\LoggerInterface
82
     */
83
    private $logger;
84
85
    /**
86
     * @var SecondFactorTypeService
87
     */
88
    private $secondFactorTypeService;
89
90
    /**
91
     * @param LoaResolutionService   $loaResolutionService
92
     * @param SecondFactorRepository $secondFactorRepository
93
     * @param YubikeyService         $yubikeyService
94
     * @param SmsSecondFactorService $smsService
95
     * @param InstitutionMatchingHelper $institutionMatchingHelper
96
     * @param TranslatorInterface    $translator
97
     * @param LoggerInterface        $logger
98
     * @param SecondFactorTypeService $secondFactorTypeService
99
     */
100
    public function __construct(
101
        LoaResolutionService $loaResolutionService,
102
        SecondFactorRepository $secondFactorRepository,
103
        YubikeyService $yubikeyService,
104
        SmsSecondFactorService $smsService,
105
        InstitutionMatchingHelper $institutionMatchingHelper,
106
        TranslatorInterface $translator,
107
        LoggerInterface $logger,
108
        SecondFactorTypeService $secondFactorTypeService
109
    ) {
110
        $this->loaResolutionService = $loaResolutionService;
111
        $this->secondFactorRepository = $secondFactorRepository;
112
        $this->yubikeyService = $yubikeyService;
113
        $this->smsService = $smsService;
114
        $this->institutionMatchingHelper = $institutionMatchingHelper;
115
        $this->translator = $translator;
116
        $this->logger = $logger;
117
        $this->secondFactorTypeService = $secondFactorTypeService;
118
    }
119
120
    /**
121
     * @param string $identityNameId
122
     * @param Loa $requiredLoa
123
     * @param WhitelistService $whitelistService
124
     * @return \Doctrine\Common\Collections\Collection
125
     */
126
    public function determineViableSecondFactors(
127
        $identityNameId,
128
        Loa $requiredLoa,
129
        WhitelistService $whitelistService
130
    ) {
131
132
        $candidateSecondFactors = $this->secondFactorRepository->getAllMatchingFor(
133
            $requiredLoa,
134
            $identityNameId,
135
            $this->secondFactorTypeService
136
        );
137
        $this->logger->info(
138
            sprintf('Loaded %d matching candidate second factors', count($candidateSecondFactors))
139
        );
140
141
        foreach ($candidateSecondFactors as $key => $secondFactor) {
142
            if (!$whitelistService->contains($secondFactor->institution)) {
143
                $this->logger->notice(
144
                    sprintf(
145
                        'Second factor "%s" is listed for institution "%s" which is not on the whitelist',
146
                        $secondFactor->secondFactorId,
147
                        $secondFactor->institution
148
                    )
149
                );
150
151
                $candidateSecondFactors->remove($key);
152
            }
153
        }
154
155
        if ($candidateSecondFactors->isEmpty()) {
156
            $this->logger->alert('No suitable candidate second factors found, sending Loa cannot be given response');
157
        }
158
159
        return $candidateSecondFactors;
160
    }
161
162
    /**
163
     * Retrieves the required LoA for the authenticating user
164
     *
165
     * @see StepUpAuthenticationServiceTest::test_resolve_highest_required_loa_conbinations
166
     *      The possible flows through the method are tested in this test case. Any additional possible outcomes should
167
     *      be covered in this test.
168
     *
169
     * @see https://github.com/OpenConext/Stepup-Deploy/wiki/Institution-Specific-LoA
170
     *      The flow of the LoA determination is described in detail in this document. The parameters of this method
171
     *      match the inputs described in the flow diagram.
172
     *
173
     * @param string $requestedLoa     <SP-LoA>   Optional. The value of the AuthnConextClassRef attribute in the
174
     *                                            AuthnRequest from the SP.
175
     *
176
     *                                            Example: 'https://example.com/authentication/loa1'
177
     *
178
     * @param array  $spConfiguredLoas <LoAs>     Optional. An associative array mapping schacHomeOrganization to LoA.
179
     *                                            This array is configured on the gateway for each SP. All keys in the
180
     *                                            spConfiguredLoas array should be normalized (lower cased).
181
     *
182
     *                                            Example:
183
     *                                            [
184
     *                                                '__default__'   => 'https://example.com/authentication/loa1',
185
     *                                                'institution_a' => 'https://example.com/authentication/loa2',
186
     *                                                'institution_b' => 'https://example.com/authentication/loa3',
187
     *                                            ]
188
     *
189
     * @param string $idpSho           <IdP-SHO>  Optional. Value of the schacHomeOrganization attribute from the
190
     *                                            Assertion from the IdP. The SHO should be normalized (lower cased) as
191
     *                                            it will be used to be compared against the $spConfiguredLoas who have
192
     *                                            also been normalized.
193
     *
194
     *                                            Example: 'institution_a', ''
195
     *
196
     * @param string $userSho          <User-SHO> Optional. The schacHomeOrganization that the user belongs to, this is
197
     *                                            the schacHomeOrganization that was provided during registration of the
198
     *                                            token. The SHO should be normalized (lower cased) as it will be used
199
     *                                            to be compared against the $spConfiguredLoas who have also been
200
     *                                            normalized.
201
     *
202
     *                                            Example: 'institution_b', ''
203
     *
204
     * @return Loa
205
     *
206
     * @throws UnknownInstitutionException        Raised when neither <User-SHO> or <IdP-SHO> is provided but <LoAs>
207
     *                                            are configured for institutions other than __default__.
208
     *
209
     * @throws InstitutionMismatchException       <User-SHO> or <IdP-SHO> are configured and <LoAs> are provided for an
210
     *                                            institution other than __default__ but the <User-SHO> and <IdP-SHO> do
211
     *                                            not match.
212
     *
213
     * @throws LoaCannotBeGivenException          Raised when no LoA candidates are found or when none of the candidate
214
     *                                            LoAs are valid (known to the application).
215
     */
216
    public function resolveHighestRequiredLoa(
217
        $requestedLoa,
218
        array $spConfiguredLoas,
219
        $idpSho,
220
        $userSho
221
    ) {
222
        // Candidate LoA's are stored in a collection. At the end of this procedure, the highest LoA is selected from
223
        // this collection.
224
        $loaCandidates = new ArrayCollection();
225
226
        // Add the default LoA as configured for all SP's to the LoA candidates collection.
227
        if (array_key_exists('__default__', $spConfiguredLoas) &&
228
            !$loaCandidates->contains($spConfiguredLoas['__default__'])
229
        ) {
230
            $loaCandidates->add($spConfiguredLoas['__default__']);
231
            $this->logger->info(sprintf('Added SP\'s default Loa "%s" as candidate', $spConfiguredLoas['__default__']));
232
        }
233
234
        // If AuthnContextClassRef was present in AuthnRequest, add the SP requested LoA to the candidates collection.
235
        if ($requestedLoa) {
236
            $loaCandidates->add($requestedLoa);
237
            $this->logger->info(sprintf('Added requested Loa "%s" as candidate', $requestedLoa));
238
        }
239
240
        if ($this->hasNonDefaultSpConfiguredLoas($spConfiguredLoas)) {
241
            // We need an userSho or idpSho to determine if any of the <LoAs> are applicable
242
            if (empty($userSho) && empty($idpSho)) {
243
                throw new UnknownInstitutionException('Unable to determine the institution for authenticating user.');
244
            }
245
246
            // If both user and IdP SHO are known, they should match
247
            if (!empty($userSho) && !empty($idpSho) && $userSho != $idpSho) {
248
                throw new InstitutionMismatchException('User and IdP SHO are set but do not match.');
249
            }
250
251
            // If the user SHO is available in the <LoAs>, add the to the candidates collection.
252 View Code Duplication
            if (isset($spConfiguredLoas[$userSho])) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
253
                $this->logger->info(sprintf('Added Loa "%s" as candidate based on user SHO', $requestedLoa));
254
                $loaCandidates->add($spConfiguredLoas[$userSho]);
255
            }
256
257
            // If the IdP SHO is available in the <LoAs>, add the to the candidates collection.
258 View Code Duplication
            if (isset($spConfiguredLoas[$idpSho])) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
259
                $this->logger->info(sprintf('Added Loa "%s" as candidate based on IdP SHO', $requestedLoa));
260
                $loaCandidates->add($spConfiguredLoas[$idpSho]);
261
            }
262
        }
263
264
        if (!count($loaCandidates)) {
265
            throw new LoaCannotBeGivenException('No Loa can be found, at least one Loa should be found');
266
        }
267
268
        // The candidate LoA's are checked against the LoA resolution service. Any LoA that is not supported in the
269
        // platform is rejected and not considered an actual LoA.
270
        $actualLoas = new ArrayCollection();
271
        foreach ($loaCandidates as $loaDefinition) {
272
            $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...
273
            if ($loa) {
274
                $actualLoas->add($loa);
275
            }
276
        }
277
278
        if (!count($actualLoas)) {
279
            throw new LoaCannotBeGivenException(
280
                sprintf(
281
                    'Out of "%d" candidates, no existing Loa could be found, no authentication is possible.',
282
                    count($loaCandidates)
283
                )
284
            );
285
        }
286
287
        /** @var Loa $highestLoa */
288
        $highestLoa = $actualLoas->first();
289
        foreach ($actualLoas as $loa) {
290
            // if the current highest Loa cannot satisfy the next Loa, that must be of a higher level...
291
            if (!$highestLoa->canSatisfyLoa($loa)) {
292
                $highestLoa = $loa;
293
            }
294
        }
295
296
        $this->logger->info(
297
            sprintf('Out of %d candidate Loa\'s, Loa "%s" is the highest', count($loaCandidates), $highestLoa)
298
        );
299
300
        return $highestLoa;
301
    }
302
303
    /**
304
     * Test if the spConfiguredLoas has institution specific LoA configurations other than the
305
     * default LoA configuration.
306
     *
307
     * @param array $spConfiguredLoas
308
     *
309
     * @return bool
310
     */
311
    private function hasNonDefaultSpConfiguredLoas(array $spConfiguredLoas)
312
    {
313
        unset($spConfiguredLoas['__default__']);
314
        return (count($spConfiguredLoas) > 0);
315
    }
316
317
    /**
318
     * Returns whether the given Loa identifier identifies the minimum Loa, intrinsic to being authenticated via an IdP.
319
     *
320
     * @param Loa $loa
321
     * @return bool
322
     */
323
    public function isIntrinsicLoa(Loa $loa)
324
    {
325
        return $loa->levelIsLowerOrEqualTo(Loa::LOA_1);
326
    }
327
328
    /**
329
     * @param VerifyYubikeyOtpCommand $command
330
     * @return YubikeyOtpVerificationResult
331
     */
332
    public function verifyYubikeyOtp(VerifyYubikeyOtpCommand $command)
333
    {
334
        /** @var SecondFactor $secondFactor */
335
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($command->secondFactorId);
336
337
        $requester = new Requester();
338
        $requester->identity = $secondFactor->identityId;
339
        $requester->institution = $secondFactor->institution;
340
341
        $otp = new ApiOtp();
342
        $otp->value = $command->otp;
343
344
        $result = $this->yubikeyService->verify($otp, $requester);
345
346
        if (!$result->isSuccessful()) {
347
            return new YubikeyOtpVerificationResult(YubikeyOtpVerificationResult::RESULT_OTP_VERIFICATION_FAILED, null);
348
        }
349
350
        $otp = YubikeyOtp::fromString($command->otp);
351
        $publicId = YubikeyPublicId::fromOtp($otp);
352
353
        if (!$publicId->equals(new YubikeyPublicId($secondFactor->secondFactorIdentifier))) {
354
            return new YubikeyOtpVerificationResult(
355
                YubikeyOtpVerificationResult::RESULT_PUBLIC_ID_DID_NOT_MATCH,
356
                $publicId
357
            );
358
        }
359
360
        return new YubikeyOtpVerificationResult(YubikeyOtpVerificationResult::RESULT_PUBLIC_ID_MATCHED, $publicId);
361
    }
362
363
    /**
364
     * @param string $secondFactorId
365
     * @return string
366
     */
367
    public function getSecondFactorIdentifier($secondFactorId)
368
    {
369
        /** @var SecondFactor $secondFactor */
370
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($secondFactorId);
371
372
        return $secondFactor->secondFactorIdentifier;
373
    }
374
375
    /**
376
     * @return int
377
     */
378
    public function getSmsOtpRequestsRemainingCount()
379
    {
380
        return $this->smsService->getOtpRequestsRemainingCount();
381
    }
382
383
    /**
384
     * @return int
385
     */
386
    public function getSmsMaximumOtpRequestsCount()
387
    {
388
        return $this->smsService->getMaximumOtpRequestsCount();
389
    }
390
391
    /**
392
     * @param SendSmsChallengeCommand $command
393
     * @return bool
394
     */
395
    public function sendSmsChallenge(SendSmsChallengeCommand $command)
396
    {
397
        /** @var SecondFactor $secondFactor */
398
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($command->secondFactorId);
399
400
        $phoneNumber = InternationalPhoneNumber::fromStringFormat($secondFactor->secondFactorIdentifier);
401
402
        $stepupCommand = new StepupSendSmsChallengeCommand();
403
        $stepupCommand->phoneNumber = $phoneNumber;
404
        $stepupCommand->body = $this->translator->trans('gateway.second_factor.sms.challenge_body');
405
        $stepupCommand->identity = $secondFactor->identityId;
406
        $stepupCommand->institution = $secondFactor->institution;
407
408
        return $this->smsService->sendChallenge($stepupCommand);
409
    }
410
411
    /**
412
     * @param VerifyPossessionOfPhoneCommand $command
413
     * @return OtpVerification
414
     */
415
    public function verifySmsChallenge(VerifyPossessionOfPhoneCommand $command)
416
    {
417
        return $this->smsService->verifyPossession($command);
418
    }
419
420
    public function clearSmsVerificationState()
421
    {
422
        $this->smsService->clearSmsVerificationState();
423
    }
424
425
    /**
426
     * Get the schacHomeOrganisation of the authenticating user based on its vetted tokens.
427
     *
428
     * @param string $identityNameId Used to load vetted tokens
429
     * @return string either the SHO or an empty string
430
     */
431
    public function getUserShoByIdentityNameId($identityNameId)
432
    {
433
        return $this->secondFactorRepository->getInstitutionByNameId($identityNameId);
434
    }
435
436
    /**
437
     * In Stepup, the schacHomeOrganization must be in a lower case format. Please note that empty string values are
438
     * considered valid SHO in this method.
439
     *
440
     * @param string $schacHomeOrganization
441
     * @throws InvalidStepupShoFormatException Raised when the SHO is not compatible with the Stepup standard (lower
442
     *                                         cased string)
443
     */
444
    public function assertValidShoFormat($schacHomeOrganization)
445
    {
446
        if (!empty($schacHomeOrganization) && strtolower($schacHomeOrganization) !== $schacHomeOrganization) {
447
            throw new InvalidStepupShoFormatException(
448
                sprintf(
449
                    'Encountered an invalid schacHomeOrganization value "%s".',
450
                    $schacHomeOrganization
451
                )
452
            );
453
        }
454
    }
455
}
456