Completed
Push — feature/dont-clear-state-on-sa... ( 57a027...b19a4c )
by
unknown
05:27 queued 03:21
created

hasNonDefaultSpConfiguredLoas()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 5
rs 9.4285
cc 1
eloc 3
nc 1
nop 1
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
    /**
73
     * @var \Symfony\Component\Translation\TranslatorInterface
74
     */
75
    private $translator;
76
77
    /**
78
     * @var \Psr\Log\LoggerInterface
79
     */
80
    private $logger;
81
82
    /**
83
     * @var SecondFactorTypeService
84
     */
85
    private $secondFactorTypeService;
86
87
    /**
88
     * @param LoaResolutionService   $loaResolutionService
89
     * @param SecondFactorRepository $secondFactorRepository
90
     * @param YubikeyService         $yubikeyService
91
     * @param SmsSecondFactorService $smsService
92
     * @param TranslatorInterface    $translator
93
     * @param LoggerInterface        $logger
94
     * @param SecondFactorTypeService $secondFactorTypeService
95
     */
96
    public function __construct(
97
        LoaResolutionService $loaResolutionService,
98
        SecondFactorRepository $secondFactorRepository,
99
        YubikeyService $yubikeyService,
100
        SmsSecondFactorService $smsService,
101
        TranslatorInterface $translator,
102
        LoggerInterface $logger,
103
        SecondFactorTypeService $secondFactorTypeService
104
    ) {
105
        $this->loaResolutionService = $loaResolutionService;
106
        $this->secondFactorRepository = $secondFactorRepository;
107
        $this->yubikeyService = $yubikeyService;
108
        $this->smsService = $smsService;
109
        $this->translator = $translator;
110
        $this->logger = $logger;
111
        $this->secondFactorTypeService = $secondFactorTypeService;
112
    }
113
114
    /**
115
     * @param string $identityNameId
116
     * @param Loa $requiredLoa
117
     * @param WhitelistService $whitelistService
118
     * @return \Doctrine\Common\Collections\Collection
119
     */
120
    public function determineViableSecondFactors(
121
        $identityNameId,
122
        Loa $requiredLoa,
123
        WhitelistService $whitelistService
124
    ) {
125
126
        $candidateSecondFactors = $this->secondFactorRepository->getAllMatchingFor(
127
            $requiredLoa,
128
            $identityNameId,
129
            $this->secondFactorTypeService
130
        );
131
        $this->logger->info(
132
            sprintf('Loaded %d matching candidate second factors', count($candidateSecondFactors))
133
        );
134
135
        foreach ($candidateSecondFactors as $key => $secondFactor) {
136
            if (!$whitelistService->contains($secondFactor->institution)) {
137
                $this->logger->notice(
138
                    sprintf(
139
                        'Second factor "%s" is listed for institution "%s" which is not on the whitelist',
140
                        $secondFactor->secondFactorId,
141
                        $secondFactor->institution
142
                    )
143
                );
144
145
                $candidateSecondFactors->remove($key);
146
            }
147
        }
148
149
        if ($candidateSecondFactors->isEmpty()) {
150
            $this->logger->alert('No suitable candidate second factors found, sending Loa cannot be given response');
151
        }
152
153
        return $candidateSecondFactors;
154
    }
