Completed
Push — bugfix/institution-based-on-sh... ( 4f0ba6...9903bb )
by
unknown
02:09
created

StepUpAuthenticationService   D

Complexity

Total Complexity 33

Size/Duplication

Total Lines 353
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 24

Importance

Changes 2
Bugs 0 Features 0
Metric Value
wmc 33
c 2
b 0
f 0
lcom 1
cbo 24
dl 0
loc 353
rs 4.9238

12 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 19 1
B determineViableSecondFactors() 0 35 4
D resolveHighestRequiredLoa() 0 90 14
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
B determineInstitutionsByIdentityNameId() 0 26 4
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\RuntimeException;
42
use Surfnet\StepupGateway\GatewayBundle\Service\StepUp\YubikeyOtpVerificationResult;
43
use Symfony\Component\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
    /** @var InstitutionMatchingHelper */
71
    private $institutionMatchingHelper;
72
73
    /**
74
     * @var \Symfony\Component\Translation\TranslatorInterface
75
     */
76
    private $translator;
77
78
    /**
79
     * @var \Psr\Log\LoggerInterface
80
     */
81
    private $logger;
82
83
    /**
84
     * @var SecondFactorTypeService
85
     */
86
    private $secondFactorTypeService;
87
88
    /**
89
     * @param LoaResolutionService   $loaResolutionService
90
     * @param SecondFactorRepository $secondFactorRepository
91
     * @param YubikeyService         $yubikeyService
92
     * @param SmsSecondFactorService $smsService
93
     * @param InstitutionMatchingHelper $institutionMatchingHelper
94
     * @param TranslatorInterface    $translator
95
     * @param LoggerInterface        $logger
96
     * @param SecondFactorTypeService $secondFactorTypeService
97
     */
98
    public function __construct(
99
        LoaResolutionService $loaResolutionService,
100
        SecondFactorRepository $secondFactorRepository,
101
        YubikeyService $yubikeyService,
102
        SmsSecondFactorService $smsService,
103
        InstitutionMatchingHelper $institutionMatchingHelper,
104
        TranslatorInterface $translator,
105
        LoggerInterface $logger,
106
        SecondFactorTypeService $secondFactorTypeService
107
    ) {
108
        $this->loaResolutionService = $loaResolutionService;
109
        $this->secondFactorRepository = $secondFactorRepository;
110
        $this->yubikeyService = $yubikeyService;
111
        $this->smsService = $smsService;
112
        $this->institutionMatchingHelper = $institutionMatchingHelper;
113
        $this->translator = $translator;
114
        $this->logger = $logger;
115
        $this->secondFactorTypeService = $secondFactorTypeService;
116
    }
117
118
    /**
119
     * @param string $identityNameId
120
     * @param Loa $requiredLoa
121
     * @param WhitelistService $whitelistService
122
     * @return \Doctrine\Common\Collections\Collection
123
     */
124
    public function determineViableSecondFactors(
125
        $identityNameId,
126
        Loa $requiredLoa,
127
        WhitelistService $whitelistService
128
    ) {
129
130
        $candidateSecondFactors = $this->secondFactorRepository->getAllMatchingFor(
131
            $requiredLoa,
132
            $identityNameId,
133
            $this->secondFactorTypeService
134
        );
135
        $this->logger->info(
136
            sprintf('Loaded %d matching candidate second factors', count($candidateSecondFactors))
137
        );
138
139
        foreach ($candidateSecondFactors as $key => $secondFactor) {
140
            if (!$whitelistService->contains($secondFactor->institution)) {
141
                $this->logger->notice(
142
                    sprintf(
143
                        'Second factor "%s" is listed for institution "%s" which is not on the whitelist',
144
                        $secondFactor->secondFactorId,
145
                        $secondFactor->institution
146
                    )
147
                );
148
149
                $candidateSecondFactors->remove($key);
150
            }
151
        }
152
153
        if ($candidateSecondFactors->isEmpty()) {
154
            $this->logger->alert('No suitable candidate second factors found, sending Loa cannot be given response');
155
        }
156
157
        return $candidateSecondFactors;
158
    }
159
160
    /**
161
     * @param string $requestedLoa
162
     * @param $identityNameId
163
     * @param string $identityInstitution
164
     * @param ServiceProvider $serviceProvider
165
     * @return null|Loa
166
     * @SuppressWarnings(PHPMD.CyclomaticComplexity) see https://www.pivotaltracker.com/story/show/96065350
167
     * @SuppressWarnings(PHPMD.NPathComplexity)      see https://www.pivotaltracker.com/story/show/96065350
168
     */
