resolveHighestRequiredLoa()   F
last analyzed

Complexity

Conditions 18
Paths 268

Size

Total Lines 85
Code Lines 38

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 18
eloc 38
nc 268
nop 4
dl 0
loc 85
rs 3.1833
c 0
b 0
f 0

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 Doctrine\Common\Collections\Collection;
23
use Psr\Log\LoggerInterface;
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\StepupGateway\ApiBundle\Dto\Otp as ApiOtp;
33
use Surfnet\StepupGateway\ApiBundle\Dto\Requester;
34
use Surfnet\StepupGateway\ApiBundle\Dto\YubikeyOtpVerificationResult;
35
use Surfnet\StepupGateway\ApiBundle\Service\YubikeyServiceInterface;
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\LoaCannotBeGivenException;
42
use Surfnet\StepupGateway\GatewayBundle\Exception\UnknownInstitutionException;
43
use Symfony\Contracts\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
    /**
71
     * @var \Symfony\Contracts\Translation\TranslatorInterface
72
     */
73
    private $translator;
74
75
    /**
76
     * @var \Psr\Log\LoggerInterface
77
     */
78
    private $logger;
79
80
    /**
81
     * @var SecondFactorTypeService
82
     */
83
    private $secondFactorTypeService;
84
85
    /**
86
     * @param LoaResolutionService   $loaResolutionService
87
     * @param SecondFactorRepository $secondFactorRepository
88
     * @param YubikeyServiceInterface $yubikeyService
89
     * @param SmsSecondFactorService $smsService
90
     * @param TranslatorInterface    $translator
91
     * @param LoggerInterface        $logger
92
     * @param SecondFactorTypeService $secondFactorTypeService
93
     */
94
    public function __construct(
95
        LoaResolutionService $loaResolutionService,
96
        SecondFactorRepository $secondFactorRepository,
97
        YubikeyServiceInterface $yubikeyService,
98
        SmsSecondFactorService $smsService,
99
        TranslatorInterface $translator,
100
        LoggerInterface $logger,
101
        SecondFactorTypeService $secondFactorTypeService
102
    ) {
103
        $this->loaResolutionService = $loaResolutionService;
104
        $this->secondFactorRepository = $secondFactorRepository;
105
        $this->yubikeyService = $yubikeyService;
0 ignored issues
show
Documentation Bug introduced by
$yubikeyService is of type Surfnet\StepupGateway\Ap...YubikeyServiceInterface, but the property $yubikeyService was declared to be of type Surfnet\StepupGateway\Ap...\Service\YubikeyService. Are you sure that you always receive this specific sub-class here, or does it make sense to add an instanceof check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a given class or a super-class is assigned to a property that is type hinted more strictly.

Either this assignment is in error or an instanceof check should be added for that assignment.

class Alien {}

class Dalek extends Alien {}

class Plot
{
    /** @var  Dalek */
    public $villain;
}

$alien = new Alien();
$plot = new Plot();
if ($alien instanceof Dalek) {
    $plot->villain = $alien;
}
Loading history...
106
        $this->smsService = $smsService;
107
        $this->translator = $translator;
108
        $this->logger = $logger;
109
        $this->secondFactorTypeService = $secondFactorTypeService;
110
    }
111
112
    public function determineViableSecondFactors(
113
        string $identityNameId,
114
        Loa $requiredLoa,
115
        WhitelistService $whitelistService
116
    ): Collection {
117
118
        $candidateSecondFactors = $this->secondFactorRepository->getAllMatchingFor(
119
            $requiredLoa,
120
            $identityNameId,
121
            $this->secondFactorTypeService
122
        );
123
        $this->logger->info(
124
            sprintf('Loaded %d matching candidate second factors', count($candidateSecondFactors))
125
        );
126
127
        foreach ($candidateSecondFactors as $key => $secondFactor) {
128
            if (!$whitelistService->contains($secondFactor->institution)) {
129
                $this->logger->notice(
130
                    sprintf(
131
                        'Second factor "%s" is listed for institution "%s" which is not on the whitelist',
132
                        $secondFactor->secondFactorId,
133
                        $secondFactor->institution
134
                    )
135
                );
136
137
                $candidateSecondFactors->remove($key);
138
            }
139
        }
140
141
        if ($candidateSecondFactors->isEmpty()) {
142
            $this->logger->alert('No suitable candidate second factors found, sending Loa cannot be given response');
143
        }
144
145
        return $candidateSecondFactors;
146
    }