155
156
    /**
157
     * Retrieves the required LoA for the authenticating user
158
     *
159
     * @see StepUpAuthenticationServiceTest::test_resolve_highest_required_loa_conbinations
160
     *      The possible flows through the method are tested in this test case. Any additional possible outcomes should
161
     *      be covered in this test.
162
     *
163
     * @see https://github.com/OpenConext/Stepup-Deploy/wiki/Institution-Specific-LoA
164
     *      The flow of the LoA determination is described in detail in this document. The parameters of this method
165
     *      match the inputs described in the flow diagram.
166
     *
167
     * @param string $requestedLoa     <SP-LoA>   Optional. The value of the AuthnConextClassRef attribute in the
168
     *                                            AuthnRequest from the SP.
169
     *
170
     *                                            Example: 'https://example.com/authentication/loa1'
171
     *
172
     * @param array  $spConfiguredLoas <LoAs>     Optional. An associative array mapping schacHomeOrganization to LoA.
173
     *                                            This array is configured on the gateway for each SP. All keys in the
174
     *                                            spConfiguredLoas array should be normalized (lower cased).
175
     *
176
     *                                            Example:
177
     *                                            [
178
     *                                                '__default__'   => 'https://example.com/authentication/loa1',
179
     *                                                'institution_a' => 'https://example.com/authentication/loa2',
180
     *                                                'institution_b' => 'https://example.com/authentication/loa3',
181
     *                                            ]
182
     *
183
     * @param string $normalizedIdpSho            <IdP-SHO>  Optional. Value of the schacHomeOrganization attribute from the
0 ignored issues
show
Coding Style introduced by
This line exceeds maximum limit of 120 characters; contains 124 characters

Overly long lines are hard to read on any screen. Most code styles therefor impose a maximum limit on the number of characters in a line.

Loading history...
184
     *                                            Assertion from the IdP. The SHO should be normalized (lower cased) as
185
     *                                            it will be used to be compared against the $spConfiguredLoas who have
186
     *                                            also been normalized.
187
     *
188
     *                                            Example: 'institution_a', ''
189
     *
190
     * @param string $normalizedUserSho           <User-SHO> Optional. The schacHomeOrganization that the user belongs to, this is
0 ignored issues
show
Coding Style introduced by
This line exceeds maximum limit of 120 characters; contains 130 characters

Overly long lines are hard to read on any screen. Most code styles therefor impose a maximum limit on the number of characters in a line.

Loading history...
191
     *                                            the schacHomeOrganization that was provided during registration of the
192
     *                                            token. The SHO should be normalized (lower cased) as it will be used
193
     *                                            to be compared against the $spConfiguredLoas who have also been
194
     *                                            normalized.
195
     *
196
     *                                            Example: 'institution_b', ''
197
     *
198
     * @return Loa
199
     *
200
     * @throws UnknownInstitutionException        Raised when neither <User-SHO> or <IdP-SHO> is provided but <LoAs>
201
     *                                            are configured for institutions other than __default__.
202
     *
203
     * @throws InstitutionMismatchException       <User-SHO> or <IdP-SHO> are configured and <LoAs> are provided for an
204
     *                                            institution other than __default__ but the <User-SHO> and <IdP-SHO> do
205
     *                                            not match.
206
     *
207
     * @throws LoaCannotBeGivenException          Raised when no LoA candidates are found or when none of the candidate
208
     *                                            LoAs are valid (known to the application).
209
     *
210
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
211
     * @SuppressWarnings(PHPMD.NPathComplexity)
212
     */