169
    public function resolveHighestRequiredLoa(
170
        $requestedLoa,
171
        $identityNameId,
172
        $identityInstitution,
173
        ServiceProvider $serviceProvider
174
    ) {
175
        $loaCandidates = new ArrayCollection();
176
177
        if ($requestedLoa) {
178
            $loaCandidates->add($requestedLoa);
179
            $this->logger->info(sprintf('Added requested Loa "%s" as candidate', $requestedLoa));
180
        }
181
182
        $spConfiguredLoas = $serviceProvider->get('configuredLoas');
183
184
        if (array_key_exists('__default__', $spConfiguredLoas) &&
185
            !$loaCandidates->contains($spConfiguredLoas['__default__'])
186
        ) {
187
            $loaCandidates->add($spConfiguredLoas['__default__']);
188
            $this->logger->info(sprintf('Added SP\'s default Loa "%s" as candidate', $spConfiguredLoas['__default__']));
189
        }
190
191
        if (count($spConfiguredLoas) > 1 && is_null($identityInstitution)) {
192
            throw new RuntimeException(
193
                'SP configured LOA\'s are applicable but the authenticating user has no ' .
194
                'schacHomeOrganization in the assertion.'
195
            );
196
        }
197
198
        // Load the institutions the user has vetted a second factor
199
        $institutionsBasedOnVettedTokens = $this->determineInstitutionsByIdentityNameId(
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...
200
            $identityNameId,
201
            $identityInstitution
202
        );
203
204
        $this->logger->info(sprintf('Loaded institution(s) for "%s"', $identityNameId));
205
206
        // Match the users institutions loas against the SP configured institutions
207
        $matchingInstitutions = $this->institutionMatchingHelper->findMatches(
208
            array_keys($spConfiguredLoas),
209
            $institutionsBasedOnVettedTokens
210
        );
211
212
        if (count($matchingInstitutions) > 0) {
213
            $this->logger->info('Found matching SP configured LoA\'s');
214
            foreach ($matchingInstitutions as $matchingInstitution) {
215
                $loaCandidates->add($spConfiguredLoas[$matchingInstitution]);
216
                $this->logger->info(sprintf(
217
                    'Added SP\'s Loa "%s" as candidate',
218
                    $spConfiguredLoas[$matchingInstitution]
219
                ));
220
            }
221
        }
222
223
        if (!count($loaCandidates)) {
224
            throw new RuntimeException('No Loa can be found, at least one Loa (SP default) should be found');
225
        }
226
227
        $actualLoas = new ArrayCollection();
228
        foreach ($loaCandidates as $loaDefinition) {
229
            $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...
230
            if ($loa) {
231
                $actualLoas->add($loa);
232
            }
233
        }
234
235
        if (!count($actualLoas)) {
236
            $this->logger->info(sprintf(
237
                'Out of "%d" candidates, no existing Loa could be found, no authentication is possible.',
238
                count($loaCandidates)
239
            ));
240
241
            return null;
242
        }
243
244
        /** @var \Surfnet\StepupBundle\Value\Loa $highestLoa */
245
        $highestLoa = $actualLoas->first();
246
        foreach ($actualLoas as $loa) {
247
            // if the current highest Loa cannot satisfy the next Loa, that must be of a higher level...
248
            if (!$highestLoa->canSatisfyLoa($loa)) {
249
                $highestLoa = $loa;
250
            }
251
        }
252
253
        $this->logger->info(
254
            sprintf('Out of %d candidate Loa\'s, Loa "%s" is the highest', count($loaCandidates), $highestLoa)
255
        );
256
257
        return $highestLoa;
258
    }
259
260
    /**
261
     * Returns whether the given Loa identifier identifies the minimum Loa, intrinsic to being authenticated via an IdP.
262
     *
263
     * @param Loa $loa
264
     * @return bool
265
     */
266
    public function isIntrinsicLoa(Loa $loa)
267
    {
268
        return $loa->levelIsLowerOrEqualTo(Loa::LOA_1);
269
    }
270
271
    /**
272
     * @param VerifyYubikeyOtpCommand $command
273
     * @return YubikeyOtpVerificationResult
274
     */
275
    public function verifyYubikeyOtp(VerifyYubikeyOtpCommand $command)
