Completed
Push — master ( 24e2c8...aa2c21 )
by
unknown
02:53
created

StepUpAuthenticationService   D

Complexity

Total Complexity 38

Size/Duplication

Total Lines 388
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 24

Importance

Changes 2
Bugs 0 Features 0
Metric Value
wmc 38
c 2
b 0
f 0
lcom 1
cbo 24
dl 0
loc 388
rs 4.4

13 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 19 1
A determineViableSecondFactors() 0 20 2
D resolveHighestRequiredLoa() 0 96 15
A isIntrinsicLoa() 0 4 1
B verifyYubikeyOtp() 0 30 3
A getSecondFactorIdentifier() 0 7 1
A getSmsOtpRequestsRemainingCount() 0 4 1
A getSmsMaximumOtpRequestsCount() 0 4 1
A sendSmsChallenge() 0 15 1
A verifySmsChallenge() 0 4 1
A clearSmsVerificationState() 0 4 1
C determineInstitutionsByIdentityNameId() 0 41 7
A hasDefaultSpConfig() 0 7 3
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
     * @return \Doctrine\Common\Collections\Collection
123
     */
124
    public function determineViableSecondFactors(
125
        $identityNameId,
126
        Loa $requiredLoa
127
    ) {
128
129
        $candidateSecondFactors = $this->secondFactorRepository->getAllMatchingFor(
130
            $requiredLoa,
131
            $identityNameId,
132
            $this->secondFactorTypeService
133
        );
134
        $this->logger->info(
135
            sprintf('Loaded %d matching candidate second factors', count($candidateSecondFactors))
136
        );
137
138
        if ($candidateSecondFactors->isEmpty()) {
139
            $this->logger->alert('No suitable candidate second factors found, sending Loa cannot be given response');
140
        }
141
142
        return $candidateSecondFactors;
143
    }
144
145
    /**
146
     * Retrieves the required LoA for the authenticating user
147
     *
148
     * The required LoA is based on several variables. These are:
149
     *
150
     *  1. SP Requested LoA.
151
     *  2. The optional SP/institution specific LoA configuration
152
     *  3. The identity of the authenticating user (used to test if the user can provide a token for the institution
153
     *     he is authenticating for). Only used when the SP/institution specific LoA configuration is in play.
154
     *  4. The institution of the authenticating user, based on the schacHomeOrganization of the user. This is used
155
     *     to validate the registered tokens are actually vetted by the correct institution. Only used when the
156
     *     SP/institution specific LoA configuration is in play.
157
     *
158
     * These four variables determine the required LoA for the authenticating user. The possible outcomes are covered
159
     * by unit tests. These tests can be found in the Test folder of this bundle.
160
     *
161
     * @see: StepUpAuthenticationServiceTest::test_resolve_highest_required_loa_conbinations
162
     *
163
     * @param string $requestedLoa The SP requested LoA
164
     * @param $identityNameId
165
     * @param string $identityInstitution
166
     * @param ServiceProvider $serviceProvider
167
     * @return null|Loa
168
     * @SuppressWarnings(PHPMD.CyclomaticComplexity) see https://www.pivotaltracker.com/story/show/96065350
169
     * @SuppressWarnings(PHPMD.NPathComplexity)      see https://www.pivotaltracker.com/story/show/96065350
170
     */