213
    public function resolveHighestRequiredLoa(
214
        $requestedLoa,
215
        array $spConfiguredLoas,
216
        $normalizedIdpSho,
217
        $normalizedUserSho
218
    ) {
219
        // Candidate LoA's are stored in a collection. At the end of this procedure, the highest LoA is selected from
220
        // this collection.
221
        $loaCandidates = new ArrayCollection();
222
223
        // Add the default LoA as configured for all SP's to the LoA candidates collection.
224
        if (array_key_exists('__default__', $spConfiguredLoas) &&
225
            !$loaCandidates->contains($spConfiguredLoas['__default__'])
226
        ) {
227
            $loaCandidates->add($spConfiguredLoas['__default__']);
228
            $this->logger->info(sprintf('Added SP\'s default Loa "%s" as candidate', $spConfiguredLoas['__default__']));
229
        }
230
231
        // If AuthnContextClassRef was present in AuthnRequest, add the SP requested LoA to the candidates collection.
232
        if ($requestedLoa) {
233
            $loaCandidates->add($requestedLoa);
234
            $this->logger->info(sprintf('Added requested Loa "%s" as candidate', $requestedLoa));
235
        }
236
237
        if ($this->hasNonDefaultSpConfiguredLoas($spConfiguredLoas)) {
238
            // We need an userSho or idpSho to determine if any of the <LoAs> are applicable
239
            if (empty($normalizedUserSho) && empty($normalizedIdpSho)) {
240
                throw new UnknownInstitutionException('Unable to determine the institution for authenticating user.');
241
            }
242
243
            // If both user and IdP SHO are known, they should match
244
            if (!empty($normalizedUserSho) && !empty($normalizedIdpSho) && $normalizedUserSho != $normalizedIdpSho) {
245
                throw new InstitutionMismatchException('User and IdP SHO are set but do not match.');
246
            }
247
248
            // If the user SHO is available in the <LoAs>, add to the candidates collection.
249 View Code Duplication
            if (isset($spConfiguredLoas[$normalizedUserSho])) {
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...
250
                $this->logger->info(sprintf('Added Loa "%s" as candidate based on user SHO', $requestedLoa));
251
                $loaCandidates->add($spConfiguredLoas[$normalizedUserSho]);
252
            }
253
254
            // If the IdP SHO is available in the <LoAs>, add the to the candidates collection.
255 View Code Duplication
            if (isset($spConfiguredLoas[$normalizedIdpSho])) {
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...
256
                $this->logger->info(sprintf('Added Loa "%s" as candidate based on IdP SHO', $requestedLoa));
257
                $loaCandidates->add($spConfiguredLoas[$normalizedIdpSho]);
258
            }
259
        }
260
261
        if (!count($loaCandidates)) {
262
            throw new LoaCannotBeGivenException('No Loa can be found, at least one Loa should be found');
263
        }
264
265
        // The candidate LoA's are checked against the LoA resolution service. Any LoA that is not supported in the
266
        // platform is rejected and not considered an actual LoA.
267
        $actualLoas = new ArrayCollection();
268
        foreach ($loaCandidates as $loaDefinition) {
269
            $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...
270
            if ($loa) {
271
                $actualLoas->add($loa);
272
            }
273
        }
274
275
        if (!count($actualLoas)) {
276
            throw new LoaCannotBeGivenException(
277
                sprintf(
278
                    'Out of "%d" candidates, no existing Loa could be found, no authentication is possible.',
279
                    count($loaCandidates)
280
                )
281
            );
282
        }
283
284
        /** @var Loa $highestLoa */
285
        $highestLoa = $actualLoas->first();
286
        foreach ($actualLoas as $loa) {
287
            // if the current highest Loa cannot satisfy the next Loa, that must be of a higher level...
288
            if (!$highestLoa->canSatisfyLoa($loa)) {
289
                $highestLoa = $loa;
290
            }
291
        }
292
293
        $this->logger->info(
294
            sprintf('Out of %d candidate Loa\'s, Loa "%s" is the highest', count($loaCandidates), $highestLoa)
295
        );
296
297
        return $highestLoa;
298
    }
299
300
    /**
301
     * Test if the spConfiguredLoas has institution specific LoA configurations other than the
302
     * default LoA configuration.
303
     *
304
     * @param array $spConfiguredLoas
305
     *
306
     * @return bool
307
     */
308
    private function hasNonDefaultSpConfiguredLoas(array $spConfiguredLoas)
309
    {
310
        unset($spConfiguredLoas['__default__']);
311
        return (count($spConfiguredLoas) > 0);
312
    }
313
314
    /**
315
     * Returns whether the given Loa identifier identifies the minimum Loa, intrinsic to being authenticated via an IdP.
316
     *
317
     * @param Loa $loa
318
     * @return bool
319
     */
320
    public function isIntrinsicLoa(Loa $loa)
321
    {
322
        return $loa->levelIsLowerOrEqualTo(Loa::LOA_1);
323
    }
324
325
    /**
326
     * @param VerifyYubikeyOtpCommand $command
327
     * @return YubikeyOtpVerificationResult
328
     */
329
    public function verifyYubikeyOtp(VerifyYubikeyOtpCommand $command)
