Completed
Push — feature/implement-is-gssf-depr... ( 002be9...292db8 )
by
unknown
13:05
created

StepUpAuthenticationService::verifyYubikeyOtp()   B

Complexity

Conditions 3
Paths 3

Size

Total Lines 30
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 30
rs 8.8571
cc 3
eloc 17
nc 3
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\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\LoaCannotBeGivenException;
42
use Surfnet\StepupGateway\GatewayBundle\Exception\RuntimeException;
43
use Surfnet\StepupGateway\GatewayBundle\Service\StepUp\YubikeyOtpVerificationResult;
44
use Symfony\Component\Translation\TranslatorInterface;
45
46
/**
47
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
48
 */
49
class StepUpAuthenticationService
50
{
51
    /**
52
     * @var \Surfnet\StepupBundle\Service\LoaResolutionService
53
     */
54
    private $loaResolutionService;
55
56
    /**
57
     * @var \Surfnet\StepupGateway\GatewayBundle\Entity\SecondFactorRepository
58
     */
59
    private $secondFactorRepository;
60
61
    /**
62
     * @var \Surfnet\StepupGateway\ApiBundle\Service\YubikeyService
63
     */
64
    private $yubikeyService;
65
66
    /**
67
     * @var \Surfnet\StepupBundle\Service\SmsSecondFactorService
68
     */
69
    private $smsService;
70
71
    /** @var InstitutionMatchingHelper */
72
    private $institutionMatchingHelper;
73
74
    /**
75
     * @var \Symfony\Component\Translation\TranslatorInterface
76
     */
77
    private $translator;
78
79
    /**
80
     * @var \Psr\Log\LoggerInterface
81
     */
82
    private $logger;
83
84
    /**
85
     * @var SecondFactorTypeService
86
     */
87
    private $secondFactorTypeService;
88
89
    /**
90
     * @param LoaResolutionService   $loaResolutionService
91
     * @param SecondFactorRepository $secondFactorRepository
92
     * @param YubikeyService         $yubikeyService
93
     * @param SmsSecondFactorService $smsService
94
     * @param InstitutionMatchingHelper $institutionMatchingHelper
95
     * @param TranslatorInterface    $translator
96
     * @param LoggerInterface        $logger
97
     * @param SecondFactorTypeService $secondFactorTypeService
98
     */
99
    public function __construct(
100
        LoaResolutionService $loaResolutionService,
101
        SecondFactorRepository $secondFactorRepository,
102
        YubikeyService $yubikeyService,
103
        SmsSecondFactorService $smsService,
104
        InstitutionMatchingHelper $institutionMatchingHelper,
105
        TranslatorInterface $translator,
106
        LoggerInterface $logger,
107
        SecondFactorTypeService $secondFactorTypeService
108
    ) {
109
        $this->loaResolutionService = $loaResolutionService;
110
        $this->secondFactorRepository = $secondFactorRepository;
111
        $this->yubikeyService = $yubikeyService;
112
        $this->smsService = $smsService;
113
        $this->institutionMatchingHelper = $institutionMatchingHelper;
114
        $this->translator = $translator;
115
        $this->logger = $logger;
116
        $this->secondFactorTypeService = $secondFactorTypeService;
117
    }
118
119
    /**
120
     * @param string $identityNameId
121
     * @param Loa $requiredLoa
122
     * @param WhitelistService $whitelistService
123
     * @return \Doctrine\Common\Collections\Collection
124
     */
125
    public function determineViableSecondFactors(
126
        $identityNameId,
127
        Loa $requiredLoa,
128
        WhitelistService $whitelistService
129
    ) {
130
131
        $candidateSecondFactors = $this->secondFactorRepository->getAllMatchingFor(
132
            $requiredLoa,
133
            $identityNameId,
134
            $this->secondFactorTypeService
135
        );
136
        $this->logger->info(
137
            sprintf('Loaded %d matching candidate second factors', count($candidateSecondFactors))
138
        );
139
140
        foreach ($candidateSecondFactors as $key => $secondFactor) {
141
            if (!$whitelistService->contains($secondFactor->institution)) {
142
                $this->logger->notice(
143
                    sprintf(
144
                        'Second factor "%s" is listed for institution "%s" which is not on the whitelist',
145
                        $secondFactor->secondFactorId,
146
                        $secondFactor->institution
147
                    )
148
                );
149
150
                $candidateSecondFactors->remove($key);
151
            }
152
        }
153
154
        if ($candidateSecondFactors->isEmpty()) {
155
            $this->logger->alert('No suitable candidate second factors found, sending Loa cannot be given response');
156
        }
157
158
        return $candidateSecondFactors;
159
    }
160
161
    /**
162
     * Retrieves the required LoA for the authenticating user
163
     *
164
     * The required LoA is based on several variables. These are:
165
     *
166
     *  1. SP Requested LoA.
167
     *  2. The optional SP/institution specific LoA configuration
168
     *  3. The identity of the authenticating user (used to test if the user can provide a token for the institution
169
     *     he is authenticating for). Only used when the SP/institution specific LoA configuration is in play.
170
     *  4. The institution of the authenticating user, based on the schacHomeOrganization of the user. This is used
171
     *     to validate the registered tokens are actually vetted by the correct institution. Only used when the
172
     *     SP/institution specific LoA configuration is in play.
173
     *
174
     * These four variables determine the required LoA for the authenticating user. The possible outcomes are covered
175
     * by unit tests. These tests can be found in the Test folder of this bundle.
176
     *
177
     * @see: StepUpAuthenticationServiceTest::test_resolve_highest_required_loa_conbinations
178
     *
179
     * @param string $requestedLoa The SP requested LoA
180
     * @param $identityNameId
181
     * @param string $identityInstitution
182
     * @param ServiceProvider $serviceProvider
183
     * @return null|Loa
184
     * @SuppressWarnings(PHPMD.CyclomaticComplexity) see https://www.pivotaltracker.com/story/show/96065350
185
     * @SuppressWarnings(PHPMD.NPathComplexity)      see https://www.pivotaltracker.com/story/show/96065350
186
     */
187
    public function resolveHighestRequiredLoa(
188
        $requestedLoa,
189
        $identityNameId,
190
        $identityInstitution,
191
        ServiceProvider $serviceProvider
192
    ) {
193
        $loaCandidates = new ArrayCollection();
194
195
        if ($requestedLoa) {
196
            $loaCandidates->add($requestedLoa);
197
            $this->logger->info(sprintf('Added requested Loa "%s" as candidate', $requestedLoa));
198
        }
199
200
        // Load the SP/institution specific LoA configuration
201
        $spConfiguredLoas = $serviceProvider->get('configuredLoas');
202
203
        if (array_key_exists('__default__', $spConfiguredLoas) &&
204
            !$loaCandidates->contains($spConfiguredLoas['__default__'])
205
        ) {
206
            $loaCandidates->add($spConfiguredLoas['__default__']);
207
            $this->logger->info(sprintf('Added SP\'s default Loa "%s" as candidate', $spConfiguredLoas['__default__']));
208
        }
209
210
        if (count($spConfiguredLoas) > 1 && is_null($identityInstitution)) {
211
            throw new RuntimeException(
212
                'SP configured LOA\'s are applicable but the authenticating user has no ' .
213
                'schacHomeOrganization in the assertion.'
214
            );
215
        }
216
217
        // Load the authenticating users institutions based on its vetted tokens.
218
        $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...
219
        // But only do so if there are SP/institution specific LoA restrictions
220
        if (!$this->hasDefaultSpConfig($spConfiguredLoas)) {
221
            $institutionsBasedOnVettedTokens = $this->determineInstitutionsByIdentityNameId(
222
                $identityNameId,
223
                $identityInstitution,
224
                $spConfiguredLoas
225
            );
226
        }
227
228
        $this->logger->info(sprintf('Loaded institution(s) for "%s"', $identityNameId));
229
230
        // Match the users institutions LoA's against the SP configured institutions
231
        $matchingInstitutions = $this->institutionMatchingHelper->findMatches(
232
            array_keys($spConfiguredLoas),
233
            $institutionsBasedOnVettedTokens
234
        );
235
236
        if (count($matchingInstitutions) > 0) {
237
            $this->logger->info('Found matching SP configured LoA\'s');
238
            foreach ($matchingInstitutions as $matchingInstitution) {
239
                $loaCandidates->add($spConfiguredLoas[$matchingInstitution]);
240
                $this->logger->info(sprintf(
241
                    'Added SP\'s Loa "%s" as candidate',
242
                    $spConfiguredLoas[$matchingInstitution]
243
                ));
244
            }
245
        }
246
247
        if (!count($loaCandidates)) {
248
            throw new RuntimeException('No Loa can be found, at least one Loa (SP default) should be found');
249
        }
250
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
            $this->logger->info(sprintf(
261
                'Out of "%d" candidates, no existing Loa could be found, no authentication is possible.',
262
                count($loaCandidates)
263
            ));
264
265
            return null;
266
        }
267
268
        /** @var \Surfnet\StepupBundle\Value\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
     * Returns whether the given Loa identifier identifies the minimum Loa, intrinsic to being authenticated via an IdP.
286
     *
287
     * @param Loa $loa
288
     * @return bool
289
     */
290
    public function isIntrinsicLoa(Loa $loa)
291
    {
292
        return $loa->levelIsLowerOrEqualTo(Loa::LOA_1);
293
    }
294
295
    /**
296
     * @param VerifyYubikeyOtpCommand $command
297
     * @return YubikeyOtpVerificationResult
298
     */
299
    public function verifyYubikeyOtp(VerifyYubikeyOtpCommand $command)
300
    {
301
        /** @var SecondFactor $secondFactor */
302
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($command->secondFactorId);
303
304
        $requester = new Requester();
305
        $requester->identity = $secondFactor->identityId;
306
        $requester->institution = $secondFactor->institution;
307
308
        $otp = new ApiOtp();
309
        $otp->value = $command->otp;
310
311
        $result = $this->yubikeyService->verify($otp, $requester);
312
313
        if (!$result->isSuccessful()) {
314
            return new YubikeyOtpVerificationResult(YubikeyOtpVerificationResult::RESULT_OTP_VERIFICATION_FAILED, null);
315
        }
316
317
        $otp = YubikeyOtp::fromString($command->otp);
318
        $publicId = YubikeyPublicId::fromOtp($otp);
319
320
        if (!$publicId->equals(new YubikeyPublicId($secondFactor->secondFactorIdentifier))) {
321
            return new YubikeyOtpVerificationResult(
322
                YubikeyOtpVerificationResult::RESULT_PUBLIC_ID_DID_NOT_MATCH,
323
                $publicId
324
            );
325
        }
326
327
        return new YubikeyOtpVerificationResult(YubikeyOtpVerificationResult::RESULT_PUBLIC_ID_MATCHED, $publicId);
328
    }
329
330
    /**
331
     * @param string $secondFactorId
332
     * @return string
333
     */
334
    public function getSecondFactorIdentifier($secondFactorId)
335
    {
336
        /** @var SecondFactor $secondFactor */
337
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($secondFactorId);
338
339
        return $secondFactor->secondFactorIdentifier;
340
    }
341
342
    /**
343
     * @return int
344
     */
345
    public function getSmsOtpRequestsRemainingCount()
346
    {
347
        return $this->smsService->getOtpRequestsRemainingCount();
348
    }
349
350
    /**
351
     * @return int
352
     */
353
    public function getSmsMaximumOtpRequestsCount()
354
    {
355
        return $this->smsService->getMaximumOtpRequestsCount();
356
    }
357
358
    /**
359
     * @param SendSmsChallengeCommand $command
360
     * @return bool
361
     */
362
    public function sendSmsChallenge(SendSmsChallengeCommand $command)
363
    {
364
        /** @var SecondFactor $secondFactor */
365
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($command->secondFactorId);
366
367
        $phoneNumber = InternationalPhoneNumber::fromStringFormat($secondFactor->secondFactorIdentifier);
368
369
        $stepupCommand = new StepupSendSmsChallengeCommand();
370
        $stepupCommand->phoneNumber = $phoneNumber;
371
        $stepupCommand->body = $this->translator->trans('gateway.second_factor.sms.challenge_body');
372
        $stepupCommand->identity = $secondFactor->identityId;
373
        $stepupCommand->institution = $secondFactor->institution;
374
375
        return $this->smsService->sendChallenge($stepupCommand);
376
    }
377
378
    /**
379
     * @param VerifyPossessionOfPhoneCommand $command
380
     * @return OtpVerification
381
     */
382
    public function verifySmsChallenge(VerifyPossessionOfPhoneCommand $command)
383
    {
384
        return $this->smsService->verifyPossession($command);
385
    }
386
387
    public function clearSmsVerificationState()
388
    {
389
        $this->smsService->clearSmsVerificationState();
390
    }
391
392
    /**
393
     * Tests if the authenticating user has any vetted tokens for the institution he is authenticating for.
394
     *
395
     * The user needs to have a SHO and one or more vetted tokens for this method to return any institutions.
396
     *
397
     * @param string $identityNameId Used to load vetted tokens
398
     * @param string $identityInstitution Used to match against the institutions of vetted tokens
399
     * @param array $spConfiguredLoas Used for validation (are users tokens applicable for any of the configured
400
     * SP/institution configured institutions?)
401
     * @return array
402
     */
403
    private function determineInstitutionsByIdentityNameId($identityNameId, $identityInstitution, $spConfiguredLoas)
404
    {
405
        // Load the institutions based on the nameId of the authenticating user. This information is extracted from
406
        // the second factors projection in the Gateway. So the institutions are based on the vetted tokens of the user.
407
        $institutions = $this->secondFactorRepository->getAllInstitutions($identityNameId);
408
409
        // Validations are performed on the institutions
410
        if (empty($institutions) && array_key_exists($identityInstitution, $spConfiguredLoas)) {
411
            throw new LoaCannotBeGivenException(
412
                'The authenticating user cannot provide a token for the institution it is authenticating for.'
413
            );
414
        }
415
416
        if (empty($institutions)) {
417
            throw new LoaCannotBeGivenException(
418
                'The authenticating user does not have any vetted tokens.'
419
            );
420
        }
421
422
        // The user has vetted tokens and it's SHO was loaded from the assertion
423
        if (!is_null($identityInstitution) && !empty($institutions)) {
424
425
            $institutionMatches = $this->institutionMatchingHelper->findMatches(
426
                $institutions,
427
                [$identityInstitution]
428
            );
429
430
            if (empty($institutionMatches)) {
431
                throw new LoaCannotBeGivenException(
432
                    'None of the authenticating users tokens are registered at an institution the user is currently ' .
433
                    'authenticating from.'
434
                );
435
            }
436
            // Add the SHO of the authenticating user to the list of institutions based on vetted tokens. This ensures
437
            // the correct LOA can be based on the organisation of the user.
438
            $institutions[] = $identityInstitution;
439
        }
440
441
442
        return $institutions;
443
    }
444
445
    private function hasDefaultSpConfig($spConfiguredLoas)
446
    {
447
        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...
448
            return true;
449
        }
450
        return false;
451
    }
452
}
453