171
    public function resolveHighestRequiredLoa(
172
        $requestedLoa,
173
        $identityNameId,
174
        $identityInstitution,
175
        ServiceProvider $serviceProvider
176
    ) {
177
        $loaCandidates = new ArrayCollection();
178
179
        if ($requestedLoa) {
180
            $loaCandidates->add($requestedLoa);
181
            $this->logger->info(sprintf('Added requested Loa "%s" as candidate', $requestedLoa));
182
        }
183
184
        // Load the SP/institution specific LoA configuration
185
        $spConfiguredLoas = $serviceProvider->get('configuredLoas');
186
187
        if (array_key_exists('__default__', $spConfiguredLoas) &&
188
            !$loaCandidates->contains($spConfiguredLoas['__default__'])
189
        ) {
190
            $loaCandidates->add($spConfiguredLoas['__default__']);
191
            $this->logger->info(sprintf('Added SP\'s default Loa "%s" as candidate', $spConfiguredLoas['__default__']));
192
        }
193
194
        if (count($spConfiguredLoas) > 1 && is_null($identityInstitution)) {
195
            throw new RuntimeException(
196
                'SP configured LOA\'s are applicable but the authenticating user has no ' .
197
                'schacHomeOrganization in the assertion.'
198
            );
199
        }
200
201
        // Load the authenticating users institutions based on its vetted tokens.
202
        $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...
203
        // But only do so if there are SP/institution specific LoA restrictions
204
        if (!$this->hasDefaultSpConfig($spConfiguredLoas)) {
205
            $institutionsBasedOnVettedTokens = $this->determineInstitutionsByIdentityNameId(
206
                $identityNameId,
207
                $identityInstitution,
208
                $spConfiguredLoas
209
            );
210
        }
211
212
        $this->logger->info(sprintf('Loaded institution(s) for "%s"', $identityNameId));
213
214
        // Match the users institutions LoA's against the SP configured institutions
215
        $matchingInstitutions = $this->institutionMatchingHelper->findMatches(
216
            array_keys($spConfiguredLoas),
217
            $institutionsBasedOnVettedTokens
218
        );
219
220
        if (count($matchingInstitutions) > 0) {
221
            $this->logger->info('Found matching SP configured LoA\'s');
222
            foreach ($matchingInstitutions as $matchingInstitution) {
223
                $loaCandidates->add($spConfiguredLoas[$matchingInstitution]);
224
                $this->logger->info(sprintf(
225
                    'Added SP\'s Loa "%s" as candidate',
226
                    $spConfiguredLoas[$matchingInstitution]
227
                ));
228
            }
229
        }
230
231
        if (!count($loaCandidates)) {
232
            throw new RuntimeException('No Loa can be found, at least one Loa (SP default) should be found');
233
        }
234
235
        $actualLoas = new ArrayCollection();
236
        foreach ($loaCandidates as $loaDefinition) {
237
            $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...
238
            if ($loa) {
239
                $actualLoas->add($loa);
240
            }
241
        }
242
243
        if (!count($actualLoas)) {
244
            $this->logger->info(sprintf(
245
                'Out of "%d" candidates, no existing Loa could be found, no authentication is possible.',
246
                count($loaCandidates)
247
            ));
248
249
            return null;
250
        }
251
252
        /** @var \Surfnet\StepupBundle\Value\Loa $highestLoa */
253
        $highestLoa = $actualLoas->first();
254
        foreach ($actualLoas as $loa) {
255
            // if the current highest Loa cannot satisfy the next Loa, that must be of a higher level...
256
            if (!$highestLoa->canSatisfyLoa($loa)) {
257
                $highestLoa = $loa;
258
            }
259
        }
260
261
        $this->logger->info(
262
            sprintf('Out of %d candidate Loa\'s, Loa "%s" is the highest', count($loaCandidates), $highestLoa)
263
        );
264
265
        return $highestLoa;
266
    }
267
268
    /**
269
     * Returns whether the given Loa identifier identifies the minimum Loa, intrinsic to being authenticated via an IdP.
270
     *
271
     * @param Loa $loa
272
     * @return bool
273
     */
274
    public function isIntrinsicLoa(Loa $loa)
275
    {
276
        return $loa->levelIsLowerOrEqualTo(Loa::LOA_1);
277
    }
278
279
    /**
280
     * @param VerifyYubikeyOtpCommand $command
281
     * @return YubikeyOtpVerificationResult
282
     */
283
    public function verifyYubikeyOtp(VerifyYubikeyOtpCommand $command)
284
    {
285
        /** @var SecondFactor $secondFactor */
286
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($command->secondFactorId);
287
288
        $requester = new Requester();
289
        $requester->identity = $secondFactor->identityId;
290
        $requester->institution = $secondFactor->institution;
291
292
        $otp = new ApiOtp();
293
        $otp->value = $command->otp;
294
295
        $result = $this->yubikeyService->verify($otp, $requester);
296
297
        if (!$result->isSuccessful()) {
298
            return new YubikeyOtpVerificationResult(YubikeyOtpVerificationResult::RESULT_OTP_VERIFICATION_FAILED, null);
299
        }
300
301
        $otp = YubikeyOtp::fromString($command->otp);
302
        $publicId = YubikeyPublicId::fromOtp($otp);
303
304
        if (!$publicId->equals(new YubikeyPublicId($secondFactor->secondFactorIdentifier))) {
305
            return new YubikeyOtpVerificationResult(
306
                YubikeyOtpVerificationResult::RESULT_PUBLIC_ID_DID_NOT_MATCH,
307
                $publicId
308
            );
309
        }
310
311
        return new YubikeyOtpVerificationResult(YubikeyOtpVerificationResult::RESULT_PUBLIC_ID_MATCHED, $publicId);
312
    }