147
148
    /**
149
     * Retrieves the required LoA for the authenticating user
150
     *
151
     * @see StepUpAuthenticationServiceTest::test_resolve_highest_required_loa_conbinations
152
     *      The possible flows through the method are tested in this test case. Any additional possible outcomes should
153
     *      be covered in this test.
154
     *
155
     * @see https://github.com/OpenConext/Stepup-Deploy/wiki/Institution-Specific-LoA
156
     *      The flow of the LoA determination is described in detail in this document. The parameters of this method
157
     *      match the inputs described in the flow diagram.
158
     *
159
     * @param string $requestedLoa     <SP-LoA>   Optional. The value of the AuthnConextClassRef attribute in the
160
     *                                            AuthnRequest from the SP.
161
     *
162
     *                                            Example: 'https://example.com/authentication/loa1'
163
     *
164
     * @param array  $spConfiguredLoas <LoAs>     Optional. An associative array mapping schacHomeOrganization to LoA.
165
     *                                            This array is configured on the gateway for each SP. All keys in the
166
     *                                            spConfiguredLoas array should be normalized (lower cased).
167
     *
168
     *                                            Example:
169
     *                                            [
170
     *                                                '__default__'   => 'https://example.com/authentication/loa1',
171
     *                                                'institution_a' => 'https://example.com/authentication/loa2',
172
     *                                                'institution_b' => 'https://example.com/authentication/loa3',
173
     *                                            ]
174
     *
175
     * @param string $normalizedIdpSho            <IdP-SHO>  Optional. Value of the schacHomeOrganization attribute from the
176
     *                                            Assertion from the IdP. The SHO should be normalized (lower cased) as
177
     *                                            it will be used to be compared against the $spConfiguredLoas who have
178
     *                                            also been normalized.
179
     *
180
     *                                            Example: 'institution_a', ''
181
     *
182
     * @param string $normalizedUserSho           <User-SHO> Optional. The schacHomeOrganization that the user belongs to, this is
183
     *                                            the schacHomeOrganization that was provided during registration of the
184
     *                                            token. The SHO should be normalized (lower cased) as it will be used
185
     *                                            to be compared against the $spConfiguredLoas who have also been
186
     *                                            normalized.
187
     *
188
     *                                            Example: 'institution_b', ''
189
     *
190
     * @return Loa
191
     *
192
     * @throws UnknownInstitutionException        Raised when neither <User-SHO> or <IdP-SHO> is provided but <LoAs>
193
     *                                            are configured for institutions other than __default__.
194
     *
195
     * @throws InstitutionMismatchException       <User-SHO> or <IdP-SHO> are configured and <LoAs> are provided for an
196
     *                                            institution other than __default__ but the <User-SHO> and <IdP-SHO> do
197
     *                                            not match.
198
     *
199
     * @throws LoaCannotBeGivenException          Raised when no LoA candidates are found or when none of the candidate
200
     *                                            LoAs are valid (known to the application).
201
     *
202
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
203
     * @SuppressWarnings(PHPMD.NPathComplexity)
204
     */
