Completed
Push — schachomeorg-case-insensitive-... ( bff6c9 )
by
unknown
02:15
created

StepUpAuthenticationService::sendSmsChallenge()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 15
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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