276
    {
277
        /** @var SecondFactor $secondFactor */
278
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($command->secondFactorId);
279
280
        $requester = new Requester();
281
        $requester->identity = $secondFactor->identityId;
282
        $requester->institution = $secondFactor->institution;
283
284
        $otp = new ApiOtp();
285
        $otp->value = $command->otp;
286
287
        $result = $this->yubikeyService->verify($otp, $requester);
288
289
        if (!$result->isSuccessful()) {
290
            return new YubikeyOtpVerificationResult(YubikeyOtpVerificationResult::RESULT_OTP_VERIFICATION_FAILED, null);
291
        }
292
293
        $otp = YubikeyOtp::fromString($command->otp);
294
        $publicId = YubikeyPublicId::fromOtp($otp);
295
296
        if (!$publicId->equals(new YubikeyPublicId($secondFactor->secondFactorIdentifier))) {
297
            return new YubikeyOtpVerificationResult(
298
                YubikeyOtpVerificationResult::RESULT_PUBLIC_ID_DID_NOT_MATCH,
299
                $publicId
300
            );
301
        }
302
303
        return new YubikeyOtpVerificationResult(YubikeyOtpVerificationResult::RESULT_PUBLIC_ID_MATCHED, $publicId);
304
    }
305
306
    /**
307
     * @param string $secondFactorId
308
     * @return string
309
     */
310
    public function getSecondFactorIdentifier($secondFactorId)
311
    {
312
        /** @var SecondFactor $secondFactor */
313
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($secondFactorId);
314
315
        return $secondFactor->secondFactorIdentifier;
316
    }
317
318
    /**
319
     * @return int
320
     */
321
    public function getSmsOtpRequestsRemainingCount()
322
    {
323
        return $this->smsService->getOtpRequestsRemainingCount();
324
    }
325
326
    /**
327
     * @return int
328
     */
329
    public function getSmsMaximumOtpRequestsCount()
330
    {
331
        return $this->smsService->getMaximumOtpRequestsCount();
332
    }
333
334
    /**
335
     * @param SendSmsChallengeCommand $command
336
     * @return bool
337
     */
338
    public function sendSmsChallenge(SendSmsChallengeCommand $command)
339
    {
340
        /** @var SecondFactor $secondFactor */
341
        $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($command->secondFactorId);
342
343
        $phoneNumber = InternationalPhoneNumber::fromStringFormat($secondFactor->secondFactorIdentifier);
344
345
        $stepupCommand = new StepupSendSmsChallengeCommand();
346
        $stepupCommand->phoneNumber = $phoneNumber;
347
        $stepupCommand->body = $this->translator->trans('gateway.second_factor.sms.challenge_body');
348
        $stepupCommand->identity = $secondFactor->identityId;
349
        $stepupCommand->institution = $secondFactor->institution;
350
351
        return $this->smsService->sendChallenge($stepupCommand);
352
    }
353
354
    /**
355
     * @param VerifyPossessionOfPhoneCommand $command
356
     * @return OtpVerification
357
     */
358
    public function verifySmsChallenge(VerifyPossessionOfPhoneCommand $command)
359
    {
360
        return $this->smsService->verifyPossession($command);
361
    }
362
363
    public function clearSmsVerificationState()
364
    {
365
        $this->smsService->clearSmsVerificationState();
366
    }
367
368
    /**
369
     * @param $identityNameId
370
     * @param $identityInstitution
371
     * @return array
372
     * @throws RuntimeException
373
     */
374
    private function determineInstitutionsByIdentityNameId($identityNameId, $identityInstitution)
375
    {
376
        $institutionsBasedOnVettedTokens = $this->secondFactorRepository->getAllInstitutions($identityNameId);
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...
377
378
        if (!is_null($identityInstitution) && !empty($institutionsBasedOnVettedTokens)) {
379
380
            $institutionMatches = $this->institutionMatchingHelper->findMatches(
381
                $institutionsBasedOnVettedTokens,
382
                [$identityInstitution]
383
            );
384
385
            if (empty($institutionMatches)) {
386
                throw new RuntimeException(
387
                    'None of the authenticating users tokens are registered at an institution the user is currently ' .
388
                    'authenticating from.'
389
                );
390
            }
391
        }
392
        // Add the SHO of the authenticating user to the list of institutions based on vetted tokens. This ensures the
393
        // correct LOA can be based on the organisation of the user.
394
        // There is a chance the user has no vetted tokens. In that case the user will not be granted access to the SP
395
        // if the LOA is higher than LOA1 @see https://www.pivotaltracker.com/story/show/153594101
396
        $institutionsBasedOnVettedTokens[] = $identityInstitution;
397
398
        return $institutionsBasedOnVettedTokens;
399
    }
400
}
401