205
    public function resolveHighestRequiredLoa(
206
        $requestedLoa,
207
        array $spConfiguredLoas,
208
        $normalizedIdpSho,
209
        $normalizedUserSho
210
    ) {
211
        // Candidate LoA's are stored in a collection. At the end of this procedure, the highest LoA is selected from
212
        // this collection.
213
        $loaCandidates = new ArrayCollection();
214
215
        // Add the default LoA as configured for all SP's to the LoA candidates collection.
216
        if (array_key_exists('__default__', $spConfiguredLoas) &&
217
            !$loaCandidates->contains($spConfiguredLoas['__default__'])
218
        ) {
219
            $loaCandidates->add($spConfiguredLoas['__default__']);
220
            $this->logger->info(sprintf('Added SP\'s default Loa "%s" as candidate', $spConfiguredLoas['__default__']));
221
        }
222
223
        // If AuthnContextClassRef was present in AuthnRequest, add the SP requested LoA to the candidates collection.
224
        if ($requestedLoa) {
225
            $loaCandidates->add($requestedLoa);
226
            $this->logger->info(sprintf('Added requested Loa "%s" as candidate', $requestedLoa));
227
        }
228
229
        if ($this->hasNonDefaultSpConfiguredLoas($spConfiguredLoas)) {
230
            // We need an userSho or idpSho to determine if any of the <LoAs> are applicable
231
            if (empty($normalizedUserSho) && empty($normalizedIdpSho)) {
232
                throw new UnknownInstitutionException('Unable to determine the institution for authenticating user.');
233
            }
234
235
            // If both user and IdP SHO are known, they should match
236
            if (!empty($normalizedUserSho) && !empty($normalizedIdpSho) && $normalizedUserSho != $normalizedIdpSho) {
237
                throw new InstitutionMismatchException('User and IdP SHO are set but do not match.');
238
            }
239
240
            // If the user SHO is available in the <LoAs>, add to the candidates collection.
241
            if (isset($spConfiguredLoas[$normalizedUserSho])) {
242
                $this->logger->info(sprintf('Added Loa "%s" as candidate based on user SHO', $requestedLoa));
243
                $loaCandidates->add($spConfiguredLoas[$normalizedUserSho]);
244
            }
245
246
            // If the IdP SHO is available in the <LoAs>, add the to the candidates collection.
247
            if (isset($spConfiguredLoas[$normalizedIdpSho])) {
248
                $this->logger->info(sprintf('Added Loa "%s" as candidate based on IdP SHO', $requestedLoa));
249
                $loaCandidates->add($spConfiguredLoas[$normalizedIdpSho]);
250
            }
251
        }
252
253
        if (!count($loaCandidates)) {
254
            throw new LoaCannotBeGivenException('No Loa can be found, at least one Loa should be found');
255
        }
256
257
        // The candidate LoA's are checked against the LoA resolution service. Any LoA that is not supported in the
258
        // platform is rejected and not considered an actual LoA.
259
        $actualLoas = new ArrayCollection();
260
        foreach ($loaCandidates as $loaDefinition) {
261
            $loa = $this->loaResolutionService->getLoa($loaDefinition);
262
            if ($loa) {
263
                $actualLoas->add($loa);
264
            }
265
        }
266
267
        if (!count($actualLoas)) {
268
            throw new LoaCannotBeGivenException(
269
                sprintf(
270
                    'Out of "%d" candidates, no existing Loa could be found, no authentication is possible.',
271
                    count($loaCandidates)
272
                )
273
            );
274
        }
275
276
        /** @var Loa $highestLoa */
277
        $highestLoa = $actualLoas->first();
278
        foreach ($actualLoas as $loa) {
279
            // if the current highest Loa cannot satisfy the next Loa, that must be of a higher level...
280
            if (!$highestLoa->canSatisfyLoa($loa)) {
281
                $highestLoa = $loa;
282
            }
283
        }
284
285
        $this->logger->info(
286
            sprintf('Out of %d candidate Loa\'s, Loa "%s" is the highest', count($loaCandidates), $highestLoa)
287
        );
288
289
        return $highestLoa;
290
    }
291
292
    /**
293
     * Test if the spConfiguredLoas has institution specific LoA configurations other than the
294
     * default LoA configuration.
295
     *
296
     * @param array $spConfiguredLoas
297
     *
298
     * @return bool
299
     */
300
    private function hasNonDefaultSpConfiguredLoas(array $spConfiguredLoas)
301
    {
302
        unset($spConfiguredLoas['__default__']);
303
        return (count($spConfiguredLoas) > 0);
304
    }
305
306
    /**
307
     * Returns whether the given Loa identifier identifies the minimum Loa, intrinsic to being authenticated via an IdP.
308
     *
309
     * @param Loa $loa
310
     * @return bool
311
     */