330
    {
331
        /** @var SecondFactor $secondFactor */
332
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($command->secondFactorId);
333
334
        $requester = new Requester();
335
        $requester->identity = $secondFactor->identityId;
336
        $requester->institution = $secondFactor->institution;
337
338
        $otp = new ApiOtp();
339
        $otp->value = $command->otp;
340
341
        $result = $this->yubikeyService->verify($otp, $requester);
342
343
        if (!$result->isSuccessful()) {
344
            return new YubikeyOtpVerificationResult(YubikeyOtpVerificationResult::RESULT_OTP_VERIFICATION_FAILED, null);
345
        }
346
347
        $otp = YubikeyOtp::fromString($command->otp);
348
        $publicId = YubikeyPublicId::fromOtp($otp);
349
350
        if (!$publicId->equals(new YubikeyPublicId($secondFactor->secondFactorIdentifier))) {
351
            return new YubikeyOtpVerificationResult(
352
                YubikeyOtpVerificationResult::RESULT_PUBLIC_ID_DID_NOT_MATCH,
353
                $publicId
354
            );
355
        }
356
357
        return new YubikeyOtpVerificationResult(YubikeyOtpVerificationResult::RESULT_PUBLIC_ID_MATCHED, $publicId);
358
    }
359
360
    /**
361
     * @param string $secondFactorId
362
     * @return string
363
     */
364
    public function getSecondFactorIdentifier($secondFactorId)
365
    {
366
        /** @var SecondFactor $secondFactor */
367
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($secondFactorId);
368
369
        return $secondFactor->secondFactorIdentifier;
370
    }
371
372
    /**
373
     * @return int
374
     */
375
    public function getSmsOtpRequestsRemainingCount()
376
    {
377
        return $this->smsService->getOtpRequestsRemainingCount();
378
    }
379
380
    /**
381
     * @return int
382
     */
383
    public function getSmsMaximumOtpRequestsCount()
384
    {
385
        return $this->smsService->getMaximumOtpRequestsCount();
386
    }
387
388
    /**
389
     * @param SendSmsChallengeCommand $command
390
     * @return bool
391
     */
392
    public function sendSmsChallenge(SendSmsChallengeCommand $command)
393
    {
394
        /** @var SecondFactor $secondFactor */
395
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($command->secondFactorId);
396
397
        $phoneNumber = InternationalPhoneNumber::fromStringFormat($secondFactor->secondFactorIdentifier);
398
399
        $stepupCommand = new StepupSendSmsChallengeCommand();
400
        $stepupCommand->phoneNumber = $phoneNumber;
401
        $stepupCommand->body = $this->translator->trans('gateway.second_factor.sms.challenge_body');
402
        $stepupCommand->identity = $secondFactor->identityId;
403
        $stepupCommand->institution = $secondFactor->institution;
404
405
        return $this->smsService->sendChallenge($stepupCommand);
406
    }
407
408
    /**
409
     * @param VerifyPossessionOfPhoneCommand $command
410
     * @return OtpVerification
411
     */
412
    public function verifySmsChallenge(VerifyPossessionOfPhoneCommand $command)
413
    {
414
        return $this->smsService->verifyPossession($command);
415
    }
416
417
    public function clearSmsVerificationState()
418
    {
419
        $this->smsService->clearSmsVerificationState();
420
    }
421
422
    /**
423
     * Return the lower-cased schacHomeOrganization of the user based on his vetted tokens.
424
     *
425
     * Comparisons on SHO values should always be case insensitive. Stepup
426
     * configuration always contains SHO values lower-cased, so this getter
427
     * can be used to compare the SHO with configured values.
428
     *
429
     * @see StepUpAuthenticationService::resolveHighestRequiredLoa()
430
     *
431
     * @param string $identityNameId Used to load vetted tokens
432
     * @return string either the SHO or an empty string
433
     */
434
    public function getNormalizedUserShoByIdentityNameId($identityNameId)
435
    {
436
        return strtolower(
437
            $this->secondFactorRepository->getInstitutionByNameId($identityNameId)
438
        );
439
    }
440
}
441