Passed
Pull Request — develop (#301)
by Michiel
12:29 queued 07:31
created

CookieService::getRemoteSp()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 1
dl 0
loc 7
rs 10
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
3
/**
4
 * Copyright 2022 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
 */
0 ignored issues
show
Coding Style introduced by
PHP version not specified
Loading history...
Coding Style introduced by
Missing @category tag in file comment
Loading history...
Coding Style introduced by
Missing @package tag in file comment
Loading history...
Coding Style introduced by
Missing @author tag in file comment
Loading history...
Coding Style introduced by
Missing @license tag in file comment
Loading history...
Coding Style introduced by
Missing @link tag in file comment
Loading history...
18
19
namespace Surfnet\StepupGateway\GatewayBundle\Sso2fa;
20
21
use Doctrine\Common\Collections\Collection;
22
use Exception;
23
use Psr\Log\LoggerInterface;
24
use Surfnet\StepupBundle\Service\SecondFactorTypeService;
25
use Surfnet\StepupGateway\GatewayBundle\Entity\SecondFactor;
26
use Surfnet\StepupGateway\GatewayBundle\Entity\ServiceProvider;
27
use Surfnet\StepupGateway\GatewayBundle\Exception\RuntimeException;
28
use Surfnet\StepupGateway\GatewayBundle\Saml\ResponseContext;
29
use Surfnet\StepupGateway\GatewayBundle\Service\InstitutionConfigurationService;
30
use Surfnet\StepupGateway\GatewayBundle\Service\SecondFactorService;
31
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\DateTime\ExpirationHelperInterface;
32
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\Exception\CookieNotFoundException;
33
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\Exception\DecryptionFailedException;
34
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\Exception\InvalidAuthenticationTimeException;
35
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\Http\CookieHelperInterface;
36
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\ValueObject\CookieValue;
37
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\ValueObject\CookieValueInterface;
38
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\ValueObject\NullCookieValue;
39
use Symfony\Component\HttpFoundation\Request;
40
use Symfony\Component\HttpFoundation\Response;
41
42
/**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
43
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects) | Coupling is high as we are integrating logic into the infrastructure
44
 */
0 ignored issues
show
Coding Style introduced by
Missing @category tag in class comment
Loading history...
Coding Style introduced by
Missing @package tag in class comment
Loading history...
Coding Style introduced by
Missing @author tag in class comment
Loading history...
Coding Style introduced by
Missing @license tag in class comment
Loading history...
Coding Style introduced by
Missing @link tag in class comment
Loading history...
45
class CookieService implements CookieServiceInterface
46
{
47
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
48
     * @var CookieHelperInterface
49
     */
50
    private $cookieHelper;
0 ignored issues
show
Coding Style introduced by
Private member variable "cookieHelper" must be prefixed with an underscore
Loading history...
51
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
52
     * @var InstitutionConfigurationService
53
     */
54
    private $institutionConfigurationService;
0 ignored issues
show
Coding Style introduced by
Private member variable "institutionConfigurationService" must be prefixed with an underscore
Loading history...
55
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
56
     * @var LoggerInterface
57
     */
58
    private $logger;
0 ignored issues
show
Coding Style introduced by
Private member variable "logger" must be prefixed with an underscore
Loading history...
59
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
60
     * @var SecondFactorService
61
     */
62
    private $secondFactorService;
0 ignored issues
show
Coding Style introduced by
Private member variable "secondFactorService" must be prefixed with an underscore
Loading history...
63
64
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
65
     * @var SecondFactorTypeService
66
     */
67
    private $secondFactorTypeService;
0 ignored issues
show
Coding Style introduced by
Private member variable "secondFactorTypeService" must be prefixed with an underscore
Loading history...
68
    /**
0 ignored issues
show
Coding Style introduced by
Missing short description in doc comment
Loading history...
69
     * @var ExpirationHelperInterface
70
     */
71
    private $expirationHelper;
0 ignored issues
show
Coding Style introduced by
Private member variable "expirationHelper" must be prefixed with an underscore
Loading history...
72
73
    public function __construct(
0 ignored issues
show
Coding Style introduced by
Missing doc comment for function __construct()
Loading history...
74
        CookieHelperInterface $cookieHelper,
75
        InstitutionConfigurationService $institutionConfigurationService,
76
        SecondFactorService $secondFactorService,
77
        SecondFactorTypeService $secondFactorTypeService,
78
        ExpirationHelperInterface $expirationHelper,
79
        LoggerInterface $logger
80
    ) {
81
        $this->cookieHelper = $cookieHelper;
82
        $this->institutionConfigurationService = $institutionConfigurationService;
83
        $this->secondFactorService = $secondFactorService;
84
        $this->secondFactorTypeService = $secondFactorTypeService;
85
        $this->expirationHelper = $expirationHelper;
86
        $this->logger = $logger;
87
    }
88
89
    public function handleSsoOn2faCookieStorage(
0 ignored issues
show
Coding Style introduced by
Missing doc comment for function handleSsoOn2faCookieStorage()
Loading history...
90
        ResponseContext $responseContext,
91
        Request $request,
92
        Response $httpResponse,
93
        string $authenticationMode = 'sso'
94
    ): Response {
95
        // Check if this specific SP is configured to allow setting of an SSO on 2FA cookie (configured in MW config)
96
        $remoteSp = $this->getRemoteSp($responseContext);
97
        if (!$remoteSp->allowedToSetSsoCookieOn2fa()) {
98
            $this->logger->notice(
99
                sprintf(
100
                    'Ignoring SSO on 2FA for SP: %s',
101
                    $remoteSp->getEntityId()
102
                )
103
            );
104
            return $httpResponse;
105
        }
106
        $secondFactorId = $responseContext->getSelectedSecondFactor();
107
108
        // We can only set an SSO on 2FA cookie if a second factor authentication is being handled.
109
        if ($secondFactorId) {
110
            $secondFactor = $this->secondFactorService->findByUuid($secondFactorId);
111
            if (!$secondFactor) {
112
                throw new RuntimeException(sprintf('Second Factor token not found with ID: %s', $secondFactorId));
113
            }
114
            // Test if the institution of the Identity this SF belongs to has SSO on 2FA enabled
115
            $isEnabled = $this->institutionConfigurationService->ssoOn2faEnabled($secondFactor->institution);
116
            $this->logger->notice(
117
                sprintf(
118
                    'SSO on 2FA is %senabled for %s',
119
                    $isEnabled ? '' : 'not ',
120
                    $secondFactor->institution
121
                )
122
            );
123
            if ($isEnabled) {
124
                // The cookie reader can return a NullCookie if the cookie is not present, was expired or was otherwise
125
                // deemed invalid. See the CookieHelper::read implementation for details.
126
                $ssoCookie = $this->read($request);
127
                $identityId = $responseContext->getIdentityNameId();
128
                $loa = $secondFactor->getLoaLevel($this->secondFactorTypeService);
129
                $isValid = $this->isCookieValid($ssoCookie, $loa, $identityId);
130
                $isVerifiedBySsoOn2faCookie = $responseContext->isVerifiedBySsoOn2faCookie();
131
                // Did the LoA requirement change? If a higher LoA was requested, or did a new token authentication
132
                // take place? In that case create a new SSO on 2FA cookie
133
                if ($this->shouldAddCookie($ssoCookie, $isValid, $loa, $isVerifiedBySsoOn2faCookie)) {
134
                    $cookie = CookieValue::from($identityId, $secondFactor->secondFactorId, $loa);
135
                    $this->store($httpResponse, $cookie);
136
                }
137
            }
138
        }
139
        $responseContext->finalizeAuthentication();
140
        return $httpResponse;
141
    }
142
143
    /**
0 ignored issues
show
Coding Style introduced by
Parameter $responseContext should have a doc-comment as per coding-style.
Loading history...
Coding Style introduced by
Parameter $requiredLoa should have a doc-comment as per coding-style.
Loading history...
Coding Style introduced by
Parameter $identityNameId should have a doc-comment as per coding-style.
Loading history...
Coding Style introduced by
Parameter $secondFactorCollection should have a doc-comment as per coding-style.
Loading history...
Coding Style introduced by
Parameter $request should have a doc-comment as per coding-style.
Loading history...
144
     * Allow high cyclomatic complexity in favour of keeping this method readable
145
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
0 ignored issues
show
Coding Style introduced by
There must be exactly one blank line before the tags in a doc comment
Loading history...
146
     * @SuppressWarnings(PHPMD.NPathComplexity)
147
     */
0 ignored issues
show
Coding Style introduced by
Missing @return tag in function comment
Loading history...
148
    public function shouldSkip2faAuthentication(
149
        ResponseContext $responseContext,
150
        float $requiredLoa,
151
        string $identityNameId,
152
        Collection $secondFactorCollection,
153
        Request $request
154
    ): bool {
155
        if ($responseContext->isForceAuthn()) {
156
            $this->logger->notice('Ignoring SSO on 2FA cookie when ForceAuthN is specified.');
157
            return false;
158
        }
159
        $remoteSp = $this->getRemoteSp($responseContext);
160
        // Test if the SP allows SSO on 2FA to take place (configured in MW config)
161
        if (!$remoteSp->allowSsoOn2fa()) {
162
            $this->logger->notice(
163
                sprintf(
164
                    'Ignoring SSO on 2FA for SP: %s',
165
                    $remoteSp->getEntityId()
166
                )
167
            );
168
            return false;
169
        }
170
        $ssoCookie = $this->read($request);
171
        // Perform validation on the cookie and its contents
172
        if (!$this->isCookieValid($ssoCookie, $requiredLoa, $identityNameId)) {
173
            return false;
174
        }
175
        if (!$this->secondFactorService->findByUuid($ssoCookie->secondFactorId())) {
176
            $this->logger->notice(
177
                'The second factor stored in the SSO cookie was revoked or has otherwise became unknown to Gateway',
178
                [
179
                    'secondFactorIdFromCookie' => $ssoCookie->secondFactorId()
180
                ]
181
            );
182
            return false;
183
        }
184
185
        /** @var SecondFactor $secondFactor */
0 ignored issues
show
Coding Style introduced by
The open comment tag must be the only content on the line
Loading history...
Coding Style introduced by
Missing short description in doc comment
Loading history...
Coding Style introduced by
The close comment tag must be the only content on the line
Loading history...
186
        foreach ($secondFactorCollection as $secondFactor) {
187
            $loa = $secondFactor->getLoaLevel($this->secondFactorTypeService);
188
            if ($loa >= $requiredLoa) {
189
                $this->logger->notice('Verified the current 2FA authentication can be given with the SSO on 2FA cookie');
190
                $responseContext->saveSelectedSecondFactor($secondFactor);
191
                return true;
192
            }
193
        }
194
        return false;
195
    }
196
197
    public function getCookieFingerprint(Request $request): string
0 ignored issues
show
Coding Style introduced by
Missing doc comment for function getCookieFingerprint()
Loading history...
198
    {
199
        return $this->cookieHelper->fingerprint($request);
200
    }
201
202
    /**
0 ignored issues
show
Coding Style introduced by
Parameter $isCookieValid should have a doc-comment as per coding-style.
Loading history...
203
     * This method determines if an SSO on 2FA cookie should be created.
204
     *
205
     * The comments in the code block should give a good feel for what business rules
206
     * are applied in this method.
207
     *
208
     * @param CookieValueInterface $ssoCookie           The SSO on 2FA cookie as read from the HTTP response
0 ignored issues
show
Coding Style introduced by
Expected 26 spaces after parameter name; 11 found
Loading history...
209
     * @param float $loa                                The LoA that was requested for this authentication, used to
0 ignored issues
show
Coding Style introduced by
Expected 16 spaces after parameter type; 1 found
Loading history...
Coding Style introduced by
Doc comment for parameter $loa does not match actual variable name $isCookieValid
Loading history...
210
     *                                                  compare to the LoA stored in the SSO cookie
211
     * @param bool $wasAuthenticatedWithSsoOn2faCookie  Indicator if the currently running authentication was performed
0 ignored issues
show
Coding Style introduced by
Expected 17 spaces after parameter type; 1 found
Loading history...
Coding Style introduced by
Doc comment for parameter $wasAuthenticatedWithSsoOn2faCookie does not match actual variable name $loa
Loading history...
Coding Style introduced by
Expected 1 spaces after parameter name; 2 found
Loading history...
212
     *                                                  with the SSO on 2FA cookie
213
     */
0 ignored issues
show
Coding Style introduced by
Missing @return tag in function comment
Loading history...
214
    private function shouldAddCookie(
0 ignored issues
show
Coding Style introduced by
Private method name "CookieService::shouldAddCookie" must be prefixed with an underscore
Loading history...
215
        CookieValueInterface $ssoCookie,
216
        bool $isCookieValid,
217
        float $loa,
218
        bool $wasAuthenticatedWithSsoOn2faCookie
219
    ): bool {
220
        // When the cookie is not yet set, was expired or was otherwise deemed invalid, we get a NullCookieValue
221
        // back from the reader. Indicating there is no valid cookie present.
222
        $cookieNotSet = $ssoCookie instanceof NullCookieValue;
223
        // OR the existing cookie does exist, but the LoA stored in that cookie does not match the required LoA
224
        $cookieDoesNotMeetLoaRequirement = ($ssoCookie instanceof CookieValue && !$ssoCookie->meetsRequiredLoa($loa));
225
        if (!$ssoCookie instanceof NullCookieValue && $cookieDoesNotMeetLoaRequirement) {
226
            $this->logger->notice(
227
                sprintf(
228
                    'Storing new SSO on 2FA cookie as LoA requirement (%d changed to %d) changed',
229
                    $ssoCookie->getLoa(),
230
                    $loa
231
                )
232
            );
233
        }
234
        // OR when a new authentication took place, we replace the existing cookie with a new one
235
        if (!$wasAuthenticatedWithSsoOn2faCookie) {
236
            $this->logger->notice('Storing new SSO on 2FA cookie as a new authentication took place');
237
        }
238
239
        // Or when the cookie is not valid for some reason (see logs for the specific error)
240
        if (!$isCookieValid) {
241
            $this->logger->notice('Storing new SSO on 2FA cookie, the current cookie is invalid');
242
        }
243
244
        return $cookieNotSet ||
245
            !$isCookieValid ||
246
            $cookieDoesNotMeetLoaRequirement ||
247
            !$wasAuthenticatedWithSsoOn2faCookie;
248
    }
249
250
    private function store(Response $response, CookieValueInterface $cookieValue)
0 ignored issues
show
Coding Style introduced by
Missing doc comment for function store()
Loading history...
Coding Style introduced by
Private method name "CookieService::store" must be prefixed with an underscore
Loading history...
251
    {
252
        $this->cookieHelper->write($response, $cookieValue);
253
    }
254
255
    private function read(Request $request): CookieValueInterface
0 ignored issues
show
Coding Style introduced by
Missing doc comment for function read()
Loading history...
Coding Style introduced by
Private method name "CookieService::read" must be prefixed with an underscore
Loading history...
256
    {
257
        try {
258
            return $this->cookieHelper->read($request);
259
        } catch (CookieNotFoundException $e) {
260
            $this->logger->notice('Attempt to decrypt the cookie failed, the cookie could not be found');
261
            return new NullCookieValue();
262
        } catch (DecryptionFailedException $e) {
263
            $this->logger->notice('Decryption of the SSO on 2FA cookie failed');
264
            return new NullCookieValue();
265
        } catch (Exception $e) {
266
            $this->logger->notice(
267
                'Decryption failed, see original message in context',
268
                ['original-exception-message' => $e->getMessage()]
269
            );
270
            return new NullCookieValue();
271
        }
272
    }
273
274
    private function getRemoteSp(ResponseContext $responseContext): ServiceProvider
0 ignored issues
show
Coding Style introduced by
Missing doc comment for function getRemoteSp()
Loading history...
Coding Style introduced by
Private method name "CookieService::getRemoteSp" must be prefixed with an underscore
Loading history...
275
    {
276
        $remoteSp = $responseContext->getServiceProvider();
277
        if (!$remoteSp) {
0 ignored issues
show
introduced by
$remoteSp is of type Surfnet\StepupGateway\Ga...\Entity\ServiceProvider, thus it always evaluated to true.
Loading history...
278
            throw new RuntimeException('SP not found in the response context, unable to continue with SSO on 2FA');
279
        }
280
        return $remoteSp;
281
    }
282
283
    private function isCookieValid(CookieValueInterface $ssoCookie, float $requiredLoa, string $identityNameId): bool
0 ignored issues
show
Coding Style introduced by
Missing doc comment for function isCookieValid()
Loading history...
Coding Style introduced by
Private method name "CookieService::isCookieValid" must be prefixed with an underscore
Loading history...
284
    {
285
        if ($ssoCookie instanceof NullCookieValue) {
286
            return false;
287
        }
288
        if ($ssoCookie instanceof CookieValue && !$ssoCookie->meetsRequiredLoa($requiredLoa)) {
289
            $this->logger->notice(
290
                sprintf(
291
                    'The required LoA %d did not match the LoA of the SSO cookie %d',
292
                    $requiredLoa,
293
                    $ssoCookie->getLoa()
294
                )
295
            );
296
            return false;
297
        }
298
        if ($ssoCookie instanceof CookieValue && !$ssoCookie->issuedTo($identityNameId)) {
299
            $this->logger->notice(
300
                sprintf(
301
                    'The SSO on 2FA cookie was not issued to %s, but to %s',
302
                    $identityNameId,
303
                    $ssoCookie->getIdentityId()
304
                )
305
            );
306
            return false;
307
        }
308
        try {
309
            $isExpired = $this->expirationHelper->isExpired($ssoCookie);
310
            if ($isExpired) {
311
                $this->logger->notice(
312
                    'The SSO on 2FA cookie has expired. Meaning [authentication time] + [cookie lifetime] is in the past'
313
                );
314
                return false;
315
            }
316
        } catch (InvalidAuthenticationTimeException $e) {
317
            $this->logger->notice('The SSO on 2FA cookie contained an invalid authentication time', [$e->getMessage()]);
318
            return false;
319
        }
320
        return true;
321
    }
322
}
323