312
    public function isIntrinsicLoa(Loa $loa)
313
    {
314
        return $loa->levelIsLowerOrEqualTo(Loa::LOA_1);
315
    }
316
317
    /**
318
     * @param VerifyYubikeyOtpCommand $command
319
     * @return YubikeyOtpVerificationResult
320
     */
321
    public function verifyYubikeyOtp(VerifyYubikeyOtpCommand $command)
322
    {
323
        /** @var SecondFactor $secondFactor */
324
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($command->secondFactorId);
325
326
        $requester = new Requester();
327
        $requester->identity = $secondFactor->identityId;
328
        $requester->institution = $secondFactor->institution;
329
330
        $otp = new ApiOtp();
331
        $otp->value = $command->otp;
332
333
        $result = $this->yubikeyService->verifyOtp($otp, $requester);
334
335
        if (!$result->isSuccessful()) {
336
            return new YubikeyOtpVerificationResult(YubikeyOtpVerificationResult::RESULT_OTP_VERIFICATION_FAILED, null);
337
        }
338
339
        return $this->yubikeyService->verifyPublicId($otp, $secondFactor->secondFactorIdentifier);
340
    }
341
342
    /**
343
     * @param string $secondFactorId
344
     * @return string
345
     */
346
    public function getSecondFactorIdentifier($secondFactorId)
347
    {
348
        /** @var SecondFactor $secondFactor */
349
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($secondFactorId);
350
351
        return $secondFactor->secondFactorIdentifier;
352
    }
353
354
    /**
355
     * @return int
356
     */
357
    public function getSmsOtpRequestsRemainingCount(string $secondFactorId)
358
    {
359
        return $this->smsService->getOtpRequestsRemainingCount($secondFactorId);
360
    }
361
362
    /**
363
     * @return int
364
     */
365
    public function getSmsMaximumOtpRequestsCount()
366
    {
367
        return $this->smsService->getMaximumOtpRequestsCount();
368
    }
369
370
    /**
371
     * @param SendSmsChallengeCommand $command
372
     * @return bool
373
     */
374
    public function sendSmsChallenge(SendSmsChallengeCommand $command)
375
    {
376
        /** @var SecondFactor $secondFactor */
377
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($command->secondFactorId);
378
379
        $phoneNumber = InternationalPhoneNumber::fromStringFormat($secondFactor->secondFactorIdentifier);
380
381
        $stepupCommand = new StepupSendSmsChallengeCommand();
382
        $stepupCommand->phoneNumber = $phoneNumber;
383
        $stepupCommand->secondFactorId = $secondFactor->secondFactorId;
384
        $stepupCommand->body = $this->translator->trans('gateway.second_factor.sms.challenge_body');
385
        $stepupCommand->identity = $secondFactor->identityId;
386
        $stepupCommand->institution = $secondFactor->institution;
387
388
        return $this->smsService->sendChallenge($stepupCommand);
389
    }
390
391
    /**
392
     * @param VerifyPossessionOfPhoneCommand $command
393
     * @return OtpVerification
394
     */
395
    public function verifySmsChallenge(VerifyPossessionOfPhoneCommand $command)
396
    {
397
        return $this->smsService->verifyPossession($command);
398
    }
399
400
    public function clearSmsVerificationState(string $secondFactorId): void
401
    {
402
        $this->smsService->clearSmsVerificationState($secondFactorId);
403
    }
404
405
    /**
406
     * Return the lower-cased schacHomeOrganization of the user based on his vetted tokens.
407
     *
408
     * Comparisons on SHO values should always be case insensitive. Stepup
409
     * configuration always contains SHO values lower-cased, so this getter
410
     * can be used to compare the SHO with configured values.
411
     *
412
     * @see StepUpAuthenticationService::resolveHighestRequiredLoa()
413
     *
414
     * @param string $identityNameId Used to load vetted tokens
415
     * @return string either the SHO or an empty string
416
     */
417
    public function getNormalizedUserShoByIdentityNameId($identityNameId)
418
    {
419
        return strtolower(
420
            $this->secondFactorRepository->getInstitutionByNameId($identityNameId)
421
        );
422
    }
423
}
424