313
314
    /**
315
     * @param string $secondFactorId
316
     * @return string
317
     */
318
    public function getSecondFactorIdentifier($secondFactorId)
319
    {
320
        /** @var SecondFactor $secondFactor */
321
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($secondFactorId);
322
323
        return $secondFactor->secondFactorIdentifier;
324
    }
325
326
    /**
327
     * @return int
328
     */
329
    public function getSmsOtpRequestsRemainingCount()
330
    {
331
        return $this->smsService->getOtpRequestsRemainingCount();
332
    }
333
334
    /**
335
     * @return int
336
     */
337
    public function getSmsMaximumOtpRequestsCount()
338
    {
339
        return $this->smsService->getMaximumOtpRequestsCount();
340
    }
341
342
    /**
343
     * @param SendSmsChallengeCommand $command
344
     * @return bool
345
     */
346
    public function sendSmsChallenge(SendSmsChallengeCommand $command)
347
    {
348
        /** @var SecondFactor $secondFactor */
349
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($command->secondFactorId);
350
351
        $phoneNumber = InternationalPhoneNumber::fromStringFormat($secondFactor->secondFactorIdentifier);
352
353
        $stepupCommand = new StepupSendSmsChallengeCommand();
354
        $stepupCommand->phoneNumber = $phoneNumber;
355
        $stepupCommand->body = $this->translator->trans('gateway.second_factor.sms.challenge_body');
356
        $stepupCommand->identity = $secondFactor->identityId;
357
        $stepupCommand->institution = $secondFactor->institution;
358
359
        return $this->smsService->sendChallenge($stepupCommand);
360
    }
361
362
    /**
363
     * @param VerifyPossessionOfPhoneCommand $command
364
     * @return OtpVerification
365
     */
366
    public function verifySmsChallenge(VerifyPossessionOfPhoneCommand $command)
367
    {
368
        return $this->smsService->verifyPossession($command);
369
    }
370
371
    public function clearSmsVerificationState()
372
    {
373
        $this->smsService->clearSmsVerificationState();
374
    }
375
376
    /**
377
     * Tests if the authenticating user has any vetted tokens for the institution he is authenticating for.
378
     *
379
     * The user needs to have a SHO and one or more vetted tokens for this method to return any institutions.
380
     *
381
     * @param string $identityNameId Used to load vetted tokens
382
     * @param string $identityInstitution Used to match against the institutions of vetted tokens
383
     * @param array $spConfiguredLoas Used for validation (are users tokens applicable for any of the configured
384
     * SP/institution configured institutions?)
385
     * @return array
386
     */
387
    private function determineInstitutionsByIdentityNameId($identityNameId, $identityInstitution, $spConfiguredLoas)
388
    {
389
        // Load the institutions based on the nameId of the authenticating user. This information is extracted from
390
        // the second factors projection in the Gateway. So the institutions are based on the vetted tokens of the user.
391
        $institutions = $this->secondFactorRepository->getAllInstitutions($identityNameId);
392
393
        // Validations are performed on the institutions
394
        if (empty($institutions) && array_key_exists($identityInstitution, $spConfiguredLoas)) {
395
            throw new LoaCannotBeGivenException(
396
                'The authenticating user cannot provide a token for the institution it is authenticating for.'
397
            );
398
        }
399
400
        if (empty($institutions)) {
401
            throw new LoaCannotBeGivenException(
402
                'The authenticating user does not have any vetted tokens.'
403
            );
404
        }
405
406
        // The user has vetted tokens and it's SHO was loaded from the assertion
407
        if (!is_null($identityInstitution) && !empty($institutions)) {
408
409
            $institutionMatches = $this->institutionMatchingHelper->findMatches(
410
                $institutions,
411
                [$identityInstitution]
412
            );
413
414
            if (empty($institutionMatches)) {
415
                throw new LoaCannotBeGivenException(
416
                    'None of the authenticating users tokens are registered at an institution the user is currently ' .
417
                    'authenticating from.'
418
                );
419
            }
420
            // Add the SHO of the authenticating user to the list of institutions based on vetted tokens. This ensures
421
            // the correct LOA can be based on the organisation of the user.
422
            $institutions[] = $identityInstitution;
423
        }
424
425
426
        return $institutions;
427
    }
428
429
    private function hasDefaultSpConfig($spConfiguredLoas)
430
    {
431
        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...
432
            return true;
433
        }
434
        return false;
435
    }
436
}
437